Compare commits
12 Commits
807532a0a4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f00c5a3193 | |||
| 1695110e0a | |||
| ff18fc202e | |||
| a45b92ea0a | |||
| 470fb0f348 | |||
| e9463e8ef2 | |||
| 3efbc06e40 | |||
| f069b74b08 | |||
| d3928d61c4 | |||
| df8ca1f049 | |||
| 70951789d2 | |||
| 3f148bcec2 |
14
.env.example
14
.env.example
@@ -1,8 +1,12 @@
|
|||||||
# App / Coolify
|
# App / Coolify
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=https://audit.matthias-meister-webdesign.de
|
||||||
|
|
||||||
# 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_TIMEOUT_MS=60000
|
||||||
TASK8_CRAWL_MAX_PAGES=20
|
TASK8_CRAWL_MAX_PAGES=20
|
||||||
TASK8_BROWSER_ASSET_URL=
|
TASK8_BROWSER_ASSET_URL=
|
||||||
@@ -31,6 +35,12 @@ OPENROUTER_MODEL_QUALITY_REVIEW=
|
|||||||
OPENROUTER_APP_NAME=
|
OPENROUTER_APP_NAME=
|
||||||
OPENROUTER_APP_URL=
|
OPENROUTER_APP_URL=
|
||||||
|
|
||||||
|
# ScreenshotOne
|
||||||
|
SCREENSHOTONE_API_KEY=
|
||||||
|
|
||||||
|
# Jina (optional fallback; no key required for current readiness)
|
||||||
|
JINA_API_KEY=
|
||||||
|
|
||||||
# SMTP / Stalwart
|
# SMTP / Stalwart
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -1,6 +1,8 @@
|
|||||||
# WebDev Pipeline
|
# WebDev Pipeline
|
||||||
|
|
||||||
Interner Akquise-Agent fuer lokale Webdesign-Leads. Das MVP startet mit Next.js App Router, TypeScript, Tailwind CSS, shadcn/ui und Platzhalter-Routen fuer Dashboard, Login und oeffentliche Audit-Seiten.
|
Persoenlicher Akquise-Agent fuer lokale Webdesign-Leads auf `audit.matthias-meister-webdesign.de`. Das MVP startet mit Next.js App Router, TypeScript, Tailwind CSS, shadcn/ui und Platzhalter-Routen fuer Dashboard, Login und oeffentliche Audit-Seiten.
|
||||||
|
|
||||||
|
Der aktuelle Scope ist bewusst persoenlich: Google, PageSpeed, OpenRouter, ScreenshotOne und optional Jina laufen ueber serverseitig verwaltete Keys. BYO-Keys, Billing und Teamrollen gehoeren zur spaeteren SaaS-Readiness, aber nicht zu dieser Welle.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -23,12 +25,13 @@ Copy `.env.example` to `.env.local` for local development. Keep real secrets out
|
|||||||
|
|
||||||
- **App / Coolify:** `APP_ENV`, `NEXT_PUBLIC_APP_URL`
|
- **App / Coolify:** `APP_ENV`, `NEXT_PUBLIC_APP_URL`
|
||||||
- **Convex:** `NEXT_PUBLIC_CONVEX_URL`, `NEXT_PUBLIC_CONVEX_SITE_URL`, `CONVEX_DEPLOYMENT`
|
- **Convex:** `NEXT_PUBLIC_CONVEX_URL`, `NEXT_PUBLIC_CONVEX_SITE_URL`, `CONVEX_DEPLOYMENT`
|
||||||
- **Google / 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`
|
- **OpenRouter:** `OPENROUTER_API_KEY`, `OPENROUTER_MODEL_CLASSIFICATION`, `OPENROUTER_MODEL_MULTIMODAL_AUDIT`, `OPENROUTER_MODEL_GERMAN_COPY`, `OPENROUTER_MODEL_QUALITY_REVIEW`, optional: `OPENROUTER_APP_NAME`, `OPENROUTER_APP_URL`
|
||||||
|
- **ScreenshotOne:** `SCREENSHOTONE_API_KEY`
|
||||||
|
- **Jina:** optional `JINA_API_KEY` for future authenticated fallback usage; not required for current readiness.
|
||||||
- **SMTP / Stalwart:** `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`
|
- **SMTP / Stalwart:** `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`
|
||||||
- **Rybbit:** `RYBBIT_API_URL`, `RYBBIT_API_KEY`, `NEXT_PUBLIC_RYBBIT_SITE_ID`
|
- **Rybbit:** `RYBBIT_API_URL`, `RYBBIT_API_KEY`, `NEXT_PUBLIC_RYBBIT_SITE_ID`
|
||||||
- **Auth:** `BETTER_AUTH_SECRET`
|
- **Auth:** `BETTER_AUTH_SECRET`
|
||||||
- **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.
|
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.
|
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
|
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.
|
||||||
variable is:
|
|
||||||
|
|
||||||
- `TASK8_BROWSER_ASSET_URL` (for example your self-hosted or CDN Chromium bundle URL if you do not rely on package defaults).
|
For Convex deployment updates, run restart/deploy after code changes:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
- Local: `pnpm exec convex dev`
|
- Local: `pnpm exec convex dev`
|
||||||
- Remote: `pnpm exec convex deploy`
|
- Remote: `pnpm exec convex deploy`
|
||||||
|
|||||||
29
app/api/internal/rybbit/audit/route.ts
Normal file
29
app/api/internal/rybbit/audit/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
18
app/api/internal/rybbit/campaign/route.ts
Normal file
18
app/api/internal/rybbit/campaign/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
|
import { AnalyticsDashboard } from "@/components/analytics/analytics-dashboard";
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
export default function AnalyticsPage() {
|
||||||
return (
|
return <AnalyticsDashboard />;
|
||||||
<DashboardPlaceholderPage
|
|
||||||
description="Kampagnenmetriken und Rybbit-Daten folgen in TASK-17 und TASK-19."
|
|
||||||
title="Analytics"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
|
import { OperationsReadiness } from "@/components/settings/operations-readiness";
|
||||||
|
import { getIntegrationReadiness } from "@/lib/operational-readiness";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
return <OperationsReadiness rows={getIntegrationReadiness(process.env)} />;
|
||||||
<DashboardPlaceholderPage
|
|
||||||
description="Provider-Status, Secrets-Hinweise und Workspace-Einstellungen folgen mit den Integrationen."
|
|
||||||
title="Einstellungen"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-15
|
id: TASK-15
|
||||||
title: Add follow-up and manual sales status tracking
|
title: Add follow-up and manual sales status tracking
|
||||||
status: To Do
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:14'
|
created_date: '2026-06-03 19:14'
|
||||||
|
updated_date: '2026-06-05 19:49'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- sales
|
- sales
|
||||||
@@ -24,11 +25,11 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 After an initial send, a single follow-up draft and suggested due date are created
|
- [x] #1 After an initial send, a single follow-up draft and suggested due date are created
|
||||||
- [ ] #2 Follow-up sending requires manual review and approval, just like the first email
|
- [x] #2 Follow-up sending requires manual review and approval, just like the first email
|
||||||
- [ ] #3 Manual statuses exist for Antwort erhalten, Kein Interesse, Später wieder melden, Gespräch vereinbart, Angebot angefragt, Angebot gesendet, Auftrag gewonnen, Auftrag verloren, Nicht weiter verfolgen, Follow-up geplant, and Follow-up gesendet
|
- [x] #3 Manual statuses exist for Antwort erhalten, Kein Interesse, Später wieder melden, Gespräch vereinbart, Angebot angefragt, Angebot gesendet, Auftrag gewonnen, Auftrag verloren, Nicht weiter verfolgen, Follow-up geplant, and Follow-up gesendet
|
||||||
- [ ] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts
|
- [x] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts
|
||||||
- [ ] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen
|
- [x] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -40,3 +41,11 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
|
|||||||
4. Add rules to stop follow-ups when manually marked answered or not interested.
|
4. Add rules to stop follow-ups when manually marked answered or not interested.
|
||||||
5. Add 12-month recheck behavior for Nicht erneut kontaktieren.
|
5. Add 12-month recheck behavior for Nicht erneut kontaktieren.
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Started implementation pass for tasks 15-19 and 27. TASK-27 note says it is superseded by TASK-13, so this pass will verify the existing PageSpeed-to-audit-generation handoff rather than implement it separately.
|
||||||
|
|
||||||
|
Implemented and verified follow-up draft creation after send, manual approval boundaries for follow-up records, manual sales status labels/mutation, reply/no-interest suppression, and 12-month do-not-contact recheck visibility. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-16
|
id: TASK-16
|
||||||
title: Orchestrate recurring Convex agent jobs and audit lifecycle
|
title: Orchestrate recurring Convex agent jobs and audit lifecycle
|
||||||
status: To Do
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:14'
|
created_date: '2026-06-03 19:14'
|
||||||
|
updated_date: '2026-06-05 19:49'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- convex
|
- convex
|
||||||
@@ -26,11 +27,11 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Convex cron or scheduled functions trigger active campaigns according to cadence
|
- [x] #1 Convex cron or scheduled functions trigger active campaigns according to cadence
|
||||||
- [ ] #2 Jetzt ausführen starts a campaign run immediately only when no other agent run is active
|
- [x] #2 Jetzt ausführen starts a campaign run immediately only when no other agent run is active
|
||||||
- [ ] #3 Cron skips or queues safely when an agent run is already active, with visible run logs
|
- [x] #3 Cron skips or queues safely when an agent run is already active, with visible run logs
|
||||||
- [ ] #4 Published audits older than 30 days create dashboard notifications asking whether to keep active
|
- [x] #4 Published audits older than 30 days create dashboard notifications asking whether to keep active
|
||||||
- [ ] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated
|
- [x] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -42,3 +43,11 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
|
|||||||
4. Add run logs and dashboard-visible status updates.
|
4. Add run logs and dashboard-visible status updates.
|
||||||
5. Add audit lifecycle checks for 30-day notification, 60-day deactivation, and reactivation.
|
5. Add audit lifecycle checks for 30-day notification, 60-day deactivation, and reactivation.
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Started implementation pass for recurring Convex agent jobs, run locking, logs, and audit lifecycle.
|
||||||
|
|
||||||
|
Implemented and verified Convex crons, due-campaign runner, single-active-run guard, visible campaign run logs, and audit lifecycle notification/deactivation controls. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-17
|
id: TASK-17
|
||||||
title: Add Rybbit audit analytics dashboard
|
title: Add Rybbit audit analytics dashboard
|
||||||
status: To Do
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:14'
|
created_date: '2026-06-03 19:14'
|
||||||
|
updated_date: '2026-06-05 19:50'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- analytics
|
- analytics
|
||||||
@@ -24,11 +25,11 @@ Display anonymous analytics for generated public audit pages inside the internal
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes
|
- [x] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes
|
||||||
- [ ] #2 Dashboard can fetch Rybbit API data for pageviews, custom events, and outbound link clicks for audit pages
|
- [x] #2 Dashboard can fetch Rybbit API data for pageviews, custom events, and outbound link clicks for audit pages
|
||||||
- [ ] #3 Per-audit analytics show opened yes/no, view count, last view, CTA clicks, website-link clicks, and device type where available
|
- [x] #3 Per-audit analytics show opened yes/no, view count, last view, CTA clicks, website-link clicks, and device type where available
|
||||||
- [ ] #4 Campaign analytics aggregate audit opens and CTA activity by campaign, niche, region, and timeframe
|
- [x] #4 Campaign analytics aggregate audit opens and CTA activity by campaign, niche, region, and timeframe
|
||||||
- [ ] #5 Rybbit API failures are shown gracefully and do not break the rest of the dashboard
|
- [x] #5 Rybbit API failures are shown gracefully and do not break the rest of the dashboard
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -40,3 +41,13 @@ Display anonymous analytics for generated public audit pages inside the internal
|
|||||||
4. Build campaign-level analytics summaries.
|
4. Build campaign-level analytics summaries.
|
||||||
5. Add graceful loading, caching if useful, and error states for API failures.
|
5. Add graceful loading, caching if useful, and error states for API failures.
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Started implementation pass for Rybbit public-audit tracking and dashboard analytics surfaces.
|
||||||
|
|
||||||
|
Implemented public-audit-only Rybbit tracking, on-demand Rybbit API routes for audit/campaign activity, per-audit summary helper, dashboard Rybbit error handling, and campaign-level overall Rybbit signals. AC4 remains open for full grouping by campaign/niche/region/timeframe because Rybbit events still need a stronger audit-to-campaign join model. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||||
|
|
||||||
|
Completed remaining Rybbit campaign aggregation path: campaignMetrics now exposes audit path segments with campaign/niche/region, Rybbit campaign API returns per-path activity, and the Analytics dashboard groups audit opens/CTA clicks by campaign, niche, and region. Verification: targeted analytics tests pass.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-18
|
id: TASK-18
|
||||||
title: Add MVP quality gates and operational polish
|
title: Add MVP quality gates and operational polish
|
||||||
status: To Do
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:15'
|
created_date: '2026-06-03 19:15'
|
||||||
updated_date: '2026-06-03 19:15'
|
updated_date: '2026-06-05 19:49'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- quality
|
- quality
|
||||||
@@ -27,11 +27,11 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Core UI text is German and organized so future i18n is feasible
|
- [x] #1 Core UI text is German and organized so future i18n is feasible
|
||||||
- [ ] #2 No secrets are stored in source code, dashboard-editable records, logs, prompts, or raw LLM history
|
- [x] #2 No secrets are stored in source code, dashboard-editable records, logs, prompts, or raw LLM history
|
||||||
- [ ] #3 Dashboard surfaces integration errors for Google, PageSpeed, OpenRouter, Playwright, SMTP, Convex jobs, and Rybbit
|
- [x] #3 Dashboard surfaces integration errors for Google, PageSpeed, OpenRouter, Playwright, SMTP, Convex jobs, and Rybbit
|
||||||
- [ ] #4 Critical user flows have basic tests or repeatable verification notes: login, campaign run, audit generation, approval, send, follow-up, analytics
|
- [x] #4 Critical user flows have basic tests or repeatable verification notes: login, campaign run, audit generation, approval, send, follow-up, analytics
|
||||||
- [ ] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions
|
- [x] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -43,3 +43,11 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
|
|||||||
4. Add smoke tests or documented verification flows for critical MVP paths.
|
4. Add smoke tests or documented verification flows for critical MVP paths.
|
||||||
5. Document Coolify deployment requirements, env vars, Playwright dependencies, and operational caveats.
|
5. Document Coolify deployment requirements, env vars, Playwright dependencies, and operational caveats.
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Started implementation pass for MVP quality gates, error observability, verification notes, and deployment readiness.
|
||||||
|
|
||||||
|
Implemented and verified German operational readiness surfaces, secret-safe integration status rows, verification notes for critical flows, and Coolify deployment notes. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-19
|
id: TASK-19
|
||||||
title: Add campaign performance metrics
|
title: Add campaign performance metrics
|
||||||
status: To Do
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:15'
|
created_date: '2026-06-03 19:15'
|
||||||
|
updated_date: '2026-06-05 19:49'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- analytics
|
- analytics
|
||||||
@@ -26,11 +27,11 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses
|
- [x] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses
|
||||||
- [ ] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe
|
- [x] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe
|
||||||
- [ ] #3 Campaign run detail shows new leads, skipped duplicates, blacklisted/skipped leads, errors, and audits generated
|
- [x] #3 Campaign run detail shows new leads, skipped duplicates, blacklisted/skipped leads, errors, and audits generated
|
||||||
- [ ] #4 Rybbit-derived audit opens and CTA clicks are shown alongside Convex sales funnel metrics
|
- [x] #4 Rybbit-derived audit opens and CTA clicks are shown alongside Convex sales funnel metrics
|
||||||
- [ ] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard
|
- [x] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -42,3 +43,11 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
|
|||||||
4. Merge Rybbit API-derived audit activity into the visible analytics where available.
|
4. Merge Rybbit API-derived audit activity into the visible analytics where available.
|
||||||
5. Add empty/error states and verify metrics update after lead, audit, send, and status changes.
|
5. Add empty/error states and verify metrics update after lead, audit, send, and status changes.
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Started implementation pass for campaign performance metrics and filters.
|
||||||
|
|
||||||
|
Implemented and verified lightweight campaign metrics query/dashboard, filter contract, run detail rows, and Rybbit-derived audit opens/CTA clicks alongside Convex metrics. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-27
|
id: TASK-27
|
||||||
title: Trigger audit generation after PageSpeed audit
|
title: Trigger audit generation after PageSpeed audit
|
||||||
status: To Do
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-05 12:10'
|
created_date: '2026-06-05 12:10'
|
||||||
updated_date: '2026-06-05 12:12'
|
updated_date: '2026-06-05 19:49'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -19,14 +19,18 @@ Wire the existing AI audit generation queue into the current automated flow so c
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Successful PageSpeed audit runs queue audit generation for the lead
|
- [x] #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
|
- [x] #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
|
- [x] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs
|
||||||
- [ ] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
|
- [x] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
Created accidentally while implementing the PageSpeed-to-audit-generation handoff. Superseded by TASK-13 because the handoff is a prerequisite for the audit/outreach review workspace. Do not implement separately.
|
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 -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
44
backlog/tasks/task-33 - Fix-v3-live-wiring-quality-issues.md
Normal file
44
backlog/tasks/task-33 - Fix-v3-live-wiring-quality-issues.md
Normal 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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
44
backlog/tasks/task-37 - Prioritize-v3-local-audit-skills.md
Normal file
44
backlog/tasks/task-37 - Prioritize-v3-local-audit-skills.md
Normal 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 -->
|
||||||
@@ -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 -->
|
||||||
34
backlog/tasks/task-39 - Secure-Convex-operator-APIs.md
Normal file
34
backlog/tasks/task-39 - Secure-Convex-operator-APIs.md
Normal 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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
43
backlog/tasks/task-49 - Improve-audit-outreach-email-tone.md
Normal file
43
backlog/tasks/task-49 - Improve-audit-outreach-email-tone.md
Normal 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 -->
|
||||||
@@ -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 -->
|
||||||
219
components/analytics/analytics-dashboard.tsx
Normal file
219
components/analytics/analytics-dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Globe } from "lucide-react";
|
import { Globe } from "lucide-react";
|
||||||
|
|
||||||
type UsedSkill = {
|
type UsedSkill = {
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
purpose?: string;
|
purpose?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
@@ -17,6 +18,12 @@ type UsedSkill = {
|
|||||||
version?: string;
|
version?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SkillSummary = {
|
||||||
|
name: string;
|
||||||
|
purpose: string;
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
type LeadContext = {
|
type LeadContext = {
|
||||||
_id: Id<"leads">;
|
_id: Id<"leads">;
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
@@ -35,12 +42,70 @@ type SkillAwareAudit = {
|
|||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
usedSkills?: UsedSkill[];
|
usedSkills?: UsedSkill[];
|
||||||
|
skillSummaries?: SkillSummary[];
|
||||||
internalSummary?: string | null;
|
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 = {
|
type AuditDetailResult = {
|
||||||
audit: SkillAwareAudit;
|
audit: SkillAwareAudit;
|
||||||
lead: LeadContext | null;
|
lead: LeadContext | null;
|
||||||
|
findings: AuditFinding[];
|
||||||
|
sourceSummaries: {
|
||||||
|
checkedPages: CheckedPageEvidence[];
|
||||||
|
};
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
const statusText: Record<string, string> = {
|
const statusText: Record<string, string> = {
|
||||||
@@ -54,6 +119,55 @@ function getStatusLabel(status: SkillAwareAudit["status"]) {
|
|||||||
return statusText[status] ?? "Unbekannt";
|
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) {
|
function leadSummary(lead: LeadContext | null | undefined) {
|
||||||
if (!lead) {
|
if (!lead) {
|
||||||
return "Kein Lead-Kontext gespeichert";
|
return "Kein Lead-Kontext gespeichert";
|
||||||
@@ -89,7 +203,24 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
|||||||
const audit = result?.audit;
|
const audit = result?.audit;
|
||||||
const lead = result?.lead;
|
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) {
|
if (result === null) {
|
||||||
return (
|
return (
|
||||||
@@ -149,6 +280,139 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Verwendete Skills</CardTitle>
|
<CardTitle>Verwendete Skills</CardTitle>
|
||||||
@@ -168,6 +432,9 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{skill.purpose ?? "Keine Zweckbeschreibung"}
|
{skill.purpose ?? "Keine Zweckbeschreibung"}
|
||||||
</p>
|
</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">
|
<p className="mt-1 inline-flex flex-wrap items-center gap-1">
|
||||||
{skill.category ? <Badge variant="outline">{skill.category}</Badge> : null}
|
{skill.category ? <Badge variant="outline">{skill.category}</Badge> : null}
|
||||||
{skill.version ? <Badge variant="outline">{skill.version}</Badge> : null}
|
{skill.version ? <Badge variant="outline">{skill.version}</Badge> : null}
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useQuery } from "convex/react";
|
import { useQuery } from "convex/react";
|
||||||
import { FunctionReturnType } from "convex/server";
|
import { FunctionReturnType } from "convex/server";
|
||||||
import { Files, SquarePen } from "lucide-react";
|
import { Activity, Files, SquarePen } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
type AuditsListResult = FunctionReturnType<typeof api.audits.list>;
|
type AuditDashboardRowsResult = FunctionReturnType<typeof api.audits.listDashboardRows>;
|
||||||
type AuditRow = NonNullable<AuditsListResult>[number];
|
type AuditRow = Extract<
|
||||||
|
NonNullable<AuditDashboardRowsResult>[number],
|
||||||
|
{ kind: "audit" }
|
||||||
|
>;
|
||||||
|
type AuditDashboardRow = NonNullable<AuditDashboardRowsResult>[number];
|
||||||
|
type AuditStatusFilter = "all" | "audit" | "generation" | "failed";
|
||||||
|
|
||||||
const statusText: Record<string, string> = {
|
const statusText: Record<string, string> = {
|
||||||
draft: "Entwurf",
|
draft: "Entwurf",
|
||||||
@@ -23,14 +35,48 @@ const statusText: Record<string, string> = {
|
|||||||
|
|
||||||
const fallbackStatus = "Unbekannt";
|
const fallbackStatus = "Unbekannt";
|
||||||
|
|
||||||
function formatPageCount(pages: AuditRow["checkedPages"]) {
|
const generationStageText: Record<string, string> = {
|
||||||
return `${pages.length} Seite${pages.length === 1 ? "" : "n"}`;
|
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"]) {
|
function getStatusLabel(status: AuditRow["status"]) {
|
||||||
return statusText[status] ?? fallbackStatus;
|
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() {
|
function AuditsBoardLoading() {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
@@ -39,28 +85,64 @@ function AuditsBoardLoading() {
|
|||||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||||
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
|
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
|
||||||
</header>
|
</header>
|
||||||
<div className="rounded-lg border">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<div className="grid gap-2 p-3">
|
{Array.from({ length: 4 }, (_, index) => (
|
||||||
{Array.from({ length: 4 }, (_, index) => (
|
<Skeleton className="h-40 rounded-lg" key={index} />
|
||||||
<Skeleton className="h-20 rounded-md" key={index} />
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuditsBoard() {
|
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(() => {
|
const rows = useMemo(() => {
|
||||||
if (!audits) {
|
if (!dashboardRows) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...audits].sort((a, b) => b.createdAt - a.createdAt);
|
return [...dashboardRows].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
}, [audits]);
|
}, [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 />;
|
return <AuditsBoardLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,13 +154,15 @@ export function AuditsBoard() {
|
|||||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<article className="rounded-lg border p-4">
|
<Card>
|
||||||
<h2 className="text-sm font-medium">Noch keine Audits</h2>
|
<CardHeader>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<h2 className="text-sm font-medium">Noch keine Audits</h2>
|
||||||
Sobald neue Audits angelegt wurden, erscheinen sie hier als kompakte
|
<CardDescription>
|
||||||
Zeilen.
|
Sobald neue Audits oder laufende Audit-Generierungen angelegt
|
||||||
</p>
|
wurden, erscheinen sie hier als kompakte Cards.
|
||||||
</article>
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -90,44 +174,118 @@ export function AuditsBoard() {
|
|||||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="space-y-2">
|
<div className="flex flex-wrap gap-2" aria-label="Audit-Filter">
|
||||||
<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">
|
{auditStatusFilters.map((filter) => (
|
||||||
<span>Slug</span>
|
<button
|
||||||
<span>Domain</span>
|
aria-pressed={activeFilter === filter.value}
|
||||||
<span>Status</span>
|
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"
|
||||||
<span>Seitenanzahl</span>
|
key={filter.value}
|
||||||
<span className="text-right">Aktion</span>
|
onClick={() => setActiveFilter(filter.value)}
|
||||||
</div>
|
type="button"
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
<Badge variant="secondary">{filter.count}</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<section
|
||||||
{rows.map((audit: AuditRow) => (
|
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||||||
<article
|
aria-label="Audit-Cards"
|
||||||
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}
|
{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}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<CardHeader className="gap-3">
|
||||||
<p className="truncate font-medium">{audit.slug}</p>
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
</div>
|
<div className="min-w-0">
|
||||||
<p className="truncate text-muted-foreground">{audit.checkedDomain}</p>
|
<CardDescription>
|
||||||
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
|
{row.kind === "audit" ? "Audit" : "Pipeline"}
|
||||||
<p className="text-muted-foreground">
|
</CardDescription>
|
||||||
<span className="inline-flex items-center gap-1">
|
<CardTitle className="mt-1 break-words text-base" id={rowTitleId}>
|
||||||
<Files className="size-3.5" />
|
{row.title}
|
||||||
{formatPageCount(audit.checkedPages)}
|
</CardTitle>
|
||||||
</span>
|
</div>
|
||||||
</p>
|
<Badge variant={row.kind === "audit" ? "secondary" : "outline"}>
|
||||||
<div className="flex justify-end">
|
{row.kind === "audit"
|
||||||
<Link
|
? getStatusLabel(row.status)
|
||||||
className="inline-flex min-h-8 items-center gap-1 text-sm text-primary"
|
: getGenerationStatusLabel(row)}
|
||||||
href={`/dashboard/audits/${audit._id}`}
|
</Badge>
|
||||||
>
|
</div>
|
||||||
<SquarePen className="size-4" />
|
</CardHeader>
|
||||||
Öffnen
|
|
||||||
</Link>
|
<CardContent className="flex flex-1 flex-col gap-4">
|
||||||
</div>
|
<div className="grid gap-3 text-sm">
|
||||||
</article>
|
<div className="min-w-0">
|
||||||
))}
|
<p className="text-xs font-medium text-muted-foreground">Domain</p>
|
||||||
</div>
|
<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>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { CampaignFormDialog } from "@/components/campaigns/campaign-form-dialog";
|
import { CampaignFormDialog } from "@/components/campaigns/campaign-form-dialog";
|
||||||
|
|
||||||
type CampaignsListResult = FunctionReturnType<typeof api.campaigns.list>;
|
type CampaignsListResult = FunctionReturnType<typeof api.campaigns.list>;
|
||||||
|
type CampaignRunsListResult = FunctionReturnType<typeof api.runs.list>;
|
||||||
type CampaignRow = NonNullable<CampaignsListResult>[number];
|
type CampaignRow = NonNullable<CampaignsListResult>[number];
|
||||||
|
type CampaignRunRow = NonNullable<CampaignRunsListResult>[number];
|
||||||
|
|
||||||
type RecurrenceLabel = Record<CampaignRow["recurrence"], string>;
|
type RecurrenceLabel = Record<CampaignRow["recurrence"], string>;
|
||||||
type CurrentRunStatusLabel = {
|
type CurrentRunStatusLabel = {
|
||||||
@@ -40,6 +42,13 @@ const statusLabel: CurrentRunStatusLabel = {
|
|||||||
paused: "Pausiert",
|
paused: "Pausiert",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stepLabel: Record<string, string> = {
|
||||||
|
campaign_cron_queued: "Cron geplant",
|
||||||
|
campaign_cron_skipped: "Cron übersprungen",
|
||||||
|
campaign_cron_stale_pending: "Timeout bereinigt",
|
||||||
|
lead_discovery: "Lead-Recherche",
|
||||||
|
};
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||||
dateStyle: "short",
|
dateStyle: "short",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
@@ -84,6 +93,10 @@ const formatNiche = (campaign: CampaignRow): string => {
|
|||||||
|
|
||||||
export function CampaignsBoard() {
|
export function CampaignsBoard() {
|
||||||
const campaigns = useQuery(api.campaigns.list, { limit: 100 });
|
const campaigns = useQuery(api.campaigns.list, { limit: 100 });
|
||||||
|
const recentCampaignRuns = useQuery(api.runs.list, {
|
||||||
|
limit: 8,
|
||||||
|
type: "campaign",
|
||||||
|
});
|
||||||
const createCampaign = useMutation(api.campaigns.create);
|
const createCampaign = useMutation(api.campaigns.create);
|
||||||
const updateCampaign = useMutation(api.campaigns.update);
|
const updateCampaign = useMutation(api.campaigns.update);
|
||||||
const setStatus = useMutation(api.campaigns.setStatus);
|
const setStatus = useMutation(api.campaigns.setStatus);
|
||||||
@@ -130,6 +143,10 @@ export function CampaignsBoard() {
|
|||||||
return [...campaigns].sort((a, b) => b.createdAt - a.createdAt);
|
return [...campaigns].sort((a, b) => b.createdAt - a.createdAt);
|
||||||
}, [campaigns]);
|
}, [campaigns]);
|
||||||
|
|
||||||
|
const visibleRuns = useMemo<CampaignRunRow[]>(() => {
|
||||||
|
return recentCampaignRuns ?? [];
|
||||||
|
}, [recentCampaignRuns]);
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setEditingCampaign(null);
|
setEditingCampaign(null);
|
||||||
setIsFormOpen(false);
|
setIsFormOpen(false);
|
||||||
@@ -267,82 +284,136 @@ export function CampaignsBoard() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{campaignsSorted.map((campaign) => (
|
{campaignsSorted.map((campaign) => {
|
||||||
<Card key={campaign._id}>
|
const campaignTitleId = `campaign-title-${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>
|
|
||||||
<CardDescription className="truncate">
|
|
||||||
{formatNiche(campaign)}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={campaign.status === "active" ? "default" : "secondary"}
|
|
||||||
>
|
|
||||||
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="grid gap-2 text-sm">
|
return (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<Card aria-labelledby={campaignTitleId} key={campaign._id}>
|
||||||
<div className="inline-flex items-center gap-1 text-muted-foreground">
|
<CardHeader>
|
||||||
<MapPin className="size-3" />
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<span>{campaign.postalCode}</span>
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="truncate" id={campaignTitleId}>
|
||||||
|
{campaign.name}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="truncate">
|
||||||
|
{formatNiche(campaign)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={campaign.status === "active" ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<span>{campaign.radiusKm} km</span>
|
</CardHeader>
|
||||||
</div>
|
|
||||||
<Separator className="bg-border" />
|
|
||||||
<div>
|
|
||||||
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p>
|
|
||||||
<p>
|
|
||||||
Limits: L {campaign.maxNewLeadsPerRun}, A{" "}
|
|
||||||
{campaign.maxAuditsPerRun}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</p>
|
|
||||||
<p className="text-muted-foreground">Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<CardContent className="grid gap-2 text-sm">
|
||||||
<Button
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
variant="outline"
|
<div className="inline-flex items-center gap-1 text-muted-foreground">
|
||||||
onClick={() => openEditDialog(campaign)}
|
<MapPin className="size-3" />
|
||||||
disabled={actionBusyId === campaign._id}
|
<span>{campaign.postalCode}</span>
|
||||||
className="w-full justify-start"
|
</div>
|
||||||
>
|
<span>{campaign.radiusKm} km</span>
|
||||||
<Pencil className="size-4" />
|
</div>
|
||||||
Bearbeiten
|
<Separator className="bg-border" />
|
||||||
</Button>
|
<div>
|
||||||
<Button
|
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p>
|
||||||
variant="outline"
|
<p>
|
||||||
onClick={() => toggleCampaign(campaign)}
|
Limits: L {campaign.maxNewLeadsPerRun}, A{" "}
|
||||||
disabled={actionBusyId === campaign._id}
|
{campaign.maxAuditsPerRun}
|
||||||
className="w-full justify-start"
|
</p>
|
||||||
>
|
</div>
|
||||||
<RefreshCcw className="size-4" />
|
<div>
|
||||||
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
|
<p className="text-muted-foreground">
|
||||||
</Button>
|
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
|
||||||
<Button
|
</p>
|
||||||
onClick={() => runCampaign(campaign)}
|
<p className="text-muted-foreground">
|
||||||
disabled={actionBusyId === campaign._id}
|
Nächster Lauf: {formatDateTime(campaign.nextRunAt)}
|
||||||
className="w-full justify-start"
|
</p>
|
||||||
>
|
<p className="text-muted-foreground">
|
||||||
<Play className="size-4" />
|
Run-Status:{" "}
|
||||||
Jetzt ausführen
|
{statusLabel[campaign.currentRunStatus] ??
|
||||||
</Button>
|
campaign.currentRunStatus}
|
||||||
</div>
|
</p>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
))}
|
<div className="grid gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openEditDialog(campaign)}
|
||||||
|
disabled={actionBusyId === campaign._id}
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => toggleCampaign(campaign)}
|
||||||
|
disabled={actionBusyId === campaign._id}
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="size-4" />
|
||||||
|
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => runCampaign(campaign)}
|
||||||
|
disabled={actionBusyId === campaign._id}
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<Play className="size-4" />
|
||||||
|
Jetzt ausführen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Aktuelle Run-Logs</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Letzte Kampagnenläufe inklusive Cron-Skips und Fehlerhinweisen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2 text-sm">
|
||||||
|
{recentCampaignRuns === undefined ? (
|
||||||
|
<Skeleton className="h-16 rounded-lg" />
|
||||||
|
) : visibleRuns.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">Noch keine Kampagnenläufe.</p>
|
||||||
|
) : (
|
||||||
|
visibleRuns.map((run) => (
|
||||||
|
<div className="rounded-md border p-3" key={run._id}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="font-medium">
|
||||||
|
{statusLabel[run.status] ?? run.status}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(run.updatedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{stepLabel[run.currentStep ?? ""] ?? run.currentStep ?? "Schritt offen"}
|
||||||
|
</p>
|
||||||
|
{run.currentStep === "campaign_cron_skipped" ? (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Cron wurde übersprungen, weil bereits ein Agentenlauf aktiv war.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{run.errorSummary ? (
|
||||||
|
<p className="mt-1 text-xs text-destructive">
|
||||||
|
{run.errorSummary}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,16 @@ import {
|
|||||||
} from "@/lib/dashboard-model";
|
} from "@/lib/dashboard-model";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardHeader } from "@/components/ui/card";
|
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 { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -63,6 +72,7 @@ type LeadReviewPayload = {
|
|||||||
reviewContactPerson?: string;
|
reviewContactPerson?: string;
|
||||||
reviewIsBusinessContactAddress?: boolean;
|
reviewIsBusinessContactAddress?: boolean;
|
||||||
};
|
};
|
||||||
|
type LeadStatusFilter = "all" | "high" | "blocked";
|
||||||
|
|
||||||
function normalizeTextInput(value: string): string | undefined {
|
function normalizeTextInput(value: string): string | undefined {
|
||||||
const next = value.trim();
|
const next = value.trim();
|
||||||
@@ -132,6 +142,7 @@ function duplicateBadgeVariant(
|
|||||||
export function LeadsReviewTable() {
|
export function LeadsReviewTable() {
|
||||||
const leads = useQuery(api.leads.list, { limit: 120 });
|
const leads = useQuery(api.leads.list, { limit: 120 });
|
||||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||||
|
const [activeFilter, setActiveFilter] = useState<LeadStatusFilter>("all");
|
||||||
|
|
||||||
const sortedLeads = useMemo(() => {
|
const sortedLeads = useMemo(() => {
|
||||||
if (!leads) {
|
if (!leads) {
|
||||||
@@ -140,6 +151,30 @@ export function LeadsReviewTable() {
|
|||||||
|
|
||||||
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
|
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
|
||||||
}, [leads]);
|
}, [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 (
|
return (
|
||||||
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
|
<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>
|
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
|
||||||
</div>
|
</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">
|
<div className="mx-auto grid w-full max-w-7xl gap-3">
|
||||||
{leads === undefined ? (
|
{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 ? (
|
) : sortedLeads.length === 0 ? (
|
||||||
<p className="rounded-md border p-4 text-sm text-muted-foreground">
|
<Card>
|
||||||
Keine Leads vorhanden. Bitte zuerst eine Kampagne starten oder
|
<CardHeader>
|
||||||
importieren.
|
<p className="text-sm font-medium">Keine Leads vorhanden</p>
|
||||||
</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
|
<LeadReviewRow
|
||||||
key={lead._id}
|
key={lead._id}
|
||||||
lead={lead}
|
lead={lead}
|
||||||
@@ -168,7 +239,7 @@ export function LeadsReviewTable() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actionMessage ? (
|
{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}
|
{actionMessage}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -183,7 +254,7 @@ function LeadReviewRow({
|
|||||||
lead: LeadRow;
|
lead: LeadRow;
|
||||||
onActionMessage: (value: string) => void;
|
onActionMessage: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
|
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
|
||||||
priority: lead.priority,
|
priority: lead.priority,
|
||||||
contactStatus: lead.contactStatus,
|
contactStatus: lead.contactStatus,
|
||||||
@@ -279,14 +350,26 @@ function LeadReviewRow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const detailsId = `lead-review-details-${lead._id}`;
|
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 (
|
return (
|
||||||
<Card>
|
<Card aria-labelledby={titleId}>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="grid min-w-0 gap-2">
|
<div className="grid min-w-0 gap-2">
|
||||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<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}
|
{lead.companyName}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
<p className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
@@ -339,24 +422,35 @@ function LeadReviewRow({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsExpanded((previous) => !previous)}
|
onClick={() => setIsDialogOpen(true)}
|
||||||
aria-expanded={isExpanded}
|
|
||||||
aria-controls={detailsId}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{isExpanded ? "Weniger anzeigen" : "Mehr anzeigen"}
|
Mehr anzeigen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<Dialog
|
||||||
id={detailsId}
|
open={isDialogOpen}
|
||||||
className="grid gap-3 border-t p-4"
|
onOpenChange={setIsDialogOpen}
|
||||||
hidden={!isExpanded}
|
|
||||||
>
|
>
|
||||||
|
<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">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
<section className="grid gap-2">
|
<section className="grid gap-2">
|
||||||
<div>
|
<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">
|
<div className="mt-2">
|
||||||
<Select
|
<Select
|
||||||
value={draft.priority}
|
value={draft.priority}
|
||||||
@@ -364,7 +458,7 @@ function LeadReviewRow({
|
|||||||
updateDraft("priority", nextPriority as LeadPriority)
|
updateDraft("priority", nextPriority as LeadPriority)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id={priorityId}>
|
||||||
<SelectValue placeholder="Priorität" />
|
<SelectValue placeholder="Priorität" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -379,7 +473,7 @@ function LeadReviewRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<div className="mt-2">
|
||||||
<Select
|
<Select
|
||||||
value={draft.contactStatus}
|
value={draft.contactStatus}
|
||||||
@@ -387,7 +481,7 @@ function LeadReviewRow({
|
|||||||
updateDraft("contactStatus", nextStatus as LeadContactStatus)
|
updateDraft("contactStatus", nextStatus as LeadContactStatus)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id={contactStatusId}>
|
||||||
<SelectValue placeholder="Kontaktstatus" />
|
<SelectValue placeholder="Kontaktstatus" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -404,8 +498,9 @@ function LeadReviewRow({
|
|||||||
|
|
||||||
<section className="grid gap-2">
|
<section className="grid gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Prioritätsgrund</p>
|
<Label className="text-xs text-muted-foreground" htmlFor={priorityReasonId}>Prioritätsgrund</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id={priorityReasonId}
|
||||||
value={draft.priorityReason}
|
value={draft.priorityReason}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
updateDraft("priorityReason", event.target.value);
|
updateDraft("priorityReason", event.target.value);
|
||||||
@@ -413,10 +508,11 @@ function LeadReviewRow({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactReasonId}>
|
||||||
Kontaktstatus-Notiz
|
Kontaktstatus-Notiz
|
||||||
</p>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id={contactReasonId}
|
||||||
value={draft.contactStatusReason}
|
value={draft.contactStatusReason}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
updateDraft("contactStatusReason", event.target.value);
|
updateDraft("contactStatusReason", event.target.value);
|
||||||
@@ -424,8 +520,9 @@ function LeadReviewRow({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<Input
|
||||||
|
id={notesId}
|
||||||
value={draft.notes}
|
value={draft.notes}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
updateDraft("notes", event.target.value);
|
updateDraft("notes", event.target.value);
|
||||||
@@ -443,8 +540,9 @@ function LeadReviewRow({
|
|||||||
|
|
||||||
<section className="grid gap-2">
|
<section className="grid gap-2">
|
||||||
<div>
|
<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
|
<Input
|
||||||
|
id={reviewEmailId}
|
||||||
value={draft.reviewEmail}
|
value={draft.reviewEmail}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
updateDraft("reviewEmail", event.target.value);
|
updateDraft("reviewEmail", event.target.value);
|
||||||
@@ -453,8 +551,9 @@ function LeadReviewRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<Input
|
||||||
|
id={reviewSourceId}
|
||||||
value={draft.reviewEmailSource}
|
value={draft.reviewEmailSource}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
updateDraft("reviewEmailSource", event.target.value);
|
updateDraft("reviewEmailSource", event.target.value);
|
||||||
@@ -462,28 +561,30 @@ function LeadReviewRow({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<Input
|
||||||
|
id={contactPersonId}
|
||||||
value={draft.reviewContactPerson}
|
value={draft.reviewContactPerson}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
updateDraft("reviewContactPerson", event.target.value);
|
updateDraft("reviewContactPerson", event.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Switch
|
||||||
|
id={businessContactId}
|
||||||
checked={draft.reviewIsBusinessContactAddress}
|
checked={draft.reviewIsBusinessContactAddress}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
updateDraft("reviewIsBusinessContactAddress", checked);
|
updateDraft("reviewIsBusinessContactAddress", checked);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
Genannte E-Mail als Business-Kontakt
|
Genannte E-Mail als Business-Kontakt
|
||||||
</label>
|
</Label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-2">
|
<section className="grid gap-2">
|
||||||
<div>
|
<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">
|
<div className="mt-2">
|
||||||
<Select
|
<Select
|
||||||
value={draft.duplicateStatus}
|
value={draft.duplicateStatus}
|
||||||
@@ -491,7 +592,7 @@ function LeadReviewRow({
|
|||||||
updateDraft("duplicateStatus", nextStatus as LeadDuplicateStatus)
|
updateDraft("duplicateStatus", nextStatus as LeadDuplicateStatus)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id={duplicateStatusId}>
|
||||||
<SelectValue placeholder="Duplikatstatus" />
|
<SelectValue placeholder="Duplikatstatus" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -506,7 +607,7 @@ function LeadReviewRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<div className="mt-2">
|
||||||
<Select
|
<Select
|
||||||
value={draft.blacklistStatus}
|
value={draft.blacklistStatus}
|
||||||
@@ -514,7 +615,7 @@ function LeadReviewRow({
|
|||||||
updateDraft("blacklistStatus", nextStatus as LeadBlacklistStatus)
|
updateDraft("blacklistStatus", nextStatus as LeadBlacklistStatus)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id={blacklistStatusId}>
|
||||||
<SelectValue placeholder="Sperrstatus" />
|
<SelectValue placeholder="Sperrstatus" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -557,11 +658,16 @@ function LeadReviewRow({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{rowMessage ? (
|
{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}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Link from "next/link";
|
|||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogCloseButton,
|
DialogCloseButton,
|
||||||
@@ -45,6 +45,7 @@ type PendingEmailConfirmation = {
|
|||||||
sender: string;
|
sender: string;
|
||||||
auditSlug: string | null;
|
auditSlug: string | null;
|
||||||
};
|
};
|
||||||
|
type ReviewStatusFilter = "all" | "ready" | "mail_open";
|
||||||
|
|
||||||
const emptyDraft: DraftState = {
|
const emptyDraft: DraftState = {
|
||||||
auditBody: "",
|
auditBody: "",
|
||||||
@@ -124,6 +125,20 @@ function skillLabel(skill: UsedSkill) {
|
|||||||
return skill.category ? `${name} · ${skill.category}` : name;
|
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({
|
function DetailToggle({
|
||||||
isOpen,
|
isOpen,
|
||||||
label,
|
label,
|
||||||
@@ -187,10 +202,40 @@ export function OutreachReviewWorkspace() {
|
|||||||
const [openRaw, setOpenRaw] = useState<Record<string, boolean>>({});
|
const [openRaw, setOpenRaw] = useState<Record<string, boolean>>({});
|
||||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = 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] =
|
const [pendingEmailConfirmation, setPendingEmailConfirmation] =
|
||||||
useState<PendingEmailConfirmation | null>(null);
|
useState<PendingEmailConfirmation | null>(null);
|
||||||
|
|
||||||
const rows = useMemo<ReviewWorkspaceItem[]>(() => records ?? [], [records]);
|
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) {
|
if (records === undefined) {
|
||||||
return <WorkspaceLoading />;
|
return <WorkspaceLoading />;
|
||||||
@@ -447,7 +492,7 @@ export function OutreachReviewWorkspace() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{notice ? (
|
{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}
|
) : null}
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -525,8 +570,107 @@ export function OutreachReviewWorkspace() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Dialog>
|
</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">
|
<div className="space-y-3">
|
||||||
{rows.map((record) => {
|
{selectedRecord ? (() => {
|
||||||
|
const record = selectedRecord;
|
||||||
const draft = drafts[record.id] ?? getDraft(record);
|
const draft = drafts[record.id] ?? getDraft(record);
|
||||||
const lead = record.lead;
|
const lead = record.lead;
|
||||||
const audit = record.audit;
|
const audit = record.audit;
|
||||||
@@ -851,7 +995,7 @@ export function OutreachReviewWorkspace() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})() : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 type { PublicAuditRenderState } from "@/lib/audits/public-audit-types";
|
||||||
|
import { RybbitTracking } from "./rybbit-tracking";
|
||||||
import { PublicAuditScreenshot } from "./public-audit-screenshot";
|
import { PublicAuditScreenshot } from "./public-audit-screenshot";
|
||||||
|
import { TrackedPublicAuditLink } from "./tracked-public-audit-link";
|
||||||
|
|
||||||
type PublicAuditPageProps = {
|
type PublicAuditPageProps = {
|
||||||
audit: Extract<PublicAuditRenderState, { kind: "published" }>["audit"];
|
audit: Extract<PublicAuditRenderState, { kind: "published" }>["audit"];
|
||||||
@@ -10,6 +12,7 @@ type PublicAuditPageProps = {
|
|||||||
export function PublicAuditPage({ audit }: PublicAuditPageProps) {
|
export function PublicAuditPage({ audit }: PublicAuditPageProps) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-dvh bg-slate-50 text-slate-950">
|
<main className="min-h-dvh bg-slate-50 text-slate-950">
|
||||||
|
<RybbitTracking domain={audit.domain} />
|
||||||
<section className="border-b border-slate-200 bg-white">
|
<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="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">
|
<div className="max-w-3xl">
|
||||||
@@ -105,17 +108,11 @@ export function PublicAuditPage({ audit }: PublicAuditPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{audit.finalOffer.ctaHref ? (
|
{audit.finalOffer.ctaHref ? (
|
||||||
<a
|
<TrackedPublicAuditLink
|
||||||
|
domain={audit.domain}
|
||||||
href={audit.finalOffer.ctaHref}
|
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"
|
label={audit.finalOffer.ctaLabel ?? "Audit besprechen"}
|
||||||
>
|
/>
|
||||||
{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>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
27
components/public-audit/rybbit-tracking.tsx
Normal file
27
components/public-audit/rybbit-tracking.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
components/public-audit/tracked-public-audit-link.tsx
Normal file
51
components/public-audit/tracked-public-audit-link.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
components/settings/operations-readiness.tsx
Normal file
62
components/settings/operations-readiness.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
convex/_generated/api.d.ts
vendored
8
convex/_generated/api.d.ts
vendored
@@ -13,7 +13,9 @@ import type * as auditGenerationAction from "../auditGenerationAction.js";
|
|||||||
import type * as auditInputs from "../auditInputs.js";
|
import type * as auditInputs from "../auditInputs.js";
|
||||||
import type * as audits from "../audits.js";
|
import type * as audits from "../audits.js";
|
||||||
import type * as blacklist from "../blacklist.js";
|
import type * as blacklist from "../blacklist.js";
|
||||||
|
import type * as campaignMetrics from "../campaignMetrics.js";
|
||||||
import type * as campaigns from "../campaigns.js";
|
import type * as campaigns from "../campaigns.js";
|
||||||
|
import type * as crons from "../crons.js";
|
||||||
import type * as domain from "../domain.js";
|
import type * as domain from "../domain.js";
|
||||||
import type * as http from "../http.js";
|
import type * as http from "../http.js";
|
||||||
import type * as leadDiscovery from "../leadDiscovery.js";
|
import type * as 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 pageSpeed from "../pageSpeed.js";
|
||||||
import type * as pageSpeedAction from "../pageSpeedAction.js";
|
import type * as pageSpeedAction from "../pageSpeedAction.js";
|
||||||
import type * as runs from "../runs.js";
|
import type * as runs from "../runs.js";
|
||||||
|
import type * as scheduledJobs from "../scheduledJobs.js";
|
||||||
import type * as settings from "../settings.js";
|
import type * as settings from "../settings.js";
|
||||||
import type * as storage from "../storage.js";
|
import type * as storage from "../storage.js";
|
||||||
|
import type * as usageEvents from "../usageEvents.js";
|
||||||
import type * as websiteEnrichment from "../websiteEnrichment.js";
|
import type * as websiteEnrichment from "../websiteEnrichment.js";
|
||||||
import type * as websiteEnrichmentAction from "../websiteEnrichmentAction.js";
|
import type * as websiteEnrichmentAction from "../websiteEnrichmentAction.js";
|
||||||
|
|
||||||
@@ -40,7 +44,9 @@ declare const fullApi: ApiFromModules<{
|
|||||||
auditInputs: typeof auditInputs;
|
auditInputs: typeof auditInputs;
|
||||||
audits: typeof audits;
|
audits: typeof audits;
|
||||||
blacklist: typeof blacklist;
|
blacklist: typeof blacklist;
|
||||||
|
campaignMetrics: typeof campaignMetrics;
|
||||||
campaigns: typeof campaigns;
|
campaigns: typeof campaigns;
|
||||||
|
crons: typeof crons;
|
||||||
domain: typeof domain;
|
domain: typeof domain;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
leadDiscovery: typeof leadDiscovery;
|
leadDiscovery: typeof leadDiscovery;
|
||||||
@@ -50,8 +56,10 @@ declare const fullApi: ApiFromModules<{
|
|||||||
pageSpeed: typeof pageSpeed;
|
pageSpeed: typeof pageSpeed;
|
||||||
pageSpeedAction: typeof pageSpeedAction;
|
pageSpeedAction: typeof pageSpeedAction;
|
||||||
runs: typeof runs;
|
runs: typeof runs;
|
||||||
|
scheduledJobs: typeof scheduledJobs;
|
||||||
settings: typeof settings;
|
settings: typeof settings;
|
||||||
storage: typeof storage;
|
storage: typeof storage;
|
||||||
|
usageEvents: typeof usageEvents;
|
||||||
websiteEnrichment: typeof websiteEnrichment;
|
websiteEnrichment: typeof websiteEnrichment;
|
||||||
websiteEnrichmentAction: typeof websiteEnrichmentAction;
|
websiteEnrichmentAction: typeof websiteEnrichmentAction;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ const auditGenerationParsedJson = v.union(
|
|||||||
v.string(),
|
v.string(),
|
||||||
v.record(v.string(), auditGenerationParsedValue),
|
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<
|
type AuditGenerationLead = Pick<
|
||||||
Doc<"leads">,
|
Doc<"leads">,
|
||||||
@@ -89,6 +102,7 @@ type AuditGenerationEvidence = {
|
|||||||
technicalChecks: AuditGenerationEvidenceTechnicalCheck[];
|
technicalChecks: AuditGenerationEvidenceTechnicalCheck[];
|
||||||
screenshots: AuditGenerationEvidenceScreenshot[];
|
screenshots: AuditGenerationEvidenceScreenshot[];
|
||||||
pageSpeedInputs: PageSpeedMinimalAuditResult[];
|
pageSpeedInputs: PageSpeedMinimalAuditResult[];
|
||||||
|
externalMarkdown?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function byteLength(value: string) {
|
function byteLength(value: string) {
|
||||||
@@ -199,6 +213,8 @@ const secretHints = [
|
|||||||
"SMTP_USER",
|
"SMTP_USER",
|
||||||
"BETTER_AUTH_SECRET",
|
"BETTER_AUTH_SECRET",
|
||||||
"RYBBIT_API_KEY",
|
"RYBBIT_API_KEY",
|
||||||
|
"SCREENSHOTONE_API_KEY",
|
||||||
|
"JINA_API_KEY",
|
||||||
];
|
];
|
||||||
|
|
||||||
function sanitizeSecretCandidates(value: string | undefined): string | undefined {
|
function sanitizeSecretCandidates(value: string | undefined): string | undefined {
|
||||||
@@ -226,7 +242,7 @@ function sanitizeSecretCandidates(value: string | undefined): string | undefined
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegExp(value: string) {
|
function escapeRegExp(value: string) {
|
||||||
return value.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&");
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
type StartLeadSnapshot = Pick<
|
type StartLeadSnapshot = Pick<
|
||||||
@@ -249,32 +265,49 @@ export const getAuditGenerationEvidence = internalQuery({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runIdFilter = {
|
|
||||||
table: "by_runId" as const,
|
|
||||||
value: args.runId,
|
|
||||||
};
|
|
||||||
const leadIdFilter = {
|
const leadIdFilter = {
|
||||||
table: "by_leadId" as const,
|
table: "by_leadId" as const,
|
||||||
value: lead._id,
|
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
|
const crawlPagesByRun = await ctx.db
|
||||||
.query("websiteCrawlPages")
|
.query("websiteCrawlPages")
|
||||||
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
|
.withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId))
|
||||||
.order("desc")
|
.order("desc")
|
||||||
.take(40);
|
.take(40);
|
||||||
|
|
||||||
const technicalChecksByRun = await ctx.db
|
const technicalChecksByRun = await ctx.db
|
||||||
.query("websiteTechnicalChecks")
|
.query("websiteTechnicalChecks")
|
||||||
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
|
.withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId))
|
||||||
.order("desc")
|
.order("desc")
|
||||||
.take(80);
|
.take(80);
|
||||||
|
|
||||||
const screenshotsByRun = await ctx.db
|
const auditCaptureScreenshotsByRun = await ctx.db
|
||||||
.query("websiteCrawlScreenshots")
|
.query("websiteCrawlScreenshots")
|
||||||
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
|
.withIndex("by_runId", (q) => q.eq("runId", args.runId))
|
||||||
.order("desc")
|
.order("desc")
|
||||||
.take(20);
|
.take(20);
|
||||||
|
const enrichmentScreenshotsByRun =
|
||||||
|
enrichmentEvidenceRunId === args.runId
|
||||||
|
? []
|
||||||
|
: await ctx.db
|
||||||
|
.query("websiteCrawlScreenshots")
|
||||||
|
.withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId))
|
||||||
|
.order("desc")
|
||||||
|
.take(20);
|
||||||
|
|
||||||
const pageSpeedByRun = run.auditId
|
const pageSpeedByRun = run.auditId
|
||||||
? await ctx.db
|
? await ctx.db
|
||||||
@@ -290,7 +323,7 @@ export const getAuditGenerationEvidence = internalQuery({
|
|||||||
|
|
||||||
const crawlPages = crawlPagesByRun;
|
const crawlPages = crawlPagesByRun;
|
||||||
const technicalChecks = technicalChecksByRun;
|
const technicalChecks = technicalChecksByRun;
|
||||||
const screenshots = screenshotsByRun;
|
const screenshots = [...auditCaptureScreenshotsByRun, ...enrichmentScreenshotsByRun];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lead: {
|
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({
|
export const finishAuditGenerationRun = internalMutation({
|
||||||
args: {
|
args: {
|
||||||
runId: v.id("agentRuns"),
|
runId: v.id("agentRuns"),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
401
convex/audits.ts
401
convex/audits.ts
@@ -2,8 +2,12 @@ import { v } from "convex/values";
|
|||||||
|
|
||||||
import { normalizeListLimit } from "./domain";
|
import { normalizeListLimit } from "./domain";
|
||||||
import { internalMutation, mutation, query } from "./_generated/server";
|
import { internalMutation, mutation, query } from "./_generated/server";
|
||||||
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||||
|
|
||||||
|
export const AUDIT_REVIEW_NOTICE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const DETAIL_EVIDENCE_LIMIT = 50;
|
||||||
|
|
||||||
const auditStatus = v.union(
|
const auditStatus = v.union(
|
||||||
v.literal("draft"),
|
v.literal("draft"),
|
||||||
v.literal("approved"),
|
v.literal("approved"),
|
||||||
@@ -12,8 +16,9 @@ const auditStatus = v.union(
|
|||||||
);
|
);
|
||||||
const usedSkillsValidator = v.array(
|
const usedSkillsValidator = v.array(
|
||||||
v.object({
|
v.object({
|
||||||
|
id: v.optional(v.string()),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
category: v.string(),
|
category: v.optional(v.string()),
|
||||||
version: v.optional(v.string()),
|
version: v.optional(v.string()),
|
||||||
source: v.optional(v.string()),
|
source: v.optional(v.string()),
|
||||||
}),
|
}),
|
||||||
@@ -38,13 +43,134 @@ const publicOfferValidator = v.object({
|
|||||||
ctaHref: v.optional(v.string()),
|
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();
|
const identity = await ctx.auth.getUserIdentity();
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
throw new Error("Nicht autorisiert.");
|
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) => {
|
const toIsoDate = (timestamp: number | undefined, fallback: number) => {
|
||||||
return new Date(timestamp ?? fallback).toISOString();
|
return new Date(timestamp ?? fallback).toISOString();
|
||||||
};
|
};
|
||||||
@@ -122,6 +248,8 @@ export const create = mutation({
|
|||||||
ctaType: v.optional(v.string()),
|
ctaType: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query("audits")
|
.query("audits")
|
||||||
@@ -144,19 +272,149 @@ export const create = mutation({
|
|||||||
export const getDetail = query({
|
export const getDetail = query({
|
||||||
args: { id: v.id("audits") },
|
args: { id: v.id("audits") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
|
|
||||||
const audit = await ctx.db.get(args.id);
|
const audit = await ctx.db.get(args.id);
|
||||||
if (!audit) {
|
if (!audit) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lead = await ctx.db.get(audit.leadId);
|
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({
|
export const get = query({
|
||||||
args: { id: v.id("audits") },
|
args: { id: v.id("audits") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
|
|
||||||
return await ctx.db.get(args.id);
|
return await ctx.db.get(args.id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -245,6 +503,8 @@ export const upsertFromAuditGeneration = internalMutation({
|
|||||||
export const getBySlug = query({
|
export const getBySlug = query({
|
||||||
args: { slug: v.string() },
|
args: { slug: v.string() },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
|
|
||||||
const audits = await ctx.db
|
const audits = await ctx.db
|
||||||
.query("audits")
|
.query("audits")
|
||||||
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
||||||
@@ -352,6 +612,9 @@ export const publishPublicAudit = mutation({
|
|||||||
await ctx.db.patch(args.id, {
|
await ctx.db.patch(args.id, {
|
||||||
status: "published",
|
status: "published",
|
||||||
publishedAt: now,
|
publishedAt: now,
|
||||||
|
reviewDueAt: now + AUDIT_REVIEW_NOTICE_AFTER_MS,
|
||||||
|
lifecycleNotificationAt: undefined,
|
||||||
|
lifecycleExtendedUntil: undefined,
|
||||||
deactivatedAt: undefined,
|
deactivatedAt: undefined,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
@@ -373,6 +636,34 @@ export const reapprovePublicAudit = mutation({
|
|||||||
await ctx.db.patch(args.id, {
|
await ctx.db.patch(args.id, {
|
||||||
status: "published",
|
status: "published",
|
||||||
publishedAt: now,
|
publishedAt: now,
|
||||||
|
reviewDueAt: now + AUDIT_REVIEW_NOTICE_AFTER_MS,
|
||||||
|
lifecycleNotificationAt: undefined,
|
||||||
|
lifecycleExtendedUntil: undefined,
|
||||||
|
deactivatedAt: undefined,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { slug: audit.slug };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const extendPublicAuditLifecycle = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("audits"),
|
||||||
|
days: v.number(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
|
const audit = await ctx.db.get(args.id);
|
||||||
|
if (!audit) {
|
||||||
|
throw new Error("Audit wurde nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await ctx.db.patch(args.id, {
|
||||||
|
status: "published",
|
||||||
|
lifecycleExtendedUntil: now + args.days * 24 * 60 * 60 * 1000,
|
||||||
|
reviewDueAt: now + args.days * 24 * 60 * 60 * 1000,
|
||||||
deactivatedAt: undefined,
|
deactivatedAt: undefined,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
@@ -408,6 +699,8 @@ export const list = query({
|
|||||||
limit: v.optional(v.number()),
|
limit: v.optional(v.number()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
|
|
||||||
const limit = normalizeListLimit(args.limit);
|
const limit = normalizeListLimit(args.limit);
|
||||||
|
|
||||||
if (args.leadId) {
|
if (args.leadId) {
|
||||||
@@ -433,3 +726,105 @@ export const list = query({
|
|||||||
return await ctx.db.query("audits").order("desc").take(limit);
|
return await ctx.db.query("audits").order("desc").take(limit);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const listDashboardRows = query({
|
||||||
|
args: {
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx: QueryCtx, args): Promise<AuditDashboardRow[]> => {
|
||||||
|
await requireOperator(ctx);
|
||||||
|
|
||||||
|
const limit = normalizeListLimit(args.limit);
|
||||||
|
const audits = await ctx.db.query("audits").order("desc").take(limit);
|
||||||
|
|
||||||
|
const finalAuditLeadIds = new Set<string>();
|
||||||
|
const finalAuditRunIds = new Set<string>();
|
||||||
|
const finalAuditIds = new Set<string>();
|
||||||
|
const rows: AuditDashboardRow[] = audits.map((audit) => {
|
||||||
|
finalAuditLeadIds.add(audit.leadId);
|
||||||
|
finalAuditIds.add(audit._id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "audit",
|
||||||
|
id: audit._id,
|
||||||
|
auditId: audit._id,
|
||||||
|
slug: audit.slug,
|
||||||
|
title: audit.slug,
|
||||||
|
checkedDomain: audit.checkedDomain,
|
||||||
|
status: audit.status,
|
||||||
|
pageCount: audit.checkedPages.length,
|
||||||
|
checkedPages: audit.checkedPages,
|
||||||
|
detailHref: `/dashboard/audits/${audit._id}`,
|
||||||
|
createdAt: audit.createdAt,
|
||||||
|
updatedAt: audit.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const audit of audits) {
|
||||||
|
const linkedGenerations = await ctx.db
|
||||||
|
.query("auditGenerations")
|
||||||
|
.withIndex("by_auditId", (q) => q.eq("auditId", audit._id))
|
||||||
|
.take(20);
|
||||||
|
|
||||||
|
for (const generation of linkedGenerations) {
|
||||||
|
finalAuditRunIds.add(generation.runId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generationRuns = await ctx.db
|
||||||
|
.query("agentRuns")
|
||||||
|
.withIndex("by_type", (q) => q.eq("type", "audit_generation"))
|
||||||
|
.order("desc")
|
||||||
|
.take(limit);
|
||||||
|
|
||||||
|
for (const run of generationRuns) {
|
||||||
|
if (!run.leadId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directFinalAudit = run.auditId ? await ctx.db.get(run.auditId) : null;
|
||||||
|
const leadFinalAudits = await ctx.db
|
||||||
|
.query("audits")
|
||||||
|
.withIndex("by_leadId", (q) => q.eq("leadId", run.leadId as Id<"leads">))
|
||||||
|
.take(1);
|
||||||
|
|
||||||
|
if (
|
||||||
|
finalAuditRunIds.has(run._id) ||
|
||||||
|
(run.auditId && finalAuditIds.has(run.auditId)) ||
|
||||||
|
directFinalAudit ||
|
||||||
|
finalAuditLeadIds.has(run.leadId) ||
|
||||||
|
leadFinalAudits.length > 0
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stages = await ctx.db
|
||||||
|
.query("auditGenerations")
|
||||||
|
.withIndex("by_runId", (q) => q.eq("runId", run._id))
|
||||||
|
.order("desc")
|
||||||
|
.take(20);
|
||||||
|
const latestStage = latestGenerationStage(stages);
|
||||||
|
const lead = await ctx.db.get(run.leadId);
|
||||||
|
const checkedDomain = domainFromLead(lead);
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
kind: "generation",
|
||||||
|
id: run._id,
|
||||||
|
runId: run._id,
|
||||||
|
leadId: run.leadId,
|
||||||
|
title: lead?.companyName ?? checkedDomain,
|
||||||
|
checkedDomain,
|
||||||
|
status: run.status,
|
||||||
|
latestStage: latestStage?.stage ?? run.currentStep ?? "audit_generation",
|
||||||
|
stageStatus: latestStage?.status ?? run.status,
|
||||||
|
errorSummary: run.errorSummary ?? latestStage?.errorSummary ?? null,
|
||||||
|
pageCount: 0,
|
||||||
|
checkedPages: [],
|
||||||
|
createdAt: run.createdAt,
|
||||||
|
updatedAt: Math.max(run.updatedAt, latestStage?.updatedAt ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
148
convex/campaignMetrics.ts
Normal file
148
convex/campaignMetrics.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
19
convex/crons.ts
Normal file
19
convex/crons.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { cronJobs } from "convex/server";
|
||||||
|
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
|
|
||||||
|
const crons = cronJobs();
|
||||||
|
|
||||||
|
crons.interval(
|
||||||
|
"campaign cadence runner",
|
||||||
|
{ hours: 1 },
|
||||||
|
internal.scheduledJobs.runDueCampaigns,
|
||||||
|
);
|
||||||
|
|
||||||
|
crons.interval(
|
||||||
|
"audit lifecycle runner",
|
||||||
|
{ hours: 24 },
|
||||||
|
internal.scheduledJobs.runAuditLifecycle,
|
||||||
|
);
|
||||||
|
|
||||||
|
export default crons;
|
||||||
@@ -96,6 +96,12 @@ export const RUN_STATUSES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export const AUDIT_GENERATION_STAGES = [
|
export const AUDIT_GENERATION_STAGES = [
|
||||||
"classification",
|
"classification",
|
||||||
|
"localSeoSpecialist",
|
||||||
|
"conversionUxSpecialist",
|
||||||
|
"visualTrustSpecialist",
|
||||||
|
"critiqueSpecialist",
|
||||||
|
"performanceAccessibilitySpecialist",
|
||||||
|
"evidenceVerifier",
|
||||||
"multimodalAudit",
|
"multimodalAudit",
|
||||||
"germanCopy",
|
"germanCopy",
|
||||||
"qualityReview",
|
"qualityReview",
|
||||||
@@ -119,6 +125,18 @@ export const PAGE_SPEED_ERROR_TYPES = [
|
|||||||
"api_error",
|
"api_error",
|
||||||
"unknown",
|
"unknown",
|
||||||
] as const;
|
] 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 CampaignStatus = (typeof CAMPAIGN_STATUSES)[number];
|
||||||
export type LeadPriority = (typeof LEAD_PRIORITIES)[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 PageSpeedStrategy = (typeof PAGE_SPEED_STRATEGIES)[number];
|
||||||
export type PageSpeedResultStatus = (typeof PAGE_SPEED_RESULT_STATUSES)[number];
|
export type PageSpeedResultStatus = (typeof PAGE_SPEED_RESULT_STATUSES)[number];
|
||||||
export type PageSpeedErrorType = (typeof PAGE_SPEED_ERROR_TYPES)[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 = {
|
export type SettingsRow = {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
351
convex/leads.ts
351
convex/leads.ts
@@ -3,7 +3,13 @@ import { v } from "convex/values";
|
|||||||
import { getUsableContactEmailFromEntries } from "../lib/lead-discovery-google";
|
import { getUsableContactEmailFromEntries } from "../lib/lead-discovery-google";
|
||||||
import { normalizeListLimit } from "./domain";
|
import { normalizeListLimit } from "./domain";
|
||||||
import type { Doc, Id } from "./_generated/dataModel";
|
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">;
|
type LeadDoc = Doc<"leads">;
|
||||||
|
|
||||||
@@ -37,6 +43,74 @@ type LeadReviewPatch = {
|
|||||||
contactPerson?: string;
|
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: {
|
function buildReviewContactPatch(args: {
|
||||||
email?: string;
|
email?: string;
|
||||||
emailSource?: string;
|
emailSource?: string;
|
||||||
@@ -88,6 +162,91 @@ function buildReviewContactPatch(args: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reviewUpdateLead(ctx: MutationCtx, args: LeadReviewUpdateArgs) {
|
||||||
|
const lead = await ctx.db.get(args.id);
|
||||||
|
|
||||||
|
if (!lead) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const patch: LeadReviewPatch = {
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (args.priority !== undefined) {
|
||||||
|
patch.priority = args.priority;
|
||||||
|
}
|
||||||
|
if (args.priorityReason !== undefined) {
|
||||||
|
patch.priorityReason = args.priorityReason;
|
||||||
|
}
|
||||||
|
if (args.contactStatus !== undefined) {
|
||||||
|
patch.contactStatus = args.contactStatus;
|
||||||
|
}
|
||||||
|
if (args.contactStatusReason !== undefined) {
|
||||||
|
patch.contactStatusReason = args.contactStatusReason;
|
||||||
|
}
|
||||||
|
if (args.notes !== undefined) {
|
||||||
|
patch.notes = args.notes;
|
||||||
|
}
|
||||||
|
if (args.duplicateStatus !== undefined) {
|
||||||
|
patch.duplicateStatus = args.duplicateStatus;
|
||||||
|
}
|
||||||
|
if (args.duplicateReason !== undefined) {
|
||||||
|
patch.duplicateReason = args.duplicateReason;
|
||||||
|
}
|
||||||
|
if (args.duplicateOfLeadId !== undefined) {
|
||||||
|
patch.duplicateOfLeadId = args.duplicateOfLeadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.applyBlacklist) {
|
||||||
|
patch.blacklistStatus = "blocked";
|
||||||
|
if (args.blacklistReason !== undefined) {
|
||||||
|
patch.blacklistReason = args.blacklistReason;
|
||||||
|
} else if (lead.blacklistReason === undefined) {
|
||||||
|
patch.blacklistReason = "Manuell in der Review als Sperrgrund gesetzt.";
|
||||||
|
}
|
||||||
|
if (args.priority === undefined || args.priority !== "blocked") {
|
||||||
|
patch.priority = "blocked";
|
||||||
|
}
|
||||||
|
} else if (args.applyBlacklist === false && args.blacklistStatus !== undefined) {
|
||||||
|
patch.blacklistStatus = args.blacklistStatus;
|
||||||
|
patch.blacklistReason = args.blacklistReason;
|
||||||
|
} else if (args.blacklistStatus !== undefined) {
|
||||||
|
patch.blacklistStatus = args.blacklistStatus;
|
||||||
|
patch.blacklistReason = args.blacklistReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewContactPatch = buildReviewContactPatch({
|
||||||
|
email: args.reviewEmail,
|
||||||
|
emailSource: args.reviewEmailSource,
|
||||||
|
contactPerson: args.reviewContactPerson,
|
||||||
|
isBusinessContactAddress: args.reviewIsBusinessContactAddress,
|
||||||
|
explicitContactStatus: args.contactStatus !== undefined,
|
||||||
|
currentContactStatus: lead.contactStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reviewContactPatch?.patch) {
|
||||||
|
Object.assign(patch, reviewContactPatch.patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
reviewContactPatch !== null &&
|
||||||
|
reviewContactPatch.setContactStatus !== undefined &&
|
||||||
|
args.contactStatus === undefined
|
||||||
|
) {
|
||||||
|
patch.contactStatus = reviewContactPatch.setContactStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.blacklistReason !== undefined && patch.blacklistStatus === undefined) {
|
||||||
|
patch.blacklistStatus = "blocked";
|
||||||
|
patch.blacklistReason = args.blacklistReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.id, patch);
|
||||||
|
return args.id;
|
||||||
|
}
|
||||||
|
|
||||||
export const create = mutation({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
campaignId: v.optional(v.id("campaigns")),
|
campaignId: v.optional(v.id("campaigns")),
|
||||||
@@ -116,44 +275,20 @@ export const create = mutation({
|
|||||||
email: v.optional(v.string()),
|
email: v.optional(v.string()),
|
||||||
emailSource: v.optional(v.string()),
|
emailSource: v.optional(v.string()),
|
||||||
contactPerson: v.optional(v.string()),
|
contactPerson: v.optional(v.string()),
|
||||||
priority: v.optional(
|
priority: v.optional(leadPriority),
|
||||||
v.union(
|
|
||||||
v.literal("high"),
|
|
||||||
v.literal("medium"),
|
|
||||||
v.literal("low"),
|
|
||||||
v.literal("defer"),
|
|
||||||
v.literal("blocked"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
priorityReason: v.optional(v.string()),
|
priorityReason: v.optional(v.string()),
|
||||||
contactStatus: v.optional(
|
contactStatus: v.optional(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"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
contactStatusReason: v.optional(v.string()),
|
contactStatusReason: v.optional(v.string()),
|
||||||
duplicateStatus: v.optional(
|
duplicateStatus: v.optional(leadDuplicateStatus),
|
||||||
v.union(
|
|
||||||
v.literal("unchecked"),
|
|
||||||
v.literal("unique"),
|
|
||||||
v.literal("possible_duplicate"),
|
|
||||||
v.literal("duplicate"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
duplicateReason: v.optional(v.string()),
|
duplicateReason: v.optional(v.string()),
|
||||||
blacklistReason: v.optional(v.string()),
|
blacklistReason: v.optional(v.string()),
|
||||||
duplicateOfLeadId: v.optional(v.id("leads")),
|
duplicateOfLeadId: v.optional(v.id("leads")),
|
||||||
blacklistStatus: v.optional(v.union(v.literal("clear"), v.literal("blocked"))),
|
blacklistStatus: v.optional(leadBlacklistStatus),
|
||||||
normalizedGooglePlaceId: v.optional(v.string()),
|
normalizedGooglePlaceId: v.optional(v.string()),
|
||||||
notes: v.optional(v.string()),
|
notes: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
return await ctx.db.insert("leads", {
|
return await ctx.db.insert("leads", {
|
||||||
@@ -174,136 +309,29 @@ export const create = mutation({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const reviewUpdate = mutation({
|
export const reviewUpdate = mutation({
|
||||||
args: {
|
args: reviewUpdateArgs,
|
||||||
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) => {
|
handler: async (ctx, args) => {
|
||||||
const lead = await ctx.db.get(args.id);
|
await requireOperator(ctx);
|
||||||
|
return await reviewUpdateLead(ctx, args);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!lead) {
|
export const reviewUpdateInternal = internalMutation({
|
||||||
return null;
|
args: reviewUpdateArgs,
|
||||||
}
|
handler: async (ctx, args) => {
|
||||||
|
return await reviewUpdateLead(ctx, args);
|
||||||
const now = Date.now();
|
|
||||||
const patch: LeadReviewPatch = {
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (args.priority !== undefined) {
|
|
||||||
patch.priority = args.priority;
|
|
||||||
}
|
|
||||||
if (args.priorityReason !== undefined) {
|
|
||||||
patch.priorityReason = args.priorityReason;
|
|
||||||
}
|
|
||||||
if (args.contactStatus !== undefined) {
|
|
||||||
patch.contactStatus = args.contactStatus;
|
|
||||||
}
|
|
||||||
if (args.contactStatusReason !== undefined) {
|
|
||||||
patch.contactStatusReason = args.contactStatusReason;
|
|
||||||
}
|
|
||||||
if (args.notes !== undefined) {
|
|
||||||
patch.notes = args.notes;
|
|
||||||
}
|
|
||||||
if (args.duplicateStatus !== undefined) {
|
|
||||||
patch.duplicateStatus = args.duplicateStatus;
|
|
||||||
}
|
|
||||||
if (args.duplicateReason !== undefined) {
|
|
||||||
patch.duplicateReason = args.duplicateReason;
|
|
||||||
}
|
|
||||||
if (args.duplicateOfLeadId !== undefined) {
|
|
||||||
patch.duplicateOfLeadId = args.duplicateOfLeadId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.applyBlacklist) {
|
|
||||||
patch.blacklistStatus = "blocked";
|
|
||||||
if (args.blacklistReason !== undefined) {
|
|
||||||
patch.blacklistReason = args.blacklistReason;
|
|
||||||
} else if (lead.blacklistReason === undefined) {
|
|
||||||
patch.blacklistReason = "Manuell in der Review als Sperrgrund gesetzt.";
|
|
||||||
}
|
|
||||||
if (args.priority === undefined || args.priority !== "blocked") {
|
|
||||||
patch.priority = "blocked";
|
|
||||||
}
|
|
||||||
} else if (args.applyBlacklist === false && args.blacklistStatus !== undefined) {
|
|
||||||
patch.blacklistStatus = args.blacklistStatus;
|
|
||||||
patch.blacklistReason = args.blacklistReason;
|
|
||||||
} else if (args.blacklistStatus !== undefined) {
|
|
||||||
patch.blacklistStatus = args.blacklistStatus;
|
|
||||||
patch.blacklistReason = args.blacklistReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reviewContactPatch = buildReviewContactPatch({
|
|
||||||
email: args.reviewEmail,
|
|
||||||
emailSource: args.reviewEmailSource,
|
|
||||||
contactPerson: args.reviewContactPerson,
|
|
||||||
isBusinessContactAddress: args.reviewIsBusinessContactAddress,
|
|
||||||
explicitContactStatus: args.contactStatus !== undefined,
|
|
||||||
currentContactStatus: lead.contactStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (reviewContactPatch?.patch) {
|
|
||||||
Object.assign(patch, reviewContactPatch.patch);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
reviewContactPatch !== null &&
|
|
||||||
reviewContactPatch.setContactStatus !== undefined &&
|
|
||||||
args.contactStatus === undefined
|
|
||||||
) {
|
|
||||||
patch.contactStatus = reviewContactPatch.setContactStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.blacklistReason !== undefined && patch.blacklistStatus === undefined) {
|
|
||||||
patch.blacklistStatus = "blocked";
|
|
||||||
patch.blacklistReason = args.blacklistReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.db.patch(args.id, patch);
|
|
||||||
return args.id;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const get = query({
|
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") },
|
args: { id: v.id("leads") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
return await ctx.db.get(args.id);
|
return await ctx.db.get(args.id);
|
||||||
@@ -313,20 +341,11 @@ export const get = query({
|
|||||||
export const list = query({
|
export const list = query({
|
||||||
args: {
|
args: {
|
||||||
campaignId: v.optional(v.id("campaigns")),
|
campaignId: v.optional(v.id("campaigns")),
|
||||||
contactStatus: v.optional(
|
contactStatus: v.optional(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"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
limit: v.optional(v.number()),
|
limit: v.optional(v.number()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
const limit = normalizeListLimit(args.limit);
|
const limit = normalizeListLimit(args.limit);
|
||||||
|
|
||||||
if (args.campaignId) {
|
if (args.campaignId) {
|
||||||
@@ -360,6 +379,7 @@ export const listFunnel = query({
|
|||||||
limit: v.optional(v.number()),
|
limit: v.optional(v.number()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
const limit = normalizeListLimit(args.limit);
|
const limit = normalizeListLimit(args.limit);
|
||||||
const leads = await ctx.db.query("leads").order("desc").take(limit);
|
const leads = await ctx.db.query("leads").order("desc").take(limit);
|
||||||
|
|
||||||
@@ -392,6 +412,7 @@ export const listFunnel = query({
|
|||||||
sendStatus: latestOutreach.sendStatus,
|
sendStatus: latestOutreach.sendStatus,
|
||||||
responseStatus: latestOutreach.responseStatus,
|
responseStatus: latestOutreach.responseStatus,
|
||||||
salesStatus: latestOutreach.salesStatus,
|
salesStatus: latestOutreach.salesStatus,
|
||||||
|
doNotContactUntil: latestOutreach.doNotContactUntil ?? null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DO_NOT_CONTACT_RECHECK_MS,
|
||||||
|
FOLLOW_UP_DUE_DELAY_MS,
|
||||||
|
shouldCreateFollowUpDraftAfterSend,
|
||||||
|
} from "../lib/outreach-follow-up";
|
||||||
import { normalizeListLimit } from "./domain";
|
import { normalizeListLimit } from "./domain";
|
||||||
import { internalMutation, mutation, query } from "./_generated/server";
|
import { internalMutation, mutation, query } from "./_generated/server";
|
||||||
import type { Doc, Id } from "./_generated/dataModel";
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
@@ -11,6 +16,19 @@ const strategy = v.union(
|
|||||||
v.literal("defer"),
|
v.literal("defer"),
|
||||||
v.literal("do_not_contact"),
|
v.literal("do_not_contact"),
|
||||||
);
|
);
|
||||||
|
const manualSalesStatus = v.union(
|
||||||
|
v.literal("follow_up_planned"),
|
||||||
|
v.literal("follow_up_sent"),
|
||||||
|
v.literal("reply_received"),
|
||||||
|
v.literal("not_interested"),
|
||||||
|
v.literal("later"),
|
||||||
|
v.literal("meeting_scheduled"),
|
||||||
|
v.literal("proposal_requested"),
|
||||||
|
v.literal("proposal_sent"),
|
||||||
|
v.literal("won"),
|
||||||
|
v.literal("lost"),
|
||||||
|
v.literal("do_not_pursue"),
|
||||||
|
);
|
||||||
|
|
||||||
const REVIEW_JOIN_LIMIT = 4;
|
const REVIEW_JOIN_LIMIT = 4;
|
||||||
|
|
||||||
@@ -189,6 +207,9 @@ type OutreachRecordInsertArgs = {
|
|||||||
emailSubject?: string;
|
emailSubject?: string;
|
||||||
emailBody?: string;
|
emailBody?: string;
|
||||||
followUpDraft?: string;
|
followUpDraft?: string;
|
||||||
|
followUpDueAt?: number;
|
||||||
|
parentOutreachId?: Id<"outreachRecords">;
|
||||||
|
salesStatus?: "follow_up_planned" | "follow_up_sent";
|
||||||
now: number;
|
now: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,10 +222,12 @@ const buildOutreachRecordsInsertPayload = (args: OutreachRecordInsertArgs) => {
|
|||||||
emailSubject?: string;
|
emailSubject?: string;
|
||||||
emailBody?: string;
|
emailBody?: string;
|
||||||
followUpDraft?: string;
|
followUpDraft?: string;
|
||||||
|
followUpDueAt?: number;
|
||||||
|
parentOutreachId?: Id<"outreachRecords">;
|
||||||
approvalStatus: "draft";
|
approvalStatus: "draft";
|
||||||
sendStatus: "not_sent";
|
sendStatus: "not_sent";
|
||||||
responseStatus: "none";
|
responseStatus: "none";
|
||||||
salesStatus: "follow_up_planned";
|
salesStatus: "follow_up_planned" | "follow_up_sent";
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
} = {
|
} = {
|
||||||
@@ -213,7 +236,7 @@ const buildOutreachRecordsInsertPayload = (args: OutreachRecordInsertArgs) => {
|
|||||||
approvalStatus: "draft",
|
approvalStatus: "draft",
|
||||||
sendStatus: "not_sent",
|
sendStatus: "not_sent",
|
||||||
responseStatus: "none",
|
responseStatus: "none",
|
||||||
salesStatus: "follow_up_planned",
|
salesStatus: args.salesStatus ?? "follow_up_planned",
|
||||||
createdAt: args.now,
|
createdAt: args.now,
|
||||||
updatedAt: args.now,
|
updatedAt: args.now,
|
||||||
};
|
};
|
||||||
@@ -233,10 +256,55 @@ const buildOutreachRecordsInsertPayload = (args: OutreachRecordInsertArgs) => {
|
|||||||
if (args.followUpDraft !== undefined) {
|
if (args.followUpDraft !== undefined) {
|
||||||
payload.followUpDraft = args.followUpDraft;
|
payload.followUpDraft = args.followUpDraft;
|
||||||
}
|
}
|
||||||
|
if (args.followUpDueAt !== undefined) {
|
||||||
|
payload.followUpDueAt = args.followUpDueAt;
|
||||||
|
}
|
||||||
|
if (args.parentOutreachId !== undefined) {
|
||||||
|
payload.parentOutreachId = args.parentOutreachId;
|
||||||
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function createFollowUpDraftAfterInitialSend(
|
||||||
|
ctx: MutationCtx,
|
||||||
|
outreach: Doc<"outreachRecords">,
|
||||||
|
sentAt: number,
|
||||||
|
) {
|
||||||
|
const existingFollowUps = await ctx.db
|
||||||
|
.query("outreachRecords")
|
||||||
|
.withIndex("by_parentOutreachId", (q) => q.eq("parentOutreachId", outreach._id))
|
||||||
|
.take(1);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!shouldCreateFollowUpDraftAfterSend({
|
||||||
|
existingFollowUpOutreachCount: existingFollowUps.length,
|
||||||
|
followUpDraft: outreach.followUpDraft,
|
||||||
|
salesStatus: outreach.salesStatus,
|
||||||
|
sendStatus: "sent",
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db.insert(
|
||||||
|
"outreachRecords",
|
||||||
|
buildOutreachRecordsInsertPayload({
|
||||||
|
leadId: outreach.leadId,
|
||||||
|
auditId: outreach.auditId,
|
||||||
|
strategy: "email_first",
|
||||||
|
emailSubject: outreach.emailSubject
|
||||||
|
? `Kurze Nachfrage: ${outreach.emailSubject}`
|
||||||
|
: "Kurze Nachfrage zum Website-Audit",
|
||||||
|
emailBody: outreach.followUpDraft,
|
||||||
|
followUpDraft: outreach.followUpDraft,
|
||||||
|
followUpDueAt: sentAt + FOLLOW_UP_DUE_DELAY_MS,
|
||||||
|
parentOutreachId: outreach._id,
|
||||||
|
now: Date.now(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const create = mutation({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
leadId: v.id("leads"),
|
leadId: v.id("leads"),
|
||||||
@@ -671,6 +739,7 @@ export const recordEmailSendSuccess = internalMutation({
|
|||||||
await ctx.db.patch(args.id, {
|
await ctx.db.patch(args.id, {
|
||||||
sendStatus: "sent",
|
sendStatus: "sent",
|
||||||
sentAt: args.sentAt,
|
sentAt: args.sentAt,
|
||||||
|
...(outreach.parentOutreachId ? { salesStatus: "follow_up_sent" as const } : {}),
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -729,6 +798,64 @@ export const recordEmailSendSuccess = internalMutation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.insert("outreachSendAttempts", attempt);
|
await ctx.db.insert("outreachSendAttempts", attempt);
|
||||||
|
await createFollowUpDraftAfterInitialSend(ctx, outreach, args.sentAt);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateManualSalesStatus = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("outreachRecords"),
|
||||||
|
salesStatus: manualSalesStatus,
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
|
|
||||||
|
const outreach = await ctx.db.get(args.id);
|
||||||
|
if (!outreach) {
|
||||||
|
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const outreachPatch: {
|
||||||
|
salesStatus: typeof args.salesStatus;
|
||||||
|
responseStatus?: "none" | "manual_reply_recorded" | "no_interest" | "follow_up_needed";
|
||||||
|
doNotContactUntil?: number;
|
||||||
|
updatedAt: number;
|
||||||
|
} = {
|
||||||
|
salesStatus: args.salesStatus,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
const leadPatch: {
|
||||||
|
contactStatus?: "contacted" | "replied" | "do_not_contact";
|
||||||
|
updatedAt: number;
|
||||||
|
} = {
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (args.salesStatus === "reply_received") {
|
||||||
|
outreachPatch.responseStatus = "manual_reply_recorded";
|
||||||
|
leadPatch.contactStatus = "replied";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.salesStatus === "not_interested") {
|
||||||
|
outreachPatch.responseStatus = "no_interest";
|
||||||
|
leadPatch.contactStatus = "contacted";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.salesStatus === "do_not_pursue") {
|
||||||
|
outreachPatch.responseStatus = "no_interest";
|
||||||
|
outreachPatch.doNotContactUntil = now + DO_NOT_CONTACT_RECHECK_MS;
|
||||||
|
leadPatch.contactStatus = "do_not_contact";
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.id, outreachPatch);
|
||||||
|
await ctx.db.patch(outreach.leadId, leadPatch);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: args.id,
|
||||||
|
salesStatus: args.salesStatus,
|
||||||
|
doNotContactUntil: outreachPatch.doNotContactUntil ?? null,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use node";
|
"use node";
|
||||||
|
|
||||||
import { api, internal } from "./_generated/api";
|
import { internal } from "./_generated/api";
|
||||||
import { internalAction } from "./_generated/server";
|
import { internalAction } from "./_generated/server";
|
||||||
import type { Id } from "./_generated/dataModel";
|
import type { Id } from "./_generated/dataModel";
|
||||||
import type { ActionCtx } from "./_generated/server";
|
import type { ActionCtx } from "./_generated/server";
|
||||||
@@ -122,7 +122,7 @@ async function queueAuditGenerationAfterPageSpeed(
|
|||||||
parentRunId: runId,
|
parentRunId: runId,
|
||||||
});
|
});
|
||||||
} catch (auditQueueError) {
|
} catch (auditQueueError) {
|
||||||
await ctx.runMutation(api.runs.appendEvent, {
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
runId,
|
runId,
|
||||||
level: "warning",
|
level: "warning",
|
||||||
message: "Audit-Generierung konnte nicht in die Warteschlange gesetzt werden.",
|
message: "Audit-Generierung konnte nicht in die Warteschlange gesetzt werden.",
|
||||||
@@ -164,7 +164,7 @@ export const processPageSpeedAudit = internalAction({
|
|||||||
errorSummary,
|
errorSummary,
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.runMutation(api.runs.appendEvent, {
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
level: "error",
|
level: "error",
|
||||||
message: "PageSpeed-Analyse fehlgeschlagen.",
|
message: "PageSpeed-Analyse fehlgeschlagen.",
|
||||||
@@ -210,7 +210,7 @@ export const processPageSpeedAudit = internalAction({
|
|||||||
fetchedAt,
|
fetchedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.runMutation(api.runs.appendEvent, {
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
level: "warning",
|
level: "warning",
|
||||||
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
|
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
|
||||||
@@ -248,7 +248,7 @@ export const processPageSpeedAudit = internalAction({
|
|||||||
normalized: toPersistedPageSpeedNormalizedResult(normalized),
|
normalized: toPersistedPageSpeedNormalizedResult(normalized),
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.runMutation(api.runs.appendEvent, {
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
level: "info",
|
level: "info",
|
||||||
message: `PageSpeed-Analyse für ${strategy} abgeschlossen.`,
|
message: `PageSpeed-Analyse für ${strategy} abgeschlossen.`,
|
||||||
@@ -274,7 +274,7 @@ export const processPageSpeedAudit = internalAction({
|
|||||||
fetchedAt,
|
fetchedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.runMutation(api.runs.appendEvent, {
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
level: "warning",
|
level: "warning",
|
||||||
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
|
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
|
||||||
@@ -310,7 +310,7 @@ export const processPageSpeedAudit = internalAction({
|
|||||||
errorSummary,
|
errorSummary,
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.runMutation(api.runs.appendEvent, {
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
level: "error",
|
level: "error",
|
||||||
message: "PageSpeed-Analyse fehlgeschlagen.",
|
message: "PageSpeed-Analyse fehlgeschlagen.",
|
||||||
|
|||||||
@@ -6,13 +6,53 @@ import {
|
|||||||
RUN_TYPES,
|
RUN_TYPES,
|
||||||
normalizeListLimit,
|
normalizeListLimit,
|
||||||
} from "./domain";
|
} 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 runType = v.union(...RUN_TYPES.map((type) => v.literal(type)));
|
||||||
const runStatus = v.union(...RUN_STATUSES.map((status) => v.literal(status)));
|
const runStatus = v.union(...RUN_STATUSES.map((status) => v.literal(status)));
|
||||||
const eventLevel = v.union(
|
const eventLevel = v.union(
|
||||||
...RUN_EVENT_LEVELS.map((level) => v.literal(level)),
|
...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({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
@@ -24,6 +64,7 @@ export const create = mutation({
|
|||||||
currentStep: v.optional(v.string()),
|
currentStep: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
return await ctx.db.insert("agentRuns", {
|
return await ctx.db.insert("agentRuns", {
|
||||||
@@ -50,6 +91,7 @@ export const updateStatus = mutation({
|
|||||||
errorSummary: v.optional(v.string()),
|
errorSummary: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const patch: {
|
const patch: {
|
||||||
status: typeof args.status;
|
status: typeof args.status;
|
||||||
@@ -92,6 +134,7 @@ export const list = query({
|
|||||||
limit: v.optional(v.number()),
|
limit: v.optional(v.number()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
const limit = normalizeListLimit(args.limit);
|
const limit = normalizeListLimit(args.limit);
|
||||||
|
|
||||||
if (args.type && args.status) {
|
if (args.type && args.status) {
|
||||||
@@ -132,25 +175,17 @@ export const list = query({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const appendEvent = mutation({
|
export const appendEvent = mutation({
|
||||||
args: {
|
args: 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()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
return await ctx.db.insert("agentRunEvents", {
|
await requireOperator(ctx);
|
||||||
...args,
|
return await appendRunEvent(ctx, args);
|
||||||
createdAt: Date.now(),
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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()),
|
limit: v.optional(v.number()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
await requireOperator(ctx);
|
||||||
const limit = normalizeListLimit(args.limit);
|
const limit = normalizeListLimit(args.limit);
|
||||||
|
|
||||||
return await ctx.db
|
return await ctx.db
|
||||||
|
|||||||
199
convex/scheduledJobs.ts
Normal file
199
convex/scheduledJobs.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { internal } from "./_generated/api";
|
||||||
|
import { internalMutation } from "./_generated/server";
|
||||||
|
import { canStartAgentRun, isStalePendingAgentRun } from "../lib/lead-discovery-run";
|
||||||
|
|
||||||
|
export const AUDIT_REVIEW_NOTICE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
export const AUDIT_AUTO_DEACTIVATE_AFTER_MS = 60 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const RUN_COUNTERS_ZERO = {
|
||||||
|
leadsFound: 0,
|
||||||
|
leadsCreated: 0,
|
||||||
|
auditsCreated: 0,
|
||||||
|
outreachPrepared: 0,
|
||||||
|
errors: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runDueCampaigns = internalMutation({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const activeRuns = [
|
||||||
|
...(await ctx.db
|
||||||
|
.query("agentRuns")
|
||||||
|
.withIndex("by_status", (q) => q.eq("status", "pending"))
|
||||||
|
.take(20)),
|
||||||
|
...(await ctx.db
|
||||||
|
.query("agentRuns")
|
||||||
|
.withIndex("by_status", (q) => q.eq("status", "running"))
|
||||||
|
.take(20)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const run of activeRuns.filter((run) => isStalePendingAgentRun(run, now))) {
|
||||||
|
await ctx.db.patch(run._id, {
|
||||||
|
status: "canceled",
|
||||||
|
currentStep: "campaign_cron_stale_pending",
|
||||||
|
errorSummary: "Ausstehender Lauf wurde nach Timeout automatisch abgebrochen.",
|
||||||
|
finishedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await ctx.db.insert("agentRunEvents", {
|
||||||
|
runId: run._id,
|
||||||
|
level: "warning",
|
||||||
|
message: "Ausstehender Lauf wurde nach Timeout automatisch abgebrochen.",
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canStartAgentRun(activeRuns, now)) {
|
||||||
|
const skippedRunId = await ctx.db.insert("agentRuns", {
|
||||||
|
type: "campaign",
|
||||||
|
status: "canceled",
|
||||||
|
currentStep: "campaign_cron_skipped",
|
||||||
|
errorSummary: "Es läuft bereits ein Agentenlauf.",
|
||||||
|
counters: RUN_COUNTERS_ZERO,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
finishedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert("agentRunEvents", {
|
||||||
|
runId: skippedRunId,
|
||||||
|
level: "warning",
|
||||||
|
message: "Es läuft bereits ein Agentenlauf. Kampagnen-Cron wurde übersprungen.",
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { started: 0, skipped: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dueCampaigns = await ctx.db
|
||||||
|
.query("campaigns")
|
||||||
|
.withIndex("by_status_and_nextRunAt", (q) =>
|
||||||
|
q.eq("status", "active").lte("nextRunAt", now),
|
||||||
|
)
|
||||||
|
.take(1);
|
||||||
|
const campaign = dueCampaigns[0];
|
||||||
|
|
||||||
|
if (!campaign || campaign.recurrence === "manual") {
|
||||||
|
return { started: 0, skipped: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const runId = await ctx.db.insert("agentRuns", {
|
||||||
|
type: "campaign",
|
||||||
|
campaignId: campaign._id,
|
||||||
|
status: "pending",
|
||||||
|
currentStep: "campaign_cron_queued",
|
||||||
|
counters: RUN_COUNTERS_ZERO,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert("agentRunEvents", {
|
||||||
|
runId,
|
||||||
|
level: "info",
|
||||||
|
message: "Kampagnenlauf wurde durch Cadence-Cron geplant.",
|
||||||
|
details: [{ label: "Kampagne", value: campaign.name }],
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.scheduler.runAfter(0, internal.leadDiscovery.processCampaignRun, {
|
||||||
|
runId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { started: 1, skipped: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const runAuditLifecycle = internalMutation({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const runId = await ctx.db.insert("agentRuns", {
|
||||||
|
type: "lifecycle",
|
||||||
|
status: "running",
|
||||||
|
currentStep: "audit_lifecycle",
|
||||||
|
counters: RUN_COUNTERS_ZERO,
|
||||||
|
startedAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
let notifications = 0;
|
||||||
|
let deactivated = 0;
|
||||||
|
|
||||||
|
const publishedAudits = await ctx.db
|
||||||
|
.query("audits")
|
||||||
|
.withIndex("by_status", (q) => q.eq("status", "published"))
|
||||||
|
.take(100);
|
||||||
|
|
||||||
|
for (const audit of publishedAudits) {
|
||||||
|
const publishedAt = audit.publishedAt ?? audit.updatedAt;
|
||||||
|
const extendedUntil = audit.lifecycleExtendedUntil ?? 0;
|
||||||
|
const isExtended = extendedUntil > now;
|
||||||
|
|
||||||
|
if (!isExtended && now - publishedAt >= AUDIT_AUTO_DEACTIVATE_AFTER_MS) {
|
||||||
|
await ctx.db.patch(audit._id, {
|
||||||
|
status: "deactivated",
|
||||||
|
deactivatedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await ctx.db.insert("dashboardNotifications", {
|
||||||
|
auditId: audit._id,
|
||||||
|
runId,
|
||||||
|
kind: "audit_auto_deactivated",
|
||||||
|
title: "Audit automatisch deaktiviert",
|
||||||
|
message: "Ein veröffentlichtes Audit war älter als 60 Tage und wurde deaktiviert.",
|
||||||
|
status: "unread",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
deactivated += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!audit.lifecycleNotificationAt &&
|
||||||
|
now - publishedAt >= AUDIT_REVIEW_NOTICE_AFTER_MS
|
||||||
|
) {
|
||||||
|
await ctx.db.patch(audit._id, {
|
||||||
|
lifecycleNotificationAt: now,
|
||||||
|
reviewDueAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await ctx.db.insert("dashboardNotifications", {
|
||||||
|
auditId: audit._id,
|
||||||
|
runId,
|
||||||
|
kind: "audit_review_due",
|
||||||
|
title: "Audit-Aktivität prüfen",
|
||||||
|
message: "Soll dieses Audit aktiv bleiben? Es ist seit 30 Tagen veröffentlicht.",
|
||||||
|
status: "unread",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
notifications += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(runId, {
|
||||||
|
status: "succeeded",
|
||||||
|
finishedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
counters: {
|
||||||
|
...RUN_COUNTERS_ZERO,
|
||||||
|
auditsCreated: notifications,
|
||||||
|
errors: deactivated,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await ctx.db.insert("agentRunEvents", {
|
||||||
|
runId,
|
||||||
|
level: "info",
|
||||||
|
message: "Audit-Lifecycle geprüft.",
|
||||||
|
details: [
|
||||||
|
{ label: "Hinweise", value: String(notifications) },
|
||||||
|
{ label: "Deaktiviert", value: String(deactivated) },
|
||||||
|
],
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { notifications, deactivated };
|
||||||
|
},
|
||||||
|
});
|
||||||
103
convex/schema.ts
103
convex/schema.ts
@@ -7,6 +7,8 @@ import {
|
|||||||
RUN_EVENT_LEVELS,
|
RUN_EVENT_LEVELS,
|
||||||
RUN_STATUSES,
|
RUN_STATUSES,
|
||||||
RUN_TYPES,
|
RUN_TYPES,
|
||||||
|
USAGE_EVENT_OPERATIONS,
|
||||||
|
USAGE_EVENT_PROVIDERS,
|
||||||
} from "./domain";
|
} from "./domain";
|
||||||
|
|
||||||
const campaignStatus = v.union(v.literal("active"), v.literal("paused"));
|
const campaignStatus = v.union(v.literal("active"), v.literal("paused"));
|
||||||
@@ -146,6 +148,12 @@ const pageSpeedErrorType = v.union(
|
|||||||
v.literal("api_error"),
|
v.literal("api_error"),
|
||||||
v.literal("unknown"),
|
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 settingsValue = v.union(v.string(), v.number(), v.boolean(), v.null());
|
||||||
const auditMetricSummary = v.object({
|
const auditMetricSummary = v.object({
|
||||||
performanceScore: v.optional(v.number()),
|
performanceScore: v.optional(v.number()),
|
||||||
@@ -172,6 +180,25 @@ const publicAuditOffer = v.object({
|
|||||||
ctaLabel: v.optional(v.string()),
|
ctaLabel: v.optional(v.string()),
|
||||||
ctaHref: 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({
|
const eventDetail = v.object({
|
||||||
label: v.string(),
|
label: v.string(),
|
||||||
value: v.string(),
|
value: v.string(),
|
||||||
@@ -282,8 +309,9 @@ export default defineSchema({
|
|||||||
usedSkills: v.optional(
|
usedSkills: v.optional(
|
||||||
v.array(
|
v.array(
|
||||||
v.object({
|
v.object({
|
||||||
|
id: v.optional(v.string()),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
category: v.string(),
|
category: v.optional(v.string()),
|
||||||
version: v.optional(v.string()),
|
version: v.optional(v.string()),
|
||||||
source: v.optional(v.string()),
|
source: v.optional(v.string()),
|
||||||
}),
|
}),
|
||||||
@@ -307,6 +335,8 @@ export default defineSchema({
|
|||||||
ctaType: v.optional(v.string()),
|
ctaType: v.optional(v.string()),
|
||||||
publishedAt: v.optional(v.number()),
|
publishedAt: v.optional(v.number()),
|
||||||
reviewDueAt: v.optional(v.number()),
|
reviewDueAt: v.optional(v.number()),
|
||||||
|
lifecycleNotificationAt: v.optional(v.number()),
|
||||||
|
lifecycleExtendedUntil: v.optional(v.number()),
|
||||||
deactivatedAt: v.optional(v.number()),
|
deactivatedAt: v.optional(v.number()),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
@@ -331,6 +361,24 @@ export default defineSchema({
|
|||||||
.index("by_auditId_and_viewport", ["auditId", "viewport"])
|
.index("by_auditId_and_viewport", ["auditId", "viewport"])
|
||||||
.index("by_storageId", ["storageId"]),
|
.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({
|
pageSpeedResults: defineTable({
|
||||||
leadId: v.id("leads"),
|
leadId: v.id("leads"),
|
||||||
auditId: v.optional(v.id("audits")),
|
auditId: v.optional(v.id("audits")),
|
||||||
@@ -397,6 +445,39 @@ export default defineSchema({
|
|||||||
.index("by_stage", ["stage"])
|
.index("by_stage", ["stage"])
|
||||||
.index("by_leadId_and_stage", ["leadId", "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({
|
websiteCrawlPages: defineTable({
|
||||||
leadId: v.id("leads"),
|
leadId: v.id("leads"),
|
||||||
runId: v.optional(v.id("agentRuns")),
|
runId: v.optional(v.id("agentRuns")),
|
||||||
@@ -484,11 +565,14 @@ export default defineSchema({
|
|||||||
emailSubject: v.optional(v.string()),
|
emailSubject: v.optional(v.string()),
|
||||||
emailBody: v.optional(v.string()),
|
emailBody: v.optional(v.string()),
|
||||||
followUpDraft: v.optional(v.string()),
|
followUpDraft: v.optional(v.string()),
|
||||||
|
followUpDueAt: v.optional(v.number()),
|
||||||
|
parentOutreachId: v.optional(v.id("outreachRecords")),
|
||||||
approvalStatus: outreachApprovalStatus,
|
approvalStatus: outreachApprovalStatus,
|
||||||
sendStatus: outreachSendStatus,
|
sendStatus: outreachSendStatus,
|
||||||
sentAt: v.optional(v.number()),
|
sentAt: v.optional(v.number()),
|
||||||
responseStatus: outreachResponseStatus,
|
responseStatus: outreachResponseStatus,
|
||||||
salesStatus: outreachSalesStatus,
|
salesStatus: outreachSalesStatus,
|
||||||
|
doNotContactUntil: v.optional(v.number()),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
})
|
})
|
||||||
@@ -502,7 +586,8 @@ export default defineSchema({
|
|||||||
"updatedAt",
|
"updatedAt",
|
||||||
])
|
])
|
||||||
.index("by_sendStatus", ["sendStatus"])
|
.index("by_sendStatus", ["sendStatus"])
|
||||||
.index("by_sendStatus_and_updatedAt", ["sendStatus", "updatedAt"]),
|
.index("by_sendStatus_and_updatedAt", ["sendStatus", "updatedAt"])
|
||||||
|
.index("by_parentOutreachId", ["parentOutreachId"]),
|
||||||
|
|
||||||
outreachSendAttempts: defineTable({
|
outreachSendAttempts: defineTable({
|
||||||
outreachId: v.id("outreachRecords"),
|
outreachId: v.id("outreachRecords"),
|
||||||
@@ -588,4 +673,18 @@ export default defineSchema({
|
|||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
}).index("by_key", ["key"]),
|
}).index("by_key", ["key"]),
|
||||||
|
|
||||||
|
dashboardNotifications: defineTable({
|
||||||
|
auditId: v.optional(v.id("audits")),
|
||||||
|
runId: v.optional(v.id("agentRuns")),
|
||||||
|
kind: v.string(),
|
||||||
|
title: v.string(),
|
||||||
|
message: v.string(),
|
||||||
|
status: v.union(v.literal("unread"), v.literal("acknowledged")),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_status_and_createdAt", ["status", "createdAt"])
|
||||||
|
.index("by_auditId", ["auditId"])
|
||||||
|
.index("by_runId", ["runId"]),
|
||||||
});
|
});
|
||||||
|
|||||||
223
convex/usageEvents.ts
Normal file
223
convex/usageEvents.ts
Normal 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));
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
getUsableContactEmailFromEntries,
|
getUsableContactEmailFromEntries,
|
||||||
normalizeEmailAddress,
|
normalizeEmailAddress,
|
||||||
} from "../lib/lead-discovery-google";
|
} from "../lib/lead-discovery-google";
|
||||||
import { api, internal } from "./_generated/api";
|
import { internal } from "./_generated/api";
|
||||||
import type { Doc, Id } from "./_generated/dataModel";
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
import { internalAction, type ActionCtx } from "./_generated/server";
|
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_LINKS = 120;
|
||||||
const MAX_PERSISTED_EMAIL_CANDIDATES = 40;
|
const MAX_PERSISTED_EMAIL_CANDIDATES = 40;
|
||||||
const SCREENSHOT_MIME_TYPE = "image/png";
|
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_SOURCE_MARKER_FILE = path.join(tmpdir(), "chromium-source.sha256");
|
||||||
const CHROMIUM_EXECUTABLE_PATH = path.join(tmpdir(), "chromium");
|
const CHROMIUM_EXECUTABLE_PATH = path.join(tmpdir(), "chromium");
|
||||||
const CHROMIUM_PACK_PATH = path.join(tmpdir(), "chromium-pack");
|
const CHROMIUM_PACK_PATH = path.join(tmpdir(), "chromium-pack");
|
||||||
@@ -116,11 +127,41 @@ type ServerlessChromiumModule = {
|
|||||||
inflate: (filePath: string) => Promise<string>;
|
inflate: (filePath: string) => Promise<string>;
|
||||||
setupLambdaEnvironment: (baseLibPath: string) => void;
|
setupLambdaEnvironment: (baseLibPath: string) => void;
|
||||||
};
|
};
|
||||||
|
type PlaywrightClosableResource = {
|
||||||
|
close: () => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
function messageFromError(error: unknown) {
|
function messageFromError(error: unknown) {
|
||||||
return error instanceof Error ? error.message : String(error);
|
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) {
|
function readPositiveIntEnv(key: string, fallback: number) {
|
||||||
const raw = process.env[key]?.trim();
|
const raw = process.env[key]?.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -230,6 +271,280 @@ function isGenericBusinessEmail(email: string) {
|
|||||||
return GENERIC_EMAIL_LOCALS.has(base);
|
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(/ | | /gi, " ")
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'|'/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() {
|
async function loadPlaywrightModules() {
|
||||||
const [playwrightCore, chromiumPackage] = await Promise.all([
|
const [playwrightCore, chromiumPackage] = await Promise.all([
|
||||||
import("playwright-core"),
|
import("playwright-core"),
|
||||||
@@ -327,7 +642,7 @@ async function captureHomepageScreenshot(
|
|||||||
mimeType: SCREENSHOT_MIME_TYPE,
|
mimeType: SCREENSHOT_MIME_TYPE,
|
||||||
} satisfies StoredScreenshot;
|
} satisfies StoredScreenshot;
|
||||||
} finally {
|
} finally {
|
||||||
await page.close();
|
await closePlaywrightResourceSafely(page, "homepage screenshot page");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +743,7 @@ async function crawlPage(
|
|||||||
hasContactCtaSignal: signals.hasContactCtaSignal,
|
hasContactCtaSignal: signals.hasContactCtaSignal,
|
||||||
} satisfies PageResult;
|
} satisfies PageResult;
|
||||||
} finally {
|
} finally {
|
||||||
await page.close();
|
await closePlaywrightResourceSafely(page, "crawl page");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,9 +773,226 @@ function deduplicateCrawlLinks(links: PersistedCrawlLink[]) {
|
|||||||
return [...unique.values()];
|
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({
|
export const processLeadEnrichment = internalAction({
|
||||||
args: { runId: v.id("agentRuns") },
|
args: { runId: v.id("agentRuns") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
|
||||||
let started: StartedLead | null = null;
|
let started: StartedLead | null = null;
|
||||||
const runId = args.runId;
|
const runId = args.runId;
|
||||||
const actionStartedAt = Date.now();
|
const actionStartedAt = Date.now();
|
||||||
@@ -486,7 +1018,7 @@ export const processLeadEnrichment = internalAction({
|
|||||||
parentRunId: runId,
|
parentRunId: runId,
|
||||||
});
|
});
|
||||||
} catch (pageSpeedQueueError) {
|
} catch (pageSpeedQueueError) {
|
||||||
await ctx.runMutation(api.runs.appendEvent, {
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
runId,
|
runId,
|
||||||
level: "warning",
|
level: "warning",
|
||||||
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
|
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
|
||||||
@@ -508,7 +1040,7 @@ export const processLeadEnrichment = internalAction({
|
|||||||
errorSummary: "Ungültige Website-URL.",
|
errorSummary: "Ungültige Website-URL.",
|
||||||
errors: 1,
|
errors: 1,
|
||||||
});
|
});
|
||||||
await ctx.runMutation(api.runs.appendEvent, {
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
runId,
|
runId,
|
||||||
level: "error",
|
level: "error",
|
||||||
message: "Website-Enrichment fehlgeschlagen: Ungültige Website-URL.",
|
message: "Website-Enrichment fehlgeschlagen: Ungültige Website-URL.",
|
||||||
@@ -526,6 +1058,18 @@ export const processLeadEnrichment = internalAction({
|
|||||||
const timeoutMs = crawlTimeoutMs();
|
const timeoutMs = crawlTimeoutMs();
|
||||||
const maxPages = crawlMaxPages();
|
const maxPages = crawlMaxPages();
|
||||||
|
|
||||||
|
if (!getChromiumExecutableSource()) {
|
||||||
|
return await processLeadEnrichmentWithoutBrowser(ctx, {
|
||||||
|
runId,
|
||||||
|
lead: started.lead,
|
||||||
|
rootUrl,
|
||||||
|
timeoutMs,
|
||||||
|
maxPages,
|
||||||
|
actionStartedAt,
|
||||||
|
actionBudget,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { playwrightCore, serverlessChromium } =
|
const { playwrightCore, serverlessChromium } =
|
||||||
await withActionTimeout(
|
await withActionTimeout(
|
||||||
loadPlaywrightModules(),
|
loadPlaywrightModules(),
|
||||||
@@ -803,7 +1347,7 @@ export const processLeadEnrichment = internalAction({
|
|||||||
parentRunId: runId,
|
parentRunId: runId,
|
||||||
});
|
});
|
||||||
} catch (pageSpeedQueueError) {
|
} catch (pageSpeedQueueError) {
|
||||||
await ctx.runMutation(api.runs.appendEvent, {
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
runId,
|
runId,
|
||||||
level: "warning",
|
level: "warning",
|
||||||
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
|
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
|
||||||
@@ -825,7 +1369,7 @@ export const processLeadEnrichment = internalAction({
|
|||||||
errors: 0,
|
errors: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.runMutation(api.runs.appendEvent, {
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
runId,
|
runId,
|
||||||
level: "info",
|
level: "info",
|
||||||
message: usable
|
message: usable
|
||||||
@@ -846,7 +1390,7 @@ export const processLeadEnrichment = internalAction({
|
|||||||
errors: 1,
|
errors: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.runMutation(api.runs.appendEvent, {
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
runId,
|
runId,
|
||||||
level: "error",
|
level: "error",
|
||||||
message: "Website-Enrichment fehlgeschlagen.",
|
message: "Website-Enrichment fehlgeschlagen.",
|
||||||
@@ -862,7 +1406,7 @@ export const processLeadEnrichment = internalAction({
|
|||||||
parentRunId: runId,
|
parentRunId: runId,
|
||||||
});
|
});
|
||||||
} catch (pageSpeedQueueError) {
|
} catch (pageSpeedQueueError) {
|
||||||
await ctx.runMutation(api.runs.appendEvent, {
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
runId,
|
runId,
|
||||||
level: "warning",
|
level: "warning",
|
||||||
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
|
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
|
||||||
@@ -886,13 +1430,19 @@ export const processLeadEnrichment = internalAction({
|
|||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (desktopContext) {
|
if (desktopContext) {
|
||||||
await desktopContext.close();
|
await closePlaywrightResourceSafely(
|
||||||
|
desktopContext,
|
||||||
|
"desktop browser context",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (mobileContext) {
|
if (mobileContext) {
|
||||||
await mobileContext.close();
|
await closePlaywrightResourceSafely(
|
||||||
|
mobileContext,
|
||||||
|
"mobile browser context",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (browser) {
|
if (browser) {
|
||||||
await browser.close();
|
await closePlaywrightResourceSafely(browser, "browser");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
50
docs/coolify-deployment.md
Normal file
50
docs/coolify-deployment.md
Normal 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
53
docs/verification.md
Normal 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.
|
||||||
@@ -13,6 +13,8 @@ const eslintConfig = defineConfig([
|
|||||||
"build/**",
|
"build/**",
|
||||||
".test-output/**",
|
".test-output/**",
|
||||||
"convex/_generated/**",
|
"convex/_generated/**",
|
||||||
|
// v2_elemente contains PRD/reference snippets, not runtime source.
|
||||||
|
"v2_elemente/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type AuditEvidenceInput = {
|
|||||||
observedUxSignals: string[];
|
observedUxSignals: string[];
|
||||||
observedContentSignals: string[];
|
observedContentSignals: string[];
|
||||||
observedTechnicalSignals: string[];
|
observedTechnicalSignals: string[];
|
||||||
|
externalMarkdown?: string;
|
||||||
screenshotReferences: Array<{
|
screenshotReferences: Array<{
|
||||||
storageId: string;
|
storageId: string;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
@@ -71,6 +72,20 @@ export type AuditEvidenceInput = {
|
|||||||
}>;
|
}>;
|
||||||
pageSpeedCustomerImplications: string[];
|
pageSpeedCustomerImplications: string[];
|
||||||
selectedSkills: AuditUsedSkill[];
|
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 = {
|
export type AuditEvidenceInputArgs = {
|
||||||
@@ -80,6 +95,7 @@ export type AuditEvidenceInputArgs = {
|
|||||||
screenshots?: readonly AuditScreenshotEvidence[];
|
screenshots?: readonly AuditScreenshotEvidence[];
|
||||||
pageSpeedInputs?: readonly PageSpeedMinimalAuditResult[];
|
pageSpeedInputs?: readonly PageSpeedMinimalAuditResult[];
|
||||||
skillRegistry?: readonly SkillRegistryEntryEvidence[];
|
skillRegistry?: readonly SkillRegistryEntryEvidence[];
|
||||||
|
externalMarkdown?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPANY_CONTEXT_LIMIT = 8;
|
const COMPANY_CONTEXT_LIMIT = 8;
|
||||||
@@ -90,6 +106,21 @@ const TECHNICAL_SIGNAL_LIMIT = 6;
|
|||||||
const PAGESPEED_SIGNAL_LIMIT = 8;
|
const PAGESPEED_SIGNAL_LIMIT = 8;
|
||||||
const SCREENSHOT_REFERENCE_LIMIT = 8;
|
const SCREENSHOT_REFERENCE_LIMIT = 8;
|
||||||
const SELECTED_SKILLS_LIMIT = 6;
|
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 URL_PATTERN = /\bhttps?:\/\/[^\s<>"']+/i;
|
||||||
const JSON_BRACKET_PATTERN = /\{[^}]*\}|\[[^\]]*\]/;
|
const JSON_BRACKET_PATTERN = /\{[^}]*\}|\[[^\]]*\]/;
|
||||||
@@ -97,6 +128,32 @@ const PAGESPEED_NOISE_PATTERN =
|
|||||||
/\b(?:raw\s*storage\s*id|rawstorageid|lighthouse|pagespeed|score)\b/i;
|
/\b(?:raw\s*storage\s*id|rawstorageid|lighthouse|pagespeed|score)\b/i;
|
||||||
const MACHINE_TOKEN_PATTERN = /\b[a-z\d_-]{24,}\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 {
|
function trimAndNormalize(input: unknown): string {
|
||||||
if (typeof input !== "string") {
|
if (typeof input !== "string") {
|
||||||
return "";
|
return "";
|
||||||
@@ -140,6 +197,19 @@ function sanitizeCustomerText(value: unknown, maxLength = 180): string {
|
|||||||
return text;
|
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(
|
function addUniqueCapped(
|
||||||
bucket: string[],
|
bucket: string[],
|
||||||
input: string,
|
input: string,
|
||||||
@@ -233,6 +303,77 @@ function selectTopSkill(
|
|||||||
return toAuditUsedSkill(scored[0]!.candidate);
|
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(
|
function buildObservedSignals(
|
||||||
crawlPages: readonly AuditCrawlPageEvidence[],
|
crawlPages: readonly AuditCrawlPageEvidence[],
|
||||||
technicalChecks: readonly AuditTechnicalCheckEvidence[],
|
technicalChecks: readonly AuditTechnicalCheckEvidence[],
|
||||||
@@ -403,8 +544,12 @@ function extractSkills(
|
|||||||
marketing: boolean;
|
marketing: boolean;
|
||||||
offer: boolean;
|
offer: boolean;
|
||||||
},
|
},
|
||||||
|
availability: SkillInputAvailability,
|
||||||
): AuditUsedSkill[] {
|
): AuditUsedSkill[] {
|
||||||
const selected: AuditUsedSkill[] = [];
|
const selected: AuditUsedSkill[] = selectV3Skills(
|
||||||
|
skillRegistry,
|
||||||
|
availability,
|
||||||
|
);
|
||||||
const categoryOrder = ["design", "ux", "copy", "seo", "marketing", "offer"] as const;
|
const categoryOrder = ["design", "ux", "copy", "seo", "marketing", "offer"] as const;
|
||||||
const evidenceText = {
|
const evidenceText = {
|
||||||
design:
|
design:
|
||||||
@@ -450,6 +595,8 @@ export function buildAuditEvidenceInput(
|
|||||||
const screenshots = args.screenshots ?? [];
|
const screenshots = args.screenshots ?? [];
|
||||||
const pageSpeedInputs = args.pageSpeedInputs ?? [];
|
const pageSpeedInputs = args.pageSpeedInputs ?? [];
|
||||||
const skillRegistry = args.skillRegistry ?? [];
|
const skillRegistry = args.skillRegistry ?? [];
|
||||||
|
const externalMarkdown = sanitizeExternalMarkdown(args.externalMarkdown);
|
||||||
|
const evidenceLedger: AuditEvidenceLedgerEntry[] = [];
|
||||||
|
|
||||||
const companyContext: string[] = [];
|
const companyContext: string[] = [];
|
||||||
const checkedPages: string[] = [];
|
const checkedPages: string[] = [];
|
||||||
@@ -515,6 +662,22 @@ export function buildAuditEvidenceInput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
addUniqueCapped(checkedPages, label, CHECKED_PAGES_LIMIT);
|
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) {
|
if (checkedPages.length === 0 && lead.companyName) {
|
||||||
@@ -529,6 +692,44 @@ export function buildAuditEvidenceInput(
|
|||||||
const pageSpeedInputsOutput = buildPageSpeedAuditInputs(pageSpeedInputs);
|
const pageSpeedInputsOutput = buildPageSpeedAuditInputs(pageSpeedInputs);
|
||||||
const pageSpeedCustomerImplications: string[] = [];
|
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) {
|
for (const implication of pageSpeedInputsOutput.customerImplications) {
|
||||||
addUniqueCapped(
|
addUniqueCapped(
|
||||||
pageSpeedCustomerImplications,
|
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, {
|
const selectedSkills = extractSkills(skillRegistry, {
|
||||||
...signals.evidenceText,
|
...signals.evidenceText,
|
||||||
marketing: false,
|
marketing: false,
|
||||||
offer: 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 {
|
return {
|
||||||
@@ -550,6 +797,7 @@ export function buildAuditEvidenceInput(
|
|||||||
observedUxSignals: signals.ux,
|
observedUxSignals: signals.ux,
|
||||||
observedContentSignals: signals.content,
|
observedContentSignals: signals.content,
|
||||||
observedTechnicalSignals: signals.technical,
|
observedTechnicalSignals: signals.technical,
|
||||||
|
...(externalMarkdown ? { externalMarkdown } : {}),
|
||||||
screenshotReferences: screenshotReferences.map((reference) => ({
|
screenshotReferences: screenshotReferences.map((reference) => ({
|
||||||
...reference,
|
...reference,
|
||||||
width: Math.max(reference.width, 0),
|
width: Math.max(reference.width, 0),
|
||||||
@@ -561,5 +809,6 @@ export function buildAuditEvidenceInput(
|
|||||||
PAGESPEED_SIGNAL_LIMIT,
|
PAGESPEED_SIGNAL_LIMIT,
|
||||||
),
|
),
|
||||||
selectedSkills,
|
selectedSkills,
|
||||||
|
evidenceLedger,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
49
lib/ai/customer-tone-guidelines.ts
Normal file
49
lib/ai/customer-tone-guidelines.ts
Normal 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");
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { customerToneGuidelines } from "./customer-tone-guidelines";
|
||||||
|
|
||||||
const GERMAN_MARKERS = new Set([
|
const GERMAN_MARKERS = new Set([
|
||||||
"ich",
|
"ich",
|
||||||
"mich",
|
"mich",
|
||||||
@@ -31,6 +33,12 @@ const GERMAN_MARKERS = new Set([
|
|||||||
"wenn",
|
"wenn",
|
||||||
"für",
|
"für",
|
||||||
"bei",
|
"bei",
|
||||||
|
"kurz",
|
||||||
|
"kurzer",
|
||||||
|
"hinweis",
|
||||||
|
"zur",
|
||||||
|
"kontaktseite",
|
||||||
|
"webauftritt",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ENGLISH_MARKERS = new Set([
|
const ENGLISH_MARKERS = new Set([
|
||||||
@@ -68,13 +76,14 @@ const ENGLISH_MARKERS = new Set([
|
|||||||
|
|
||||||
const OBSERVATION_TOKENS = [
|
const OBSERVATION_TOKENS = [
|
||||||
/\b(mir|ich)\b[^\n]{0,80}\b(aufgefallen|festgestellt|bemerkt|beobachtet|gesehen|sichtbar)\b/i,
|
/\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,
|
/\b(ich sehe|ich habe gesehen|bei der Prüfung)\b/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
const SUGGESTION_TOKENS = [
|
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(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(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,
|
/\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,
|
/\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 = {
|
export type GermanCopyGuardIssue = {
|
||||||
field: string;
|
field: string;
|
||||||
rule: string;
|
rule: string;
|
||||||
@@ -255,6 +321,178 @@ function hasRawArtifact(value: string): boolean {
|
|||||||
return RAW_TECH_PATTERNS.some((pattern) => pattern.test(value));
|
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(
|
function validateTextField(
|
||||||
issues: GermanCopyGuardIssue[],
|
issues: GermanCopyGuardIssue[],
|
||||||
field: string,
|
field: string,
|
||||||
@@ -371,10 +609,12 @@ export function validateEmailCopy(email: EmailCopy): GermanCopyGuardResult {
|
|||||||
const issues: GermanCopyGuardIssue[] = [];
|
const issues: GermanCopyGuardIssue[] = [];
|
||||||
|
|
||||||
validateTextField(issues, "emailSubject", email.subject, { skipIfTooShort: true });
|
validateTextField(issues, "emailSubject", email.subject, { skipIfTooShort: true });
|
||||||
|
validateEmailSubjectTone(issues, email.subject);
|
||||||
validateTextField(issues, "emailBody", email.body, {
|
validateTextField(issues, "emailBody", email.body, {
|
||||||
requireIchForm: true,
|
requireIchForm: false,
|
||||||
requireObservationAndSuggestion: true,
|
requireObservationAndSuggestion: false,
|
||||||
});
|
});
|
||||||
|
validateEmailBodyTone(issues, email.body);
|
||||||
|
|
||||||
return { passed: issues.length === 0, issues };
|
return { passed: issues.length === 0, issues };
|
||||||
}
|
}
|
||||||
@@ -386,7 +626,7 @@ export function validateCallScriptCopy(script: CallScriptCopy): GermanCopyGuardR
|
|||||||
requireIchForm: true,
|
requireIchForm: true,
|
||||||
});
|
});
|
||||||
validateCallScriptText(issues, "callScript.closeLine", script.closeLine, {
|
validateCallScriptText(issues, "callScript.closeLine", script.closeLine, {
|
||||||
requireIchForm: true,
|
requireIchForm: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
script.callScript.forEach((line, index) => {
|
script.callScript.forEach((line, index) => {
|
||||||
@@ -452,13 +692,15 @@ export function validateCustomerFacingCopy(input: GermanCustomerCopy): GermanCop
|
|||||||
validateTextField(issues, "emailSubject", input.emailSubject, {
|
validateTextField(issues, "emailSubject", input.emailSubject, {
|
||||||
skipIfTooShort: true,
|
skipIfTooShort: true,
|
||||||
});
|
});
|
||||||
|
validateEmailSubjectTone(issues, input.emailSubject);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.emailBody !== undefined) {
|
if (input.emailBody !== undefined) {
|
||||||
validateTextField(issues, "emailBody", input.emailBody, {
|
validateTextField(issues, "emailBody", input.emailBody, {
|
||||||
requireIchForm: true,
|
requireIchForm: false,
|
||||||
requireObservationAndSuggestion: true,
|
requireObservationAndSuggestion: false,
|
||||||
});
|
});
|
||||||
|
validateEmailBodyTone(issues, input.emailBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.callScript) {
|
if (input.callScript) {
|
||||||
|
|||||||
163
lib/ai/local-audit-skill-registry.ts
Normal file
163
lib/ai/local-audit-skill-registry.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,16 +1,108 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const findingItemSchema = z.object({
|
const nonEmptyTextSchema = z.string().trim().min(1);
|
||||||
|
|
||||||
|
export const legacyFindingItemSchema = z.object({
|
||||||
section: z.string(),
|
section: z.string(),
|
||||||
finding: z.string(),
|
finding: z.string(),
|
||||||
suggestion: 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({
|
export const internalFindingsSchema = z.object({
|
||||||
findings: z.array(findingItemSchema),
|
findings: z.array(findingItemSchema),
|
||||||
summary: z.string(),
|
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({
|
export const auditSummarySchema = z.object({
|
||||||
summary: z.string(),
|
summary: z.string(),
|
||||||
keyFindings: z.array(z.string()),
|
keyFindings: z.array(z.string()),
|
||||||
@@ -36,19 +128,26 @@ export const callScriptSchema = z.object({
|
|||||||
|
|
||||||
export const followUpDraftSchema = z.object({
|
export const followUpDraftSchema = z.object({
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
followInDays: z.number().int().min(0).optional(),
|
followInDays: z.number().int().min(0).nullable(),
|
||||||
goals: z.array(z.string()).optional(),
|
goals: z.array(z.string()).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const qualityReviewSchema = z.object({
|
export const qualityReviewSchema = z.object({
|
||||||
isValid: z.boolean(),
|
isValid: z.boolean(),
|
||||||
issues: z.array(z.string()),
|
issues: z.array(z.string()),
|
||||||
suggestions: 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 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 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 AuditSummary = z.infer<typeof auditSummarySchema>;
|
||||||
export type PublicAuditText = z.infer<typeof publicAuditTextSchema>;
|
export type PublicAuditText = z.infer<typeof publicAuditTextSchema>;
|
||||||
export type EmailDraft = z.infer<typeof emailDraftSchema>;
|
export type EmailDraft = z.infer<typeof emailDraftSchema>;
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export type LeadFunnelOutreach = {
|
|||||||
sendStatus?: OutreachSendStatus | null;
|
sendStatus?: OutreachSendStatus | null;
|
||||||
responseStatus?: OutreachResponseStatus | null;
|
responseStatus?: OutreachResponseStatus | null;
|
||||||
salesStatus?: OutreachSalesStatus | null;
|
salesStatus?: OutreachSalesStatus | null;
|
||||||
|
doNotContactUntil?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LeadFunnelInput = {
|
export type LeadFunnelInput = {
|
||||||
@@ -103,6 +104,7 @@ export type LeadFunnelInput = {
|
|||||||
contactPerson?: string | null;
|
contactPerson?: string | null;
|
||||||
websiteDomain?: string | null;
|
websiteDomain?: string | null;
|
||||||
outreach?: LeadFunnelOutreach | null;
|
outreach?: LeadFunnelOutreach | null;
|
||||||
|
now?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LeadFunnelCard = {
|
export type LeadFunnelCard = {
|
||||||
@@ -303,6 +305,14 @@ function getLeadNextAction(lead: LeadFunnelInput): string {
|
|||||||
const stageId = getLeadFunnelStageId(lead);
|
const stageId = getLeadFunnelStageId(lead);
|
||||||
|
|
||||||
if (stageId === "deferred") {
|
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";
|
return "Zurückstellung prüfen";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
233
lib/external-audit-services.ts
Normal file
233
lib/external-audit-services.ts
Normal 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}`;
|
||||||
|
}
|
||||||
90
lib/operational-readiness.ts
Normal file
90
lib/operational-readiness.ts
Normal 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",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
89
lib/outreach-follow-up.ts
Normal file
89
lib/outreach-follow-up.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type {
|
||||||
|
OutreachResponseStatus,
|
||||||
|
OutreachSalesStatus,
|
||||||
|
OutreachSendStatus,
|
||||||
|
} from "./dashboard-model";
|
||||||
|
|
||||||
|
export const FOLLOW_UP_DUE_DELAY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
export const DO_NOT_CONTACT_RECHECK_MS = 365 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type FollowUpPromptState = "not_ready" | "pending" | "due" | "suppressed";
|
||||||
|
|
||||||
|
export const manualSalesStatusLabels: Record<OutreachSalesStatus, string> = {
|
||||||
|
follow_up_planned: "Follow-up geplant",
|
||||||
|
follow_up_sent: "Follow-up gesendet",
|
||||||
|
reply_received: "Antwort erhalten",
|
||||||
|
not_interested: "Kein Interesse",
|
||||||
|
later: "Später wieder melden",
|
||||||
|
meeting_scheduled: "Gespräch vereinbart",
|
||||||
|
proposal_requested: "Angebot angefragt",
|
||||||
|
proposal_sent: "Angebot gesendet",
|
||||||
|
won: "Auftrag gewonnen",
|
||||||
|
lost: "Auftrag verloren",
|
||||||
|
do_not_pursue: "Nicht weiter verfolgen",
|
||||||
|
};
|
||||||
|
|
||||||
|
const suppressingSalesStatuses = new Set<OutreachSalesStatus>([
|
||||||
|
"reply_received",
|
||||||
|
"not_interested",
|
||||||
|
"do_not_pursue",
|
||||||
|
"follow_up_sent",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const suppressingResponseStatuses = new Set<OutreachResponseStatus>([
|
||||||
|
"manual_reply_recorded",
|
||||||
|
"no_interest",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function getManualSalesStatusLabel(status: OutreachSalesStatus) {
|
||||||
|
return manualSalesStatusLabels[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCreateFollowUpDraftAfterSend(input: {
|
||||||
|
existingFollowUpOutreachCount: number;
|
||||||
|
followUpDraft?: string | null;
|
||||||
|
salesStatus: OutreachSalesStatus;
|
||||||
|
sendStatus: OutreachSendStatus;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
input.sendStatus === "sent" &&
|
||||||
|
input.salesStatus === "follow_up_planned" &&
|
||||||
|
input.existingFollowUpOutreachCount === 0 &&
|
||||||
|
Boolean(input.followUpDraft?.trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFollowUpPromptState(input: {
|
||||||
|
followUpDueAt?: number | null;
|
||||||
|
responseStatus: OutreachResponseStatus;
|
||||||
|
salesStatus: OutreachSalesStatus;
|
||||||
|
now: number;
|
||||||
|
}): FollowUpPromptState {
|
||||||
|
if (
|
||||||
|
suppressingSalesStatuses.has(input.salesStatus) ||
|
||||||
|
suppressingResponseStatuses.has(input.responseStatus)
|
||||||
|
) {
|
||||||
|
return "suppressed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input.followUpDueAt !== "number") {
|
||||||
|
return "not_ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.now >= input.followUpDueAt ? "due" : "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDoNotContactRecheckState(input: {
|
||||||
|
doNotContactUntil?: number | null;
|
||||||
|
now: number;
|
||||||
|
}) {
|
||||||
|
if (typeof input.doNotContactUntil !== "number") {
|
||||||
|
return { status: "none" as const, label: "Offen" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.now >= input.doNotContactUntil) {
|
||||||
|
return { status: "recheck" as const, label: "Erneut prüfen" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "blocked" as const, label: "Nicht erneut kontaktieren" };
|
||||||
|
}
|
||||||
298
lib/rybbit-analytics.ts
Normal file
298
lib/rybbit-analytics.ts
Normal 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([]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,20 +13,27 @@ export const SKILL_CATEGORIES = [
|
|||||||
export type SkillCategory = (typeof SKILL_CATEGORIES)[number];
|
export type SkillCategory = (typeof SKILL_CATEGORIES)[number];
|
||||||
|
|
||||||
export type SkillRegistryEntry = {
|
export type SkillRegistryEntry = {
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
title?: string;
|
||||||
purpose: string;
|
purpose: string;
|
||||||
whenToUse: string;
|
whenToUse: string;
|
||||||
whenNotToUse: string;
|
whenNotToUse: string;
|
||||||
requiredInput: string;
|
requiredInput: string;
|
||||||
expectedOutput: string;
|
expectedOutput: string;
|
||||||
category: SkillCategory;
|
category?: SkillCategory;
|
||||||
|
appliesWhen?: string;
|
||||||
|
inputs?: string[];
|
||||||
|
outputs?: string;
|
||||||
|
instructions?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuditUsedSkill = {
|
export type AuditUsedSkill = {
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
category: SkillCategory;
|
category?: SkillCategory;
|
||||||
version?: string;
|
version?: string;
|
||||||
source?: 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 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 {
|
function normalizeCategory(value: string): SkillCategory {
|
||||||
const normalized = value.toLowerCase();
|
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[] {
|
export function parseSkillsRegistry(source: string): SkillRegistryEntry[] {
|
||||||
const normalized = source.replace(/\r\n/g, "\n");
|
const normalized = source.replace(/\r\n/g, "\n");
|
||||||
const rawSections = normalized
|
const rawSections = normalized
|
||||||
@@ -138,6 +248,45 @@ export function parseSkillsRegistry(source: string): SkillRegistryEntry[] {
|
|||||||
|
|
||||||
const entries: SkillRegistryEntry[] = [];
|
const entries: SkillRegistryEntry[] = [];
|
||||||
const names = new Set<string>();
|
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) {
|
for (let index = 0; index < rawSections.length; index += 1) {
|
||||||
const rawSection = rawSections[index];
|
const rawSection = rawSections[index];
|
||||||
@@ -146,16 +295,10 @@ export function parseSkillsRegistry(source: string): SkillRegistryEntry[] {
|
|||||||
.map((line) => line.trimEnd())
|
.map((line) => line.trimEnd())
|
||||||
.filter((line, lineIndex) => line.length > 0 || lineIndex === 0);
|
.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 parsed = parseSection(sectionLines, index + 1);
|
||||||
|
addParsedEntry(entries, names, ids, parsed);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
@@ -169,10 +312,24 @@ export async function loadSkillsRegistry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toAuditUsedSkill(skill: SkillRegistryEntry): AuditUsedSkill {
|
export function toAuditUsedSkill(skill: SkillRegistryEntry): AuditUsedSkill {
|
||||||
return {
|
const usedSkill: AuditUsedSkill = {
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
category: skill.category,
|
|
||||||
version: skill.version,
|
version: skill.version,
|
||||||
source: skill.source,
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,19 @@ import {
|
|||||||
auditSummarySchema,
|
auditSummarySchema,
|
||||||
qualityReviewSchema,
|
qualityReviewSchema,
|
||||||
publicAuditTextSchema,
|
publicAuditTextSchema,
|
||||||
|
auditClassificationSchema,
|
||||||
internalFindingsSchema,
|
internalFindingsSchema,
|
||||||
|
auditGenerationResultSchema,
|
||||||
type CallScript,
|
type CallScript,
|
||||||
type EmailDraft,
|
type EmailDraft,
|
||||||
type EmailSubject,
|
type EmailSubject,
|
||||||
type FollowUpDraft,
|
type FollowUpDraft,
|
||||||
type AuditSummary,
|
type AuditSummary,
|
||||||
type PublicAuditText,
|
type PublicAuditText,
|
||||||
|
type AuditClassification,
|
||||||
type QualityReview,
|
type QualityReview,
|
||||||
type InternalFindings,
|
type InternalFindings,
|
||||||
|
type AuditGenerationResult,
|
||||||
} from "../lib/ai/schemas";
|
} from "../lib/ai/schemas";
|
||||||
|
|
||||||
test("internal findings schema accepts task-focused evidence", () => {
|
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");
|
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", () => {
|
test("audit summary and public text schemas remain intentionally lightweight", () => {
|
||||||
const summaryParsed = auditSummarySchema.parse({
|
const summaryParsed = auditSummarySchema.parse({
|
||||||
summary: "Kurze Zusammenfassung mit den wichtigsten Verbesserungen.",
|
summary: "Kurze Zusammenfassung mit den wichtigsten Verbesserungen.",
|
||||||
@@ -72,6 +340,7 @@ test("outreach schemas parse German customer-facing payloads", () => {
|
|||||||
isValid: true,
|
isValid: true,
|
||||||
issues: [],
|
issues: [],
|
||||||
suggestions: ["Mehr Kundennutzen konkret beschreiben."],
|
suggestions: ["Mehr Kundennutzen konkret beschreiben."],
|
||||||
|
notes: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(typeof emailDraftParsed.body, "string");
|
assert.equal(typeof emailDraftParsed.body, "string");
|
||||||
@@ -118,12 +387,52 @@ test("schema-inferred types are exported for Convex action wiring", () => {
|
|||||||
|
|
||||||
const typedFollowUp: FollowUpDraft = {
|
const typedFollowUp: FollowUpDraft = {
|
||||||
message: "Kurzes Follow-up ohne harte Floskel.",
|
message: "Kurzes Follow-up ohne harte Floskel.",
|
||||||
|
followInDays: null,
|
||||||
|
goals: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const typedQuality: QualityReview = {
|
const typedQuality: QualityReview = {
|
||||||
isValid: true,
|
isValid: true,
|
||||||
issues: [],
|
issues: [],
|
||||||
suggestions: [],
|
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);
|
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(typedCall.callScript.length, 1);
|
||||||
assert.equal(typedFollowUp.message.length > 0, true);
|
assert.equal(typedFollowUp.message.length > 0, true);
|
||||||
assert.equal(typedQuality.isValid, true);
|
assert.equal(typedQuality.isValid, true);
|
||||||
|
assert.equal(typedAuditGeneration.usedSkills.length, 1);
|
||||||
|
assert.equal(typedClassification.findings.length, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
82
tests/analytics-source.test.ts
Normal file
82
tests/analytics-source.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
buildAuditEvidenceInput,
|
buildAuditEvidenceInput,
|
||||||
type SkillRegistryEntryEvidence,
|
type SkillRegistryEntryEvidence,
|
||||||
} from "../lib/ai/audit-evidence";
|
} 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[] = [
|
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", () => {
|
test("buildAuditEvidenceInput converts PageSpeed implications into sanitized customer-facing text", () => {
|
||||||
const actual = buildAuditEvidenceInput({
|
const actual = buildAuditEvidenceInput({
|
||||||
pageSpeedInputs: [
|
pageSpeedInputs: [
|
||||||
@@ -335,3 +455,153 @@ test("buildAuditEvidenceInput selects deterministic skills and supports design/u
|
|||||||
assert.equal(selectedCategories.has(category), true);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import test from "node:test";
|
|||||||
|
|
||||||
const actionPath = path.join(process.cwd(), "convex", "auditGenerationAction.ts");
|
const actionPath = path.join(process.cwd(), "convex", "auditGenerationAction.ts");
|
||||||
const actionSource = existsSync(actionPath) ? readFileSync(actionPath, "utf8") : "";
|
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 generationSourcePath = path.join(process.cwd(), "convex", "auditGeneration.ts");
|
||||||
const generationSource = existsSync(generationSourcePath)
|
const generationSource = existsSync(generationSourcePath)
|
||||||
? readFileSync(generationSourcePath, "utf8")
|
? 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", () => {
|
test("auditGenerationAction module exists and is a Node action file", () => {
|
||||||
assert.equal(existsSync(actionPath), true, "auditGenerationAction.ts should exist");
|
assert.equal(existsSync(actionPath), true, "auditGenerationAction.ts should exist");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -96,6 +138,12 @@ test("action starts, queries evidence, and runs stage pipeline", () => {
|
|||||||
test("action includes all required audit stages", () => {
|
test("action includes all required audit stages", () => {
|
||||||
for (const stage of [
|
for (const stage of [
|
||||||
"classification",
|
"classification",
|
||||||
|
"localSeoSpecialist",
|
||||||
|
"conversionUxSpecialist",
|
||||||
|
"visualTrustSpecialist",
|
||||||
|
"critiqueSpecialist",
|
||||||
|
"performanceAccessibilitySpecialist",
|
||||||
|
"evidenceVerifier",
|
||||||
"multimodalAudit",
|
"multimodalAudit",
|
||||||
"germanCopy",
|
"germanCopy",
|
||||||
"qualityReview",
|
"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", () => {
|
test("action handles post-start failure paths in action-level catch", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hasPattern(
|
hasPattern(
|
||||||
@@ -130,7 +331,7 @@ test("action handles post-start failure paths in action-level catch", () => {
|
|||||||
|
|
||||||
test("action calls generateObject with required schemas", () => {
|
test("action calls generateObject with required schemas", () => {
|
||||||
const requiredSchemas = [
|
const requiredSchemas = [
|
||||||
"internalFindingsSchema",
|
"auditClassificationSchema",
|
||||||
"auditSummarySchema",
|
"auditSummarySchema",
|
||||||
"publicAuditTextSchema",
|
"publicAuditTextSchema",
|
||||||
"emailDraftSchema",
|
"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", () => {
|
test("action uses multimodal file parts with mediaType image/* when screenshots are available", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hasPattern(
|
hasPattern(
|
||||||
@@ -190,14 +545,23 @@ test("action runs german copy guard and blocks outreach-ready on validation fail
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
hasPattern(
|
hasPattern(
|
||||||
actionSource,
|
actionSource,
|
||||||
/guardResult\.passed|qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
|
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
|
||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
|
"Model QA and deterministic German copy guard failures should hard-block the audit run.",
|
||||||
);
|
);
|
||||||
assert.equal(
|
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,
|
true,
|
||||||
"Action should patch lead via api.leads.reviewUpdate",
|
"Action should patch lead via internal.leads.reviewUpdateInternal",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hasPattern(
|
hasPattern(
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ test("auditGeneration module exports required mutation contracts", () => {
|
|||||||
"queueLeadAuditGeneration",
|
"queueLeadAuditGeneration",
|
||||||
"startAuditGenerationRun",
|
"startAuditGenerationRun",
|
||||||
"persistAuditGenerationResult",
|
"persistAuditGenerationResult",
|
||||||
|
"replaceAuditFindings",
|
||||||
"finishAuditGenerationRun",
|
"finishAuditGenerationRun",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -113,6 +114,7 @@ test("auditGeneration module registers internalMutation contracts", () => {
|
|||||||
"queueLeadAuditGeneration",
|
"queueLeadAuditGeneration",
|
||||||
"startAuditGenerationRun",
|
"startAuditGenerationRun",
|
||||||
"persistAuditGenerationResult",
|
"persistAuditGenerationResult",
|
||||||
|
"replaceAuditFindings",
|
||||||
"finishAuditGenerationRun",
|
"finishAuditGenerationRun",
|
||||||
]) {
|
]) {
|
||||||
assert.equal(
|
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", () => {
|
test("queueLeadAuditGeneration dedupes pending/running runs and schedules action", () => {
|
||||||
const queueSource = extractExportSource("queueLeadAuditGeneration");
|
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", () => {
|
test("truncateWithMarker is byte-capped and marker-safe in persistence", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hasPattern(auditGenerationSource, /const markerBytes = byteLength\(TRUNCATION_MARKER\);/),
|
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", () => {
|
test("finishAuditGenerationRun updates run status/counters/currentStep", () => {
|
||||||
const finishSource = extractExportSource("finishAuditGenerationRun");
|
const finishSource = extractExportSource("finishAuditGenerationRun");
|
||||||
|
|
||||||
|
|||||||
@@ -202,3 +202,90 @@ test("audit-generation validators are declared", () => {
|
|||||||
"auditGenerationStage should include qualityReview.",
|
"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}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
97
tests/audit-skill-registry-v3.test.ts
Normal file
97
tests/audit-skill-registry-v3.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
@@ -127,8 +127,13 @@ test("audits schema stores compact usedSkills metadata", () => {
|
|||||||
);
|
);
|
||||||
hasPattern(
|
hasPattern(
|
||||||
usedSkillsSection,
|
usedSkillsSection,
|
||||||
/category:\s*v\.string\(\)/,
|
/id:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||||
"usedSkills.category should be string.",
|
"usedSkills.id should be optional string.",
|
||||||
|
);
|
||||||
|
hasPattern(
|
||||||
|
usedSkillsSection,
|
||||||
|
/category:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||||
|
"usedSkills.category should be optional string.",
|
||||||
);
|
);
|
||||||
hasPattern(
|
hasPattern(
|
||||||
usedSkillsSection,
|
usedSkillsSection,
|
||||||
@@ -179,8 +184,8 @@ test("audits.create accepts usedSkills validator and persists metadata payloads"
|
|||||||
);
|
);
|
||||||
hasPattern(
|
hasPattern(
|
||||||
auditsSource,
|
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*\)/,
|
/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 a reusable usedSkillsValidator.",
|
"audits.ts should define reusable v3-compatible usedSkillsValidator fields.",
|
||||||
);
|
);
|
||||||
|
|
||||||
hasPattern(
|
hasPattern(
|
||||||
@@ -220,8 +225,18 @@ test("audits.getDetail returns audit + lead context with null-safe lead lookup",
|
|||||||
);
|
);
|
||||||
hasPattern(
|
hasPattern(
|
||||||
getDetailSource,
|
getDetailSource,
|
||||||
/return\s*{\s*audit,\s*lead\s*}/,
|
/return\s*{[\s\S]*audit,[\s\S]*lead,[\s\S]*sourceSummaries:[\s\S]*}/,
|
||||||
"getDetail should return { audit, lead }.",
|
"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(
|
hasPattern(
|
||||||
sourceFile.getFullText(),
|
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.",
|
"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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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");
|
const boardSource = await source("components/audits/audits-board.tsx");
|
||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -40,13 +40,13 @@ test("audits board renders compact list with convex list query and core columns"
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
boardSource,
|
boardSource,
|
||||||
/useQuery\s*\(\s*api\.audits\.list,\s*\{\s*limit:\s*100\s*\}\s*\)/,
|
/useQuery\s*\(\s*api\.audits\.listDashboardRows,\s*\{\s*limit:\s*100\s*\}\s*\)/,
|
||||||
"AuditsBoard should call api.audits.list with { limit: 100 }.",
|
"AuditsBoard should call api.audits.listDashboardRows with { limit: 100 }.",
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
boardSource,
|
boardSource,
|
||||||
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.createdAt\s*-\s*a\.createdAt\)/,
|
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.updatedAt\s*-\s*a\.updatedAt\)/,
|
||||||
"Audits should be sorted newest first.",
|
"Dashboard rows should be sorted newest first.",
|
||||||
);
|
);
|
||||||
assert.match(boardSource, /Loading|lädt|Lade/i);
|
assert.match(boardSource, /Loading|lädt|Lade/i);
|
||||||
assert.match(boardSource, /Keine Audits|keine Audits/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, /Domain/);
|
||||||
assert.match(boardSource, /Status/);
|
assert.match(boardSource, /Status/);
|
||||||
assert.match(boardSource, /Seiten/);
|
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(
|
assert.match(
|
||||||
boardSource,
|
boardSource,
|
||||||
/href=\{`\/dashboard\/audits\/\$\{audit\._id\}`\}/,
|
/href=\{row\.detailHref\}/,
|
||||||
"Each audit row should link to /dashboard/audits/{id}.",
|
"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;/,
|
/const\s+lead\s*=\s*result\?\.lead;/,
|
||||||
"AuditDetail should destructure lead from 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(
|
assert.match(
|
||||||
detailSource,
|
detailSource,
|
||||||
/leadSummary\(\s*lead\s*\)/,
|
/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 () => {
|
test("audits detail route passes id to AuditDetail via Promise params", async () => {
|
||||||
const pageSource = await source("app/dashboard/audits/[id]/page.tsx");
|
const pageSource = await source("app/dashboard/audits/[id]/page.tsx");
|
||||||
|
|
||||||
|
|||||||
181
tests/audit-specialist-schemas.test.ts
Normal file
181
tests/audit-specialist-schemas.test.ts
Normal 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);
|
||||||
|
});
|
||||||
73
tests/audits-auth-source.test.ts
Normal file
73
tests/audits-auth-source.test.ts
Normal 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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
38
tests/audits-board-layout.test.ts
Normal file
38
tests/audits-board-layout.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
124
tests/audits-dashboard-query-source.test.ts
Normal file
124
tests/audits-dashboard-query-source.test.ts
Normal 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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -23,8 +23,20 @@ test("campaign board renders campaigns as responsive cards", async () => {
|
|||||||
assert.doesNotMatch(source, /md:hidden/i);
|
assert.doesNotMatch(source, /md:hidden/i);
|
||||||
assert.doesNotMatch(source, /md:block/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, /openEditDialog\(campaign\)/);
|
||||||
assert.match(source, /toggleCampaign\(campaign\)/);
|
assert.match(source, /toggleCampaign\(campaign\)/);
|
||||||
assert.match(source, /runCampaign\(campaign\)/);
|
assert.match(source, /runCampaign\(campaign\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("campaign board surfaces recent run logs", async () => {
|
||||||
|
const source = await readFile(campaignsBoardPath, "utf8");
|
||||||
|
|
||||||
|
assert.match(source, /api\.runs\.list/);
|
||||||
|
assert.match(source, /type:\s*"campaign"/);
|
||||||
|
assert.match(source, /Aktuelle Run-Logs/);
|
||||||
|
assert.match(source, /run\.errorSummary/);
|
||||||
|
assert.match(source, /campaign_cron_skipped/);
|
||||||
|
});
|
||||||
|
|||||||
27
tests/customer-tone-guidelines.test.ts
Normal file
27
tests/customer-tone-guidelines.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
@@ -192,6 +192,27 @@ test("toLeadFunnelCard maps blocked priority to deferred stage with blocker labe
|
|||||||
assert.equal(card.nextAction, "Zurückstellung prüfen");
|
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", () => {
|
test("dashboard-model exposes stable lead label helpers for UI mapping", () => {
|
||||||
assert.deepEqual(leadPriorityOptions, [
|
assert.deepEqual(leadPriorityOptions, [
|
||||||
"high",
|
"high",
|
||||||
|
|||||||
335
tests/external-audit-pipeline-source.test.ts
Normal file
335
tests/external-audit-pipeline-source.test.ts
Normal 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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
184
tests/external-audit-services.test.ts
Normal file
184
tests/external-audit-services.test.ts
Normal 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\]$/);
|
||||||
|
});
|
||||||
@@ -9,12 +9,12 @@ import {
|
|||||||
|
|
||||||
const validPayload = {
|
const validPayload = {
|
||||||
auditSummary:
|
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:
|
auditBody:
|
||||||
"Mir ist aufgefallen, dass die Kontaktseite nur am Ende der Startseite eingebettet ist. Ich empfehle, sie im Kopfbereich direkt zu platzieren.",
|
"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:
|
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: {
|
callScript: {
|
||||||
openingLine: "Hallo, ich bin Matthias von der Webberatung.",
|
openingLine: "Hallo, ich bin Matthias von der Webberatung.",
|
||||||
callScript: [
|
callScript: [
|
||||||
@@ -34,6 +34,18 @@ test("validateCustomerFacingCopy passes clean German outreach and audit copy", (
|
|||||||
assert.equal(result.issues.length, 0);
|
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", () => {
|
test("validateCustomerFacingCopy rejects likely non-German copy and reports language", () => {
|
||||||
const result = validateCustomerFacingCopy({
|
const result = validateCustomerFacingCopy({
|
||||||
...validPayload,
|
...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({
|
const result = validateCustomerFacingCopy({
|
||||||
...validPayload,
|
...validPayload,
|
||||||
auditBody:
|
auditBody:
|
||||||
"Ihre Seite hat eine gute Struktur. Der Kontaktbereich sollte klarer werden.",
|
"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.",
|
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", () => {
|
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({
|
const result = validateCustomerFacingCopy({
|
||||||
...validPayload,
|
...validPayload,
|
||||||
emailBody:
|
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);
|
assert.equal(result.passed, false);
|
||||||
@@ -198,7 +230,81 @@ test("validateCustomerFacingCopy enforces observation + suggestion style", () =>
|
|||||||
result.issues.some(
|
result.issues.some(
|
||||||
(issue) =>
|
(issue) =>
|
||||||
issue.field === "emailBody" &&
|
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,
|
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.",
|
"Ich habe gesehen, dass eure Kontaktseite am 12.02.2026 aktualisiert wurde. Ich empfehle, den Kontaktbereich als Nächstes im Header zu verbessern.",
|
||||||
auditBody:
|
auditBody:
|
||||||
"Mir ist aufgefallen, dass die Telefonnummer 0201 123456 in der Fußzeile steht. Ich empfehle, sie zusätzlich im Header zu platzieren.",
|
"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:
|
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: {
|
callScript: {
|
||||||
openingLine:
|
openingLine:
|
||||||
"Hallo, ich bin Matthias und ich habe eure Seite geprüft.",
|
"Hallo, ich bin Matthias und ich habe eure Seite geprüft.",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const leadsReviewPath = join(
|
|||||||
"leads-review-table.tsx",
|
"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");
|
const source = await readFile(leadsReviewPath, "utf8");
|
||||||
|
|
||||||
assert.doesNotMatch(source, /<table\b/i);
|
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, /<th\b/i);
|
||||||
assert.doesNotMatch(source, /min-w-\[/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, /Mehr anzeigen/);
|
||||||
assert.match(source, /Weniger anzeigen/);
|
|
||||||
assert.match(source, /aria-expanded=\{[^}]+\}/);
|
|
||||||
assert.match(source, /aria-controls=\{[^}]+\}/);
|
|
||||||
assert.match(source, /id=\{[^}]+\}/);
|
assert.match(source, /id=\{[^}]+\}/);
|
||||||
assert.match(
|
assert.doesNotMatch(source, /Weniger anzeigen/);
|
||||||
source,
|
assert.doesNotMatch(source, /aria-expanded=\{[^}]+\}/);
|
||||||
/aria-expanded=\{[^}]+\}[\s\S]{0,160}aria-controls=\{[^}]+\}[\s\S]{0,160}(Mehr anzeigen|Weniger anzeigen)/i,
|
assert.doesNotMatch(source, /aria-controls=\{[^}]+\}/);
|
||||||
);
|
assert.doesNotMatch(source, /hidden=\{!?isExpanded\}/);
|
||||||
assert.match(
|
|
||||||
source,
|
|
||||||
/hidden=\{!?isExpanded\}/,
|
|
||||||
);
|
|
||||||
|
|
||||||
const companyNameMatch = source.match(
|
const companyNameMatch = source.match(
|
||||||
/<p className="([^"]+)">\s*\{lead\.companyName\}\s*<\/p>/,
|
/<p className="([^"]+)"[^>]*>\s*\{lead\.companyName\}\s*<\/p>/,
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
companyNameMatch !== null &&
|
companyNameMatch !== null &&
|
||||||
@@ -110,3 +110,15 @@ test("LeadsReviewTable uses compact card summaries with expandable review detail
|
|||||||
assert.match(source, /Sperren/);
|
assert.match(source, /Sperren/);
|
||||||
assert.match(source, /Speichern/);
|
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"/);
|
||||||
|
});
|
||||||
|
|||||||
195
tests/leads-runs-auth-source.test.ts
Normal file
195
tests/leads-runs-auth-source.test.ts
Normal 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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
81
tests/operational-readiness.test.ts
Normal file
81
tests/operational-readiness.test.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
70
tests/ops-quality-source.test.ts
Normal file
70
tests/ops-quality-source.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
43
tests/outreach-follow-up-source.test.ts
Normal file
43
tests/outreach-follow-up-source.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const outreachSource = readFileSync(
|
||||||
|
join(process.cwd(), "convex", "outreach.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const schemaSource = readFileSync(
|
||||||
|
join(process.cwd(), "convex", "schema.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("outreach schema stores follow-up due and do-not-contact recheck dates as optional migration-safe fields", () => {
|
||||||
|
assert.match(schemaSource, /followUpDueAt:\s*v\.optional\(v\.number\(\)\)/);
|
||||||
|
assert.match(schemaSource, /parentOutreachId:\s*v\.optional\(v\.id\("outreachRecords"\)\)/);
|
||||||
|
assert.match(schemaSource, /doNotContactUntil:\s*v\.optional\(v\.number\(\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("successful initial sends create one follow-up draft for manual approval", () => {
|
||||||
|
assert.match(outreachSource, /createFollowUpDraftAfterInitialSend/);
|
||||||
|
assert.match(outreachSource, /followUpDueAt:\s*sentAt\s*\+\s*FOLLOW_UP_DUE_DELAY_MS/);
|
||||||
|
assert.match(outreachSource, /approvalStatus:\s*"draft"[\s\S]*sendStatus:\s*"not_sent"/);
|
||||||
|
assert.match(outreachSource, /parentOutreachId:\s*outreach\._id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manual sales status mutation updates lead suppression states", () => {
|
||||||
|
assert.match(outreachSource, /export const updateManualSalesStatus = mutation/);
|
||||||
|
assert.match(outreachSource, /salesStatus:\s*manualSalesStatus/);
|
||||||
|
assert.match(outreachSource, /reply_received[\s\S]*leadPatch\.contactStatus\s*=\s*"replied"/);
|
||||||
|
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/);
|
||||||
|
});
|
||||||
94
tests/outreach-follow-up.test.ts
Normal file
94
tests/outreach-follow-up.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DO_NOT_CONTACT_RECHECK_MS,
|
||||||
|
FOLLOW_UP_DUE_DELAY_MS,
|
||||||
|
getManualSalesStatusLabel,
|
||||||
|
getFollowUpPromptState,
|
||||||
|
getDoNotContactRecheckState,
|
||||||
|
shouldCreateFollowUpDraftAfterSend,
|
||||||
|
} from "../lib/outreach-follow-up";
|
||||||
|
|
||||||
|
test("manual sales statuses expose the German MVP labels", () => {
|
||||||
|
assert.equal(getManualSalesStatusLabel("reply_received"), "Antwort erhalten");
|
||||||
|
assert.equal(getManualSalesStatusLabel("not_interested"), "Kein Interesse");
|
||||||
|
assert.equal(getManualSalesStatusLabel("later"), "Später wieder melden");
|
||||||
|
assert.equal(getManualSalesStatusLabel("meeting_scheduled"), "Gespräch vereinbart");
|
||||||
|
assert.equal(getManualSalesStatusLabel("proposal_requested"), "Angebot angefragt");
|
||||||
|
assert.equal(getManualSalesStatusLabel("proposal_sent"), "Angebot gesendet");
|
||||||
|
assert.equal(getManualSalesStatusLabel("won"), "Auftrag gewonnen");
|
||||||
|
assert.equal(getManualSalesStatusLabel("lost"), "Auftrag verloren");
|
||||||
|
assert.equal(getManualSalesStatusLabel("do_not_pursue"), "Nicht weiter verfolgen");
|
||||||
|
assert.equal(getManualSalesStatusLabel("follow_up_planned"), "Follow-up geplant");
|
||||||
|
assert.equal(getManualSalesStatusLabel("follow_up_sent"), "Follow-up gesendet");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("initial send creates exactly one pending follow-up window", () => {
|
||||||
|
const sentAt = Date.UTC(2026, 5, 5);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldCreateFollowUpDraftAfterSend({
|
||||||
|
existingFollowUpOutreachCount: 0,
|
||||||
|
followUpDraft: "Kurze Nachfrage",
|
||||||
|
salesStatus: "follow_up_planned",
|
||||||
|
sendStatus: "sent",
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getFollowUpPromptState({
|
||||||
|
followUpDueAt: sentAt + FOLLOW_UP_DUE_DELAY_MS,
|
||||||
|
responseStatus: "none",
|
||||||
|
salesStatus: "follow_up_planned",
|
||||||
|
now: sentAt + FOLLOW_UP_DUE_DELAY_MS,
|
||||||
|
}),
|
||||||
|
"due",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("answers and no-interest statuses suppress pending follow-up prompts", () => {
|
||||||
|
const dueAt = Date.UTC(2026, 5, 12);
|
||||||
|
|
||||||
|
for (const salesStatus of ["reply_received", "not_interested"] as const) {
|
||||||
|
assert.equal(
|
||||||
|
getFollowUpPromptState({
|
||||||
|
followUpDueAt: dueAt,
|
||||||
|
responseStatus: "none",
|
||||||
|
salesStatus,
|
||||||
|
now: dueAt + 1,
|
||||||
|
}),
|
||||||
|
"suppressed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
getFollowUpPromptState({
|
||||||
|
followUpDueAt: dueAt,
|
||||||
|
responseStatus: "manual_reply_recorded",
|
||||||
|
salesStatus: "follow_up_planned",
|
||||||
|
now: dueAt + 1,
|
||||||
|
}),
|
||||||
|
"suppressed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("do-not-contact blocks outreach for twelve months before recheck", () => {
|
||||||
|
const markedAt = Date.UTC(2026, 0, 1);
|
||||||
|
const recheckAt = markedAt + DO_NOT_CONTACT_RECHECK_MS;
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
getDoNotContactRecheckState({
|
||||||
|
doNotContactUntil: recheckAt,
|
||||||
|
now: recheckAt - 1,
|
||||||
|
}),
|
||||||
|
{ status: "blocked", label: "Nicht erneut kontaktieren" },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getDoNotContactRecheckState({
|
||||||
|
doNotContactUntil: recheckAt,
|
||||||
|
now: recheckAt,
|
||||||
|
}),
|
||||||
|
{ status: "recheck", label: "Erneut prüfen" },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -83,6 +83,21 @@ test("OutreachReviewWorkspace uses the review workspace API and required control
|
|||||||
].forEach((label) => assert.match(source, new RegExp(label)));
|
].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 () => {
|
test("OutreachReviewWorkspace keeps exactly one recommended email subject and body editor", async () => {
|
||||||
const source = await readFile(outreachWorkspacePath, "utf8");
|
const source = await readFile(outreachWorkspacePath, "utf8");
|
||||||
|
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ test("pageSpeedAction stores and persists results and writes events", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(
|
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,
|
actionSource,
|
||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
@@ -283,7 +283,7 @@ test("pageSpeedAction does not expose API key in event messages/details", () =>
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
hasPattern(
|
hasPattern(
|
||||||
actionSource,
|
actionSource,
|
||||||
/api\.runs\.appendEvent[\s\S]{0,500}PAGESPEED_API_KEY/,
|
/internal\.runs\.appendEventInternal[\s\S]{0,500}PAGESPEED_API_KEY/,
|
||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
"Action events should not include raw PAGESPEED_API_KEY",
|
"Action events should not include raw PAGESPEED_API_KEY",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user