Compare commits

...

10 Commits

115 changed files with 13393 additions and 663 deletions

View File

@@ -1,8 +1,12 @@
# App / Coolify
APP_ENV=development
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=https://audit.matthias-meister-webdesign.de
# TASK-8 Playwright
# Personal deployment scope
# This repo currently targets audit.matthias-meister-webdesign.de with managed
# server-side provider keys. SaaS BYO keys, billing, and team roles come later.
# Legacy TASK-8 Playwright enrichment (not required for the new external pipeline)
TASK8_CRAWL_TIMEOUT_MS=60000
TASK8_CRAWL_MAX_PAGES=20
TASK8_BROWSER_ASSET_URL=
@@ -31,6 +35,12 @@ OPENROUTER_MODEL_QUALITY_REVIEW=
OPENROUTER_APP_NAME=
OPENROUTER_APP_URL=
# ScreenshotOne
SCREENSHOTONE_API_KEY=
# Jina (optional fallback; no key required for current readiness)
JINA_API_KEY=
# SMTP / Stalwart
SMTP_HOST=
SMTP_PORT=465

View File

@@ -1,6 +1,8 @@
# WebDev Pipeline
Interner Akquise-Agent fuer lokale Webdesign-Leads. Das MVP startet mit Next.js App Router, TypeScript, Tailwind CSS, shadcn/ui und Platzhalter-Routen fuer Dashboard, Login und oeffentliche Audit-Seiten.
Persoenlicher Akquise-Agent fuer lokale Webdesign-Leads auf `audit.matthias-meister-webdesign.de`. Das MVP startet mit Next.js App Router, TypeScript, Tailwind CSS, shadcn/ui und Platzhalter-Routen fuer Dashboard, Login und oeffentliche Audit-Seiten.
Der aktuelle Scope ist bewusst persoenlich: Google, PageSpeed, OpenRouter, ScreenshotOne und optional Jina laufen ueber serverseitig verwaltete Keys. BYO-Keys, Billing und Teamrollen gehoeren zur spaeteren SaaS-Readiness, aber nicht zu dieser Welle.
## Getting Started
@@ -23,12 +25,13 @@ Copy `.env.example` to `.env.local` for local development. Keep real secrets out
- **App / Coolify:** `APP_ENV`, `NEXT_PUBLIC_APP_URL`
- **Convex:** `NEXT_PUBLIC_CONVEX_URL`, `NEXT_PUBLIC_CONVEX_SITE_URL`, `CONVEX_DEPLOYMENT`
- **Google / Task-9 PageSpeed:** `GOOGLE_GEOCODING_API_KEY`, `GOOGLE_PLACES_API_KEY`, `PAGESPEED_API_KEY`, `PAGESPEED_TIMEOUT_MS`
- **Google / PageSpeed:** `GOOGLE_GEOCODING_API_KEY`, `GOOGLE_PLACES_API_KEY`, `PAGESPEED_API_KEY`, `PAGESPEED_TIMEOUT_MS`
- **OpenRouter:** `OPENROUTER_API_KEY`, `OPENROUTER_MODEL_CLASSIFICATION`, `OPENROUTER_MODEL_MULTIMODAL_AUDIT`, `OPENROUTER_MODEL_GERMAN_COPY`, `OPENROUTER_MODEL_QUALITY_REVIEW`, optional: `OPENROUTER_APP_NAME`, `OPENROUTER_APP_URL`
- **ScreenshotOne:** `SCREENSHOTONE_API_KEY`
- **Jina:** optional `JINA_API_KEY` for future authenticated fallback usage; not required for current readiness.
- **SMTP / Stalwart:** `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`
- **Rybbit:** `RYBBIT_API_URL`, `RYBBIT_API_KEY`, `NEXT_PUBLIC_RYBBIT_SITE_ID`
- **Auth:** `BETTER_AUTH_SECRET`
- **TASK-8 enrichment:** `TASK8_BROWSER_ASSET_URL`
Only variables prefixed with `NEXT_PUBLIC_` are intended for browser exposure. All API keys, SMTP credentials, and server-only URLs must stay server-side.
@@ -50,24 +53,11 @@ Only variables prefixed with `NEXT_PUBLIC_` are intended for browser exposure. A
Coolify should run `pnpm install`, `pnpm build`, and `pnpm start`. The current font setup uses `next/font/google`, so production builds need outbound access to Google Fonts unless fonts are later self-hosted.
TASK-8 enrichment uses `playwright-core` with `@sparticuz/chromium-min` in Convex. Local `npx playwright install` is a browser-testing helper only and does not affect the Convex runtime bundle.
The new audit pipeline expects managed server-side provider configuration for Google, PageSpeed, OpenRouter, ScreenshotOne, and optional Jina. Do not expose provider secrets in browser-prefixed variables.
TASK-8 requires a browser binary source URL configured on Convex. The preferred
variable is:
Playwright/TASK-8 is legacy enrichment context, not a required integration for the new external audit pipeline. Local `npx playwright install` remains a browser-testing helper only and does not affect the managed external-service readiness check.
- `TASK8_BROWSER_ASSET_URL` (for example your self-hosted or CDN Chromium bundle URL if you do not rely on package defaults).
For backward compatibility, the action also supports:
- `TASK8_CHROMIUM_EXECUTABLE_URL`
- `TASK8_CHROMIUM_EXECUTABLE`
If none are set, enrichment deployment/startup will fail with a clear configuration
error so no silent fallback is used.
If the URL is missing and no default is available in your environment, the enqueue action will throw a clear deploy/configuration error so enrichment does not silently fall back to a missing binary.
For TASK-8 deployment updates, run Convex restart/deploy after code changes:
For Convex deployment updates, run restart/deploy after code changes:
- Local: `pnpm exec convex dev`
- Remote: `pnpm exec convex deploy`

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ title: Add follow-up and manual sales status tracking
status: In Progress
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:30'
updated_date: '2026-06-05 19:49'
labels:
- mvp
- sales
@@ -25,11 +25,11 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 After an initial send, a single follow-up draft and suggested due date are created
- [ ] #2 Follow-up sending requires manual review and approval, just like the first email
- [ ] #3 Manual statuses exist for Antwort erhalten, Kein Interesse, Später wieder melden, Gespräch vereinbart, Angebot angefragt, Angebot gesendet, Auftrag gewonnen, Auftrag verloren, Nicht weiter verfolgen, Follow-up geplant, and Follow-up gesendet
- [ ] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts
- [ ] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen
- [x] #1 After an initial send, a single follow-up draft and suggested due date are created
- [x] #2 Follow-up sending requires manual review and approval, just like the first email
- [x] #3 Manual statuses exist for Antwort erhalten, Kein Interesse, Später wieder melden, Gespräch vereinbart, Angebot angefragt, Angebot gesendet, Auftrag gewonnen, Auftrag verloren, Nicht weiter verfolgen, Follow-up geplant, and Follow-up gesendet
- [x] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts
- [x] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen
<!-- AC:END -->
## Implementation Plan
@@ -46,4 +46,6 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for tasks 15-19 and 27. TASK-27 note says it is superseded by TASK-13, so this pass will verify the existing PageSpeed-to-audit-generation handoff rather than implement it separately.
Implemented and verified follow-up draft creation after send, manual approval boundaries for follow-up records, manual sales status labels/mutation, reply/no-interest suppression, and 12-month do-not-contact recheck visibility. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Orchestrate recurring Convex agent jobs and audit lifecycle
status: In Progress
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:30'
updated_date: '2026-06-05 19:49'
labels:
- mvp
- convex
@@ -27,11 +27,11 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Convex cron or scheduled functions trigger active campaigns according to cadence
- [ ] #2 Jetzt ausführen starts a campaign run immediately only when no other agent run is active
- [ ] #3 Cron skips or queues safely when an agent run is already active, with visible run logs
- [ ] #4 Published audits older than 30 days create dashboard notifications asking whether to keep active
- [ ] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated
- [x] #1 Convex cron or scheduled functions trigger active campaigns according to cadence
- [x] #2 Jetzt ausführen starts a campaign run immediately only when no other agent run is active
- [x] #3 Cron skips or queues safely when an agent run is already active, with visible run logs
- [x] #4 Published audits older than 30 days create dashboard notifications asking whether to keep active
- [x] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated
<!-- AC:END -->
## Implementation Plan
@@ -48,4 +48,6 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for recurring Convex agent jobs, run locking, logs, and audit lifecycle.
Implemented and verified Convex crons, due-campaign runner, single-active-run guard, visible campaign run logs, and audit lifecycle notification/deactivation controls. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Add Rybbit audit analytics dashboard
status: In Progress
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:30'
updated_date: '2026-06-05 19:50'
labels:
- mvp
- analytics
@@ -25,11 +25,11 @@ Display anonymous analytics for generated public audit pages inside the internal
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes
- [ ] #2 Dashboard can fetch Rybbit API data for pageviews, custom events, and outbound link clicks for audit pages
- [ ] #3 Per-audit analytics show opened yes/no, view count, last view, CTA clicks, website-link clicks, and device type where available
- [ ] #4 Campaign analytics aggregate audit opens and CTA activity by campaign, niche, region, and timeframe
- [ ] #5 Rybbit API failures are shown gracefully and do not break the rest of the dashboard
- [x] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes
- [x] #2 Dashboard can fetch Rybbit API data for pageviews, custom events, and outbound link clicks for audit pages
- [x] #3 Per-audit analytics show opened yes/no, view count, last view, CTA clicks, website-link clicks, and device type where available
- [x] #4 Campaign analytics aggregate audit opens and CTA activity by campaign, niche, region, and timeframe
- [x] #5 Rybbit API failures are shown gracefully and do not break the rest of the dashboard
<!-- AC:END -->
## Implementation Plan
@@ -46,4 +46,8 @@ Display anonymous analytics for generated public audit pages inside the internal
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for Rybbit public-audit tracking and dashboard analytics surfaces.
Implemented public-audit-only Rybbit tracking, on-demand Rybbit API routes for audit/campaign activity, per-audit summary helper, dashboard Rybbit error handling, and campaign-level overall Rybbit signals. AC4 remains open for full grouping by campaign/niche/region/timeframe because Rybbit events still need a stronger audit-to-campaign join model. Verification: pnpm test 305/305; pnpm lint 0 errors.
Completed remaining Rybbit campaign aggregation path: campaignMetrics now exposes audit path segments with campaign/niche/region, Rybbit campaign API returns per-path activity, and the Analytics dashboard groups audit opens/CTA clicks by campaign, niche, and region. Verification: targeted analytics tests pass.
<!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Add MVP quality gates and operational polish
status: In Progress
assignee: []
created_date: '2026-06-03 19:15'
updated_date: '2026-06-05 19:30'
updated_date: '2026-06-05 19:49'
labels:
- mvp
- quality
@@ -27,11 +27,11 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Core UI text is German and organized so future i18n is feasible
- [ ] #2 No secrets are stored in source code, dashboard-editable records, logs, prompts, or raw LLM history
- [ ] #3 Dashboard surfaces integration errors for Google, PageSpeed, OpenRouter, Playwright, SMTP, Convex jobs, and Rybbit
- [ ] #4 Critical user flows have basic tests or repeatable verification notes: login, campaign run, audit generation, approval, send, follow-up, analytics
- [ ] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions
- [x] #1 Core UI text is German and organized so future i18n is feasible
- [x] #2 No secrets are stored in source code, dashboard-editable records, logs, prompts, or raw LLM history
- [x] #3 Dashboard surfaces integration errors for Google, PageSpeed, OpenRouter, Playwright, SMTP, Convex jobs, and Rybbit
- [x] #4 Critical user flows have basic tests or repeatable verification notes: login, campaign run, audit generation, approval, send, follow-up, analytics
- [x] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions
<!-- AC:END -->
## Implementation Plan
@@ -48,4 +48,6 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for MVP quality gates, error observability, verification notes, and deployment readiness.
Implemented and verified German operational readiness surfaces, secret-safe integration status rows, verification notes for critical flows, and Coolify deployment notes. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Add campaign performance metrics
status: In Progress
assignee: []
created_date: '2026-06-03 19:15'
updated_date: '2026-06-05 19:30'
updated_date: '2026-06-05 19:49'
labels:
- mvp
- analytics
@@ -27,11 +27,11 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses
- [ ] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe
- [ ] #3 Campaign run detail shows new leads, skipped duplicates, blacklisted/skipped leads, errors, and audits generated
- [ ] #4 Rybbit-derived audit opens and CTA clicks are shown alongside Convex sales funnel metrics
- [ ] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard
- [x] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses
- [x] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe
- [x] #3 Campaign run detail shows new leads, skipped duplicates, blacklisted/skipped leads, errors, and audits generated
- [x] #4 Rybbit-derived audit opens and CTA clicks are shown alongside Convex sales funnel metrics
- [x] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard
<!-- AC:END -->
## Implementation Plan
@@ -48,4 +48,6 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for campaign performance metrics and filters.
Implemented and verified lightweight campaign metrics query/dashboard, filter contract, run detail rows, and Rybbit-derived audit opens/CTA clicks alongside Convex metrics. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Trigger audit generation after PageSpeed audit
status: In Progress
assignee: []
created_date: '2026-06-05 12:10'
updated_date: '2026-06-05 19:30'
updated_date: '2026-06-05 19:49'
labels: []
dependencies: []
priority: high
@@ -19,10 +19,10 @@ Wire the existing AI audit generation queue into the current automated flow so c
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Successful PageSpeed audit runs queue audit generation for the lead
- [ ] #2 Failed PageSpeed audit runs still queue audit generation when a lead was started so partial evidence can produce an audit
- [ ] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs
- [ ] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
- [x] #1 Successful PageSpeed audit runs queue audit generation for the lead
- [x] #2 Failed PageSpeed audit runs still queue audit generation when a lead was started so partial evidence can produce an audit
- [x] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs
- [x] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
<!-- AC:END -->
## Implementation Notes
@@ -31,4 +31,6 @@ Wire the existing AI audit generation queue into the current automated flow so c
Created accidentally while implementing the PageSpeed-to-audit-generation handoff. Superseded by TASK-13 because the handoff is a prerequisite for the audit/outreach review workspace. Do not implement separately.
Started verification pass. Implementation notes say TASK-27 is superseded by TASK-13, so only regression coverage and existing handoff will be checked.
Verified existing PageSpeed-to-audit-generation handoff in pageSpeedAction. Successful and failure paths queue audit generation for the started lead, queue failures are warning-logged, existing queueLeadAuditGeneration dedupe remains in place, and regression source tests pass. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
---
id: TASK-34
title: Harden v3 selection and Convex payloads
status: In Progress
assignee: []
created_date: '2026-06-06 20:54'
updated_date: '2026-06-06 21:03'
labels: []
dependencies: []
priority: high
ordinal: 36000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix v3 quality review issues by removing explicit undefined values from Convex mutation payloads and making v3 skill selection registry-driven with negative applicability tests.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Convex mutation payloads in auditGenerationAction omit undefined top-level and nested fields
- [x] #2 v3 skill selection is registry-driven by applies_when and declared inputs with deterministic capped output
- [x] #3 Negative v3 input/applicability tests and legacy category tests pass
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect current Convex mutation payload construction and v3 selection
2. Add RED tests for no undefined payload patterns, negative v3 gating, and deterministic cap
3. Run focused tests and record RED failures
4. Implement minimal payload omission and registry-driven v3 selection
5. Run focused tests green plus pnpm test if fast
6. Self-review scope and leave task In Progress
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
RED: tsc passed, focused node --test .test-output/tests/audit-evidence.test.js .test-output/tests/audit-generation-action-source.test.js failed as expected on registry-order v3 cap and explicit undefined stage payload contract. GREEN: tsc passed; focused tests passed 26/26; full pnpm test passed 350/350. Self-review: no commits/staging, no changes to convex/usageEvents.ts, no ScreenshotOne missing-key behavior changes.
<!-- SECTION:NOTES:END -->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
---
id: TASK-40
title: Behebe abschliessende Lint-Blocker
status: In Progress
assignee: []
created_date: '2026-06-06 22:10'
updated_date: '2026-06-06 22:15'
labels: []
dependencies: []
priority: high
ordinal: 42000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix the final lint blockers after the v2 pipeline implementation without changing runtime behavior. Keep v2_elemente as planning/reference material unless production imports require otherwise.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 pnpm lint exits 0 or only documents unrelated pre-existing generated warnings with a scoped suppression decision
- [x] #2 pnpm test remains green
- [x] #3 git diff --check remains green
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce pnpm lint failures
2. Apply scoped minimal lint policy or test-file cleanup
3. Re-run pnpm lint, pnpm test, git diff --check
4. Leave task In Progress until Matthias confirms Done
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
TASK-40 worker update: fixed final lint blockers by ignoring v2_elemente reference snippets in ESLint and removing an unused helper from tests/external-audit-pipeline-source.test.ts. Verification: pnpm lint exits 0 with only generated convex/betterAuth/_generated unused-disable warnings; pnpm test passes 363/363; git diff --check exits 0. Task intentionally left In Progress pending user confirmation.
<!-- SECTION:NOTES:END -->

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
---
id: TASK-45
title: Show audit evidence on detail pages
status: In Progress
assignee: []
created_date: '2026-06-07 21:50'
updated_date: '2026-06-07 22:01'
labels: []
dependencies: []
priority: high
ordinal: 47000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix the audit detail view so stored checked pages and compact website-enrichment evidence are visible instead of only showing the page count.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Audit detail query returns ordered checked-page evidence with crawl, technical, and screenshot summaries
- [x] #2 Audit detail UI renders a compact Geprüfte Seiten section between overview and skills
- [x] #3 Fallback rows render checkedPages even when enrichment evidence is missing
- [x] #4 Public audit and outreach flows remain unchanged
- [x] #5 Regression tests and local verification pass
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add RED tests for getDetail sourceSummaries checked-page evidence
2. Add RED tests for AuditDetail compact evidence rendering
3. Extend audits.getDetail with bounded lead/enrichment evidence summaries
4. Render compact checked-page evidence card in AuditDetail
5. Run focused and full verification without closing the task
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented getDetail sourceSummaries.checkedPages with latest successful website_enrichment evidence by lead, bounded crawl/technical/screenshot joins, storage URL resolution, and checkedPages fallback rows. AuditDetail now renders a compact Geprüfte Seiten card between overview and skills. Verification passed: focused tests, pnpm test, app tsc, lint, git diff --check, convex codegen dry-run/typecheck, and convex dev --once. Browser plugin reached login because its session is unauthenticated; Arc/local authenticated session should show the deployed query after reload.
<!-- SECTION:NOTES:END -->

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
---
id: TASK-48
title: Integrate impeccable critique into audit pipeline
status: In Progress
assignee: []
created_date: '2026-06-08 12:02'
updated_date: '2026-06-08 12:10'
labels: []
dependencies: []
priority: high
ordinal: 50000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Extend the evidence-first audit pipeline with design critique/impeccable-style visual and UX evaluation, especially the critique skill, while keeping verified findings evidence-linked and customer-safe.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Critique/impeccable skill guidance is inspected and translated into bounded audit stages or skill prompts
- [x] #2 New critique findings stay evidence-linked and flow through the compact evidence verifier
- [x] #3 German copy synthesis consumes only verified critique findings, not raw skill output
- [x] #4 Audit UI exposes critique findings with evidence chips and actual skill purpose text
- [x] #5 Focused and full regression tests cover the new critique integration
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect impeccable/critique skill guidance and current audit pipeline shape
2. Define a compact critique/impeccable stage that maps skill guidance into evidence-backed audit findings
3. Add schemas/prompts or stage wiring without expanding verifier output size
4. Update UI/tests so critique findings are visible with evidence and real skill purpose
5. Run focused and full regression tests, deploy Convex dev, keep task In Progress for live confirmation
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented the impeccable/critique integration as an evidence-bound audit extension. Inspected the local impeccable and critique skills; no project-specific .impeccable.md was present, so the product guidance was translated into bounded audit behavior instead of broad design taste claims. Added the V3 skill registry entry `impeccable-critique`, prioritized it in selected local audit skills, and wired a new Convex `critiqueSpecialist` stage between visual trust and performance/accessibility. The stage is instructed to produce only evidence-linked findings using skillId `impeccable-critique`; the existing compact verifier and German synthesis path remain the gate, so raw specialist output is not customer-facing. UI tests continue to cover evidence chips and real registry purpose text. Verification: focused specialist/evidence tests 45/45 passed; skill/UI tests 15/15 passed; full `pnpm test` 388/388 passed; `git diff --check` passed; targeted ESLint passed; `npx convex dev --once` synced successfully.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-49
title: Improve audit outreach email tone
status: In Progress
assignee: []
created_date: '2026-06-08 19:30'
updated_date: '2026-06-08 19:48'
labels: []
dependencies: []
priority: high
ordinal: 51000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add evidence-first, collegial-direct tonal guidelines for generated outreach emails, wire them into the existing German copy stage without extra AI calls, and hard-block unnatural email copy before outreach_ready.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Shared customer tone guidelines capture the selected collegial-direct email style and banned patterns
- [x] #2 German copy prompts use the tone guidelines, explicit lead context, at most two verified findings, and no extra AI stage or model call
- [x] #3 Deterministic German copy guard blocks unnatural email subjects and bodies while keeping public audit tone checks limited to existing rules
- [x] #4 Quality review applies the same first-contact email rubric
- [x] #5 Focused and full regression tests cover natural email pass cases, unnatural email failures, source wiring, and no new generation stage
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing tests for natural vs. formulaic outreach email tone
2. Add shared collegial-direct tone guideline module
3. Add deterministic hard guard for email subject/body tone
4. Wire guidelines into German copy and quality review prompts without a new AI stage
5. Run focused tests, full regression, lint, diff check, and Convex dev sync
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented the evidence-first outreach email tone pass. Added `lib/ai/customer-tone-guidelines.ts` with the selected collegial-direct sender posture, short first-contact email constraints, banned phrases, and prompt helper. Updated German copy generation to remove the old Ich-Ich instruction, include the shared tone section, pass normalized evidence context, and keep the existing generation call structure. Added hard deterministic email tone checks for subject length/pitch patterns, email length, sentence/paragraph count, formulaic Ich-habe/Ich-schlage-vor patterns, brochure language, mini-audit structure, informal address, and missing low-friction asks. Public audit hard guard behavior remains limited to the existing rules. Quality review now explicitly asks whether the email sounds like a real first email from Matthias, not AI sales copy, and whether concrete claims are backed by verified findings. Verification: focused tests 60/60 passed; full `pnpm test` 395/395 passed; targeted ESLint passed; `git diff --check` passed; `npx convex dev --once` synced successfully after fixing the Convex-only typecheck issue by passing `evidenceInput` instead of raw evidence.
<!-- SECTION:NOTES:END -->

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
import { Globe } from "lucide-react";
type UsedSkill = {
id?: string;
name: string;
purpose?: string;
category?: string;
@@ -17,6 +18,12 @@ type UsedSkill = {
version?: string;
};
type SkillSummary = {
name: string;
purpose: string;
summary: string;
};
type LeadContext = {
_id: Id<"leads">;
companyName?: string;
@@ -35,12 +42,70 @@ type SkillAwareAudit = {
createdAt?: number;
updatedAt?: number;
usedSkills?: UsedSkill[];
skillSummaries?: SkillSummary[];
internalSummary?: string | null;
};
type AuditFindingEvidenceRef = {
id: string;
type:
| "crawl_page"
| "technical_check"
| "screenshot"
| "pagespeed"
| "jina_excerpt"
| "generation_stage";
label: string;
sourceUrl?: string;
};
type AuditFinding = {
_id: string;
skillId: string;
claim: string;
recommendation: string;
customerBenefit: string;
severity: 1 | 2 | 3;
confidence: number;
evidenceRefs: AuditFindingEvidenceRef[];
reviewStatus: "pending" | "accepted" | "rejected";
};
type CheckedPageScreenshot = {
id: Id<"_storage">;
url: string;
viewport: "desktop" | "mobile";
sourceUrl: string;
width: number;
height: number;
createdAt: number;
};
type CheckedPageEvidence = {
url: string;
sourceUrl: string | null;
finalUrl: string | null;
pageKind: string | null;
title: string | null;
metaDescription: string | null;
headings: string[];
visibleTextExcerpt: string | null;
hasContactFormSignal: boolean | null;
hasContactCtaSignal: boolean | null;
usesHttps: boolean | null;
missingMetaDescription: boolean | null;
brokenInternalLinkCount: number | null;
screenshots: CheckedPageScreenshot[];
createdAt: number | null;
};
type AuditDetailResult = {
audit: SkillAwareAudit;
lead: LeadContext | null;
findings: AuditFinding[];
sourceSummaries: {
checkedPages: CheckedPageEvidence[];
};
} | null;
const statusText: Record<string, string> = {
@@ -54,6 +119,55 @@ function getStatusLabel(status: SkillAwareAudit["status"]) {
return statusText[status] ?? "Unbekannt";
}
function getPageKindLabel(pageKind: string | null) {
const labels: Record<string, string> = {
contact: "Kontakt",
homepage: "Startseite",
imprint: "Impressum",
other: "Unterseite",
service: "Leistung",
};
return pageKind ? labels[pageKind] ?? pageKind : "Geprüft";
}
function signalText(value: boolean | null, positive: string, negative: string) {
if (value === null) {
return "Unbekannt";
}
return value ? positive : negative;
}
function metaSignalText(page: CheckedPageEvidence) {
if (page.metaDescription) {
return "Vorhanden";
}
if (page.missingMetaDescription === true) {
return "Fehlt";
}
if (page.missingMetaDescription === false) {
return "Vorhanden";
}
return "Unbekannt";
}
function evidenceTypeLabel(type: AuditFindingEvidenceRef["type"]) {
const labels: Record<AuditFindingEvidenceRef["type"], string> = {
crawl_page: "Crawl",
technical_check: "Technik",
screenshot: "Screenshot",
pagespeed: "PageSpeed",
jina_excerpt: "Reader",
generation_stage: "KI-Stufe",
};
return labels[type] ?? type;
}
function leadSummary(lead: LeadContext | null | undefined) {
if (!lead) {
return "Kein Lead-Kontext gespeichert";
@@ -89,7 +203,24 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
const audit = result?.audit;
const lead = result?.lead;
const usedSkills = useMemo(() => audit?.usedSkills ?? [], [audit]);
const usedSkills = useMemo(() => {
const summaries = audit?.skillSummaries ?? [];
return (audit?.usedSkills ?? []).map((skill) => {
const summary = summaries.find(
(candidate) => candidate.name === skill.name || candidate.name === skill.id,
);
return {
...skill,
purpose: summary?.purpose ?? skill.purpose,
summary: summary?.summary,
};
});
}, [audit]);
const findings = useMemo(() => result?.findings ?? [], [result]);
const checkedPageEvidence = useMemo(
() => result?.sourceSummaries.checkedPages ?? [],
[result],
);
if (result === null) {
return (
@@ -149,6 +280,139 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Geprüfte Befunde</CardTitle>
<CardDescription>
Verifizierte Aussagen mit konkreten Belegen aus Crawl, Screenshots und Messungen.
</CardDescription>
</CardHeader>
<CardContent>
{findings.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine verifizierten Befunde gespeichert.
</p>
) : (
<ul className="grid gap-3">
{findings.map((finding) => (
<li className="rounded-md border p-3 text-sm" key={finding._id}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{finding.claim}</p>
<p className="mt-2 text-muted-foreground">
{finding.customerBenefit}
</p>
</div>
<div className="flex flex-wrap gap-1">
<Badge variant={finding.severity === 3 ? "secondary" : "outline"}>
Priorität {finding.severity}
</Badge>
<Badge variant="outline">
{Math.round(finding.confidence * 100)}% sicher
</Badge>
</div>
</div>
<p className="mt-3">
<span className="font-medium">Empfehlung: </span>
{finding.recommendation}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{finding.evidenceRefs.map((ref) => (
<Badge variant="outline" key={ref.id}>
Quelle: {evidenceTypeLabel(ref.type)} · {ref.label}
</Badge>
))}
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Geprüfte Seiten</CardTitle>
<CardDescription>
Kompakte Evidence aus Website-Enrichment und Screenshot-Erfassung.
</CardDescription>
</CardHeader>
<CardContent>
{checkedPageEvidence.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Seiten-Evidence gespeichert</p>
) : (
<ul className="grid gap-3">
{checkedPageEvidence.map((page, index) => (
<li
className="rounded-md border p-3 text-sm"
key={`${page.url}-${index}`}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="break-words font-medium">
{page.title ?? page.finalUrl ?? page.sourceUrl ?? page.url}
</p>
<p className="mt-1 break-all text-xs text-muted-foreground">
{page.finalUrl ?? page.sourceUrl ?? page.url}
</p>
</div>
<Badge variant="outline">{getPageKindLabel(page.pageKind)}</Badge>
</div>
{page.visibleTextExcerpt ? (
<p className="mt-3 line-clamp-3 text-sm text-muted-foreground">
{page.visibleTextExcerpt}
</p>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant={page.missingMetaDescription === true ? "secondary" : "outline"}>
Meta: {metaSignalText(page)}
</Badge>
<Badge variant="outline">
Kontaktformular:{" "}
{signalText(page.hasContactFormSignal, "Signal", "Kein Signal")}
</Badge>
<Badge variant="outline">
CTA: {signalText(page.hasContactCtaSignal, "Signal", "Kein Signal")}
</Badge>
<Badge variant="outline">
Interne Links: {page.brokenInternalLinkCount ?? "Unbekannt"}
</Badge>
</div>
{page.screenshots.length > 0 ? (
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{page.screenshots.map((screenshot) => (
<figure
className="overflow-hidden rounded-md border bg-muted/20"
key={`${screenshot.id}-${screenshot.viewport}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={screenshot.url}
alt={`${screenshot.viewport === "desktop" ? "Desktop" : "Mobile"} Screenshot von ${screenshot.sourceUrl}`}
width={screenshot.width}
height={screenshot.height}
className="aspect-[16/10] w-full object-cover"
/>
<figcaption className="flex items-center justify-between gap-2 border-t px-2 py-1 text-xs text-muted-foreground">
<span className="font-medium">
{screenshot.viewport === "desktop" ? "Desktop" : "Mobil"}
</span>
<span className="truncate">{screenshot.sourceUrl}</span>
</figcaption>
</figure>
))}
</div>
) : null}
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Verwendete Skills</CardTitle>
@@ -168,6 +432,9 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
<p className="text-sm text-muted-foreground">
{skill.purpose ?? "Keine Zweckbeschreibung"}
</p>
{"summary" in skill && skill.summary ? (
<p className="text-sm text-muted-foreground">{skill.summary}</p>
) : null}
<p className="mt-1 inline-flex flex-wrap items-center gap-1">
{skill.category ? <Badge variant="outline">{skill.category}</Badge> : null}
{skill.version ? <Badge variant="outline">{skill.version}</Badge> : null}

View File

@@ -1,18 +1,30 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState } 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";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type AuditsListResult = FunctionReturnType<typeof api.audits.list>;
type AuditRow = NonNullable<AuditsListResult>[number];
type AuditDashboardRowsResult = FunctionReturnType<typeof api.audits.listDashboardRows>;
type AuditRow = Extract<
NonNullable<AuditDashboardRowsResult>[number],
{ kind: "audit" }
>;
type AuditDashboardRow = NonNullable<AuditDashboardRowsResult>[number];
type AuditStatusFilter = "all" | "audit" | "generation" | "failed";
const statusText: Record<string, string> = {
draft: "Entwurf",
@@ -23,14 +35,48 @@ const statusText: Record<string, string> = {
const fallbackStatus = "Unbekannt";
function formatPageCount(pages: AuditRow["checkedPages"]) {
return `${pages.length} Seite${pages.length === 1 ? "" : "n"}`;
const generationStageText: Record<string, string> = {
audit_generation: "Audit-Generierung",
classification: "Klassifikation",
multimodalAudit: "Multimodale Analyse",
germanCopy: "Deutsche Texte",
qualityReview: "Qualitätsprüfung",
};
function formatPageCount(pageCount: number) {
return `${pageCount} Seite${pageCount === 1 ? "" : "n"}`;
}
function getStatusLabel(status: AuditRow["status"]) {
return statusText[status] ?? fallbackStatus;
}
function getGenerationStatusLabel(
row: Extract<AuditDashboardRow, { kind: "generation" }>,
) {
if (row.status === "pending") {
return "Wartet auf Start";
}
if (row.status === "failed") {
return "Fehlgeschlagen";
}
if (row.status === "canceled") {
return "Abgebrochen";
}
if (row.status === "succeeded") {
return "Wartet auf finales Audit";
}
return "Generierung läuft";
}
function getStageLabel(stage: string) {
return generationStageText[stage] ?? stage;
}
function AuditsBoardLoading() {
return (
<section className="space-y-4">
@@ -39,28 +85,64 @@ function AuditsBoardLoading() {
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
</header>
<div className="rounded-lg border">
<div className="grid gap-2 p-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton className="h-20 rounded-md" key={index} />
<Skeleton className="h-40 rounded-lg" key={index} />
))}
</div>
</div>
</section>
);
}
export function AuditsBoard() {
const audits = useQuery(api.audits.list, { limit: 100 });
const dashboardRows = useQuery(api.audits.listDashboardRows, { limit: 100 });
const [activeFilter, setActiveFilter] = useState<AuditStatusFilter>("all");
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]);
const statusCounts = useMemo(() => {
return {
all: rows.length,
audit: rows.filter((row) => row.kind === "audit").length,
generation: rows.filter((row) => row.kind === "generation").length,
failed: rows.filter(
(row) => row.kind === "generation" && row.status === "failed",
).length,
};
}, [rows]);
const visibleRows = useMemo(() => {
if (activeFilter === "audit") {
return rows.filter((row) => row.kind === "audit");
}
if (audits === undefined) {
if (activeFilter === "generation") {
return rows.filter((row) => row.kind === "generation");
}
if (activeFilter === "failed") {
return rows.filter(
(row) => row.kind === "generation" && row.status === "failed",
);
}
return rows;
}, [activeFilter, rows]);
const auditStatusFilters: Array<{
label: string;
value: AuditStatusFilter;
count: number;
}> = [
{ label: "Alle", value: "all", count: statusCounts.all },
{ label: "Audits", value: "audit", count: statusCounts.audit },
{ label: "Pipeline", value: "generation", count: statusCounts.generation },
{ label: "Fehlgeschlagen", value: "failed", count: statusCounts.failed },
];
if (dashboardRows === undefined) {
return <AuditsBoardLoading />;
}
@@ -72,13 +154,15 @@ export function AuditsBoard() {
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
</header>
<article className="rounded-lg border p-4">
<Card>
<CardHeader>
<h2 className="text-sm font-medium">Noch keine Audits</h2>
<p className="mt-1 text-sm text-muted-foreground">
Sobald neue Audits angelegt wurden, erscheinen sie hier als kompakte
Zeilen.
</p>
</article>
<CardDescription>
Sobald neue Audits oder laufende Audit-Generierungen angelegt
wurden, erscheinen sie hier als kompakte Cards.
</CardDescription>
</CardHeader>
</Card>
</section>
);
}
@@ -90,44 +174,118 @@ export function AuditsBoard() {
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
</header>
<section className="space-y-2">
<div className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] gap-2 rounded-md border bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>Slug</span>
<span>Domain</span>
<span>Status</span>
<span>Seitenanzahl</span>
<span className="text-right">Aktion</span>
</div>
<div className="space-y-2">
{rows.map((audit: AuditRow) => (
<article
className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] items-center gap-2 rounded-lg border px-3 py-2 text-sm"
key={audit._id}
<div className="flex flex-wrap gap-2" aria-label="Audit-Filter">
{auditStatusFilters.map((filter) => (
<button
aria-pressed={activeFilter === filter.value}
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
>
<div className="min-w-0">
<p className="truncate font-medium">{audit.slug}</p>
</div>
<p className="truncate text-muted-foreground">{audit.checkedDomain}</p>
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
<p className="text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Files className="size-3.5" />
{formatPageCount(audit.checkedPages)}
</span>
</p>
<div className="flex justify-end">
<Link
className="inline-flex min-h-8 items-center gap-1 text-sm text-primary"
href={`/dashboard/audits/${audit._id}`}
>
<SquarePen className="size-4" />
Öffnen
</Link>
</div>
</article>
{filter.label}
<Badge variant="secondary">{filter.count}</Badge>
</button>
))}
</div>
<section
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
aria-label="Audit-Cards"
>
{visibleRows.map((row: AuditDashboardRow) => {
const rowTitleId = `audit-row-title-${row.id}`;
return (
<Card
aria-labelledby={rowTitleId}
className="flex min-w-0 flex-col"
key={row.id}
>
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardDescription>
{row.kind === "audit" ? "Audit" : "Pipeline"}
</CardDescription>
<CardTitle className="mt-1 break-words text-base" id={rowTitleId}>
{row.title}
</CardTitle>
</div>
<Badge variant={row.kind === "audit" ? "secondary" : "outline"}>
{row.kind === "audit"
? getStatusLabel(row.status)
: getGenerationStatusLabel(row)}
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="grid gap-3 text-sm">
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">Domain</p>
<p className="mt-1 break-all">{row.checkedDomain}</p>
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">
{row.kind === "audit" ? "Seiten" : "Phase"}
</p>
<p className="mt-1 inline-flex items-center gap-1 text-muted-foreground">
{row.kind === "audit" ? (
<>
<Files className="size-3.5" aria-hidden="true" />
{formatPageCount(row.pageCount)}
</>
) : (
<>
<Activity className="size-3.5" aria-hidden="true" />
{getStageLabel(row.latestStage)}
</>
)}
</p>
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">Slug</p>
<p className="mt-1 break-words text-muted-foreground">
{row.kind === "generation" ? `Run ${row.runId}` : row.title}
</p>
</div>
{row.kind === "generation" && row.errorSummary ? (
<p className="break-words rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{row.errorSummary}
</p>
) : null}
</div>
<div className="mt-auto flex justify-end">
{row.kind === "audit" ? (
<Link
className="inline-flex min-h-8 items-center gap-1 rounded-md px-2 text-sm text-primary hover:bg-muted"
href={row.detailHref}
>
<SquarePen className="size-4" aria-hidden="true" />
Öffnen
</Link>
) : (
<span className="inline-flex min-h-8 items-center text-sm text-muted-foreground">
Pipeline läuft
</span>
)}
</div>
</CardContent>
</Card>
);
})}
{visibleRows.length === 0 ? (
<Card className="sm:col-span-2 xl:col-span-3">
<CardHeader>
<CardTitle>Keine Treffer</CardTitle>
<CardDescription>
Für diesen Filter gibt es aktuell keine Audit-Einträge.
</CardDescription>
</CardHeader>
</Card>
) : null}
</section>
</section>
);

View File

@@ -284,13 +284,18 @@ export function CampaignsBoard() {
</CardHeader>
</Card>
) : (
<div className="grid gap-3">
{campaignsSorted.map((campaign) => (
<Card key={campaign._id}>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{campaignsSorted.map((campaign) => {
const campaignTitleId = `campaign-title-${campaign._id}`;
return (
<Card aria-labelledby={campaignTitleId} key={campaign._id}>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate">{campaign.name}</CardTitle>
<CardTitle className="truncate" id={campaignTitleId}>
{campaign.name}
</CardTitle>
<CardDescription className="truncate">
{formatNiche(campaign)}
</CardDescription>
@@ -320,10 +325,16 @@ export function CampaignsBoard() {
</p>
</div>
<div>
<p className="text-muted-foreground">Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</p>
<p className="text-muted-foreground">Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</p>
<p className="text-muted-foreground">
Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
</p>
<p className="text-muted-foreground">
Nächster Lauf: {formatDateTime(campaign.nextRunAt)}
</p>
<p className="text-muted-foreground">
Run-Status:{" "}
{statusLabel[campaign.currentRunStatus] ??
campaign.currentRunStatus}
</p>
</div>
@@ -357,7 +368,8 @@ export function CampaignsBoard() {
</div>
</CardContent>
</Card>
))}
);
})}
</div>
)}

View File

@@ -23,7 +23,16 @@ import {
} from "@/lib/dashboard-model";
import { Button } from "@/components/ui/button";
import { Card, CardHeader } from "@/components/ui/card";
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
@@ -63,6 +72,7 @@ type LeadReviewPayload = {
reviewContactPerson?: string;
reviewIsBusinessContactAddress?: boolean;
};
type LeadStatusFilter = "all" | "high" | "blocked";
function normalizeTextInput(value: string): string | undefined {
const next = value.trim();
@@ -132,6 +142,7 @@ function duplicateBadgeVariant(
export function LeadsReviewTable() {
const leads = useQuery(api.leads.list, { limit: 120 });
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<LeadStatusFilter>("all");
const sortedLeads = useMemo(() => {
if (!leads) {
@@ -140,6 +151,30 @@ export function LeadsReviewTable() {
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
}, [leads]);
const filteredLeads = useMemo(() => {
if (activeFilter === "high") {
return sortedLeads.filter((lead) => lead.priority === "high");
}
if (activeFilter === "blocked") {
return sortedLeads.filter((lead) => lead.blacklistStatus === "blocked");
}
return sortedLeads;
}, [activeFilter, sortedLeads]);
const leadStatusFilters: Array<{ label: string; value: LeadStatusFilter; count: number }> = [
{ label: "Alle Leads", value: "all", count: sortedLeads.length },
{
label: "Hohe Priorität",
value: "high",
count: sortedLeads.filter((lead) => lead.priority === "high").length,
},
{
label: "Gesperrt",
value: "blocked",
count: sortedLeads.filter((lead) => lead.blacklistStatus === "blocked").length,
},
];
return (
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
@@ -148,16 +183,52 @@ export function LeadsReviewTable() {
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
</div>
<div className="mx-auto flex w-full max-w-7xl flex-wrap gap-2" aria-label="Lead-Filter">
{leadStatusFilters.map((filter) => (
<button
aria-pressed={activeFilter === filter.value}
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
>
{filter.label}
<Badge variant="secondary">{filter.count}</Badge>
</button>
))}
</div>
<div className="mx-auto grid w-full max-w-7xl gap-3">
{leads === undefined ? (
<p className="rounded-md bg-muted p-4 text-sm">Leads werden geladen</p>
Array.from({ length: 4 }, (_, index) => (
<Card key={index}>
<CardHeader>
<div className="h-5 w-2/3 rounded-md bg-muted" />
<div className="h-4 w-1/2 rounded-md bg-muted" />
<div className="mt-2 h-12 rounded-md bg-muted" />
</CardHeader>
</Card>
))
) : sortedLeads.length === 0 ? (
<p className="rounded-md border p-4 text-sm text-muted-foreground">
Keine Leads vorhanden. Bitte zuerst eine Kampagne starten oder
importieren.
<Card>
<CardHeader>
<p className="text-sm font-medium">Keine Leads vorhanden</p>
<p className="text-sm text-muted-foreground">
Bitte zuerst eine Kampagne starten oder importieren.
</p>
</CardHeader>
</Card>
) : filteredLeads.length === 0 ? (
<Card>
<CardHeader>
<p className="text-sm font-medium">Keine Treffer</p>
<p className="text-sm text-muted-foreground">
Für diesen Filter sind aktuell keine Leads vorhanden.
</p>
</CardHeader>
</Card>
) : (
sortedLeads.map((lead) => (
filteredLeads.map((lead) => (
<LeadReviewRow
key={lead._id}
lead={lead}
@@ -168,7 +239,7 @@ export function LeadsReviewTable() {
</div>
{actionMessage ? (
<p className="mx-auto max-w-7xl text-sm text-muted-foreground">
<p className="mx-auto max-w-7xl text-sm text-muted-foreground" role="status">
{actionMessage}
</p>
) : null}
@@ -183,7 +254,7 @@ function LeadReviewRow({
lead: LeadRow;
onActionMessage: (value: string) => void;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
priority: lead.priority,
contactStatus: lead.contactStatus,
@@ -279,14 +350,26 @@ function LeadReviewRow({
};
const detailsId = `lead-review-details-${lead._id}`;
const titleId = `lead-review-title-${lead._id}`;
const priorityId = `lead-priority-${lead._id}`;
const contactStatusId = `lead-contact-status-${lead._id}`;
const priorityReasonId = `lead-priority-reason-${lead._id}`;
const contactReasonId = `lead-contact-reason-${lead._id}`;
const notesId = `lead-notes-${lead._id}`;
const reviewEmailId = `lead-review-email-${lead._id}`;
const reviewSourceId = `lead-review-source-${lead._id}`;
const contactPersonId = `lead-contact-person-${lead._id}`;
const businessContactId = `lead-business-contact-${lead._id}`;
const duplicateStatusId = `lead-duplicate-status-${lead._id}`;
const blacklistStatusId = `lead-blacklist-status-${lead._id}`;
return (
<Card>
<Card aria-labelledby={titleId}>
<CardHeader className="pb-3">
<div className="grid min-w-0 gap-2">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="max-w-full truncate font-medium">
<p className="max-w-full truncate font-medium" id={titleId}>
{lead.companyName}
</p>
<p className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
@@ -339,24 +422,35 @@ function LeadReviewRow({
<Button
type="button"
variant="outline"
onClick={() => setIsExpanded((previous) => !previous)}
aria-expanded={isExpanded}
aria-controls={detailsId}
onClick={() => setIsDialogOpen(true)}
size="sm"
>
{isExpanded ? "Weniger anzeigen" : "Mehr anzeigen"}
Mehr anzeigen
</Button>
</div>
<div
id={detailsId}
className="grid gap-3 border-t p-4"
hidden={!isExpanded}
<Dialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
>
<DialogContent
className="max-h-[calc(100dvh-2rem)] max-w-5xl overflow-y-auto"
id={detailsId}
>
<DialogHeader>
<div>
<DialogTitle>{lead.companyName} prüfen</DialogTitle>
<DialogDescription>
Priorität, Kontaktstatus, Duplikate und Kontaktinformationen bearbeiten.
</DialogDescription>
</div>
<DialogCloseButton />
</DialogHeader>
<div className="grid gap-3 xl:grid-cols-2">
<section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Priorität</p>
<Label className="text-xs text-muted-foreground" htmlFor={priorityId}>Priorität</Label>
<div className="mt-2">
<Select
value={draft.priority}
@@ -364,7 +458,7 @@ function LeadReviewRow({
updateDraft("priority", nextPriority as LeadPriority)
}
>
<SelectTrigger>
<SelectTrigger id={priorityId}>
<SelectValue placeholder="Priorität" />
</SelectTrigger>
<SelectContent>
@@ -379,7 +473,7 @@ function LeadReviewRow({
</div>
<div>
<p className="text-xs text-muted-foreground">Kontaktstatus</p>
<Label className="text-xs text-muted-foreground" htmlFor={contactStatusId}>Kontaktstatus</Label>
<div className="mt-2">
<Select
value={draft.contactStatus}
@@ -387,7 +481,7 @@ function LeadReviewRow({
updateDraft("contactStatus", nextStatus as LeadContactStatus)
}
>
<SelectTrigger>
<SelectTrigger id={contactStatusId}>
<SelectValue placeholder="Kontaktstatus" />
</SelectTrigger>
<SelectContent>
@@ -404,8 +498,9 @@ function LeadReviewRow({
<section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Prioritätsgrund</p>
<Label className="text-xs text-muted-foreground" htmlFor={priorityReasonId}>Prioritätsgrund</Label>
<Input
id={priorityReasonId}
value={draft.priorityReason}
onChange={(event) => {
updateDraft("priorityReason", event.target.value);
@@ -413,10 +508,11 @@ function LeadReviewRow({
/>
</div>
<div>
<p className="mt-2 text-xs text-muted-foreground">
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactReasonId}>
Kontaktstatus-Notiz
</p>
</Label>
<Input
id={contactReasonId}
value={draft.contactStatusReason}
onChange={(event) => {
updateDraft("contactStatusReason", event.target.value);
@@ -424,8 +520,9 @@ function LeadReviewRow({
/>
</div>
<div>
<p className="mt-2 text-xs text-muted-foreground">Notiz</p>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={notesId}>Notiz</Label>
<Input
id={notesId}
value={draft.notes}
onChange={(event) => {
updateDraft("notes", event.target.value);
@@ -443,8 +540,9 @@ function LeadReviewRow({
<section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Review-E-Mail</p>
<Label className="text-xs text-muted-foreground" htmlFor={reviewEmailId}>Review-E-Mail</Label>
<Input
id={reviewEmailId}
value={draft.reviewEmail}
onChange={(event) => {
updateDraft("reviewEmail", event.target.value);
@@ -453,8 +551,9 @@ function LeadReviewRow({
</div>
<div>
<p className="mt-2 text-xs text-muted-foreground">Review-Quelle</p>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={reviewSourceId}>Review-Quelle</Label>
<Input
id={reviewSourceId}
value={draft.reviewEmailSource}
onChange={(event) => {
updateDraft("reviewEmailSource", event.target.value);
@@ -462,28 +561,30 @@ function LeadReviewRow({
/>
</div>
<div>
<p className="mt-2 text-xs text-muted-foreground">Ansprechperson</p>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactPersonId}>Ansprechperson</Label>
<Input
id={contactPersonId}
value={draft.reviewContactPerson}
onChange={(event) => {
updateDraft("reviewContactPerson", event.target.value);
}}
/>
</div>
<label className="mt-2 inline-flex items-center gap-2 text-xs text-muted-foreground">
<Label className="mt-2 inline-flex items-center gap-2 text-xs text-muted-foreground" htmlFor={businessContactId}>
<Switch
id={businessContactId}
checked={draft.reviewIsBusinessContactAddress}
onCheckedChange={(checked) => {
updateDraft("reviewIsBusinessContactAddress", checked);
}}
/>
Genannte E-Mail als Business-Kontakt
</label>
</Label>
</section>
<section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Duplikatstatus</p>
<Label className="text-xs text-muted-foreground" htmlFor={duplicateStatusId}>Duplikatstatus</Label>
<div className="mt-2">
<Select
value={draft.duplicateStatus}
@@ -491,7 +592,7 @@ function LeadReviewRow({
updateDraft("duplicateStatus", nextStatus as LeadDuplicateStatus)
}
>
<SelectTrigger>
<SelectTrigger id={duplicateStatusId}>
<SelectValue placeholder="Duplikatstatus" />
</SelectTrigger>
<SelectContent>
@@ -506,7 +607,7 @@ function LeadReviewRow({
</div>
<div>
<label className="text-xs text-muted-foreground">Sperrstatus</label>
<Label className="text-xs text-muted-foreground" htmlFor={blacklistStatusId}>Sperrstatus</Label>
<div className="mt-2">
<Select
value={draft.blacklistStatus}
@@ -514,7 +615,7 @@ function LeadReviewRow({
updateDraft("blacklistStatus", nextStatus as LeadBlacklistStatus)
}
>
<SelectTrigger>
<SelectTrigger id={blacklistStatusId}>
<SelectValue placeholder="Sperrstatus" />
</SelectTrigger>
<SelectContent>
@@ -557,11 +658,16 @@ function LeadReviewRow({
</Button>
</div>
{rowMessage ? (
<p className="text-xs text-muted-foreground">{rowMessage}</p>
rowMessage === "Speichern fehlgeschlagen" ? (
<p className="text-xs text-destructive" role="alert">{rowMessage}</p>
) : (
<p className="text-xs text-muted-foreground" role="status">{rowMessage}</p>
)
) : null}
</section>
</div>
</div>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -10,7 +10,7 @@ import Link from "next/link";
import { api } from "@/convex/_generated/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogCloseButton,
@@ -45,6 +45,7 @@ type PendingEmailConfirmation = {
sender: string;
auditSlug: string | null;
};
type ReviewStatusFilter = "all" | "ready" | "mail_open";
const emptyDraft: DraftState = {
auditBody: "",
@@ -124,6 +125,20 @@ function skillLabel(skill: UsedSkill) {
return skill.category ? `${name} · ${skill.category}` : name;
}
function isEmailDraftReady(record: ReviewWorkspaceItem) {
const outreach = record.latestOutreach;
return Boolean(outreach?.emailSubject?.trim() && outreach.emailBody?.trim());
}
function isReadyToSend(record: ReviewWorkspaceItem) {
return Boolean(
record.latestOutreach &&
record.latestOutreach.sendStatus !== "queued" &&
isEmailDraftReady(record),
);
}
function DetailToggle({
isOpen,
label,
@@ -187,10 +202,40 @@ export function OutreachReviewWorkspace() {
const [openRaw, setOpenRaw] = useState<Record<string, boolean>>({});
const [busyAction, setBusyAction] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<ReviewStatusFilter>("all");
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null);
const [pendingEmailConfirmation, setPendingEmailConfirmation] =
useState<PendingEmailConfirmation | null>(null);
const rows = useMemo<ReviewWorkspaceItem[]>(() => records ?? [], [records]);
const reviewStatusFilters: Array<{ label: string; value: ReviewStatusFilter; count: number }> = [
{ label: "Alle Reviews", value: "all", count: rows.length },
{
label: "Bereit zum Versand",
value: "ready",
count: rows.filter(isReadyToSend).length,
},
{
label: "Mail offen",
value: "mail_open",
count: rows.filter((row) => !isEmailDraftReady(row)).length,
},
];
const filteredRows = useMemo(() => {
if (activeFilter === "ready") {
return rows.filter(isReadyToSend);
}
if (activeFilter === "mail_open") {
return rows.filter((row) => !isEmailDraftReady(row));
}
return rows;
}, [activeFilter, rows]);
const selectedRecord =
filteredRows.find((row) => row.id === selectedRecordId) ??
filteredRows[0] ??
null;
if (records === undefined) {
return <WorkspaceLoading />;
@@ -447,7 +492,7 @@ export function OutreachReviewWorkspace() {
</header>
{notice ? (
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm">{notice}</p>
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm" role="status">{notice}</p>
) : null}
<Dialog
@@ -525,8 +570,107 @@ export function OutreachReviewWorkspace() {
) : null}
</Dialog>
<section className="space-y-3" aria-label="Review-Queue">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-semibold">Review-Queue</h2>
<div className="flex flex-wrap gap-2" aria-label="Review-Filter">
{reviewStatusFilters.map((filter) => (
<button
aria-pressed={activeFilter === filter.value}
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
>
{filter.label}
<Badge variant="secondary">{filter.count}</Badge>
</button>
))}
</div>
</div>
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
{filteredRows.map((record) => {
const lead = record.lead;
const audit = record.audit;
const outreach = record.latestOutreach;
const strategy = outreach?.strategy;
const publicAuditHref = audit?.slug ? `/audit/${audit.slug}` : null;
const queueTitleId = `review-queue-title-${record.id}`;
return (
<Card
aria-labelledby={queueTitleId}
className={cn(
"flex min-w-0 flex-col",
selectedRecord?.id === record.id ? "border-foreground" : "",
)}
key={record.id}
>
<CardHeader className="gap-3">
<div className="min-w-0 space-y-1">
<CardTitle className="break-words text-base" id={queueTitleId}>
{compactText(lead?.companyName, "Unbenannter Lead")}
</CardTitle>
<CardDescription className="break-all">
{compactText(lead?.websiteDomain ?? lead?.websiteUrl, "Keine Domain")}
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">{formatStrategy(strategy)}</Badge>
<Badge variant="outline">
{compactText(lead?.contactStatus, "Kontaktstatus offen")}
</Badge>
<Badge variant="outline">
{compactText(audit?.status, "Auditstatus offen")}
</Badge>
<Badge variant={isEmailDraftReady(record) ? "secondary" : "outline"}>
{isEmailDraftReady(record) ? "E-Mail bereit" : "Mail offen"}
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
<p className="line-clamp-2 text-sm text-muted-foreground">
{compactText(lead?.priorityReason, "Kein Prioritätsgrund hinterlegt.")}
</p>
<div className="mt-auto flex flex-wrap gap-2">
<Button
aria-pressed={selectedRecord?.id === record.id}
onClick={() => setSelectedRecordId(record.id)}
size="sm"
type="button"
>
Details prüfen
</Button>
{publicAuditHref ? (
<Button asChild size="sm" type="button" variant="outline">
<Link href={publicAuditHref}>
<ExternalLink className="size-3.5" aria-hidden="true" />
Public-Audit
</Link>
</Button>
) : null}
</div>
</CardContent>
</Card>
);
})}
{filteredRows.length === 0 ? (
<Card className="lg:col-span-2 xl:col-span-3">
<CardHeader>
<CardTitle>Keine Treffer</CardTitle>
<CardDescription>
Für diesen Review-Filter gibt es aktuell keine Einträge.
</CardDescription>
</CardHeader>
</Card>
) : null}
</div>
</section>
<div className="space-y-3">
{rows.map((record) => {
{selectedRecord ? (() => {
const record = selectedRecord;
const draft = drafts[record.id] ?? getDraft(record);
const lead = record.lead;
const audit = record.audit;
@@ -851,7 +995,7 @@ export function OutreachReviewWorkspace() {
</CardContent>
</Card>
);
})}
})() : null}
</div>
</section>
);

View File

@@ -1,7 +1,9 @@
import { ArrowRight, CheckCircle2, ExternalLink } from "lucide-react";
import { CheckCircle2 } from "lucide-react";
import type { PublicAuditRenderState } from "@/lib/audits/public-audit-types";
import { RybbitTracking } from "./rybbit-tracking";
import { PublicAuditScreenshot } from "./public-audit-screenshot";
import { TrackedPublicAuditLink } from "./tracked-public-audit-link";
type PublicAuditPageProps = {
audit: Extract<PublicAuditRenderState, { kind: "published" }>["audit"];
@@ -10,6 +12,7 @@ type PublicAuditPageProps = {
export function PublicAuditPage({ audit }: PublicAuditPageProps) {
return (
<main className="min-h-dvh bg-slate-50 text-slate-950">
<RybbitTracking domain={audit.domain} />
<section className="border-b border-slate-200 bg-white">
<div className="mx-auto grid min-h-[72dvh] w-full max-w-6xl content-center gap-10 px-6 py-14 md:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)] md:px-8">
<div className="max-w-3xl">
@@ -105,17 +108,11 @@ export function PublicAuditPage({ audit }: PublicAuditPageProps) {
</p>
</div>
{audit.finalOffer.ctaHref ? (
<a
<TrackedPublicAuditLink
domain={audit.domain}
href={audit.finalOffer.ctaHref}
className="mt-6 inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-slate-950 px-4 text-sm font-semibold text-white transition hover:bg-slate-800 md:mt-0"
>
{audit.finalOffer.ctaLabel ?? "Audit besprechen"}
{audit.finalOffer.ctaHref.startsWith("/") ? (
<ArrowRight className="h-4 w-4" aria-hidden />
) : (
<ExternalLink className="h-4 w-4" aria-hidden />
)}
</a>
label={audit.finalOffer.ctaLabel ?? "Audit besprechen"}
/>
) : null}
</div>
</section>

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,9 @@ import type * as auditGenerationAction from "../auditGenerationAction.js";
import type * as auditInputs from "../auditInputs.js";
import type * as audits from "../audits.js";
import type * as blacklist from "../blacklist.js";
import type * as campaignMetrics from "../campaignMetrics.js";
import type * as campaigns from "../campaigns.js";
import type * as crons from "../crons.js";
import type * as domain from "../domain.js";
import type * as http from "../http.js";
import type * as leadDiscovery from "../leadDiscovery.js";
@@ -23,8 +25,10 @@ import type * as outreachSendAction from "../outreachSendAction.js";
import type * as pageSpeed from "../pageSpeed.js";
import type * as pageSpeedAction from "../pageSpeedAction.js";
import type * as runs from "../runs.js";
import type * as scheduledJobs from "../scheduledJobs.js";
import type * as settings from "../settings.js";
import type * as storage from "../storage.js";
import type * as usageEvents from "../usageEvents.js";
import type * as websiteEnrichment from "../websiteEnrichment.js";
import type * as websiteEnrichmentAction from "../websiteEnrichmentAction.js";
@@ -40,7 +44,9 @@ declare const fullApi: ApiFromModules<{
auditInputs: typeof auditInputs;
audits: typeof audits;
blacklist: typeof blacklist;
campaignMetrics: typeof campaignMetrics;
campaigns: typeof campaigns;
crons: typeof crons;
domain: typeof domain;
http: typeof http;
leadDiscovery: typeof leadDiscovery;
@@ -50,8 +56,10 @@ declare const fullApi: ApiFromModules<{
pageSpeed: typeof pageSpeed;
pageSpeedAction: typeof pageSpeedAction;
runs: typeof runs;
scheduledJobs: typeof scheduledJobs;
settings: typeof settings;
storage: typeof storage;
usageEvents: typeof usageEvents;
websiteEnrichment: typeof websiteEnrichment;
websiteEnrichmentAction: typeof websiteEnrichmentAction;
}>;

View File

@@ -38,6 +38,19 @@ const auditGenerationParsedJson = v.union(
v.string(),
v.record(v.string(), auditGenerationParsedValue),
);
const auditFindingEvidenceRef = v.object({
id: v.string(),
type: v.union(
v.literal("crawl_page"),
v.literal("technical_check"),
v.literal("screenshot"),
v.literal("pagespeed"),
v.literal("jina_excerpt"),
v.literal("generation_stage"),
),
label: v.string(),
sourceUrl: v.optional(v.string()),
});
type AuditGenerationLead = Pick<
Doc<"leads">,
@@ -89,6 +102,7 @@ type AuditGenerationEvidence = {
technicalChecks: AuditGenerationEvidenceTechnicalCheck[];
screenshots: AuditGenerationEvidenceScreenshot[];
pageSpeedInputs: PageSpeedMinimalAuditResult[];
externalMarkdown?: string;
};
function byteLength(value: string) {
@@ -199,6 +213,8 @@ const secretHints = [
"SMTP_USER",
"BETTER_AUTH_SECRET",
"RYBBIT_API_KEY",
"SCREENSHOTONE_API_KEY",
"JINA_API_KEY",
];
function sanitizeSecretCandidates(value: string | undefined): string | undefined {
@@ -226,7 +242,7 @@ function sanitizeSecretCandidates(value: string | undefined): string | undefined
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&");
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
type StartLeadSnapshot = Pick<
@@ -249,30 +265,47 @@ export const getAuditGenerationEvidence = internalQuery({
return null;
}
const runIdFilter = {
table: "by_runId" as const,
value: args.runId,
};
const leadIdFilter = {
table: "by_leadId" as const,
value: lead._id,
};
const latestSuccessfulEnrichmentRun = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q
.eq("type", "website_enrichment")
.eq("status", "succeeded")
.eq("leadId", lead._id),
)
.order("desc")
.take(1);
const enrichmentEvidenceRunId =
latestSuccessfulEnrichmentRun[0]?._id ?? args.runId;
const crawlPagesByRun = await ctx.db
.query("websiteCrawlPages")
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
.withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId))
.order("desc")
.take(40);
const technicalChecksByRun = await ctx.db
.query("websiteTechnicalChecks")
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
.withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId))
.order("desc")
.take(80);
const screenshotsByRun = await ctx.db
const auditCaptureScreenshotsByRun = await ctx.db
.query("websiteCrawlScreenshots")
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
.withIndex("by_runId", (q) => q.eq("runId", args.runId))
.order("desc")
.take(20);
const enrichmentScreenshotsByRun =
enrichmentEvidenceRunId === args.runId
? []
: await ctx.db
.query("websiteCrawlScreenshots")
.withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId))
.order("desc")
.take(20);
@@ -290,7 +323,7 @@ export const getAuditGenerationEvidence = internalQuery({
const crawlPages = crawlPagesByRun;
const technicalChecks = technicalChecksByRun;
const screenshots = screenshotsByRun;
const screenshots = [...auditCaptureScreenshotsByRun, ...enrichmentScreenshotsByRun];
return {
lead: {
@@ -549,6 +582,86 @@ export const persistAuditGenerationResult = internalMutation({
},
});
export const replaceAuditFindings = internalMutation({
args: {
auditId: v.id("audits"),
runId: v.id("agentRuns"),
findings: v.array(
v.object({
skillId: v.string(),
claim: v.string(),
recommendation: v.string(),
customerBenefit: v.string(),
severity: v.union(v.literal(1), v.literal(2), v.literal(3)),
confidence: v.number(),
evidenceRefs: v.array(auditFindingEvidenceRef),
reviewStatus: v.union(
v.literal("pending"),
v.literal("accepted"),
v.literal("rejected"),
),
}),
),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("auditFindings")
.withIndex("by_auditId", (q) => q.eq("auditId", args.auditId))
.collect();
for (const finding of existing) {
await ctx.db.delete(finding._id);
}
const now = Date.now();
for (const finding of args.findings) {
await ctx.db.insert("auditFindings", {
auditId: args.auditId,
runId: args.runId,
skillId: finding.skillId,
claim: finding.claim,
recommendation: finding.recommendation,
customerBenefit: finding.customerBenefit,
severity: finding.severity,
confidence: finding.confidence,
evidenceRefs: finding.evidenceRefs,
reviewStatus: finding.reviewStatus,
createdAt: now,
updatedAt: now,
});
}
},
});
export const persistExternalCaptureScreenshot = internalMutation({
args: {
leadId: v.id("leads"),
runId: v.id("agentRuns"),
storageId: v.id("_storage"),
viewport: v.union(v.literal("desktop"), v.literal("mobile")),
sourceUrl: v.string(),
capturedAt: v.number(),
width: v.number(),
height: v.number(),
mimeType: v.string(),
},
returns: v.id("websiteCrawlScreenshots"),
handler: async (ctx, args): Promise<Id<"websiteCrawlScreenshots">> => {
return await ctx.db.insert("websiteCrawlScreenshots", {
leadId: args.leadId,
runId: args.runId,
storageId: args.storageId,
viewport: args.viewport,
sourceUrl: args.sourceUrl,
capturedAt: args.capturedAt,
width: args.width,
height: args.height,
mimeType: args.mimeType,
createdAt: Date.now(),
});
},
});
export const finishAuditGenerationRun = internalMutation({
args: {
runId: v.id("agentRuns"),

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,11 @@ 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;
const DETAIL_EVIDENCE_LIMIT = 50;
const auditStatus = v.union(
v.literal("draft"),
@@ -14,8 +16,9 @@ const auditStatus = v.union(
);
const usedSkillsValidator = v.array(
v.object({
id: v.optional(v.string()),
name: v.string(),
category: v.string(),
category: v.optional(v.string()),
version: v.optional(v.string()),
source: v.optional(v.string()),
}),
@@ -40,13 +43,134 @@ const publicOfferValidator = v.object({
ctaHref: v.optional(v.string()),
});
const requireOperator = async (ctx: MutationCtx) => {
type AuditDashboardRow =
| {
kind: "audit";
id: Id<"audits">;
auditId: Id<"audits">;
slug: string;
title: string;
checkedDomain: string;
status: Doc<"audits">["status"];
pageCount: number;
checkedPages: string[];
detailHref: string;
createdAt: number;
updatedAt: number;
}
| {
kind: "generation";
id: Id<"agentRuns">;
runId: Id<"agentRuns">;
leadId: Id<"leads"> | null;
title: string;
checkedDomain: string;
status: Doc<"agentRuns">["status"];
latestStage: string;
stageStatus: Doc<"agentRuns">["status"];
errorSummary: string | null;
pageCount: number;
checkedPages: string[];
createdAt: number;
updatedAt: number;
};
const requireOperator = async (ctx: MutationCtx | QueryCtx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Nicht autorisiert.");
}
};
const domainFromLead = (
lead: Pick<Doc<"leads">, "companyName" | "websiteDomain" | "websiteUrl"> | null,
) => {
if (lead?.websiteDomain) {
return lead.websiteDomain;
}
if (lead?.websiteUrl) {
try {
return new URL(lead.websiteUrl).hostname;
} catch {
return lead.websiteUrl;
}
}
return "unbekannte-domain";
};
const latestGenerationStage = (stages: Doc<"auditGenerations">[]) => {
return [...stages].sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
};
const normalizeComparableAuditUrl = (value: string | null | undefined) => {
const trimmed = value?.trim();
if (!trimmed) {
return "";
}
const normalizeParsedUrl = (parsedUrl: URL) => {
const hostname = parsedUrl.hostname.toLowerCase().replace(/^www\./, "");
const pathname = parsedUrl.pathname.replace(/\/+$/, "");
return `${hostname}${pathname}${parsedUrl.search}`.toLowerCase();
};
try {
return normalizeParsedUrl(new URL(trimmed));
} catch {
try {
return normalizeParsedUrl(new URL(`https://${trimmed}`));
} catch {
return trimmed
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/\/+$/, "");
}
}
};
const setIfPresent = <T>(
target: Map<string, T>,
url: string | null | undefined,
value: T,
) => {
const key = normalizeComparableAuditUrl(url);
if (key && !target.has(key)) {
target.set(key, value);
}
};
const findByUrl = <T>(source: Map<string, T>, ...urls: Array<string | null | undefined>) => {
for (const url of urls) {
const key = normalizeComparableAuditUrl(url);
if (key && source.has(key)) {
return source.get(key) ?? null;
}
}
return null;
};
const fallbackCheckedPageEvidence = (url: string) => ({
url,
sourceUrl: null,
finalUrl: null,
pageKind: null,
title: null,
metaDescription: null,
headings: [],
visibleTextExcerpt: null,
hasContactFormSignal: null,
hasContactCtaSignal: null,
usesHttps: null,
missingMetaDescription: null,
brokenInternalLinkCount: null,
screenshots: [],
createdAt: null,
});
const toIsoDate = (timestamp: number | undefined, fallback: number) => {
return new Date(timestamp ?? fallback).toISOString();
};
@@ -124,6 +248,8 @@ export const create = mutation({
ctaType: v.optional(v.string()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const now = Date.now();
const existing = await ctx.db
.query("audits")
@@ -146,19 +272,149 @@ export const create = mutation({
export const getDetail = query({
args: { id: v.id("audits") },
handler: async (ctx, args) => {
await requireOperator(ctx);
const audit = await ctx.db.get(args.id);
if (!audit) {
return null;
}
const lead = await ctx.db.get(audit.leadId);
return { audit, lead };
const latestSuccessfulEnrichmentRun = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q
.eq("type", "website_enrichment")
.eq("status", "succeeded")
.eq("leadId", audit.leadId),
)
.order("desc")
.take(1);
const enrichmentRunId = latestSuccessfulEnrichmentRun[0]?._id ?? null;
const crawlPages = enrichmentRunId
? await ctx.db
.query("websiteCrawlPages")
.withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId))
.order("desc")
.take(DETAIL_EVIDENCE_LIMIT)
: [];
const technicalChecks = enrichmentRunId
? await ctx.db
.query("websiteTechnicalChecks")
.withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId))
.order("desc")
.take(DETAIL_EVIDENCE_LIMIT)
: [];
const crawlScreenshots = enrichmentRunId
? await ctx.db
.query("websiteCrawlScreenshots")
.withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId))
.order("desc")
.take(DETAIL_EVIDENCE_LIMIT)
: [];
const findings = await ctx.db
.query("auditFindings")
.withIndex("by_auditId", (q) => q.eq("auditId", audit._id))
.order("desc")
.take(DETAIL_EVIDENCE_LIMIT);
const pagesByUrl = new Map<string, Doc<"websiteCrawlPages">>();
for (const page of crawlPages) {
setIfPresent(pagesByUrl, page.sourceUrl, page);
setIfPresent(pagesByUrl, page.finalUrl, page);
}
const checksByUrl = new Map<string, Doc<"websiteTechnicalChecks">>();
for (const checks of technicalChecks) {
setIfPresent(checksByUrl, checks.sourceUrl, checks);
setIfPresent(checksByUrl, checks.finalUrl, checks);
}
const screenshotsByUrl = new Map<
string,
Array<{
id: Id<"_storage">;
url: string;
viewport: Doc<"websiteCrawlScreenshots">["viewport"];
sourceUrl: string;
width: number;
height: number;
createdAt: number;
}>
>();
for (const screenshot of crawlScreenshots) {
const url = await ctx.storage.getUrl(screenshot.storageId);
if (!url) {
continue;
}
const key = normalizeComparableAuditUrl(screenshot.sourceUrl);
if (!key) {
continue;
}
const current = screenshotsByUrl.get(key) ?? [];
current.push({
id: screenshot.storageId,
url,
viewport: screenshot.viewport,
sourceUrl: screenshot.sourceUrl,
width: screenshot.width,
height: screenshot.height,
createdAt: screenshot.createdAt,
});
screenshotsByUrl.set(key, current);
}
const checkedPages = audit.checkedPages.map((checkedUrl) => {
const page = findByUrl(pagesByUrl, checkedUrl);
if (!page) {
return fallbackCheckedPageEvidence(checkedUrl);
}
const checks = findByUrl(checksByUrl, checkedUrl, page.sourceUrl, page.finalUrl);
const screenshots = [
...(
findByUrl(screenshotsByUrl, checkedUrl, page.sourceUrl, page.finalUrl) ?? []
),
].sort((a, b) => b.createdAt - a.createdAt);
return {
url: checkedUrl,
sourceUrl: page.sourceUrl,
finalUrl: page.finalUrl,
pageKind: page.pageKind,
title: page.title ?? null,
metaDescription: page.metaDescription ?? null,
headings: page.headings.slice(0, DETAIL_EVIDENCE_LIMIT),
visibleTextExcerpt: page.visibleTextExcerpt ?? null,
hasContactFormSignal: page.hasContactFormSignal,
hasContactCtaSignal: page.hasContactCtaSignal,
usesHttps: checks?.usesHttps ?? null,
missingMetaDescription: checks?.missingMetaDescription ?? null,
brokenInternalLinkCount: checks?.brokenInternalLinkCount ?? null,
screenshots,
createdAt: page.createdAt,
};
});
return {
audit,
lead,
findings,
sourceSummaries: {
checkedPages,
},
};
},
});
export const get = query({
args: { id: v.id("audits") },
handler: async (ctx, args) => {
await requireOperator(ctx);
return await ctx.db.get(args.id);
},
});
@@ -247,6 +503,8 @@ export const upsertFromAuditGeneration = internalMutation({
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
await requireOperator(ctx);
const audits = await ctx.db
.query("audits")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
@@ -441,6 +699,8 @@ export const list = query({
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const limit = normalizeListLimit(args.limit);
if (args.leadId) {
@@ -466,3 +726,105 @@ export const list = query({
return await ctx.db.query("audits").order("desc").take(limit);
},
});
export const listDashboardRows = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx: QueryCtx, args): Promise<AuditDashboardRow[]> => {
await requireOperator(ctx);
const limit = normalizeListLimit(args.limit);
const audits = await ctx.db.query("audits").order("desc").take(limit);
const finalAuditLeadIds = new Set<string>();
const finalAuditRunIds = new Set<string>();
const finalAuditIds = new Set<string>();
const rows: AuditDashboardRow[] = audits.map((audit) => {
finalAuditLeadIds.add(audit.leadId);
finalAuditIds.add(audit._id);
return {
kind: "audit",
id: audit._id,
auditId: audit._id,
slug: audit.slug,
title: audit.slug,
checkedDomain: audit.checkedDomain,
status: audit.status,
pageCount: audit.checkedPages.length,
checkedPages: audit.checkedPages,
detailHref: `/dashboard/audits/${audit._id}`,
createdAt: audit.createdAt,
updatedAt: audit.updatedAt,
};
});
for (const audit of audits) {
const linkedGenerations = await ctx.db
.query("auditGenerations")
.withIndex("by_auditId", (q) => q.eq("auditId", audit._id))
.take(20);
for (const generation of linkedGenerations) {
finalAuditRunIds.add(generation.runId);
}
}
const generationRuns = await ctx.db
.query("agentRuns")
.withIndex("by_type", (q) => q.eq("type", "audit_generation"))
.order("desc")
.take(limit);
for (const run of generationRuns) {
if (!run.leadId) {
continue;
}
const directFinalAudit = run.auditId ? await ctx.db.get(run.auditId) : null;
const leadFinalAudits = await ctx.db
.query("audits")
.withIndex("by_leadId", (q) => q.eq("leadId", run.leadId as Id<"leads">))
.take(1);
if (
finalAuditRunIds.has(run._id) ||
(run.auditId && finalAuditIds.has(run.auditId)) ||
directFinalAudit ||
finalAuditLeadIds.has(run.leadId) ||
leadFinalAudits.length > 0
) {
continue;
}
const stages = await ctx.db
.query("auditGenerations")
.withIndex("by_runId", (q) => q.eq("runId", run._id))
.order("desc")
.take(20);
const latestStage = latestGenerationStage(stages);
const lead = await ctx.db.get(run.leadId);
const checkedDomain = domainFromLead(lead);
rows.push({
kind: "generation",
id: run._id,
runId: run._id,
leadId: run.leadId,
title: lead?.companyName ?? checkedDomain,
checkedDomain,
status: run.status,
latestStage: latestStage?.stage ?? run.currentStep ?? "audit_generation",
stageStatus: latestStage?.status ?? run.status,
errorSummary: run.errorSummary ?? latestStage?.errorSummary ?? null,
pageCount: 0,
checkedPages: [],
createdAt: run.createdAt,
updatedAt: Math.max(run.updatedAt, latestStage?.updatedAt ?? 0),
});
}
return rows.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit);
},
});

148
convex/campaignMetrics.ts Normal file
View File

@@ -0,0 +1,148 @@
import { v } from "convex/values";
import { normalizeListLimit } from "./domain";
import { query } from "./_generated/server";
const priority = v.union(
v.literal("high"),
v.literal("medium"),
v.literal("low"),
v.literal("defer"),
v.literal("blocked"),
);
const leadStatus = v.union(
v.literal("new"),
v.literal("missing_contact"),
v.literal("audit_ready"),
v.literal("outreach_ready"),
v.literal("contacted"),
v.literal("replied"),
v.literal("do_not_contact"),
);
export const getDashboard = query({
args: {
campaignId: v.optional(v.id("campaigns")),
niche: v.optional(v.string()),
postalCode: v.optional(v.string()),
radiusKm: v.optional(v.number()),
priority: v.optional(priority),
status: v.optional(leadStatus),
from: v.optional(v.number()),
to: v.optional(v.number()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = normalizeListLimit(args.limit);
const campaigns = await ctx.db.query("campaigns").order("desc").take(100);
const leads = await ctx.db.query("leads").order("desc").take(500);
const audits = await ctx.db.query("audits").order("desc").take(500);
const outreach = await ctx.db.query("outreachRecords").order("desc").take(500);
const runs = await ctx.db
.query("agentRuns")
.withIndex("by_type", (q) => q.eq("type", "campaign"))
.order("desc")
.take(100);
const filteredLeads = leads.filter((lead) => {
const campaign = lead.campaignId
? campaigns.find((row) => row._id === lead.campaignId)
: null;
if (args.campaignId && lead.campaignId !== args.campaignId) {
return false;
}
if (args.niche && lead.niche !== args.niche) {
return false;
}
if (args.postalCode && lead.postalCode !== args.postalCode) {
return false;
}
if (args.radiusKm && campaign?.radiusKm !== args.radiusKm) {
return false;
}
if (args.priority && lead.priority !== args.priority) {
return false;
}
if (args.status && lead.contactStatus !== args.status) {
return false;
}
if (args.from && lead.createdAt < args.from) {
return false;
}
if (args.to && lead.createdAt > args.to) {
return false;
}
return true;
});
const leadIds = new Set(filteredLeads.map((lead) => lead._id));
const filteredAudits = audits.filter((audit) => leadIds.has(audit.leadId));
const filteredOutreach = outreach.filter((row) => leadIds.has(row.leadId));
const runRows = runs.slice(0, limit).map((run) => ({
id: run._id,
campaignId: run.campaignId ?? null,
status: run.status,
newLeads: run.counters?.leadsCreated ?? 0,
skippedDuplicates: 0,
skippedBlacklisted: 0,
errors: run.counters?.errors ?? 0,
auditsGenerated: run.counters?.auditsCreated ?? 0,
updatedAt: run.updatedAt,
errorSummary: run.errorSummary ?? null,
}));
return {
filters: {
campaigns: campaigns.map((campaign) => ({
id: campaign._id,
name: campaign.name,
})),
niches: [...new Set(leads.map((lead) => lead.niche).filter(Boolean))].sort(),
postalCodes: [...new Set(leads.map((lead) => lead.postalCode).filter(Boolean))].sort(),
},
auditSegments: filteredAudits.map((audit) => {
const lead = leads.find((row) => row._id === audit.leadId);
const campaign = lead?.campaignId
? campaigns.find((row) => row._id === lead.campaignId)
: null;
return {
path: `/audit/${audit.slug}`,
campaignId: lead?.campaignId ?? null,
campaignName: campaign?.name ?? "Ohne Kampagne",
niche: lead?.niche ?? "Nische offen",
region: campaign?.region ?? lead?.postalCode ?? "Region offen",
};
}),
metrics: {
foundLeads: filteredLeads.length,
leadsWithContact: filteredLeads.filter((lead) => Boolean(lead.email || lead.phone)).length,
missingContact: filteredLeads.filter((lead) => lead.contactStatus === "missing_contact").length,
auditsCreated: filteredAudits.length,
approvalsOpen: filteredOutreach.filter((row) => row.approvalStatus === "draft").length,
emailsSent: filteredOutreach.filter((row) => row.sendStatus === "sent").length,
followUpsPlanned: filteredOutreach.filter((row) => row.salesStatus === "follow_up_planned").length,
followUpsSent: filteredOutreach.filter((row) => row.salesStatus === "follow_up_sent").length,
responses: filteredOutreach.filter((row) => row.salesStatus === "reply_received").length,
conversations: filteredOutreach.filter((row) =>
row.salesStatus === "meeting_scheduled" ||
row.salesStatus === "proposal_requested" ||
row.salesStatus === "proposal_sent" ||
row.salesStatus === "won",
).length,
offers: filteredOutreach.filter((row) =>
row.salesStatus === "proposal_requested" ||
row.salesStatus === "proposal_sent",
).length,
wins: filteredOutreach.filter((row) => row.salesStatus === "won").length,
losses: filteredOutreach.filter((row) => row.salesStatus === "lost").length,
skippedDuplicates: runRows.reduce((total, run) => total + run.skippedDuplicates, 0),
skippedBlacklisted: runRows.reduce((total, run) => total + run.skippedBlacklisted, 0),
rybbitAuditOpens: 0,
rybbitCtaClicks: 0,
},
runs: runRows,
};
},
});

View File

@@ -5,13 +5,13 @@ import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval(
"Kampagnen nach Cadence starten",
"campaign cadence runner",
{ hours: 1 },
internal.scheduledJobs.runDueCampaigns,
);
crons.interval(
"Audit-Lifecycle prüfen",
"audit lifecycle runner",
{ hours: 24 },
internal.scheduledJobs.runAuditLifecycle,
);

View File

@@ -96,6 +96,12 @@ export const RUN_STATUSES = [
] as const;
export const AUDIT_GENERATION_STAGES = [
"classification",
"localSeoSpecialist",
"conversionUxSpecialist",
"visualTrustSpecialist",
"critiqueSpecialist",
"performanceAccessibilitySpecialist",
"evidenceVerifier",
"multimodalAudit",
"germanCopy",
"qualityReview",
@@ -119,6 +125,18 @@ export const PAGE_SPEED_ERROR_TYPES = [
"api_error",
"unknown",
] as const;
export const USAGE_EVENT_PROVIDERS = [
"openrouter",
"screenshotone",
"jina",
"pagespeed",
"google_places",
] as const;
export const USAGE_EVENT_OPERATIONS = [
"audit_capture",
"audit_generation",
"lead_lookup",
] as const;
export type CampaignStatus = (typeof CAMPAIGN_STATUSES)[number];
export type LeadPriority = (typeof LEAD_PRIORITIES)[number];
@@ -143,6 +161,8 @@ export type ScreenshotViewport = (typeof SCREENSHOT_VIEWPORTS)[number];
export type PageSpeedStrategy = (typeof PAGE_SPEED_STRATEGIES)[number];
export type PageSpeedResultStatus = (typeof PAGE_SPEED_RESULT_STATUSES)[number];
export type PageSpeedErrorType = (typeof PAGE_SPEED_ERROR_TYPES)[number];
export type UsageEventProvider = (typeof USAGE_EVENT_PROVIDERS)[number];
export type UsageEventOperation = (typeof USAGE_EVENT_OPERATIONS)[number];
export type SettingsRow = {
key: string;

View File

@@ -3,7 +3,13 @@ import { v } from "convex/values";
import { getUsableContactEmailFromEntries } from "../lib/lead-discovery-google";
import { normalizeListLimit } from "./domain";
import type { Doc, Id } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server";
import {
internalMutation,
internalQuery,
mutation,
query,
} from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
type LeadDoc = Doc<"leads">;
@@ -37,6 +43,74 @@ type LeadReviewPatch = {
contactPerson?: string;
};
type LeadReviewUpdateArgs = {
id: Id<"leads">;
priority?: LeadDoc["priority"];
priorityReason?: string;
contactStatus?: LeadDoc["contactStatus"];
contactStatusReason?: string;
notes?: string;
duplicateStatus?: LeadDoc["duplicateStatus"];
duplicateReason?: string;
blacklistStatus?: LeadDoc["blacklistStatus"];
blacklistReason?: string;
duplicateOfLeadId?: Id<"leads">;
applyBlacklist?: boolean;
reviewEmail?: string;
reviewEmailSource?: string;
reviewContactPerson?: string;
reviewIsBusinessContactAddress?: boolean;
};
const leadPriority = v.union(
v.literal("high"),
v.literal("medium"),
v.literal("low"),
v.literal("defer"),
v.literal("blocked"),
);
const leadContactStatus = v.union(
v.literal("new"),
v.literal("missing_contact"),
v.literal("audit_ready"),
v.literal("outreach_ready"),
v.literal("contacted"),
v.literal("replied"),
v.literal("do_not_contact"),
);
const leadDuplicateStatus = v.union(
v.literal("unchecked"),
v.literal("unique"),
v.literal("possible_duplicate"),
v.literal("duplicate"),
);
const leadBlacklistStatus = v.union(v.literal("clear"), v.literal("blocked"));
const reviewUpdateArgs = {
id: v.id("leads"),
priority: v.optional(leadPriority),
priorityReason: v.optional(v.string()),
contactStatus: v.optional(leadContactStatus),
contactStatusReason: v.optional(v.string()),
notes: v.optional(v.string()),
duplicateStatus: v.optional(leadDuplicateStatus),
duplicateReason: v.optional(v.string()),
blacklistStatus: v.optional(leadBlacklistStatus),
blacklistReason: v.optional(v.string()),
duplicateOfLeadId: v.optional(v.id("leads")),
applyBlacklist: v.optional(v.boolean()),
reviewEmail: v.optional(v.string()),
reviewEmailSource: v.optional(v.string()),
reviewContactPerson: v.optional(v.string()),
reviewIsBusinessContactAddress: v.optional(v.boolean()),
};
const requireOperator = async (ctx: MutationCtx | QueryCtx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Nicht autorisiert.");
}
};
function buildReviewContactPatch(args: {
email?: string;
emailSource?: string;
@@ -88,136 +162,7 @@ function buildReviewContactPatch(args: {
});
}
export const create = mutation({
args: {
campaignId: v.optional(v.id("campaigns")),
discoveryRunId: v.optional(v.id("agentRuns")),
companyName: v.string(),
niche: v.optional(v.string()),
address: v.optional(v.string()),
city: v.optional(v.string()),
postalCode: v.optional(v.string()),
googlePlaceId: v.optional(v.string()),
googleMapsUrl: v.optional(v.string()),
googlePrimaryType: v.optional(v.string()),
googleTypes: v.optional(v.array(v.string())),
googleRating: v.optional(v.number()),
googleUserRatingCount: v.optional(v.number()),
googleBusinessStatus: v.optional(v.string()),
sourceProvider: v.optional(v.literal("google_places")),
sourceFetchedAt: v.optional(v.number()),
websiteUrl: v.optional(v.string()),
websiteDomain: v.optional(v.string()),
phone: v.optional(v.string()),
normalizedEmail: v.optional(v.string()),
normalizedPhone: v.optional(v.string()),
normalizedCompanyName: v.optional(v.string()),
normalizedAddress: v.optional(v.string()),
email: v.optional(v.string()),
emailSource: v.optional(v.string()),
contactPerson: v.optional(v.string()),
priority: v.optional(
v.union(
v.literal("high"),
v.literal("medium"),
v.literal("low"),
v.literal("defer"),
v.literal("blocked"),
),
),
priorityReason: v.optional(v.string()),
contactStatus: v.optional(
v.union(
v.literal("new"),
v.literal("missing_contact"),
v.literal("audit_ready"),
v.literal("outreach_ready"),
v.literal("contacted"),
v.literal("replied"),
v.literal("do_not_contact"),
),
),
contactStatusReason: v.optional(v.string()),
duplicateStatus: v.optional(
v.union(
v.literal("unchecked"),
v.literal("unique"),
v.literal("possible_duplicate"),
v.literal("duplicate"),
),
),
duplicateReason: v.optional(v.string()),
blacklistReason: v.optional(v.string()),
duplicateOfLeadId: v.optional(v.id("leads")),
blacklistStatus: v.optional(v.union(v.literal("clear"), v.literal("blocked"))),
normalizedGooglePlaceId: v.optional(v.string()),
notes: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now();
return await ctx.db.insert("leads", {
...args,
normalizedEmail: args.normalizedEmail,
normalizedPhone: args.normalizedPhone,
normalizedCompanyName: args.normalizedCompanyName,
normalizedAddress: args.normalizedAddress,
normalizedGooglePlaceId: args.normalizedGooglePlaceId,
priority: args.priority ?? "medium",
contactStatus: args.contactStatus ?? "new",
duplicateStatus: args.duplicateStatus ?? "unchecked",
blacklistStatus: args.blacklistStatus ?? "clear",
createdAt: now,
updatedAt: now,
});
},
});
export const reviewUpdate = mutation({
args: {
id: v.id("leads"),
priority: v.optional(
v.union(
v.literal("high"),
v.literal("medium"),
v.literal("low"),
v.literal("defer"),
v.literal("blocked"),
),
),
priorityReason: v.optional(v.string()),
contactStatus: v.optional(
v.union(
v.literal("new"),
v.literal("missing_contact"),
v.literal("audit_ready"),
v.literal("outreach_ready"),
v.literal("contacted"),
v.literal("replied"),
v.literal("do_not_contact"),
),
),
contactStatusReason: v.optional(v.string()),
notes: v.optional(v.string()),
duplicateStatus: v.optional(
v.union(
v.literal("unchecked"),
v.literal("unique"),
v.literal("possible_duplicate"),
v.literal("duplicate"),
),
),
duplicateReason: v.optional(v.string()),
blacklistStatus: v.optional(v.union(v.literal("clear"), v.literal("blocked"))),
blacklistReason: v.optional(v.string()),
duplicateOfLeadId: v.optional(v.id("leads")),
applyBlacklist: v.optional(v.boolean()),
reviewEmail: v.optional(v.string()),
reviewEmailSource: v.optional(v.string()),
reviewContactPerson: v.optional(v.string()),
reviewIsBusinessContactAddress: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
async function reviewUpdateLead(ctx: MutationCtx, args: LeadReviewUpdateArgs) {
const lead = await ctx.db.get(args.id);
if (!lead) {
@@ -300,10 +245,93 @@ export const reviewUpdate = mutation({
await ctx.db.patch(args.id, patch);
return args.id;
}
export const create = mutation({
args: {
campaignId: v.optional(v.id("campaigns")),
discoveryRunId: v.optional(v.id("agentRuns")),
companyName: v.string(),
niche: v.optional(v.string()),
address: v.optional(v.string()),
city: v.optional(v.string()),
postalCode: v.optional(v.string()),
googlePlaceId: v.optional(v.string()),
googleMapsUrl: v.optional(v.string()),
googlePrimaryType: v.optional(v.string()),
googleTypes: v.optional(v.array(v.string())),
googleRating: v.optional(v.number()),
googleUserRatingCount: v.optional(v.number()),
googleBusinessStatus: v.optional(v.string()),
sourceProvider: v.optional(v.literal("google_places")),
sourceFetchedAt: v.optional(v.number()),
websiteUrl: v.optional(v.string()),
websiteDomain: v.optional(v.string()),
phone: v.optional(v.string()),
normalizedEmail: v.optional(v.string()),
normalizedPhone: v.optional(v.string()),
normalizedCompanyName: v.optional(v.string()),
normalizedAddress: v.optional(v.string()),
email: v.optional(v.string()),
emailSource: v.optional(v.string()),
contactPerson: v.optional(v.string()),
priority: v.optional(leadPriority),
priorityReason: v.optional(v.string()),
contactStatus: v.optional(leadContactStatus),
contactStatusReason: v.optional(v.string()),
duplicateStatus: v.optional(leadDuplicateStatus),
duplicateReason: v.optional(v.string()),
blacklistReason: v.optional(v.string()),
duplicateOfLeadId: v.optional(v.id("leads")),
blacklistStatus: v.optional(leadBlacklistStatus),
normalizedGooglePlaceId: v.optional(v.string()),
notes: v.optional(v.string()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const now = Date.now();
return await ctx.db.insert("leads", {
...args,
normalizedEmail: args.normalizedEmail,
normalizedPhone: args.normalizedPhone,
normalizedCompanyName: args.normalizedCompanyName,
normalizedAddress: args.normalizedAddress,
normalizedGooglePlaceId: args.normalizedGooglePlaceId,
priority: args.priority ?? "medium",
contactStatus: args.contactStatus ?? "new",
duplicateStatus: args.duplicateStatus ?? "unchecked",
blacklistStatus: args.blacklistStatus ?? "clear",
createdAt: now,
updatedAt: now,
});
},
});
export const reviewUpdate = mutation({
args: reviewUpdateArgs,
handler: async (ctx, args) => {
await requireOperator(ctx);
return await reviewUpdateLead(ctx, args);
},
});
export const reviewUpdateInternal = internalMutation({
args: reviewUpdateArgs,
handler: async (ctx, args) => {
return await reviewUpdateLead(ctx, args);
},
});
export const get = query({
args: { id: v.id("leads") },
handler: async (ctx, args) => {
await requireOperator(ctx);
return await ctx.db.get(args.id);
},
});
export const getInternal = internalQuery({
args: { id: v.id("leads") },
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
@@ -313,20 +341,11 @@ export const get = query({
export const list = query({
args: {
campaignId: v.optional(v.id("campaigns")),
contactStatus: v.optional(
v.union(
v.literal("new"),
v.literal("missing_contact"),
v.literal("audit_ready"),
v.literal("outreach_ready"),
v.literal("contacted"),
v.literal("replied"),
v.literal("do_not_contact"),
),
),
contactStatus: v.optional(leadContactStatus),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const limit = normalizeListLimit(args.limit);
if (args.campaignId) {
@@ -360,6 +379,7 @@ export const listFunnel = query({
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const limit = normalizeListLimit(args.limit);
const leads = await ctx.db.query("leads").order("desc").take(limit);
@@ -392,6 +412,7 @@ export const listFunnel = query({
sendStatus: latestOutreach.sendStatus,
responseStatus: latestOutreach.responseStatus,
salesStatus: latestOutreach.salesStatus,
doNotContactUntil: latestOutreach.doNotContactUntil ?? null,
}
: null,
};

View File

@@ -1,6 +1,6 @@
"use node";
import { api, internal } from "./_generated/api";
import { internal } from "./_generated/api";
import { internalAction } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import type { ActionCtx } from "./_generated/server";
@@ -122,7 +122,7 @@ async function queueAuditGenerationAfterPageSpeed(
parentRunId: runId,
});
} catch (auditQueueError) {
await ctx.runMutation(api.runs.appendEvent, {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "warning",
message: "Audit-Generierung konnte nicht in die Warteschlange gesetzt werden.",
@@ -164,7 +164,7 @@ export const processPageSpeedAudit = internalAction({
errorSummary,
});
await ctx.runMutation(api.runs.appendEvent, {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId: args.runId,
level: "error",
message: "PageSpeed-Analyse fehlgeschlagen.",
@@ -210,7 +210,7 @@ export const processPageSpeedAudit = internalAction({
fetchedAt,
});
await ctx.runMutation(api.runs.appendEvent, {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId: args.runId,
level: "warning",
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
@@ -248,7 +248,7 @@ export const processPageSpeedAudit = internalAction({
normalized: toPersistedPageSpeedNormalizedResult(normalized),
});
await ctx.runMutation(api.runs.appendEvent, {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId: args.runId,
level: "info",
message: `PageSpeed-Analyse für ${strategy} abgeschlossen.`,
@@ -274,7 +274,7 @@ export const processPageSpeedAudit = internalAction({
fetchedAt,
});
await ctx.runMutation(api.runs.appendEvent, {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId: args.runId,
level: "warning",
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
@@ -310,7 +310,7 @@ export const processPageSpeedAudit = internalAction({
errorSummary,
});
await ctx.runMutation(api.runs.appendEvent, {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId: args.runId,
level: "error",
message: "PageSpeed-Analyse fehlgeschlagen.",

View File

@@ -6,13 +6,53 @@ import {
RUN_TYPES,
normalizeListLimit,
} from "./domain";
import { mutation, query } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import { internalMutation, mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
const runType = v.union(...RUN_TYPES.map((type) => v.literal(type)));
const runStatus = v.union(...RUN_STATUSES.map((status) => v.literal(status)));
const eventLevel = v.union(
...RUN_EVENT_LEVELS.map((level) => v.literal(level)),
);
const appendEventArgs = {
runId: v.id("agentRuns"),
level: eventLevel,
message: v.string(),
details: v.optional(
v.array(
v.object({
label: v.string(),
value: v.string(),
source: v.optional(v.string()),
}),
),
),
};
type AppendEventArgs = {
runId: Id<"agentRuns">;
level: (typeof RUN_EVENT_LEVELS)[number];
message: string;
details?: { label: string; value: string; source?: string }[];
};
const requireOperator = async (ctx: MutationCtx | QueryCtx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Nicht autorisiert.");
}
};
async function appendRunEvent(
ctx: MutationCtx,
args: AppendEventArgs,
) {
return await ctx.db.insert("agentRunEvents", {
...args,
createdAt: Date.now(),
});
}
export const create = mutation({
args: {
@@ -24,6 +64,7 @@ export const create = mutation({
currentStep: v.optional(v.string()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const now = Date.now();
return await ctx.db.insert("agentRuns", {
@@ -50,6 +91,7 @@ export const updateStatus = mutation({
errorSummary: v.optional(v.string()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const now = Date.now();
const patch: {
status: typeof args.status;
@@ -92,6 +134,7 @@ export const list = query({
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const limit = normalizeListLimit(args.limit);
if (args.type && args.status) {
@@ -132,25 +175,17 @@ export const list = query({
});
export const appendEvent = mutation({
args: {
runId: v.id("agentRuns"),
level: eventLevel,
message: v.string(),
details: v.optional(
v.array(
v.object({
label: v.string(),
value: v.string(),
source: v.optional(v.string()),
}),
),
),
},
args: appendEventArgs,
handler: async (ctx, args) => {
return await ctx.db.insert("agentRunEvents", {
...args,
createdAt: Date.now(),
});
await requireOperator(ctx);
return await appendRunEvent(ctx, args);
},
});
export const appendEventInternal = internalMutation({
args: appendEventArgs,
handler: async (ctx, args) => {
return await appendRunEvent(ctx, args);
},
});
@@ -160,6 +195,7 @@ export const listEvents = query({
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const limit = normalizeListLimit(args.limit);
return await ctx.db

View File

@@ -7,6 +7,8 @@ import {
RUN_EVENT_LEVELS,
RUN_STATUSES,
RUN_TYPES,
USAGE_EVENT_OPERATIONS,
USAGE_EVENT_PROVIDERS,
} from "./domain";
const campaignStatus = v.union(v.literal("active"), v.literal("paused"));
@@ -146,6 +148,12 @@ const pageSpeedErrorType = v.union(
v.literal("api_error"),
v.literal("unknown"),
);
const usageEventProvider = v.union(
...USAGE_EVENT_PROVIDERS.map((provider) => v.literal(provider)),
);
const usageEventOperation = v.union(
...USAGE_EVENT_OPERATIONS.map((operation) => v.literal(operation)),
);
const settingsValue = v.union(v.string(), v.number(), v.boolean(), v.null());
const auditMetricSummary = v.object({
performanceScore: v.optional(v.number()),
@@ -172,6 +180,25 @@ const publicAuditOffer = v.object({
ctaLabel: v.optional(v.string()),
ctaHref: v.optional(v.string()),
});
const auditFindingEvidenceType = v.union(
v.literal("crawl_page"),
v.literal("technical_check"),
v.literal("screenshot"),
v.literal("pagespeed"),
v.literal("jina_excerpt"),
v.literal("generation_stage"),
);
const auditFindingEvidenceRef = v.object({
id: v.string(),
type: auditFindingEvidenceType,
label: v.string(),
sourceUrl: v.optional(v.string()),
});
const auditFindingReviewStatus = v.union(
v.literal("pending"),
v.literal("accepted"),
v.literal("rejected"),
);
const eventDetail = v.object({
label: v.string(),
value: v.string(),
@@ -282,8 +309,9 @@ export default defineSchema({
usedSkills: v.optional(
v.array(
v.object({
id: v.optional(v.string()),
name: v.string(),
category: v.string(),
category: v.optional(v.string()),
version: v.optional(v.string()),
source: v.optional(v.string()),
}),
@@ -333,6 +361,24 @@ export default defineSchema({
.index("by_auditId_and_viewport", ["auditId", "viewport"])
.index("by_storageId", ["storageId"]),
auditFindings: defineTable({
auditId: v.id("audits"),
runId: v.id("agentRuns"),
skillId: v.string(),
claim: v.string(),
recommendation: v.string(),
customerBenefit: v.string(),
severity: v.union(v.literal(1), v.literal(2), v.literal(3)),
confidence: v.number(),
evidenceRefs: v.array(auditFindingEvidenceRef),
reviewStatus: auditFindingReviewStatus,
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_auditId", ["auditId"])
.index("by_runId", ["runId"])
.index("by_auditId_and_reviewStatus", ["auditId", "reviewStatus"]),
pageSpeedResults: defineTable({
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
@@ -399,6 +445,39 @@ export default defineSchema({
.index("by_stage", ["stage"])
.index("by_leadId_and_stage", ["leadId", "stage"]),
usageEvents: defineTable({
provider: usageEventProvider,
operation: usageEventOperation,
runId: v.optional(v.id("agentRuns")),
leadId: v.optional(v.id("leads")),
auditId: v.optional(v.id("audits")),
estimatedCostUsd: v.number(),
tokens: v.optional(
v.object({
inputTokens: v.optional(v.number()),
outputTokens: v.optional(v.number()),
promptTokens: v.optional(v.number()),
completionTokens: v.optional(v.number()),
totalTokens: v.optional(v.number()),
cacheReadTokens: v.optional(v.number()),
}),
),
callCounts: v.optional(
v.object({
requests: v.optional(v.number()),
pages: v.optional(v.number()),
screenshots: v.optional(v.number()),
lookups: v.optional(v.number()),
}),
),
createdAt: v.number(),
})
.index("by_runId_and_createdAt", ["runId", "createdAt"])
.index("by_leadId_and_createdAt", ["leadId", "createdAt"])
.index("by_auditId_and_createdAt", ["auditId", "createdAt"])
.index("by_provider_and_createdAt", ["provider", "createdAt"])
.index("by_createdAt", ["createdAt"]),
websiteCrawlPages: defineTable({
leadId: v.id("leads"),
runId: v.optional(v.id("agentRuns")),

223
convex/usageEvents.ts Normal file
View File

@@ -0,0 +1,223 @@
import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation, query } from "./_generated/server";
import type { QueryCtx } from "./_generated/server";
import {
normalizeListLimit,
USAGE_EVENT_OPERATIONS,
USAGE_EVENT_PROVIDERS,
} from "./domain";
import { v } from "convex/values";
const usageEventProvider = v.union(
...USAGE_EVENT_PROVIDERS.map((provider) => v.literal(provider)),
);
const usageEventOperation = v.union(
...USAGE_EVENT_OPERATIONS.map((operation) => v.literal(operation)),
);
const usageEventTokens = v.object({
inputTokens: v.optional(v.number()),
outputTokens: v.optional(v.number()),
promptTokens: v.optional(v.number()),
completionTokens: v.optional(v.number()),
totalTokens: v.optional(v.number()),
cacheReadTokens: v.optional(v.number()),
});
const usageEventCallCounts = v.object({
requests: v.optional(v.number()),
pages: v.optional(v.number()),
screenshots: v.optional(v.number()),
lookups: v.optional(v.number()),
});
const usageEventDoc = v.object({
_id: v.id("usageEvents"),
_creationTime: v.number(),
provider: usageEventProvider,
operation: usageEventOperation,
runId: v.optional(v.id("agentRuns")),
leadId: v.optional(v.id("leads")),
auditId: v.optional(v.id("audits")),
estimatedCostUsd: v.number(),
tokens: v.optional(usageEventTokens),
callCounts: v.optional(usageEventCallCounts),
createdAt: v.number(),
});
type UsageEventTokens = {
inputTokens?: number;
outputTokens?: number;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
cacheReadTokens?: number;
};
type UsageEventCallCounts = {
requests?: number;
pages?: number;
screenshots?: number;
lookups?: number;
};
type UsageEventNumberArgs = {
estimatedCostUsd: number;
tokens?: UsageEventTokens;
callCounts?: UsageEventCallCounts;
};
const requireOperator = async (ctx: QueryCtx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Nicht autorisiert.");
}
};
function assertFiniteNonNegativeNumber(value: number, fieldName: string) {
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${fieldName} must be a finite non-negative number.`);
}
}
function assertFiniteNonNegativeInteger(
value: number | undefined,
fieldName: string,
) {
if (value === undefined) {
return;
}
if (!Number.isFinite(value) || value < 0 || !Number.isInteger(value)) {
throw new Error(`${fieldName} must be a finite non-negative integer.`);
}
}
function assertValidUsageEventNumbers(args: UsageEventNumberArgs) {
assertFiniteNonNegativeNumber(args.estimatedCostUsd, "estimatedCostUsd");
assertFiniteNonNegativeInteger(args.tokens?.inputTokens, "tokens.inputTokens");
assertFiniteNonNegativeInteger(args.tokens?.outputTokens, "tokens.outputTokens");
assertFiniteNonNegativeInteger(args.tokens?.promptTokens, "tokens.promptTokens");
assertFiniteNonNegativeInteger(args.tokens?.completionTokens, "tokens.completionTokens");
assertFiniteNonNegativeInteger(args.tokens?.totalTokens, "tokens.totalTokens");
assertFiniteNonNegativeInteger(args.tokens?.cacheReadTokens, "tokens.cacheReadTokens");
assertFiniteNonNegativeInteger(args.callCounts?.requests, "callCounts.requests");
assertFiniteNonNegativeInteger(args.callCounts?.pages, "callCounts.pages");
assertFiniteNonNegativeInteger(args.callCounts?.screenshots, "callCounts.screenshots");
assertFiniteNonNegativeInteger(args.callCounts?.lookups, "callCounts.lookups");
}
export const recordUsageEvent = internalMutation({
args: {
provider: usageEventProvider,
operation: usageEventOperation,
runId: v.optional(v.id("agentRuns")),
leadId: v.optional(v.id("leads")),
auditId: v.optional(v.id("audits")),
estimatedCostUsd: v.number(),
tokens: v.optional(usageEventTokens),
callCounts: v.optional(usageEventCallCounts),
createdAt: v.optional(v.number()),
},
returns: v.id("usageEvents"),
handler: async (ctx, args): Promise<Id<"usageEvents">> => {
assertValidUsageEventNumbers(args);
const now = args.createdAt ?? Date.now();
return await ctx.db.insert("usageEvents", {
provider: args.provider,
operation: args.operation,
...(args.runId ? { runId: args.runId } : {}),
...(args.leadId ? { leadId: args.leadId } : {}),
...(args.auditId ? { auditId: args.auditId } : {}),
estimatedCostUsd: args.estimatedCostUsd,
...(args.tokens ? { tokens: args.tokens } : {}),
...(args.callCounts ? { callCounts: args.callCounts } : {}),
createdAt: now,
});
},
});
export const listLatestUsageEvents = query({
args: {
limit: v.optional(v.number()),
},
returns: v.array(usageEventDoc),
handler: async (ctx, args): Promise<Doc<"usageEvents">[]> => {
await requireOperator(ctx);
return await ctx.db
.query("usageEvents")
.withIndex("by_createdAt")
.order("desc")
.take(normalizeListLimit(args.limit));
},
});
export const listUsageEventsByRun = query({
args: {
runId: v.id("agentRuns"),
limit: v.optional(v.number()),
},
returns: v.array(usageEventDoc),
handler: async (ctx, args): Promise<Doc<"usageEvents">[]> => {
await requireOperator(ctx);
return await ctx.db
.query("usageEvents")
.withIndex("by_runId_and_createdAt", (q) => q.eq("runId", args.runId))
.order("desc")
.take(normalizeListLimit(args.limit));
},
});
export const listUsageEventsByLead = query({
args: {
leadId: v.id("leads"),
limit: v.optional(v.number()),
},
returns: v.array(usageEventDoc),
handler: async (ctx, args): Promise<Doc<"usageEvents">[]> => {
await requireOperator(ctx);
return await ctx.db
.query("usageEvents")
.withIndex("by_leadId_and_createdAt", (q) => q.eq("leadId", args.leadId))
.order("desc")
.take(normalizeListLimit(args.limit));
},
});
export const listUsageEventsByAudit = query({
args: {
auditId: v.id("audits"),
limit: v.optional(v.number()),
},
returns: v.array(usageEventDoc),
handler: async (ctx, args): Promise<Doc<"usageEvents">[]> => {
await requireOperator(ctx);
return await ctx.db
.query("usageEvents")
.withIndex("by_auditId_and_createdAt", (q) => q.eq("auditId", args.auditId))
.order("desc")
.take(normalizeListLimit(args.limit));
},
});
export const listUsageEventsByProvider = query({
args: {
provider: usageEventProvider,
limit: v.optional(v.number()),
},
returns: v.array(usageEventDoc),
handler: async (ctx, args): Promise<Doc<"usageEvents">[]> => {
await requireOperator(ctx);
return await ctx.db
.query("usageEvents")
.withIndex("by_provider_and_createdAt", (q) =>
q.eq("provider", args.provider),
)
.order("desc")
.take(normalizeListLimit(args.limit));
},
});

View File

@@ -17,7 +17,7 @@ import {
getUsableContactEmailFromEntries,
normalizeEmailAddress,
} from "../lib/lead-discovery-google";
import { api, internal } from "./_generated/api";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { internalAction, type ActionCtx } from "./_generated/server";
@@ -30,6 +30,17 @@ const ACTION_TIMEOUT_BUFFER_MS = 5_000;
const MAX_PERSISTED_LINKS = 120;
const MAX_PERSISTED_EMAIL_CANDIDATES = 40;
const SCREENSHOT_MIME_TYPE = "image/png";
const MAX_BROWSERLESS_PAGE_BYTES = 750_000;
const MAX_BROWSERLESS_LINK_TEXT_CHARS = 180;
const BROWSERLESS_CRAWL_PATHS = [
"/",
"/kontakt",
"/impressum",
"/leistungen",
"/ueber-uns",
];
const BROWSERLESS_USER_AGENT =
"Mozilla/5.0 (compatible; WebDevPipelineBot/1.0; +https://webdev-pipeline.local)";
const CHROMIUM_SOURCE_MARKER_FILE = path.join(tmpdir(), "chromium-source.sha256");
const CHROMIUM_EXECUTABLE_PATH = path.join(tmpdir(), "chromium");
const CHROMIUM_PACK_PATH = path.join(tmpdir(), "chromium-pack");
@@ -116,11 +127,41 @@ type ServerlessChromiumModule = {
inflate: (filePath: string) => Promise<string>;
setupLambdaEnvironment: (baseLibPath: string) => void;
};
type PlaywrightClosableResource = {
close: () => Promise<unknown>;
};
function messageFromError(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
function isPlaywrightTargetClosedError(error: unknown) {
const message = messageFromError(error);
return /Target page, context or browser has been closed|Target closed|Browser has been closed|Context has been closed|Page has been closed/i.test(
message,
);
}
async function closePlaywrightResourceSafely(
resource: PlaywrightClosableResource | null,
label: string,
) {
if (!resource) {
return;
}
try {
await resource.close();
} catch (error) {
if (isPlaywrightTargetClosedError(error)) {
return;
}
console.warn(`Playwright cleanup ignored failed close for ${label}.`, {
error: messageFromError(error),
});
}
}
function readPositiveIntEnv(key: string, fallback: number) {
const raw = process.env[key]?.trim();
if (!raw) {
@@ -230,6 +271,280 @@ function isGenericBusinessEmail(email: string) {
return GENERIC_EMAIL_LOCALS.has(base);
}
function decodeHtmlCodePoint(rawCode: string, radix: number) {
const codePoint = Number.parseInt(rawCode, radix);
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
return "";
}
try {
return String.fromCodePoint(codePoint);
} catch {
return "";
}
}
function decodeHtmlText(input: string) {
return input
.replace(/&#(\d+);/g, (_, code: string) =>
decodeHtmlCodePoint(code, 10),
)
.replace(/&#x([0-9a-f]+);/gi, (_, code: string) =>
decodeHtmlCodePoint(code, 16),
)
.replace(/&nbsp;|&#xa0;|&#160;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'")
.replace(/\s+/g, " ")
.trim();
}
function stripHtmlForLabel(input: string) {
return decodeHtmlText(
input
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]*>/g, " "),
);
}
function getHtmlAttribute(tag: string, attribute: string) {
const match = new RegExp(
`\\b${attribute}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
"i",
).exec(tag);
const value = match?.[1] ?? match?.[2] ?? match?.[3];
return value ? decodeHtmlText(value) : "";
}
function extractFirstTagText(html: string, tagName: string) {
const match = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "i").exec(
html,
);
return match?.[1] ? stripHtmlForLabel(match[1]) : "";
}
function extractMetaDescriptionFromHtml(html: string) {
const metaTags = html.matchAll(/<meta\b[^>]*>/gi);
for (const match of metaTags) {
const tag = match[0] ?? "";
const name = getHtmlAttribute(tag, "name") || getHtmlAttribute(tag, "property");
if (!/^(description|og:description|twitter:description)$/i.test(name)) {
continue;
}
const content = getHtmlAttribute(tag, "content");
if (content) {
return content;
}
}
return "";
}
function extractHeadingsFromHtml(html: string) {
return Array.from(html.matchAll(/<h[1-3]\b[^>]*>([\s\S]*?)<\/h[1-3]>/gi))
.map((match) => stripHtmlForLabel(match[1] ?? ""))
.filter((heading) => heading.length > 0)
.slice(0, 12);
}
function extractAnchorLinksFromHtml(
html: string,
finalUrl: string,
rootUrl: string,
) {
return Array.from(html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi))
.map((match) => {
const href = getHtmlAttribute(match[1] ?? "", "href");
const normalizedHref = normalizeCrawlUrl(href, finalUrl);
if (!normalizedHref) {
return null;
}
return {
href: normalizedHref,
text: stripHtmlForLabel(match[2] ?? "").slice(
0,
MAX_BROWSERLESS_LINK_TEXT_CHARS,
),
isInternal: isSameRegistrableHostishDomain(normalizedHref, rootUrl),
};
})
.filter(
(entry): entry is { href: string; text: string; isInternal: boolean } =>
entry !== null,
);
}
function makeBrowserlessCrawlTargets(
rootUrl: string,
homepageLinks: string[],
maxPages: number,
) {
const normalizedRoot = normalizeCrawlUrl(rootUrl);
if (!normalizedRoot) {
return [];
}
const discoveredUrls = discoverRelevantSubpageUrls(homepageLinks, normalizedRoot);
const fallbackUrls = BROWSERLESS_CRAWL_PATHS.map((pathname) =>
normalizeCrawlUrl(pathname, normalizedRoot),
).filter((url): url is string => url !== null);
const seen = new Set<string>();
const targets: string[] = [];
for (const candidate of [normalizedRoot, ...discoveredUrls, ...fallbackUrls]) {
const normalized = normalizeCrawlUrl(candidate, normalizedRoot);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
targets.push(normalized);
if (targets.length >= maxPages) {
break;
}
}
return targets;
}
async function readLimitedBrowserlessResponseText(
response: Response,
signal?: AbortSignal,
) {
if (!response.body) {
return "";
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
if (signal?.aborted) {
throw new Error("Website-Enrichment Fetch wurde abgebrochen.");
}
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
const nextChunk = value.slice(
0,
Math.max(0, MAX_BROWSERLESS_PAGE_BYTES - totalBytes),
);
if (nextChunk.length > 0) {
chunks.push(nextChunk);
totalBytes += nextChunk.length;
}
if (totalBytes >= MAX_BROWSERLESS_PAGE_BYTES) {
await reader.cancel().catch(() => undefined);
break;
}
}
} finally {
reader.releaseLock();
}
const output = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder().decode(output);
}
async function fetchBrowserlessPage(targetUrl: string, timeoutMs: number) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
try {
const response = await fetch(targetUrl, {
headers: { "User-Agent": BROWSERLESS_USER_AGENT },
redirect: "follow",
signal: controller.signal,
});
const contentType = response.headers.get("content-type") ?? "";
if (
response.status >= 400 ||
(contentType && !/text|html|xml|xhtml/i.test(contentType))
) {
await response.body?.cancel().catch(() => undefined);
return null;
}
return {
finalUrl: normalizeCrawlUrl(response.url || targetUrl, targetUrl) ?? targetUrl,
html: await readLimitedBrowserlessResponseText(
response,
controller.signal,
),
status: response.status,
};
} finally {
clearTimeout(timeout);
}
}
async function crawlPageWithoutBrowser(
targetUrl: string,
rootUrl: string,
timeoutMs: number,
) {
const fetched = await fetchBrowserlessPage(targetUrl, timeoutMs);
if (!fetched || !fetched.html.trim()) {
return null;
}
const finalUrl = fetched.finalUrl;
const signals = extractContactSignalsFromHtmlLikeText(fetched.html);
const links = extractAnchorLinksFromHtml(fetched.html, finalUrl, rootUrl);
const emailCandidates = signals.emailCandidates
.map((entry) => {
const normalizedEmail = normalizeEmailAddress(entry.email);
if (!normalizedEmail) {
return null;
}
return {
email: normalizedEmail,
emailSource: finalUrl,
contactPerson: entry.contactPerson ?? null,
isBusinessContactAddress: entry.isBusinessContactAddress,
isGeneric: isGenericBusinessEmail(normalizedEmail),
sourceUrl: finalUrl,
accepted: false,
normalizedEmail,
};
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
return {
sourceUrl: targetUrl,
finalUrl,
pageKind: makePageKind(finalUrl, rootUrl),
title: extractFirstTagText(fetched.html, "title"),
metaDescription: extractMetaDescriptionFromHtml(fetched.html),
headings: extractHeadingsFromHtml(fetched.html),
visibleText: signals.visibleText,
links,
emailCandidates,
hasContactFormSignal: signals.hasContactFormSignal,
hasContactCtaSignal: signals.hasContactCtaSignal,
} satisfies PageResult;
}
async function loadPlaywrightModules() {
const [playwrightCore, chromiumPackage] = await Promise.all([
import("playwright-core"),
@@ -327,7 +642,7 @@ async function captureHomepageScreenshot(
mimeType: SCREENSHOT_MIME_TYPE,
} satisfies StoredScreenshot;
} finally {
await page.close();
await closePlaywrightResourceSafely(page, "homepage screenshot page");
}
}
@@ -428,7 +743,7 @@ async function crawlPage(
hasContactCtaSignal: signals.hasContactCtaSignal,
} satisfies PageResult;
} finally {
await page.close();
await closePlaywrightResourceSafely(page, "crawl page");
}
}
@@ -458,9 +773,226 @@ function deduplicateCrawlLinks(links: PersistedCrawlLink[]) {
return [...unique.values()];
}
async function processLeadEnrichmentWithoutBrowser(
ctx: ActionCtx,
args: {
runId: Id<"agentRuns">;
lead: WebsiteLead;
rootUrl: string;
timeoutMs: number;
maxPages: number;
actionStartedAt: number;
actionBudget: number;
},
): Promise<Id<"agentRuns">> {
const {
runId,
lead,
rootUrl,
timeoutMs,
maxPages,
actionStartedAt,
actionBudget,
} = args;
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "warning",
message:
"Chromium ist nicht konfiguriert; Website-Enrichment nutzt browserlosen Fetch-Fallback.",
details: [{ label: "Lead", value: lead._id }],
});
const homepage = await withActionTimeout(
crawlPageWithoutBrowser(
rootUrl,
rootUrl,
Math.min(timeoutMs, remainingActionBudgetMs(actionStartedAt, actionBudget)),
),
remainingActionBudgetMs(actionStartedAt, actionBudget),
"Homepage browserlos crawlen",
);
if (!homepage) {
throw new Error("Homepage konnte im browserlosen Fallback nicht geladen werden.");
}
const crawlTargets = makeBrowserlessCrawlTargets(
rootUrl,
homepage.links.map((link) => link.href),
maxPages,
);
const crawledPages: PageResult[] = [homepage];
const crawledUrls = new Set<string>();
const normalizedHomepageUrl = normalizeCrawlUrl(homepage.finalUrl, rootUrl);
if (normalizedHomepageUrl) {
crawledUrls.add(normalizedHomepageUrl);
}
for (const pageUrl of crawlTargets.slice(1)) {
const normalizedTarget = normalizeCrawlUrl(pageUrl, rootUrl);
if (!normalizedTarget || crawledUrls.has(normalizedTarget)) {
continue;
}
const crawled = await withActionTimeout(
crawlPageWithoutBrowser(
normalizedTarget,
rootUrl,
Math.min(
timeoutMs,
remainingActionBudgetMs(actionStartedAt, actionBudget),
),
),
remainingActionBudgetMs(actionStartedAt, actionBudget),
`Unterseite browserlos crawlen: ${normalizedTarget}`,
);
if (crawled) {
crawledPages.push(crawled);
const normalizedCrawledUrl = normalizeCrawlUrl(crawled.finalUrl, rootUrl);
if (normalizedCrawledUrl) {
crawledUrls.add(normalizedCrawledUrl);
}
}
}
const allLinks: PersistedCrawlLink[] = crawledPages.flatMap((page) =>
page.links.map((link) => ({
...link,
pageUrl: page.finalUrl,
})),
);
const technicalInput = buildTechnicalChecks({
rootUrl,
finalUrl: homepage.finalUrl,
title: homepage.title,
metaDescription: homepage.metaDescription,
visibleText: homepage.visibleText,
checkedUrls: crawledPages.map((page) => page.finalUrl),
links: allLinks.map((link) => link.href),
});
const validCandidates = deduplicateLeadEmailCandidates(
crawledPages.flatMap((page) => page.emailCandidates),
);
const persistedLinks = deduplicateCrawlLinks(allLinks).slice(
0,
MAX_PERSISTED_LINKS,
);
const persistedCandidates = validCandidates.slice(
0,
MAX_PERSISTED_EMAIL_CANDIDATES,
);
const usable = getUsableContactEmailFromEntries(
validCandidates.map((candidate) => ({
email: candidate.email,
emailSource: candidate.emailSource,
contactPerson: candidate.contactPerson,
isBusinessContactAddress: candidate.isBusinessContactAddress,
})),
);
await ctx.runMutation(internal.websiteEnrichment.persistLeadEnrichmentResult, {
runId,
leadId: lead._id,
pages: crawledPages.map((page) => ({
sourceUrl: page.sourceUrl,
finalUrl: page.finalUrl,
pageKind: page.pageKind,
title: page.title,
metaDescription: page.metaDescription,
headings: page.headings,
visibleTextExcerpt: trimExcerpt(page.visibleText),
hasContactFormSignal: page.hasContactFormSignal,
hasContactCtaSignal: page.hasContactCtaSignal,
})),
links: persistedLinks.map((link) => ({
pageUrl: link.pageUrl,
href: link.href,
text: link.text,
isInternal: link.isInternal,
})),
emailCandidates: persistedCandidates.map((candidate) => ({
email: candidate.email,
normalizedEmail: candidate.normalizedEmail,
emailSource: candidate.emailSource,
sourceUrl: candidate.sourceUrl,
contactPerson: candidate.contactPerson ?? undefined,
isBusinessContactAddress: candidate.isBusinessContactAddress,
isGeneric: candidate.isGeneric,
accepted: usable !== null && candidate.normalizedEmail === usable.email,
})),
screenshots: [],
technicalChecks: [
{
sourceUrl: homepage.sourceUrl,
finalUrl: homepage.finalUrl,
usesHttps: technicalInput.https,
missingTitle: technicalInput.missingTitle,
missingMetaDescription: technicalInput.missingMetaDescription,
hasVisibleContactPath: technicalInput.hasVisibleContactPath,
brokenInternalLinkCount: technicalInput.brokenInternalLinks.length,
},
],
});
if (usable) {
await ctx.runMutation(internal.websiteEnrichment.patchLeadFromWebsiteEnrichment, {
leadId: lead._id,
email: usable.email,
emailSource: usable.emailSource ?? undefined,
contactPerson: usable.contactPerson ?? undefined,
currentContactStatus: lead.contactStatus,
});
} else {
await ctx.runMutation(internal.websiteEnrichment.patchLeadFromWebsiteEnrichment, {
leadId: lead._id,
currentContactStatus: lead.contactStatus,
contactStatusReason:
"Browserloses Website-Enrichment abgeschlossen, aber kein verwertbarer Kontakt gefunden.",
});
}
try {
await ctx.runMutation(internal.pageSpeed.queueLeadPageSpeedAudit, {
leadId: lead._id,
parentRunId: runId,
});
} catch (pageSpeedQueueError) {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "warning",
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
details: [
{ label: "Lead", value: lead._id },
{
label: "Fehler",
value: messageFromError(pageSpeedQueueError),
source: "pagespeed_queue",
},
],
});
}
await ctx.runMutation(internal.websiteEnrichment.finishLeadEnrichmentRun, {
runId,
status: "succeeded",
currentStep: "website_enrichment",
errors: 0,
});
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "info",
message: usable
? "Website-Enrichment browserlos mit nutzbarer E-Mail abgeschlossen."
: "Website-Enrichment browserlos abgeschlossen, aber ohne nutzbare E-Mail.",
});
return runId;
}
export const processLeadEnrichment = internalAction({
args: { runId: v.id("agentRuns") },
handler: async (ctx, args) => {
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
let started: StartedLead | null = null;
const runId = args.runId;
const actionStartedAt = Date.now();
@@ -486,7 +1018,7 @@ export const processLeadEnrichment = internalAction({
parentRunId: runId,
});
} catch (pageSpeedQueueError) {
await ctx.runMutation(api.runs.appendEvent, {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "warning",
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
@@ -508,7 +1040,7 @@ export const processLeadEnrichment = internalAction({
errorSummary: "Ungültige Website-URL.",
errors: 1,
});
await ctx.runMutation(api.runs.appendEvent, {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "error",
message: "Website-Enrichment fehlgeschlagen: Ungültige Website-URL.",
@@ -526,6 +1058,18 @@ export const processLeadEnrichment = internalAction({
const timeoutMs = crawlTimeoutMs();
const maxPages = crawlMaxPages();
if (!getChromiumExecutableSource()) {
return await processLeadEnrichmentWithoutBrowser(ctx, {
runId,
lead: started.lead,
rootUrl,
timeoutMs,
maxPages,
actionStartedAt,
actionBudget,
});
}
const { playwrightCore, serverlessChromium } =
await withActionTimeout(
loadPlaywrightModules(),
@@ -803,7 +1347,7 @@ export const processLeadEnrichment = internalAction({
parentRunId: runId,
});
} catch (pageSpeedQueueError) {
await ctx.runMutation(api.runs.appendEvent, {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "warning",
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
@@ -825,7 +1369,7 @@ export const processLeadEnrichment = internalAction({
errors: 0,
});
await ctx.runMutation(api.runs.appendEvent, {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "info",
message: usable
@@ -846,7 +1390,7 @@ export const processLeadEnrichment = internalAction({
errors: 1,
});
await ctx.runMutation(api.runs.appendEvent, {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "error",
message: "Website-Enrichment fehlgeschlagen.",
@@ -862,7 +1406,7 @@ export const processLeadEnrichment = internalAction({
parentRunId: runId,
});
} catch (pageSpeedQueueError) {
await ctx.runMutation(api.runs.appendEvent, {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "warning",
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
@@ -886,13 +1430,19 @@ export const processLeadEnrichment = internalAction({
return null;
} finally {
if (desktopContext) {
await desktopContext.close();
await closePlaywrightResourceSafely(
desktopContext,
"desktop browser context",
);
}
if (mobileContext) {
await mobileContext.close();
await closePlaywrightResourceSafely(
mobileContext,
"mobile browser context",
);
}
if (browser) {
await browser.close();
await closePlaywrightResourceSafely(browser, "browser");
}
}
},

View File

@@ -0,0 +1,50 @@
# Coolify Deployment
## Environment Variables
Set production values in Coolify and Convex secrets, not in source code.
- `APP_ENV`
- `NEXT_PUBLIC_APP_URL`
- `NEXT_PUBLIC_CONVEX_URL`
- `NEXT_PUBLIC_CONVEX_SITE_URL`
- `CONVEX_DEPLOYMENT`
- `BETTER_AUTH_SECRET`
- `GOOGLE_GEOCODING_API_KEY`
- `GOOGLE_PLACES_API_KEY`
- `PAGESPEED_API_KEY`
- `PAGESPEED_TIMEOUT_MS`
- `OPENROUTER_API_KEY`
- `OPENROUTER_MODEL_CLASSIFICATION`
- `OPENROUTER_MODEL_MULTIMODAL_AUDIT`
- `OPENROUTER_MODEL_GERMAN_COPY`
- `OPENROUTER_MODEL_QUALITY_REVIEW`
- `OPENROUTER_APP_NAME`
- `OPENROUTER_APP_URL`
- `SMTP_HOST`
- `SMTP_PORT`
- `SMTP_USER`
- `SMTP_PASSWORD`
- `SMTP_FROM`
- `RYBBIT_API_URL`
- `RYBBIT_API_KEY`
- `NEXT_PUBLIC_RYBBIT_SITE_ID`
- `TASK8_BROWSER_ASSET_URL`
## Build And Runtime
Coolify commands:
- Install: `pnpm install --frozen-lockfile`
- Build: `pnpm build`
- Start: `pnpm start`
Expose Port 3000 from the Next.js container.
## Playwright
Website enrichment uses `playwright-core` with a hosted Chromium bundle. Configure `TASK8_BROWSER_ASSET_URL` to a reachable browser asset. If the platform image also installs system browser dependencies, keep them aligned with the Chromium bundle used by `@sparticuz/chromium-min`.
## Domains
Set `NEXT_PUBLIC_APP_URL` to the public dashboard Domain. Configure Convex deployment URLs in `NEXT_PUBLIC_CONVEX_URL` and `NEXT_PUBLIC_CONVEX_SITE_URL`. Public audit links assume the same app domain unless a reverse proxy maps `/audit/*` separately.

53
docs/verification.md Normal file
View File

@@ -0,0 +1,53 @@
# MVP Verification Notes
Diese Checkliste ist die wiederholbare manuelle Prüfung für die kritischen MVP-Flows.
## Login
1. `/login` öffnen.
2. Mit Admin-Zugang anmelden.
3. Prüfen, dass `/dashboard` erreichbar ist und geschützte Routen ohne Session zurück zu `/login` gehen.
## Kampagnenlauf
1. Kampagne mit deutscher PLZ und aktivem Status anlegen.
2. `Jetzt ausführen` starten.
3. In Kampagnen-Run-Logs prüfen, dass der Lauf `pending/running/succeeded` oder ein sichtbarer Fehlerstatus wird.
## Audit-Generierung
1. Lead mit Website durch externe Audit-Services laufen lassen.
2. Prüfen, dass Google, PageSpeed, OpenRouter und ScreenshotOne als serverseitig verwaltete Provider konfiguriert sind.
3. Prüfen, dass fehlendes Jina keine Blockade auslöst.
4. Im Outreach Review Workspace prüfen, dass Audit-Text, Quellen und Skills sichtbar sind.
## Operations Readiness
1. `audit.matthias-meister-webdesign.de` als persönlichen Deployment-Scope prüfen.
2. Sicherstellen, dass BYO-Keys, Billing und Teamrollen nicht als aktuelle Voraussetzungen angezeigt werden.
3. Sicherstellen, dass Playwright/TASK-8 nicht als Pflichtintegration für die neue externe Pipeline angezeigt wird.
## Freigabe
1. Public-Audit-Text editieren.
2. Änderungen speichern.
3. Audit veröffentlichen und öffentliche Audit-URL öffnen.
## Versand
1. E-Mail-Betreff und Text prüfen.
2. E-Mail freigeben.
3. Finale SMTP-Bestätigung kontrollieren und senden.
4. Bei SMTP-Fehler prüfen, dass der Datensatz retrybar bleibt und keine Credentials angezeigt werden.
## Follow-up
1. Nach Erstversand prüfen, dass ein Follow-up-Draft mit Due-Date entsteht.
2. Follow-up erst nach manueller Review/Freigabe senden.
3. `Antwort erhalten` oder `Kein Interesse` setzen und prüfen, dass Follow-up-Prompts verschwinden.
## Analytics
1. Öffentliche Audit-Seite öffnen und CTA klicken.
2. `/dashboard/analytics` prüfen.
3. Convex-Metriken und Rybbit-Fehlerzustand bzw. Rybbit-Signale kontrollieren.

View File

@@ -13,6 +13,8 @@ const eslintConfig = defineConfig([
"build/**",
".test-output/**",
"convex/_generated/**",
// v2_elemente contains PRD/reference snippets, not runtime source.
"v2_elemente/**",
"next-env.d.ts",
]),
]);

View File

@@ -60,6 +60,7 @@ export type AuditEvidenceInput = {
observedUxSignals: string[];
observedContentSignals: string[];
observedTechnicalSignals: string[];
externalMarkdown?: string;
screenshotReferences: Array<{
storageId: string;
sourceUrl: string;
@@ -71,6 +72,20 @@ export type AuditEvidenceInput = {
}>;
pageSpeedCustomerImplications: string[];
selectedSkills: AuditUsedSkill[];
evidenceLedger: AuditEvidenceLedgerEntry[];
};
export type AuditEvidenceLedgerEntry = {
id: string;
type:
| "crawl_page"
| "technical_check"
| "screenshot"
| "pagespeed"
| "jina_excerpt";
label: string;
sourceUrl?: string;
summary: string;
};
export type AuditEvidenceInputArgs = {
@@ -80,6 +95,7 @@ export type AuditEvidenceInputArgs = {
screenshots?: readonly AuditScreenshotEvidence[];
pageSpeedInputs?: readonly PageSpeedMinimalAuditResult[];
skillRegistry?: readonly SkillRegistryEntryEvidence[];
externalMarkdown?: string;
};
const COMPANY_CONTEXT_LIMIT = 8;
@@ -90,6 +106,21 @@ const TECHNICAL_SIGNAL_LIMIT = 6;
const PAGESPEED_SIGNAL_LIMIT = 8;
const SCREENSHOT_REFERENCE_LIMIT = 8;
const SELECTED_SKILLS_LIMIT = 6;
const EXTERNAL_MARKDOWN_LIMIT = 4_000;
const V3_LOCAL_AUDIT_PRIORITY = new Map(
[
"visual-design",
"impeccable-critique",
"contact-conversion",
"local-seo-basics",
"performance-experience",
"mobile-usability",
"conversion-copy",
"first-impression-clarity",
"trust-signals",
"accessibility-basics",
].map((id, index) => [id, index] as const),
);
const URL_PATTERN = /\bhttps?:\/\/[^\s<>"']+/i;
const JSON_BRACKET_PATTERN = /\{[^}]*\}|\[[^\]]*\]/;
@@ -97,6 +128,32 @@ const PAGESPEED_NOISE_PATTERN =
/\b(?:raw\s*storage\s*id|rawstorageid|lighthouse|pagespeed|score)\b/i;
const MACHINE_TOKEN_PATTERN = /\b[a-z\d_-]{24,}\b/i;
function stableEvidencePart(value: unknown) {
const normalized = trimAndNormalize(String(value ?? "").toLowerCase())
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return normalized || "source";
}
function evidenceId(type: AuditEvidenceLedgerEntry["type"], ...parts: unknown[]) {
return [type, ...parts.map(stableEvidencePart)].join(":");
}
function addEvidenceLedgerEntry(
ledger: AuditEvidenceLedgerEntry[],
entry: AuditEvidenceLedgerEntry,
) {
if (!entry.summary || ledger.some((current) => current.id === entry.id)) {
return;
}
ledger.push(entry);
}
function trimAndNormalize(input: unknown): string {
if (typeof input !== "string") {
return "";
@@ -140,6 +197,19 @@ function sanitizeCustomerText(value: unknown, maxLength = 180): string {
return text;
}
function sanitizeExternalMarkdown(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const markdown = value.replace(/\s+/g, " ").trim();
if (!markdown) {
return undefined;
}
return markdown.slice(0, EXTERNAL_MARKDOWN_LIMIT);
}
function addUniqueCapped(
bucket: string[],
input: string,
@@ -233,6 +303,77 @@ function selectTopSkill(
return toAuditUsedSkill(scored[0]!.candidate);
}
type SkillInputAvailability = {
websiteExists: boolean;
hasDesktopScreenshot: boolean;
hasMobileScreenshot: boolean;
hasMarkdown: boolean;
hasPageSpeed: boolean;
hasDom: boolean;
};
function hasRequiredV3Input(input: string, availability: SkillInputAvailability) {
switch (input) {
case "desktop_screenshot":
return availability.hasDesktopScreenshot;
case "mobile_screenshot":
return availability.hasMobileScreenshot;
case "markdown":
return availability.hasMarkdown;
case "pagespeed":
return availability.hasPageSpeed;
case "dom":
return availability.hasDom;
default:
return false;
}
}
function v3SkillApplies(
skill: SkillRegistryEntryEvidence,
availability: SkillInputAvailability,
) {
const appliesWhen = skill.appliesWhen ?? "website_exists";
const applies =
appliesWhen === "always" ||
(appliesWhen === "website_exists" && availability.websiteExists) ||
(appliesWhen === "has_mobile_screenshot" &&
availability.hasMobileScreenshot) ||
(appliesWhen === "has_pagespeed" && availability.hasPageSpeed);
if (!applies) {
return false;
}
return (skill.inputs ?? []).every((input) =>
hasRequiredV3Input(input, availability),
);
}
function selectV3Skills(
skillRegistry: readonly SkillRegistryEntryEvidence[],
availability: SkillInputAvailability,
) {
return skillRegistry
.map((skill, registryIndex) => ({ skill, registryIndex }))
.filter(({ skill }) => skill.id && !skill.category)
.filter(({ skill }) => v3SkillApplies(skill, availability))
.sort((a, b) => {
// Keep core local-audit coverage inside the cap; otherwise preserve registry order.
const aPriority = V3_LOCAL_AUDIT_PRIORITY.get(a.skill.id ?? "");
const bPriority = V3_LOCAL_AUDIT_PRIORITY.get(b.skill.id ?? "");
if (aPriority !== undefined || bPriority !== undefined) {
return (
(aPriority ?? Number.POSITIVE_INFINITY) -
(bPriority ?? Number.POSITIVE_INFINITY)
);
}
return a.registryIndex - b.registryIndex;
})
.slice(0, SELECTED_SKILLS_LIMIT)
.map(({ skill }) => toAuditUsedSkill(skill));
}
function buildObservedSignals(
crawlPages: readonly AuditCrawlPageEvidence[],
technicalChecks: readonly AuditTechnicalCheckEvidence[],
@@ -403,8 +544,12 @@ function extractSkills(
marketing: boolean;
offer: boolean;
},
availability: SkillInputAvailability,
): AuditUsedSkill[] {
const selected: AuditUsedSkill[] = [];
const selected: AuditUsedSkill[] = selectV3Skills(
skillRegistry,
availability,
);
const categoryOrder = ["design", "ux", "copy", "seo", "marketing", "offer"] as const;
const evidenceText = {
design:
@@ -450,6 +595,8 @@ export function buildAuditEvidenceInput(
const screenshots = args.screenshots ?? [];
const pageSpeedInputs = args.pageSpeedInputs ?? [];
const skillRegistry = args.skillRegistry ?? [];
const externalMarkdown = sanitizeExternalMarkdown(args.externalMarkdown);
const evidenceLedger: AuditEvidenceLedgerEntry[] = [];
const companyContext: string[] = [];
const checkedPages: string[] = [];
@@ -515,6 +662,22 @@ export function buildAuditEvidenceInput(
}
addUniqueCapped(checkedPages, label, CHECKED_PAGES_LIMIT);
addEvidenceLedgerEntry(evidenceLedger, {
id: evidenceId("crawl_page", page.finalUrl ?? page.sourceUrl, page.pageKind),
type: "crawl_page",
label,
...(page.finalUrl ?? page.sourceUrl ? { sourceUrl: page.finalUrl ?? page.sourceUrl ?? undefined } : {}),
summary: sanitizeCustomerText(
[
title ? `Titel: ${title}` : "",
page.metaDescription ? `Meta: ${page.metaDescription}` : "",
page.visibleTextExcerpt ?? page.visibleText ?? "",
]
.filter(Boolean)
.join(" | "),
260,
),
});
}
if (checkedPages.length === 0 && lead.companyName) {
@@ -529,6 +692,44 @@ export function buildAuditEvidenceInput(
const pageSpeedInputsOutput = buildPageSpeedAuditInputs(pageSpeedInputs);
const pageSpeedCustomerImplications: string[] = [];
for (const check of technicalChecks) {
const summary = [
check.usesHttps === true ? "HTTPS vorhanden" : "",
check.usesHttps === false ? "HTTPS fehlt" : "",
check.missingTitle === true ? "Title fehlt" : "",
check.missingMetaDescription === true ? "Meta-Description fehlt" : "",
check.hasVisibleContactPath === true ? "Kontaktpfad sichtbar" : "",
check.brokenInternalLinkCount !== undefined
? `Interne Linkfehler: ${check.brokenInternalLinkCount}`
: "",
]
.filter(Boolean)
.join(" | ");
addEvidenceLedgerEntry(evidenceLedger, {
id: evidenceId("technical_check", check.finalUrl ?? check.sourceUrl),
type: "technical_check",
label: `Technik: ${toSafePath(check.finalUrl ?? check.sourceUrl ?? "") || "Seite"}`,
...(check.finalUrl ?? check.sourceUrl ? { sourceUrl: check.finalUrl ?? check.sourceUrl ?? undefined } : {}),
summary: sanitizeCustomerText(summary, 260),
});
}
for (const screenshot of screenshots) {
addEvidenceLedgerEntry(evidenceLedger, {
id: evidenceId(
"screenshot",
screenshot.storageId,
screenshot.viewport,
screenshot.sourceUrl,
),
type: "screenshot",
label: `${screenshot.viewport === "desktop" ? "Desktop" : "Mobil"} Screenshot`,
sourceUrl: screenshot.sourceUrl,
summary: `${screenshot.viewport} Screenshot ${screenshot.width}x${screenshot.height}`,
});
}
for (const implication of pageSpeedInputsOutput.customerImplications) {
addUniqueCapped(
pageSpeedCustomerImplications,
@@ -538,10 +739,56 @@ export function buildAuditEvidenceInput(
);
}
for (const input of pageSpeedInputs) {
const implication = pageSpeedInputsOutput.customerImplications.find(Boolean);
addEvidenceLedgerEntry(evidenceLedger, {
id: evidenceId("pagespeed", input.strategy, input.sourceUrl, input.status),
type: "pagespeed",
label: `PageSpeed ${input.strategy}`,
sourceUrl: input.sourceUrl,
summary: sanitizeCustomerText(
implication ??
(input.status === "succeeded"
? "PageSpeed-Messung erfolgreich"
: "PageSpeed-Messung nicht verfügbar"),
260,
),
});
}
if (externalMarkdown) {
addEvidenceLedgerEntry(evidenceLedger, {
id: evidenceId("jina_excerpt", externalMarkdown.slice(0, 80)),
type: "jina_excerpt",
label: "Jina Reader Auszug",
summary: sanitizeCustomerText(externalMarkdown, 260),
});
}
const selectedSkills = extractSkills(skillRegistry, {
...signals.evidenceText,
marketing: false,
offer: false,
}, {
websiteExists:
Boolean(lead.websiteDomain || lead.websiteUrl) ||
crawlPages.length > 0 ||
screenshots.length > 0,
hasDesktopScreenshot: screenshots.some(
(screenshot) => screenshot.viewport === "desktop",
),
hasMobileScreenshot: screenshots.some(
(screenshot) => screenshot.viewport === "mobile",
),
hasMarkdown:
Boolean(externalMarkdown) ||
crawlPages.some((page) =>
Boolean(page.visibleText || page.visibleTextExcerpt),
),
hasPageSpeed:
pageSpeedInputsOutput.customerImplications.length > 0 ||
pageSpeedInputs.some((input) => input.status === "succeeded"),
hasDom: crawlPages.length > 0 || technicalChecks.length > 0,
});
return {
@@ -550,6 +797,7 @@ export function buildAuditEvidenceInput(
observedUxSignals: signals.ux,
observedContentSignals: signals.content,
observedTechnicalSignals: signals.technical,
...(externalMarkdown ? { externalMarkdown } : {}),
screenshotReferences: screenshotReferences.map((reference) => ({
...reference,
width: Math.max(reference.width, 0),
@@ -561,5 +809,6 @@ export function buildAuditEvidenceInput(
PAGESPEED_SIGNAL_LIMIT,
),
selectedSkills,
evidenceLedger,
};
}

View File

@@ -0,0 +1,49 @@
export const customerToneGuidelines = {
senderPosture: "kollegial_direkt",
voiceLabel: "kollegial direkt",
email: {
wordCount: {
min: 60,
max: 130,
},
maxSentences: 7,
maxParagraphs: 2,
subject: {
minWords: 2,
maxWords: 6,
maxCharacters: 55,
},
bannedPhrases: [
"Optimierungspotenziale",
"Mehr Sichtbarkeit und bessere Nutzererfahrung",
"Ich habe beobachtet",
"Ich schlage vor",
"Maßnahmen umsetzen",
"Conversion-Rate steigern",
"Ranking positiv beeinflussen",
"Absprungraten senken",
"nachhaltig verbessern",
"signifikant",
],
preferredAskExamples: [
"Soll ich Ihnen die zwei Punkte kurz schicken?",
"Soll ich Ihnen die Stelle kurz als Screenshot schicken?",
"Wäre ein kurzer Hinweis dazu hilfreich?",
],
},
} as const;
export function buildCustomerTonePromptSection() {
return [
"Tonalität für Kunden-E-Mail: kollegial direkt, konkret, ruhig und nicht verkäuferisch.",
"Schreibe wie Matthias als lokaler Web-Profi, nicht wie eine Agentur-Broschüre.",
"Die E-Mail ist eine erste Kontaktaufnahme: maximal zwei verifizierte Befunde, kein Mini-Audit.",
"Betreff: 2-6 Wörter, maximal 55 Zeichen, kein Doppelpunkt, keine Benefit-Kette.",
"E-Mail-Text: 60-130 Wörter, maximal 7 Sätze, 1-2 kurze Absätze.",
"Starte mit einer konkreten Beobachtung zur Website, nicht mit 'Ich habe beobachtet, dass'.",
"Nenne eine praktische Auswirkung in Alltagssprache und ende mit einer weichen Frage.",
"Nutze für unbekannte lokale Betriebe formal Sie/Ihnen.",
"Ich-Form ist erlaubt, aber nicht als Wiederholungsmuster: kein mehrfaches 'Ich habe...' oder 'Ich schlage vor...'.",
`Beispiel für den Abschluss: ${customerToneGuidelines.email.preferredAskExamples[0]}`,
].join("\n");
}

View File

@@ -1,3 +1,5 @@
import { customerToneGuidelines } from "./customer-tone-guidelines";
const GERMAN_MARKERS = new Set([
"ich",
"mich",
@@ -31,6 +33,12 @@ const GERMAN_MARKERS = new Set([
"wenn",
"für",
"bei",
"kurz",
"kurzer",
"hinweis",
"zur",
"kontaktseite",
"webauftritt",
]);
const ENGLISH_MARKERS = new Set([
@@ -68,13 +76,14 @@ const ENGLISH_MARKERS = new Set([
const OBSERVATION_TOKENS = [
/\b(mir|ich)\b[^\n]{0,80}\b(aufgefallen|festgestellt|bemerkt|beobachtet|gesehen|sichtbar)\b/i,
/\b(erkennt|zeigt|sichtbar|feststell|finde|fällt)\b/i,
/\b(erkennt|zeigt|sichtbar|festgestellt|feststellen|feststellbar|finde|fällt)\b/i,
/\b(ich sehe|ich habe gesehen|bei der Prüfung)\b/i,
];
const SUGGESTION_TOKENS = [
/\b(empfehle|empfiehlt|vorschlage|vorschlagen|schlage vor|könnte helfen|kannst|können wir|sollte|sollten|ich könnte|ich würde|ich empfehle)\b/i,
/\b(schlage vor|schlage)\b/i,
/\b(?:mein(?:e[rmns]?)?\s+)?(?:konkreter\s+)?vorschlag(?:\s+ist)?\b/i,
/\b(ergänzt|ergänzen|anpassen|optimieren|verbessern|prüfen|einbauen|einzusetzen|setzten)\b/i,
];
@@ -119,6 +128,63 @@ const RAW_TECH_PATTERNS = [
/\b[0-9a-f]{24}\b/i,
];
const EMAIL_TEMPLATE_PATTERNS = [
/\bich habe beobachtet\b/i,
/\bmir ist aufgefallen\b/i,
/\bich schlage vor\b/i,
/\bich empfehle\b/i,
];
const EMAIL_BROCHURE_PATTERNS = [
/\bmaßnahmen umsetzen\b/i,
/\bconversion[- ]rate steigern\b/i,
/\branking positiv beeinflussen\b/i,
/\babsprungraten senken\b/i,
/\bnachhaltig verbessern\b/i,
/\bsignifikant\b/i,
/\boptimierungspotenzial(?:e)?\b/i,
/\bnutzerzufriedenheit\b/i,
/\bsuchmaschinenplatzierung\b/i,
];
const EMAIL_AUDIT_TOPIC_PATTERNS = [
/\bmeta[- ]beschreibung\b/i,
/\bpage[- ]?speed\b/i,
/\bladezeit(?:en)?\b/i,
/\bkontaktformular\b/i,
/\bcall[- ]to[- ]action\b/i,
/\bmobile(?:n|r|s)? gerät/i,
/\bdesktop\b/i,
/\bh1[- ]?überschrift(?:en)?\b/i,
/\bbewertung(?:en)?\b/i,
/\bvertrauenssignal(?:e)?\b/i,
/\bstrukturierte daten\b/i,
];
const EMAIL_MINI_AUDIT_TRANSITIONS = [
/\baußerdem\b/i,
/\bzudem\b/i,
/\bein weiterer punkt\b/i,
/\bschließlich\b/i,
/\bdurch die umsetzung\b/i,
];
const EMAIL_LOW_FRICTION_ASK_PATTERNS = [
/\bsoll ich ihnen\b/i,
/\bwäre (?:das|ein kurzer hinweis)\b/i,
/\bdarf ich ihnen\b/i,
/\bkann ich ihnen\b/i,
/\boffen für\b/i,
];
const INFORMAL_EMAIL_ADDRESS_PATTERNS = [
/\bdu\b/i,
/\bdir\b/i,
/\bdein(?:e[rmns]?)?\b/i,
/\beuch\b/i,
/\beuer(?:e[rmns]?)?\b/i,
];
export type GermanCopyGuardIssue = {
field: string;
rule: string;
@@ -255,6 +321,178 @@ function hasRawArtifact(value: string): boolean {
return RAW_TECH_PATTERNS.some((pattern) => pattern.test(value));
}
function countMatches(value: string, patterns: readonly RegExp[]) {
return patterns.reduce(
(count, pattern) => count + (pattern.test(value) ? 1 : 0),
0,
);
}
function countRegexMatches(value: string, pattern: RegExp) {
return value.match(pattern)?.length ?? 0;
}
function countSentences(value: string) {
return value
.split(/[.!?]+/)
.map((sentence) => sentence.trim())
.filter(Boolean).length;
}
function countParagraphs(value: string) {
return value
.trim()
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean).length;
}
function startsWithTemplateEmailPhrase(value: string) {
return new RegExp(
String.raw`^\s*(?:(?:guten tag|hallo|sehr geehrte[^,.!?]*|moin)[,.!?\s]+)?(?:${EMAIL_TEMPLATE_PATTERNS.map(
(pattern) => pattern.source,
).join("|")})`,
"i",
).test(value);
}
function hasLowFrictionAsk(value: string) {
return (
value.includes("?") &&
EMAIL_LOW_FRICTION_ASK_PATTERNS.some((pattern) => pattern.test(value))
);
}
function validateEmailSubjectTone(
issues: GermanCopyGuardIssue[],
subject: string,
) {
const trimmed = subject.trim();
if (!trimmed) {
return;
}
const words = tokenizeWords(trimmed);
const { subject: subjectRules } = customerToneGuidelines.email;
const hasInflatedSubject =
/optimierungspotenzial/i.test(trimmed) ||
/mehr sichtbarkeit/i.test(trimmed) ||
/bessere nutzererfahrung/i.test(trimmed) ||
/kundengewinnung/i.test(trimmed) ||
/conversion/i.test(trimmed) ||
/ranking/i.test(trimmed);
if (
trimmed.length > subjectRules.maxCharacters ||
words.length < subjectRules.minWords ||
words.length > subjectRules.maxWords ||
/:/.test(trimmed) ||
hasInflatedSubject
) {
addIssue(
issues,
"emailSubject",
"unnatural_email_subject",
"Betreff wirkt zu pitchig, zu lang oder nicht wie eine kurze Erstmail.",
);
}
}
function validateEmailBodyTone(
issues: GermanCopyGuardIssue[],
body: string,
) {
const trimmed = body.trim();
if (!trimmed) {
return;
}
const { email } = customerToneGuidelines;
const wordCount = tokenizeWords(trimmed).length;
if (wordCount < email.wordCount.min || wordCount > email.wordCount.max) {
addIssue(
issues,
"emailBody",
"unnatural_email_length",
"E-Mail sollte als Erstkontakt kurz bleiben: 60-130 Wörter.",
);
}
if (countSentences(trimmed) > email.maxSentences) {
addIssue(
issues,
"emailBody",
"too_many_email_sentences",
"E-Mail enthält zu viele Sätze für eine erste Kontaktaufnahme.",
);
}
if (countParagraphs(trimmed) > email.maxParagraphs) {
addIssue(
issues,
"emailBody",
"too_many_email_paragraphs",
"E-Mail sollte höchstens zwei kurze Absätze enthalten.",
);
}
const templatePhraseCount = EMAIL_TEMPLATE_PATTERNS.reduce(
(count, pattern) => count + countRegexMatches(trimmed, new RegExp(pattern.source, "gi")),
0,
);
const firstPersonCount = countRegexMatches(trimmed, /\bich\b/gi);
if (
startsWithTemplateEmailPhrase(trimmed) ||
templatePhraseCount >= 2 ||
firstPersonCount > 2
) {
addIssue(
issues,
"emailBody",
"formulaic_email_tone",
"E-Mail wirkt formelhaft; vermeide wiederholte Ich-habe-/Ich-schlage-vor-Muster.",
);
}
if (EMAIL_BROCHURE_PATTERNS.some((pattern) => pattern.test(trimmed))) {
addIssue(
issues,
"emailBody",
"brochure_email_language",
"E-Mail klingt nach Broschüre statt nach natürlicher Erstansprache.",
);
}
const topicCount = countMatches(trimmed, EMAIL_AUDIT_TOPIC_PATTERNS);
const transitionCount = countMatches(trimmed, EMAIL_MINI_AUDIT_TRANSITIONS);
if (topicCount >= 4 || transitionCount >= 2) {
addIssue(
issues,
"emailBody",
"email_reads_like_mini_audit",
"E-Mail bündelt zu viele Audit-Punkte und sollte höchstens zwei Befunde anreißen.",
);
}
if (INFORMAL_EMAIL_ADDRESS_PATTERNS.some((pattern) => pattern.test(trimmed))) {
addIssue(
issues,
"emailBody",
"informal_email_address",
"E-Mail sollte unbekannte lokale Betriebe formal mit Sie/Ihnen ansprechen.",
);
}
if (!hasLowFrictionAsk(trimmed)) {
addIssue(
issues,
"emailBody",
"missing_low_friction_ask",
"E-Mail sollte mit einer kurzen, leicht beantwortbaren Frage enden.",
);
}
}
function validateTextField(
issues: GermanCopyGuardIssue[],
field: string,
@@ -371,10 +609,12 @@ export function validateEmailCopy(email: EmailCopy): GermanCopyGuardResult {
const issues: GermanCopyGuardIssue[] = [];
validateTextField(issues, "emailSubject", email.subject, { skipIfTooShort: true });
validateEmailSubjectTone(issues, email.subject);
validateTextField(issues, "emailBody", email.body, {
requireIchForm: true,
requireObservationAndSuggestion: true,
requireIchForm: false,
requireObservationAndSuggestion: false,
});
validateEmailBodyTone(issues, email.body);
return { passed: issues.length === 0, issues };
}
@@ -386,7 +626,7 @@ export function validateCallScriptCopy(script: CallScriptCopy): GermanCopyGuardR
requireIchForm: true,
});
validateCallScriptText(issues, "callScript.closeLine", script.closeLine, {
requireIchForm: true,
requireIchForm: false,
});
script.callScript.forEach((line, index) => {
@@ -452,13 +692,15 @@ export function validateCustomerFacingCopy(input: GermanCustomerCopy): GermanCop
validateTextField(issues, "emailSubject", input.emailSubject, {
skipIfTooShort: true,
});
validateEmailSubjectTone(issues, input.emailSubject);
}
if (input.emailBody !== undefined) {
validateTextField(issues, "emailBody", input.emailBody, {
requireIchForm: true,
requireObservationAndSuggestion: true,
requireIchForm: false,
requireObservationAndSuggestion: false,
});
validateEmailBodyTone(issues, input.emailBody);
}
if (input.callScript) {

View File

@@ -0,0 +1,163 @@
import { parseSkillsRegistry } from "../skills-registry";
export const LOCAL_AUDIT_SKILL_REGISTRY_SOURCE = [
"## 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.",
"",
"## impeccable-critique",
"",
"```yaml",
"id: impeccable-critique",
"title: Impeccable Critique Review",
"applies_when: website_exists",
"inputs: [desktop_screenshot, mobile_screenshot, markdown, dom]",
"outputs: findings",
"```",
"",
"Bewerte die Seite wie ein strenger Design Director: visuelle Hierarchie,",
"Informationsarchitektur, kognitive Last, Orientierung, Lesbarkeit, Progressive",
"Disclosure und erkennbare AI-Slop-/Template-Muster. Nutze Nielsen-Heuristiken",
"als Denkrahmen, aber gib keine Score-Tabelle aus. Befunde müssen beobachtbar und",
"belegt sein: z. B. „mehrere gleich laute CTAs konkurrieren im sichtbaren Bereich\"",
"statt „Design wirkt beliebig\". Marken- oder Emotionsfit nur nennen, wenn Evidence",
"aus Screenshot, Text oder DOM vorliegt. Kundennutzen: eine klarere, weniger",
"generische Oberfläche senkt Zweifel und führt Besucher schneller zur Anfrage.",
"",
"## 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.",
"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.",
].join("\n");
export function loadLocalAuditSkillRegistry() {
return parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE);
}

View File

@@ -1,16 +1,108 @@
import { z } from "zod";
export const findingItemSchema = z.object({
const nonEmptyTextSchema = z.string().trim().min(1);
export const legacyFindingItemSchema = z.object({
section: z.string(),
finding: z.string(),
suggestion: z.string(),
});
export const v3FindingItemSchema = z.object({
skill_id: nonEmptyTextSchema,
observation: nonEmptyTextSchema,
customer_benefit: nonEmptyTextSchema,
public_phrasing: nonEmptyTextSchema,
severity: z.union([z.literal(1), z.literal(2), z.literal(3)]),
evidence: nonEmptyTextSchema,
applies: z.boolean(),
});
export const findingItemSchema = legacyFindingItemSchema;
export const auditFindingEvidenceRefSchema = z.object({
id: nonEmptyTextSchema,
type: z.enum([
"crawl_page",
"technical_check",
"screenshot",
"pagespeed",
"jina_excerpt",
"generation_stage",
]),
label: nonEmptyTextSchema,
sourceUrl: z.string().trim(),
});
export const auditSpecialistFindingSchema = z
.object({
skillId: nonEmptyTextSchema,
claim: nonEmptyTextSchema,
recommendation: nonEmptyTextSchema,
customerBenefit: nonEmptyTextSchema,
severity: z.union([z.literal(1), z.literal(2), z.literal(3)]),
confidence: z.number().min(0).max(1),
evidenceRefs: z.array(auditFindingEvidenceRefSchema).min(1),
applies: z.boolean(),
unknowns: z.array(z.string()),
})
.superRefine((finding, ctx) => {
const combined = [
finding.claim,
finding.recommendation,
finding.customerBenefit,
].join(" ");
if (/\bunbekannt\b|\bunknown\b/i.test(combined)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "unknown-only findings are not valid audit claims",
path: ["claim"],
});
}
});
export const auditSpecialistResultSchema = z.object({
status: z.enum(["success", "partial", "skipped", "failed"]),
findings: z.array(auditSpecialistFindingSchema),
notes: z.array(z.string()),
});
export const auditRejectedFindingSchema = z.object({
findingId: nonEmptyTextSchema,
skillId: nonEmptyTextSchema,
claim: nonEmptyTextSchema,
rejectionReason: nonEmptyTextSchema,
});
export const auditEvidenceVerificationSchema = z.object({
verifiedFindingIds: z.array(nonEmptyTextSchema),
rejectedFindings: z.array(auditRejectedFindingSchema),
contradictions: z.array(z.string()),
notes: z.array(z.string()),
});
export const internalFindingsSchema = z.object({
findings: z.array(findingItemSchema),
summary: z.string(),
});
export const auditClassificationSchema = z.object({
findings: z.array(v3FindingItemSchema).min(1),
summary: nonEmptyTextSchema,
usedSkills: z.array(nonEmptyTextSchema).nullable(),
});
export const auditGenerationResultSchema = z.object({
findings: z.array(v3FindingItemSchema).min(1),
usedSkills: z.array(nonEmptyTextSchema).min(1),
publicAuditText: nonEmptyTextSchema,
finalSummary: nonEmptyTextSchema,
emailSubject: nonEmptyTextSchema,
emailBody: nonEmptyTextSchema,
phoneScript: nonEmptyTextSchema,
ctaType: z.enum(["anruf", "termin", "rueckruf"]),
});
export const auditSummarySchema = z.object({
summary: z.string(),
keyFindings: z.array(z.string()),
@@ -36,19 +128,26 @@ export const callScriptSchema = z.object({
export const followUpDraftSchema = z.object({
message: z.string(),
followInDays: z.number().int().min(0).optional(),
goals: z.array(z.string()).optional(),
followInDays: z.number().int().min(0).nullable(),
goals: z.array(z.string()).nullable(),
});
export const qualityReviewSchema = z.object({
isValid: z.boolean(),
issues: z.array(z.string()),
suggestions: z.array(z.string()),
notes: z.array(z.string()).optional(),
notes: z.array(z.string()).nullable(),
});
export type FindingItem = z.infer<typeof findingItemSchema>;
export type V3FindingItem = z.infer<typeof v3FindingItemSchema>;
export type AuditFindingEvidenceRef = z.infer<typeof auditFindingEvidenceRefSchema>;
export type AuditSpecialistFinding = z.infer<typeof auditSpecialistFindingSchema>;
export type AuditSpecialistResult = z.infer<typeof auditSpecialistResultSchema>;
export type AuditEvidenceVerification = z.infer<typeof auditEvidenceVerificationSchema>;
export type InternalFindings = z.infer<typeof internalFindingsSchema>;
export type AuditClassification = z.infer<typeof auditClassificationSchema>;
export type AuditGenerationResult = z.infer<typeof auditGenerationResultSchema>;
export type AuditSummary = z.infer<typeof auditSummarySchema>;
export type PublicAuditText = z.infer<typeof publicAuditTextSchema>;
export type EmailDraft = z.infer<typeof emailDraftSchema>;

View File

@@ -86,6 +86,7 @@ export type LeadFunnelOutreach = {
sendStatus?: OutreachSendStatus | null;
responseStatus?: OutreachResponseStatus | null;
salesStatus?: OutreachSalesStatus | null;
doNotContactUntil?: number | null;
};
export type LeadFunnelInput = {
@@ -103,6 +104,7 @@ export type LeadFunnelInput = {
contactPerson?: string | null;
websiteDomain?: string | null;
outreach?: LeadFunnelOutreach | null;
now?: number;
};
export type LeadFunnelCard = {
@@ -303,6 +305,14 @@ function getLeadNextAction(lead: LeadFunnelInput): string {
const stageId = getLeadFunnelStageId(lead);
if (stageId === "deferred") {
if (
lead.outreach?.salesStatus === "do_not_pursue" &&
typeof lead.outreach.doNotContactUntil === "number" &&
(lead.now ?? Date.now()) >= lead.outreach.doNotContactUntil
) {
return "Erneut prüfen";
}
return "Zurückstellung prüfen";
}

View File

@@ -0,0 +1,233 @@
export type ExternalAuditUsageInput = {
openRouter?: {
inputTokens?: number;
outputTokens?: number;
inputUsdPerMillionTokens?: number;
outputUsdPerMillionTokens?: number;
};
screenshotOne?: {
screenshots?: number;
usdPerScreenshot?: number;
};
jina?: {
requests?: number;
pages?: number;
usdPerRequest?: number;
usdPerPage?: number;
};
pageSpeed?: {
requests?: number;
};
};
export type ExternalAuditCostEstimate = {
byProvider: {
openRouter: number;
screenshotOne: number;
jina: number;
pageSpeed: number;
};
totalUsd: number;
};
export type ScreenshotOneViewport = "desktop" | "mobile";
export type ScreenshotOneRequest = {
viewport: ScreenshotOneViewport;
url: string;
};
export type BuildScreenshotOneRequestsInput = {
accessKey: string;
targetUrl: string;
endpoint?: string;
};
export type JinaReaderPagePath = "/" | "/kontakt" | "/impressum" | "/leistungen" | "/ueber-uns";
export type JinaReaderPageInput = {
url: string;
markdown: string;
};
export type JinaReaderAuditInput = {
pages: Array<{
path: JinaReaderPagePath;
sourceUrl: string;
readerUrl: string;
}>;
readerUrls: string[];
markdown: string;
};
export type BuildJinaReaderAuditInputOptions = {
baseUrl: string;
pages?: JinaReaderPageInput[];
maxMarkdownChars: number;
};
const SCREENSHOT_ONE_ENDPOINT = "https://api.screenshotone.com/take";
const JINA_READER_PREFIX = "https://r.jina.ai/";
const JINA_PAGE_PATHS: JinaReaderPagePath[] = [
"/",
"/kontakt",
"/impressum",
"/leistungen",
"/ueber-uns",
];
function roundUsd(value: number): number {
return Math.round((value + Number.EPSILON) * 1_000_000) / 1_000_000;
}
function nonNegativeOrZero(value: number | undefined): number {
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
}
export function estimateExternalAuditCostUsd(
usage: ExternalAuditUsageInput,
): ExternalAuditCostEstimate {
const openRouter = roundUsd(
(nonNegativeOrZero(usage.openRouter?.inputTokens) / 1_000_000) *
nonNegativeOrZero(usage.openRouter?.inputUsdPerMillionTokens) +
(nonNegativeOrZero(usage.openRouter?.outputTokens) / 1_000_000) *
nonNegativeOrZero(usage.openRouter?.outputUsdPerMillionTokens),
);
const screenshotOne = roundUsd(
nonNegativeOrZero(usage.screenshotOne?.screenshots) *
nonNegativeOrZero(usage.screenshotOne?.usdPerScreenshot),
);
const jina = roundUsd(
nonNegativeOrZero(usage.jina?.requests) * nonNegativeOrZero(usage.jina?.usdPerRequest) +
nonNegativeOrZero(usage.jina?.pages) * nonNegativeOrZero(usage.jina?.usdPerPage),
);
const pageSpeed = 0;
return {
byProvider: {
openRouter,
screenshotOne,
jina,
pageSpeed,
},
totalUsd: roundUsd(openRouter + screenshotOne + jina + pageSpeed),
};
}
export function buildScreenshotOneRequests({
accessKey,
targetUrl,
endpoint = SCREENSHOT_ONE_ENDPOINT,
}: BuildScreenshotOneRequestsInput): ScreenshotOneRequest[] {
let normalizedTargetUrl: string;
try {
const parsedTargetUrl = parseWebUrl(targetUrl, "target URL");
normalizedTargetUrl = parsedTargetUrl.toString();
} catch {
throw new Error("Invalid target URL for ScreenshotOne request. Only http and https URLs are supported.");
}
const viewports: Array<{
viewport: ScreenshotOneViewport;
width: number;
height: number;
scale: number;
}> = [
{ viewport: "desktop", width: 1280, height: 900, scale: 1 },
{ viewport: "mobile", width: 390, height: 844, scale: 2 },
];
return viewports.map(({ viewport, width, height, scale }) => {
const requestUrl = new URL(endpoint);
requestUrl.searchParams.set("access_key", accessKey);
requestUrl.searchParams.set("url", normalizedTargetUrl);
requestUrl.searchParams.set("viewport_width", String(width));
requestUrl.searchParams.set("viewport_height", String(height));
requestUrl.searchParams.set("device_scale_factor", String(scale));
requestUrl.searchParams.set("full_page", "true");
requestUrl.searchParams.set("block_cookie_banners", "true");
requestUrl.searchParams.set("block_ads", "true");
requestUrl.searchParams.set("block_trackers", "true");
return {
viewport,
url: requestUrl.toString(),
};
});
}
export function buildJinaReaderAuditInput({
baseUrl,
pages = [],
maxMarkdownChars,
}: BuildJinaReaderAuditInputOptions): JinaReaderAuditInput {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const pagesByUrl = new Map(
pages.map((page) => [normalizeComparableUrl(page.url), page.markdown]),
);
const preparedPages = JINA_PAGE_PATHS.map((path) => {
const sourceUrl = new URL(path, normalizedBaseUrl).toString();
const readerUrl = toJinaReaderUrl(sourceUrl);
return {
path,
sourceUrl,
readerUrl,
};
});
const markdown = preparedPages
.map((page) => {
const pageMarkdown = pagesByUrl.get(normalizeComparableUrl(page.sourceUrl)) ?? "";
return `Source: ${page.sourceUrl}\n\n${pageMarkdown.trim()}`;
})
.join("\n\n---\n\n");
return {
pages: preparedPages,
readerUrls: preparedPages.map((page) => page.readerUrl),
markdown: capMarkdown(markdown, maxMarkdownChars),
};
}
function normalizeBaseUrl(baseUrl: string): URL {
try {
const url = parseWebUrl(baseUrl, "base URL");
url.hash = "";
url.search = "";
url.pathname = "/";
return url;
} catch {
throw new Error("Invalid base URL for Jina Reader input. Only http and https URLs are supported.");
}
}
function normalizeComparableUrl(url: string): string {
const normalized = parseWebUrl(url, "page URL");
normalized.hash = "";
if (normalized.pathname !== "/" && normalized.pathname.endsWith("/")) {
normalized.pathname = normalized.pathname.slice(0, -1);
}
return normalized.toString();
}
function toJinaReaderUrl(sourceUrl: string): string {
const url = parseWebUrl(sourceUrl, "source URL");
return `${JINA_READER_PREFIX}${url.protocol}//${url.host}${url.pathname}${url.search}`;
}
function parseWebUrl(value: string, label: string): URL {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Invalid ${label}. Only http and https URLs are supported.`);
}
return url;
}
function capMarkdown(markdown: string, maxMarkdownChars: number): string {
if (markdown.length <= maxMarkdownChars) {
return markdown;
}
const suffix = `[truncated to ${maxMarkdownChars} chars]`;
const availableChars = Math.max(0, maxMarkdownChars - suffix.length);
return `${markdown.slice(0, availableChars)}${suffix}`;
}

View File

@@ -0,0 +1,90 @@
export type IntegrationReadinessStatus = "configured" | "missing";
export type IntegrationReadinessDefinition = {
id:
| "google"
| "pagespeed"
| "openrouter"
| "screenshotone"
| "smtp"
| "convex_jobs"
| "rybbit"
| "jina";
label: string;
requiredEnv: string[];
errorSurface: string;
};
export type IntegrationReadinessRow = IntegrationReadinessDefinition & {
status: IntegrationReadinessStatus;
missingEnv: string[];
};
export const integrationReadinessDefinitions: IntegrationReadinessDefinition[] = [
{
id: "google",
label: "Google",
requiredEnv: ["GOOGLE_GEOCODING_API_KEY", "GOOGLE_PLACES_API_KEY"],
errorSurface: "Run-Events der Lead-Recherche zeigen Google-Fehler.",
},
{
id: "pagespeed",
label: "PageSpeed",
requiredEnv: ["PAGESPEED_API_KEY", "PAGESPEED_TIMEOUT_MS"],
errorSurface: "PageSpeed-Run-Events und Audit-Quellen zeigen Fehlerdetails.",
},
{
id: "openrouter",
label: "OpenRouter",
requiredEnv: ["OPENROUTER_API_KEY"],
errorSurface: "Audit-Generierungsruns zeigen Modell- und Guard-Fehler.",
},
{
id: "screenshotone",
label: "ScreenshotOne",
requiredEnv: ["SCREENSHOTONE_API_KEY"],
errorSurface:
"Convex-Run-Events der Audit-Generierung zeigen fehlende Keys, API-, Quota- und Rendering-Fehler.",
},
{
id: "smtp",
label: "SMTP",
requiredEnv: ["SMTP_HOST", "SMTP_USER", "SMTP_PASSWORD", "SMTP_FROM"],
errorSurface: "Outreach-Sendeversuche zeigen SMTP-Fehler ohne Credentials.",
},
{
id: "convex_jobs",
label: "Convex Jobs",
requiredEnv: ["NEXT_PUBLIC_CONVEX_URL", "CONVEX_DEPLOYMENT"],
errorSurface: "Kampagnen-Run-Logs und Lifecycle-Runs zeigen Job-Status.",
},
{
id: "rybbit",
label: "Rybbit",
requiredEnv: ["RYBBIT_API_URL", "RYBBIT_API_KEY", "NEXT_PUBLIC_RYBBIT_SITE_ID"],
errorSurface: "Analytics zeigt API-Fehler als nicht blockierende Meldung.",
},
{
id: "jina",
label: "Jina",
requiredEnv: [],
errorSurface: "Optionaler Fetch-/Reader-Fallback zeigt Fehler im Audit-Quellenkontext.",
},
];
export function getIntegrationReadiness(
env: Record<string, string | undefined>,
): IntegrationReadinessRow[] {
return integrationReadinessDefinitions.map((definition) => {
const missingEnv = definition.requiredEnv.filter((key) => {
const value = env[key];
return !value || value.trim().length === 0;
});
return {
...definition,
missingEnv,
status: missingEnv.length === 0 ? "configured" : "missing",
};
});
}

298
lib/rybbit-analytics.ts Normal file
View File

@@ -0,0 +1,298 @@
export type RybbitEvent = {
type?: string;
timestamp?: string;
pathname?: string;
path?: string;
url?: string;
event_name?: string;
name?: string;
properties?: Record<string, unknown>;
};
export type AuditRybbitSummary = {
opened: boolean;
viewCount: number;
lastView: string | null;
ctaClicks: number;
websiteLinkClicks: number;
deviceTypes: string[];
};
export type CampaignRybbitSummary = {
auditOpens: number;
ctaClicks: number;
outboundClicks: number;
byPath: Record<string, {
auditOpens: number;
ctaClicks: number;
outboundClicks: number;
}>;
};
type FetchLike = (
input: string | URL,
init?: RequestInit,
) => Promise<Pick<Response, "ok" | "status" | "json" | "text">>;
export function buildRybbitEventsUrl(input: {
apiUrl: string;
siteId: string;
startDate?: string;
endDate?: string;
}) {
const base = input.apiUrl.endsWith("/") ? input.apiUrl : `${input.apiUrl}/`;
const url = new URL(`api/sites/${encodeURIComponent(input.siteId)}/events`, base);
if (input.startDate) {
url.searchParams.set("start_date", input.startDate);
}
if (input.endDate) {
url.searchParams.set("end_date", input.endDate);
}
return url;
}
function eventPath(event: RybbitEvent) {
const propertyPath =
typeof event.properties?.pathname === "string"
? event.properties.pathname
: typeof event.properties?.path === "string"
? event.properties.path
: undefined;
return event.pathname ?? event.path ?? propertyPath ?? event.url ?? "";
}
function eventName(event: RybbitEvent) {
const propertyName =
typeof event.properties?.event_name === "string"
? event.properties.event_name
: typeof event.properties?.name === "string"
? event.properties.name
: undefined;
return event.event_name ?? event.name ?? propertyName ?? "";
}
function eventDevice(event: RybbitEvent) {
const value =
event.properties?.deviceType ??
event.properties?.device_type ??
event.properties?.device;
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}
function isAuditEvent(event: RybbitEvent, auditPath: string) {
const path = eventPath(event);
return path === auditPath || path.endsWith(auditPath);
}
export function summarizeAuditRybbitEvents(
events: RybbitEvent[],
auditPath: string,
): AuditRybbitSummary {
const matchingEvents = events.filter((event) => isAuditEvent(event, auditPath));
const pageviews = matchingEvents.filter((event) => event.type === "pageview");
const ctaClicks = matchingEvents.filter((event) => {
const name = eventName(event);
return event.type === "custom_event" && name === "audit_cta_click";
});
const websiteLinkClicks = matchingEvents.filter((event) => {
const name = eventName(event);
return event.type === "outbound_link" || name === "audit_website_link_click";
});
const lastView = pageviews
.map((event) => event.timestamp)
.filter((timestamp): timestamp is string => Boolean(timestamp))
.sort()
.at(-1) ?? null;
const deviceTypes = [
...new Set(
matchingEvents
.map(eventDevice)
.filter((device): device is string => device !== null),
),
].sort();
return {
opened: pageviews.length > 0,
viewCount: pageviews.length,
lastView,
ctaClicks: ctaClicks.length,
websiteLinkClicks: websiteLinkClicks.length,
deviceTypes,
};
}
export function summarizeCampaignRybbitEvents(
events: RybbitEvent[],
): CampaignRybbitSummary {
const auditEvents = events.filter((event) => eventPath(event).startsWith("/audit/"));
const byPath: CampaignRybbitSummary["byPath"] = {};
for (const event of auditEvents) {
const path = eventPath(event);
byPath[path] ??= { auditOpens: 0, ctaClicks: 0, outboundClicks: 0 };
if (event.type === "pageview") {
byPath[path].auditOpens += 1;
}
if (event.type === "custom_event" && eventName(event) === "audit_cta_click") {
byPath[path].ctaClicks += 1;
}
if (
event.type === "outbound_link" ||
eventName(event) === "audit_website_link_click"
) {
byPath[path].outboundClicks += 1;
}
}
return {
auditOpens: auditEvents.filter((event) => event.type === "pageview").length,
ctaClicks: auditEvents.filter((event) => {
return event.type === "custom_event" && eventName(event) === "audit_cta_click";
}).length,
outboundClicks: auditEvents.filter((event) => {
return event.type === "outbound_link" ||
eventName(event) === "audit_website_link_click";
}).length,
byPath,
};
}
function normalizeEventsPayload(payload: unknown): RybbitEvent[] {
if (Array.isArray(payload)) {
return payload.filter((event): event is RybbitEvent => typeof event === "object" && event !== null);
}
if (
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
Array.isArray((payload as { data?: unknown }).data)
) {
return (payload as { data: unknown[] }).data.filter(
(event): event is RybbitEvent => typeof event === "object" && event !== null,
);
}
return [];
}
export async function fetchRybbitAuditAnalytics(input: {
apiUrl?: string;
apiKey?: string;
siteId?: string;
auditPath: string;
startDate?: string;
endDate?: string;
fetchImpl?: FetchLike;
}) {
if (!input.apiUrl || !input.apiKey || !input.siteId) {
return {
ok: false as const,
error: "Rybbit ist nicht vollständig konfiguriert.",
data: summarizeAuditRybbitEvents([], input.auditPath),
};
}
try {
const response = await (input.fetchImpl ?? fetch)(
buildRybbitEventsUrl({
apiUrl: input.apiUrl,
siteId: input.siteId,
startDate: input.startDate,
endDate: input.endDate,
}),
{
headers: {
Authorization: `Bearer ${input.apiKey}`,
},
},
);
if (!response.ok) {
const body = await response.text();
return {
ok: false as const,
error: `Rybbit API Fehler ${response.status}: ${body.slice(0, 160)}`,
data: summarizeAuditRybbitEvents([], input.auditPath),
};
}
const payload = await response.json();
return {
ok: true as const,
data: summarizeAuditRybbitEvents(
normalizeEventsPayload(payload),
input.auditPath,
),
};
} catch (error) {
return {
ok: false as const,
error: error instanceof Error ? error.message : String(error),
data: summarizeAuditRybbitEvents([], input.auditPath),
};
}
}
export async function fetchRybbitCampaignAnalytics(input: {
apiUrl?: string;
apiKey?: string;
siteId?: string;
startDate?: string;
endDate?: string;
fetchImpl?: FetchLike;
}) {
if (!input.apiUrl || !input.apiKey || !input.siteId) {
return {
ok: false as const,
error: "Rybbit ist nicht vollständig konfiguriert.",
data: summarizeCampaignRybbitEvents([]),
};
}
try {
const response = await (input.fetchImpl ?? fetch)(
buildRybbitEventsUrl({
apiUrl: input.apiUrl,
siteId: input.siteId,
startDate: input.startDate,
endDate: input.endDate,
}),
{
headers: {
Authorization: `Bearer ${input.apiKey}`,
},
},
);
if (!response.ok) {
const body = await response.text();
return {
ok: false as const,
error: `Rybbit API Fehler ${response.status}: ${body.slice(0, 160)}`,
data: summarizeCampaignRybbitEvents([]),
};
}
return {
ok: true as const,
data: summarizeCampaignRybbitEvents(
normalizeEventsPayload(await response.json()),
),
};
} catch (error) {
return {
ok: false as const,
error: error instanceof Error ? error.message : String(error),
data: summarizeCampaignRybbitEvents([]),
};
}
}

View File

@@ -13,20 +13,27 @@ export const SKILL_CATEGORIES = [
export type SkillCategory = (typeof SKILL_CATEGORIES)[number];
export type SkillRegistryEntry = {
id?: string;
name: string;
title?: string;
purpose: string;
whenToUse: string;
whenNotToUse: string;
requiredInput: string;
expectedOutput: string;
category: SkillCategory;
category?: SkillCategory;
appliesWhen?: string;
inputs?: string[];
outputs?: string;
instructions?: string;
version?: string;
source?: string;
};
export type AuditUsedSkill = {
id?: string;
name: string;
category: SkillCategory;
category?: SkillCategory;
version?: string;
source?: string;
};
@@ -51,6 +58,7 @@ const REQUIRED_FIELDS: ParsedFieldName[] = [
];
const FIELD_LABELS_RE = /^(Purpose|When to use|When not to use|Required input|Expected output|Category|Version|Source):\s*(.*?)\s*$/;
const V3_META_BLOCK_RE = /```yaml\s*\n([\s\S]*?)\n```\s*\n?([\s\S]*)$/;
function normalizeCategory(value: string): SkillCategory {
const normalized = value.toLowerCase();
@@ -129,6 +137,108 @@ function parseSection(lines: string[], sectionIndex: number): SkillRegistryEntry
};
}
function parseV3List(value: string): string[] {
const trimmed = value.trim();
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) {
return trimmed ? [trimmed] : [];
}
return trimmed
.slice(1, -1)
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
function parseV3MetaBlock(metaSource: string): Record<string, string> {
const values: Record<string, string> = {};
for (const line of metaSource.split("\n")) {
const match = line.trim().match(/^([a-z_]+):\s*(.*?)\s*$/);
if (match) {
values[match[1]] = match[2].trim();
}
}
return values;
}
function parseV3Section(
rawBody: string,
sectionIndex: number,
): SkillRegistryEntry | null {
const match = rawBody.match(V3_META_BLOCK_RE);
if (!match) {
return null;
}
const values = parseV3MetaBlock(match[1]);
if (!values.id) {
return null;
}
const requiredFields = ["id", "title", "applies_when", "inputs", "outputs"];
for (const field of requiredFields) {
if (!values[field]) {
throw new Error(
`Missing required v3 field "${field}" for skill section ${sectionIndex}.`,
);
}
}
const id = values.id;
const title = values.title;
const inputs = parseV3List(values.inputs);
const instructions = match[2].trim();
if (instructions.length === 0) {
throw new Error(`Missing instructions for v3 skill "${id}".`);
}
return {
id,
name: title,
title,
purpose: instructions,
whenToUse: values.applies_when,
whenNotToUse: "Use only when applies_when and inputs match.",
requiredInput: inputs.join(", "),
expectedOutput: values.outputs,
appliesWhen: values.applies_when,
inputs,
outputs: values.outputs,
instructions,
};
}
function addParsedEntry(
entries: SkillRegistryEntry[],
names: Set<string>,
ids: Set<string>,
parsed: SkillRegistryEntry,
) {
const normalizedName = parsed.name.trim().toLowerCase();
if (names.has(normalizedName)) {
throw new Error(`Duplicate skill name "${parsed.name}" in skills registry.`);
}
if (parsed.id) {
const normalizedId = parsed.id.trim().toLowerCase();
if (ids.has(normalizedId)) {
throw new Error(`Duplicate skill id "${parsed.id}" in skills registry.`);
}
ids.add(normalizedId);
}
names.add(normalizedName);
entries.push(parsed);
}
function hasLegacyFieldLabels(source: string): boolean {
return source
.split("\n")
.some((line) => FIELD_LABELS_RE.test(line.trim()));
}
export function parseSkillsRegistry(source: string): SkillRegistryEntry[] {
const normalized = source.replace(/\r\n/g, "\n");
const rawSections = normalized
@@ -138,6 +248,45 @@ export function parseSkillsRegistry(source: string): SkillRegistryEntry[] {
const entries: SkillRegistryEntry[] = [];
const names = new Set<string>();
const ids = new Set<string>();
const v3Entries: SkillRegistryEntry[] = [];
for (let index = 0; index < rawSections.length; index += 1) {
const rawSection = rawSections[index];
const lines = rawSection
.split("\n")
.map((line) => line.trimEnd())
.filter((line, lineIndex) => line.length > 0 || lineIndex === 0);
const sectionBody = lines.slice(1).join("\n");
const parsed = parseV3Section(sectionBody, index + 1);
if (parsed && parsed.id !== "kebab-case-id") {
v3Entries.push(parsed);
}
}
if (v3Entries.length > 0) {
for (let index = 0; index < rawSections.length; index += 1) {
const rawSection = rawSections[index];
const lines = rawSection
.split("\n")
.map((line) => line.trimEnd())
.filter((line, lineIndex) => line.length > 0 || lineIndex === 0);
const sectionTitle = lines.at(0) ?? "";
const sectionBody = lines.slice(1).join("\n");
const sectionLines = [`## ${sectionTitle}`, ...lines.slice(1)];
const parsed = parseV3Section(sectionBody, index + 1);
if (parsed) {
if (parsed.id !== "kebab-case-id") {
addParsedEntry(entries, names, ids, parsed);
}
continue;
}
if (hasLegacyFieldLabels(sectionBody)) {
addParsedEntry(entries, names, ids, parseSection(sectionLines, index + 1));
}
}
return entries;
}
for (let index = 0; index < rawSections.length; index += 1) {
const rawSection = rawSections[index];
@@ -146,16 +295,10 @@ export function parseSkillsRegistry(source: string): SkillRegistryEntry[] {
.map((line) => line.trimEnd())
.filter((line, lineIndex) => line.length > 0 || lineIndex === 0);
const sectionLines = [`## ${lines.at(0) ?? ""}`, ...lines.slice(1)];
const sectionTitle = lines.at(0) ?? "";
const sectionLines = [`## ${sectionTitle}`, ...lines.slice(1)];
const parsed = parseSection(sectionLines, index + 1);
const normalizedName = parsed.name.trim().toLowerCase();
if (names.has(normalizedName)) {
throw new Error(`Duplicate skill name "${parsed.name}" in skills registry.`);
}
names.add(normalizedName);
entries.push(parsed);
addParsedEntry(entries, names, ids, parsed);
}
return entries;
@@ -169,10 +312,24 @@ export async function loadSkillsRegistry(
}
export function toAuditUsedSkill(skill: SkillRegistryEntry): AuditUsedSkill {
return {
const usedSkill: AuditUsedSkill = {
name: skill.name,
category: skill.category,
version: skill.version,
source: skill.source,
};
if (skill.id) {
usedSkill.id = skill.id;
}
if (skill.category) {
usedSkill.category = skill.category;
}
if (!skill.version) {
delete usedSkill.version;
}
if (!skill.source) {
delete usedSkill.source;
}
return usedSkill;
}

View File

@@ -8,15 +8,19 @@ import {
auditSummarySchema,
qualityReviewSchema,
publicAuditTextSchema,
auditClassificationSchema,
internalFindingsSchema,
auditGenerationResultSchema,
type CallScript,
type EmailDraft,
type EmailSubject,
type FollowUpDraft,
type AuditSummary,
type PublicAuditText,
type AuditClassification,
type QualityReview,
type InternalFindings,
type AuditGenerationResult,
} from "../lib/ai/schemas";
test("internal findings schema accepts task-focused evidence", () => {
@@ -35,6 +39,270 @@ test("internal findings schema accepts task-focused evidence", () => {
assert.equal(parsed.findings[0].section, "UX");
});
test("audit generation result schema accepts v3 findings and aggregate outreach fields", () => {
const parsed = auditGenerationResultSchema.parse({
findings: [
{
skill_id: "contact-conversion",
observation: "Die Telefonnummer ist mobil erst nach langem Scrollen sichtbar.",
customer_benefit: "Ein sichtbarer Kontaktweg senkt Reibung und erhöht Anfragen.",
public_phrasing:
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
severity: 3,
evidence: "screenshot_mobile",
applies: true,
},
],
usedSkills: ["contact-conversion", "mobile-usability"],
publicAuditText:
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
finalSummary: "Hohe Priorität: mobile Kontaktaufnahme sichtbarer machen.",
emailSubject: "Kurzer Blick auf euren Webauftritt",
emailBody: "Hallo, ich habe mir eure Website angesehen...",
phoneScript: "Ich habe mir kurz eure mobile Kontaktstrecke angesehen.",
ctaType: "anruf",
});
assert.equal(parsed.findings[0].skill_id, "contact-conversion");
assert.equal(parsed.findings[0].severity, 3);
assert.equal(parsed.findings[0].applies, true);
assert.deepEqual(parsed.usedSkills, ["contact-conversion", "mobile-usability"]);
});
test("audit classification schema accepts v3 findings and required used skills", () => {
const parsed = auditClassificationSchema.parse({
findings: [
{
skill_id: "contact-conversion",
observation: "Die Telefonnummer ist mobil erst nach langem Scrollen sichtbar.",
customer_benefit: "Ein sichtbarer Kontaktweg senkt Reibung und erhöht Anfragen.",
public_phrasing:
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
severity: 3,
evidence: "screenshot_mobile",
applies: true,
},
],
summary: "Kontaktaufnahme hat die höchste Priorität.",
usedSkills: ["contact-conversion"],
});
assert.equal(parsed.findings[0].skill_id, "contact-conversion");
assert.deepEqual(parsed.usedSkills, ["contact-conversion"]);
});
test("structured output schemas avoid optional top-level fields for OpenAI strict mode", () => {
const classificationPayload = {
findings: [
{
skill_id: "contact-conversion",
observation: "Die Telefonnummer ist mobil erst nach langem Scrollen sichtbar.",
customer_benefit: "Ein sichtbarer Kontaktweg senkt Reibung und erhöht Anfragen.",
public_phrasing:
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
severity: 3,
evidence: "screenshot_mobile",
applies: true,
},
],
summary: "Kontaktaufnahme hat die höchste Priorität.",
} as const;
assert.throws(
() => auditClassificationSchema.parse(classificationPayload),
/usedSkills|invalid|required/i,
);
assert.equal(
auditClassificationSchema.parse({
...classificationPayload,
usedSkills: null,
}).usedSkills,
null,
);
assert.throws(
() =>
followUpDraftSchema.parse({
message: "Kurzer Follow-up-Hinweis für nächste Woche.",
}),
/followInDays|goals|invalid|required/i,
);
const followParsed = followUpDraftSchema.parse({
message: "Kurzer Follow-up-Hinweis für nächste Woche.",
followInDays: null,
goals: null,
});
assert.equal(followParsed.followInDays, null);
assert.equal(followParsed.goals, null);
assert.throws(
() =>
qualityReviewSchema.parse({
isValid: true,
issues: [],
suggestions: [],
}),
/notes|invalid|required/i,
);
assert.equal(
qualityReviewSchema.parse({
isValid: true,
issues: [],
suggestions: [],
notes: null,
}).notes,
null,
);
});
test("audit classification schema rejects legacy-only finding payloads", () => {
assert.throws(
() =>
auditClassificationSchema.parse({
findings: [
{
section: "UX",
finding: "Landingpage is not responsive on mobile viewport.",
suggestion: "Add responsive breakpoints for cards and typography.",
},
],
summary: "Legacy payload.",
}),
/invalid|expected|required/i,
);
});
test("v3 finding severity only accepts internal priority levels 1 through 3", () => {
assert.throws(
() =>
auditGenerationResultSchema.parse({
findings: [
{
skill_id: "visual-design",
observation: "Kontrast ist gering.",
customer_benefit: "Bessere Lesbarkeit stärkt den ersten Eindruck.",
public_phrasing: "Ein staerkerer Kontrast wuerde die Lesbarkeit verbessern.",
severity: 4,
evidence: "screenshot_desktop",
applies: true,
},
],
usedSkills: ["visual-design"],
publicAuditText: "Ein staerkerer Kontrast wuerde die Lesbarkeit verbessern.",
finalSummary: "Kontrast priorisieren.",
emailSubject: "Kurzer Website-Hinweis",
emailBody: "Hallo...",
phoneScript: "Kurzer Gespraechseinstieg.",
ctaType: "anruf",
}),
/invalid input/i,
);
});
test("audit generation result schema rejects blank text fields and empty collections", () => {
const validPayload = {
findings: [
{
skill_id: "contact-conversion",
observation: "Die Telefonnummer ist mobil erst nach langem Scrollen sichtbar.",
customer_benefit: "Ein sichtbarer Kontaktweg senkt Reibung und erhöht Anfragen.",
public_phrasing:
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
severity: 2,
evidence: "screenshot_mobile",
applies: true,
},
],
usedSkills: ["contact-conversion"],
publicAuditText:
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
finalSummary: "Mobile Kontaktaufnahme sichtbarer machen.",
emailSubject: "Kurzer Blick auf euren Webauftritt",
emailBody: "Hallo, ich habe mir eure Website angesehen...",
phoneScript: "Ich habe mir kurz eure mobile Kontaktstrecke angesehen.",
ctaType: "termin",
};
assert.throws(
() =>
auditGenerationResultSchema.parse({
...validPayload,
publicAuditText: " ",
}),
/too small|invalid/i,
);
assert.throws(
() =>
auditGenerationResultSchema.parse({
...validPayload,
findings: [],
}),
/too small|invalid/i,
);
assert.throws(
() =>
auditGenerationResultSchema.parse({
...validPayload,
usedSkills: [],
}),
/too small|invalid/i,
);
assert.throws(
() =>
auditGenerationResultSchema.parse({
...validPayload,
findings: [
{
...validPayload.findings[0],
observation: "",
},
],
}),
/too small|invalid/i,
);
});
test("audit generation result schema only accepts documented cta types", () => {
const basePayload = {
findings: [
{
skill_id: "visual-design",
observation: "Die Schrift ist mobil klein.",
customer_benefit: "Lesbare Inhalte halten Besucher laenger auf der Seite.",
public_phrasing: "Die mobile Schrift koennte an einigen Stellen lesbarer sein.",
severity: 1,
evidence: "screenshot_mobile",
applies: true,
},
],
usedSkills: ["visual-design"],
publicAuditText: "Die mobile Schrift koennte an einigen Stellen lesbarer sein.",
finalSummary: "Mobile Lesbarkeit verbessern.",
emailSubject: "Kurzer Website-Hinweis",
emailBody: "Hallo...",
phoneScript: "Kurzer Gespraechseinstieg.",
};
for (const ctaType of ["anruf", "termin", "rueckruf"] as const) {
assert.equal(
auditGenerationResultSchema.parse({
...basePayload,
ctaType,
}).ctaType,
ctaType,
);
}
assert.throws(
() =>
auditGenerationResultSchema.parse({
...basePayload,
ctaType: "angebot",
}),
/invalid/i,
);
});
test("audit summary and public text schemas remain intentionally lightweight", () => {
const summaryParsed = auditSummarySchema.parse({
summary: "Kurze Zusammenfassung mit den wichtigsten Verbesserungen.",
@@ -72,6 +340,7 @@ test("outreach schemas parse German customer-facing payloads", () => {
isValid: true,
issues: [],
suggestions: ["Mehr Kundennutzen konkret beschreiben."],
notes: null,
});
assert.equal(typeof emailDraftParsed.body, "string");
@@ -118,12 +387,52 @@ test("schema-inferred types are exported for Convex action wiring", () => {
const typedFollowUp: FollowUpDraft = {
message: "Kurzes Follow-up ohne harte Floskel.",
followInDays: null,
goals: null,
};
const typedQuality: QualityReview = {
isValid: true,
issues: [],
suggestions: [],
notes: null,
};
const typedAuditGeneration: AuditGenerationResult = {
findings: [
{
skill_id: "visual-design",
observation: "Schrift ist mobil klein.",
customer_benefit: "Lesbare Inhalte halten Besucher laenger auf der Seite.",
public_phrasing: "Die mobile Schrift koennte an einigen Stellen lesbarer sein.",
severity: 2,
evidence: "screenshot_mobile",
applies: true,
},
],
usedSkills: ["visual-design"],
publicAuditText: "Die mobile Schrift koennte an einigen Stellen lesbarer sein.",
finalSummary: "Mobile Lesbarkeit verbessern.",
emailSubject: "Kurzer Website-Hinweis",
emailBody: "Hallo...",
phoneScript: "Kurzer Gespraechseinstieg.",
ctaType: "anruf",
};
const typedClassification: AuditClassification = {
findings: [
{
skill_id: "contact-conversion",
observation: "Kontakt ist mobil spaet sichtbar.",
customer_benefit: "Schneller Kontakt senkt Reibung.",
public_phrasing: "Der Kontaktweg koennte mobil schneller sichtbar sein.",
severity: 2,
evidence: "screenshot_mobile",
applies: true,
},
],
summary: "Kontaktweg priorisieren.",
usedSkills: ["contact-conversion"],
};
assert.equal(typedFindings.findings.length, 1);
@@ -134,4 +443,6 @@ test("schema-inferred types are exported for Convex action wiring", () => {
assert.equal(typedCall.callScript.length, 1);
assert.equal(typedFollowUp.message.length > 0, true);
assert.equal(typedQuality.isValid, true);
assert.equal(typedAuditGeneration.usedSkills.length, 1);
assert.equal(typedClassification.findings.length, 1);
});

View File

@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
function source(path: string) {
return readFileSync(join(process.cwd(), ...path.split("/")), "utf8");
}
test("Rybbit tracking is mounted only in public audit presentation", () => {
const publicAuditSource = source("components/public-audit/public-audit-page.tsx");
const dashboardLayoutSource = source("app/dashboard/layout.tsx");
const dashboardAnalyticsSource = source("app/dashboard/analytics/page.tsx");
assert.match(publicAuditSource, /RybbitTracking/);
assert.match(publicAuditSource, /TrackedPublicAuditLink/);
assert.doesNotMatch(dashboardLayoutSource, /RybbitTracking|rybbit/i);
assert.doesNotMatch(dashboardAnalyticsSource, /next\/script|RybbitTracking/);
});
test("internal Rybbit route fetches audit analytics on demand with graceful errors", () => {
const routePath = "app/api/internal/rybbit/audit/route.ts";
assert.equal(existsSync(join(process.cwd(), ...routePath.split("/"))), true);
const routeSource = source(routePath);
assert.match(routeSource, /export async function GET/);
assert.match(routeSource, /fetchRybbitAuditAnalytics/);
assert.match(routeSource, /RYBBIT_API_KEY/);
assert.match(routeSource, /return Response\.json\(\{ ok: false/);
});
test("campaign metrics query exposes lightweight funnel and run metrics", () => {
const metricsSource = source("convex/campaignMetrics.ts");
assert.match(metricsSource, /export const getDashboard = query/);
for (const label of [
"foundLeads",
"leadsWithContact",
"missingContact",
"auditsCreated",
"approvalsOpen",
"emailsSent",
"followUpsPlanned",
"followUpsSent",
"responses",
"conversations",
"offers",
"wins",
"losses",
"skippedDuplicates",
"skippedBlacklisted",
]) {
assert.match(metricsSource, new RegExp(label));
}
assert.match(metricsSource, /auditSegments/);
assert.match(metricsSource, /campaignName/);
assert.match(metricsSource, /region/);
});
test("analytics dashboard renders filters, Convex metrics, and Rybbit error states", () => {
const pageSource = source("app/dashboard/analytics/page.tsx");
const componentSource = source("components/analytics/analytics-dashboard.tsx");
assert.doesNotMatch(pageSource, /DashboardPlaceholderPage/);
assert.match(pageSource, /AnalyticsDashboard/);
assert.match(componentSource, /api\.campaignMetrics\.getDashboard/);
assert.match(componentSource, /\/api\/internal\/rybbit\/campaign/);
assert.match(componentSource, /Kampagne/);
assert.match(componentSource, /Nische/);
assert.match(componentSource, /PLZ/);
assert.match(componentSource, /Radius/);
assert.match(componentSource, /Priorität/);
assert.match(componentSource, /Status/);
assert.match(componentSource, /Zeitraum/);
assert.match(componentSource, /Rybbit-Daten konnten nicht geladen werden/);
assert.match(componentSource, /Audit-Öffnungen/);
assert.match(componentSource, /CTA-Klicks/);
assert.match(componentSource, /rybbitGroups/);
assert.match(componentSource, /Kampagne/);
assert.match(componentSource, /Nische/);
assert.match(componentSource, /Region/);
});

View File

@@ -5,6 +5,8 @@ import {
buildAuditEvidenceInput,
type SkillRegistryEntryEvidence,
} from "../lib/ai/audit-evidence";
import { LOCAL_AUDIT_SKILL_REGISTRY_SOURCE } from "../lib/ai/local-audit-skill-registry";
import { parseSkillsRegistry } from "../lib/skills-registry";
const SAMPLE_SKILL_REGISTRY: SkillRegistryEntryEvidence[] = [
{
@@ -225,6 +227,124 @@ test("buildAuditEvidenceInput preserves screenshot references without base64 pay
}
});
test("buildAuditEvidenceInput creates stable evidence ledger refs for source facts", () => {
const first = buildAuditEvidenceInput({
crawlPages: [
{
sourceUrl: "https://example.com",
finalUrl: "https://example.com/",
pageKind: "homepage",
title: "Startseite",
metaDescription: "Bäckerei Muster in Berlin",
visibleTextExcerpt: "Bäckerei Muster Berlin mit Kontakt und Öffnungszeiten.",
hasContactCtaSignal: true,
},
],
technicalChecks: [
{
sourceUrl: "https://example.com",
finalUrl: "https://example.com/",
usesHttps: true,
missingMetaDescription: false,
hasVisibleContactPath: true,
brokenInternalLinkCount: 0,
},
],
screenshots: [
{
storageId: "storage-home-mobile",
sourceUrl: "https://example.com",
viewport: "mobile",
width: 390,
height: 844,
mimeType: "image/png",
capturedAt: 1_700_000_001_000,
},
],
pageSpeedInputs: [
{
strategy: "mobile",
status: "succeeded",
sourceUrl: "https://example.com",
normalized: {
implications: [
"Die wichtigsten Inhalte erscheinen auf dem Smartphone spürbar verzögert.",
],
},
},
],
externalMarkdown:
"# Startseite\nBäckerei Muster Berlin. Telefon und Öffnungszeiten sind sichtbar.",
skillRegistry: SAMPLE_SKILL_REGISTRY,
});
const second = buildAuditEvidenceInput({
...first,
crawlPages: [
{
sourceUrl: "https://example.com",
finalUrl: "https://example.com/",
pageKind: "homepage",
title: "Startseite",
metaDescription: "Bäckerei Muster in Berlin",
visibleTextExcerpt: "Bäckerei Muster Berlin mit Kontakt und Öffnungszeiten.",
hasContactCtaSignal: true,
},
],
technicalChecks: [
{
sourceUrl: "https://example.com",
finalUrl: "https://example.com/",
usesHttps: true,
missingMetaDescription: false,
hasVisibleContactPath: true,
brokenInternalLinkCount: 0,
},
],
screenshots: [
{
storageId: "storage-home-mobile",
sourceUrl: "https://example.com",
viewport: "mobile",
width: 390,
height: 844,
mimeType: "image/png",
capturedAt: 1_700_000_001_000,
},
],
pageSpeedInputs: [
{
strategy: "mobile",
status: "succeeded",
sourceUrl: "https://example.com",
normalized: {
implications: [
"Die wichtigsten Inhalte erscheinen auf dem Smartphone spürbar verzögert.",
],
},
},
],
externalMarkdown:
"# Startseite\nBäckerei Muster Berlin. Telefon und Öffnungszeiten sind sichtbar.",
});
assert.deepEqual(first.evidenceLedger, second.evidenceLedger);
const evidenceTypes = new Set(first.evidenceLedger.map((entry) => entry.type));
for (const type of [
"crawl_page",
"technical_check",
"screenshot",
"pagespeed",
"jina_excerpt",
] as const) {
assert.equal(evidenceTypes.has(type), true, `${type} evidence should exist.`);
}
assert.equal(
first.evidenceLedger.every((entry) => entry.id.includes("unknown") === false),
true,
"Evidence IDs should be stable source refs, not unknown placeholders.",
);
});
test("buildAuditEvidenceInput converts PageSpeed implications into sanitized customer-facing text", () => {
const actual = buildAuditEvidenceInput({
pageSpeedInputs: [
@@ -335,3 +455,153 @@ test("buildAuditEvidenceInput selects deterministic skills and supports design/u
assert.equal(selectedCategories.has(category), true);
}
});
test("buildAuditEvidenceInput prioritizes local-audit v3 skills before cap", () => {
const skillRegistry = parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE);
assert.equal(
skillRegistry.some((skill) => skill.id === "visual-design" && !skill.category),
true,
);
const actual = buildAuditEvidenceInput({
lead: {
companyName: "Bäckerei Muster",
niche: "Bäckerei",
city: "Berlin",
websiteDomain: "example.com",
},
crawlPages: [
{
sourceUrl: "https://example.com",
finalUrl: "https://example.com",
pageKind: "homepage",
title: "Bäckerei Muster Berlin",
visibleTextExcerpt:
"Frische Backwaren in Berlin. Rufen Sie uns an oder schreiben Sie uns fuer eine Bestellung.",
hasContactCtaSignal: true,
},
{
sourceUrl: "https://example.com/kontakt",
finalUrl: "https://example.com/kontakt",
pageKind: "contact",
title: "Kontakt",
visibleTextExcerpt:
"Telefon 030 123456, E-Mail hallo@example.com, Öffnungszeiten und Kontaktformular.",
hasContactFormSignal: true,
hasContactCtaSignal: true,
},
],
technicalChecks: [
{
sourceUrl: "https://example.com",
finalUrl: "https://example.com",
usesHttps: true,
missingMetaDescription: true,
hasVisibleContactPath: true,
},
],
screenshots: [
{
storageId: "desktop-storage",
sourceUrl: "https://example.com",
viewport: "desktop",
width: 1280,
height: 900,
mimeType: "image/png",
capturedAt: 1700000000000,
},
{
storageId: "mobile-storage",
sourceUrl: "https://example.com",
viewport: "mobile",
width: 390,
height: 844,
mimeType: "image/png",
capturedAt: 1700000001000,
},
],
pageSpeedInputs: [
{
strategy: "mobile",
status: "succeeded",
sourceUrl: "https://example.com",
normalized: {
implications: [
"Die wichtigsten Inhalte erscheinen auf dem Smartphone spürbar verzögert.",
],
},
},
],
skillRegistry,
});
const selectedIds = new Set(actual.selectedSkills.map((skill) => skill.id));
assert.deepEqual(actual.selectedSkills.map((skill) => skill.id), [
"visual-design",
"impeccable-critique",
"contact-conversion",
"local-seo-basics",
"performance-experience",
"mobile-usability",
]);
assert.equal(actual.selectedSkills.length, 6);
for (const id of [
"visual-design",
"impeccable-critique",
"contact-conversion",
"local-seo-basics",
"performance-experience",
]) {
assert.equal(selectedIds.has(id), true, `${id} should be inside the cap.`);
}
assert.equal(
actual.selectedSkills.every((skill) => skill.category === undefined),
true,
);
});
test("buildAuditEvidenceInput gates v3 skills when declared inputs are missing", () => {
const skillRegistry = parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE);
const actual = buildAuditEvidenceInput({
lead: {
companyName: "Bäckerei Muster",
websiteDomain: "example.com",
},
crawlPages: [
{
sourceUrl: "https://example.com",
finalUrl: "https://example.com",
pageKind: "homepage",
title: "Bäckerei Muster",
},
],
screenshots: [
{
storageId: "desktop-storage",
sourceUrl: "https://example.com",
viewport: "desktop",
width: 1280,
height: 900,
mimeType: "image/png",
capturedAt: 1700000000000,
},
],
skillRegistry,
});
const selectedIds = new Set(actual.selectedSkills.map((skill) => skill.id));
for (const id of [
"visual-design",
"impeccable-critique",
"first-impression-clarity",
"contact-conversion",
"mobile-usability",
"conversion-copy",
"performance-experience",
]) {
assert.equal(selectedIds.has(id), false, `${id} should require missing inputs.`);
}
assert.equal(selectedIds.has("accessibility-basics"), true);
});

View File

@@ -5,6 +5,15 @@ import test from "node:test";
const actionPath = path.join(process.cwd(), "convex", "auditGenerationAction.ts");
const actionSource = existsSync(actionPath) ? readFileSync(actionPath, "utf8") : "";
const toneGuidelinesPath = path.join(
process.cwd(),
"lib",
"ai",
"customer-tone-guidelines.ts",
);
const toneGuidelinesSource = existsSync(toneGuidelinesPath)
? readFileSync(toneGuidelinesPath, "utf8")
: "";
const generationSourcePath = path.join(process.cwd(), "convex", "auditGeneration.ts");
const generationSource = existsSync(generationSourcePath)
? readFileSync(generationSourcePath, "utf8")
@@ -32,6 +41,39 @@ function hasStageCall(schema: string) {
);
}
function extractFunctionSource(functionName: string) {
const marker = `function ${functionName}`;
const asyncMarker = `async function ${functionName}`;
const declarationIndex = actionSource.indexOf(marker) === -1
? actionSource.indexOf(asyncMarker)
: actionSource.indexOf(marker);
assert.notEqual(
declarationIndex,
-1,
`Expected function ${functionName} to exist.`,
);
const openBraceIndex = actionSource.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < actionSource.length; index += 1) {
const char = actionSource[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(end, -1, `Expected balanced braces for ${functionName}.`);
return actionSource.slice(declarationIndex, end + 1);
}
test("auditGenerationAction module exists and is a Node action file", () => {
assert.equal(existsSync(actionPath), true, "auditGenerationAction.ts should exist");
assert.equal(
@@ -96,6 +138,12 @@ test("action starts, queries evidence, and runs stage pipeline", () => {
test("action includes all required audit stages", () => {
for (const stage of [
"classification",
"localSeoSpecialist",
"conversionUxSpecialist",
"visualTrustSpecialist",
"critiqueSpecialist",
"performanceAccessibilitySpecialist",
"evidenceVerifier",
"multimodalAudit",
"germanCopy",
"qualityReview",
@@ -109,6 +157,159 @@ test("action includes all required audit stages", () => {
}
});
test("specialist fan-out runs after evidence input and before German copy", () => {
const evidenceInputIndex = actionSource.indexOf("const evidenceInput = buildAuditEvidenceInput");
const fanOutIndex = actionSource.indexOf("Promise.all(\n specialistStageConfigs.map");
const verifierIndex = actionSource.indexOf('currentStep = "evidenceVerifier"');
const germanCopyIndex = actionSource.indexOf('currentStep = "germanCopy"');
assert.notEqual(evidenceInputIndex, -1, "Action should build evidence input.");
assert.notEqual(germanCopyIndex, -1, "Action should still run German copy.");
assert.notEqual(fanOutIndex, -1, "Action should fan out specialist stage configs.");
assert.notEqual(verifierIndex, -1, "Action should run the evidence verifier.");
assert.equal(
fanOutIndex > evidenceInputIndex && fanOutIndex < germanCopyIndex,
true,
"Specialist fan-out should run after evidence input and before German copy.",
);
assert.equal(
verifierIndex > fanOutIndex && verifierIndex < germanCopyIndex,
true,
"Evidence verifier should run after specialist fan-out and before German copy.",
);
});
test("specialist stages use specialist schemas and verified findings feed German copy", () => {
assert.equal(
hasStageCall("auditSpecialistResultSchema"),
true,
"Specialist stages should call generateObject with auditSpecialistResultSchema.",
);
assert.equal(
hasStageCall("auditEvidenceVerificationSchema"),
true,
"Verifier stage should call generateObject with auditEvidenceVerificationSchema.",
);
assert.match(
actionSource,
/(?:const|let)\s+verifiedFindings\s*[:=]/,
"Action should derive verifiedFindings before synthesis.",
);
assert.match(
actionSource,
/verifiedResult?\.?object|verifiedFindingIds/,
"Verifier output should use compact finding IDs instead of echoing full findings.",
);
assert.match(
actionSource,
/verifiedFindingIds\.has\(candidate\.findingId\)/,
"Action should map verifier-approved IDs back to original specialist findings.",
);
assert.match(
actionSource,
/buildGermanCopyPrompt\(\s*verifiedFindingsText/,
"German copy should be generated from verified findings text.",
);
assert.doesNotMatch(
actionSource,
/buildGermanCopyPrompt\(\s*classificationSummary\s*,/,
"German copy should no longer use raw classification summary as its primary finding input.",
);
});
test("critique specialist translates impeccable critique guidance into the audit fan-out", () => {
assert.match(
actionSource,
/stage:\s*["']critiqueSpecialist["']/,
"Action should include a dedicated critique specialist stage.",
);
assert.match(
actionSource,
/impeccable-critique/,
"Critique specialist should anchor findings to the impeccable critique skill id.",
);
assert.match(
actionSource,
/kognitive Last|Nielsen|AI-Slop|Informationsarchitektur/,
"Critique specialist should include critique guidance beyond generic visual trust.",
);
});
test("German copy prompt uses first-contact email tone guidelines without a new AI stage", () => {
const buildPromptSource = extractFunctionSource("buildGermanCopyPrompt");
assert.doesNotMatch(
buildPromptSource,
/Ich-Ich Kontext/,
"German copy prompt should not force formulaic Ich-Ich copy.",
);
assert.match(
actionSource,
/buildCustomerTonePromptSection/,
"German copy prompt should inject shared customer tone guidelines.",
);
assert.match(
buildPromptSource,
/evidence:\s*AuditEvidence/,
"German copy prompt should accept explicit evidence context.",
);
assert.match(
actionSource,
/buildGermanCopyPrompt\([\s\S]*verifiedFindingsText[\s\S]*multimodalSummary[\s\S]*evidenceInput[\s\S]*\)/,
"German copy prompt should receive the explicit evidence context at the callsite.",
);
assert.match(
toneGuidelinesSource,
/kollegial direkt/,
"Tone guidelines should lock the selected sender posture.",
);
assert.match(
toneGuidelinesSource,
/maximal zwei verifizierte Befunde|max\. zwei verifizierte Befunde/,
"Tone guidelines should keep outreach emails to at most two verified findings.",
);
assert.match(
toneGuidelinesSource,
/kein Mini-Audit/,
"Tone guidelines should explicitly forbid mini-audit emails.",
);
assert.doesNotMatch(
actionSource,
/tone(?:Review|Rewrite|Specialist)|emailToneSpecialist|copyToneSpecialist/,
"Tone work should not add another model-backed generation stage.",
);
});
test("quality review blocks when model review or German copy guard fails", () => {
const qualityPromptSource = extractFunctionSource("buildQualityReviewPrompt");
assert.match(
actionSource,
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
"qualityPassed should require both model review validity and German copy guard.",
);
assert.doesNotMatch(
actionSource,
/qualityPassed\s*=\s*guardResult\.passed\s*;/,
"qualityPassed must not ignore the model quality review.",
);
assert.match(
qualityPromptSource,
/echte Erstmail von Matthias/,
"Quality review should apply the selected first-contact email rubric.",
);
assert.match(
qualityPromptSource,
/KI-Verkaufstext/,
"Quality review should reject AI-like sales copy.",
);
assert.match(
qualityPromptSource,
/verified findings|verifizierte Befunde/i,
"Quality review should keep concrete claims tied to verified findings.",
);
});
test("action handles post-start failure paths in action-level catch", () => {
assert.equal(
hasPattern(
@@ -130,7 +331,7 @@ test("action handles post-start failure paths in action-level catch", () => {
test("action calls generateObject with required schemas", () => {
const requiredSchemas = [
"internalFindingsSchema",
"auditClassificationSchema",
"auditSummarySchema",
"publicAuditTextSchema",
"emailDraftSchema",
@@ -149,6 +350,160 @@ test("action calls generateObject with required schemas", () => {
}
});
test("action loads v3 skill registry from bundled MVP source for evidence input", () => {
assert.equal(
hasPattern(actionSource, /import\s*{[\s\S]*loadLocalAuditSkillRegistry[\s\S]*}\s*from\s*["']\.\.\/lib\/ai\/local-audit-skill-registry["']/),
true,
"Action should import the bundled MVP skill registry loader.",
);
assert.equal(
hasPattern(actionSource, /loadLocalAuditSkillRegistry\(\s*\)/),
true,
"Action should load the v3 registry from a bundled MVP module.",
);
assert.doesNotMatch(
actionSource,
/v2_elemente|process\.cwd\(\)|loadSkillsRegistry\(|node:path/,
"Action should not read v2 reference files or filesystem paths at runtime.",
);
assert.equal(
hasPattern(actionSource, /skillRegistry:\s*\[\s*\]/),
false,
"Action should not pass an always-empty skillRegistry to buildAuditEvidenceInput.",
);
});
test("registry load warning logging is isolated from fallback return", () => {
const loadRegistrySource = extractFunctionSource("loadAuditSkillRegistry");
assert.equal(
hasPattern(
loadRegistrySource,
/catch\s*\(error\)\s*{[\s\S]*try\s*{[\s\S]*appendRunEvent[\s\S]*}\s*catch\s*{[\s\S]*}\s*return\s*\[\s*\]/,
),
true,
"Registry load fallback should return [] even when warning event logging fails.",
);
});
test("persistAuditStage omits undefined fields from Convex mutation args", () => {
const persistSource = extractFunctionSource("persistAuditStage");
const mutationPayloadSource = persistSource.slice(
persistSource.indexOf("await ctx.runMutation"),
);
assert.doesNotMatch(
actionSource,
/persistAuditStage\(\s*{(?:(?!\n\s*}\s*\);)[\s\S])*(?:parsedJson|rawResponse|usage|finishReason|errorSummary):\s*undefined/,
"Call sites should not pass explicit undefined stage payload fields.",
);
assert.doesNotMatch(
persistSource,
/usage:\s*usage\s*\?\s*toPersistedUsage\(usage\)\s*:\s*undefined/,
"persistAuditStage should not emit usage: undefined.",
);
for (const field of [
"systemPrompt",
"rawResponse",
"parsedJson",
"finishReason",
"errorSummary",
]) {
assert.doesNotMatch(
mutationPayloadSource,
new RegExp(`\\n\\s*${field},`),
`persistAuditStage should conditionally spread ${field}.`,
);
}
});
test("OpenRouter usage payloads omit undefined token fields", () => {
const recordUsageSource = extractFunctionSource("recordOpenRouterUsage");
assert.match(
actionSource,
/function toPersistedUsage[\s\S]*usage\.inputTokens\s*!==\s*undefined[\s\S]*promptTokens:\s*usage\.inputTokens/,
"toPersistedUsage should omit promptTokens when inputTokens is undefined.",
);
assert.doesNotMatch(
recordUsageSource,
/tokens:\s*{[\s\S]*inputTokens:\s*args\.usage\.inputTokens/,
"recordOpenRouterUsage should not build token payloads with undefined properties.",
);
});
test("appendRunEvent omits undefined details from Convex mutation args", () => {
assert.doesNotMatch(
actionSource,
/ctx\.runMutation\(internal\.runs\.appendEventInternal,\s*{[\s\S]*\n\s*details:\s*args\.details,\n/,
"appendRunEvent should conditionally include details only when defined.",
);
});
test("success finishAuditGenerationRun omits undefined errorSummary", () => {
assert.doesNotMatch(
actionSource,
/finishAuditGenerationRun,\s*{[\s\S]*status:\s*["']succeeded["'][\s\S]*errorSummary:\s*qualityPassed\s*\?\s*undefined/,
"Succeeded finishAuditGenerationRun payload should not send errorSummary: undefined.",
);
});
test("quality review stage does not pass explicit undefined optional fields", () => {
assert.doesNotMatch(
actionSource,
/persistAuditStage\(\s*{[\s\S]*stage:\s*["']qualityReview["'][\s\S]*errorSummary:\s*qualityPassed\s*\?\s*undefined/,
"Quality persistAuditStage callsite should conditionally include errorSummary.",
);
});
test("persistAuditStage callsites conditionally include optional auditId", () => {
assert.doesNotMatch(
actionSource,
/await\s+persistAuditStage\(\s*{(?:(?!\n\s*}\s*\);)[\s\S])*\n\s*auditId,\n/,
"persistAuditStage callsites should spread auditId only when defined.",
);
});
test("audit generation helper callsites conditionally include optional auditId", () => {
assert.doesNotMatch(
actionSource,
/(?:recordOpenRouterUsage|captureExternalAuditArtifacts)\(\s*ctx,\s*{(?:(?!\n\s*}\s*\);)[\s\S])*\n\s*auditId,\n/,
"Helper callsites should spread auditId only when defined.",
);
assert.doesNotMatch(
actionSource,
/recordAuditUsageEvent\(\s*ctx,\s*{(?:(?!\n\s*}\s*\);)[\s\S])*\n\s*auditId:\s*args\.auditId,\n/,
"recordAuditUsageEvent callsites should spread args.auditId only when defined.",
);
});
test("persistAuditStage callsites avoid nested maybe-undefined usage objects", () => {
assert.doesNotMatch(
actionSource,
/persistAuditStage\(\s*{(?:(?!\n\s*}\s*\);)[\s\S])*usage:\s*{[\s\S]*?(?:inputTokens|outputTokens|totalTokens|cacheReadTokens):/,
"persistAuditStage callsites should use a usage helper or conditional spreads, not inline maybe-undefined usage objects.",
);
});
test("classification stage uses v3 audit classification schema", () => {
assert.equal(
hasPattern(actionSource, /auditClassificationSchema/),
true,
"Action should reference the v3 auditClassificationSchema.",
);
assert.equal(
hasStageCall("auditClassificationSchema"),
true,
"Classification generateObject call should validate v3 finding payloads.",
);
assert.equal(
hasStageCall("internalFindingsSchema"),
false,
"Classification should no longer validate against legacy-only internalFindingsSchema.",
);
});
test("action uses multimodal file parts with mediaType image/* when screenshots are available", () => {
assert.equal(
hasPattern(
@@ -190,14 +545,23 @@ test("action runs german copy guard and blocks outreach-ready on validation fail
assert.equal(
hasPattern(
actionSource,
/guardResult\.passed|qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
),
true,
"Model QA and deterministic German copy guard failures should hard-block the audit run.",
);
assert.equal(
hasPattern(actionSource, /api\.leads\.reviewUpdate/),
hasPattern(
actionSource,
/qualityPassed\s*=\s*guardResult\.passed\s*;/,
),
false,
"Action must not ignore the model QA validity flag.",
);
assert.equal(
hasPattern(actionSource, /internal\.leads\.reviewUpdateInternal/),
true,
"Action should patch lead via api.leads.reviewUpdate",
"Action should patch lead via internal.leads.reviewUpdateInternal",
);
assert.equal(
hasPattern(

View File

@@ -96,6 +96,7 @@ test("auditGeneration module exports required mutation contracts", () => {
"queueLeadAuditGeneration",
"startAuditGenerationRun",
"persistAuditGenerationResult",
"replaceAuditFindings",
"finishAuditGenerationRun",
];
@@ -113,6 +114,7 @@ test("auditGeneration module registers internalMutation contracts", () => {
"queueLeadAuditGeneration",
"startAuditGenerationRun",
"persistAuditGenerationResult",
"replaceAuditFindings",
"finishAuditGenerationRun",
]) {
assert.equal(
@@ -126,6 +128,47 @@ test("auditGeneration module registers internalMutation contracts", () => {
}
});
test("replaceAuditFindings replaces persisted audit findings with evidence refs", () => {
const replaceSource = extractExportSource("replaceAuditFindings");
assert.equal(
hasPattern(replaceSource, /query\("auditFindings"\)/),
true,
"replaceAuditFindings should query auditFindings.",
);
assert.equal(
hasPattern(replaceSource, /withIndex\("by_auditId"/),
true,
"replaceAuditFindings should query existing findings by auditId.",
);
assert.equal(
hasPattern(replaceSource, /ctx\.db\.delete\(/),
true,
"replaceAuditFindings should delete stale findings before inserting replacements.",
);
assert.equal(
hasPattern(replaceSource, /ctx\.db\.insert\(\s*"auditFindings"/),
true,
"replaceAuditFindings should insert into auditFindings.",
);
for (const field of [
"skillId",
"claim",
"recommendation",
"customerBenefit",
"severity",
"confidence",
"evidenceRefs",
"reviewStatus",
]) {
assert.equal(
hasPattern(replaceSource, new RegExp(`${field}:\\s*finding\\.${field}`)),
true,
`replaceAuditFindings should persist ${field}.`,
);
}
});
test("queueLeadAuditGeneration dedupes pending/running runs and schedules action", () => {
const queueSource = extractExportSource("queueLeadAuditGeneration");
@@ -224,6 +267,50 @@ test("persistAuditGenerationResult inserts into auditGenerations", () => {
);
});
test("getAuditGenerationEvidence loads latest successful website enrichment evidence by lead", () => {
const evidenceSource = extractExportSource("getAuditGenerationEvidence");
assert.equal(
hasPattern(
evidenceSource,
/query\("agentRuns"\)[\s\S]*withIndex\("by_type_and_status_and_leadId"[\s\S]*eq\("type",\s*"website_enrichment"\)[\s\S]*eq\("status",\s*"succeeded"\)[\s\S]*eq\("leadId",\s*lead\._id\)[\s\S]*order\("desc"\)[\s\S]*take\(1\)/,
),
true,
"Evidence query should locate the latest successful website_enrichment run for the same lead.",
);
assert.equal(
hasPattern(
evidenceSource,
/const\s+enrichmentEvidenceRunId\s*=\s*latestSuccessfulEnrichmentRun\[0\]\?\._id\s*\?\?\s*args\.runId/,
),
true,
"Evidence query should fall back to the audit run only when no enrichment run exists.",
);
for (const table of [
"websiteCrawlPages",
"websiteTechnicalChecks",
]) {
assert.equal(
hasPattern(
evidenceSource,
new RegExp(
`query\\("${table}"\\)[\\s\\S]*withIndex\\("by_runId"[\\s\\S]*eq\\("runId",\\s*enrichmentEvidenceRunId\\)`,
),
),
true,
`${table} should be loaded from the enrichment evidence run.`,
);
}
assert.equal(
hasPattern(
evidenceSource,
/const\s+screenshots\s*=\s*\[\s*\.\.\.auditCaptureScreenshotsByRun,\s*\.\.\.enrichmentScreenshotsByRun\s*\]/,
),
true,
"Evidence query should include audit-run ScreenshotOne captures and enrichment screenshots.",
);
});
test("truncateWithMarker is byte-capped and marker-safe in persistence", () => {
assert.equal(
hasPattern(auditGenerationSource, /const markerBytes = byteLength\(TRUNCATION_MARKER\);/),
@@ -285,6 +372,29 @@ test("sanitizer masks env-backed secret values in persistence", () => {
);
});
test("persistence sanitizer handles external service secrets with regex metacharacters", () => {
for (const secretKey of ["SCREENSHOTONE_API_KEY", "JINA_API_KEY"]) {
assert.equal(
hasPattern(auditGenerationSource, new RegExp(`["']${secretKey}["']`)),
true,
`Persistence sanitizer should redact ${secretKey}.`,
);
}
assert.equal(
auditGenerationSource.includes(
'return value.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&");',
),
true,
"escapeRegExp should escape regex metacharacters with the canonical character class.",
);
assert.equal(
auditGenerationSource.includes("/[.*+?^${}()|[\\\\]\\\\]/g"),
false,
"escapeRegExp should not keep the malformed bracket/backslash character class.",
);
});
test("finishAuditGenerationRun updates run status/counters/currentStep", () => {
const finishSource = extractExportSource("finishAuditGenerationRun");

View File

@@ -202,3 +202,90 @@ test("audit-generation validators are declared", () => {
"auditGenerationStage should include qualityReview.",
);
});
test("auditFindings table stores verified specialist findings with evidence refs", () => {
const { section, objectBlock } = extractTableSection("auditFindings");
assertHas(
/auditId:\s*v\.id\(["']audits["']\)/,
objectBlock,
"auditFindings.auditId must be required audit id.",
);
assertHas(
/runId:\s*v\.id\(["']agentRuns["']\)/,
objectBlock,
"auditFindings.runId must be required run id.",
);
assertHas(
/skillId:\s*v\.string\(\)/,
objectBlock,
"auditFindings.skillId must identify the source specialist skill.",
);
assertHas(
/claim:\s*v\.string\(\)/,
objectBlock,
"auditFindings.claim must store the verified claim.",
);
assertHas(
/recommendation:\s*v\.string\(\)/,
objectBlock,
"auditFindings.recommendation must store the concrete fix.",
);
assertHas(
/customerBenefit:\s*v\.string\(\)/,
objectBlock,
"auditFindings.customerBenefit must store customer-facing impact.",
);
assertHas(
/severity:\s*v\.union\(\s*v\.literal\(1\),\s*v\.literal\(2\),\s*v\.literal\(3\)\s*\)/,
objectBlock,
"auditFindings.severity should be a 1-3 literal union.",
);
assertHas(
/confidence:\s*v\.number\(\)/,
objectBlock,
"auditFindings.confidence must be persisted for review calibration.",
);
assertHas(
/evidenceRefs:\s*v\.array\(\s*auditFindingEvidenceRef\s*\)/,
objectBlock,
"auditFindings.evidenceRefs must persist typed evidence refs.",
);
assertHas(
/reviewStatus:\s*auditFindingReviewStatus/,
objectBlock,
"auditFindings.reviewStatus should use a review-status validator.",
);
assertHas(
/index\("by_auditId",\s*\["auditId"\]\)/,
section,
"auditFindings should have by_auditId index.",
);
assertHas(
/index\("by_runId",\s*\["runId"\]\)/,
section,
"auditFindings should have by_runId index.",
);
assertHas(
/index\("by_auditId_and_reviewStatus",\s*\["auditId",\s*"reviewStatus"\]\)/,
section,
"auditFindings should support review-status filtering per audit.",
);
});
test("specialist fan-out audit stages are declared in domain", () => {
for (const stage of [
"localSeoSpecialist",
"conversionUxSpecialist",
"visualTrustSpecialist",
"critiqueSpecialist",
"performanceAccessibilitySpecialist",
"evidenceVerifier",
]) {
assertHas(
new RegExp(`AUDIT_GENERATION_STAGES\\s*=\\s*\\[[\\s\\S]*["']${stage}["'][\\s\\S]*\\]`),
domainSource,
`auditGenerationStage should include ${stage}.`,
);
}
});

View File

@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import { LOCAL_AUDIT_SKILL_REGISTRY_SOURCE } from "../lib/ai/local-audit-skill-registry";
import { parseSkillsRegistry, toAuditUsedSkill } from "../lib/skills-registry";
test("parseSkillsRegistry parses v3 yaml metablocks from the MVP registry source", () => {
const parsed = parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE);
assert.equal(parsed.length, 10);
const visualDesign = parsed.find((entry) => entry.id === "visual-design");
assert.ok(visualDesign);
assert.equal(visualDesign.title, "Visueller Gesamteindruck & Zeitgemäßheit");
assert.equal(visualDesign.name, "Visueller Gesamteindruck & Zeitgemäßheit");
assert.equal(visualDesign.appliesWhen, "website_exists");
assert.deepEqual(visualDesign.inputs, [
"desktop_screenshot",
"mobile_screenshot",
]);
assert.equal(visualDesign.outputs, "findings");
const instructions = visualDesign.instructions;
if (typeof instructions !== "string") {
assert.fail("Expected visual-design instructions to be parsed.");
}
assert.match(instructions, /Beurteile den ersten visuellen Eindruck/);
const critique = parsed.find((entry) => entry.id === "impeccable-critique");
assert.ok(critique);
assert.equal(critique.title, "Impeccable Critique Review");
assert.equal(critique.appliesWhen, "website_exists");
assert.deepEqual(critique.inputs, [
"desktop_screenshot",
"mobile_screenshot",
"markdown",
"dom",
]);
assert.match(
critique.instructions ?? "",
/Nielsen|kognitive Last|AI-Slop/,
);
});
test("toAuditUsedSkill exposes stable ids for v3 registry entries", () => {
const parsed = parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE);
const skill = parsed.find((entry) => entry.id === "contact-conversion");
assert.ok(skill);
assert.deepEqual(toAuditUsedSkill(skill), {
id: "contact-conversion",
name: "Kontaktaufnahme & Handlungsaufforderung",
});
});
test("parseSkillsRegistry does not infer categories for v3 entries without explicit metadata", () => {
const parsed = parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE);
const skill = parsed.find((entry) => entry.id === "performance-experience");
assert.ok(skill);
assert.equal(skill.category, undefined);
assert.deepEqual(toAuditUsedSkill(skill), {
id: "performance-experience",
name: "Tempo & Ladeerlebnis",
});
});
test("parseSkillsRegistry can read legacy and v3 skill sections from one registry", () => {
const source = `
## Legacy Copy Skill
Purpose: Improve customer-facing copy.
When to use: Use when page text is unclear.
When not to use: Skip when copy is not available.
Required input: Markdown copy.
Expected output: Copy recommendations.
Category: copy
## mobile-usability
\`\`\`yaml
id: mobile-usability
title: Mobile Nutzbarkeit
applies_when: has_mobile_screenshot
inputs: [mobile_screenshot, pagespeed]
outputs: findings
\`\`\`
Pruefe mobile Lesbarkeit und Tap-Ziele.
`;
const parsed = parseSkillsRegistry(source);
assert.equal(parsed.length, 2);
assert.equal(parsed[0].name, "Legacy Copy Skill");
assert.equal(parsed[0].category, "copy");
assert.equal(parsed[1].id, "mobile-usability");
assert.equal(parsed[1].category, undefined);
assert.deepEqual(parsed[1].inputs, ["mobile_screenshot", "pagespeed"]);
});

View File

@@ -127,8 +127,13 @@ test("audits schema stores compact usedSkills metadata", () => {
);
hasPattern(
usedSkillsSection,
/category:\s*v\.string\(\)/,
"usedSkills.category should be string.",
/id:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"usedSkills.id should be optional string.",
);
hasPattern(
usedSkillsSection,
/category:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"usedSkills.category should be optional string.",
);
hasPattern(
usedSkillsSection,
@@ -179,8 +184,8 @@ test("audits.create accepts usedSkills validator and persists metadata payloads"
);
hasPattern(
auditsSource,
/v\.object\([\s\S]*?name:\s*v\.string\(\)[\s\S]*?category:\s*v\.string\(\)[\s\S]*?version:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"audits.ts should define a reusable usedSkillsValidator.",
/v\.object\([\s\S]*?id:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?name:\s*v\.string\(\)[\s\S]*?category:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?version:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"audits.ts should define reusable v3-compatible usedSkillsValidator fields.",
);
hasPattern(
@@ -220,8 +225,18 @@ test("audits.getDetail returns audit + lead context with null-safe lead lookup",
);
hasPattern(
getDetailSource,
/return\s*{\s*audit,\s*lead\s*}/,
"getDetail should return { audit, lead }.",
/return\s*{[\s\S]*audit,[\s\S]*lead,[\s\S]*sourceSummaries:[\s\S]*}/,
"getDetail should return audit, lead, and sourceSummaries.",
);
hasPattern(
getDetailSource,
/query\("auditFindings"\)[\s\S]*withIndex\("by_auditId"[\s\S]*eq\("auditId",\s*audit\._id\)[\s\S]*take\(DETAIL_EVIDENCE_LIMIT\)/,
"getDetail should load persisted findings by auditId.",
);
hasPattern(
getDetailSource,
/return\s*{[\s\S]*audit,[\s\S]*lead,[\s\S]*findings,[\s\S]*sourceSummaries:[\s\S]*}/,
"getDetail should return top-level findings for the detail UI.",
);
hasPattern(
sourceFile.getFullText(),
@@ -229,3 +244,48 @@ test("audits.getDetail returns audit + lead context with null-safe lead lookup",
"audits.ts should export a getDetail query.",
);
});
test("audits.getDetail joins compact checked-page evidence from latest successful enrichment", () => {
const getDetailSource = extractExportSource("getDetail");
hasPattern(
getDetailSource,
/query\("agentRuns"\)[\s\S]*withIndex\("by_type_and_status_and_leadId"[\s\S]*eq\("type",\s*"website_enrichment"\)[\s\S]*eq\("status",\s*"succeeded"\)[\s\S]*eq\("leadId",\s*audit\.leadId\)[\s\S]*order\("desc"\)[\s\S]*take\(1\)/,
"getDetail should locate the latest successful website_enrichment run for the audit lead.",
);
for (const table of [
"websiteCrawlPages",
"websiteTechnicalChecks",
"websiteCrawlScreenshots",
]) {
hasPattern(
getDetailSource,
new RegExp(
`query\\("${table}"\\)[\\s\\S]*withIndex\\("by_runId"[\\s\\S]*eq\\("runId",\\s*enrichmentRunId\\)[\\s\\S]*take\\(DETAIL_EVIDENCE_LIMIT\\)`,
),
`${table} should be loaded from the bounded enrichment run evidence window.`,
);
}
hasPattern(
getDetailSource,
/audit\.checkedPages\.map\(/,
"getDetail should preserve audit.checkedPages as the canonical display order.",
);
hasPattern(
getDetailSource,
/fallbackCheckedPageEvidence/,
"getDetail should return checked-page fallback rows when enrichment evidence is missing.",
);
hasPattern(
getDetailSource,
/ctx\.storage\.getUrl\(screenshot\.storageId\)/,
"getDetail should resolve screenshot storage ids to display URLs.",
);
hasPattern(
getDetailSource,
/sourceSummaries:\s*{\s*checkedPages/,
"getDetail should expose checked page summaries under sourceSummaries.checkedPages.",
);
});

View File

@@ -30,7 +30,7 @@ test("audits dashboard page uses a dedicated board component", async () => {
);
});
test("audits board renders compact list with convex list query and core columns", async () => {
test("audits board renders compact list with dashboard rows query and core columns", async () => {
const boardSource = await source("components/audits/audits-board.tsx");
assert.match(
@@ -40,13 +40,13 @@ test("audits board renders compact list with convex list query and core columns"
);
assert.match(
boardSource,
/useQuery\s*\(\s*api\.audits\.list,\s*\{\s*limit:\s*100\s*\}\s*\)/,
"AuditsBoard should call api.audits.list with { limit: 100 }.",
/useQuery\s*\(\s*api\.audits\.listDashboardRows,\s*\{\s*limit:\s*100\s*\}\s*\)/,
"AuditsBoard should call api.audits.listDashboardRows with { limit: 100 }.",
);
assert.match(
boardSource,
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.createdAt\s*-\s*a\.createdAt\)/,
"Audits should be sorted newest first.",
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.updatedAt\s*-\s*a\.updatedAt\)/,
"Dashboard rows should be sorted newest first.",
);
assert.match(boardSource, /Loading|lädt|Lade/i);
assert.match(boardSource, /Keine Audits|keine Audits/i);
@@ -54,10 +54,20 @@ test("audits board renders compact list with convex list query and core columns"
assert.match(boardSource, /Domain/);
assert.match(boardSource, /Status/);
assert.match(boardSource, /Seiten/);
assert.match(boardSource, /Generierung läuft|Generierung laeuft/);
assert.match(boardSource, /Fehlgeschlagen/);
assert.match(boardSource, /Wartet auf finales Audit/);
assert.match(boardSource, /Wartet auf Start/);
assert.match(boardSource, /Abgebrochen/);
assert.match(
boardSource,
/href=\{`\/dashboard\/audits\/\$\{audit\._id\}`\}/,
"Each audit row should link to /dashboard/audits/{id}.",
/href=\{row\.detailHref\}/,
"Final audit rows should link through their detailHref.",
);
assert.match(
boardSource,
/row\.kind\s*===\s*"audit"/,
"Board should branch between final audit rows and generation rows.",
);
});
@@ -94,6 +104,11 @@ test("audit detail component uses getDetail query and renders skills overview se
/const\s+lead\s*=\s*result\?\.lead;/,
"AuditDetail should destructure lead from result.lead.",
);
assert.match(
detailSource,
/const\s+findings\s*=/,
"AuditDetail should derive findings from result.findings.",
);
assert.match(
detailSource,
/leadSummary\(\s*lead\s*\)/,
@@ -131,6 +146,90 @@ test("audit detail component uses getDetail query and renders skills overview se
);
});
test("audit detail component renders verified findings before checked-page evidence", async () => {
const detailSource = await source("components/audits/audit-detail.tsx");
const findingsIndex = detailSource.indexOf("Geprüfte Befunde");
const checkedPagesIndex = detailSource.indexOf("Geprüfte Seiten");
assert.notEqual(findingsIndex, -1, "AuditDetail should render a findings section.");
assert.notEqual(checkedPagesIndex, -1, "AuditDetail should still render checked pages.");
assert.equal(
findingsIndex < checkedPagesIndex,
true,
"Findings should be rendered before raw checked-page evidence.",
);
assert.match(
detailSource,
/findings\.map/,
"AuditDetail should render one row per verified finding.",
);
for (const field of [
"claim",
"recommendation",
"customerBenefit",
"evidenceRefs",
"confidence",
]) {
assert.match(
detailSource,
new RegExp(field),
`AuditDetail should surface finding.${field}.`,
);
}
assert.match(
detailSource,
/Evidence|Beleg|Quelle/,
"AuditDetail should label evidence chips for each finding.",
);
});
test("audit detail component renders compact checked-page evidence", async () => {
const detailSource = await source("components/audits/audit-detail.tsx");
assert.match(
detailSource,
/sourceSummaries/,
"AuditDetail should read sourceSummaries from getDetail.",
);
assert.match(
detailSource,
/checkedPageEvidence/,
"AuditDetail should derive checked page evidence from sourceSummaries.checkedPages.",
);
assert.match(
detailSource,
/Geprüfte Seiten/,
"AuditDetail should render a checked-pages evidence card.",
);
assert.match(
detailSource,
/checkedPageEvidence\.map/,
"AuditDetail should render one compact row per checked page.",
);
for (const label of [
"Meta",
"Kontaktformular",
"CTA",
"Interne Links",
]) {
assert.match(
detailSource,
new RegExp(label),
`AuditDetail should expose ${label} evidence for each page.`,
);
}
assert.match(
detailSource,
/page\.screenshots\.map/,
"AuditDetail should render optional screenshot thumbnails when present.",
);
assert.match(
detailSource,
/<img[\s\S]*src=\{screenshot\.url\}/,
"AuditDetail should render screenshot URLs from the detail query.",
);
});
test("audits detail route passes id to AuditDetail via Promise params", async () => {
const pageSource = await source("app/dashboard/audits/[id]/page.tsx");

View File

@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import test from "node:test";
import { zodSchema } from "ai";
import {
auditEvidenceVerificationSchema,
auditSpecialistResultSchema,
} from "../lib/ai/schemas";
const validFinding = {
skillId: "local-seo-basics",
claim: "Die Startseite nennt den Standort im sichtbaren Bereich nicht klar.",
recommendation: "Ort und wichtigste Leistung in die erste Überschrift aufnehmen.",
customerBenefit: "Besucher erkennen schneller, ob das Angebot für sie passt.",
severity: 2,
confidence: 0.82,
evidenceRefs: [
{
id: "crawl_page:homepage:https-example-com",
type: "crawl_page",
label: "Startseite",
sourceUrl: "https://example.com",
},
],
applies: true,
unknowns: [],
};
type JsonSchemaObject = {
type?: string;
properties?: Record<string, JsonSchemaObject>;
required?: string[];
items?: JsonSchemaObject;
};
function assertStrictRequiredProperties(schema: JsonSchemaObject, path = "schema") {
if (schema.type === "object" && schema.properties) {
const required = new Set(schema.required ?? []);
for (const key of Object.keys(schema.properties)) {
assert.equal(
required.has(key),
true,
`${path}.${key} must be required for Azure/OpenAI structured outputs`,
);
assertStrictRequiredProperties(schema.properties[key]!, `${path}.${key}`);
}
}
if (schema.type === "array" && schema.items) {
assertStrictRequiredProperties(schema.items, `${path}[]`);
}
}
test("specialist structured-output schemas require every declared property", () => {
assertStrictRequiredProperties(
zodSchema(auditSpecialistResultSchema).jsonSchema as JsonSchemaObject,
);
assertStrictRequiredProperties(
zodSchema(auditEvidenceVerificationSchema).jsonSchema as JsonSchemaObject,
);
});
test("auditSpecialistResultSchema accepts evidence-backed specialist findings", () => {
const parsed = auditSpecialistResultSchema.parse({
status: "success",
findings: [validFinding],
notes: ["Lokale Relevanz wurde anhand der Startseite geprüft."],
});
assert.equal(parsed.status, "success");
assert.equal(parsed.findings[0]?.evidenceRefs[0]?.type, "crawl_page");
});
test("auditSpecialistResultSchema rejects findings without evidence refs", () => {
assert.throws(
() =>
auditSpecialistResultSchema.parse({
status: "success",
findings: [{ ...validFinding, evidenceRefs: [] }],
notes: [],
}),
/evidenceRefs/,
);
});
test("auditSpecialistResultSchema rejects unsupported severity and confidence", () => {
assert.throws(
() =>
auditSpecialistResultSchema.parse({
status: "success",
findings: [{ ...validFinding, severity: 4 }],
notes: [],
}),
/severity/,
);
assert.throws(
() =>
auditSpecialistResultSchema.parse({
status: "success",
findings: [{ ...validFinding, confidence: 1.4 }],
notes: [],
}),
/confidence/,
);
});
test("auditSpecialistResultSchema rejects unknown-only findings", () => {
assert.throws(
() =>
auditSpecialistResultSchema.parse({
status: "success",
findings: [
{
...validFinding,
claim: "Kontaktformular: Unbekannt",
recommendation: "Unbekannt prüfen.",
customerBenefit: "Unbekannt.",
evidenceRefs: [
{
id: "technical_check:unknown",
type: "technical_check",
label: "Kontaktformular unbekannt",
sourceUrl: "",
},
],
},
],
notes: [],
}),
/unknown/i,
);
});
test("auditEvidenceVerificationSchema returns compact verified ids and rejected decisions", () => {
const parsed = auditEvidenceVerificationSchema.parse({
verifiedFindingIds: ["finding-1"],
rejectedFindings: [
{
findingId: "finding-2",
skillId: validFinding.skillId,
claim: "Die Seite koennte moderner wirken.",
rejectionReason: "Zu generisch und nicht ausreichend belegt.",
},
],
contradictions: [],
notes: ["Ein Finding wurde wegen generischer Sprache verworfen."],
});
assert.deepEqual(parsed.verifiedFindingIds, ["finding-1"]);
assert.equal(parsed.rejectedFindings[0]?.rejectionReason.includes("generisch"), true);
});
test("auditEvidenceVerificationSchema accepts rejected unknown-only claims", () => {
const parsed = auditEvidenceVerificationSchema.parse({
verifiedFindingIds: [],
rejectedFindings: [
{
findingId: "finding-1",
skillId: "contact-conversion",
claim: "Kontaktformular: Unbekannt",
rejectionReason: "Unknown-only Befunde duerfen nicht in die Kundencopy.",
},
],
contradictions: [],
notes: [],
});
assert.equal(parsed.rejectedFindings.length, 1);
});
test("auditEvidenceVerificationSchema keeps verifier output compact for many findings", () => {
const parsed = auditEvidenceVerificationSchema.parse({
verifiedFindingIds: Array.from({ length: 12 }, (_, index) => `finding-${index + 1}`),
rejectedFindings: [],
contradictions: [],
notes: ["Full specialist findings stay in application state and are not echoed."],
});
assert.equal(parsed.verifiedFindingIds.length, 12);
});

View File

@@ -0,0 +1,73 @@
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("audit admin APIs require operator auth before database access", async () => {
const auditsSource = await source("convex/audits.ts");
assert.match(
auditsSource,
/const requireOperator\s*=\s*async\s*\(\s*ctx:\s*(?:MutationCtx\s*\|\s*QueryCtx|QueryCtx\s*\|\s*MutationCtx)\s*\)[\s\S]*ctx\.auth\.getUserIdentity\(\)[\s\S]*throw new Error\(["']Nicht autorisiert\.["']\)/,
"audits should define the local requireOperator auth guard.",
);
for (const name of ["create", "getDetail", "get", "getBySlug", "list"]) {
const exportSource = extractExportSource(auditsSource, name);
const authIndex = exportSource.indexOf("await requireOperator(ctx)");
const dbIndex = exportSource.indexOf("ctx.db");
assert.notEqual(authIndex, -1, `${name} should require operator auth.`);
assert.notEqual(dbIndex, -1, `${name} should access ctx.db.`);
assert.equal(
authIndex < dbIndex,
true,
`${name} should require operator auth before accessing ctx.db.`,
);
}
});
test("public audit slug lookup remains unauthenticated", async () => {
const auditsSource = await source("convex/audits.ts");
const publicSource = extractExportSource(auditsSource, "getPublicBySlug");
assert.match(publicSource, /ctx\.db/, "public audit lookup should keep reading public audit data.");
assert.doesNotMatch(
publicSource,
/requireOperator\(ctx\)/,
"getPublicBySlug should remain public and unauthenticated.",
);
});

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const auditsBoardPath = join(
process.cwd(),
"components",
"audits",
"audits-board.tsx",
);
test("AuditsBoard renders dashboard rows as responsive cards", async () => {
const source = await readFile(auditsBoardPath, "utf8");
assert.doesNotMatch(source, /min-w-\[/i);
assert.doesNotMatch(source, /overflow-x-auto/i);
assert.doesNotMatch(source, /grid-cols-\[minmax/i);
assert.match(source, /Card/);
assert.match(source, /CardHeader/);
assert.match(source, /CardTitle/);
assert.match(source, /CardContent/);
assert.match(source, /className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"/);
assert.match(source, /aria-labelledby=\{rowTitleId\}/);
assert.match(source, /id=\{rowTitleId\}/);
});
test("AuditsBoard keeps audit detail links and non-clickable pipeline cards", async () => {
const source = await readFile(auditsBoardPath, "utf8");
assert.match(source, /row\.kind === "audit"/);
assert.match(source, /href=\{row\.detailHref\}/);
assert.match(source, /Öffnen/);
assert.match(source, /Pipeline läuft/);
assert.match(source, /getGenerationStatusLabel\(row\)/);
assert.match(source, /row\.errorSummary/);
});

View File

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

View File

@@ -23,7 +23,9 @@ test("campaign board renders campaigns as responsive cards", async () => {
assert.doesNotMatch(source, /md:hidden/i);
assert.doesNotMatch(source, /md:block/i);
assert.match(source, /className="grid gap-3"/);
assert.match(source, /className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"/);
assert.match(source, /<Card[^>]+aria-labelledby=\{campaignTitleId\}/);
assert.match(source, /id=\{campaignTitleId\}/);
assert.match(source, /openEditDialog\(campaign\)/);
assert.match(source, /toggleCampaign\(campaign\)/);
assert.match(source, /runCampaign\(campaign\)/);

View File

@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildCustomerTonePromptSection,
customerToneGuidelines,
} from "../lib/ai/customer-tone-guidelines";
test("customer tone guidelines capture the selected collegial direct email posture", () => {
assert.equal(customerToneGuidelines.senderPosture, "kollegial_direkt");
assert.equal(customerToneGuidelines.email.wordCount.min, 60);
assert.equal(customerToneGuidelines.email.wordCount.max, 130);
assert.equal(customerToneGuidelines.email.subject.maxCharacters, 55);
assert.equal(
customerToneGuidelines.email.bannedPhrases.includes("Optimierungspotenziale"),
true,
);
});
test("customer tone prompt section gives concrete first-contact email rules", () => {
const promptSection = buildCustomerTonePromptSection();
assert.match(promptSection, /kollegial direkt/);
assert.match(promptSection, /maximal zwei verifizierte Befunde/);
assert.match(promptSection, /kein Mini-Audit/);
assert.match(promptSection, /Soll ich Ihnen die zwei Punkte kurz schicken/);
});

View File

@@ -192,6 +192,27 @@ test("toLeadFunnelCard maps blocked priority to deferred stage with blocker labe
assert.equal(card.nextAction, "Zurückstellung prüfen");
});
test("toLeadFunnelCard shows do-not-contact rechecks after the block window", () => {
const card = toLeadFunnelCard({
id: "lead-recheck",
companyName: "Agentur Recheck",
priority: "medium",
contactStatus: "do_not_contact",
blacklistStatus: "clear",
outreach: {
approvalStatus: "approved",
sendStatus: "sent",
responseStatus: "no_interest",
salesStatus: "do_not_pursue",
doNotContactUntil: Date.UTC(2026, 0, 1),
},
now: Date.UTC(2026, 0, 2),
});
assert.equal(card.stageId, "deferred");
assert.equal(card.nextAction, "Erneut prüfen");
});
test("dashboard-model exposes stable lead label helpers for UI mapping", () => {
assert.deepEqual(leadPriorityOptions, [
"high",

View File

@@ -0,0 +1,335 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
const actionPath = path.join(process.cwd(), "convex", "auditGenerationAction.ts");
const actionSource = existsSync(actionPath) ? readFileSync(actionPath, "utf8") : "";
const generationPath = path.join(process.cwd(), "convex", "auditGeneration.ts");
const generationSource = existsSync(generationPath)
? readFileSync(generationPath, "utf8")
: "";
function extractFunctionSource(functionName: string) {
const declarationPattern = new RegExp(
`(?:async\\s+)?function\\s+${functionName}\\s*\\([\\s\\S]*?\\n\\)\\s*(?::\\s*[^\\{]+)?\\{`,
);
const match = declarationPattern.exec(actionSource);
assert.notEqual(match, null, `Expected function ${functionName}.`);
const openBraceIndex = match!.index + match![0].lastIndexOf("{");
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < actionSource.length; index += 1) {
const char = actionSource[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(end, -1, `Expected balanced braces for ${functionName}.`);
return actionSource.slice(match!.index, end + 1);
}
test("audit generation action orchestrates external capture helpers when legacy crawl artifacts are absent", () => {
assert.match(
actionSource,
/buildScreenshotOneRequests[\s\S]*buildJinaReaderAuditInput[\s\S]*estimateExternalAuditCostUsd|estimateExternalAuditCostUsd[\s\S]*buildScreenshotOneRequests[\s\S]*buildJinaReaderAuditInput/,
"Action should import and use the approved external audit service helpers.",
);
assert.match(
actionSource,
/SCREENSHOTONE_API_KEY/,
"ScreenshotOne capture should be guarded by the managed SCREENSHOTONE_API_KEY env key.",
);
assert.match(
actionSource,
/JINA_API_KEY/,
"Jina capture should be compatible with the optional managed JINA_API_KEY env key.",
);
assert.match(
actionSource,
/evidence\.screenshots\.length\s*===\s*0[\s\S]*(started\.lead\.websiteUrl|started\.lead\.websiteDomain)/,
"External capture should be prepared from the started lead URL/domain when legacy screenshots are missing.",
);
});
test("audit generation action records provider usage events for capture and OpenRouter generation", () => {
assert.match(
actionSource,
/internal\.usageEvents\.recordUsageEvent/,
"Action should record usage through internal.usageEvents.recordUsageEvent.",
);
for (const provider of ["screenshotone", "jina", "openrouter"]) {
assert.match(
actionSource,
new RegExp(`provider:\\s*["']${provider}["']`),
`Action should record ${provider} usage.`,
);
}
assert.match(
actionSource,
/provider:\s*["']openrouter["'][\s\S]*operation:\s*["']audit_generation["']/,
"OpenRouter usage should be recorded as audit_generation.",
);
assert.match(
actionSource,
/provider:\s*["']screenshotone["'][\s\S]*operation:\s*["']audit_capture["']/,
"ScreenshotOne usage should be recorded as audit_capture.",
);
assert.match(
actionSource,
/provider:\s*["']jina["'][\s\S]*operation:\s*["']audit_capture["']/,
"Jina usage should be recorded as audit_capture.",
);
});
test("Jina markdown joins the evidence prompt without requiring Playwright crawl pages", () => {
assert.match(
actionSource,
/jina(?:Reader)?AuditInput[\s\S]*markdown/,
"Action should keep Jina reader markdown as an audit evidence input.",
);
assert.match(
actionSource,
/buildAuditEvidenceInput\(\{[\s\S]*externalMarkdown|externalMarkdown[\s\S]*buildAuditEvidenceInput\(\{/,
"Action should pass external markdown into the evidence builder.",
);
assert.match(
generationSource,
/externalMarkdown/,
"Audit generation evidence types should expose external markdown for prompts.",
);
});
test("external capture fetches use timeout, abort signal, and bounded response readers", () => {
for (const constantName of [
"EXTERNAL_CAPTURE_TIMEOUT_MS",
"MAX_SCREENSHOT_BYTES",
"MAX_JINA_MARKDOWN_BYTES",
"MAX_JINA_MARKDOWN_CHARS",
]) {
assert.match(
actionSource,
new RegExp(`const\\s+${constantName}\\s*=`),
`Action should define ${constantName}.`,
);
}
assert.match(
actionSource,
/AbortController/,
"External fetches should use AbortController for per-request timeouts.",
);
assert.match(
actionSource,
/fetch\([\s\S]*signal:/,
"External fetches should pass an AbortSignal.",
);
assert.doesNotMatch(
actionSource,
/response\.blob\(\)/,
"ScreenshotOne capture should not call unbounded response.blob().",
);
assert.doesNotMatch(
actionSource,
/response\.text\(\)/,
"Jina capture should not call unbounded response.text().",
);
});
test("audit generation action sanitizes raw errors before run events and run failure summaries", () => {
assert.match(
actionSource,
/function messageFromError[\s\S]*sanitizeSecretCandidates/,
"messageFromError should sanitize/redact before returning error text.",
);
for (const secretName of ["SCREENSHOTONE_API_KEY", "JINA_API_KEY"]) {
assert.match(
actionSource,
new RegExp(`["']${secretName}["']`),
`Secret sanitizer should know ${secretName}.`,
);
}
assert.doesNotMatch(
actionSource,
/value:\s*messageFromError\(error\)/,
"Run event details should not receive raw messageFromError calls inline.",
);
assert.doesNotMatch(
actionSource,
/errorSummary\s*=\s*messageFromError\(error\)/,
"Failure summaries should not assign unsanitized raw errors inline.",
);
});
test("german-copy OpenRouter usage event aggregates all six generation calls", () => {
assert.match(
actionSource,
/aggregateOpenRouterUsage/,
"Action should expose an aggregation helper for stage-level OpenRouter usage.",
);
assert.match(
actionSource,
/aggregateOpenRouterUsage\(\[[\s\S]*publicSummaryResult\.usage[\s\S]*germanBodyResult\.usage[\s\S]*germanSubjectResult\.usage[\s\S]*germanEmailResult\.usage[\s\S]*germanCallScriptResult\.usage[\s\S]*germanFollowUpResult\.usage[\s\S]*\]\)/,
"German-copy usage should aggregate public summary, body, subject, email, call script, and follow-up calls.",
);
});
test("usage event recording is best-effort and cannot fail audit generation", () => {
const usageRecorder = extractFunctionSource("recordAuditUsageEvent");
assert.match(
usageRecorder,
/try\s*\{[\s\S]*await ctx\.runMutation\(internal\.usageEvents\.recordUsageEvent/,
"Usage recorder should isolate recordUsageEvent in a try block.",
);
assert.match(
usageRecorder,
/catch\s*\(error\)\s*\{[\s\S]*messageFromError\(error\)[\s\S]*level:\s*["']warning["']/,
"Usage recorder should sanitize/log failures as warnings.",
);
assert.match(
usageRecorder,
/catch\s*\(error\)\s*\{[\s\S]*try\s*\{[\s\S]*appendRunEvent[\s\S]*\}\s*catch/,
"Warning logging for usage failures should also be best-effort.",
);
});
test("external capture timeout covers body streaming and cancels readers", () => {
const fetcher = extractFunctionSource("fetchExternalCapture");
const reader = extractFunctionSource("readLimitedResponseBytes");
assert.match(
fetcher,
/return\s*\{[\s\S]*response[\s\S]*abortController:\s*controller[\s\S]*timeout[\s\S]*\}/,
"fetchExternalCapture should return the active deadline context for body reads.",
);
assert.doesNotMatch(
fetcher,
/finally\s*\{[\s\S]*clearTimeout\(timeout\)/,
"fetchExternalCapture should not clear the timeout before body streaming completes.",
);
assert.match(
reader,
/signal\??:\s*AbortSignal/,
"Bounded response reader should accept an AbortSignal.",
);
assert.match(
reader,
/signal\?\.addEventListener\(\s*["']abort["'][\s\S]*reader\.cancel/,
"Bounded response reader should cancel the reader on timeout/abort.",
);
assert.match(
reader,
/totalBytes\s*>\s*maxBytes[\s\S]*await reader\.cancel\(/,
"Bounded response reader should cancel the stream when the byte cap is exceeded.",
);
assert.match(
actionSource,
/readLimitedResponseBytes\([\s\S]*MAX_SCREENSHOT_BYTES[\s\S]*abortController\.signal/,
"Screenshot body reads should use the active timeout signal.",
);
assert.match(
actionSource,
/readLimitedMarkdown\([\s\S]*abortController\.signal/,
"Jina markdown body reads should use the active timeout signal.",
);
assert.match(
actionSource,
/finally\s*\{[\s\S]*clearExternalCaptureTimeout/,
"Capture loops should clear the external timeout after fetch and body streaming finish.",
);
});
test("external capture request builders are provider-level best-effort", () => {
const capture = extractFunctionSource("captureExternalAuditArtifacts");
assert.match(
capture,
/if\s*\(args\.needsScreenshots\)[\s\S]*try\s*\{[\s\S]*buildScreenshotOneRequests/,
"ScreenshotOne request construction should be inside a provider-level try block.",
);
assert.match(
capture,
/buildScreenshotOneRequests[\s\S]*catch\s*\(error\)[\s\S]*messageFromError\(error\)[\s\S]*level:\s*["']warning["']/,
"ScreenshotOne request construction failures should degrade to sanitized warnings.",
);
assert.match(
capture,
/if\s*\(args\.needsMarkdown\)[\s\S]*try\s*\{[\s\S]*buildJinaReaderAuditInput/,
"Jina reader input construction should be inside a provider-level try block.",
);
assert.match(
capture,
/buildJinaReaderAuditInput[\s\S]*catch\s*\(error\)[\s\S]*messageFromError\(error\)[\s\S]*level:\s*["']warning["']/,
"Jina reader input construction failures should degrade to sanitized warnings.",
);
});
test("ScreenshotOne missing-key skip emits best-effort warning only when screenshots are needed", () => {
const capture = extractFunctionSource("captureExternalAuditArtifacts");
const needsScreenshotsIndex = capture.indexOf("if (args.needsScreenshots)");
const needsMarkdownIndex = capture.indexOf("if (args.needsMarkdown)");
const missingKeyWarningIndex = capture.indexOf(
"ScreenshotOne ist nicht konfiguriert; Screenshot-Erfassung wurde übersprungen.",
);
assert.notEqual(
needsScreenshotsIndex,
-1,
"External capture should branch on needsScreenshots.",
);
assert.notEqual(
needsMarkdownIndex,
-1,
"External capture should keep the later needsMarkdown branch.",
);
assert.notEqual(
missingKeyWarningIndex,
-1,
"Missing ScreenshotOne config should emit a clear warning message.",
);
assert.equal(
missingKeyWarningIndex > needsScreenshotsIndex &&
missingKeyWarningIndex < needsMarkdownIndex,
true,
"Missing-key warning should live inside the needsScreenshots branch, so legacy screenshots do not warn.",
);
assert.match(
capture,
/if\s*\(!screenshotOneApiKey\)\s*\{[\s\S]*try\s*\{[\s\S]*await appendRunEvent\(ctx,\s*\{[\s\S]*level:\s*["']warning["'][\s\S]*ScreenshotOne ist nicht konfiguriert; Screenshot-Erfassung wurde übersprungen\.[\s\S]*\}\s*\);[\s\S]*\}\s*catch\s*\{[\s\S]*\}/,
"Missing-key warning logging should be best-effort and unable to fail the audit run.",
);
});
test("external capture non-OK responses cancel bodies before continuing", () => {
const capture = extractFunctionSource("captureExternalAuditArtifacts");
const nonOkCancelCount = [
...capture.matchAll(
/if\s*\(!response\.ok\)\s*\{[\s\S]*?await cancelExternalResponseBody\(response\);[\s\S]*?continue;/g,
),
].length;
assert.match(
actionSource,
/async function cancelExternalResponseBody/,
"Action should centralize best-effort body cancellation for non-OK responses.",
);
assert.equal(
nonOkCancelCount,
2,
"Both ScreenshotOne and Jina non-OK branches should cancel bodies before continue.",
);
});

View File

@@ -0,0 +1,184 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildJinaReaderAuditInput,
buildScreenshotOneRequests,
estimateExternalAuditCostUsd,
} from "../lib/external-audit-services";
test("estimateExternalAuditCostUsd totals managed provider usage", () => {
const estimate = estimateExternalAuditCostUsd({
openRouter: {
inputTokens: 1_500_000,
outputTokens: 250_000,
inputUsdPerMillionTokens: 0.25,
outputUsdPerMillionTokens: 1.25,
},
screenshotOne: {
screenshots: 2,
usdPerScreenshot: 0.01,
},
jina: {
requests: 4,
pages: 4,
usdPerRequest: 0.001,
usdPerPage: 0.002,
},
pageSpeed: {
requests: 2,
},
});
assert.equal(estimate.totalUsd, 0.7195);
assert.deepEqual(estimate.byProvider, {
openRouter: 0.6875,
screenshotOne: 0.02,
jina: 0.012,
pageSpeed: 0,
});
});
test("estimateExternalAuditCostUsd clamps negative usage and prices to zero", () => {
const estimate = estimateExternalAuditCostUsd({
openRouter: {
inputTokens: -1_000_000,
outputTokens: 100_000,
inputUsdPerMillionTokens: 0.25,
outputUsdPerMillionTokens: -1.25,
},
screenshotOne: {
screenshots: -2,
usdPerScreenshot: 0.01,
},
jina: {
requests: 4,
pages: -4,
usdPerRequest: -0.001,
usdPerPage: 0.002,
},
});
assert.deepEqual(estimate.byProvider, {
openRouter: 0,
screenshotOne: 0,
jina: 0,
pageSpeed: 0,
});
assert.equal(estimate.totalUsd, 0);
});
test("buildScreenshotOneRequests creates stable desktop and mobile URLs", () => {
const requests = buildScreenshotOneRequests({
accessKey: "sso_secret_key",
targetUrl: "https://example.com/landing?utm=abc",
});
assert.equal(requests.length, 2);
assert.deepEqual(
requests.map((request) => request.viewport),
["desktop", "mobile"],
);
const desktop = new URL(requests[0]?.url ?? "");
assert.equal(desktop.searchParams.get("access_key"), "sso_secret_key");
assert.equal(desktop.searchParams.get("url"), "https://example.com/landing?utm=abc");
assert.equal(desktop.searchParams.get("viewport_width"), "1280");
assert.equal(desktop.searchParams.get("viewport_height"), "900");
assert.equal(desktop.searchParams.get("device_scale_factor"), "1");
assert.equal(desktop.searchParams.get("full_page"), "true");
assert.equal(desktop.searchParams.get("block_cookie_banners"), "true");
assert.equal(desktop.searchParams.get("block_ads"), "true");
assert.equal(desktop.searchParams.get("block_trackers"), "true");
const mobile = new URL(requests[1]?.url ?? "");
assert.equal(mobile.searchParams.get("viewport_width"), "390");
assert.equal(mobile.searchParams.get("viewport_height"), "844");
assert.equal(mobile.searchParams.get("device_scale_factor"), "2");
assert.equal(mobile.searchParams.get("full_page"), "true");
});
test("buildScreenshotOneRequests rejects non-web target URLs without leaking secrets", () => {
assert.throws(
() =>
buildScreenshotOneRequests({
accessKey: "sso_secret_key",
targetUrl: "ftp://example.com/landing",
}),
(error) => {
assert.equal(error instanceof Error, true);
assert.equal((error as Error).message.includes("sso_secret_key"), false);
assert.match((error as Error).message, /http.*https/i);
return true;
},
);
});
test("buildScreenshotOneRequests does not leak the access key in validation errors", () => {
assert.throws(
() =>
buildScreenshotOneRequests({
accessKey: "sso_secret_key",
targetUrl: "not a url",
}),
(error) => {
assert.equal(error instanceof Error, true);
assert.equal((error as Error).message.includes("sso_secret_key"), false);
assert.match((error as Error).message, /target url/i);
return true;
},
);
});
test("buildJinaReaderAuditInput rejects non-web base and page URLs", () => {
assert.throws(
() =>
buildJinaReaderAuditInput({
baseUrl: "file:///tmp/site.html",
maxMarkdownChars: 100,
}),
/http.*https/i,
);
assert.throws(
() =>
buildJinaReaderAuditInput({
baseUrl: "https://example.com",
pages: [{ url: "ftp://example.com/kontakt", markdown: "Kontakt" }],
maxMarkdownChars: 100,
}),
/http.*https/i,
);
});
test("buildJinaReaderAuditInput prepares capped markdown for relevant pages", () => {
const input = buildJinaReaderAuditInput({
baseUrl: "https://example.com",
pages: [
{ url: "https://example.com", markdown: "# Home\nWillkommen auf der Startseite." },
{ url: "https://example.com/kontakt", markdown: "Kontaktformular und Telefonnummer." },
{ url: "https://example.com/impressum", markdown: "Impressum mit Anbieterkennzeichnung." },
{ url: "https://example.com/leistungen", markdown: "Leistungen fuer Webdesign und SEO." },
{ url: "https://example.com/ueber-uns", markdown: "Ueber uns und Arbeitsweise." },
],
maxMarkdownChars: 95,
});
assert.deepEqual(
input.pages.map((page) => page.path),
["/", "/kontakt", "/impressum", "/leistungen", "/ueber-uns"],
);
assert.deepEqual(
input.readerUrls,
[
"https://r.jina.ai/https://example.com/",
"https://r.jina.ai/https://example.com/kontakt",
"https://r.jina.ai/https://example.com/impressum",
"https://r.jina.ai/https://example.com/leistungen",
"https://r.jina.ai/https://example.com/ueber-uns",
],
);
assert.equal(input.markdown.length <= 95, true);
assert.match(input.markdown, /Source: https:\/\/example.com/);
assert.match(input.markdown, /\[truncated to 95 chars\]$/);
});

View File

@@ -9,12 +9,12 @@ import {
const validPayload = {
auditSummary:
"Ich habe euren Webauftritt geprüft. Mir ist aufgefallen, dass die Kontaktseite nicht klar erreichbar ist. Ich empfehle, den Kontaktbereich im Header sichtbar zu platzieren.",
"Ich habe Ihren Webauftritt geprüft. Mir ist aufgefallen, dass die Kontaktseite nicht klar erreichbar ist. Ich empfehle, den Kontaktbereich im Header sichtbar zu platzieren.",
auditBody:
"Mir ist aufgefallen, dass die Kontaktseite nur am Ende der Startseite eingebettet ist. Ich empfehle, sie im Kopfbereich direkt zu platzieren.",
emailSubject: "Kurzes Feedback zu eurem Webauftritt",
emailSubject: "Kurzer Website-Hinweis",
emailBody:
"Hallo, ich habe eure Seite betrachtet und festgestellt, dass die Kontaktoptionen auf mobilen Geräten schwer zu finden sind. Ich empfehle, einen klar sichtbaren Button einzubauen.",
"Guten Tag, auf Ihrer Kontaktseite ist die Telefonnummer gut sichtbar, der mobile Kontaktbutton liegt aber erst weiter unten. Das kostet Besuchern unterwegs einen extra Schritt, gerade wenn sie schnell anrufen oder einen Termin anfragen wollen. Ich wollte Ihnen das kurz spiegeln, weil es mit wenig Aufwand klarer wirken kann. Mehr braucht es dafür wahrscheinlich nicht. Soll ich Ihnen die zwei Punkte kurz schicken?",
callScript: {
openingLine: "Hallo, ich bin Matthias von der Webberatung.",
callScript: [
@@ -34,6 +34,18 @@ test("validateCustomerFacingCopy passes clean German outreach and audit copy", (
assert.equal(result.issues.length, 0);
});
test("validateCustomerFacingCopy passes a natural short formal first-contact email", () => {
const result = validateCustomerFacingCopy({
...validPayload,
emailSubject: "Kurz zur Kontaktseite",
emailBody:
"Guten Tag, Ihre Telefonnummer ist auf der Kontaktseite gut auffindbar. Auf dem Handy rutscht der direkte Kontaktweg aber recht weit nach unten, sodass Besucher erst suchen müssen, bevor sie anrufen oder schreiben können. Ich wollte Ihnen das kurz zurückmelden, weil es ein kleiner Hebel für mehr Anfragen sein kann. Es geht nicht um einen großen Umbau. Soll ich Ihnen die Stelle kurz als Screenshot schicken?",
});
assert.equal(result.passed, true);
assert.deepEqual(result.issues, []);
});
test("validateCustomerFacingCopy rejects likely non-German copy and reports language", () => {
const result = validateCustomerFacingCopy({
...validPayload,
@@ -75,11 +87,13 @@ test("validateCustomerFacingCopy flags short English artifact-like snippets in c
}
});
test("validateCustomerFacingCopy requires Ich-form in applicable customer-facing fields", () => {
test("validateCustomerFacingCopy requires Ich-form in applicable public audit and follow-up fields", () => {
const result = validateCustomerFacingCopy({
...validPayload,
auditBody:
"Ihre Seite hat eine gute Struktur. Der Kontaktbereich sollte klarer werden.",
emailBody:
"Guten Tag, Ihre Kontaktseite ist schon klar aufgebaut. Auf dem Handy liegt der direkte Kontaktweg aber recht weit unten, sodass Besucher erst suchen müssen, bevor sie anrufen oder schreiben können. Das ist vermutlich schnell zu beheben und würde den Einstieg einfacher machen. Soll ich Ihnen die konkrete Stelle kurz schicken?",
followUp: "Die Website sollte verbessert werden. Setzt bitte einen Kontaktbutton.",
});
@@ -187,10 +201,28 @@ test("validateCustomerFacingCopy strips technical artifacts like model ids and r
});
test("validateCustomerFacingCopy enforces observation + suggestion style", () => {
const result = validateCustomerFacingCopy({
...validPayload,
auditBody:
"Ihre Website wirkt freundlich und klar.",
});
assert.equal(result.passed, false);
assert.equal(
result.issues.some(
(issue) =>
issue.field === "auditBody" &&
issue.rule === "missing_observation_or_suggestion",
),
true,
);
});
test("validateCustomerFacingCopy blocks formulaic observed-and-suggested email copy", () => {
const result = validateCustomerFacingCopy({
...validPayload,
emailBody:
"Deine Website ist großartig, tolle Arbeit.",
"Guten Tag, ich habe beobachtet, dass Ihre Website klare Kontaktinformationen bietet. Ich schlage vor, diese Sichtbarkeit auf allen Seiten beizubehalten. Ich habe beobachtet, dass außerdem die Ladezeiten verbesserungswürdig sind. Ich schlage vor, technische Maßnahmen umzusetzen, damit die Nutzererfahrung nachhaltig verbessert wird. Soll ich Ihnen dazu mehr Informationen senden?",
});
assert.equal(result.passed, false);
@@ -198,7 +230,81 @@ test("validateCustomerFacingCopy enforces observation + suggestion style", () =>
result.issues.some(
(issue) =>
issue.field === "emailBody" &&
issue.rule === "missing_observation_or_suggestion",
issue.rule === "formulaic_email_tone",
),
true,
);
});
test("validateCustomerFacingCopy blocks long mini-audit outreach emails", () => {
const result = validateCustomerFacingCopy({
...validPayload,
emailBody:
"Guten Tag, auf Ihrer Website sind Adresse und Telefonnummer gut sichtbar. Außerdem fehlt eine aussagekräftige Meta-Beschreibung. Zudem sind die Ladezeiten auf mobilen Geräten verbesserungswürdig. Ein weiterer Punkt ist die Nutzerführung mit Call-to-Action-Elementen. Schließlich könnten lokale Vertrauenssignale wie Bewertungen ergänzt werden. Ich empfehle, diese Maßnahmen umzusetzen, um die Conversion-Rate zu steigern, Absprungraten zu senken und Ihr Ranking positiv zu beeinflussen. Soll ich Ihnen eine ausführliche Analyse schicken?",
});
assert.equal(result.passed, false);
assert.equal(
result.issues.some(
(issue) =>
issue.field === "emailBody" &&
(issue.rule === "email_reads_like_mini_audit" ||
issue.rule === "brochure_email_language"),
),
true,
);
});
test("validateCustomerFacingCopy blocks inflated outreach subject lines", () => {
const result = validateCustomerFacingCopy({
...validPayload,
emailSubject:
"Optimierungspotenziale für Ihre Website: Mehr Sichtbarkeit und bessere Nutzererfahrung",
});
assert.equal(result.passed, false);
assert.equal(
result.issues.some(
(issue) =>
issue.field === "emailSubject" &&
issue.rule === "unnatural_email_subject",
),
true,
);
});
test("validateCustomerFacingCopy blocks old live audit copy that reads like generated outreach", () => {
const result = validateCustomerFacingCopy({
auditSummary:
"Ich habe beobachtet, dass die Website von Diehl & Pape Rechtsanwälte zwar durch ihre klare Spezialisierung und umfassenden Kontaktinformationen überzeugt, jedoch durch langsame Ladezeiten und sichtbare Inhaltsverschiebungen beim Laden an Nutzerkomfort verliert. Ich schlage vor, gezielt die Ladegeschwindigkeit zu optimieren und das Seitenlayout stabil zu gestalten, um das Vertrauen potenzieller Mandanten zu stärken und die Nutzerbindung nachhaltig zu erhöhen.",
auditBody:
"Ich habe die Website von Diehl & Pape Rechtsanwälte genau betrachtet und festgestellt, dass die langsamen Ladezeiten und die sichtbaren Inhaltsverschiebungen beim Laden den ersten Eindruck deutlich beeinträchtigen. Mir ist aufgefallen, wie wichtig gerade für eine erfahrene Kanzlei mit klarer Spezialisierung ein reibungsloses Nutzererlebnis ist, um Vertrauen bei potenziellen Mandanten aufzubauen. Deshalb schlage ich vor, gezielt die Ladegeschwindigkeit zu optimieren und die Stabilität des Seitenlayouts zu verbessern.",
emailSubject:
"Ich habe beobachtet, dass die Website von Diehl & Pape Rechtsanwälte durch langsame Ladezeiten und sichtbare Inhaltsverschiebungen die Nutzererfahrung beeinträchtigt.",
emailBody:
"Ich habe die Website von Diehl & Pape Rechtsanwälte genau unter die Lupe genommen und festgestellt, dass die langsamen Ladezeiten und die sichtbaren Inhaltsverschiebungen beim Laden den ersten Eindruck deutlich trüben. Mein konkreter Vorschlag: Eine gezielte Optimierung der Ladegeschwindigkeit und eine Stabilisierung des Seitenlayouts könnten die Nutzerzufriedenheit erheblich steigern.",
callScript: {
openingLine:
"Ich habe die Website von Diehl & Pape Rechtsanwälte genau unter die Lupe genommen und dabei ein wichtiges Verbesserungspotenzial entdeckt.",
callScript: [
"Mir ist aufgefallen, dass die Seite beim Laden deutlich sichtbare Inhaltsverschiebungen zeigt.",
"Ich schlage vor, gezielt die Ladegeschwindigkeit zu optimieren und die Stabilität des Seitenlayouts zu verbessern.",
],
closeLine:
"Lassen Sie uns gemeinsam diese technischen Hürden beseitigen und Ihre Website zu einem überzeugenden Aushängeschild Ihrer Expertise machen.",
},
followUp:
"Ich habe beobachtet, dass die Website von Diehl & Pape Rechtsanwälte durch langsame Ladezeiten an Nutzerkomfort verliert. Mein konkreter Vorschlag ist, die Ladegeschwindigkeit gezielt zu optimieren und die Stabilität des Seitenlayouts sicherzustellen.",
});
assert.equal(result.passed, false);
assert.equal(
result.issues.some(
(issue) =>
(issue.field === "emailSubject" &&
issue.rule === "unnatural_email_subject") ||
(issue.field === "emailBody" &&
issue.rule === "formulaic_email_tone"),
),
true,
);
@@ -210,9 +316,9 @@ test("validateCustomerFacingCopy is permissive for phone numbers and date values
"Ich habe gesehen, dass eure Kontaktseite am 12.02.2026 aktualisiert wurde. Ich empfehle, den Kontaktbereich als Nächstes im Header zu verbessern.",
auditBody:
"Mir ist aufgefallen, dass die Telefonnummer 0201 123456 in der Fußzeile steht. Ich empfehle, sie zusätzlich im Header zu platzieren.",
emailSubject: "Kurzes Feedback zu eurem Terminplan",
emailSubject: "Kurz zum Terminplan",
emailBody:
"Hallo, ich habe euren Webauftritt geprüft und habe gesehen, dass Termine auf der Seite mit dem Datum 12. Oktober erwähnt sind. Ich empfehle, diese Terminangabe im Header stärker hervorzuheben.",
"Guten Tag, auf Ihrer Seite ist der Termin am 12. Oktober schon erwähnt. Auf dem Handy steht diese Info aber recht weit unten, sodass Besucher sie leicht übersehen können, wenn sie nur schnell nach Öffnungszeiten oder Kontakt suchen. Ich wollte Ihnen das kurz zurückmelden, weil die Stelle ohne großen Umbau klarer werden kann. Mehr als eine kleine Umplatzierung braucht es vermutlich nicht. Soll ich Ihnen den Ausschnitt kurz schicken?",
callScript: {
openingLine:
"Hallo, ich bin Matthias und ich habe eure Seite geprüft.",

View File

@@ -10,7 +10,7 @@ const leadsReviewPath = join(
"leads-review-table.tsx",
);
test("LeadsReviewTable uses compact card summaries with expandable review details", async () => {
test("LeadsReviewTable uses compact card summaries with modal review details", async () => {
const source = await readFile(leadsReviewPath, "utf8");
assert.doesNotMatch(source, /<table\b/i);
@@ -21,22 +21,22 @@ test("LeadsReviewTable uses compact card summaries with expandable review detail
assert.doesNotMatch(source, /<th\b/i);
assert.doesNotMatch(source, /min-w-\[/i);
assert.match(source, /Dialog/);
assert.match(source, /DialogContent/);
assert.match(source, /DialogHeader/);
assert.match(source, /DialogTitle/);
assert.match(source, /DialogDescription/);
assert.match(source, /max-h-\[calc\(100dvh-2rem\)\]/);
assert.match(source, /overflow-y-auto/);
assert.match(source, /Mehr anzeigen/);
assert.match(source, /Weniger anzeigen/);
assert.match(source, /aria-expanded=\{[^}]+\}/);
assert.match(source, /aria-controls=\{[^}]+\}/);
assert.match(source, /id=\{[^}]+\}/);
assert.match(
source,
/aria-expanded=\{[^}]+\}[\s\S]{0,160}aria-controls=\{[^}]+\}[\s\S]{0,160}(Mehr anzeigen|Weniger anzeigen)/i,
);
assert.match(
source,
/hidden=\{!?isExpanded\}/,
);
assert.doesNotMatch(source, /Weniger anzeigen/);
assert.doesNotMatch(source, /aria-expanded=\{[^}]+\}/);
assert.doesNotMatch(source, /aria-controls=\{[^}]+\}/);
assert.doesNotMatch(source, /hidden=\{!?isExpanded\}/);
const companyNameMatch = source.match(
/<p className="([^"]+)">\s*\{lead\.companyName\}\s*<\/p>/,
/<p className="([^"]+)"[^>]*>\s*\{lead\.companyName\}\s*<\/p>/,
);
assert.ok(
companyNameMatch !== null &&
@@ -110,3 +110,15 @@ test("LeadsReviewTable uses compact card summaries with expandable review detail
assert.match(source, /Sperren/);
assert.match(source, /Speichern/);
});
test("LeadsReviewTable exposes count filters and live status feedback", async () => {
const source = await readFile(leadsReviewPath, "utf8");
assert.match(source, /leadStatusFilters/);
assert.match(source, /setActiveFilter/);
assert.match(source, /Alle Leads/);
assert.match(source, /Hohe Priorit(?:aet|ät)/);
assert.match(source, /Gesperrt/);
assert.match(source, /role="status"/);
assert.match(source, /role="alert"/);
});

View File

@@ -0,0 +1,195 @@
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);
}
function assertRequiresOperatorBeforeDataAccess(
moduleSource: string,
exportName: string,
helperName?: string,
) {
const functionSource = extractExportSource(moduleSource, exportName);
const authIndex = functionSource.indexOf("await requireOperator(ctx)");
const dbIndex = functionSource.indexOf("ctx.db");
const helperIndex = helperName === undefined
? -1
: functionSource.indexOf(helperName);
const dataAccessIndex = dbIndex === -1 ? helperIndex : dbIndex;
assert.notEqual(
authIndex,
-1,
`${exportName} should call requireOperator before DB access.`,
);
assert.notEqual(
dataAccessIndex,
-1,
`${exportName} should access ctx.db or call its DB helper.`,
);
assert.ok(
authIndex < dataAccessIndex,
`${exportName} should require operator auth before its first data access.`,
);
}
test("lead public APIs require operator auth before DB access", async () => {
const leadsSource = await source("convex/leads.ts");
assert.match(
leadsSource,
/const requireOperator\s*=\s*async\s*\(\s*ctx:\s*(?:MutationCtx\s*\|\s*QueryCtx|QueryCtx\s*\|\s*MutationCtx)\s*\)/,
"leads.ts should define a local requireOperator helper.",
);
assert.match(
leadsSource,
/ctx\.auth\.getUserIdentity\(\)[\s\S]*throw new Error\(["']Nicht autorisiert\.["']\)/,
"requireOperator should derive operator identity from Convex auth.",
);
for (const exportName of [
"create",
"reviewUpdate",
"get",
"list",
"listFunnel",
]) {
assertRequiresOperatorBeforeDataAccess(
leadsSource,
exportName,
exportName === "reviewUpdate" ? "reviewUpdateLead" : undefined,
);
}
});
test("lead internal APIs exist for audit-generation action callsites", async () => {
const [leadsSource, actionSource] = await Promise.all([
source("convex/leads.ts"),
source("convex/auditGenerationAction.ts"),
]);
assert.match(
leadsSource,
/import\s*{[\s\S]*internalMutation[\s\S]*internalQuery[\s\S]*}/,
"leads.ts should import internal Convex builders.",
);
assert.match(
leadsSource,
/export const getInternal\s*=\s*internalQuery\(/,
"leads.ts should expose an internal lead get query for actions.",
);
assert.match(
leadsSource,
/export const reviewUpdateInternal\s*=\s*internalMutation\(/,
"leads.ts should expose an internal lead review mutation for actions.",
);
assert.match(
actionSource,
/internal\.leads\.getInternal/,
"auditGenerationAction should load leads through internal.leads.getInternal.",
);
assert.match(
actionSource,
/internal\.leads\.reviewUpdateInternal/,
"auditGenerationAction should update leads through internal.leads.reviewUpdateInternal.",
);
assert.doesNotMatch(
actionSource,
/api\.leads\.(get|reviewUpdate)/,
"auditGenerationAction should not use public lead APIs for internal calls.",
);
});
test("run public APIs require operator auth before DB access", async () => {
const runsSource = await source("convex/runs.ts");
assert.match(
runsSource,
/const requireOperator\s*=\s*async\s*\(\s*ctx:\s*(?:MutationCtx\s*\|\s*QueryCtx|QueryCtx\s*\|\s*MutationCtx)\s*\)/,
"runs.ts should define a local requireOperator helper.",
);
assert.match(
runsSource,
/ctx\.auth\.getUserIdentity\(\)[\s\S]*throw new Error\(["']Nicht autorisiert\.["']\)/,
"requireOperator should derive operator identity from Convex auth.",
);
for (const exportName of [
"create",
"updateStatus",
"list",
"appendEvent",
"listEvents",
]) {
assertRequiresOperatorBeforeDataAccess(
runsSource,
exportName,
exportName === "appendEvent" ? "appendRunEvent" : undefined,
);
}
});
test("actions append run events through internal run mutation", async () => {
const [runsSource, auditAction, pageSpeedAction, enrichmentAction] =
await Promise.all([
source("convex/runs.ts"),
source("convex/auditGenerationAction.ts"),
source("convex/pageSpeedAction.ts"),
source("convex/websiteEnrichmentAction.ts"),
]);
assert.match(
runsSource,
/export const appendEventInternal\s*=\s*internalMutation\(/,
"runs.ts should expose an internal append event mutation for actions.",
);
for (const [name, actionSource] of [
["auditGenerationAction", auditAction],
["pageSpeedAction", pageSpeedAction],
["websiteEnrichmentAction", enrichmentAction],
] as const) {
assert.match(
actionSource,
/internal\.runs\.appendEventInternal/,
`${name} should append events through internal.runs.appendEventInternal.`,
);
assert.doesNotMatch(
actionSource,
/api\.runs\.appendEvent/,
`${name} should not append events through the public runs API.`,
);
}
});

View File

@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getIntegrationReadiness,
integrationReadinessDefinitions,
} from "../lib/operational-readiness";
test("integration readiness covers all MVP providers", () => {
assert.deepEqual(
integrationReadinessDefinitions.map((definition) => definition.id),
[
"google",
"pagespeed",
"openrouter",
"screenshotone",
"smtp",
"convex_jobs",
"rybbit",
"jina",
],
);
});
test("integration readiness reports missing configuration without leaking values", () => {
const rows = getIntegrationReadiness({
GOOGLE_GEOCODING_API_KEY: "secret-google",
GOOGLE_PLACES_API_KEY: "secret-places",
PAGESPEED_API_KEY: "",
});
const google = rows.find((row) => row.id === "google");
const pageSpeed = rows.find((row) => row.id === "pagespeed");
assert.equal(google?.status, "configured");
assert.equal(pageSpeed?.status, "missing");
assert.equal(JSON.stringify(rows).includes("secret-google"), false);
assert.equal(JSON.stringify(rows).includes("secret-places"), false);
});
test("integration readiness treats ScreenshotOne as required and Jina as optional", () => {
const rows = getIntegrationReadiness({
GOOGLE_GEOCODING_API_KEY: "secret-google",
GOOGLE_PLACES_API_KEY: "secret-places",
PAGESPEED_API_KEY: "secret-pagespeed",
PAGESPEED_TIMEOUT_MS: "60000",
OPENROUTER_API_KEY: "secret-openrouter",
SMTP_HOST: "smtp.example.com",
SMTP_USER: "user",
SMTP_PASSWORD: "password",
SMTP_FROM: "Audit <audit@example.com>",
NEXT_PUBLIC_CONVEX_URL: "https://example.convex.cloud",
CONVEX_DEPLOYMENT: "prod:example",
RYBBIT_API_URL: "https://analytics.example.com",
RYBBIT_API_KEY: "secret-rybbit",
NEXT_PUBLIC_RYBBIT_SITE_ID: "site-id",
});
const screenshotOne = rows.find((row) => row.id === "screenshotone");
const jina = rows.find((row) => row.id === "jina");
assert.equal(screenshotOne?.status, "missing");
assert.deepEqual(screenshotOne?.missingEnv, ["SCREENSHOTONE_API_KEY"]);
assert.equal(jina?.status, "configured");
assert.deepEqual(jina?.missingEnv, []);
});
test("integration readiness no longer requires Playwright for the new pipeline", () => {
const definitionIds = integrationReadinessDefinitions.map((definition) => definition.id as string);
assert.equal(
definitionIds.includes("playwright"),
false,
);
assert.equal(
integrationReadinessDefinitions.some((definition) =>
definition.requiredEnv.includes("TASK8_BROWSER_ASSET_URL"),
),
false,
);
});

View File

@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
function source(path: string) {
return readFileSync(join(process.cwd(), ...path.split("/")), "utf8");
}
test("settings page surfaces integration status instead of a placeholder", () => {
const pageSource = source("app/dashboard/settings/page.tsx");
const componentSource = source("components/settings/operations-readiness.tsx");
const helperSource = source("lib/operational-readiness.ts");
assert.doesNotMatch(pageSource, /DashboardPlaceholderPage/);
assert.match(pageSource, /OperationsReadiness/);
for (const label of [
"Google",
"PageSpeed",
"OpenRouter",
"ScreenshotOne",
"SMTP",
"Convex Jobs",
"Rybbit",
"Jina",
"Konfiguration fehlt",
]) {
assert.match(`${componentSource}\n${helperSource}`, new RegExp(label));
}
assert.doesNotMatch(helperSource, /id: "playwright"/);
assert.doesNotMatch(helperSource, /requiredEnv: \["TASK8_BROWSER_ASSET_URL"\]/);
assert.match(helperSource, /requiredEnv: \["SCREENSHOTONE_API_KEY"\]/);
assert.match(helperSource, /requiredEnv: \[\]/);
assert.match(componentSource, /Next\.js-Runtime/);
assert.match(componentSource, /Convex-Action-Env/);
assert.match(helperSource, /Convex-Run-Events/);
});
test("verification notes cover critical MVP flows", () => {
const docPath = join(process.cwd(), "docs", "verification.md");
assert.equal(existsSync(docPath), true);
const doc = readFileSync(docPath, "utf8");
for (const label of [
"Login",
"Kampagnenlauf",
"Audit-Generierung",
"Freigabe",
"Versand",
"Follow-up",
"Analytics",
]) {
assert.match(doc, new RegExp(label));
}
});
test("Coolify deployment notes cover env vars, Playwright dependencies, port, and domains", () => {
const docPath = join(process.cwd(), "docs", "coolify-deployment.md");
assert.equal(existsSync(docPath), true);
const doc = readFileSync(docPath, "utf8");
assert.match(doc, /Environment Variables/);
assert.match(doc, /TASK8_BROWSER_ASSET_URL/);
assert.match(doc, /Playwright/);
assert.match(doc, /Port 3000/);
assert.match(doc, /NEXT_PUBLIC_APP_URL/);
assert.match(doc, /Domain/);
});

View File

@@ -32,3 +32,12 @@ test("manual sales status mutation updates lead suppression states", () => {
assert.match(outreachSource, /not_interested[\s\S]*outreachPatch\.responseStatus\s*=\s*"no_interest"/);
assert.match(outreachSource, /do_not_pursue[\s\S]*outreachPatch\.doNotContactUntil\s*=\s*now\s*\+\s*DO_NOT_CONTACT_RECHECK_MS/);
});
test("lead funnel query exposes do-not-contact recheck dates", () => {
const leadsSource = readFileSync(
join(process.cwd(), "convex", "leads.ts"),
"utf8",
);
assert.match(leadsSource, /doNotContactUntil:\s*latestOutreach\.doNotContactUntil/);
});

View File

@@ -83,6 +83,21 @@ test("OutreachReviewWorkspace uses the review workspace API and required control
].forEach((label) => assert.match(source, new RegExp(label)));
});
test("OutreachReviewWorkspace renders a compact queue with one selected detail editor", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
assert.match(source, /selectedRecordId/);
assert.match(source, /selectedRecord/);
assert.match(source, /Details prüfen/);
assert.match(source, /Review-Queue/);
assert.match(source, /reviewStatusFilters/);
assert.match(source, /setActiveFilter/);
assert.match(source, /Bereit zum Versand/);
assert.match(source, /Mail offen/);
assert.match(source, /role="status"/);
assert.match(source, /aria-pressed=\{selectedRecord\?\.id === record\.id\}/);
});
test("OutreachReviewWorkspace keeps exactly one recommended email subject and body editor", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");

View File

@@ -238,7 +238,7 @@ test("pageSpeedAction stores and persists results and writes events", () => {
);
assert.equal(
/api\.runs\.appendEvent,\s*{\s*[\s\S]*runId:\s*args\.runId,\s*[\s\S]*level:\s*["']info["']/.test(
/internal\.runs\.appendEventInternal,\s*{\s*[\s\S]*runId:\s*args\.runId,\s*[\s\S]*level:\s*["']info["']/.test(
actionSource,
),
true,
@@ -283,7 +283,7 @@ test("pageSpeedAction does not expose API key in event messages/details", () =>
assert.equal(
hasPattern(
actionSource,
/api\.runs\.appendEvent[\s\S]{0,500}PAGESPEED_API_KEY/,
/internal\.runs\.appendEventInternal[\s\S]{0,500}PAGESPEED_API_KEY/,
),
false,
"Action events should not include raw PAGESPEED_API_KEY",

View File

@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildRybbitEventsUrl,
summarizeCampaignRybbitEvents,
summarizeAuditRybbitEvents,
type RybbitEvent,
} from "../lib/rybbit-analytics";
test("buildRybbitEventsUrl targets the documented events endpoint", () => {
const url = buildRybbitEventsUrl({
apiUrl: "https://analytics.example.com/",
siteId: "site_123",
startDate: "2026-06-01T00:00:00.000Z",
endDate: "2026-06-05T00:00:00.000Z",
});
assert.equal(
url.toString(),
"https://analytics.example.com/api/sites/site_123/events?start_date=2026-06-01T00%3A00%3A00.000Z&end_date=2026-06-05T00%3A00%3A00.000Z",
);
});
test("summarizeAuditRybbitEvents extracts opens, clicks, last view, and devices", () => {
const events: RybbitEvent[] = [
{
type: "pageview",
timestamp: "2026-06-05T10:00:00.000Z",
pathname: "/audit/demo",
properties: { device: "desktop" },
},
{
type: "custom_event",
timestamp: "2026-06-05T10:05:00.000Z",
event_name: "audit_cta_click",
pathname: "/audit/demo",
properties: { target: "cta", deviceType: "mobile" },
},
{
type: "outbound_link",
timestamp: "2026-06-05T10:06:00.000Z",
pathname: "/audit/demo",
properties: { href: "https://example.com", device: "mobile" },
},
{
type: "pageview",
timestamp: "2026-06-05T11:00:00.000Z",
pathname: "/pricing",
properties: { device: "desktop" },
},
];
assert.deepEqual(summarizeAuditRybbitEvents(events, "/audit/demo"), {
opened: true,
viewCount: 1,
lastView: "2026-06-05T10:00:00.000Z",
ctaClicks: 1,
websiteLinkClicks: 1,
deviceTypes: ["desktop", "mobile"],
});
});
test("summarizeAuditRybbitEvents returns graceful empty metrics", () => {
assert.deepEqual(summarizeAuditRybbitEvents([], "/audit/demo"), {
opened: false,
viewCount: 0,
lastView: null,
ctaClicks: 0,
websiteLinkClicks: 0,
deviceTypes: [],
});
});
test("summarizeCampaignRybbitEvents aggregates public audit activity", () => {
assert.deepEqual(
summarizeCampaignRybbitEvents([
{ type: "pageview", pathname: "/audit/a" },
{ type: "pageview", pathname: "/dashboard" },
{ type: "custom_event", event_name: "audit_cta_click", pathname: "/audit/a" },
{ type: "outbound_link", pathname: "/audit/a" },
]),
{
auditOpens: 1,
ctaClicks: 1,
outboundClicks: 1,
byPath: {
"/audit/a": {
auditOpens: 1,
ctaClicks: 1,
outboundClicks: 1,
},
},
},
);
});

View File

@@ -0,0 +1,356 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import ts from "typescript";
const schemaPath = join(process.cwd(), "convex", "schema.ts");
const domainPath = join(process.cwd(), "convex", "domain.ts");
const usageEventsPath = join(process.cwd(), "convex", "usageEvents.ts");
const schemaSource = readFileSync(schemaPath, "utf8");
const domainSource = readFileSync(domainPath, "utf8");
const usageEventsSource = existsSync(usageEventsPath)
? readFileSync(usageEventsPath, "utf8")
: "";
const usageEventsSourceFile = ts.createSourceFile(
"usageEvents.ts",
usageEventsSource,
ts.ScriptTarget.ES2022,
true,
ts.ScriptKind.TS,
);
function getExportedConstNames(file: ts.SourceFile) {
const names = new Set<string>();
const visit = (node: ts.Node) => {
if (ts.isVariableStatement(node)) {
const isExported = node.modifiers?.some(
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
);
const isConst = node.declarationList.flags & ts.NodeFlags.Const;
if (isExported && isConst) {
for (const declaration of node.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(file, visit);
return names;
}
function extractTableSection(tableName: string) {
const marker = `${tableName}: defineTable({`;
const markerIndex = schemaSource.indexOf(marker);
assert.notEqual(
markerIndex,
-1,
`Expected schema table definition for ${tableName}.`,
);
const objectStart = schemaSource.indexOf("{", markerIndex);
let depth = 0;
let objectEnd = -1;
for (let index = objectStart; index < schemaSource.length; index += 1) {
if (schemaSource[index] === "{") {
depth += 1;
} else if (schemaSource[index] === "}") {
depth -= 1;
if (depth === 0) {
objectEnd = index;
break;
}
}
}
assert.notEqual(objectEnd, -1, `Could not parse schema object for ${tableName}.`);
const remainder = schemaSource.slice(objectEnd + 1);
const nextTableMatch = remainder.match(/^\s*[a-zA-Z_][\w]*:\s*defineTable\(/m);
const sectionEnd =
nextTableMatch === null ? schemaSource.length : objectEnd + 1 + nextTableMatch.index!;
return {
objectBlock: schemaSource.slice(markerIndex, objectEnd + 1),
section: schemaSource.slice(markerIndex, sectionEnd),
};
}
function extractExportSource(name: string) {
const marker = `export const ${name} = `;
const declarationIndex = usageEventsSource.indexOf(marker);
assert.notEqual(declarationIndex, -1, `Expected declaration for ${name}`);
const openBraceIndex = usageEventsSource.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < usageEventsSource.length; index += 1) {
const char = usageEventsSource[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 usageEventsSource.slice(openBraceIndex, end + 1);
}
function assertHas(pattern: RegExp, source: string, message: string) {
assert.equal(pattern.test(source), true, message);
}
const usageReadQueries = [
{
name: "listLatestUsageEvents",
indexAssertion:
/withIndex\("by_createdAt"\)/,
message: "latest query should use by_createdAt.",
},
{
name: "listUsageEventsByRun",
indexAssertion:
/withIndex\("by_runId_and_createdAt"[\s\S]*?eq\("runId",\s*args\.runId\)/,
message: "run query should use by_runId_and_createdAt with runId equality.",
},
{
name: "listUsageEventsByLead",
indexAssertion:
/withIndex\("by_leadId_and_createdAt"[\s\S]*?eq\("leadId",\s*args\.leadId\)/,
message: "lead query should use by_leadId_and_createdAt with leadId equality.",
},
{
name: "listUsageEventsByAudit",
indexAssertion:
/withIndex\("by_auditId_and_createdAt"[\s\S]*?eq\("auditId",\s*args\.auditId\)/,
message: "audit query should use by_auditId_and_createdAt with auditId equality.",
},
{
name: "listUsageEventsByProvider",
indexAssertion:
/withIndex\("by_provider_and_createdAt"[\s\S]*?eq\("provider",\s*args\.provider\)/,
message: "provider query should use by_provider_and_createdAt with provider equality.",
},
] as const;
test("usage domain constants declare supported providers and operations", () => {
assertHas(
/USAGE_EVENT_PROVIDERS\s*=\s*\[[\s\S]*"openrouter"[\s\S]*"screenshotone"[\s\S]*"jina"[\s\S]*"pagespeed"[\s\S]*"google_places"[\s\S]*\]\s*as const/,
domainSource,
"Domain should declare usage providers for all managed external services.",
);
assertHas(
/USAGE_EVENT_OPERATIONS\s*=\s*\[[\s\S]*"audit_capture"[\s\S]*"audit_generation"[\s\S]*"lead_lookup"[\s\S]*\]\s*as const/,
domainSource,
"Domain should declare usage operations for capture, generation, and lookup.",
);
});
test("usageEvents schema stores cost and usage dimensions with bounded indexes", () => {
const { objectBlock, section } = extractTableSection("usageEvents");
assertHas(/provider:\s*usageEventProvider/, objectBlock, "provider should use the provider validator.");
assertHas(/operation:\s*usageEventOperation/, objectBlock, "operation should use the operation validator.");
assertHas(
/runId:\s*v\.optional\(\s*v\.id\(["']agentRuns["']\)\s*\)/,
objectBlock,
"runId should be optional for SaaS-ready attribution.",
);
assertHas(
/leadId:\s*v\.optional\(\s*v\.id\(["']leads["']\)\s*\)/,
objectBlock,
"leadId should be optional for lead-level attribution.",
);
assertHas(
/auditId:\s*v\.optional\(\s*v\.id\(["']audits["']\)\s*\)/,
objectBlock,
"auditId should be optional for audit-level attribution.",
);
assertHas(
/estimatedCostUsd:\s*v\.number\(\)/,
objectBlock,
"estimatedCostUsd should be a required normalized number.",
);
assertHas(
/tokens:\s*v\.optional\(\s*v\.object\([\s\S]*?inputTokens:\s*v\.optional\(v\.number\(\)\)[\s\S]*?outputTokens:\s*v\.optional\(v\.number\(\)\)[\s\S]*?promptTokens:\s*v\.optional\(v\.number\(\)\)[\s\S]*?completionTokens:\s*v\.optional\(v\.number\(\)\)[\s\S]*?totalTokens:\s*v\.optional\(v\.number\(\)\)[\s\S]*?\)\s*\)/,
objectBlock,
"tokens should capture OpenRouter-compatible token dimensions.",
);
assertHas(
/callCounts:\s*v\.optional\(\s*v\.object\(/,
objectBlock,
"callCounts should be an optional object.",
);
for (const countName of ["requests", "pages", "screenshots", "lookups"]) {
assertHas(
new RegExp(`${countName}:\\s*v\\.optional\\(v\\.number\\(\\)\\)`),
objectBlock,
`callCounts.${countName} should be optional number.`,
);
}
assertHas(/createdAt:\s*v\.number\(\)/, objectBlock, "createdAt should be required.");
for (const [indexName, fields] of [
["by_runId_and_createdAt", '"runId",\\s*"createdAt"'],
["by_leadId_and_createdAt", '"leadId",\\s*"createdAt"'],
["by_auditId_and_createdAt", '"auditId",\\s*"createdAt"'],
["by_provider_and_createdAt", '"provider",\\s*"createdAt"'],
["by_createdAt", '"createdAt"'],
] as const) {
assertHas(
new RegExp(`index\\("${indexName}",\\s*\\[${fields}\\]\\)`),
section,
`usageEvents should define ${indexName}.`,
);
}
});
test("usageEvents module exposes internal recorder and authenticated bounded read queries", () => {
assert.equal(existsSync(usageEventsPath), true, "usageEvents.ts should be present.");
const exports = getExportedConstNames(usageEventsSourceFile);
for (const exportName of [
"recordUsageEvent",
...usageReadQueries.map((readQuery) => readQuery.name),
]) {
assert.equal(exports.has(exportName), true, `Expected export: ${exportName}`);
}
assertHas(
/export const recordUsageEvent = internalMutation\s*\(/,
usageEventsSource,
"recordUsageEvent should be an internalMutation.",
);
for (const { name } of usageReadQueries) {
assertHas(
new RegExp(`export const ${name} = query\\s*\\(`),
usageEventsSource,
`${name} should remain a public authenticated bounded query.`,
);
}
});
test("usageEvents queries use indexes and bounded take without filters or collect", () => {
const querySources = usageReadQueries.map((readQuery) => ({
...readQuery,
source: extractExportSource(readQuery.name),
}));
for (const { source } of querySources) {
assertHas(/limit:\s*v\.optional\(v\.number\(\)\)/, source, "read query should validate limit.");
}
for (const { source, indexAssertion, message } of querySources) {
assertHas(indexAssertion, source, message);
}
for (const source of [usageEventsSource, ...querySources.map((querySource) => querySource.source)]) {
assert.doesNotMatch(source, /\.filter\s*\(/, "usageEvents should not use query filters.");
assert.doesNotMatch(source, /\.collect\s*\(/, "usageEvents should not use unbounded collect.");
}
for (const { source } of querySources) {
assertHas(
/\.take\(\s*normalizeListLimit\(args\.limit\)\s*\)/,
source,
"read query should be bounded.",
);
}
});
test("usageEvents read queries require operator auth before reading telemetry", () => {
assertHas(
/const\s+requireOperator\s*=\s*async\s*\(\s*ctx:\s*QueryCtx\s*\)[\s\S]*ctx\.auth\.getUserIdentity\(\)[\s\S]*throw new Error\(["']Nicht autorisiert\.["']\)/,
usageEventsSource,
"usageEvents should define the local requireOperator auth guard.",
);
for (const { name } of usageReadQueries) {
const source = extractExportSource(name);
const authIndex = source.indexOf("await requireOperator(ctx)");
const readIndex = source.indexOf("ctx.db");
assert.notEqual(authIndex, -1, `${name} should require operator auth.`);
assert.notEqual(readIndex, -1, `${name} should read from ctx.db.`);
assert.equal(
authIndex < readIndex,
true,
`${name} should require auth before reading usage telemetry.`,
);
}
});
test("recordUsageEvent guards finite non-negative usage numbers before insert", () => {
const recordSource = extractExportSource("recordUsageEvent");
const guardCallIndex = recordSource.indexOf("assertValidUsageEventNumbers(args)");
const insertIndex = recordSource.indexOf('ctx.db.insert("usageEvents"');
assert.notEqual(
guardCallIndex,
-1,
"recordUsageEvent should call assertValidUsageEventNumbers(args).",
);
assert.notEqual(insertIndex, -1, "recordUsageEvent should insert usageEvents.");
assert.equal(
guardCallIndex < insertIndex,
true,
"recordUsageEvent should validate usage numbers before inserting.",
);
assertHas(
/function\s+assertFiniteNonNegativeNumber[\s\S]*Number\.isFinite\(value\)[\s\S]*value\s*<\s*0/,
usageEventsSource,
"Cost guard should reject NaN, Infinity, and negative numbers.",
);
assertHas(
/function\s+assertFiniteNonNegativeInteger[\s\S]*Number\.isFinite\(value\)[\s\S]*value\s*<\s*0[\s\S]*Number\.isInteger\(value\)/,
usageEventsSource,
"Token/count guard should require finite non-negative integers.",
);
assertHas(
/assertFiniteNonNegativeNumber\(args\.estimatedCostUsd,\s*["']estimatedCostUsd["']\)/,
usageEventsSource,
"estimatedCostUsd should use the finite non-negative number guard.",
);
for (const tokenName of [
"inputTokens",
"outputTokens",
"promptTokens",
"completionTokens",
"totalTokens",
"cacheReadTokens",
]) {
assertHas(
new RegExp(
`assertFiniteNonNegativeInteger\\(args\\.tokens\\?\\.${tokenName},\\s*["']tokens\\.${tokenName}["']\\)`,
),
usageEventsSource,
`tokens.${tokenName} should use the finite non-negative integer guard.`,
);
}
for (const countName of ["requests", "pages", "screenshots", "lookups"]) {
assertHas(
new RegExp(
`assertFiniteNonNegativeInteger\\(args\\.callCounts\\?\\.${countName},\\s*["']callCounts\\.${countName}["']\\)`,
),
usageEventsSource,
`callCounts.${countName} should use the finite non-negative integer guard.`,
);
}
});

View File

@@ -195,7 +195,7 @@ test("queueLeadEnrichment uses lead-aware run index and does not use fixed-size
assert.equal(hasPattern(queueBody, /take\(50\)/), false, "No fixed-size .take(50) window in dedupe queries.");
});
test("website enrichment action uses Chromium desktop/mobile devices and runtime Playwright import", () => {
test("website enrichment action can still use Chromium desktop/mobile devices when configured", () => {
assert.equal(
hasPattern(
actionSource,
@@ -224,14 +224,6 @@ test("website enrichment action uses Chromium desktop/mobile devices and runtime
true,
"Action should reference TASK8_BROWSER_ASSET_URL when loading browser assets",
);
assert.equal(
hasPattern(
actionSource,
/TASK8_BROWSER_ASSET_URL[\s\S]{0,240}(throw|Error|required|missing|not configured|configured|konfiguriert|setze)/i,
),
true,
"Action should surface a clear error when the browser asset URL is not configured",
);
assert.equal(
hasPattern(actionSource, /import\("@sparticuz\/chromium"\)/),
false,
@@ -271,6 +263,84 @@ test("website enrichment action uses Chromium desktop/mobile devices and runtime
);
});
test("processLeadEnrichment uses browserless enrichment when Chromium source is missing", () => {
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
const fallbackGuardIndex = processBody.indexOf("if (!getChromiumExecutableSource())");
const playwrightLoadIndex = processBody.indexOf("loadPlaywrightModules()");
assert.notEqual(
fallbackGuardIndex,
-1,
"processLeadEnrichment should branch before Playwright bootstrap when no Chromium source is configured.",
);
assert.equal(
fallbackGuardIndex < playwrightLoadIndex,
true,
"The missing-Chromium fallback should run before loadPlaywrightModules().",
);
assert.equal(
hasPattern(
processBody,
/if \(!getChromiumExecutableSource\(\)\)\s*\{[\s\S]*processLeadEnrichmentWithoutBrowser\(/,
),
true,
"Missing browser asset config should call the browserless enrichment path instead of throwing.",
);
assert.equal(
hasPattern(actionSource, /async function processLeadEnrichmentWithoutBrowser\(/),
true,
"Action should expose a dedicated browserless enrichment helper.",
);
assert.equal(
hasPattern(
actionSource,
/Chromium ist nicht konfiguriert; Website-Enrichment nutzt browserlosen Fetch-Fallback\./,
),
true,
"The fallback should make the degraded mode visible in run events.",
);
});
test("browserless website enrichment persists crawl evidence without screenshots", () => {
const fallbackSource = actionSource.slice(
actionSource.indexOf("async function processLeadEnrichmentWithoutBrowser("),
);
assert.equal(
hasPattern(fallbackSource, /crawlPageWithoutBrowser\(/),
true,
"Browserless enrichment should fetch pages directly instead of launching Playwright.",
);
assert.equal(
hasPattern(
actionSource,
/function crawlPageWithoutBrowser[\s\S]*extractContactSignalsFromHtmlLikeText\(/,
),
true,
"Browserless enrichment should still extract contact signals from fetched page content.",
);
assert.equal(
hasPattern(fallbackSource, /internal\.websiteEnrichment\.persistLeadEnrichmentResult/),
true,
"Browserless enrichment should persist pages, links, email candidates, and technical checks.",
);
assert.equal(
hasPattern(fallbackSource, /screenshots:\s*\[\]/),
true,
"Browserless enrichment should not pretend screenshots exist.",
);
assert.equal(
hasPattern(fallbackSource, /status:\s*"succeeded"/),
true,
"A successful browserless crawl should finish the enrichment run as succeeded.",
);
assert.equal(
hasPattern(fallbackSource, /internal\.pageSpeed\.queueLeadPageSpeedAudit/),
true,
"Browserless enrichment should keep the downstream PageSpeed handoff.",
);
});
test("website enrichment action invalidates stale @sparticuz/chromium-min cache when source changes", () => {
assert.equal(
hasPattern(actionSource, /CHROMIUM_SOURCE_MARKER_FILE/),
@@ -518,7 +588,7 @@ test("failure handling marks run as failed and writes lead-facing reason", () =>
assert.equal(
hasPattern(
actionSource,
/runMutation\(\s*api\.runs\.appendEvent[\s\S]*?level:\s*"error"[\s\S]*?message:\s*"Website-Enrichment fehlgeschlagen/,
/runMutation\(\s*internal\.runs\.appendEventInternal[\s\S]*?level:\s*"error"[\s\S]*?message:\s*"Website-Enrichment fehlgeschlagen/,
),
true,
"Action should append a visible error event on failure",
@@ -541,6 +611,66 @@ test("failure handling marks run as failed and writes lead-facing reason", () =>
);
});
test("website enrichment action treats Playwright close operations as best-effort cleanup", () => {
assert.equal(
hasPattern(actionSource, /function isPlaywrightTargetClosedError\(/),
true,
"Action should centralize recognition of Playwright target/page/context/browser closed errors.",
);
assert.equal(
hasPattern(actionSource, /async function closePlaywrightResourceSafely\(/),
true,
"Action should centralize best-effort Playwright resource cleanup.",
);
assert.equal(
hasPattern(
actionSource,
/isPlaywrightTargetClosedError[\s\S]*Target page, context or browser has been closed/,
),
true,
"Target page/context/browser closed errors should be recognized explicitly.",
);
assert.equal(
hasPattern(
actionSource,
/closePlaywrightResourceSafely[\s\S]*console\.warn\(/,
),
true,
"Unexpected Playwright close failures should be swallowed with a warning.",
);
const directUnsafeClosePatterns = [
/finally\s*\{\s*await page\.close\(\);?\s*\}/,
/finally\s*\{[\s\S]*await desktopContext\.close\(\);/,
/finally\s*\{[\s\S]*await mobileContext\.close\(\);/,
/finally\s*\{[\s\S]*await browser\.close\(\);/,
];
for (const pattern of directUnsafeClosePatterns) {
assert.equal(
hasPattern(actionSource, pattern),
false,
`Playwright cleanup should not await close() directly in finally: ${pattern}`,
);
}
const safeCloseCalls = [
/closePlaywrightResourceSafely\(\s*page,\s*"homepage screenshot page"/,
/closePlaywrightResourceSafely\(\s*page,\s*"crawl page"/,
/closePlaywrightResourceSafely\(\s*desktopContext,\s*"desktop browser context"/,
/closePlaywrightResourceSafely\(\s*mobileContext,\s*"mobile browser context"/,
/closePlaywrightResourceSafely\(\s*browser,\s*"browser"/,
];
for (const pattern of safeCloseCalls) {
assert.equal(
hasPattern(actionSource, pattern),
true,
`Expected Playwright cleanup to use safe close helper: ${pattern}`,
);
}
});
test("website enrichment enforces TASK-8 crawler limits and runtime timeboxes", () => {
assert.equal(
hasPattern(actionSource, /TASK8_CRAWL_TIMEOUT_MS/g),
@@ -666,7 +796,7 @@ test("processLeadEnrichment records warning on PageSpeed queue failure and conti
assert.equal(
hasPattern(
processBody,
/try\s*\{[\s\S]*internal\.pageSpeed\.queueLeadPageSpeedAudit[\s\S]*\}\s*catch\s*\([^)]*\)\s*\{[\s\S]*api\.runs\.appendEvent[\s\S]*level:\s*"warning"/,
/try\s*\{[\s\S]*internal\.pageSpeed\.queueLeadPageSpeedAudit[\s\S]*\}\s*catch\s*\([^)]*\)\s*\{[\s\S]*internal\.runs\.appendEventInternal[\s\S]*level:\s*"warning"/,
),
true,
"Queueing PageSpeed should be wrapped in warning-safe try/catch",

View File

@@ -30,5 +30,8 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": [
"node_modules",
"v2_elemente/**"
]
}

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