Compare commits
12 Commits
70951789d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ff4c572157 | |||
| 21c7e4c9a4 | |||
| f00c5a3193 | |||
| 1695110e0a | |||
| ff18fc202e | |||
| a45b92ea0a | |||
| 470fb0f348 | |||
| e9463e8ef2 | |||
| 3efbc06e40 | |||
| f069b74b08 | |||
| d3928d61c4 | |||
| df8ca1f049 |
@@ -96,7 +96,7 @@ export default defineSchema({
|
||||
userId: v.string(),
|
||||
message: v.string(),
|
||||
read: v.boolean(),
|
||||
}).index("by_user", ["userId"]),
|
||||
}).index("by_user_read", ["userId", "read"]),
|
||||
});
|
||||
```
|
||||
|
||||
@@ -131,8 +131,9 @@ export const listUnread = query({
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query("notifications")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.filter((q) => q.eq(q.field("read"), false))
|
||||
.withIndex("by_user_read", (q) =>
|
||||
q.eq("userId", args.userId).eq("read", false),
|
||||
)
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
@@ -208,6 +209,8 @@ Note the reference path shape: a function in
|
||||
- If the component needs pagination, use `paginator` from `convex-helpers`
|
||||
instead of built-in `.paginate()`, because `.paginate()` does not work across
|
||||
the component boundary.
|
||||
- Define indexes for queried fields instead of using Convex `.filter()` after a
|
||||
database query.
|
||||
- Add `args` and `returns` validators to all public component functions, because
|
||||
the component boundary requires explicit type contracts.
|
||||
|
||||
@@ -263,14 +266,14 @@ export const sendNotification = mutation({
|
||||
```ts
|
||||
// Bad: parent app table IDs are not valid component validators
|
||||
args: {
|
||||
userId: v.id("users");
|
||||
userId: v.id("users"),
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: treat parent-owned IDs as strings at the boundary
|
||||
args: {
|
||||
userId: v.string();
|
||||
userId: v.string(),
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ for Convex schema and data migrations.
|
||||
users: defineTable({
|
||||
name: v.string(),
|
||||
role: v.optional(v.union(v.literal("user"), v.literal("admin"))),
|
||||
});
|
||||
}).index("by_role", ["role"]);
|
||||
|
||||
// Migration: backfill the field
|
||||
export const addDefaultRole = migrations.define({
|
||||
@@ -225,7 +225,7 @@ export const verifyMigration = query({
|
||||
handler: async (ctx) => {
|
||||
const remaining = await ctx.db
|
||||
.query("users")
|
||||
.filter((q) => q.eq(q.field("role"), undefined))
|
||||
.withIndex("by_role", (q) => q.eq("role", undefined))
|
||||
.take(10);
|
||||
|
||||
return {
|
||||
|
||||
21
.env.example
21
.env.example
@@ -1,8 +1,12 @@
|
||||
# App / Coolify
|
||||
APP_ENV=development
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_APP_URL=https://audit.matthias-meister-webdesign.de
|
||||
|
||||
# TASK-8 Playwright
|
||||
# Personal deployment scope
|
||||
# This repo currently targets audit.matthias-meister-webdesign.de with managed
|
||||
# server-side provider keys. SaaS BYO keys, billing, and team roles come later.
|
||||
|
||||
# Legacy TASK-8 Playwright enrichment (not required for the new external pipeline)
|
||||
TASK8_CRAWL_TIMEOUT_MS=60000
|
||||
TASK8_CRAWL_MAX_PAGES=20
|
||||
TASK8_BROWSER_ASSET_URL=
|
||||
@@ -16,9 +20,10 @@ CONVEX_DEPLOYMENT=
|
||||
NEXT_PUBLIC_CONVEX_SITE_URL=
|
||||
BETTER_AUTH_SECRET=
|
||||
|
||||
# Google APIs
|
||||
GOOGLE_GEOCODING_API_KEY=
|
||||
GOOGLE_PLACES_API_KEY=
|
||||
# Lead discovery
|
||||
LOCAL_BUSINESS_DATA_API_KEY=
|
||||
|
||||
# Audit diagnostics
|
||||
PAGESPEED_API_KEY=
|
||||
PAGESPEED_TIMEOUT_MS=60000
|
||||
|
||||
@@ -31,6 +36,12 @@ OPENROUTER_MODEL_QUALITY_REVIEW=
|
||||
OPENROUTER_APP_NAME=
|
||||
OPENROUTER_APP_URL=
|
||||
|
||||
# ScreenshotOne
|
||||
SCREENSHOTONE_API_KEY=
|
||||
|
||||
# Jina (optional fallback; no key required for current readiness)
|
||||
JINA_API_KEY=
|
||||
|
||||
# SMTP / Stalwart
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=465
|
||||
|
||||
219
.impeccable.md
Normal file
219
.impeccable.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Design Context
|
||||
|
||||
## Product
|
||||
|
||||
WebDev Pipeline is a SaaS acquisition and audit workspace for web designers, small agencies, and local marketing service providers. It helps customers find local business leads, evaluate their web presence, prepare specific mini-audits, and manage respectful outreach with human approval.
|
||||
|
||||
The product is not primarily an internal tool for Matthias Meister Webdesign. Matthias is the seed user and domain expert, but design decisions must serve external customers who will pay for a reliable, professional workflow they can trust with their own prospects.
|
||||
|
||||
## Target Audience
|
||||
|
||||
### Primary SaaS Users
|
||||
|
||||
Independent web designers, small web studios, small agencies, and local marketing providers in Germany or DACH. They sell websites, redesigns, local SEO, maintenance, or digital consulting to local businesses. They need a repeatable acquisition workflow that feels professional rather than spammy.
|
||||
|
||||
They often work alone or in small teams, so the product must reduce operational load: lead discovery, qualification, audit evidence, outreach drafting, follow-up tracking, and handoff status should be clear without requiring CRM-heavy process management.
|
||||
|
||||
### Team Buyers And Operators
|
||||
|
||||
Small agency owners, account leads, sales assistants, and freelancers delegating research/outreach preparation. They care about quality control, brand safety, clear approval gates, and whether the tool makes their agency look thoughtful rather than automated.
|
||||
|
||||
### Recipient Audience
|
||||
|
||||
The downstream recipients are local businesses. They are not dashboard users, but the SaaS must protect their dignity and trust. Public audit pages and emails created through the product must feel specific, respectful, and helpful. No public scores, scare tactics, or generic AI language.
|
||||
|
||||
## Core Use Cases
|
||||
|
||||
- Configure local campaigns by niche, postal code, radius, cadence, and daily/weekly limits.
|
||||
- Discover fewer but higher-quality local business leads.
|
||||
- Keep leads without email visible as "Kontakt fehlt" or similar action states instead of dropping them.
|
||||
- Prioritize website potential: no website, outdated site, poor mobile experience, weak contact guidance, accessibility issues, local SEO opportunity.
|
||||
- Capture evidence: screenshots, PageSpeed signals, relevant pages, text excerpts, checked links, and skill outputs.
|
||||
- Turn evidence into reviewable public audit pages for prospects.
|
||||
- Review and edit generated audit copy, email drafts, subjects, phone scripts, and follow-up drafts.
|
||||
- Require explicit human approval before publishing or sending anything.
|
||||
- Track replies, follow-ups, offers, wins, losses, blocked leads, duplicates, and do-not-contact states.
|
||||
- Give small teams enough operational visibility without becoming a full CRM.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Concrete words: trustworthy, exacting, field-tested.
|
||||
|
||||
The interface should feel like a professional agency workbench: dense, capable, calm, and evidence-led. It should reassure customers that the product will help them acquire clients without damaging their reputation.
|
||||
|
||||
It should communicate: "This system helps your agency notice real opportunities, build specific proof, and contact people carefully."
|
||||
|
||||
Tone qualities:
|
||||
|
||||
- Professional, direct, and restrained.
|
||||
- Useful before clever.
|
||||
- Confident without hype.
|
||||
- Operational but not cold.
|
||||
- Respectful toward both the SaaS customer and the business being contacted.
|
||||
- Quality-control oriented: review, approve, publish, send.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Internal-only language tied to Matthias as the sole operator.
|
||||
- Growth-hacker energy.
|
||||
- Neon AI dashboards.
|
||||
- Overfriendly startup copy.
|
||||
- CRM bloat.
|
||||
- Generic "AI-powered" emphasis.
|
||||
- Threatening scores, shame language, or urgency theater.
|
||||
- Decorative metrics that do not help the next decision.
|
||||
|
||||
## Design Direction
|
||||
|
||||
### Working Concept
|
||||
|
||||
"Agency Evidence Desk": a compact SaaS workspace where local-business opportunities become evidence-backed, human-approved outreach.
|
||||
|
||||
The product should visually combine:
|
||||
|
||||
- A consulting dossier: findings, evidence, public copy, approval history.
|
||||
- An agency production desk: queues, roles, handoffs, status, limits.
|
||||
- A calm control room: campaign health, blockers, next actions, and send safety are obvious.
|
||||
|
||||
The memorable thing should be the feeling of an agency-grade proof workflow, not another generic lead-gen dashboard.
|
||||
|
||||
### Theme
|
||||
|
||||
Light-first for everyday professional office work. It should not be sterile white. Use tinted surfaces that feel like organized dossier paper, with crisp contrast for repeated scanning.
|
||||
|
||||
Dark mode can exist for long review sessions, but it should remain serious and restrained. Avoid glowing "AI cockpit" styling.
|
||||
|
||||
### Layout Principles
|
||||
|
||||
- Prioritize "what needs review or approval now" over vanity KPIs.
|
||||
- Use dense but calm SaaS dashboards; avoid landing-page composition inside the product.
|
||||
- Prefer work queues, split panes, evidence stacks, approval rails, timelines, and status ledgers over equal card grids.
|
||||
- Make workflow hierarchy visible through structure first, color second.
|
||||
- Keep repeated records compact and scannable.
|
||||
- Surface account/team safety states: limits, blocked leads, approval status, send readiness.
|
||||
- On mobile, preserve critical review and approval actions; adapt the workflow rather than hiding it.
|
||||
|
||||
### Component Principles
|
||||
|
||||
- Cards are for records, queue items, modals, and framed tools only.
|
||||
- Avoid card-inside-card compositions.
|
||||
- Avoid generic icon-heading-text card grids.
|
||||
- Filters should be action-oriented, not database-oriented.
|
||||
- Badges should encode workflow state, risk, ownership, or approval status.
|
||||
- Primary actions should be rare and state-specific.
|
||||
- Publishing and sending actions must look consequential, not casual.
|
||||
- Use icons for familiar tools and statuses; pair icon plus text when the consequence matters.
|
||||
|
||||
## Visual Language
|
||||
|
||||
### Palette Direction
|
||||
|
||||
Use OKLCH. The base should be a muted, professional operational palette:
|
||||
|
||||
- Deep green/teal for approved, safe-to-proceed, and primary operator action.
|
||||
- Ochre or paper-yellow for manual review, attention, and pending approval.
|
||||
- Muted blue for evidence, source material, screenshots, audit artifacts, and analytics.
|
||||
- Restrained red only for failed, blocked, destructive, compliance-sensitive, or do-not-contact states.
|
||||
- Tinted neutrals toward the brand hue; avoid plain grayscale.
|
||||
|
||||
The UI must not read as one-note green or default gray. It needs distinguishable semantic families for lead, audit, evidence, review, outreach, blocked, running, and manual-required states.
|
||||
|
||||
### Typography Direction
|
||||
|
||||
Brand words: trustworthy, exacting, field-tested.
|
||||
|
||||
Reflex fonts rejected for this project: Inter, IBM Plex, Space Grotesk, DM Sans, Instrument Sans, Newsreader, Fraunces, Playfair.
|
||||
|
||||
For future font exploration, prefer fonts that feel like professional labels, consulting dossiers, municipal forms, and precise tool interfaces rather than generic SaaS. Candidates to evaluate before implementation:
|
||||
|
||||
- Atkinson Hyperlegible Next for readable dense body UI.
|
||||
- Commissioner for firm, constructed headings.
|
||||
- Archivo or Familjen Grotesk for compact operational labels.
|
||||
|
||||
Do not switch fonts casually if it adds build or network risk. If using `next/font/google`, verify locally because font downloads can affect builds.
|
||||
|
||||
### Motion
|
||||
|
||||
Motion should confirm workflow state changes, not entertain. Use quick, precise transitions for selection, queue movement, publish confirmation, send confirmation, and progressive disclosure. Avoid bouncy motion and decorative reveal sequences.
|
||||
|
||||
## UX Priorities
|
||||
|
||||
### Dashboard
|
||||
|
||||
The dashboard should answer: "What needs attention across my acquisition workflow?" before "How many things exist?"
|
||||
|
||||
Preferred first screen:
|
||||
|
||||
- Review and approval queue.
|
||||
- Campaign health and blocked runs.
|
||||
- Contact missing / audit ready / review open / follow-up due buckets.
|
||||
- Account or team-level safety signals.
|
||||
- Metrics as supporting context, not hero content.
|
||||
|
||||
### Campaigns
|
||||
|
||||
Campaign UI should feel like controlled lead sourcing, not scraping. Make limits, cadence, recent run outcome, and next scheduled run highly visible. Manual start should be clear, but not framed as an uncontrolled blast action.
|
||||
|
||||
### Leads
|
||||
|
||||
Lead UI should be organized around next action:
|
||||
|
||||
- Kontakt recherchieren.
|
||||
- Audit starten.
|
||||
- Daten klären.
|
||||
- Sperren / nicht kontaktieren.
|
||||
- Zurückstellen.
|
||||
|
||||
Rows/cards should show why the action is needed and what evidence supports it, not just static company fields.
|
||||
|
||||
### Audits
|
||||
|
||||
Audit UI should foreground:
|
||||
|
||||
- Critical finding.
|
||||
- Evidence available.
|
||||
- Checked pages.
|
||||
- Screenshot status.
|
||||
- Public readiness.
|
||||
- Review owner/status.
|
||||
- Failure requiring manual intervention.
|
||||
|
||||
Avoid exposing raw PageSpeed scores externally. Internal technical signals are allowed only when they help the SaaS user decide what to say or do.
|
||||
|
||||
### Outreach
|
||||
|
||||
Outreach is the highest-trust workflow. The UI must make separation obvious:
|
||||
|
||||
- Draft generated.
|
||||
- Draft reviewed.
|
||||
- Audit published.
|
||||
- Email approved.
|
||||
- Final send confirmed.
|
||||
|
||||
Never make send feel automatic or casual. The product should protect the customer from reputational mistakes.
|
||||
|
||||
### Analytics
|
||||
|
||||
Analytics should diagnose the acquisition workflow, not create a generic metric wall. Prioritize conversion from discovered lead to contactable lead, audit-ready lead, approved outreach, sent email, reply, conversation, offer, and won client.
|
||||
|
||||
### Public Audit Pages
|
||||
|
||||
Fixed light design, quiet and respectful. No public scores, no pressure language, no overdramatic urgency. It should read like a specific observation from a competent web professional who actually looked at the site.
|
||||
|
||||
Public audit pages should make the SaaS customer look credible. The page should support a trust-building sales conversation, not feel like an automated report.
|
||||
|
||||
## Accessibility And Constraints
|
||||
|
||||
- Next.js App Router, Tailwind CSS, shadcn/ui, Convex, Better Auth.
|
||||
- Preserve existing client/server boundaries.
|
||||
- Do not change Convex hook order or mutation semantics during visual work.
|
||||
- Maintain responsive behavior across mobile, tablet, and desktop.
|
||||
- Avoid fragile visual hacks that conflict with tests around navigation, prefetch, or protected routes.
|
||||
- Public audit pages must remain noindex and calm.
|
||||
- This is SaaS-facing product UI. Avoid internal-only assumptions, single-user copy, and anything that would not scale to customer accounts or small teams.
|
||||
|
||||
## Current Design Gap
|
||||
|
||||
The current UI still reads too much like default shadcn: neutral cards, neutral muted labels, equal-weight badges, and generic dashboard spacing. The next craft pass should make larger structural changes: queue-first dashboard, split review workspace, action-oriented filters, evidence-first audit cards, semantic status surfaces, and SaaS-grade account/team framing.
|
||||
|
||||
The goal is not "prettier shadcn." The goal is a recognizable agency acquisition product that makes customers feel in control of evidence, approvals, outreach safety, and pipeline quality.
|
||||
28
README.md
28
README.md
@@ -1,6 +1,8 @@
|
||||
# WebDev Pipeline
|
||||
|
||||
Interner Akquise-Agent fuer lokale Webdesign-Leads. Das MVP startet mit Next.js App Router, TypeScript, Tailwind CSS, shadcn/ui und Platzhalter-Routen fuer Dashboard, Login und oeffentliche Audit-Seiten.
|
||||
Persoenlicher Akquise-Agent fuer lokale Webdesign-Leads auf `audit.matthias-meister-webdesign.de`. Das MVP startet mit Next.js App Router, TypeScript, Tailwind CSS, shadcn/ui und Platzhalter-Routen fuer Dashboard, Login und oeffentliche Audit-Seiten.
|
||||
|
||||
Der aktuelle Scope ist bewusst persoenlich: Google, PageSpeed, OpenRouter, ScreenshotOne und optional Jina laufen ueber serverseitig verwaltete Keys. BYO-Keys, Billing und Teamrollen gehoeren zur spaeteren SaaS-Readiness, aber nicht zu dieser Welle.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -23,12 +25,13 @@ Copy `.env.example` to `.env.local` for local development. Keep real secrets out
|
||||
|
||||
- **App / Coolify:** `APP_ENV`, `NEXT_PUBLIC_APP_URL`
|
||||
- **Convex:** `NEXT_PUBLIC_CONVEX_URL`, `NEXT_PUBLIC_CONVEX_SITE_URL`, `CONVEX_DEPLOYMENT`
|
||||
- **Google / Task-9 PageSpeed:** `GOOGLE_GEOCODING_API_KEY`, `GOOGLE_PLACES_API_KEY`, `PAGESPEED_API_KEY`, `PAGESPEED_TIMEOUT_MS`
|
||||
- **Google / PageSpeed:** `GOOGLE_GEOCODING_API_KEY`, `GOOGLE_PLACES_API_KEY`, `PAGESPEED_API_KEY`, `PAGESPEED_TIMEOUT_MS`
|
||||
- **OpenRouter:** `OPENROUTER_API_KEY`, `OPENROUTER_MODEL_CLASSIFICATION`, `OPENROUTER_MODEL_MULTIMODAL_AUDIT`, `OPENROUTER_MODEL_GERMAN_COPY`, `OPENROUTER_MODEL_QUALITY_REVIEW`, optional: `OPENROUTER_APP_NAME`, `OPENROUTER_APP_URL`
|
||||
- **ScreenshotOne:** `SCREENSHOTONE_API_KEY`
|
||||
- **Jina:** optional `JINA_API_KEY` for future authenticated fallback usage; not required for current readiness.
|
||||
- **SMTP / Stalwart:** `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`
|
||||
- **Rybbit:** `RYBBIT_API_URL`, `RYBBIT_API_KEY`, `NEXT_PUBLIC_RYBBIT_SITE_ID`
|
||||
- **Auth:** `BETTER_AUTH_SECRET`
|
||||
- **TASK-8 enrichment:** `TASK8_BROWSER_ASSET_URL`
|
||||
|
||||
Only variables prefixed with `NEXT_PUBLIC_` are intended for browser exposure. All API keys, SMTP credentials, and server-only URLs must stay server-side.
|
||||
|
||||
@@ -50,24 +53,11 @@ Only variables prefixed with `NEXT_PUBLIC_` are intended for browser exposure. A
|
||||
|
||||
Coolify should run `pnpm install`, `pnpm build`, and `pnpm start`. The current font setup uses `next/font/google`, so production builds need outbound access to Google Fonts unless fonts are later self-hosted.
|
||||
|
||||
TASK-8 enrichment uses `playwright-core` with `@sparticuz/chromium-min` in Convex. Local `npx playwright install` is a browser-testing helper only and does not affect the Convex runtime bundle.
|
||||
The new audit pipeline expects managed server-side provider configuration for Google, PageSpeed, OpenRouter, ScreenshotOne, and optional Jina. Do not expose provider secrets in browser-prefixed variables.
|
||||
|
||||
TASK-8 requires a browser binary source URL configured on Convex. The preferred
|
||||
variable is:
|
||||
Playwright/TASK-8 is legacy enrichment context, not a required integration for the new external audit pipeline. Local `npx playwright install` remains a browser-testing helper only and does not affect the managed external-service readiness check.
|
||||
|
||||
- `TASK8_BROWSER_ASSET_URL` (for example your self-hosted or CDN Chromium bundle URL if you do not rely on package defaults).
|
||||
|
||||
For backward compatibility, the action also supports:
|
||||
|
||||
- `TASK8_CHROMIUM_EXECUTABLE_URL`
|
||||
- `TASK8_CHROMIUM_EXECUTABLE`
|
||||
|
||||
If none are set, enrichment deployment/startup will fail with a clear configuration
|
||||
error so no silent fallback is used.
|
||||
|
||||
If the URL is missing and no default is available in your environment, the enqueue action will throw a clear deploy/configuration error so enrichment does not silently fall back to a missing binary.
|
||||
|
||||
For TASK-8 deployment updates, run Convex restart/deploy after code changes:
|
||||
For Convex deployment updates, run restart/deploy after code changes:
|
||||
|
||||
- Local: `pnpm exec convex dev`
|
||||
- Remote: `pnpm exec convex deploy`
|
||||
|
||||
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() {
|
||||
return (
|
||||
<DashboardPlaceholderPage
|
||||
description="Kampagnenmetriken und Rybbit-Daten folgen in TASK-17 und TASK-19."
|
||||
title="Analytics"
|
||||
/>
|
||||
);
|
||||
return <AnalyticsDashboard />;
|
||||
}
|
||||
|
||||
@@ -4,35 +4,134 @@ import {
|
||||
reviewQueue,
|
||||
} from "@/lib/dashboard-model";
|
||||
import { LeadFunnelBoard } from "@/components/lead-funnel-board";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
FileSearch,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
const reviewSignals = [
|
||||
{
|
||||
label: "Evidence",
|
||||
value: "belegt",
|
||||
detail: "Audit-Aussagen brauchen Quellen, Screenshots oder geprüfte Seiten.",
|
||||
icon: FileSearch,
|
||||
surface: "evidence-surface",
|
||||
},
|
||||
{
|
||||
label: "Approval",
|
||||
value: "manuell",
|
||||
detail: "Public Audit, E-Mail und finaler Versand bleiben getrennte Schritte.",
|
||||
icon: ClipboardCheck,
|
||||
surface: "review-surface",
|
||||
},
|
||||
{
|
||||
label: "Reputation",
|
||||
value: "geschützt",
|
||||
detail: "Do-not-contact, Sperrliste und Limits sind Teil der Produktqualität.",
|
||||
icon: ShieldCheck,
|
||||
surface: "safe-surface",
|
||||
},
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<main className="px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Interner Arbeitsbereich
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
|
||||
Pipeline-Übersicht
|
||||
<div className="mx-auto flex w-full max-w-[92rem] flex-col gap-6">
|
||||
<header className="agency-panel overflow-hidden">
|
||||
<div className="grid gap-5 p-5 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,0.42fr)] lg:p-6">
|
||||
<div className="min-w-0">
|
||||
<p className="agency-kicker">SaaS Workspace · Agency Evidence Desk</p>
|
||||
<h1 className="mt-3 max-w-4xl font-heading text-4xl font-semibold tracking-normal text-foreground">
|
||||
Review, Evidence und Outreach-Sicherheit in einem Arbeitsfluss.
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Recherche, Audit-Freigabe und Outreach bleiben eng gekoppelt:
|
||||
wenige gute Leads, manuelle Prüfung, kein automatischer Versand.
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
WebDev Pipeline hilft Agenturen, lokale Chancen zu finden,
|
||||
konkrete Belege zu sammeln und Outreach erst nach sauberer
|
||||
Freigabe zu starten.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">MVP intern</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{reviewSignals.map((signal) => {
|
||||
const Icon = signal.icon;
|
||||
|
||||
return (
|
||||
<article
|
||||
className="rounded-md border border-border/70 bg-background/55 p-3"
|
||||
key={signal.label}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={`flex size-9 shrink-0 items-center justify-center rounded-md ${signal.surface}`}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold uppercase text-muted-foreground">
|
||||
{signal.label}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">{signal.value}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{signal.detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{dashboardKpis.map((kpi) => (
|
||||
<section className="grid gap-3 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||
<div className="agency-panel p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="agency-kicker">Heute prüfen</p>
|
||||
<h2 className="mt-1 font-heading text-xl font-semibold">
|
||||
Approval Queue
|
||||
</h2>
|
||||
</div>
|
||||
<span className="rounded-md bg-[var(--surface-review)] px-2.5 py-1 text-sm font-bold text-secondary-foreground">
|
||||
{reviewQueue.length} offen
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{reviewQueue.map((item) => (
|
||||
<article
|
||||
className="rounded-lg border bg-card p-4 text-card-foreground"
|
||||
key={kpi.label}
|
||||
className="rounded-md border border-border/75 bg-background/60 p-3"
|
||||
key={`${item.title}-${item.company}`}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">{kpi.label}</p>
|
||||
<p className="mt-3 text-3xl font-semibold tracking-normal">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">{item.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{item.company}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="mt-1 size-4 shrink-0 text-primary" />
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-5 text-muted-foreground">
|
||||
{item.detail}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"
|
||||
aria-label="Pipeline-Kennzahlen"
|
||||
>
|
||||
{dashboardKpis.map((kpi) => (
|
||||
<article className="agency-rail p-4" key={kpi.label}>
|
||||
<p className="text-xs font-bold uppercase text-muted-foreground">
|
||||
{kpi.label}
|
||||
</p>
|
||||
<p className="mt-3 font-heading text-3xl font-semibold tracking-normal">
|
||||
{kpi.value}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-5 text-muted-foreground">
|
||||
@@ -41,64 +140,43 @@ export default function DashboardPage() {
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<LeadFunnelBoard />
|
||||
|
||||
<section className="grid gap-3 lg:grid-cols-[1.45fr_0.55fr]">
|
||||
<div className="rounded-lg border bg-card text-card-foreground">
|
||||
<div className="border-b p-4">
|
||||
<h2 className="text-base font-semibold tracking-normal">
|
||||
Nächste Review-Schritte
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
Alles bleibt an manuelle Freigabe gekoppelt.
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{reviewQueue.map((item) => (
|
||||
<article
|
||||
className="grid gap-2 p-4 sm:grid-cols-[1fr_auto]"
|
||||
key={`${item.title}-${item.company}`}
|
||||
>
|
||||
<section className="agency-panel p-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{item.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{item.company}
|
||||
</p>
|
||||
</div>
|
||||
<p className="max-w-sm text-sm leading-6 text-muted-foreground sm:text-right">
|
||||
{item.detail}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 text-card-foreground">
|
||||
<h2 className="text-base font-semibold tracking-normal">
|
||||
Betriebsmodus
|
||||
<p className="agency-kicker">Workspace Health</p>
|
||||
<h2 className="mt-1 font-heading text-xl font-semibold">
|
||||
Safety Ledger
|
||||
</h2>
|
||||
<div className="mt-4 grid gap-3">
|
||||
</div>
|
||||
<p className="inline-flex items-center gap-2 text-sm font-semibold text-muted-foreground">
|
||||
<CheckCircle2 className="size-4 text-primary" />
|
||||
Kundenreputation bleibt geschützt
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
{pipelineHealth.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 rounded-lg border bg-background p-3"
|
||||
className="rounded-md border border-border/75 bg-background/60 p-3"
|
||||
key={item.label}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span className="inline-flex items-center gap-2 text-sm font-semibold">
|
||||
<Icon className="size-4 text-primary" />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-right text-sm text-muted-foreground">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{item.value}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
|
||||
import { OperationsReadiness } from "@/components/settings/operations-readiness";
|
||||
import { getIntegrationReadiness } from "@/lib/operational-readiness";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<DashboardPlaceholderPage
|
||||
description="Provider-Status, Secrets-Hinweise und Workspace-Einstellungen folgen mit den Integrationen."
|
||||
title="Einstellungen"
|
||||
/>
|
||||
);
|
||||
return <OperationsReadiness rows={getIntegrationReadiness(process.env)} />;
|
||||
}
|
||||
|
||||
221
app/globals.css
221
app/globals.css
@@ -7,9 +7,9 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-heading: var(--font-geist-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -49,72 +49,90 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--background: oklch(0.967 0.017 96);
|
||||
--foreground: oklch(0.175 0.033 178);
|
||||
--card: oklch(0.991 0.009 104);
|
||||
--card-foreground: oklch(0.175 0.033 178);
|
||||
--popover: oklch(0.991 0.009 104);
|
||||
--popover-foreground: oklch(0.175 0.033 178);
|
||||
--primary: oklch(0.31 0.083 177);
|
||||
--primary-foreground: oklch(0.986 0.014 104);
|
||||
--secondary: oklch(0.91 0.056 82);
|
||||
--secondary-foreground: oklch(0.255 0.052 71);
|
||||
--muted: oklch(0.917 0.023 102);
|
||||
--muted-foreground: oklch(0.42 0.04 168);
|
||||
--accent: oklch(0.61 0.111 242);
|
||||
--accent-foreground: oklch(0.982 0.012 104);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--border: oklch(0.82 0.029 104);
|
||||
--input: oklch(0.86 0.026 104);
|
||||
--ring: oklch(0.54 0.095 177);
|
||||
--chart-1: oklch(0.31 0.083 177);
|
||||
--chart-2: oklch(0.61 0.111 242);
|
||||
--chart-3: oklch(0.68 0.137 82);
|
||||
--chart-4: oklch(0.57 0.12 30);
|
||||
--chart-5: oklch(0.48 0.08 296);
|
||||
--radius: 0.45rem;
|
||||
--sidebar: oklch(0.214 0.034 181);
|
||||
--sidebar-foreground: oklch(0.94 0.02 103);
|
||||
--sidebar-primary: oklch(0.76 0.112 157);
|
||||
--sidebar-primary-foreground: oklch(0.145 0.024 185);
|
||||
--sidebar-accent: oklch(0.285 0.041 181);
|
||||
--sidebar-accent-foreground: oklch(0.96 0.018 103);
|
||||
--sidebar-border: oklch(0.94 0.018 103 / 15%);
|
||||
--sidebar-ring: oklch(0.76 0.112 157);
|
||||
--surface-rail: oklch(0.93 0.027 98);
|
||||
--surface-panel: oklch(0.984 0.012 100);
|
||||
--surface-pressed: oklch(0.885 0.035 103);
|
||||
--surface-evidence: oklch(0.93 0.034 235);
|
||||
--surface-review: oklch(0.932 0.06 82);
|
||||
--surface-safe: oklch(0.91 0.06 151);
|
||||
--warning-soft: oklch(0.932 0.06 82);
|
||||
--success-soft: oklch(0.91 0.06 151);
|
||||
--danger-soft: oklch(0.93 0.05 28);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--background: oklch(0.17 0.02 185);
|
||||
--foreground: oklch(0.948 0.016 142);
|
||||
--card: oklch(0.215 0.024 187);
|
||||
--card-foreground: oklch(0.948 0.016 142);
|
||||
--popover: oklch(0.215 0.024 187);
|
||||
--popover-foreground: oklch(0.948 0.016 142);
|
||||
--primary: oklch(0.76 0.112 157);
|
||||
--primary-foreground: oklch(0.145 0.024 185);
|
||||
--secondary: oklch(0.314 0.044 80);
|
||||
--secondary-foreground: oklch(0.94 0.04 93);
|
||||
--muted: oklch(0.27 0.023 185);
|
||||
--muted-foreground: oklch(0.735 0.028 165);
|
||||
--accent: oklch(0.77 0.121 82);
|
||||
--accent-foreground: oklch(0.17 0.02 185);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--border: oklch(0.94 0.018 160 / 13%);
|
||||
--input: oklch(0.94 0.018 160 / 17%);
|
||||
--ring: oklch(0.76 0.112 157);
|
||||
--chart-1: oklch(0.76 0.112 157);
|
||||
--chart-2: oklch(0.77 0.121 82);
|
||||
--chart-3: oklch(0.68 0.095 245);
|
||||
--chart-4: oklch(0.7 0.12 32);
|
||||
--chart-5: oklch(0.72 0.095 296);
|
||||
--sidebar: oklch(0.13 0.018 187);
|
||||
--sidebar-foreground: oklch(0.92 0.016 142);
|
||||
--sidebar-primary: oklch(0.76 0.112 157);
|
||||
--sidebar-primary-foreground: oklch(0.145 0.024 185);
|
||||
--sidebar-accent: oklch(0.24 0.028 186);
|
||||
--sidebar-accent-foreground: oklch(0.94 0.016 142);
|
||||
--sidebar-border: oklch(0.94 0.018 160 / 12%);
|
||||
--sidebar-ring: oklch(0.76 0.112 157);
|
||||
--surface-rail: oklch(0.145 0.02 187);
|
||||
--surface-panel: oklch(0.21 0.024 187);
|
||||
--surface-pressed: oklch(0.27 0.027 184);
|
||||
--surface-evidence: oklch(0.24 0.036 236);
|
||||
--surface-review: oklch(0.314 0.044 80);
|
||||
--surface-safe: oklch(0.25 0.046 151);
|
||||
--warning-soft: oklch(0.314 0.044 80);
|
||||
--success-soft: oklch(0.25 0.046 151);
|
||||
--danger-soft: oklch(0.28 0.062 28);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -123,8 +141,85 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "ss03";
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.dashboard-canvas {
|
||||
background-color: var(--background);
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in oklch, var(--foreground), transparent 93%) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in oklch, var(--foreground), transparent 95%) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
|
||||
.agency-panel {
|
||||
border: 1px solid color-mix(in oklch, var(--border), transparent 8%);
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklch, var(--card), white 22%), var(--card));
|
||||
box-shadow:
|
||||
0 1px 0 color-mix(in oklch, var(--foreground), transparent 94%),
|
||||
0 20px 48px color-mix(in oklch, var(--foreground), transparent 94%);
|
||||
}
|
||||
|
||||
.agency-rail {
|
||||
border: 1px solid color-mix(in oklch, var(--border), transparent 12%);
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(135deg, var(--surface-panel), color-mix(in oklch, var(--surface-panel), var(--surface-evidence) 22%));
|
||||
}
|
||||
|
||||
.agency-kicker {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.agency-tab {
|
||||
display: inline-flex;
|
||||
min-height: 2.25rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid color-mix(in oklch, var(--border), transparent 10%);
|
||||
background: color-mix(in oklch, var(--background), var(--card) 45%);
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted-foreground);
|
||||
transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.agency-tab:hover {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.agency-tab[aria-pressed="true"] {
|
||||
border-color: color-mix(in oklch, var(--primary), transparent 25%);
|
||||
background: color-mix(in oklch, var(--primary), var(--background) 84%);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.evidence-surface {
|
||||
background: var(--surface-evidence);
|
||||
color: color-mix(in oklch, var(--accent), var(--foreground) 55%);
|
||||
}
|
||||
|
||||
.review-surface {
|
||||
background: var(--surface-review);
|
||||
color: var(--secondary-foreground);
|
||||
}
|
||||
|
||||
.safe-surface {
|
||||
background: var(--surface-safe);
|
||||
color: color-mix(in oklch, var(--primary), var(--foreground) 42%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-13
|
||||
title: Build the audit and outreach review workspace
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-03 19:14'
|
||||
updated_date: '2026-06-05 14:21'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels:
|
||||
- mvp
|
||||
- review
|
||||
@@ -56,3 +56,9 @@ Implemented first TASK-13 prerequisite: PageSpeed completion now queues audit_ge
|
||||
|
||||
2026-06-05: Completed TASK-13 implementation subagent-driven and test-driven on branch codex-task-13-review-workspace. Worker A added authenticated Convex review workspace contracts, save/approve draft mutations, protected existing outreach create/list, audit ownership checks, sent-record protection, approval reset on regenerated copy, and combined review eligibility indexes. Worker B replaced /dashboard/outreach placeholder with the review workspace UI, editable audit/outreach drafts, raw/source toggles, used skills, phone-script gating, and save-before-approve/publish safeguards. Worker C fixed funnel regression so approved-but-unsent outreach remains in Freigabe offen. Reviews: backend spec approved, backend quality approved after fixes, UI spec approved, UI quality approved after fixes, funnel spec/quality approved, final TASK-13 spec approved. Verification passed: pnpm test (263/263), pnpm exec tsc --noEmit, pnpm lint (0 errors; existing BetterAuth generated warnings only), pnpm build with network escalation for Google Fonts.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-15
|
||||
title: Add follow-up and manual sales status tracking
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-03 19:14'
|
||||
updated_date: '2026-06-05 19:30'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels:
|
||||
- mvp
|
||||
- sales
|
||||
@@ -25,11 +25,11 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 After an initial send, a single follow-up draft and suggested due date are created
|
||||
- [ ] #2 Follow-up sending requires manual review and approval, just like the first email
|
||||
- [ ] #3 Manual statuses exist for Antwort erhalten, Kein Interesse, Später wieder melden, Gespräch vereinbart, Angebot angefragt, Angebot gesendet, Auftrag gewonnen, Auftrag verloren, Nicht weiter verfolgen, Follow-up geplant, and Follow-up gesendet
|
||||
- [ ] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts
|
||||
- [ ] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen
|
||||
- [x] #1 After an initial send, a single follow-up draft and suggested due date are created
|
||||
- [x] #2 Follow-up sending requires manual review and approval, just like the first email
|
||||
- [x] #3 Manual statuses exist for Antwort erhalten, Kein Interesse, Später wieder melden, Gespräch vereinbart, Angebot angefragt, Angebot gesendet, Auftrag gewonnen, Auftrag verloren, Nicht weiter verfolgen, Follow-up geplant, and Follow-up gesendet
|
||||
- [x] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts
|
||||
- [x] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
@@ -46,4 +46,12 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Started implementation pass for tasks 15-19 and 27. TASK-27 note says it is superseded by TASK-13, so this pass will verify the existing PageSpeed-to-audit-generation handoff rather than implement it separately.
|
||||
|
||||
Implemented and verified follow-up draft creation after send, manual approval boundaries for follow-up records, manual sales status labels/mutation, reply/no-interest suppression, and 12-month do-not-contact recheck visibility. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-16
|
||||
title: Orchestrate recurring Convex agent jobs and audit lifecycle
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-03 19:14'
|
||||
updated_date: '2026-06-05 19:30'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels:
|
||||
- mvp
|
||||
- convex
|
||||
@@ -27,11 +27,11 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Convex cron or scheduled functions trigger active campaigns according to cadence
|
||||
- [ ] #2 Jetzt ausführen starts a campaign run immediately only when no other agent run is active
|
||||
- [ ] #3 Cron skips or queues safely when an agent run is already active, with visible run logs
|
||||
- [ ] #4 Published audits older than 30 days create dashboard notifications asking whether to keep active
|
||||
- [ ] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated
|
||||
- [x] #1 Convex cron or scheduled functions trigger active campaigns according to cadence
|
||||
- [x] #2 Jetzt ausführen starts a campaign run immediately only when no other agent run is active
|
||||
- [x] #3 Cron skips or queues safely when an agent run is already active, with visible run logs
|
||||
- [x] #4 Published audits older than 30 days create dashboard notifications asking whether to keep active
|
||||
- [x] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
@@ -48,4 +48,12 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Started implementation pass for recurring Convex agent jobs, run locking, logs, and audit lifecycle.
|
||||
|
||||
Implemented and verified Convex crons, due-campaign runner, single-active-run guard, visible campaign run logs, and audit lifecycle notification/deactivation controls. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-17
|
||||
title: Add Rybbit audit analytics dashboard
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-03 19:14'
|
||||
updated_date: '2026-06-05 19:30'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels:
|
||||
- mvp
|
||||
- analytics
|
||||
@@ -25,11 +25,11 @@ Display anonymous analytics for generated public audit pages inside the internal
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes
|
||||
- [ ] #2 Dashboard can fetch Rybbit API data for pageviews, custom events, and outbound link clicks for audit pages
|
||||
- [ ] #3 Per-audit analytics show opened yes/no, view count, last view, CTA clicks, website-link clicks, and device type where available
|
||||
- [ ] #4 Campaign analytics aggregate audit opens and CTA activity by campaign, niche, region, and timeframe
|
||||
- [ ] #5 Rybbit API failures are shown gracefully and do not break the rest of the dashboard
|
||||
- [x] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes
|
||||
- [x] #2 Dashboard can fetch Rybbit API data for pageviews, custom events, and outbound link clicks for audit pages
|
||||
- [x] #3 Per-audit analytics show opened yes/no, view count, last view, CTA clicks, website-link clicks, and device type where available
|
||||
- [x] #4 Campaign analytics aggregate audit opens and CTA activity by campaign, niche, region, and timeframe
|
||||
- [x] #5 Rybbit API failures are shown gracefully and do not break the rest of the dashboard
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
@@ -46,4 +46,14 @@ Display anonymous analytics for generated public audit pages inside the internal
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Started implementation pass for Rybbit public-audit tracking and dashboard analytics surfaces.
|
||||
|
||||
Implemented public-audit-only Rybbit tracking, on-demand Rybbit API routes for audit/campaign activity, per-audit summary helper, dashboard Rybbit error handling, and campaign-level overall Rybbit signals. AC4 remains open for full grouping by campaign/niche/region/timeframe because Rybbit events still need a stronger audit-to-campaign join model. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||
|
||||
Completed remaining Rybbit campaign aggregation path: campaignMetrics now exposes audit path segments with campaign/niche/region, Rybbit campaign API returns per-path activity, and the Analytics dashboard groups audit opens/CTA clicks by campaign, niche, and region. Verification: targeted analytics tests pass.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-18
|
||||
title: Add MVP quality gates and operational polish
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-03 19:15'
|
||||
updated_date: '2026-06-05 19:30'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels:
|
||||
- mvp
|
||||
- quality
|
||||
@@ -27,11 +27,11 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Core UI text is German and organized so future i18n is feasible
|
||||
- [ ] #2 No secrets are stored in source code, dashboard-editable records, logs, prompts, or raw LLM history
|
||||
- [ ] #3 Dashboard surfaces integration errors for Google, PageSpeed, OpenRouter, Playwright, SMTP, Convex jobs, and Rybbit
|
||||
- [ ] #4 Critical user flows have basic tests or repeatable verification notes: login, campaign run, audit generation, approval, send, follow-up, analytics
|
||||
- [ ] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions
|
||||
- [x] #1 Core UI text is German and organized so future i18n is feasible
|
||||
- [x] #2 No secrets are stored in source code, dashboard-editable records, logs, prompts, or raw LLM history
|
||||
- [x] #3 Dashboard surfaces integration errors for Google, PageSpeed, OpenRouter, Playwright, SMTP, Convex jobs, and Rybbit
|
||||
- [x] #4 Critical user flows have basic tests or repeatable verification notes: login, campaign run, audit generation, approval, send, follow-up, analytics
|
||||
- [x] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
@@ -48,4 +48,12 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Started implementation pass for MVP quality gates, error observability, verification notes, and deployment readiness.
|
||||
|
||||
Implemented and verified German operational readiness surfaces, secret-safe integration status rows, verification notes for critical flows, and Coolify deployment notes. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-19
|
||||
title: Add campaign performance metrics
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-03 19:15'
|
||||
updated_date: '2026-06-05 19:30'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels:
|
||||
- mvp
|
||||
- analytics
|
||||
@@ -27,11 +27,11 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses
|
||||
- [ ] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe
|
||||
- [ ] #3 Campaign run detail shows new leads, skipped duplicates, blacklisted/skipped leads, errors, and audits generated
|
||||
- [ ] #4 Rybbit-derived audit opens and CTA clicks are shown alongside Convex sales funnel metrics
|
||||
- [ ] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard
|
||||
- [x] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses
|
||||
- [x] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe
|
||||
- [x] #3 Campaign run detail shows new leads, skipped duplicates, blacklisted/skipped leads, errors, and audits generated
|
||||
- [x] #4 Rybbit-derived audit opens and CTA clicks are shown alongside Convex sales funnel metrics
|
||||
- [x] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
@@ -48,4 +48,12 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Started implementation pass for campaign performance metrics and filters.
|
||||
|
||||
Implemented and verified lightweight campaign metrics query/dashboard, filter contract, run detail rows, and Rybbit-derived audit opens/CTA clicks alongside Convex metrics. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-21
|
||||
title: Replace oversized Convex browser runtime dependency
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-04 15:30'
|
||||
updated_date: '2026-06-04 16:41'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
@@ -43,3 +43,9 @@ Follow-up for repeated /tmp/chromium cannot execute binary file: Context7 confir
|
||||
|
||||
Follow-up for libnspr4.so runtime error: Context7 and local @sparticuz/chromium-min docs show remote pack includes al2023.tar.br, but package only auto-inflates it when AL2023 detection fires. Convex needs those shared libs without being detected. Added explicit AL2023 shared-library preparation after executablePath(): inflate CHROMIUM_PACK_PATH/al2023.tar.br and setupLambdaEnvironment(/tmp/al2023/lib) before Playwright launch. Verification passed: pnpm exec tsc -p tsconfig.json; pnpm test (109/109); pnpm lint (existing generated BetterAuth warnings only); pnpm exec convex codegen --dry-run --typecheck enable.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-22
|
||||
title: Add source assertions for Convex AL2023 Chromium lib setup
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-04 16:37'
|
||||
updated_date: '2026-06-04 16:41'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
@@ -38,3 +38,9 @@ Added source-only assertion in tests/website-enrichment-action.test.ts for AL202
|
||||
|
||||
GREEN follow-up completed: runtime action now exposes chromium-min inflate/setupLambdaEnvironment, prepares /tmp/al2023/lib after executablePath resolution and before Playwright launch, and focused/full verification passes.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-23
|
||||
title: Improve website email extraction
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-04 17:28'
|
||||
updated_date: '2026-06-04 17:34'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
@@ -33,3 +33,9 @@ Updated website-crawler extractor to support mailto query stripping/decoding, HT
|
||||
|
||||
Implemented via subagents/TDD: added RED tests for mailto query params, obfuscated email forms, footer/impressum usability, no-guessing false-positive guard, and mailto dedupe. Extractor now decodes common HTML entities, strips/decodes mailto query strings, parses [at]/(at)/punkt/dot/spaced forms with guardrails, expands footer/impressum/contact business context, and leaves TASK-7 selection unchanged. Verification passed: pnpm exec tsc -p tsconfig.json; pnpm test (114/114); pnpm lint (existing generated BetterAuth warnings only); pnpm exec convex codegen --dry-run --typecheck enable.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-24
|
||||
title: Improve crawler handling for Bock Rechtsanwaelte edge cases
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-04 18:04'
|
||||
updated_date: '2026-06-04 18:09'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
@@ -48,3 +48,9 @@ Minimal scoped fix applied in lib/website-crawler.ts: mailto business-context no
|
||||
|
||||
Reproduced Bock Impressum against captured public HTML. Extractor found 5 candidates but all were business=false because mailto anchor offsets from original HTML were checked against normalized HTML; TASK-7 therefore returned null. Added RED tests for Bock-like Impressum mailto context and email-label contactPerson behavior. Fixed mailto path to evaluate business context against original input offsets and suppress contactPerson when anchor label is the email itself. Verified captured real HTML now returns usable chemnitz@bock-rechtsanwaelte.de. Full verification passed: pnpm exec tsc -p tsconfig.json; pnpm test (116/116); pnpm lint (existing generated BetterAuth warnings only); pnpm exec convex codegen --dry-run --typecheck enable. Timeout mitigation not changed yet because timeout root cause is not identified.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-25
|
||||
title: Harden website enrichment against Convex action runtime aborts
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-05 06:59'
|
||||
updated_date: '2026-06-05 07:04'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
@@ -41,3 +41,9 @@ Website enrichment actions can be killed by Convex with a transient invalid envi
|
||||
|
||||
2026-06-05: Implemented action-level budget guard (default 120s, TASK8_ACTION_BUDGET_MS override) around Playwright import, Chromium executable resolution, AL2023 library preparation, browser launch/context creation, page crawls, internal link checks, and desktop/mobile screenshots so long work rejects inside the action catch path before Convex invalidates the runtime. Verified with targeted website-enrichment action tests, full pnpm test, TypeScript, lint, and Convex dev typecheck/deploy.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-27
|
||||
title: Trigger audit generation after PageSpeed audit
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-05 12:10'
|
||||
updated_date: '2026-06-05 19:30'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
@@ -19,10 +19,10 @@ Wire the existing AI audit generation queue into the current automated flow so c
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Successful PageSpeed audit runs queue audit generation for the lead
|
||||
- [ ] #2 Failed PageSpeed audit runs still queue audit generation when a lead was started so partial evidence can produce an audit
|
||||
- [ ] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs
|
||||
- [ ] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
|
||||
- [x] #1 Successful PageSpeed audit runs queue audit generation for the lead
|
||||
- [x] #2 Failed PageSpeed audit runs still queue audit generation when a lead was started so partial evidence can produce an audit
|
||||
- [x] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs
|
||||
- [x] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
@@ -31,4 +31,12 @@ Wire the existing AI audit generation queue into the current automated flow so c
|
||||
Created accidentally while implementing the PageSpeed-to-audit-generation handoff. Superseded by TASK-13 because the handoff is a prerequisite for the audit/outreach review workspace. Do not implement separately.
|
||||
|
||||
Started verification pass. Implementation notes say TASK-27 is superseded by TASK-13, so only regression coverage and existing handoff will be checked.
|
||||
|
||||
Verified existing PageSpeed-to-audit-generation handoff in pageSpeedAction. Successful and failure paths queue audit generation for the started lead, queue failures are warning-logged, existing queueLeadAuditGeneration dedupe remains in place, and regression source tests pass. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: TASK-29
|
||||
title: Surface audit generations on dashboard audits
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-05 20:30'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: TASK-30
|
||||
title: Externalisiere die persönliche Audit-Pipeline
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 18:44'
|
||||
updated_date: '2026-06-10 19: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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: TASK-31
|
||||
title: Require auth for usage event reads
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 20:27'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: TASK-32
|
||||
title: Wire v3 skill registry into audit generation
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 20:27'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
50
backlog/tasks/task-33 - Fix-v3-live-wiring-quality-issues.md
Normal file
50
backlog/tasks/task-33 - Fix-v3-live-wiring-quality-issues.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: TASK-33
|
||||
title: Fix v3 live wiring quality issues
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 20:41'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: TASK-34
|
||||
title: Harden v3 selection and Convex payloads
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 20:54'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: TASK-35
|
||||
title: Remove remaining undefined audit generation payloads
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 21:06'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: TASK-36
|
||||
title: Remove optional helper undefined args
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 21:15'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
50
backlog/tasks/task-37 - Prioritize-v3-local-audit-skills.md
Normal file
50
backlog/tasks/task-37 - Prioritize-v3-local-audit-skills.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: TASK-37
|
||||
title: Prioritize v3 local audit skills
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 21:30'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: TASK-38
|
||||
title: Add ScreenshotOne missing-key run warning
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 21:41'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
40
backlog/tasks/task-39 - Secure-Convex-operator-APIs.md
Normal file
40
backlog/tasks/task-39 - Secure-Convex-operator-APIs.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: TASK-39
|
||||
title: Secure Convex operator APIs
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 21:52'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: TASK-40
|
||||
title: Behebe abschliessende Lint-Blocker
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 22:10'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
id: TASK-41
|
||||
title: Repariere Convex-Typecheck fuer Usage Events
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 22:13'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: TASK-42
|
||||
title: Scope v2 Referenzdateien aus dem Typecheck
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-06 22:16'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
id: TASK-43
|
||||
title: Stabilisiere Website-Enrichment ohne Playwright-Abbruch
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-07 19:40'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
id: TASK-44
|
||||
title: Port audit pipeline fully into the MVP
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-07 21:16'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: TASK-45
|
||||
title: Show audit evidence on detail pages
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-07 21:50'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: TASK-46
|
||||
title: Add Convex specialist fan-out audit pipeline
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-08 09:04'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: TASK-47
|
||||
title: Fix evidence verifier audit generation failure
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-08 09:35'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: TASK-48
|
||||
title: Integrate impeccable critique into audit pipeline
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-08 12:02'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
49
backlog/tasks/task-49 - Improve-audit-outreach-email-tone.md
Normal file
49
backlog/tasks/task-49 - Improve-audit-outreach-email-tone.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: TASK-49
|
||||
title: Improve audit outreach email tone
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-08 19:30'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
id: TASK-50
|
||||
title: Refactor dashboard views into compact cards
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-08 19:56'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: TASK-51
|
||||
title: >-
|
||||
Replace Google Places discovery with Local Business Data and manual audit
|
||||
starts
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-10 19:49'
|
||||
updated_date: '2026-06-10 20:26'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 53000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Replace the campaign lead discovery provider with RapidAPI Local Business Data, stop automatic enrichment/audit chaining after discovery, and add an explicit user-triggered audit start from lead review.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Campaign runs use Local Business Data via LOCAL_BUSINESS_DATA_API_KEY and no longer require Google Geocoding or Places keys.
|
||||
- [x] #2 Campaign lead discovery persists direct contact emails from the provider and does not queue website enrichment.
|
||||
- [x] #3 Website enrichment no longer auto-queues PageSpeed or audit generation.
|
||||
- [x] #4 Operators can start an audit manually from a lead review row via an authenticated mutation.
|
||||
- [x] #5 Operational readiness, env docs, usage providers, source assertions, and regression tests cover the new flow.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add tests for Local Business Data adapter, schema/provider changes, and removed auto-trigger source paths
|
||||
2. Implement provider adapter and swap Convex lead discovery to single Local Business Data search
|
||||
3. Entangle manual audit start from automatic discovery/enrichment chain
|
||||
4. Add Lead Review UI action and update campaign/ops copy and env docs
|
||||
5. Run focused tests, full pnpm test, and update acceptance criteria notes
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented Local Business Data lead discovery adapter and swapped campaign runs to LOCAL_BUSINESS_DATA_API_KEY/RapidAPI search. Removed automatic website enrichment queueing from discovery and automatic PageSpeed queueing from website enrichment. Added authenticated manual pageSpeed.requestLeadAudit mutation plus Lead Review Audit starten UI with disabled reasons. Updated schema/domain/source provider fields, Source Business ID blacklist matching, ops readiness/env docs/copy, and regression tests. Verification passed: pnpm test (407/407).
|
||||
|
||||
Bug follow-up: First Local Business Data campaign test showed no emails in Lead Review. Root cause investigation found provider emails under data[].emails_and_contacts.emails while the adapter only parsed top-level email fields. Planned fix: parse nested emails and update existing duplicate leads with missing email on campaign re-run, without automatic enrichment/audit queueing.
|
||||
|
||||
Bug fix implemented: Local Business Data adapter now parses data[].emails_and_contacts.emails, normalizes and dedupes email candidates, and campaign re-runs backfill missing email fields on duplicate leads when safe. Verified focused lead-discovery tests and full pnpm test (409/409). TASK-51 remains In Progress pending user confirmation.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: TASK-52
|
||||
title: Make lead card contact fields clickable
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-10 20:49'
|
||||
updated_date: '2026-06-10 20:55'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
ordinal: 54000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Implement clickable email, phone, and website links in the dashboard leads card view so operators can contact or inspect leads directly from each card.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Lead email values render as mailto links when present while the no-email fallback stays plain text
|
||||
- [x] #2 Lead phone values render as tel links when present
|
||||
- [x] #3 Lead website/domain values render as external website links when present
|
||||
- [x] #4 Existing leads review source tests cover the clickable link behavior
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Inspect current leads card rendering and tests
|
||||
2. Add safe href helpers for email, phone, and website/domain values
|
||||
3. Render available contact fields as accessible links with existing overflow styling
|
||||
4. Extend source tests for link behavior
|
||||
5. Run focused verification
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented helper-backed contact links in components/leads/leads-review-table.tsx and extended tests/leads-review-table.test.ts source assertions.
|
||||
|
||||
Verified with pnpm test: TypeScript test compile and 409 node tests passed.
|
||||
|
||||
Verified with pnpm lint: exit 0 with 3 existing warnings outside this change. Browser navigation to /dashboard/leads redirected to /login without an authenticated in-app browser session, so clickable DOM could not be manually exercised there.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
53
backlog/tasks/task-53 - Refine-SaaS-MVP-visual-identity.md
Normal file
53
backlog/tasks/task-53 - Refine-SaaS-MVP-visual-identity.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
id: TASK-53
|
||||
title: Refine SaaS MVP visual identity
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-11 11:29'
|
||||
updated_date: '2026-06-12 18:54'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 55000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Review the current SaaS MVP interface with impeccable design principles and an agency-style subagent critique, then improve the generic shadcn UI feel while preserving existing product workflows.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Dashboard shell and core workspace screens have a more distinctive visual language than default shadcn styling
|
||||
- [x] #2 Changes preserve existing responsive behavior and workflow semantics
|
||||
- [x] #3 Relevant automated checks pass or any remaining blockers are documented
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Review product/design context and current dashboard surfaces
|
||||
2. Run an agency-style critique across visual identity, workflow clarity, and implementation risk
|
||||
3. Apply a focused UI polish pass to reduce generic shadcn feel
|
||||
4. Verify with lint/tests and a local browser pass
|
||||
5. Leave TASK-53 in progress until user confirms manual acceptance
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Initial low-risk visual pass applied to global theme tokens, shared Card/Button/Badge primitives, dashboard shell, sidebar, dashboard overview, and lead funnel styling. Kept Convex hooks, mutations, routes, and workflow logic untouched.
|
||||
|
||||
Verification: pnpm run lint completed with 0 errors and 3 pre-existing warnings outside this UI pass. pnpm test passed 409 tests. Browser opened http://localhost:3001/dashboard; route compiled, but unauthenticated browser session hit existing Convex operator auth error on leads:listFunnel, so authenticated visual inspection remains manual.
|
||||
|
||||
User feedback: first visual pass is not visibly transformative enough. Next step: run impeccable teach by extracting explicit target audience, use cases, brand tone, design direction, and visual principles from PRD into .impeccable.md before making a bolder UI pass.
|
||||
|
||||
Created .impeccable.md via impeccable teach context setup using PRD content: target audience, workflows, brand tone, visual direction, palette/typography guidance, UX priorities, and current design gaps. This should guide the next bolder craft pass.
|
||||
|
||||
Clarified .impeccable.md for the actual SaaS repo: primary audience is external web designers, small agencies, and local marketing providers. Matthias is now framed as seed user/domain expert, not the primary product audience. Updated brand, UX priorities, visual direction, and constraints to SaaS/customer-account framing.
|
||||
|
||||
User requested a full design rebuild using skills and agents. Scope expanded from context setup/theme polish to a bolder SaaS-facing redesign of dashboard shell and core workspace surfaces guided by .impeccable.md.
|
||||
|
||||
Full redesign pass applied after agent review: rebuilt SaaS visual identity around Agency Evidence Desk tokens, dark sidebar, dashboard approval queue, evidence pipeline, safety ledger, campaign control cards, audit evidence dossier cards, outreach approval bench, lead intake, and customer-facing workspace login. Preserved Convex query/mutation semantics and protected workflow boundaries. Verification: pnpm lint passed with 0 errors and the same 3 unrelated warnings in generated/Convex files; pnpm test passed 409 tests; Browser verified http://localhost:3001/login renders Agency Evidence Desk / Workspace Login, old MVP/Admin copy removed, and no console errors. Dashboard remains auth-protected and redirects unauthenticated sessions to login as expected.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
id: TASK-54
|
||||
title: Orchestrate audits with Convex Workflow and Workpool
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-12 19:45'
|
||||
updated_date: '2026-06-13 05:56'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 56000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Replace the fragile audit scheduler chain with Convex Workflow/Workpool orchestration while keeping agentRuns as the visible product state for progress, retries, dashboard cards, and manual retry.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Audit starts create a visible agentRuns-backed card immediately in the audit dashboard
|
||||
- [x] #2 Convex Workflow and Workpool dependencies and components are registered
|
||||
- [x] #3 Audit progress exposes step, total, label, and percent in dashboard rows
|
||||
- [x] #4 Retry behavior is tracked on agentRuns and user-facing errors are hidden until final failure
|
||||
- [x] #5 Audit dashboard supports a manual retry action for final failed runs
|
||||
- [x] #6 Existing audit and outreach persistence remains compatible
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add failing source/UI tests for workflow registration, immediate audit dashboard rows, progress mapping, retry controls, and retry orchestration.
|
||||
2. Add Convex Workflow/Workpool dependencies and register components.
|
||||
3. Add agentRun orchestration/progress fields and helper mappings.
|
||||
4. Start audits through Workflow while preserving existing agentRuns as product state.
|
||||
5. Surface active audit runs, progress, retry state, and manual retry in the audit dashboard.
|
||||
6. Adjust quality-review behavior and run verification.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented Workflow/Workpool orchestration for root audit runs, progress mapping, dashboard retry UI, parallel PageSpeed strategies, and parallel German copy calls. Ran npx convex codegen and pnpm test successfully (416/416). Task remains In Progress pending user confirmation.
|
||||
|
||||
Added workflow-specific wrapper actions so Workpool retries throw on failed PageSpeed/audit-generation steps, while legacy actions remain compatible. Re-ran pnpm test successfully (416/416) after codegen.
|
||||
|
||||
User reported runtime regression: root audit workflow fails during 2/6 PageSpeed and jumps to 6/6 with PageSpeed-Analyse konnte nicht abgeschlossen werden. Investigating with systematic debugging before patching.
|
||||
|
||||
Fixed runtime regression reported after Workflow migration: the workflow marked root audit runs as running before PageSpeed, while startPageSpeedAuditRun rejected running runs and returned null before any PageSpeed work began. startPageSpeedAuditRun now accepts running root audit runs. The final workflow failure path now preserves the current failing progress step instead of forcing qualityReview/6 of 6. Added regression coverage in tests/audit-workflow-source.test.ts. Verified with pnpm test (418/418), git diff --check, and npx convex dev --once against the dev deployment.
|
||||
|
||||
Implemented LLM copy review replacement for the hard German-Copy-Guard gate. qualityReview now supports severity, rewriteRequired, revisedCopy, one automatic rewrite attempt, deterministic guard telemetry, and warning-only copy feedback. Audit/outreach persistence proceeds after copy review warnings; only technical/schema/provider/persistence failures remain fatal. Also deduped audit dashboard rows so child audit_generation runs are hidden behind visible root audit runs for the same lead. Verified with focused tests, pnpm test (420/420), git diff --check, and npx convex dev --once against the dev deployment.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-8
|
||||
title: Implement Playwright website crawling and screenshot capture
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-03 19:13'
|
||||
updated_date: '2026-06-04 18:09'
|
||||
updated_date: '2026-06-10 19:27'
|
||||
labels:
|
||||
- mvp
|
||||
- audit
|
||||
@@ -73,3 +73,9 @@ TASK-23 extractor improvement applied: website enrichment now extracts published
|
||||
|
||||
TASK-24 Bock Rechtsanwaelte follow-up: mailto candidates on real Impressum HTML were found but incorrectly marked non-business due index mismatch in context detection. Fixed mailto business-context detection and email-label contactPerson suppression; captured Bock HTML now yields usable chemnitz@bock-rechtsanwaelte.de.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed per explicit user request while switching project tracking to pitchfast.
|
||||
<!-- SECTION:FINAL_SUMMARY: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";
|
||||
|
||||
type UsedSkill = {
|
||||
id?: string;
|
||||
name: string;
|
||||
purpose?: string;
|
||||
category?: string;
|
||||
@@ -17,6 +18,12 @@ type UsedSkill = {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type SkillSummary = {
|
||||
name: string;
|
||||
purpose: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
type LeadContext = {
|
||||
_id: Id<"leads">;
|
||||
companyName?: string;
|
||||
@@ -35,12 +42,70 @@ type SkillAwareAudit = {
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
usedSkills?: UsedSkill[];
|
||||
skillSummaries?: SkillSummary[];
|
||||
internalSummary?: string | null;
|
||||
};
|
||||
|
||||
type AuditFindingEvidenceRef = {
|
||||
id: string;
|
||||
type:
|
||||
| "crawl_page"
|
||||
| "technical_check"
|
||||
| "screenshot"
|
||||
| "pagespeed"
|
||||
| "jina_excerpt"
|
||||
| "generation_stage";
|
||||
label: string;
|
||||
sourceUrl?: string;
|
||||
};
|
||||
|
||||
type AuditFinding = {
|
||||
_id: string;
|
||||
skillId: string;
|
||||
claim: string;
|
||||
recommendation: string;
|
||||
customerBenefit: string;
|
||||
severity: 1 | 2 | 3;
|
||||
confidence: number;
|
||||
evidenceRefs: AuditFindingEvidenceRef[];
|
||||
reviewStatus: "pending" | "accepted" | "rejected";
|
||||
};
|
||||
|
||||
type CheckedPageScreenshot = {
|
||||
id: Id<"_storage">;
|
||||
url: string;
|
||||
viewport: "desktop" | "mobile";
|
||||
sourceUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type CheckedPageEvidence = {
|
||||
url: string;
|
||||
sourceUrl: string | null;
|
||||
finalUrl: string | null;
|
||||
pageKind: string | null;
|
||||
title: string | null;
|
||||
metaDescription: string | null;
|
||||
headings: string[];
|
||||
visibleTextExcerpt: string | null;
|
||||
hasContactFormSignal: boolean | null;
|
||||
hasContactCtaSignal: boolean | null;
|
||||
usesHttps: boolean | null;
|
||||
missingMetaDescription: boolean | null;
|
||||
brokenInternalLinkCount: number | null;
|
||||
screenshots: CheckedPageScreenshot[];
|
||||
createdAt: number | null;
|
||||
};
|
||||
|
||||
type AuditDetailResult = {
|
||||
audit: SkillAwareAudit;
|
||||
lead: LeadContext | null;
|
||||
findings: AuditFinding[];
|
||||
sourceSummaries: {
|
||||
checkedPages: CheckedPageEvidence[];
|
||||
};
|
||||
} | null;
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
@@ -54,6 +119,55 @@ function getStatusLabel(status: SkillAwareAudit["status"]) {
|
||||
return statusText[status] ?? "Unbekannt";
|
||||
}
|
||||
|
||||
function getPageKindLabel(pageKind: string | null) {
|
||||
const labels: Record<string, string> = {
|
||||
contact: "Kontakt",
|
||||
homepage: "Startseite",
|
||||
imprint: "Impressum",
|
||||
other: "Unterseite",
|
||||
service: "Leistung",
|
||||
};
|
||||
|
||||
return pageKind ? labels[pageKind] ?? pageKind : "Geprüft";
|
||||
}
|
||||
|
||||
function signalText(value: boolean | null, positive: string, negative: string) {
|
||||
if (value === null) {
|
||||
return "Unbekannt";
|
||||
}
|
||||
|
||||
return value ? positive : negative;
|
||||
}
|
||||
|
||||
function metaSignalText(page: CheckedPageEvidence) {
|
||||
if (page.metaDescription) {
|
||||
return "Vorhanden";
|
||||
}
|
||||
|
||||
if (page.missingMetaDescription === true) {
|
||||
return "Fehlt";
|
||||
}
|
||||
|
||||
if (page.missingMetaDescription === false) {
|
||||
return "Vorhanden";
|
||||
}
|
||||
|
||||
return "Unbekannt";
|
||||
}
|
||||
|
||||
function evidenceTypeLabel(type: AuditFindingEvidenceRef["type"]) {
|
||||
const labels: Record<AuditFindingEvidenceRef["type"], string> = {
|
||||
crawl_page: "Crawl",
|
||||
technical_check: "Technik",
|
||||
screenshot: "Screenshot",
|
||||
pagespeed: "PageSpeed",
|
||||
jina_excerpt: "Reader",
|
||||
generation_stage: "KI-Stufe",
|
||||
};
|
||||
|
||||
return labels[type] ?? type;
|
||||
}
|
||||
|
||||
function leadSummary(lead: LeadContext | null | undefined) {
|
||||
if (!lead) {
|
||||
return "Kein Lead-Kontext gespeichert";
|
||||
@@ -89,7 +203,24 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
||||
const audit = result?.audit;
|
||||
const lead = result?.lead;
|
||||
|
||||
const usedSkills = useMemo(() => audit?.usedSkills ?? [], [audit]);
|
||||
const usedSkills = useMemo(() => {
|
||||
const summaries = audit?.skillSummaries ?? [];
|
||||
return (audit?.usedSkills ?? []).map((skill) => {
|
||||
const summary = summaries.find(
|
||||
(candidate) => candidate.name === skill.name || candidate.name === skill.id,
|
||||
);
|
||||
return {
|
||||
...skill,
|
||||
purpose: summary?.purpose ?? skill.purpose,
|
||||
summary: summary?.summary,
|
||||
};
|
||||
});
|
||||
}, [audit]);
|
||||
const findings = useMemo(() => result?.findings ?? [], [result]);
|
||||
const checkedPageEvidence = useMemo(
|
||||
() => result?.sourceSummaries.checkedPages ?? [],
|
||||
[result],
|
||||
);
|
||||
|
||||
if (result === null) {
|
||||
return (
|
||||
@@ -149,6 +280,139 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Geprüfte Befunde</CardTitle>
|
||||
<CardDescription>
|
||||
Verifizierte Aussagen mit konkreten Belegen aus Crawl, Screenshots und Messungen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{findings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Noch keine verifizierten Befunde gespeichert.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-3">
|
||||
{findings.map((finding) => (
|
||||
<li className="rounded-md border p-3 text-sm" key={finding._id}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{finding.claim}</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{finding.customerBenefit}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant={finding.severity === 3 ? "secondary" : "outline"}>
|
||||
Priorität {finding.severity}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{Math.round(finding.confidence * 100)}% sicher
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3">
|
||||
<span className="font-medium">Empfehlung: </span>
|
||||
{finding.recommendation}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{finding.evidenceRefs.map((ref) => (
|
||||
<Badge variant="outline" key={ref.id}>
|
||||
Quelle: {evidenceTypeLabel(ref.type)} · {ref.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Geprüfte Seiten</CardTitle>
|
||||
<CardDescription>
|
||||
Kompakte Evidence aus Website-Enrichment und Screenshot-Erfassung.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{checkedPageEvidence.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Seiten-Evidence gespeichert</p>
|
||||
) : (
|
||||
<ul className="grid gap-3">
|
||||
{checkedPageEvidence.map((page, index) => (
|
||||
<li
|
||||
className="rounded-md border p-3 text-sm"
|
||||
key={`${page.url}-${index}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="break-words font-medium">
|
||||
{page.title ?? page.finalUrl ?? page.sourceUrl ?? page.url}
|
||||
</p>
|
||||
<p className="mt-1 break-all text-xs text-muted-foreground">
|
||||
{page.finalUrl ?? page.sourceUrl ?? page.url}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{getPageKindLabel(page.pageKind)}</Badge>
|
||||
</div>
|
||||
|
||||
{page.visibleTextExcerpt ? (
|
||||
<p className="mt-3 line-clamp-3 text-sm text-muted-foreground">
|
||||
{page.visibleTextExcerpt}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant={page.missingMetaDescription === true ? "secondary" : "outline"}>
|
||||
Meta: {metaSignalText(page)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
Kontaktformular:{" "}
|
||||
{signalText(page.hasContactFormSignal, "Signal", "Kein Signal")}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
CTA: {signalText(page.hasContactCtaSignal, "Signal", "Kein Signal")}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
Interne Links: {page.brokenInternalLinkCount ?? "Unbekannt"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{page.screenshots.length > 0 ? (
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{page.screenshots.map((screenshot) => (
|
||||
<figure
|
||||
className="overflow-hidden rounded-md border bg-muted/20"
|
||||
key={`${screenshot.id}-${screenshot.viewport}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={screenshot.url}
|
||||
alt={`${screenshot.viewport === "desktop" ? "Desktop" : "Mobile"} Screenshot von ${screenshot.sourceUrl}`}
|
||||
width={screenshot.width}
|
||||
height={screenshot.height}
|
||||
className="aspect-[16/10] w-full object-cover"
|
||||
/>
|
||||
<figcaption className="flex items-center justify-between gap-2 border-t px-2 py-1 text-xs text-muted-foreground">
|
||||
<span className="font-medium">
|
||||
{screenshot.viewport === "desktop" ? "Desktop" : "Mobil"}
|
||||
</span>
|
||||
<span className="truncate">{screenshot.sourceUrl}</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verwendete Skills</CardTitle>
|
||||
@@ -168,6 +432,9 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skill.purpose ?? "Keine Zweckbeschreibung"}
|
||||
</p>
|
||||
{"summary" in skill && skill.summary ? (
|
||||
<p className="text-sm text-muted-foreground">{skill.summary}</p>
|
||||
) : null}
|
||||
<p className="mt-1 inline-flex flex-wrap items-center gap-1">
|
||||
{skill.category ? <Badge variant="outline">{skill.category}</Badge> : null}
|
||||
{skill.version ? <Badge variant="outline">{skill.version}</Badge> : null}
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { FunctionReturnType } from "convex/server";
|
||||
import { Files, SquarePen } from "lucide-react";
|
||||
import { Activity, Files, FileSearch, RotateCcw, SquarePen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
type AuditsListResult = FunctionReturnType<typeof api.audits.list>;
|
||||
type AuditRow = NonNullable<AuditsListResult>[number];
|
||||
type AuditDashboardRowsResult = FunctionReturnType<typeof api.audits.listDashboardRows>;
|
||||
type AuditRow = Extract<
|
||||
NonNullable<AuditDashboardRowsResult>[number],
|
||||
{ kind: "audit" }
|
||||
>;
|
||||
type AuditDashboardRow = NonNullable<AuditDashboardRowsResult>[number];
|
||||
type AuditStatusFilter = "all" | "audit" | "generation" | "failed";
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
draft: "Entwurf",
|
||||
@@ -23,111 +36,319 @@ const statusText: Record<string, string> = {
|
||||
|
||||
const fallbackStatus = "Unbekannt";
|
||||
|
||||
function formatPageCount(pages: AuditRow["checkedPages"]) {
|
||||
return `${pages.length} Seite${pages.length === 1 ? "" : "n"}`;
|
||||
const generationStageText: Record<string, string> = {
|
||||
audit_generation: "Audit-Generierung",
|
||||
classification: "Klassifikation",
|
||||
multimodalAudit: "Multimodale Analyse",
|
||||
germanCopy: "Deutsche Texte",
|
||||
qualityReview: "Qualitätsprüfung",
|
||||
};
|
||||
|
||||
function formatPageCount(pageCount: number) {
|
||||
return `${pageCount} Seite${pageCount === 1 ? "" : "n"}`;
|
||||
}
|
||||
|
||||
function getStatusLabel(status: AuditRow["status"]) {
|
||||
return statusText[status] ?? fallbackStatus;
|
||||
}
|
||||
|
||||
function getGenerationStatusLabel(
|
||||
row: Extract<AuditDashboardRow, { kind: "generation" }>,
|
||||
) {
|
||||
if (row.status === "pending") {
|
||||
return "Wartet auf Start";
|
||||
}
|
||||
|
||||
if (row.status === "failed") {
|
||||
return "Fehlgeschlagen";
|
||||
}
|
||||
|
||||
if (row.status === "canceled") {
|
||||
return "Abgebrochen";
|
||||
}
|
||||
|
||||
if (row.status === "succeeded") {
|
||||
return "Wartet auf finales Audit";
|
||||
}
|
||||
|
||||
return "Generierung läuft";
|
||||
}
|
||||
|
||||
function getStageLabel(stage: string) {
|
||||
return generationStageText[stage] ?? stage;
|
||||
}
|
||||
|
||||
function AuditsBoardLoading() {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<header className="agency-panel space-y-2 p-4">
|
||||
<p className="agency-kicker">Evidence Dossier</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
|
||||
</header>
|
||||
<div className="rounded-lg border">
|
||||
<div className="grid gap-2 p-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Skeleton className="h-20 rounded-md" key={index} />
|
||||
<Skeleton className="h-40 rounded-lg" key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditsBoard() {
|
||||
const audits = useQuery(api.audits.list, { limit: 100 });
|
||||
const dashboardRows = useQuery(api.audits.listDashboardRows, { limit: 100 });
|
||||
const retryAuditRun = useMutation(api.audits.retryAuditRun);
|
||||
const [activeFilter, setActiveFilter] = useState<AuditStatusFilter>("all");
|
||||
const [retryingRunId, setRetryingRunId] = useState<string | null>(null);
|
||||
const rows = useMemo(() => {
|
||||
if (!audits) {
|
||||
if (!dashboardRows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...audits].sort((a, b) => b.createdAt - a.createdAt);
|
||||
}, [audits]);
|
||||
return [...dashboardRows].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}, [dashboardRows]);
|
||||
const statusCounts = useMemo(() => {
|
||||
return {
|
||||
all: rows.length,
|
||||
audit: rows.filter((row) => row.kind === "audit").length,
|
||||
generation: rows.filter((row) => row.kind === "generation").length,
|
||||
failed: rows.filter(
|
||||
(row) => row.kind === "generation" && row.status === "failed",
|
||||
).length,
|
||||
};
|
||||
}, [rows]);
|
||||
const visibleRows = useMemo(() => {
|
||||
if (activeFilter === "audit") {
|
||||
return rows.filter((row) => row.kind === "audit");
|
||||
}
|
||||
|
||||
if (audits === undefined) {
|
||||
if (activeFilter === "generation") {
|
||||
return rows.filter((row) => row.kind === "generation");
|
||||
}
|
||||
|
||||
if (activeFilter === "failed") {
|
||||
return rows.filter(
|
||||
(row) => row.kind === "generation" && row.status === "failed",
|
||||
);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [activeFilter, rows]);
|
||||
const auditStatusFilters: Array<{
|
||||
label: string;
|
||||
value: AuditStatusFilter;
|
||||
count: number;
|
||||
}> = [
|
||||
{ label: "Alle", value: "all", count: statusCounts.all },
|
||||
{ label: "Audits", value: "audit", count: statusCounts.audit },
|
||||
{ label: "Pipeline", value: "generation", count: statusCounts.generation },
|
||||
{ label: "Fehlgeschlagen", value: "failed", count: statusCounts.failed },
|
||||
];
|
||||
const handleRetryAudit = async (runId: Id<"agentRuns">) => {
|
||||
setRetryingRunId(runId);
|
||||
try {
|
||||
await retryAuditRun({ runId });
|
||||
} finally {
|
||||
setRetryingRunId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (dashboardRows === undefined) {
|
||||
return <AuditsBoardLoading />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<header className="agency-panel space-y-2 p-4">
|
||||
<p className="agency-kicker">Evidence Dossier</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
</header>
|
||||
|
||||
<article className="rounded-lg border p-4">
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<h2 className="text-sm font-medium">Noch keine Audits</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Sobald neue Audits angelegt wurden, erscheinen sie hier als kompakte
|
||||
Zeilen.
|
||||
</p>
|
||||
</article>
|
||||
<CardDescription>
|
||||
Sobald neue Audits oder laufende Audit-Generierungen angelegt
|
||||
wurden, erscheinen sie hier als kompakte Cards.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<header className="agency-panel space-y-2 p-4">
|
||||
<p className="agency-kicker">Evidence Dossier</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<p className="max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
Laufende Generierungen, veröffentlichbare Audits und Fehlerzustände
|
||||
als Prüfmappe statt lose Datensatzliste.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] gap-2 rounded-md border bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>Slug</span>
|
||||
<span>Domain</span>
|
||||
<span>Status</span>
|
||||
<span>Seitenanzahl</span>
|
||||
<span className="text-right">Aktion</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rows.map((audit: AuditRow) => (
|
||||
<article
|
||||
className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
key={audit._id}
|
||||
<div className="flex flex-wrap gap-2" aria-label="Audit-Filter">
|
||||
{auditStatusFilters.map((filter) => (
|
||||
<button
|
||||
aria-pressed={activeFilter === filter.value}
|
||||
className="agency-tab"
|
||||
key={filter.value}
|
||||
onClick={() => setActiveFilter(filter.value)}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{audit.slug}</p>
|
||||
</div>
|
||||
<p className="truncate text-muted-foreground">{audit.checkedDomain}</p>
|
||||
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
|
||||
<p className="text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Files className="size-3.5" />
|
||||
{formatPageCount(audit.checkedPages)}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
className="inline-flex min-h-8 items-center gap-1 text-sm text-primary"
|
||||
href={`/dashboard/audits/${audit._id}`}
|
||||
>
|
||||
<SquarePen className="size-4" />
|
||||
Öffnen
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
{filter.label}
|
||||
<Badge variant="secondary">{filter.count}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||||
aria-label="Audit-Cards"
|
||||
>
|
||||
{visibleRows.map((row: AuditDashboardRow) => {
|
||||
const rowTitleId = `audit-row-title-${row.id}`;
|
||||
|
||||
return (
|
||||
<Card
|
||||
aria-labelledby={rowTitleId}
|
||||
className="agency-panel flex min-w-0 flex-col overflow-hidden"
|
||||
key={row.id}
|
||||
>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardDescription className="inline-flex items-center gap-2">
|
||||
<FileSearch className="size-3.5" aria-hidden="true" />
|
||||
{row.kind === "audit" ? "Audit Evidence" : "Pipeline Evidence"}
|
||||
</CardDescription>
|
||||
<CardTitle className="mt-1 break-words text-base" id={rowTitleId}>
|
||||
{row.title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Badge variant={row.kind === "audit" ? "secondary" : "outline"}>
|
||||
{row.kind === "audit"
|
||||
? getStatusLabel(row.status)
|
||||
: getGenerationStatusLabel(row)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col gap-4">
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div className="evidence-surface min-w-0 rounded-md px-3 py-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Domain</p>
|
||||
<p className="mt-1 break-all">{row.checkedDomain}</p>
|
||||
</div>
|
||||
<div className="min-w-0 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<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 rounded-md bg-muted/45 p-3">
|
||||
<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 border-destructive/30 bg-[var(--danger-soft)] px-3 py-2 text-xs text-destructive">
|
||||
{row.errorSummary}
|
||||
</p>
|
||||
) : null}
|
||||
{row.kind === "generation" ? (
|
||||
<div className="min-w-0 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{row.progress.step}/{row.progress.total} · {row.progress.label}
|
||||
</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{row.progress.percent}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
aria-label={row.progress.label}
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={row.progress.percent}
|
||||
className="mt-2 h-2 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${row.progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{row.retry.isRetrying ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Versuch {row.retry.attempt}/{row.retry.maxAttempts} läuft
|
||||
</p>
|
||||
) : null}
|
||||
{row.retry.isRetrying && row.retry.lastRetryReason ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{row.retry.lastRetryReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : 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 font-semibold text-primary hover:bg-muted"
|
||||
href={row.detailHref}
|
||||
>
|
||||
<SquarePen className="size-4" aria-hidden="true" />
|
||||
Öffnen
|
||||
</Link>
|
||||
) : row.canRetry ? (
|
||||
<button
|
||||
className="inline-flex min-h-8 items-center gap-1 rounded-md px-2 text-sm font-semibold text-primary hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingRunId === row.runId}
|
||||
onClick={() => {
|
||||
void handleRetryAudit(row.runId);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="size-4" aria-hidden="true" />
|
||||
Erneut starten
|
||||
</button>
|
||||
) : (
|
||||
<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="agency-panel 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>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
"use client"
|
||||
"use client";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, LockKeyhole } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
FileSearch,
|
||||
LockKeyhole,
|
||||
ShieldCheck,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const authSignals: Array<{
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}> = [
|
||||
{
|
||||
icon: FileSearch,
|
||||
label: "Evidence",
|
||||
value: "Audit-Aussagen bleiben belegbar.",
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
label: "Approval",
|
||||
value: "Public Audit und Mail bleiben getrennt.",
|
||||
},
|
||||
{
|
||||
icon: CheckCircle2,
|
||||
label: "Safety",
|
||||
value: "Versand erfolgt erst nach Finalbestätigung.",
|
||||
},
|
||||
];
|
||||
|
||||
export function AuthEntry() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
@@ -37,34 +66,42 @@ export function AuthEntry() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-background px-6 py-10">
|
||||
<section className="grid w-full max-w-5xl overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="flex min-h-[520px] flex-col justify-between border-b p-6 md:border-b-0 md:border-r lg:p-8">
|
||||
<main className="dashboard-canvas flex min-h-dvh items-center justify-center px-6 py-10">
|
||||
<section className="agency-panel grid w-full max-w-5xl overflow-hidden text-card-foreground md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="flex min-h-[520px] flex-col justify-between border-b border-border/75 p-6 md:border-b-0 md:border-r lg:p-8">
|
||||
<div>
|
||||
<div className="mb-8 inline-flex size-10 items-center justify-center rounded-lg border bg-background">
|
||||
<LockKeyhole className="size-5" />
|
||||
<div className="mb-8 inline-flex items-center gap-3">
|
||||
<div className="flex size-11 items-center justify-center rounded-md bg-primary font-heading text-sm font-black text-primary-foreground">
|
||||
WP
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
WebDev Pipeline MVP
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">
|
||||
WebDev Pipeline
|
||||
</p>
|
||||
<h1 className="mt-4 max-w-xl text-3xl font-semibold tracking-normal sm:text-4xl">
|
||||
Lokale Webdesign-Leads recherchieren, auditieren und freigeben.
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Agency Evidence Desk
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="agency-kicker">
|
||||
SaaS Workspace
|
||||
</p>
|
||||
<h1 className="mt-4 max-w-xl font-heading text-3xl font-semibold tracking-normal sm:text-4xl">
|
||||
Lokale Chancen belegen, prüfen und erst dann kontaktieren.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-lg text-sm leading-6 text-muted-foreground sm:text-base">
|
||||
Melde dich mit dem Admin-Konto an, um Kampagnen, Lead-Qualitaet,
|
||||
Audit-Freigaben und Outreach-Schritte in einem Arbeitsbereich zu
|
||||
steuern.
|
||||
Melde dich an, um Kampagnen, Lead-Qualität, Audit-Evidence und
|
||||
Outreach-Freigaben in einem geschützten Kunden-Workspace zu steuern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-4 pt-8 sm:grid-cols-3">
|
||||
{[
|
||||
["Recherche", "Google Places Quellen und Kontaktluecken."],
|
||||
["Audit", "Website-Potenzial und Review-Status."],
|
||||
["Outreach", "Manuelle Freigabe vor Versand."],
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-sm font-medium">{label}</dt>
|
||||
{authSignals.map(({ icon: Icon, label, value }) => (
|
||||
<div className="rounded-md border border-border/75 bg-background/60 p-3" key={label}>
|
||||
<dt className="inline-flex items-center gap-2 text-sm font-semibold">
|
||||
<Icon className="size-4 text-primary" />
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
{value}
|
||||
</dd>
|
||||
@@ -73,10 +110,10 @@ export function AuthEntry() {
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center p-6 lg:p-8">
|
||||
<div className="flex flex-col justify-center bg-background/45 p-6 lg:p-8">
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<h2 className="text-2xl font-semibold tracking-normal">
|
||||
Admin Login
|
||||
<h2 className="font-heading text-2xl font-semibold tracking-normal">
|
||||
Workspace Login
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
Melde dich mit E-Mail und Passwort an.
|
||||
@@ -88,7 +125,7 @@ export function AuthEntry() {
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/35"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="admin@firma.de"
|
||||
@@ -99,7 +136,7 @@ export function AuthEntry() {
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/35"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
minLength={8}
|
||||
|
||||
@@ -20,17 +20,22 @@ type BlacklistType =
|
||||
| "email"
|
||||
| "phone"
|
||||
| "company"
|
||||
| "google_place_id";
|
||||
| "google_place_id"
|
||||
| "source_business_id";
|
||||
|
||||
const blacklistTypeOptions: BlacklistType[] = [
|
||||
"domain",
|
||||
"email",
|
||||
"phone",
|
||||
"company",
|
||||
"source_business_id",
|
||||
"google_place_id",
|
||||
];
|
||||
|
||||
function labelForType(type: BlacklistType): string {
|
||||
if (type === "source_business_id") {
|
||||
return "Source Business ID";
|
||||
}
|
||||
if (type === "google_place_id") {
|
||||
return "Google Place ID";
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ export function CampaignFormDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Wähle Kategorie, PLZ, Radius und Limits je Kampagne.
|
||||
Wähle Kategorie, PLZ, Radius und Lead-Limit je Kampagne.
|
||||
</DialogDescription>
|
||||
<DialogCloseButton />
|
||||
</DialogHeader>
|
||||
@@ -315,7 +315,6 @@ export function CampaignFormDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={control}
|
||||
name="maxNewLeadsPerRun"
|
||||
@@ -339,30 +338,6 @@ export function CampaignFormDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="maxAuditsPerRun"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max. Audits</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value ?? ""}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
field.onChange(Number.isFinite(value) ? value : 0);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="status"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { FunctionReturnType } from "convex/server";
|
||||
import { MapPin, Pencil, Play, RefreshCcw, Plus } from "lucide-react";
|
||||
import { Clock3, MapPin, Pencil, Play, RefreshCcw, Plus, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -256,12 +256,16 @@ export function CampaignsBoard() {
|
||||
onSubmit={submitCampaign}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 border-b pb-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="agency-panel flex flex-col gap-4 p-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Lokale Kampagnenverwaltung</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
|
||||
<p className="agency-kicker">Controlled Sourcing</p>
|
||||
<h1 className="mt-2 font-heading text-3xl font-semibold tracking-normal">
|
||||
Kampagnen
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Plane Suchläufe mit festen Limits, sauberem Radius und manuellen
|
||||
Prüfstationen vor Audit und Outreach.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={openCreateDialog} className="justify-start sm:w-auto">
|
||||
@@ -275,7 +279,7 @@ export function CampaignsBoard() {
|
||||
{actionLabel ? <p className="text-sm" role="status">{actionLabel}</p> : null}
|
||||
|
||||
{campaignsSorted.length === 0 ? (
|
||||
<Card>
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>Keine Kampagnen</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -284,13 +288,22 @@ export function CampaignsBoard() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{campaignsSorted.map((campaign) => (
|
||||
<Card key={campaign._id}>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{campaignsSorted.map((campaign) => {
|
||||
const campaignTitleId = `campaign-title-${campaign._id}`;
|
||||
|
||||
return (
|
||||
<Card
|
||||
aria-labelledby={campaignTitleId}
|
||||
className="agency-panel overflow-hidden"
|
||||
key={campaign._id}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate">{campaign.name}</CardTitle>
|
||||
<CardTitle className="truncate" id={campaignTitleId}>
|
||||
{campaign.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate">
|
||||
{formatNiche(campaign)}
|
||||
</CardDescription>
|
||||
@@ -303,27 +316,36 @@ export function CampaignsBoard() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-2 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardContent className="grid gap-3 text-sm">
|
||||
<div className="evidence-surface flex flex-wrap items-center justify-between gap-3 rounded-md px-3 py-2">
|
||||
<div className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<MapPin className="size-3" />
|
||||
<span>{campaign.postalCode}</span>
|
||||
</div>
|
||||
<span>{campaign.radiusKm} km</span>
|
||||
<span className="font-semibold">{campaign.radiusKm} km</span>
|
||||
</div>
|
||||
<Separator className="bg-border" />
|
||||
<div>
|
||||
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p>
|
||||
<p>
|
||||
Limits: L {campaign.maxNewLeadsPerRun}, A{" "}
|
||||
{campaign.maxAuditsPerRun}
|
||||
<div className="grid gap-2 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<p className="inline-flex items-center gap-2 font-medium">
|
||||
<Clock3 className="size-3.5 text-primary" />
|
||||
Cadence: {recurrenceLabel[campaign.recurrence]}
|
||||
</p>
|
||||
<p className="inline-flex items-center gap-2 font-medium">
|
||||
<ShieldCheck className="size-3.5 text-primary" />
|
||||
Lead-Limit: {campaign.maxNewLeadsPerRun}
|
||||
</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>
|
||||
<div className="grid gap-1 rounded-md bg-muted/45 p-3">
|
||||
<p className="text-muted-foreground">
|
||||
Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}
|
||||
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Nächster Lauf: {formatDateTime(campaign.nextRunAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Run-Status:{" "}
|
||||
{statusLabel[campaign.currentRunStatus] ??
|
||||
campaign.currentRunStatus}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -357,11 +379,12 @@ export function CampaignsBoard() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>Aktuelle Run-Logs</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -375,7 +398,7 @@ export function CampaignsBoard() {
|
||||
<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="rounded-md border border-border/75 bg-background/60 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}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { CheckCircle2, LogOut, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,21 +20,41 @@ export function DashboardSidebar() {
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
return (
|
||||
<aside className="flex w-full shrink-0 flex-col border-b bg-sidebar text-sidebar-foreground md:sticky md:top-0 md:min-h-dvh md:w-72 md:border-b-0 md:border-r">
|
||||
<div className="flex h-16 items-center gap-3 border-b px-4">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
W
|
||||
<aside className="flex w-full shrink-0 flex-col border-b border-sidebar-border bg-sidebar text-sidebar-foreground md:sticky md:top-0 md:min-h-dvh md:w-[19rem] md:border-b-0 md:border-r">
|
||||
<div className="flex h-[5.25rem] items-center gap-3 border-b border-sidebar-border px-4">
|
||||
<div className="flex size-11 items-center justify-center rounded-md bg-sidebar-primary font-heading text-sm font-black text-sidebar-primary-foreground shadow-[inset_0_1px_0_color-mix(in_oklch,var(--sidebar-primary-foreground),transparent_75%)]">
|
||||
WP
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">WebDev Pipeline</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
Akquise Workspace
|
||||
<p className="truncate font-heading text-sm font-semibold">
|
||||
WebDev Pipeline
|
||||
</p>
|
||||
<p className="truncate text-xs font-medium text-muted-foreground">
|
||||
Agency Evidence Desk
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 border-b border-sidebar-border p-3">
|
||||
<div className="rounded-md border border-sidebar-border bg-sidebar-accent p-3">
|
||||
<p className="text-xs font-semibold text-sidebar-accent-foreground">
|
||||
Workspace Safety
|
||||
</p>
|
||||
<div className="mt-2 grid gap-2 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ShieldCheck className="size-3.5 text-sidebar-primary" />
|
||||
Versand nur nach Freigabe
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<CheckCircle2 className="size-3.5 text-sidebar-primary" />
|
||||
Evidence vor Outreach
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className="flex gap-1 overflow-x-auto p-3 md:grid md:overflow-visible"
|
||||
className="flex gap-1 overflow-x-auto p-3 md:grid md:gap-1.5 md:overflow-visible"
|
||||
aria-label="Dashboard navigation"
|
||||
>
|
||||
{dashboardNavigation.map((item) => {
|
||||
@@ -48,9 +68,9 @@ export function DashboardSidebar() {
|
||||
<Link
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex h-9 shrink-0 items-center gap-2 rounded-lg px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"flex h-10 shrink-0 items-center gap-2 rounded-md px-3 text-sm font-semibold outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/35",
|
||||
isActive
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-[inset_0_1px_0_color-mix(in_oklch,var(--sidebar-primary-foreground),transparent_82%)]"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
href={item.href}
|
||||
@@ -64,13 +84,13 @@ export function DashboardSidebar() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-3 md:mt-auto">
|
||||
<div className="mb-3 rounded-lg border bg-background p-3 md:block">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{isPending ? "Lade..." : session?.user?.name ?? "Admin"}
|
||||
<div className="border-t border-sidebar-border p-3 md:mt-auto">
|
||||
<div className="mb-3 rounded-md border border-sidebar-border bg-sidebar-accent p-3 md:block">
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{isPending ? "Lade..." : session?.user?.name ?? "Workspace"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{session?.user?.email ?? "admin@local"}
|
||||
<p className="truncate text-xs font-medium text-muted-foreground">
|
||||
{session?.user?.email ?? "team@workspace"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
|
||||
@@ -73,7 +73,7 @@ export function DashboardThemeProvider({ children }: { children: ReactNode }) {
|
||||
<div
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"min-h-dvh bg-background text-foreground md:flex",
|
||||
"dashboard-canvas min-h-dvh text-foreground md:flex",
|
||||
theme === "dark" && "dark",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import type { FunctionReturnType } from "convex/server";
|
||||
import { ArrowRight, Building2, MapPin } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
FileSearch,
|
||||
MapPin,
|
||||
ShieldAlert,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
@@ -24,6 +33,42 @@ const stageActionHref: Record<LeadFunnelStageId, string> = {
|
||||
deferred: "/dashboard/leads",
|
||||
};
|
||||
|
||||
const stageVisuals: Record<
|
||||
LeadFunnelStageId,
|
||||
{ surface: string; label: string; icon: LucideIcon }
|
||||
> = {
|
||||
missing_contact: {
|
||||
surface: "review-surface",
|
||||
label: "Kontakt klären",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
audit_ready: {
|
||||
surface: "evidence-surface",
|
||||
label: "Evidence sammeln",
|
||||
icon: FileSearch,
|
||||
},
|
||||
review_open: {
|
||||
surface: "review-surface",
|
||||
label: "Freigabe prüfen",
|
||||
icon: Clock3,
|
||||
},
|
||||
contacted: {
|
||||
surface: "safe-surface",
|
||||
label: "Kontakt läuft",
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
follow_up: {
|
||||
surface: "safe-surface",
|
||||
label: "Follow-up",
|
||||
icon: Clock3,
|
||||
},
|
||||
deferred: {
|
||||
surface: "bg-[var(--danger-soft)] text-destructive",
|
||||
label: "Zurückgestellt",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
};
|
||||
|
||||
export function LeadFunnelBoard() {
|
||||
const leads: LeadFunnelQueryResult | undefined = useQuery(
|
||||
api.leads.listFunnel,
|
||||
@@ -40,14 +85,14 @@ export function LeadFunnelBoard() {
|
||||
if (totalCards === 0) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-lg border bg-card p-6 text-card-foreground"
|
||||
className="rounded-lg border border-border/80 bg-card p-6 text-card-foreground"
|
||||
aria-labelledby="lead-funnel-heading"
|
||||
>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
<p className="text-sm font-semibold text-muted-foreground">
|
||||
Lead-Funnel
|
||||
</p>
|
||||
<h2
|
||||
className="mt-2 text-xl font-semibold tracking-normal"
|
||||
className="mt-2 font-heading text-xl font-semibold tracking-normal"
|
||||
id="lead-funnel-heading"
|
||||
>
|
||||
Noch keine Leads im Arbeitsfluss
|
||||
@@ -61,48 +106,74 @@ export function LeadFunnelBoard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="grid gap-3" aria-labelledby="lead-funnel-heading">
|
||||
<section className="agency-panel p-4" aria-labelledby="lead-funnel-heading">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="agency-kicker">Lead Workflow</p>
|
||||
<h2
|
||||
className="text-xl font-semibold tracking-normal"
|
||||
className="mt-1 font-heading text-xl font-semibold tracking-normal"
|
||||
id="lead-funnel-heading"
|
||||
>
|
||||
Lead-Funnel
|
||||
Evidence Pipeline
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
{totalCards} Leads nach Kontaktlage, Audit-Stand und nächster
|
||||
manueller Aktion.
|
||||
{totalCards} Leads nach nächster Entscheidung, Beleglage und
|
||||
Outreach-Sicherheit.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Kein automatischer Versand
|
||||
<p className="rounded-md bg-[var(--surface-review)] px-2.5 py-1 text-sm font-bold text-secondary-foreground">
|
||||
Human approval required
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{groups.map((group) => (
|
||||
<LeadFunnelStageView group={group} key={group.stage.id} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LeadFunnelStageView({
|
||||
group,
|
||||
}: {
|
||||
group: ReturnType<typeof groupLeadFunnelCards>[number];
|
||||
}) {
|
||||
const visual = stageVisuals[group.stage.id];
|
||||
const Icon = visual.icon;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex min-h-[24rem] flex-col rounded-lg border bg-card text-card-foreground"
|
||||
key={group.stage.id}
|
||||
className="rounded-md border border-border/80 bg-background/55"
|
||||
aria-labelledby={`${group.stage.id}-heading`}
|
||||
>
|
||||
<div className="border-b p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border/75 p-3">
|
||||
<div className="flex min-w-0 gap-3">
|
||||
<span
|
||||
className={`flex size-10 shrink-0 items-center justify-center rounded-md ${visual.surface}`}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold uppercase text-muted-foreground">
|
||||
{visual.label}
|
||||
</p>
|
||||
<h3
|
||||
className="text-sm font-semibold"
|
||||
className="mt-1 text-sm font-semibold"
|
||||
id={`${group.stage.id}-heading`}
|
||||
>
|
||||
{group.stage.title}
|
||||
</h3>
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{group.cards.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{group.stage.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-md bg-card px-2 py-1 text-sm font-bold">
|
||||
{group.cards.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 p-2">
|
||||
{group.cards.length > 0 ? (
|
||||
@@ -110,22 +181,19 @@ export function LeadFunnelBoard() {
|
||||
<LeadFunnelCardView card={card} key={card.id} />
|
||||
))
|
||||
) : (
|
||||
<p className="rounded-md border border-dashed p-3 text-xs leading-5 text-muted-foreground">
|
||||
Keine Leads in dieser Spalte.
|
||||
<p className="rounded-md border border-dashed border-border/80 bg-card/50 p-3 text-xs leading-5 text-muted-foreground">
|
||||
Keine Leads in diesem Entscheidungsschritt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
|
||||
return (
|
||||
<article
|
||||
className="rounded-lg border bg-background p-3"
|
||||
className="rounded-lg border border-border/80 bg-background/65 p-3"
|
||||
aria-labelledby={`${card.id}-company`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -143,7 +211,7 @@ function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-2 py-1 text-xs font-medium",
|
||||
"shrink-0 rounded-md px-2 py-1 text-xs font-semibold",
|
||||
card.priorityLabel === "Hoch"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
@@ -159,16 +227,16 @@ function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<span className="rounded-md bg-secondary px-2 py-1 text-xs text-secondary-foreground">
|
||||
<span className="rounded-md bg-secondary px-2 py-1 text-xs font-semibold text-secondary-foreground">
|
||||
{card.contactStatusLabel}
|
||||
</span>
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{card.contactDetail}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
className="mt-3 inline-flex min-h-8 items-center gap-1 rounded-md text-sm font-medium text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="mt-3 inline-flex min-h-8 items-center gap-1 rounded-md text-sm font-semibold text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/35"
|
||||
href={stageActionHref[card.stageId]}
|
||||
prefetch={false}
|
||||
>
|
||||
@@ -189,7 +257,7 @@ function LeadFunnelSkeleton() {
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div
|
||||
className="min-h-[24rem] rounded-lg border bg-card p-3"
|
||||
className="min-h-[24rem] rounded-lg border border-border/80 bg-card p-3"
|
||||
key={index}
|
||||
>
|
||||
<div className="h-5 w-28 rounded-md bg-muted" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { FunctionReturnType } from "convex/server";
|
||||
import { Building2, Mail, MapPin, Phone, ShieldAlert } from "lucide-react";
|
||||
import { Building2, ExternalLink, Mail, MapPin, Phone, PlayCircle, ShieldAlert } from "lucide-react";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -23,13 +23,26 @@ import {
|
||||
} from "@/lib/dashboard-model";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
type LeadsListResult = FunctionReturnType<typeof api.leads.list>;
|
||||
type LeadRow = NonNullable<LeadsListResult>[number];
|
||||
type AuditStartStatesResult = FunctionReturnType<
|
||||
typeof api.pageSpeed.getLeadAuditStartStates
|
||||
>;
|
||||
type AuditStartState = NonNullable<AuditStartStatesResult>[number];
|
||||
|
||||
type LeadReviewDraft = {
|
||||
priority: LeadPriority;
|
||||
@@ -63,6 +76,7 @@ type LeadReviewPayload = {
|
||||
reviewContactPerson?: string;
|
||||
reviewIsBusinessContactAddress?: boolean;
|
||||
};
|
||||
type LeadStatusFilter = "all" | "high" | "blocked";
|
||||
|
||||
function normalizeTextInput(value: string): string | undefined {
|
||||
const next = value.trim();
|
||||
@@ -70,6 +84,33 @@ function normalizeTextInput(value: string): string | undefined {
|
||||
return next.length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function toEmailHref(email?: string | null): string | null {
|
||||
const normalizedEmail = normalizeTextInput(email ?? "");
|
||||
|
||||
return normalizedEmail ? `mailto:${normalizedEmail}` : null;
|
||||
}
|
||||
|
||||
function toPhoneHref(phone?: string | null): string | null {
|
||||
const normalizedPhone = normalizeTextInput(phone ?? "");
|
||||
const dialablePhone = normalizedPhone?.replace(/[^\d+]/g, "");
|
||||
|
||||
return dialablePhone ? `tel:${dialablePhone}` : null;
|
||||
}
|
||||
|
||||
function toWebsiteHref(lead: Pick<LeadRow, "websiteDomain" | "websiteUrl">): string | null {
|
||||
const website = normalizeTextInput(lead.websiteUrl ?? "") ?? normalizeTextInput(lead.websiteDomain ?? "");
|
||||
|
||||
if (!website) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(website)) {
|
||||
return website;
|
||||
}
|
||||
|
||||
return `https://${website.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
function contactSourceLabel(lead: LeadRow): string {
|
||||
if (lead.sourceProvider) {
|
||||
return lead.sourceProvider;
|
||||
@@ -129,9 +170,49 @@ function duplicateBadgeVariant(
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function auditStartDisabledReason({
|
||||
lead,
|
||||
auditStartState,
|
||||
isLoading,
|
||||
isStarting,
|
||||
}: {
|
||||
lead: LeadRow;
|
||||
auditStartState?: AuditStartState;
|
||||
isLoading: boolean;
|
||||
isStarting: boolean;
|
||||
}) {
|
||||
if (isStarting) {
|
||||
return "Audit läuft";
|
||||
}
|
||||
|
||||
if (!lead.websiteUrl) {
|
||||
return "Keine Website hinterlegt.";
|
||||
}
|
||||
|
||||
if (
|
||||
lead.priority === "blocked" ||
|
||||
lead.priority === "defer" ||
|
||||
lead.blacklistStatus === "blocked" ||
|
||||
lead.contactStatus === "do_not_contact"
|
||||
) {
|
||||
return "Lead ist gesperrt oder zurückgestellt.";
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return "Audit-Status wird geladen.";
|
||||
}
|
||||
|
||||
if (auditStartState && !auditStartState.canStart) {
|
||||
return auditStartState.reason ?? "Audit kann aktuell nicht gestartet werden.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function LeadsReviewTable() {
|
||||
const leads = useQuery(api.leads.list, { limit: 120 });
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<LeadStatusFilter>("all");
|
||||
|
||||
const sortedLeads = useMemo(() => {
|
||||
if (!leads) {
|
||||
@@ -140,27 +221,108 @@ export function LeadsReviewTable() {
|
||||
|
||||
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
|
||||
}, [leads]);
|
||||
const auditStartStates = useQuery(
|
||||
api.pageSpeed.getLeadAuditStartStates,
|
||||
leads
|
||||
? {
|
||||
leadIds: sortedLeads.map((lead) => lead._id),
|
||||
}
|
||||
: "skip",
|
||||
);
|
||||
const auditStartStateByLeadId = useMemo(() => {
|
||||
const next = new Map<string, AuditStartState>();
|
||||
for (const state of auditStartStates ?? []) {
|
||||
next.set(state.leadId, state);
|
||||
}
|
||||
return next;
|
||||
}, [auditStartStates]);
|
||||
const filteredLeads = useMemo(() => {
|
||||
if (activeFilter === "high") {
|
||||
return sortedLeads.filter((lead) => lead.priority === "high");
|
||||
}
|
||||
|
||||
if (activeFilter === "blocked") {
|
||||
return sortedLeads.filter((lead) => lead.blacklistStatus === "blocked");
|
||||
}
|
||||
|
||||
return sortedLeads;
|
||||
}, [activeFilter, sortedLeads]);
|
||||
const leadStatusFilters: Array<{ label: string; value: LeadStatusFilter; count: number }> = [
|
||||
{ label: "Alle Leads", value: "all", count: sortedLeads.length },
|
||||
{
|
||||
label: "Hohe Priorität",
|
||||
value: "high",
|
||||
count: sortedLeads.filter((lead) => lead.priority === "high").length,
|
||||
},
|
||||
{
|
||||
label: "Gesperrt",
|
||||
value: "blocked",
|
||||
count: sortedLeads.filter((lead) => lead.blacklistStatus === "blocked").length,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">Leads Review</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
|
||||
<div className="agency-panel mx-auto flex w-full max-w-7xl flex-col gap-2 p-4">
|
||||
<p className="agency-kicker">Lead Intake</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Leads prüfen</h1>
|
||||
<p className="max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
Kontaktlage, Sperrlisten, Duplikate und Audit-Start bleiben vor jedem
|
||||
Outreach als überprüfbare Entscheidungen sichtbar.
|
||||
</p>
|
||||
</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="agency-tab"
|
||||
key={filter.value}
|
||||
onClick={() => setActiveFilter(filter.value)}
|
||||
type="button"
|
||||
>
|
||||
{filter.label}
|
||||
<Badge variant="secondary">{filter.count}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto grid w-full max-w-7xl gap-3">
|
||||
{leads === undefined ? (
|
||||
<p className="rounded-md bg-muted p-4 text-sm">Leads werden geladen…</p>
|
||||
Array.from({ length: 4 }, (_, index) => (
|
||||
<Card className="agency-panel" key={index}>
|
||||
<CardHeader>
|
||||
<div className="h-5 w-2/3 rounded-md bg-muted" />
|
||||
<div className="h-4 w-1/2 rounded-md bg-muted" />
|
||||
<div className="mt-2 h-12 rounded-md bg-muted" />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))
|
||||
) : sortedLeads.length === 0 ? (
|
||||
<p className="rounded-md border p-4 text-sm text-muted-foreground">
|
||||
Keine Leads vorhanden. Bitte zuerst eine Kampagne starten oder
|
||||
importieren.
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<p className="text-sm font-medium">Keine Leads vorhanden</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bitte zuerst eine Kampagne starten oder importieren.
|
||||
</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : filteredLeads.length === 0 ? (
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<p className="text-sm font-medium">Keine Treffer</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Für diesen Filter sind aktuell keine Leads vorhanden.
|
||||
</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
sortedLeads.map((lead) => (
|
||||
filteredLeads.map((lead) => (
|
||||
<LeadReviewRow
|
||||
key={lead._id}
|
||||
lead={lead}
|
||||
auditStartState={auditStartStateByLeadId.get(lead._id)}
|
||||
auditStartStateLoading={auditStartStates === undefined}
|
||||
onActionMessage={setActionMessage}
|
||||
/>
|
||||
))
|
||||
@@ -168,7 +330,7 @@ export function LeadsReviewTable() {
|
||||
</div>
|
||||
|
||||
{actionMessage ? (
|
||||
<p className="mx-auto max-w-7xl text-sm text-muted-foreground">
|
||||
<p className="mx-auto max-w-7xl text-sm text-muted-foreground" role="status">
|
||||
{actionMessage}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -178,12 +340,16 @@ export function LeadsReviewTable() {
|
||||
|
||||
function LeadReviewRow({
|
||||
lead,
|
||||
auditStartState,
|
||||
auditStartStateLoading,
|
||||
onActionMessage,
|
||||
}: {
|
||||
lead: LeadRow;
|
||||
auditStartState?: AuditStartState;
|
||||
auditStartStateLoading: boolean;
|
||||
onActionMessage: (value: string) => void;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
|
||||
priority: lead.priority,
|
||||
contactStatus: lead.contactStatus,
|
||||
@@ -199,16 +365,28 @@ function LeadReviewRow({
|
||||
}));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isBlocking, setIsBlocking] = useState(false);
|
||||
const [isStartingAudit, setIsStartingAudit] = useState(false);
|
||||
const [rowMessage, setRowMessage] = useState<string | null>(null);
|
||||
const reviewUpdate = useMutation(api.leads.reviewUpdate);
|
||||
const requestLeadAudit = useMutation(api.pageSpeed.requestLeadAudit);
|
||||
|
||||
const location = formatLocation(lead);
|
||||
const emailHref = toEmailHref(lead.email);
|
||||
const phoneHref = toPhoneHref(lead.phone);
|
||||
const websiteHref = toWebsiteHref(lead);
|
||||
const websiteLabel = lead.websiteDomain ?? lead.websiteUrl;
|
||||
const reasonParts = [
|
||||
lead.priorityReason,
|
||||
lead.contactStatusReason,
|
||||
lead.duplicateReason,
|
||||
lead.blacklistReason,
|
||||
].filter((item): item is string => Boolean(item));
|
||||
const manualAuditDisabledReason = auditStartDisabledReason({
|
||||
lead,
|
||||
auditStartState,
|
||||
isLoading: auditStartStateLoading,
|
||||
isStarting: isStartingAudit,
|
||||
});
|
||||
|
||||
const update = async (
|
||||
payload?: Omit<LeadReviewPayload, "id">,
|
||||
@@ -271,6 +449,28 @@ function LeadReviewRow({
|
||||
setIsBlocking(false);
|
||||
};
|
||||
|
||||
const startAudit = async () => {
|
||||
if (manualAuditDisabledReason) {
|
||||
setRowMessage(manualAuditDisabledReason);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStartingAudit(true);
|
||||
setRowMessage(null);
|
||||
onActionMessage("");
|
||||
|
||||
try {
|
||||
const result = await requestLeadAudit({ leadId: lead._id });
|
||||
setRowMessage(result.message);
|
||||
onActionMessage(result.message);
|
||||
} catch {
|
||||
setRowMessage("Audit-Start fehlgeschlagen");
|
||||
} finally {
|
||||
setIsStartingAudit(false);
|
||||
setTimeout(() => setRowMessage(null), 1800);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDraft = <T extends keyof LeadReviewDraft>(
|
||||
field: T,
|
||||
value: LeadReviewDraft[T],
|
||||
@@ -279,14 +479,26 @@ function LeadReviewRow({
|
||||
};
|
||||
|
||||
const detailsId = `lead-review-details-${lead._id}`;
|
||||
const titleId = `lead-review-title-${lead._id}`;
|
||||
const priorityId = `lead-priority-${lead._id}`;
|
||||
const contactStatusId = `lead-contact-status-${lead._id}`;
|
||||
const priorityReasonId = `lead-priority-reason-${lead._id}`;
|
||||
const contactReasonId = `lead-contact-reason-${lead._id}`;
|
||||
const notesId = `lead-notes-${lead._id}`;
|
||||
const reviewEmailId = `lead-review-email-${lead._id}`;
|
||||
const reviewSourceId = `lead-review-source-${lead._id}`;
|
||||
const contactPersonId = `lead-contact-person-${lead._id}`;
|
||||
const businessContactId = `lead-business-contact-${lead._id}`;
|
||||
const duplicateStatusId = `lead-duplicate-status-${lead._id}`;
|
||||
const blacklistStatusId = `lead-blacklist-status-${lead._id}`;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card aria-labelledby={titleId} className="agency-panel overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="grid min-w-0 gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="max-w-full truncate font-medium">
|
||||
<p className="max-w-full truncate font-medium" id={titleId}>
|
||||
{lead.companyName}
|
||||
</p>
|
||||
<p className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -315,48 +527,94 @@ function LeadReviewRow({
|
||||
<div className="grid min-w-0 gap-1 text-xs text-muted-foreground">
|
||||
<p className="inline-flex min-w-0 items-center gap-1">
|
||||
<Mail className="size-3 shrink-0" />
|
||||
<span className="max-w-full min-w-0 break-all">
|
||||
{lead.email || "Keine E-Mail"}
|
||||
</span>
|
||||
{emailHref ? (
|
||||
<a className="max-w-full min-w-0 break-all hover:text-foreground hover:underline" href={emailHref}>
|
||||
{lead.email}
|
||||
</a>
|
||||
) : (
|
||||
<span className="max-w-full min-w-0 break-all">Keine E-Mail</span>
|
||||
)}
|
||||
</p>
|
||||
{lead.phone ? (
|
||||
{lead.phone && phoneHref ? (
|
||||
<p className="inline-flex min-w-0 items-center gap-1">
|
||||
<Phone className="size-3 shrink-0" />
|
||||
<span className="max-w-full min-w-0 break-all">{lead.phone}</span>
|
||||
<a className="max-w-full min-w-0 break-all hover:text-foreground hover:underline" href={phoneHref}>
|
||||
{lead.phone}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="truncate max-w-full">
|
||||
Quelle: {contactSourceLabel(lead)}
|
||||
</p>
|
||||
{lead.websiteDomain ? (
|
||||
<p className="truncate max-w-full">Domain: {lead.websiteDomain}</p>
|
||||
{websiteHref && websiteLabel ? (
|
||||
<p className="inline-flex min-w-0 max-w-full items-center gap-1">
|
||||
<ExternalLink className="size-3 shrink-0" />
|
||||
<span className="shrink-0">Website:</span>
|
||||
<a
|
||||
className="min-w-0 truncate hover:text-foreground hover:underline"
|
||||
href={websiteHref}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{websiteLabel}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="border-t p-4 pt-3">
|
||||
<div className="flex flex-wrap gap-2 border-t bg-muted/25 p-4 pt-3">
|
||||
<div className="grid gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={startAudit}
|
||||
disabled={manualAuditDisabledReason !== null}
|
||||
size="sm"
|
||||
title={manualAuditDisabledReason ?? "Audit manuell starten"}
|
||||
>
|
||||
<PlayCircle className="size-4" />
|
||||
Audit starten
|
||||
</Button>
|
||||
{manualAuditDisabledReason ? (
|
||||
<p className="max-w-56 text-xs text-muted-foreground">
|
||||
{manualAuditDisabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded((previous) => !previous)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={detailsId}
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
{isExpanded ? "Weniger anzeigen" : "Mehr anzeigen"}
|
||||
Mehr anzeigen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={detailsId}
|
||||
className="grid gap-3 border-t p-4"
|
||||
hidden={!isExpanded}
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-h-[calc(100dvh-2rem)] max-w-5xl overflow-y-auto"
|
||||
id={detailsId}
|
||||
>
|
||||
<DialogHeader>
|
||||
<div>
|
||||
<DialogTitle>{lead.companyName} prüfen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Priorität, Kontaktstatus, Duplikate und Kontaktinformationen bearbeiten.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogCloseButton />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<section className="grid gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Priorität</p>
|
||||
<Label className="text-xs text-muted-foreground" htmlFor={priorityId}>Priorität</Label>
|
||||
<div className="mt-2">
|
||||
<Select
|
||||
value={draft.priority}
|
||||
@@ -364,7 +622,7 @@ function LeadReviewRow({
|
||||
updateDraft("priority", nextPriority as LeadPriority)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={priorityId}>
|
||||
<SelectValue placeholder="Priorität" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -379,7 +637,7 @@ function LeadReviewRow({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Kontaktstatus</p>
|
||||
<Label className="text-xs text-muted-foreground" htmlFor={contactStatusId}>Kontaktstatus</Label>
|
||||
<div className="mt-2">
|
||||
<Select
|
||||
value={draft.contactStatus}
|
||||
@@ -387,7 +645,7 @@ function LeadReviewRow({
|
||||
updateDraft("contactStatus", nextStatus as LeadContactStatus)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={contactStatusId}>
|
||||
<SelectValue placeholder="Kontaktstatus" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -404,8 +662,9 @@ function LeadReviewRow({
|
||||
|
||||
<section className="grid gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Prioritätsgrund</p>
|
||||
<Label className="text-xs text-muted-foreground" htmlFor={priorityReasonId}>Prioritätsgrund</Label>
|
||||
<Input
|
||||
id={priorityReasonId}
|
||||
value={draft.priorityReason}
|
||||
onChange={(event) => {
|
||||
updateDraft("priorityReason", event.target.value);
|
||||
@@ -413,10 +672,11 @@ function LeadReviewRow({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactReasonId}>
|
||||
Kontaktstatus-Notiz
|
||||
</p>
|
||||
</Label>
|
||||
<Input
|
||||
id={contactReasonId}
|
||||
value={draft.contactStatusReason}
|
||||
onChange={(event) => {
|
||||
updateDraft("contactStatusReason", event.target.value);
|
||||
@@ -424,8 +684,9 @@ function LeadReviewRow({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Notiz</p>
|
||||
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={notesId}>Notiz</Label>
|
||||
<Input
|
||||
id={notesId}
|
||||
value={draft.notes}
|
||||
onChange={(event) => {
|
||||
updateDraft("notes", event.target.value);
|
||||
@@ -443,8 +704,9 @@ function LeadReviewRow({
|
||||
|
||||
<section className="grid gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Review-E-Mail</p>
|
||||
<Label className="text-xs text-muted-foreground" htmlFor={reviewEmailId}>Review-E-Mail</Label>
|
||||
<Input
|
||||
id={reviewEmailId}
|
||||
value={draft.reviewEmail}
|
||||
onChange={(event) => {
|
||||
updateDraft("reviewEmail", event.target.value);
|
||||
@@ -453,8 +715,9 @@ function LeadReviewRow({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Review-Quelle</p>
|
||||
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={reviewSourceId}>Review-Quelle</Label>
|
||||
<Input
|
||||
id={reviewSourceId}
|
||||
value={draft.reviewEmailSource}
|
||||
onChange={(event) => {
|
||||
updateDraft("reviewEmailSource", event.target.value);
|
||||
@@ -462,28 +725,30 @@ function LeadReviewRow({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Ansprechperson</p>
|
||||
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactPersonId}>Ansprechperson</Label>
|
||||
<Input
|
||||
id={contactPersonId}
|
||||
value={draft.reviewContactPerson}
|
||||
onChange={(event) => {
|
||||
updateDraft("reviewContactPerson", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="mt-2 inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Label className="mt-2 inline-flex items-center gap-2 text-xs text-muted-foreground" htmlFor={businessContactId}>
|
||||
<Switch
|
||||
id={businessContactId}
|
||||
checked={draft.reviewIsBusinessContactAddress}
|
||||
onCheckedChange={(checked) => {
|
||||
updateDraft("reviewIsBusinessContactAddress", checked);
|
||||
}}
|
||||
/>
|
||||
Genannte E-Mail als Business-Kontakt
|
||||
</label>
|
||||
</Label>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Duplikatstatus</p>
|
||||
<Label className="text-xs text-muted-foreground" htmlFor={duplicateStatusId}>Duplikatstatus</Label>
|
||||
<div className="mt-2">
|
||||
<Select
|
||||
value={draft.duplicateStatus}
|
||||
@@ -491,7 +756,7 @@ function LeadReviewRow({
|
||||
updateDraft("duplicateStatus", nextStatus as LeadDuplicateStatus)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={duplicateStatusId}>
|
||||
<SelectValue placeholder="Duplikatstatus" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -506,7 +771,7 @@ function LeadReviewRow({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Sperrstatus</label>
|
||||
<Label className="text-xs text-muted-foreground" htmlFor={blacklistStatusId}>Sperrstatus</Label>
|
||||
<div className="mt-2">
|
||||
<Select
|
||||
value={draft.blacklistStatus}
|
||||
@@ -514,7 +779,7 @@ function LeadReviewRow({
|
||||
updateDraft("blacklistStatus", nextStatus as LeadBlacklistStatus)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={blacklistStatusId}>
|
||||
<SelectValue placeholder="Sperrstatus" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -557,11 +822,16 @@ function LeadReviewRow({
|
||||
</Button>
|
||||
</div>
|
||||
{rowMessage ? (
|
||||
<p className="text-xs text-muted-foreground">{rowMessage}</p>
|
||||
rowMessage === "Speichern fehlgeschlagen" ? (
|
||||
<p className="text-xs text-destructive" role="alert">{rowMessage}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground" role="status">{rowMessage}</p>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,22 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import type { FunctionReturnType } from "convex/server";
|
||||
import { ChevronDown, ChevronRight, ExternalLink, MailCheck, Save } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
FileSearch,
|
||||
MailCheck,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
@@ -45,6 +54,7 @@ type PendingEmailConfirmation = {
|
||||
sender: string;
|
||||
auditSlug: string | null;
|
||||
};
|
||||
type ReviewStatusFilter = "all" | "ready" | "mail_open";
|
||||
|
||||
const emptyDraft: DraftState = {
|
||||
auditBody: "",
|
||||
@@ -124,6 +134,20 @@ function skillLabel(skill: UsedSkill) {
|
||||
return skill.category ? `${name} · ${skill.category}` : name;
|
||||
}
|
||||
|
||||
function isEmailDraftReady(record: ReviewWorkspaceItem) {
|
||||
const outreach = record.latestOutreach;
|
||||
|
||||
return Boolean(outreach?.emailSubject?.trim() && outreach.emailBody?.trim());
|
||||
}
|
||||
|
||||
function isReadyToSend(record: ReviewWorkspaceItem) {
|
||||
return Boolean(
|
||||
record.latestOutreach &&
|
||||
record.latestOutreach.sendStatus !== "queued" &&
|
||||
isEmailDraftReady(record),
|
||||
);
|
||||
}
|
||||
|
||||
function DetailToggle({
|
||||
isOpen,
|
||||
label,
|
||||
@@ -161,9 +185,9 @@ function FieldPair({ label, value }: { label: string; value?: string | null }) {
|
||||
function WorkspaceLoading() {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||
<header className="agency-panel space-y-2 p-4">
|
||||
<p className="agency-kicker">Approval Bench</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||
</header>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
@@ -187,10 +211,40 @@ export function OutreachReviewWorkspace() {
|
||||
const [openRaw, setOpenRaw] = useState<Record<string, boolean>>({});
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<ReviewStatusFilter>("all");
|
||||
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null);
|
||||
const [pendingEmailConfirmation, setPendingEmailConfirmation] =
|
||||
useState<PendingEmailConfirmation | null>(null);
|
||||
|
||||
const rows = useMemo<ReviewWorkspaceItem[]>(() => records ?? [], [records]);
|
||||
const reviewStatusFilters: Array<{ label: string; value: ReviewStatusFilter; count: number }> = [
|
||||
{ label: "Alle Reviews", value: "all", count: rows.length },
|
||||
{
|
||||
label: "Bereit zum Versand",
|
||||
value: "ready",
|
||||
count: rows.filter(isReadyToSend).length,
|
||||
},
|
||||
{
|
||||
label: "Mail offen",
|
||||
value: "mail_open",
|
||||
count: rows.filter((row) => !isEmailDraftReady(row)).length,
|
||||
},
|
||||
];
|
||||
const filteredRows = useMemo(() => {
|
||||
if (activeFilter === "ready") {
|
||||
return rows.filter(isReadyToSend);
|
||||
}
|
||||
|
||||
if (activeFilter === "mail_open") {
|
||||
return rows.filter((row) => !isEmailDraftReady(row));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [activeFilter, rows]);
|
||||
const selectedRecord =
|
||||
filteredRows.find((row) => row.id === selectedRecordId) ??
|
||||
filteredRows[0] ??
|
||||
null;
|
||||
|
||||
if (records === undefined) {
|
||||
return <WorkspaceLoading />;
|
||||
@@ -199,11 +253,11 @@ export function OutreachReviewWorkspace() {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||
<header className="agency-panel space-y-2 p-4">
|
||||
<p className="agency-kicker">Approval Bench</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||
</header>
|
||||
<Card>
|
||||
<Card className="agency-panel">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium">Keine offenen Reviews</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
@@ -437,9 +491,9 @@ export function OutreachReviewWorkspace() {
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||
<header className="agency-panel space-y-2 p-4">
|
||||
<p className="agency-kicker">Approval Bench</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Audits, E-Mail-Empfehlung und Telefonnotizen prüfen, bevor etwas öffentlich
|
||||
wird oder eine Freigabe erhält.
|
||||
@@ -447,7 +501,7 @@ export function OutreachReviewWorkspace() {
|
||||
</header>
|
||||
|
||||
{notice ? (
|
||||
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm">{notice}</p>
|
||||
<p className="rounded-md border border-border/75 bg-muted/30 px-3 py-2 text-sm" role="status">{notice}</p>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
@@ -515,7 +569,7 @@ export function OutreachReviewWorkspace() {
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
Senden
|
||||
Final senden
|
||||
</Button>
|
||||
<Button onClick={closeEmailConfirmation} size="sm" type="button" variant="outline">
|
||||
Abbrechen
|
||||
@@ -525,8 +579,110 @@ export function OutreachReviewWorkspace() {
|
||||
) : null}
|
||||
</Dialog>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(18rem,0.78fr)_minmax(0,1.22fr)]">
|
||||
<section className="agency-panel space-y-3 p-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="agency-tab"
|
||||
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">
|
||||
{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 overflow-hidden",
|
||||
selectedRecord?.id === record.id
|
||||
? "border-primary bg-[var(--surface-evidence)]"
|
||||
: "bg-background/60",
|
||||
)}
|
||||
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="agency-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>Keine Treffer</CardTitle>
|
||||
<CardDescription>
|
||||
Für diesen Review-Filter gibt es aktuell keine Einträge.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-3">
|
||||
{rows.map((record) => {
|
||||
{selectedRecord ? (() => {
|
||||
const record = selectedRecord;
|
||||
const draft = drafts[record.id] ?? getDraft(record);
|
||||
const lead = record.lead;
|
||||
const audit = record.audit;
|
||||
@@ -551,8 +707,8 @@ export function OutreachReviewWorkspace() {
|
||||
const publicAuditHref = audit?.slug ? `/audit/${audit.slug}` : null;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden" key={record.id}>
|
||||
<CardHeader className="gap-3 border-b bg-muted/20 p-4">
|
||||
<Card className="agency-panel overflow-hidden" key={record.id}>
|
||||
<CardHeader className="gap-4 border-b bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className="break-words text-lg">
|
||||
@@ -575,11 +731,49 @@ export function OutreachReviewWorkspace() {
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
<div className="evidence-surface rounded-md px-3 py-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<FileSearch className="size-3.5" />
|
||||
Evidence
|
||||
</span>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{audit ? "Audit vorhanden" : "Audit offen"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="review-surface rounded-md px-3 py-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
Public Audit
|
||||
</span>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{audit?.status === "published" ? "Veröffentlicht" : "Prüfung offen"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="safe-surface rounded-md px-3 py-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<MailCheck className="size-3.5" />
|
||||
E-Mail
|
||||
</span>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{isEmailDraftReady(record) ? "Bereit" : "Entwurf offen"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/75 bg-background/70 px-3 py-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<CheckCircle2 className="size-3.5 text-primary" />
|
||||
Final Send
|
||||
</span>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{isQueuedSend ? "Wird gesendet" : "Bestätigung nötig"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5 p-4">
|
||||
<section className="grid gap-4 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<h2 className="text-sm font-semibold">Lead-Details</h2>
|
||||
<dl className="grid gap-3 sm:grid-cols-2">
|
||||
<FieldPair label="Nische" value={lead?.niche} />
|
||||
@@ -608,7 +802,7 @@ export function OutreachReviewWorkspace() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold">Audit-Zusammenfassung</h2>
|
||||
{publicAuditHref ? (
|
||||
@@ -684,7 +878,7 @@ export function OutreachReviewWorkspace() {
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<h2 className="text-sm font-semibold">Empfohlene E-Mail</h2>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
@@ -733,12 +927,12 @@ export function OutreachReviewWorkspace() {
|
||||
type="button"
|
||||
>
|
||||
<MailCheck className="size-3.5" />
|
||||
E-Mail freigeben und senden
|
||||
E-Mail freigeben
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<h2 className="text-sm font-semibold">Telefon & Follow-up</h2>
|
||||
{hasCallablePhone ? (
|
||||
<label className="block space-y-1">
|
||||
@@ -851,7 +1045,8 @@ export function OutreachReviewWorkspace() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
})() : null}
|
||||
</div>
|
||||
</div>
|
||||
</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 { RybbitTracking } from "./rybbit-tracking";
|
||||
import { PublicAuditScreenshot } from "./public-audit-screenshot";
|
||||
import { TrackedPublicAuditLink } from "./tracked-public-audit-link";
|
||||
|
||||
type PublicAuditPageProps = {
|
||||
audit: Extract<PublicAuditRenderState, { kind: "published" }>["audit"];
|
||||
@@ -10,6 +12,7 @@ type PublicAuditPageProps = {
|
||||
export function PublicAuditPage({ audit }: PublicAuditPageProps) {
|
||||
return (
|
||||
<main className="min-h-dvh bg-slate-50 text-slate-950">
|
||||
<RybbitTracking domain={audit.domain} />
|
||||
<section className="border-b border-slate-200 bg-white">
|
||||
<div className="mx-auto grid min-h-[72dvh] w-full max-w-6xl content-center gap-10 px-6 py-14 md:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)] md:px-8">
|
||||
<div className="max-w-3xl">
|
||||
@@ -105,17 +108,11 @@ export function PublicAuditPage({ audit }: PublicAuditPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
{audit.finalOffer.ctaHref ? (
|
||||
<a
|
||||
<TrackedPublicAuditLink
|
||||
domain={audit.domain}
|
||||
href={audit.finalOffer.ctaHref}
|
||||
className="mt-6 inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-slate-950 px-4 text-sm font-semibold text-white transition hover:bg-slate-800 md:mt-0"
|
||||
>
|
||||
{audit.finalOffer.ctaLabel ?? "Audit besprechen"}
|
||||
{audit.finalOffer.ctaHref.startsWith("/") ? (
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<ExternalLink className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</a>
|
||||
label={audit.finalOffer.ctaLabel ?? "Audit besprechen"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,15 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex h-6 items-center rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||
"inline-flex h-6 items-center rounded-md border px-2 py-0.5 text-xs font-semibold",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground",
|
||||
outline:
|
||||
"text-foreground border-border bg-background hover:bg-muted/40",
|
||||
"border-border/90 bg-background/70 text-foreground hover:bg-muted/50",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground",
|
||||
},
|
||||
|
||||
@@ -5,13 +5,14 @@ import { Slot } from "radix-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-semibold whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-[inset_0_1px_0_color-mix(in_oklch,var(--primary-foreground),transparent_82%)] hover:bg-[color-mix(in_oklch,var(--primary),var(--foreground)_10%)]",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
"border-border/90 bg-background/70 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
|
||||
@@ -8,7 +8,10 @@ const Card = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border bg-card text-card-foreground", className)}
|
||||
className={cn(
|
||||
"rounded-lg border border-border/80 bg-card text-card-foreground shadow-[0_1px_0_color-mix(in_oklch,var(--foreground),transparent_94%)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -21,7 +24,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-4", className)}
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -33,7 +36,10 @@ const CardTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-base leading-none font-semibold tracking-normal", className)}
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-semibold tracking-normal",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
"guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57",
|
||||
"agentsMdSectionHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
|
||||
"claudeMdHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
|
||||
"agentSkillsSha": "294d4f05edb5e7b57f3c534b79dd00e8e3d7b60d"
|
||||
"agentSkillsSha": "7a6fcc6882f344577a34365fdadbd0f8f8c467d7"
|
||||
}
|
||||
|
||||
12
convex/_generated/api.d.ts
vendored
12
convex/_generated/api.d.ts
vendored
@@ -11,9 +11,12 @@
|
||||
import type * as auditGeneration from "../auditGeneration.js";
|
||||
import type * as auditGenerationAction from "../auditGenerationAction.js";
|
||||
import type * as auditInputs from "../auditInputs.js";
|
||||
import type * as auditWorkflow from "../auditWorkflow.js";
|
||||
import type * as audits from "../audits.js";
|
||||
import type * as blacklist from "../blacklist.js";
|
||||
import type * as campaignMetrics from "../campaignMetrics.js";
|
||||
import type * as campaigns from "../campaigns.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as domain from "../domain.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as leadDiscovery from "../leadDiscovery.js";
|
||||
@@ -23,8 +26,10 @@ import type * as outreachSendAction from "../outreachSendAction.js";
|
||||
import type * as pageSpeed from "../pageSpeed.js";
|
||||
import type * as pageSpeedAction from "../pageSpeedAction.js";
|
||||
import type * as runs from "../runs.js";
|
||||
import type * as scheduledJobs from "../scheduledJobs.js";
|
||||
import type * as settings from "../settings.js";
|
||||
import type * as storage from "../storage.js";
|
||||
import type * as usageEvents from "../usageEvents.js";
|
||||
import type * as websiteEnrichment from "../websiteEnrichment.js";
|
||||
import type * as websiteEnrichmentAction from "../websiteEnrichmentAction.js";
|
||||
|
||||
@@ -38,9 +43,12 @@ declare const fullApi: ApiFromModules<{
|
||||
auditGeneration: typeof auditGeneration;
|
||||
auditGenerationAction: typeof auditGenerationAction;
|
||||
auditInputs: typeof auditInputs;
|
||||
auditWorkflow: typeof auditWorkflow;
|
||||
audits: typeof audits;
|
||||
blacklist: typeof blacklist;
|
||||
campaignMetrics: typeof campaignMetrics;
|
||||
campaigns: typeof campaigns;
|
||||
crons: typeof crons;
|
||||
domain: typeof domain;
|
||||
http: typeof http;
|
||||
leadDiscovery: typeof leadDiscovery;
|
||||
@@ -50,8 +58,10 @@ declare const fullApi: ApiFromModules<{
|
||||
pageSpeed: typeof pageSpeed;
|
||||
pageSpeedAction: typeof pageSpeedAction;
|
||||
runs: typeof runs;
|
||||
scheduledJobs: typeof scheduledJobs;
|
||||
settings: typeof settings;
|
||||
storage: typeof storage;
|
||||
usageEvents: typeof usageEvents;
|
||||
websiteEnrichment: typeof websiteEnrichment;
|
||||
websiteEnrichmentAction: typeof websiteEnrichmentAction;
|
||||
}>;
|
||||
@@ -84,4 +94,6 @@ export declare const internal: FilterApi<
|
||||
|
||||
export declare const components: {
|
||||
betterAuth: import("../betterAuth/_generated/component.js").ComponentApi<"betterAuth">;
|
||||
workflow: import("@convex-dev/workflow/_generated/component.js").ComponentApi<"workflow">;
|
||||
auditWorkpool: import("@convex-dev/workpool/_generated/component.js").ComponentApi<"auditWorkpool">;
|
||||
};
|
||||
|
||||
@@ -38,6 +38,19 @@ const auditGenerationParsedJson = v.union(
|
||||
v.string(),
|
||||
v.record(v.string(), auditGenerationParsedValue),
|
||||
);
|
||||
const auditFindingEvidenceRef = v.object({
|
||||
id: v.string(),
|
||||
type: v.union(
|
||||
v.literal("crawl_page"),
|
||||
v.literal("technical_check"),
|
||||
v.literal("screenshot"),
|
||||
v.literal("pagespeed"),
|
||||
v.literal("jina_excerpt"),
|
||||
v.literal("generation_stage"),
|
||||
),
|
||||
label: v.string(),
|
||||
sourceUrl: v.optional(v.string()),
|
||||
});
|
||||
|
||||
type AuditGenerationLead = Pick<
|
||||
Doc<"leads">,
|
||||
@@ -89,6 +102,7 @@ type AuditGenerationEvidence = {
|
||||
technicalChecks: AuditGenerationEvidenceTechnicalCheck[];
|
||||
screenshots: AuditGenerationEvidenceScreenshot[];
|
||||
pageSpeedInputs: PageSpeedMinimalAuditResult[];
|
||||
externalMarkdown?: string;
|
||||
};
|
||||
|
||||
function byteLength(value: string) {
|
||||
@@ -191,14 +205,16 @@ const auditGenerationUsage = v.object({
|
||||
|
||||
const secretHints = [
|
||||
"OPENROUTER_API_KEY",
|
||||
"GOOGLE_PLACES_API_KEY",
|
||||
"GOOGLE_GEOCODING_API_KEY",
|
||||
"LOCAL_BUSINESS_DATA_API_KEY",
|
||||
"RAPIDAPI_KEY",
|
||||
"PAGESPEED_API_KEY",
|
||||
"SMTP_PASSWORD",
|
||||
"SMTP_HOST",
|
||||
"SMTP_USER",
|
||||
"BETTER_AUTH_SECRET",
|
||||
"RYBBIT_API_KEY",
|
||||
"SCREENSHOTONE_API_KEY",
|
||||
"JINA_API_KEY",
|
||||
];
|
||||
|
||||
function sanitizeSecretCandidates(value: string | undefined): string | undefined {
|
||||
@@ -226,7 +242,7 @@ function sanitizeSecretCandidates(value: string | undefined): string | undefined
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&");
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
type StartLeadSnapshot = Pick<
|
||||
@@ -249,30 +265,47 @@ export const getAuditGenerationEvidence = internalQuery({
|
||||
return null;
|
||||
}
|
||||
|
||||
const runIdFilter = {
|
||||
table: "by_runId" as const,
|
||||
value: args.runId,
|
||||
};
|
||||
const leadIdFilter = {
|
||||
table: "by_leadId" as const,
|
||||
value: lead._id,
|
||||
};
|
||||
|
||||
const latestSuccessfulEnrichmentRun = await ctx.db
|
||||
.query("agentRuns")
|
||||
.withIndex("by_type_and_status_and_leadId", (q) =>
|
||||
q
|
||||
.eq("type", "website_enrichment")
|
||||
.eq("status", "succeeded")
|
||||
.eq("leadId", lead._id),
|
||||
)
|
||||
.order("desc")
|
||||
.take(1);
|
||||
const enrichmentEvidenceRunId =
|
||||
latestSuccessfulEnrichmentRun[0]?._id ?? args.runId;
|
||||
|
||||
const crawlPagesByRun = await ctx.db
|
||||
.query("websiteCrawlPages")
|
||||
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
|
||||
.withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId))
|
||||
.order("desc")
|
||||
.take(40);
|
||||
|
||||
const technicalChecksByRun = await ctx.db
|
||||
.query("websiteTechnicalChecks")
|
||||
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
|
||||
.withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId))
|
||||
.order("desc")
|
||||
.take(80);
|
||||
|
||||
const screenshotsByRun = await ctx.db
|
||||
const auditCaptureScreenshotsByRun = await ctx.db
|
||||
.query("websiteCrawlScreenshots")
|
||||
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
|
||||
.withIndex("by_runId", (q) => q.eq("runId", args.runId))
|
||||
.order("desc")
|
||||
.take(20);
|
||||
const enrichmentScreenshotsByRun =
|
||||
enrichmentEvidenceRunId === args.runId
|
||||
? []
|
||||
: await ctx.db
|
||||
.query("websiteCrawlScreenshots")
|
||||
.withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId))
|
||||
.order("desc")
|
||||
.take(20);
|
||||
|
||||
@@ -290,7 +323,7 @@ export const getAuditGenerationEvidence = internalQuery({
|
||||
|
||||
const crawlPages = crawlPagesByRun;
|
||||
const technicalChecks = technicalChecksByRun;
|
||||
const screenshots = screenshotsByRun;
|
||||
const screenshots = [...auditCaptureScreenshotsByRun, ...enrichmentScreenshotsByRun];
|
||||
|
||||
return {
|
||||
lead: {
|
||||
@@ -317,6 +350,7 @@ export const queueLeadAuditGeneration = internalMutation({
|
||||
leadId: v.id("leads"),
|
||||
auditId: v.optional(v.id("audits")),
|
||||
parentRunId: v.optional(v.id("agentRuns")),
|
||||
scheduleAction: v.optional(v.boolean()),
|
||||
},
|
||||
returns: v.union(v.id("agentRuns"), v.null()),
|
||||
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
|
||||
@@ -385,6 +419,7 @@ export const queueLeadAuditGeneration = internalMutation({
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
if (args.scheduleAction !== false) {
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.auditGenerationAction.processAuditGeneration,
|
||||
@@ -392,6 +427,7 @@ export const queueLeadAuditGeneration = internalMutation({
|
||||
runId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return runId;
|
||||
},
|
||||
@@ -427,7 +463,11 @@ export const startAuditGenerationRun = internalMutation({
|
||||
const now = Date.now();
|
||||
const run = await ctx.db.get(args.runId);
|
||||
|
||||
if (!run || run.type !== "audit_generation" || run.status !== "pending") {
|
||||
if (
|
||||
!run ||
|
||||
run.type !== "audit_generation" ||
|
||||
(run.status !== "pending" && run.status !== "failed")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -478,6 +518,7 @@ export const startAuditGenerationRun = internalMutation({
|
||||
status: "running",
|
||||
currentStep: "audit_generation",
|
||||
startedAt: now,
|
||||
finishedAt: undefined,
|
||||
updatedAt: now,
|
||||
errorSummary: undefined,
|
||||
});
|
||||
@@ -549,6 +590,86 @@ export const persistAuditGenerationResult = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const replaceAuditFindings = internalMutation({
|
||||
args: {
|
||||
auditId: v.id("audits"),
|
||||
runId: v.id("agentRuns"),
|
||||
findings: v.array(
|
||||
v.object({
|
||||
skillId: v.string(),
|
||||
claim: v.string(),
|
||||
recommendation: v.string(),
|
||||
customerBenefit: v.string(),
|
||||
severity: v.union(v.literal(1), v.literal(2), v.literal(3)),
|
||||
confidence: v.number(),
|
||||
evidenceRefs: v.array(auditFindingEvidenceRef),
|
||||
reviewStatus: v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("accepted"),
|
||||
v.literal("rejected"),
|
||||
),
|
||||
}),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("auditFindings")
|
||||
.withIndex("by_auditId", (q) => q.eq("auditId", args.auditId))
|
||||
.collect();
|
||||
|
||||
for (const finding of existing) {
|
||||
await ctx.db.delete(finding._id);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const finding of args.findings) {
|
||||
await ctx.db.insert("auditFindings", {
|
||||
auditId: args.auditId,
|
||||
runId: args.runId,
|
||||
skillId: finding.skillId,
|
||||
claim: finding.claim,
|
||||
recommendation: finding.recommendation,
|
||||
customerBenefit: finding.customerBenefit,
|
||||
severity: finding.severity,
|
||||
confidence: finding.confidence,
|
||||
evidenceRefs: finding.evidenceRefs,
|
||||
reviewStatus: finding.reviewStatus,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const persistExternalCaptureScreenshot = internalMutation({
|
||||
args: {
|
||||
leadId: v.id("leads"),
|
||||
runId: v.id("agentRuns"),
|
||||
storageId: v.id("_storage"),
|
||||
viewport: v.union(v.literal("desktop"), v.literal("mobile")),
|
||||
sourceUrl: v.string(),
|
||||
capturedAt: v.number(),
|
||||
width: v.number(),
|
||||
height: v.number(),
|
||||
mimeType: v.string(),
|
||||
},
|
||||
returns: v.id("websiteCrawlScreenshots"),
|
||||
handler: async (ctx, args): Promise<Id<"websiteCrawlScreenshots">> => {
|
||||
return await ctx.db.insert("websiteCrawlScreenshots", {
|
||||
leadId: args.leadId,
|
||||
runId: args.runId,
|
||||
storageId: args.storageId,
|
||||
viewport: args.viewport,
|
||||
sourceUrl: args.sourceUrl,
|
||||
capturedAt: args.capturedAt,
|
||||
width: args.width,
|
||||
height: args.height,
|
||||
mimeType: args.mimeType,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const finishAuditGenerationRun = internalMutation({
|
||||
args: {
|
||||
runId: v.id("agentRuns"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
238
convex/auditWorkflow.ts
Normal file
238
convex/auditWorkflow.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { WorkflowManager, type WorkflowId } from "@convex-dev/workflow";
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { components, internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { internalMutation } from "./_generated/server";
|
||||
import { getAuditProgressForStep } from "../lib/audits/progress";
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
export const workflow = new WorkflowManager(components.workflow, {
|
||||
workpoolOptions: {
|
||||
maxParallelism: 3,
|
||||
retryActionsByDefault: true,
|
||||
defaultRetryBehavior: {
|
||||
maxAttempts: 3,
|
||||
initialBackoffMs: 1000,
|
||||
base: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function progressPatch(runId: Id<"agentRuns">, currentStep: string) {
|
||||
const progress = getAuditProgressForStep(currentStep);
|
||||
|
||||
return {
|
||||
id: runId,
|
||||
currentStep,
|
||||
progressStep: progress.step,
|
||||
progressTotal: progress.total,
|
||||
progressLabel: progress.label,
|
||||
progressPercent: progress.percent,
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export const runLeadAuditWorkflow = workflow
|
||||
.define({
|
||||
args: {
|
||||
runId: v.id("agentRuns"),
|
||||
},
|
||||
})
|
||||
.handler(async (step, args): Promise<Id<"agentRuns">> => {
|
||||
try {
|
||||
await step.runMutation(
|
||||
internal.runs.updateProgressInternal,
|
||||
{
|
||||
...progressPatch(args.runId, "audit_prepared"),
|
||||
status: "running",
|
||||
maxAttempts: MAX_ATTEMPTS,
|
||||
},
|
||||
{ name: "1/6 Audit vorbereitet" },
|
||||
);
|
||||
|
||||
const [, pageSpeedResult] = await Promise.all([
|
||||
step.runMutation(
|
||||
internal.runs.updateProgressInternal,
|
||||
{
|
||||
...progressPatch(args.runId, "pagespeed_insights"),
|
||||
status: "running",
|
||||
},
|
||||
{ name: "2/6 Messe PageSpeed" },
|
||||
),
|
||||
step.runAction(
|
||||
internal.pageSpeedAction.processPageSpeedAuditForWorkflow,
|
||||
{ runId: args.runId },
|
||||
{ name: "PageSpeed mobile/desktop", retry: true },
|
||||
),
|
||||
]);
|
||||
|
||||
if (!pageSpeedResult) {
|
||||
throw new Error("PageSpeed-Analyse konnte nicht abgeschlossen werden.");
|
||||
}
|
||||
|
||||
const auditRun = await step.runQuery(
|
||||
internal.runs.getAuditRunForWorkflowInternal,
|
||||
{ id: args.runId },
|
||||
{ name: "Audit-Run laden" },
|
||||
);
|
||||
|
||||
if (!auditRun?.leadId) {
|
||||
throw new Error("Audit-Run hat keine Lead-ID.");
|
||||
}
|
||||
if (auditRun.status === "failed" || auditRun.status === "canceled") {
|
||||
throw new Error("PageSpeed-Analyse ist final fehlgeschlagen.");
|
||||
}
|
||||
|
||||
await step.runMutation(
|
||||
internal.runs.updateProgressInternal,
|
||||
{
|
||||
...progressPatch(args.runId, "website_signals"),
|
||||
status: "running",
|
||||
},
|
||||
{ name: "3/6 Sammle Website-Signale" },
|
||||
);
|
||||
|
||||
const generationRunId = await step.runMutation(
|
||||
internal.auditGeneration.queueLeadAuditGeneration,
|
||||
{
|
||||
leadId: auditRun.leadId,
|
||||
...(auditRun.auditId ? { auditId: auditRun.auditId } : {}),
|
||||
parentRunId: args.runId,
|
||||
scheduleAction: false,
|
||||
},
|
||||
{ name: "Audit-Generierung vorbereiten" },
|
||||
);
|
||||
|
||||
if (!generationRunId) {
|
||||
throw new Error("Audit-Generierung konnte nicht angelegt werden.");
|
||||
}
|
||||
|
||||
await step.runMutation(
|
||||
internal.runs.updateProgressInternal,
|
||||
{
|
||||
...progressPatch(args.runId, "classification"),
|
||||
status: "running",
|
||||
},
|
||||
{ name: "4/6 Bewerte Befunde" },
|
||||
);
|
||||
|
||||
const generationResult = await step.runAction(
|
||||
internal.auditGenerationAction.processAuditGenerationForWorkflow,
|
||||
{ runId: generationRunId, rootRunId: args.runId },
|
||||
{ name: "Specialists und German Copy", retry: true },
|
||||
);
|
||||
|
||||
if (!generationResult) {
|
||||
throw new Error("Audit-Generierung konnte nicht abgeschlossen werden.");
|
||||
}
|
||||
|
||||
await step.runMutation(
|
||||
internal.runs.updateProgressInternal,
|
||||
{
|
||||
...progressPatch(args.runId, "qualityReview"),
|
||||
status: "succeeded",
|
||||
},
|
||||
{ name: "6/6 Speichere Audit" },
|
||||
);
|
||||
|
||||
return args.runId;
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
await step.runMutation(
|
||||
internal.runs.updateProgressInternal,
|
||||
{
|
||||
id: args.runId,
|
||||
status: "failed",
|
||||
errorSummary: "Audit konnte nach automatischen Versuchen nicht abgeschlossen werden.",
|
||||
lastRetryReason: message,
|
||||
},
|
||||
{ name: "Audit final fehlgeschlagen", unstableArgs: true },
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const startLeadAuditWorkflow = internalMutation({
|
||||
args: {
|
||||
runId: v.id("agentRuns"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<WorkflowId> => {
|
||||
const workflowId: WorkflowId = await workflow.start(
|
||||
ctx,
|
||||
internal.auditWorkflow.runLeadAuditWorkflow,
|
||||
{ runId: args.runId },
|
||||
{ startAsync: true },
|
||||
);
|
||||
|
||||
await ctx.db.patch(args.runId, {
|
||||
workflowId: String(workflowId),
|
||||
attempt: 1,
|
||||
maxAttempts: MAX_ATTEMPTS,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
return workflowId;
|
||||
},
|
||||
});
|
||||
|
||||
export const restartAuditWorkflow = internalMutation({
|
||||
args: {
|
||||
runId: v.id("agentRuns"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<string> => {
|
||||
const run = await ctx.db.get(args.runId);
|
||||
if (!run || run.type !== "audit") {
|
||||
throw new Error("Audit-Run wurde nicht gefunden.");
|
||||
}
|
||||
|
||||
const nextAttempt = (run.attempt ?? 1) + 1;
|
||||
const maxAttempts = run.maxAttempts ?? MAX_ATTEMPTS;
|
||||
if (nextAttempt > maxAttempts) {
|
||||
throw new Error("Maximale Anzahl an Audit-Versuchen erreicht.");
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.runId, {
|
||||
status: "pending",
|
||||
currentStep: "pagespeed_insights",
|
||||
errorSummary: undefined,
|
||||
lastRetryReason:
|
||||
"Provider war kurz nicht erreichbar, ich versuche es erneut",
|
||||
attempt: nextAttempt,
|
||||
maxAttempts,
|
||||
progressStep: 1,
|
||||
progressTotal: 6,
|
||||
progressLabel: "Audit vorbereitet",
|
||||
progressPercent: 17,
|
||||
startedAt: undefined,
|
||||
finishedAt: undefined,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
if (run.workflowId) {
|
||||
await workflow.restart(ctx, run.workflowId as WorkflowId, {
|
||||
from: 0,
|
||||
startAsync: true,
|
||||
});
|
||||
return run.workflowId;
|
||||
}
|
||||
|
||||
const workflowId: WorkflowId = await workflow.start(
|
||||
ctx,
|
||||
internal.auditWorkflow.runLeadAuditWorkflow,
|
||||
{ runId: args.runId },
|
||||
{ startAsync: true },
|
||||
);
|
||||
|
||||
await ctx.db.patch(args.runId, {
|
||||
workflowId: String(workflowId),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
return String(workflowId);
|
||||
},
|
||||
});
|
||||
469
convex/audits.ts
469
convex/audits.ts
@@ -1,10 +1,14 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { normalizeListLimit } from "./domain";
|
||||
import { internal } from "./_generated/api";
|
||||
import { internalMutation, mutation, query } from "./_generated/server";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { getAuditProgressForStep } from "../lib/audits/progress";
|
||||
|
||||
export const AUDIT_REVIEW_NOTICE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const DETAIL_EVIDENCE_LIMIT = 50;
|
||||
|
||||
const auditStatus = v.union(
|
||||
v.literal("draft"),
|
||||
@@ -14,8 +18,9 @@ const auditStatus = v.union(
|
||||
);
|
||||
const usedSkillsValidator = v.array(
|
||||
v.object({
|
||||
id: v.optional(v.string()),
|
||||
name: v.string(),
|
||||
category: v.string(),
|
||||
category: v.optional(v.string()),
|
||||
version: v.optional(v.string()),
|
||||
source: v.optional(v.string()),
|
||||
}),
|
||||
@@ -40,13 +45,181 @@ const publicOfferValidator = v.object({
|
||||
ctaHref: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const requireOperator = async (ctx: MutationCtx) => {
|
||||
type AuditDashboardRow =
|
||||
| {
|
||||
kind: "audit";
|
||||
id: Id<"audits">;
|
||||
auditId: Id<"audits">;
|
||||
slug: string;
|
||||
title: string;
|
||||
checkedDomain: string;
|
||||
status: Doc<"audits">["status"];
|
||||
pageCount: number;
|
||||
checkedPages: string[];
|
||||
detailHref: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
| {
|
||||
kind: "generation";
|
||||
id: Id<"agentRuns">;
|
||||
runId: Id<"agentRuns">;
|
||||
leadId: Id<"leads"> | null;
|
||||
runType: Doc<"agentRuns">["type"];
|
||||
title: string;
|
||||
checkedDomain: string;
|
||||
status: Doc<"agentRuns">["status"];
|
||||
latestStage: string;
|
||||
stageStatus: Doc<"agentRuns">["status"];
|
||||
errorSummary: string | null;
|
||||
progress: {
|
||||
step: number;
|
||||
total: number;
|
||||
label: string;
|
||||
percent: number;
|
||||
};
|
||||
retry: {
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
isRetrying: boolean;
|
||||
lastRetryReason: string | null;
|
||||
canRetry: boolean;
|
||||
};
|
||||
canRetry: boolean;
|
||||
pageCount: number;
|
||||
checkedPages: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const requireOperator = async (ctx: MutationCtx | QueryCtx) => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
throw new Error("Nicht autorisiert.");
|
||||
}
|
||||
};
|
||||
|
||||
const domainFromLead = (
|
||||
lead: Pick<Doc<"leads">, "companyName" | "websiteDomain" | "websiteUrl"> | null,
|
||||
) => {
|
||||
if (lead?.websiteDomain) {
|
||||
return lead.websiteDomain;
|
||||
}
|
||||
|
||||
if (lead?.websiteUrl) {
|
||||
try {
|
||||
return new URL(lead.websiteUrl).hostname;
|
||||
} catch {
|
||||
return lead.websiteUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return "unbekannte-domain";
|
||||
};
|
||||
|
||||
const latestGenerationStage = (stages: Doc<"auditGenerations">[]) => {
|
||||
return [...stages].sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
|
||||
};
|
||||
|
||||
const progressForRun = (
|
||||
run: Doc<"agentRuns">,
|
||||
latestStage: Doc<"auditGenerations"> | null,
|
||||
) => {
|
||||
const fallback = getAuditProgressForStep(latestStage?.stage ?? run.currentStep);
|
||||
|
||||
return {
|
||||
step: run.progressStep ?? fallback.step,
|
||||
total: run.progressTotal ?? fallback.total,
|
||||
label: run.progressLabel ?? fallback.label,
|
||||
percent: run.progressPercent ?? fallback.percent,
|
||||
};
|
||||
};
|
||||
|
||||
const retryForRun = (run: Doc<"agentRuns">) => {
|
||||
const attempt = run.attempt ?? 1;
|
||||
const maxAttempts = run.maxAttempts ?? 3;
|
||||
const canRetry =
|
||||
run.type === "audit" &&
|
||||
(run.status === "failed" || run.status === "canceled") &&
|
||||
attempt < maxAttempts;
|
||||
|
||||
return {
|
||||
attempt,
|
||||
maxAttempts,
|
||||
isRetrying:
|
||||
(run.status === "pending" || run.status === "running") && attempt > 1,
|
||||
lastRetryReason: run.lastRetryReason ?? null,
|
||||
canRetry,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeComparableAuditUrl = (value: string | null | undefined) => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalizeParsedUrl = (parsedUrl: URL) => {
|
||||
const hostname = parsedUrl.hostname.toLowerCase().replace(/^www\./, "");
|
||||
const pathname = parsedUrl.pathname.replace(/\/+$/, "");
|
||||
return `${hostname}${pathname}${parsedUrl.search}`.toLowerCase();
|
||||
};
|
||||
|
||||
try {
|
||||
return normalizeParsedUrl(new URL(trimmed));
|
||||
} catch {
|
||||
try {
|
||||
return normalizeParsedUrl(new URL(`https://${trimmed}`));
|
||||
} catch {
|
||||
return trimmed
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^www\./, "")
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setIfPresent = <T>(
|
||||
target: Map<string, T>,
|
||||
url: string | null | undefined,
|
||||
value: T,
|
||||
) => {
|
||||
const key = normalizeComparableAuditUrl(url);
|
||||
if (key && !target.has(key)) {
|
||||
target.set(key, value);
|
||||
}
|
||||
};
|
||||
|
||||
const findByUrl = <T>(source: Map<string, T>, ...urls: Array<string | null | undefined>) => {
|
||||
for (const url of urls) {
|
||||
const key = normalizeComparableAuditUrl(url);
|
||||
if (key && source.has(key)) {
|
||||
return source.get(key) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const fallbackCheckedPageEvidence = (url: string) => ({
|
||||
url,
|
||||
sourceUrl: null,
|
||||
finalUrl: null,
|
||||
pageKind: null,
|
||||
title: null,
|
||||
metaDescription: null,
|
||||
headings: [],
|
||||
visibleTextExcerpt: null,
|
||||
hasContactFormSignal: null,
|
||||
hasContactCtaSignal: null,
|
||||
usesHttps: null,
|
||||
missingMetaDescription: null,
|
||||
brokenInternalLinkCount: null,
|
||||
screenshots: [],
|
||||
createdAt: null,
|
||||
});
|
||||
|
||||
const toIsoDate = (timestamp: number | undefined, fallback: number) => {
|
||||
return new Date(timestamp ?? fallback).toISOString();
|
||||
};
|
||||
@@ -124,6 +297,8 @@ export const create = mutation({
|
||||
ctaType: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
|
||||
const now = Date.now();
|
||||
const existing = await ctx.db
|
||||
.query("audits")
|
||||
@@ -146,19 +321,149 @@ export const create = mutation({
|
||||
export const getDetail = query({
|
||||
args: { id: v.id("audits") },
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
|
||||
const audit = await ctx.db.get(args.id);
|
||||
if (!audit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lead = await ctx.db.get(audit.leadId);
|
||||
return { audit, lead };
|
||||
const latestSuccessfulEnrichmentRun = await ctx.db
|
||||
.query("agentRuns")
|
||||
.withIndex("by_type_and_status_and_leadId", (q) =>
|
||||
q
|
||||
.eq("type", "website_enrichment")
|
||||
.eq("status", "succeeded")
|
||||
.eq("leadId", audit.leadId),
|
||||
)
|
||||
.order("desc")
|
||||
.take(1);
|
||||
const enrichmentRunId = latestSuccessfulEnrichmentRun[0]?._id ?? null;
|
||||
|
||||
const crawlPages = enrichmentRunId
|
||||
? await ctx.db
|
||||
.query("websiteCrawlPages")
|
||||
.withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId))
|
||||
.order("desc")
|
||||
.take(DETAIL_EVIDENCE_LIMIT)
|
||||
: [];
|
||||
const technicalChecks = enrichmentRunId
|
||||
? await ctx.db
|
||||
.query("websiteTechnicalChecks")
|
||||
.withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId))
|
||||
.order("desc")
|
||||
.take(DETAIL_EVIDENCE_LIMIT)
|
||||
: [];
|
||||
const crawlScreenshots = enrichmentRunId
|
||||
? await ctx.db
|
||||
.query("websiteCrawlScreenshots")
|
||||
.withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId))
|
||||
.order("desc")
|
||||
.take(DETAIL_EVIDENCE_LIMIT)
|
||||
: [];
|
||||
const findings = await ctx.db
|
||||
.query("auditFindings")
|
||||
.withIndex("by_auditId", (q) => q.eq("auditId", audit._id))
|
||||
.order("desc")
|
||||
.take(DETAIL_EVIDENCE_LIMIT);
|
||||
|
||||
const pagesByUrl = new Map<string, Doc<"websiteCrawlPages">>();
|
||||
for (const page of crawlPages) {
|
||||
setIfPresent(pagesByUrl, page.sourceUrl, page);
|
||||
setIfPresent(pagesByUrl, page.finalUrl, page);
|
||||
}
|
||||
|
||||
const checksByUrl = new Map<string, Doc<"websiteTechnicalChecks">>();
|
||||
for (const checks of technicalChecks) {
|
||||
setIfPresent(checksByUrl, checks.sourceUrl, checks);
|
||||
setIfPresent(checksByUrl, checks.finalUrl, checks);
|
||||
}
|
||||
|
||||
const screenshotsByUrl = new Map<
|
||||
string,
|
||||
Array<{
|
||||
id: Id<"_storage">;
|
||||
url: string;
|
||||
viewport: Doc<"websiteCrawlScreenshots">["viewport"];
|
||||
sourceUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
createdAt: number;
|
||||
}>
|
||||
>();
|
||||
for (const screenshot of crawlScreenshots) {
|
||||
const url = await ctx.storage.getUrl(screenshot.storageId);
|
||||
if (!url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = normalizeComparableAuditUrl(screenshot.sourceUrl);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = screenshotsByUrl.get(key) ?? [];
|
||||
current.push({
|
||||
id: screenshot.storageId,
|
||||
url,
|
||||
viewport: screenshot.viewport,
|
||||
sourceUrl: screenshot.sourceUrl,
|
||||
width: screenshot.width,
|
||||
height: screenshot.height,
|
||||
createdAt: screenshot.createdAt,
|
||||
});
|
||||
screenshotsByUrl.set(key, current);
|
||||
}
|
||||
|
||||
const checkedPages = audit.checkedPages.map((checkedUrl) => {
|
||||
const page = findByUrl(pagesByUrl, checkedUrl);
|
||||
if (!page) {
|
||||
return fallbackCheckedPageEvidence(checkedUrl);
|
||||
}
|
||||
|
||||
const checks = findByUrl(checksByUrl, checkedUrl, page.sourceUrl, page.finalUrl);
|
||||
const screenshots = [
|
||||
...(
|
||||
findByUrl(screenshotsByUrl, checkedUrl, page.sourceUrl, page.finalUrl) ?? []
|
||||
),
|
||||
].sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
return {
|
||||
url: checkedUrl,
|
||||
sourceUrl: page.sourceUrl,
|
||||
finalUrl: page.finalUrl,
|
||||
pageKind: page.pageKind,
|
||||
title: page.title ?? null,
|
||||
metaDescription: page.metaDescription ?? null,
|
||||
headings: page.headings.slice(0, DETAIL_EVIDENCE_LIMIT),
|
||||
visibleTextExcerpt: page.visibleTextExcerpt ?? null,
|
||||
hasContactFormSignal: page.hasContactFormSignal,
|
||||
hasContactCtaSignal: page.hasContactCtaSignal,
|
||||
usesHttps: checks?.usesHttps ?? null,
|
||||
missingMetaDescription: checks?.missingMetaDescription ?? null,
|
||||
brokenInternalLinkCount: checks?.brokenInternalLinkCount ?? null,
|
||||
screenshots,
|
||||
createdAt: page.createdAt,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
audit,
|
||||
lead,
|
||||
findings,
|
||||
sourceSummaries: {
|
||||
checkedPages,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { id: v.id("audits") },
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
|
||||
return await ctx.db.get(args.id);
|
||||
},
|
||||
});
|
||||
@@ -247,6 +552,8 @@ export const upsertFromAuditGeneration = internalMutation({
|
||||
export const getBySlug = query({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
|
||||
const audits = await ctx.db
|
||||
.query("audits")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
||||
@@ -441,6 +748,8 @@ export const list = query({
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
|
||||
const limit = normalizeListLimit(args.limit);
|
||||
|
||||
if (args.leadId) {
|
||||
@@ -466,3 +775,157 @@ export const list = query({
|
||||
return await ctx.db.query("audits").order("desc").take(limit);
|
||||
},
|
||||
});
|
||||
|
||||
export const retryAuditRun = mutation({
|
||||
args: {
|
||||
runId: v.id("agentRuns"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
|
||||
const run = await ctx.db.get(args.runId);
|
||||
if (!run || run.type !== "audit") {
|
||||
throw new Error("Audit-Run wurde nicht gefunden.");
|
||||
}
|
||||
|
||||
const status = run.status;
|
||||
if (status !== "failed" && status !== "canceled") {
|
||||
throw new Error("Nur final fehlgeschlagene Audits können neu gestartet werden.");
|
||||
}
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.auditWorkflow.restartAuditWorkflow, {
|
||||
runId: args.runId,
|
||||
});
|
||||
|
||||
return { runId: args.runId };
|
||||
},
|
||||
});
|
||||
|
||||
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 rootAuditRuns = await ctx.db
|
||||
.query("agentRuns")
|
||||
.withIndex("by_type", (q) => q.eq("type", "audit"))
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
const rootAuditRunLeadIds = new Set(
|
||||
rootAuditRuns
|
||||
.map((run) => run.leadId)
|
||||
.filter((leadId): leadId is Id<"leads"> => leadId !== undefined),
|
||||
);
|
||||
|
||||
const generationRuns = await ctx.db
|
||||
.query("agentRuns")
|
||||
.withIndex("by_type", (q) => q.eq("type", "audit_generation"))
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
|
||||
for (const run of [...rootAuditRuns, ...generationRuns]) {
|
||||
if (!run.leadId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
run.type === "audit_generation" &&
|
||||
rootAuditRunLeadIds.has(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);
|
||||
|
||||
const shouldHideBehindFinalAudit =
|
||||
run.status === "succeeded" || run.type === "audit_generation";
|
||||
|
||||
if (
|
||||
(shouldHideBehindFinalAudit && finalAuditRunIds.has(run._id)) ||
|
||||
(shouldHideBehindFinalAudit && run.auditId && finalAuditIds.has(run.auditId)) ||
|
||||
(shouldHideBehindFinalAudit && directFinalAudit) ||
|
||||
(shouldHideBehindFinalAudit && finalAuditLeadIds.has(run.leadId)) ||
|
||||
(shouldHideBehindFinalAudit && 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);
|
||||
const progress = progressForRun(run, latestStage);
|
||||
const retry = retryForRun(run);
|
||||
|
||||
rows.push({
|
||||
kind: "generation",
|
||||
id: run._id,
|
||||
runId: run._id,
|
||||
leadId: run.leadId,
|
||||
runType: run.type,
|
||||
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,
|
||||
progress,
|
||||
retry,
|
||||
canRetry: retry.canRetry,
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ const blacklistType = v.union(
|
||||
v.literal("phone"),
|
||||
v.literal("company"),
|
||||
v.literal("google_place_id"),
|
||||
v.literal("source_business_id"),
|
||||
);
|
||||
|
||||
type BlacklistType =
|
||||
@@ -24,7 +25,8 @@ type BlacklistType =
|
||||
| "email"
|
||||
| "phone"
|
||||
| "company"
|
||||
| "google_place_id";
|
||||
| "google_place_id"
|
||||
| "source_business_id";
|
||||
|
||||
const BLACKLIST_APPLY_BATCH_SIZE = 100;
|
||||
const BLACKLIST_REVIEW_NOTE_PREFIX =
|
||||
@@ -51,6 +53,7 @@ type LeadMatchingFieldsPatch = Partial<
|
||||
| "normalizedCompanyName"
|
||||
| "normalizedAddress"
|
||||
| "normalizedGooglePlaceId"
|
||||
| "normalizedSourceBusinessId"
|
||||
>
|
||||
> & {
|
||||
updatedAt: number;
|
||||
@@ -138,6 +141,13 @@ function getLeadMatchQuery(
|
||||
.withIndex("by_normalizedGooglePlaceId", (q) =>
|
||||
q.eq("normalizedGooglePlaceId", normalizedValue),
|
||||
);
|
||||
case "source_business_id":
|
||||
return () =>
|
||||
ctx.db
|
||||
.query("leads")
|
||||
.withIndex("by_normalizedSourceBusinessId", (q) =>
|
||||
q.eq("normalizedSourceBusinessId", normalizedValue),
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -152,6 +162,9 @@ function buildLeadMatchingFieldsPatch(lead: Doc<"leads">) {
|
||||
const normalizedCompanyName = normalizeText(lead.companyName);
|
||||
const normalizedAddress = normalizeText(lead.address);
|
||||
const normalizedGooglePlaceId = normalizeDomain(lead.googlePlaceId);
|
||||
const normalizedSourceBusinessId = normalizeDomain(
|
||||
lead.sourceBusinessId ?? lead.googlePlaceId,
|
||||
);
|
||||
|
||||
if (!lead.normalizedEmail && normalizedEmail) {
|
||||
patch.normalizedEmail = normalizedEmail;
|
||||
@@ -168,6 +181,9 @@ function buildLeadMatchingFieldsPatch(lead: Doc<"leads">) {
|
||||
if (!lead.normalizedGooglePlaceId && normalizedGooglePlaceId) {
|
||||
patch.normalizedGooglePlaceId = normalizedGooglePlaceId;
|
||||
}
|
||||
if (!lead.normalizedSourceBusinessId && normalizedSourceBusinessId) {
|
||||
patch.normalizedSourceBusinessId = normalizedSourceBusinessId;
|
||||
}
|
||||
|
||||
return Object.keys(patch).length > 1 ? patch : null;
|
||||
}
|
||||
@@ -200,6 +216,7 @@ function normalizeBlacklistValue(type: BlacklistType, value: string) {
|
||||
return normalizePhone(trimmed);
|
||||
case "domain":
|
||||
case "google_place_id":
|
||||
case "source_business_id":
|
||||
return normalizeDomain(trimmed);
|
||||
case "company":
|
||||
return normalizeText(trimmed);
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { defineApp } from "convex/server";
|
||||
import workflow from "@convex-dev/workflow/convex.config";
|
||||
import auditWorkpool from "@convex-dev/workpool/convex.config";
|
||||
|
||||
import betterAuth from "./betterAuth/convex.config";
|
||||
|
||||
const app = defineApp();
|
||||
|
||||
app.use(betterAuth);
|
||||
app.use(workflow);
|
||||
app.use(auditWorkpool, { name: "auditWorkpool" });
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -5,13 +5,13 @@ import { internal } from "./_generated/api";
|
||||
const crons = cronJobs();
|
||||
|
||||
crons.interval(
|
||||
"Kampagnen nach Cadence starten",
|
||||
"campaign cadence runner",
|
||||
{ hours: 1 },
|
||||
internal.scheduledJobs.runDueCampaigns,
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"Audit-Lifecycle prüfen",
|
||||
"audit lifecycle runner",
|
||||
{ hours: 24 },
|
||||
internal.scheduledJobs.runAuditLifecycle,
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ const SECRET_KEY_PATTERNS = [
|
||||
/credential/i,
|
||||
/smtp/i,
|
||||
/openrouter/i,
|
||||
/rapidapi/i,
|
||||
/local[_-]?business[_-]?data/i,
|
||||
/google[_-]?(geocoding|places)?/i,
|
||||
/pagespeed/i,
|
||||
/rybbit/i,
|
||||
@@ -77,6 +79,7 @@ export const BLACKLIST_TYPES = [
|
||||
"phone",
|
||||
"company",
|
||||
"google_place_id",
|
||||
"source_business_id",
|
||||
] as const;
|
||||
export const RUN_TYPES = [
|
||||
"campaign",
|
||||
@@ -96,6 +99,12 @@ export const RUN_STATUSES = [
|
||||
] as const;
|
||||
export const AUDIT_GENERATION_STAGES = [
|
||||
"classification",
|
||||
"localSeoSpecialist",
|
||||
"conversionUxSpecialist",
|
||||
"visualTrustSpecialist",
|
||||
"critiqueSpecialist",
|
||||
"performanceAccessibilitySpecialist",
|
||||
"evidenceVerifier",
|
||||
"multimodalAudit",
|
||||
"germanCopy",
|
||||
"qualityReview",
|
||||
@@ -119,6 +128,19 @@ export const PAGE_SPEED_ERROR_TYPES = [
|
||||
"api_error",
|
||||
"unknown",
|
||||
] as const;
|
||||
export const USAGE_EVENT_PROVIDERS = [
|
||||
"openrouter",
|
||||
"screenshotone",
|
||||
"jina",
|
||||
"pagespeed",
|
||||
"google_places",
|
||||
"local_business_data",
|
||||
] as const;
|
||||
export const USAGE_EVENT_OPERATIONS = [
|
||||
"audit_capture",
|
||||
"audit_generation",
|
||||
"lead_lookup",
|
||||
] as const;
|
||||
|
||||
export type CampaignStatus = (typeof CAMPAIGN_STATUSES)[number];
|
||||
export type LeadPriority = (typeof LEAD_PRIORITIES)[number];
|
||||
@@ -143,6 +165,8 @@ export type ScreenshotViewport = (typeof SCREENSHOT_VIEWPORTS)[number];
|
||||
export type PageSpeedStrategy = (typeof PAGE_SPEED_STRATEGIES)[number];
|
||||
export type PageSpeedResultStatus = (typeof PAGE_SPEED_RESULT_STATUSES)[number];
|
||||
export type PageSpeedErrorType = (typeof PAGE_SPEED_ERROR_TYPES)[number];
|
||||
export type UsageEventProvider = (typeof USAGE_EVENT_PROVIDERS)[number];
|
||||
export type UsageEventOperation = (typeof USAGE_EVENT_OPERATIONS)[number];
|
||||
|
||||
export type SettingsRow = {
|
||||
key: string;
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import {
|
||||
GOOGLE_PLACES_FIELD_MASK,
|
||||
buildGeocodingUrl,
|
||||
getBlacklistLookupValues,
|
||||
getBlacklistMatches,
|
||||
getCandidateEmailValues,
|
||||
getPlacesSearchSpec,
|
||||
getUsableContactEmail,
|
||||
normalizeDomain,
|
||||
normalizePhone,
|
||||
normalizeText,
|
||||
normalizePlacesResponse,
|
||||
parseGeocodingResponse,
|
||||
} from "../lib/lead-discovery-google";
|
||||
import {
|
||||
LOCAL_BUSINESS_DATA_HOST,
|
||||
getLocalBusinessSearchSpec,
|
||||
normalizeLocalBusinessSearchResponse,
|
||||
} from "../lib/lead-discovery-local-business";
|
||||
import {
|
||||
buildLeadDiscoveryLeadRecord,
|
||||
buildLeadDiscoveryCounters,
|
||||
getLeadDiscoveryPriority,
|
||||
shouldScheduleWebsiteEnrichment,
|
||||
} from "../lib/lead-discovery-run";
|
||||
import { calculateNextRunAt } from "../lib/campaign-scheduling";
|
||||
|
||||
@@ -26,12 +26,21 @@ import { Doc, Id } from "./_generated/dataModel";
|
||||
import { internalAction, internalMutation } from "./_generated/server";
|
||||
|
||||
type CampaignDoc = Doc<"campaigns">;
|
||||
type DuplicateEmailBackfillPatch = Partial<
|
||||
Pick<
|
||||
Doc<"leads">,
|
||||
"normalizedEmail" | "email" | "emailSource" | "contactPerson" | "contactStatus" | "contactStatusReason"
|
||||
>
|
||||
> & {
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const nullableString = v.union(v.string(), v.null());
|
||||
const nullableNumber = v.union(v.number(), v.null());
|
||||
|
||||
const candidateValidator = v.object({
|
||||
placeId: v.string(),
|
||||
sourceBusinessId: v.optional(nullableString),
|
||||
businessName: v.string(),
|
||||
address: v.string(),
|
||||
websiteUrl: nullableString,
|
||||
@@ -57,7 +66,10 @@ const candidateValidator = v.object({
|
||||
}),
|
||||
),
|
||||
),
|
||||
sourceProvider: v.literal("google_places"),
|
||||
sourceProvider: v.union(
|
||||
v.literal("google_places"),
|
||||
v.literal("local_business_data"),
|
||||
),
|
||||
sourceFetchedAt: v.number(),
|
||||
});
|
||||
|
||||
@@ -98,7 +110,7 @@ async function fetchJson(url: string, init?: RequestInit) {
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(
|
||||
`Google API request failed with HTTP ${response.status}: ${body.slice(0, 500)}`,
|
||||
`Local Business Data request failed with HTTP ${response.status}: ${body.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,89 +134,54 @@ export const processCampaignRun = internalAction({
|
||||
}
|
||||
|
||||
try {
|
||||
const geocodingApiKey = getRequiredEnv("GOOGLE_GEOCODING_API_KEY");
|
||||
const placesApiKey = getRequiredEnv("GOOGLE_PLACES_API_KEY");
|
||||
const localBusinessDataApiKey = getRequiredEnv("LOCAL_BUSINESS_DATA_API_KEY");
|
||||
const campaign = started.campaign;
|
||||
const fetchedAt = Date.now();
|
||||
let latitude = campaign.latitude;
|
||||
let longitude = campaign.longitude;
|
||||
|
||||
if (typeof latitude !== "number" || typeof longitude !== "number") {
|
||||
const geocodingUrl = buildGeocodingUrl({
|
||||
postalCode: campaign.postalCode,
|
||||
apiKey: geocodingApiKey,
|
||||
});
|
||||
const geocodingJson = await fetchJson(geocodingUrl);
|
||||
const geocoding = parseGeocodingResponse(geocodingJson, fetchedAt);
|
||||
|
||||
latitude = geocoding.latitude;
|
||||
longitude = geocoding.longitude;
|
||||
|
||||
await ctx.runMutation(internal.leadDiscovery.cacheCampaignGeocode, {
|
||||
campaignId: campaign._id,
|
||||
latitude,
|
||||
longitude,
|
||||
geocodedAt: geocoding.fetchedAt,
|
||||
geocodingPlaceId: geocoding.placeId,
|
||||
geocodingFormattedAddress: geocoding.formattedAddress,
|
||||
});
|
||||
|
||||
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
|
||||
runId: args.runId,
|
||||
level: "info",
|
||||
message: "PLZ geocodiert.",
|
||||
details: [
|
||||
{ label: "PLZ", value: campaign.postalCode, source: "google_geocoding" },
|
||||
{
|
||||
label: "Koordinaten",
|
||||
value: `${latitude}, ${longitude}`,
|
||||
source: "google_geocoding",
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
|
||||
runId: args.runId,
|
||||
level: "info",
|
||||
message: "Geocoding-Cache der Kampagne verwendet.",
|
||||
details: [
|
||||
{ label: "PLZ", value: campaign.postalCode },
|
||||
{ label: "Koordinaten", value: `${latitude}, ${longitude}` },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const searchSpec = getPlacesSearchSpec({
|
||||
const searchSpec = getLocalBusinessSearchSpec({
|
||||
categoryMode: campaign.categoryMode,
|
||||
category: campaign.category,
|
||||
customSearchTerm: campaign.customSearchTerm,
|
||||
postalCode: campaign.postalCode,
|
||||
radiusKm: campaign.radiusKm,
|
||||
latitude,
|
||||
longitude,
|
||||
maxNewLeads: campaign.maxNewLeadsPerRun,
|
||||
});
|
||||
const placesJson = await fetchJson(
|
||||
`https://places.googleapis.com/v1/places:${searchSpec.endpoint}`,
|
||||
{
|
||||
method: "POST",
|
||||
const localBusinessJson = await fetchJson(searchSpec.url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": placesApiKey,
|
||||
"X-Goog-FieldMask": GOOGLE_PLACES_FIELD_MASK,
|
||||
},
|
||||
body: JSON.stringify(searchSpec.body),
|
||||
"X-RapidAPI-Key": localBusinessDataApiKey,
|
||||
"X-RapidAPI-Host": LOCAL_BUSINESS_DATA_HOST,
|
||||
},
|
||||
});
|
||||
const candidates = normalizeLocalBusinessSearchResponse(
|
||||
localBusinessJson,
|
||||
Date.now(),
|
||||
);
|
||||
const candidates = normalizePlacesResponse(placesJson, Date.now());
|
||||
|
||||
await ctx.runMutation(internal.usageEvents.recordUsageEvent, {
|
||||
provider: "local_business_data",
|
||||
operation: "lead_lookup",
|
||||
runId: args.runId,
|
||||
estimatedCostUsd: 0,
|
||||
callCounts: {
|
||||
requests: 1,
|
||||
lookups: candidates.length,
|
||||
},
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
|
||||
runId: args.runId,
|
||||
level: "warning",
|
||||
message: "Google Places lieferte keine Ergebnisse.",
|
||||
message: "Local Business Data lieferte keine Ergebnisse.",
|
||||
details: [
|
||||
{ label: "Suchtyp", value: searchSpec.searchType, source: "google_places" },
|
||||
{ label: "Kategorie", value: getCampaignNiche(campaign), source: "google_places" },
|
||||
{
|
||||
label: "Suchquery",
|
||||
value: searchSpec.query,
|
||||
source: "local_business_data",
|
||||
},
|
||||
{
|
||||
label: "Kategorie",
|
||||
value: getCampaignNiche(campaign),
|
||||
source: "local_business_data",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -215,11 +192,6 @@ export const processCampaignRun = internalAction({
|
||||
skippedDuplicates: number;
|
||||
skippedBlacklisted: number;
|
||||
errors: number;
|
||||
websiteEnrichmentQueue: Array<{
|
||||
leadId: Id<"leads">;
|
||||
companyName: string;
|
||||
website: string;
|
||||
}>;
|
||||
} = await ctx.runMutation(internal.leadDiscovery.persistDiscoveredLeads, {
|
||||
runId: args.runId,
|
||||
campaignId: campaign._id,
|
||||
@@ -229,31 +201,6 @@ export const processCampaignRun = internalAction({
|
||||
candidates,
|
||||
});
|
||||
|
||||
for (const enrichment of result.websiteEnrichmentQueue) {
|
||||
await ctx.runMutation(internal.websiteEnrichment.queueLeadEnrichment, {
|
||||
leadId: enrichment.leadId,
|
||||
parentRunId: args.runId,
|
||||
});
|
||||
|
||||
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
|
||||
runId: args.runId,
|
||||
level: "info",
|
||||
message: "Website-Kontaktanreicherung geplant.",
|
||||
details: [
|
||||
{
|
||||
label: "Unternehmen",
|
||||
value: enrichment.companyName,
|
||||
source: "google_places",
|
||||
},
|
||||
{
|
||||
label: "Website",
|
||||
value: enrichment.website,
|
||||
source: "google_places",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.leadDiscovery.finishCampaignRun, {
|
||||
runId: args.runId,
|
||||
status: "succeeded",
|
||||
@@ -423,11 +370,6 @@ export const persistDiscoveredLeads = internalMutation({
|
||||
let skippedDuplicates = 0;
|
||||
let skippedBlacklisted = 0;
|
||||
let errors = 0;
|
||||
const websiteEnrichmentQueue: Array<{
|
||||
leadId: Id<"leads">;
|
||||
companyName: string;
|
||||
website: string;
|
||||
}> = [];
|
||||
|
||||
for (const candidate of args.candidates) {
|
||||
if (leadsCreated >= args.maxNewLeads) {
|
||||
@@ -446,7 +388,7 @@ export const persistDiscoveredLeads = internalMutation({
|
||||
await ctx.db.insert("agentRunEvents", {
|
||||
runId: args.runId,
|
||||
level: "warning",
|
||||
message: "Google-Places-Ergebnis ohne Unternehmensname übersprungen.",
|
||||
message: "Lead-Recherche-Ergebnis ohne Unternehmensname übersprungen.",
|
||||
details: [{ label: "Place ID", value: candidate.placeId }],
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
@@ -454,6 +396,9 @@ export const persistDiscoveredLeads = internalMutation({
|
||||
}
|
||||
|
||||
const normalizedPlaceId = normalizeDomain(candidate.placeId);
|
||||
const normalizedSourceBusinessId = normalizeDomain(
|
||||
candidate.sourceBusinessId ?? candidate.placeId,
|
||||
);
|
||||
const normalizedDomain = normalizeDomain(candidate.websiteDomain);
|
||||
const normalizedEmails = getCandidateEmailValues(candidate);
|
||||
const normalizedPhone = normalizePhone(candidate.phone);
|
||||
@@ -476,6 +421,15 @@ export const persistDiscoveredLeads = internalMutation({
|
||||
.take(1)
|
||||
: [];
|
||||
|
||||
const duplicateBySourceBusinessId = normalizedSourceBusinessId
|
||||
? await ctx.db
|
||||
.query("leads")
|
||||
.withIndex("by_normalizedSourceBusinessId", (q) =>
|
||||
q.eq("normalizedSourceBusinessId", normalizedSourceBusinessId),
|
||||
)
|
||||
.take(1)
|
||||
: [];
|
||||
|
||||
const duplicateByEmailRows = [];
|
||||
for (const email of normalizedEmails) {
|
||||
const rows = await ctx.db
|
||||
@@ -487,17 +441,84 @@ export const persistDiscoveredLeads = internalMutation({
|
||||
|
||||
if (
|
||||
duplicateByPlaceId.length > 0 ||
|
||||
duplicateBySourceBusinessId.length > 0 ||
|
||||
duplicateByDomain.length > 0 ||
|
||||
duplicateByEmailRows.length > 0
|
||||
) {
|
||||
skippedDuplicates += 1;
|
||||
const duplicateLeadForEmailBackfill =
|
||||
duplicateBySourceBusinessId[0] ??
|
||||
duplicateByPlaceId[0] ??
|
||||
duplicateByDomain[0] ??
|
||||
null;
|
||||
const usableEmail = getUsableContactEmail(candidate);
|
||||
|
||||
if (
|
||||
duplicateLeadForEmailBackfill &&
|
||||
usableEmail &&
|
||||
!duplicateLeadForEmailBackfill.email &&
|
||||
duplicateLeadForEmailBackfill.contactStatus !== "do_not_contact" &&
|
||||
duplicateLeadForEmailBackfill.blacklistStatus !== "blocked" &&
|
||||
duplicateLeadForEmailBackfill.priority !== "blocked" &&
|
||||
duplicateByEmailRows.length === 0
|
||||
) {
|
||||
const emailBackfillPatch: DuplicateEmailBackfillPatch = {
|
||||
normalizedEmail: usableEmail.email,
|
||||
email: usableEmail.email,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (usableEmail.emailSource !== null) {
|
||||
emailBackfillPatch.emailSource = usableEmail.emailSource;
|
||||
}
|
||||
if (usableEmail.contactPerson !== null) {
|
||||
emailBackfillPatch.contactPerson = usableEmail.contactPerson;
|
||||
}
|
||||
if (duplicateLeadForEmailBackfill.contactStatus === "missing_contact") {
|
||||
emailBackfillPatch.contactStatus = "new";
|
||||
emailBackfillPatch.contactStatusReason =
|
||||
"E-Mail bei erneutem Local-Business-Data-Lauf ergänzt.";
|
||||
}
|
||||
|
||||
await ctx.db.patch(
|
||||
duplicateLeadForEmailBackfill._id,
|
||||
emailBackfillPatch,
|
||||
);
|
||||
await ctx.db.insert("agentRunEvents", {
|
||||
runId: args.runId,
|
||||
level: "info",
|
||||
message: "E-Mail für bestehenden Lead ergänzt.",
|
||||
details: [
|
||||
{
|
||||
label: "Unternehmen",
|
||||
value: duplicateLeadForEmailBackfill.companyName,
|
||||
source: candidate.sourceProvider,
|
||||
},
|
||||
{
|
||||
label: "E-Mail-Quelle",
|
||||
value: usableEmail.emailSource ?? "local_business_data",
|
||||
source: candidate.sourceProvider,
|
||||
},
|
||||
],
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert("agentRunEvents", {
|
||||
runId: args.runId,
|
||||
level: "info",
|
||||
message: "Doppelter Lead übersprungen.",
|
||||
details: [
|
||||
{ label: "Unternehmen", value: candidate.businessName, source: "google_places" },
|
||||
{ label: "Place ID", value: candidate.placeId, source: "google_places" },
|
||||
{
|
||||
label: "Unternehmen",
|
||||
value: candidate.businessName,
|
||||
source: candidate.sourceProvider,
|
||||
},
|
||||
{
|
||||
label: "Source ID",
|
||||
value: candidate.sourceBusinessId ?? candidate.placeId,
|
||||
source: candidate.sourceProvider,
|
||||
},
|
||||
],
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
@@ -569,13 +590,16 @@ export const persistDiscoveredLeads = internalMutation({
|
||||
const priorityResult = getLeadDiscoveryPriority({
|
||||
isDuplicate: !!probableDuplicateLead,
|
||||
hasWebsite,
|
||||
hasWebsiteSignal: false, // plain Google-Places website hint maps to medium priority.
|
||||
hasWebsiteSignal: false,
|
||||
});
|
||||
const isDuplicateCandidate = !!probableDuplicateLead;
|
||||
|
||||
if (normalizedPlaceId) {
|
||||
lead.normalizedGooglePlaceId = normalizedPlaceId;
|
||||
}
|
||||
if (normalizedSourceBusinessId) {
|
||||
lead.normalizedSourceBusinessId = normalizedSourceBusinessId;
|
||||
}
|
||||
if (normalizedPhone !== "") {
|
||||
lead.normalizedPhone = normalizedPhone;
|
||||
}
|
||||
@@ -596,20 +620,21 @@ export const persistDiscoveredLeads = internalMutation({
|
||||
|
||||
const leadId = await ctx.db.insert("leads", lead);
|
||||
leadsCreated += 1;
|
||||
if (shouldScheduleWebsiteEnrichment(lead)) {
|
||||
websiteEnrichmentQueue.push({
|
||||
leadId,
|
||||
companyName: lead.companyName,
|
||||
website: lead.websiteDomain ?? lead.websiteUrl ?? "unbekannt",
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("agentRunEvents", {
|
||||
runId: args.runId,
|
||||
level: "info",
|
||||
message: "Lead aus Google Places gespeichert.",
|
||||
message: "Lead aus Local Business Data gespeichert.",
|
||||
details: [
|
||||
{ label: "Unternehmen", value: candidate.businessName, source: "google_places" },
|
||||
{ label: "Place ID", value: candidate.placeId, source: "google_places" },
|
||||
{
|
||||
label: "Unternehmen",
|
||||
value: candidate.businessName,
|
||||
source: candidate.sourceProvider,
|
||||
},
|
||||
{
|
||||
label: "Source ID",
|
||||
value: candidate.sourceBusinessId ?? candidate.placeId,
|
||||
source: candidate.sourceProvider,
|
||||
},
|
||||
],
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
@@ -634,7 +659,6 @@ export const persistDiscoveredLeads = internalMutation({
|
||||
skippedDuplicates,
|
||||
skippedBlacklisted,
|
||||
errors,
|
||||
websiteEnrichmentQueue,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
310
convex/leads.ts
310
convex/leads.ts
@@ -3,7 +3,13 @@ import { v } from "convex/values";
|
||||
import { getUsableContactEmailFromEntries } from "../lib/lead-discovery-google";
|
||||
import { normalizeListLimit } from "./domain";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import {
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from "./_generated/server";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
|
||||
type LeadDoc = Doc<"leads">;
|
||||
|
||||
@@ -37,6 +43,74 @@ type LeadReviewPatch = {
|
||||
contactPerson?: string;
|
||||
};
|
||||
|
||||
type LeadReviewUpdateArgs = {
|
||||
id: Id<"leads">;
|
||||
priority?: LeadDoc["priority"];
|
||||
priorityReason?: string;
|
||||
contactStatus?: LeadDoc["contactStatus"];
|
||||
contactStatusReason?: string;
|
||||
notes?: string;
|
||||
duplicateStatus?: LeadDoc["duplicateStatus"];
|
||||
duplicateReason?: string;
|
||||
blacklistStatus?: LeadDoc["blacklistStatus"];
|
||||
blacklistReason?: string;
|
||||
duplicateOfLeadId?: Id<"leads">;
|
||||
applyBlacklist?: boolean;
|
||||
reviewEmail?: string;
|
||||
reviewEmailSource?: string;
|
||||
reviewContactPerson?: string;
|
||||
reviewIsBusinessContactAddress?: boolean;
|
||||
};
|
||||
|
||||
const leadPriority = v.union(
|
||||
v.literal("high"),
|
||||
v.literal("medium"),
|
||||
v.literal("low"),
|
||||
v.literal("defer"),
|
||||
v.literal("blocked"),
|
||||
);
|
||||
const leadContactStatus = v.union(
|
||||
v.literal("new"),
|
||||
v.literal("missing_contact"),
|
||||
v.literal("audit_ready"),
|
||||
v.literal("outreach_ready"),
|
||||
v.literal("contacted"),
|
||||
v.literal("replied"),
|
||||
v.literal("do_not_contact"),
|
||||
);
|
||||
const leadDuplicateStatus = v.union(
|
||||
v.literal("unchecked"),
|
||||
v.literal("unique"),
|
||||
v.literal("possible_duplicate"),
|
||||
v.literal("duplicate"),
|
||||
);
|
||||
const leadBlacklistStatus = v.union(v.literal("clear"), v.literal("blocked"));
|
||||
const reviewUpdateArgs = {
|
||||
id: v.id("leads"),
|
||||
priority: v.optional(leadPriority),
|
||||
priorityReason: v.optional(v.string()),
|
||||
contactStatus: v.optional(leadContactStatus),
|
||||
contactStatusReason: v.optional(v.string()),
|
||||
notes: v.optional(v.string()),
|
||||
duplicateStatus: v.optional(leadDuplicateStatus),
|
||||
duplicateReason: v.optional(v.string()),
|
||||
blacklistStatus: v.optional(leadBlacklistStatus),
|
||||
blacklistReason: v.optional(v.string()),
|
||||
duplicateOfLeadId: v.optional(v.id("leads")),
|
||||
applyBlacklist: v.optional(v.boolean()),
|
||||
reviewEmail: v.optional(v.string()),
|
||||
reviewEmailSource: v.optional(v.string()),
|
||||
reviewContactPerson: v.optional(v.string()),
|
||||
reviewIsBusinessContactAddress: v.optional(v.boolean()),
|
||||
};
|
||||
|
||||
const requireOperator = async (ctx: MutationCtx | QueryCtx) => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
throw new Error("Nicht autorisiert.");
|
||||
}
|
||||
};
|
||||
|
||||
function buildReviewContactPatch(args: {
|
||||
email?: string;
|
||||
emailSource?: string;
|
||||
@@ -88,136 +162,7 @@ function buildReviewContactPatch(args: {
|
||||
});
|
||||
}
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
campaignId: v.optional(v.id("campaigns")),
|
||||
discoveryRunId: v.optional(v.id("agentRuns")),
|
||||
companyName: v.string(),
|
||||
niche: v.optional(v.string()),
|
||||
address: v.optional(v.string()),
|
||||
city: v.optional(v.string()),
|
||||
postalCode: v.optional(v.string()),
|
||||
googlePlaceId: v.optional(v.string()),
|
||||
googleMapsUrl: v.optional(v.string()),
|
||||
googlePrimaryType: v.optional(v.string()),
|
||||
googleTypes: v.optional(v.array(v.string())),
|
||||
googleRating: v.optional(v.number()),
|
||||
googleUserRatingCount: v.optional(v.number()),
|
||||
googleBusinessStatus: v.optional(v.string()),
|
||||
sourceProvider: v.optional(v.literal("google_places")),
|
||||
sourceFetchedAt: v.optional(v.number()),
|
||||
websiteUrl: v.optional(v.string()),
|
||||
websiteDomain: v.optional(v.string()),
|
||||
phone: v.optional(v.string()),
|
||||
normalizedEmail: v.optional(v.string()),
|
||||
normalizedPhone: v.optional(v.string()),
|
||||
normalizedCompanyName: v.optional(v.string()),
|
||||
normalizedAddress: v.optional(v.string()),
|
||||
email: v.optional(v.string()),
|
||||
emailSource: v.optional(v.string()),
|
||||
contactPerson: v.optional(v.string()),
|
||||
priority: v.optional(
|
||||
v.union(
|
||||
v.literal("high"),
|
||||
v.literal("medium"),
|
||||
v.literal("low"),
|
||||
v.literal("defer"),
|
||||
v.literal("blocked"),
|
||||
),
|
||||
),
|
||||
priorityReason: v.optional(v.string()),
|
||||
contactStatus: v.optional(
|
||||
v.union(
|
||||
v.literal("new"),
|
||||
v.literal("missing_contact"),
|
||||
v.literal("audit_ready"),
|
||||
v.literal("outreach_ready"),
|
||||
v.literal("contacted"),
|
||||
v.literal("replied"),
|
||||
v.literal("do_not_contact"),
|
||||
),
|
||||
),
|
||||
contactStatusReason: v.optional(v.string()),
|
||||
duplicateStatus: v.optional(
|
||||
v.union(
|
||||
v.literal("unchecked"),
|
||||
v.literal("unique"),
|
||||
v.literal("possible_duplicate"),
|
||||
v.literal("duplicate"),
|
||||
),
|
||||
),
|
||||
duplicateReason: v.optional(v.string()),
|
||||
blacklistReason: v.optional(v.string()),
|
||||
duplicateOfLeadId: v.optional(v.id("leads")),
|
||||
blacklistStatus: v.optional(v.union(v.literal("clear"), v.literal("blocked"))),
|
||||
normalizedGooglePlaceId: v.optional(v.string()),
|
||||
notes: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
|
||||
return await ctx.db.insert("leads", {
|
||||
...args,
|
||||
normalizedEmail: args.normalizedEmail,
|
||||
normalizedPhone: args.normalizedPhone,
|
||||
normalizedCompanyName: args.normalizedCompanyName,
|
||||
normalizedAddress: args.normalizedAddress,
|
||||
normalizedGooglePlaceId: args.normalizedGooglePlaceId,
|
||||
priority: args.priority ?? "medium",
|
||||
contactStatus: args.contactStatus ?? "new",
|
||||
duplicateStatus: args.duplicateStatus ?? "unchecked",
|
||||
blacklistStatus: args.blacklistStatus ?? "clear",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const reviewUpdate = mutation({
|
||||
args: {
|
||||
id: v.id("leads"),
|
||||
priority: v.optional(
|
||||
v.union(
|
||||
v.literal("high"),
|
||||
v.literal("medium"),
|
||||
v.literal("low"),
|
||||
v.literal("defer"),
|
||||
v.literal("blocked"),
|
||||
),
|
||||
),
|
||||
priorityReason: v.optional(v.string()),
|
||||
contactStatus: v.optional(
|
||||
v.union(
|
||||
v.literal("new"),
|
||||
v.literal("missing_contact"),
|
||||
v.literal("audit_ready"),
|
||||
v.literal("outreach_ready"),
|
||||
v.literal("contacted"),
|
||||
v.literal("replied"),
|
||||
v.literal("do_not_contact"),
|
||||
),
|
||||
),
|
||||
contactStatusReason: v.optional(v.string()),
|
||||
notes: v.optional(v.string()),
|
||||
duplicateStatus: v.optional(
|
||||
v.union(
|
||||
v.literal("unchecked"),
|
||||
v.literal("unique"),
|
||||
v.literal("possible_duplicate"),
|
||||
v.literal("duplicate"),
|
||||
),
|
||||
),
|
||||
duplicateReason: v.optional(v.string()),
|
||||
blacklistStatus: v.optional(v.union(v.literal("clear"), v.literal("blocked"))),
|
||||
blacklistReason: v.optional(v.string()),
|
||||
duplicateOfLeadId: v.optional(v.id("leads")),
|
||||
applyBlacklist: v.optional(v.boolean()),
|
||||
reviewEmail: v.optional(v.string()),
|
||||
reviewEmailSource: v.optional(v.string()),
|
||||
reviewContactPerson: v.optional(v.string()),
|
||||
reviewIsBusinessContactAddress: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
async function reviewUpdateLead(ctx: MutationCtx, args: LeadReviewUpdateArgs) {
|
||||
const lead = await ctx.db.get(args.id);
|
||||
|
||||
if (!lead) {
|
||||
@@ -300,10 +245,98 @@ export const reviewUpdate = mutation({
|
||||
|
||||
await ctx.db.patch(args.id, patch);
|
||||
return args.id;
|
||||
}
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
campaignId: v.optional(v.id("campaigns")),
|
||||
discoveryRunId: v.optional(v.id("agentRuns")),
|
||||
companyName: v.string(),
|
||||
niche: v.optional(v.string()),
|
||||
address: v.optional(v.string()),
|
||||
city: v.optional(v.string()),
|
||||
postalCode: v.optional(v.string()),
|
||||
googlePlaceId: v.optional(v.string()),
|
||||
googleMapsUrl: v.optional(v.string()),
|
||||
googlePrimaryType: v.optional(v.string()),
|
||||
googleTypes: v.optional(v.array(v.string())),
|
||||
googleRating: v.optional(v.number()),
|
||||
googleUserRatingCount: v.optional(v.number()),
|
||||
googleBusinessStatus: v.optional(v.string()),
|
||||
sourceProvider: v.optional(
|
||||
v.union(v.literal("google_places"), v.literal("local_business_data")),
|
||||
),
|
||||
sourceBusinessId: v.optional(v.string()),
|
||||
sourceFetchedAt: v.optional(v.number()),
|
||||
websiteUrl: v.optional(v.string()),
|
||||
websiteDomain: v.optional(v.string()),
|
||||
phone: v.optional(v.string()),
|
||||
normalizedEmail: v.optional(v.string()),
|
||||
normalizedPhone: v.optional(v.string()),
|
||||
normalizedCompanyName: v.optional(v.string()),
|
||||
normalizedAddress: v.optional(v.string()),
|
||||
email: v.optional(v.string()),
|
||||
emailSource: v.optional(v.string()),
|
||||
contactPerson: v.optional(v.string()),
|
||||
priority: v.optional(leadPriority),
|
||||
priorityReason: v.optional(v.string()),
|
||||
contactStatus: v.optional(leadContactStatus),
|
||||
contactStatusReason: v.optional(v.string()),
|
||||
duplicateStatus: v.optional(leadDuplicateStatus),
|
||||
duplicateReason: v.optional(v.string()),
|
||||
blacklistReason: v.optional(v.string()),
|
||||
duplicateOfLeadId: v.optional(v.id("leads")),
|
||||
blacklistStatus: v.optional(leadBlacklistStatus),
|
||||
normalizedGooglePlaceId: v.optional(v.string()),
|
||||
normalizedSourceBusinessId: v.optional(v.string()),
|
||||
notes: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
const now = Date.now();
|
||||
|
||||
return await ctx.db.insert("leads", {
|
||||
...args,
|
||||
normalizedEmail: args.normalizedEmail,
|
||||
normalizedPhone: args.normalizedPhone,
|
||||
normalizedCompanyName: args.normalizedCompanyName,
|
||||
normalizedAddress: args.normalizedAddress,
|
||||
normalizedGooglePlaceId: args.normalizedGooglePlaceId,
|
||||
normalizedSourceBusinessId: args.normalizedSourceBusinessId,
|
||||
priority: args.priority ?? "medium",
|
||||
contactStatus: args.contactStatus ?? "new",
|
||||
duplicateStatus: args.duplicateStatus ?? "unchecked",
|
||||
blacklistStatus: args.blacklistStatus ?? "clear",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const reviewUpdate = mutation({
|
||||
args: reviewUpdateArgs,
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
return await reviewUpdateLead(ctx, args);
|
||||
},
|
||||
});
|
||||
|
||||
export const reviewUpdateInternal = internalMutation({
|
||||
args: reviewUpdateArgs,
|
||||
handler: async (ctx, args) => {
|
||||
return await reviewUpdateLead(ctx, args);
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { id: v.id("leads") },
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
return await ctx.db.get(args.id);
|
||||
},
|
||||
});
|
||||
|
||||
export const getInternal = internalQuery({
|
||||
args: { id: v.id("leads") },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.get(args.id);
|
||||
@@ -313,20 +346,11 @@ export const get = query({
|
||||
export const list = query({
|
||||
args: {
|
||||
campaignId: v.optional(v.id("campaigns")),
|
||||
contactStatus: v.optional(
|
||||
v.union(
|
||||
v.literal("new"),
|
||||
v.literal("missing_contact"),
|
||||
v.literal("audit_ready"),
|
||||
v.literal("outreach_ready"),
|
||||
v.literal("contacted"),
|
||||
v.literal("replied"),
|
||||
v.literal("do_not_contact"),
|
||||
),
|
||||
),
|
||||
contactStatus: v.optional(leadContactStatus),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
const limit = normalizeListLimit(args.limit);
|
||||
|
||||
if (args.campaignId) {
|
||||
@@ -360,6 +384,7 @@ export const listFunnel = query({
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
const limit = normalizeListLimit(args.limit);
|
||||
const leads = await ctx.db.query("leads").order("desc").take(limit);
|
||||
|
||||
@@ -392,6 +417,7 @@ export const listFunnel = query({
|
||||
sendStatus: latestOutreach.sendStatus,
|
||||
responseStatus: latestOutreach.responseStatus,
|
||||
salesStatus: latestOutreach.salesStatus,
|
||||
doNotContactUntil: latestOutreach.doNotContactUntil ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { internalMutation } from "./_generated/server";
|
||||
import {
|
||||
internalMutation,
|
||||
mutation,
|
||||
query,
|
||||
} from "./_generated/server";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
const PAGE_SPEED_COUNTER_TEMPLATE = {
|
||||
@@ -17,6 +22,13 @@ type PageSpeedLead = Pick<
|
||||
> & {
|
||||
websiteUrl: string;
|
||||
};
|
||||
type AuditStartState = {
|
||||
leadId: Id<"leads">;
|
||||
canStart: boolean;
|
||||
reason?: string;
|
||||
activeRunId?: Id<"agentRuns">;
|
||||
activeRunStatus?: Doc<"agentRuns">["status"];
|
||||
};
|
||||
|
||||
const runStatus = v.union(
|
||||
v.literal("pending"),
|
||||
@@ -39,50 +51,152 @@ const pageSpeedErrorType = v.union(
|
||||
v.literal("unknown"),
|
||||
);
|
||||
|
||||
export const queueLeadPageSpeedAudit = internalMutation({
|
||||
args: {
|
||||
leadId: v.id("leads"),
|
||||
parentRunId: v.optional(v.id("agentRuns")),
|
||||
},
|
||||
returns: v.union(v.id("agentRuns"), v.null()),
|
||||
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
|
||||
const now = Date.now();
|
||||
const lead = await ctx.db.get(args.leadId);
|
||||
|
||||
if (!lead || lead.priority === "blocked" || lead.priority === "defer") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!lead.websiteUrl) {
|
||||
return null;
|
||||
const requireOperator = async (ctx: MutationCtx | QueryCtx) => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
throw new Error("Nicht autorisiert.");
|
||||
}
|
||||
};
|
||||
|
||||
async function getActivePageSpeedAuditRun(
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
leadId: Id<"leads">,
|
||||
) {
|
||||
const existingPending = await ctx.db
|
||||
.query("agentRuns")
|
||||
.withIndex("by_type_and_status_and_leadId", (q) =>
|
||||
q.eq("type", "audit").eq("status", "pending").eq("leadId", args.leadId),
|
||||
q.eq("type", "audit").eq("status", "pending").eq("leadId", leadId),
|
||||
)
|
||||
.take(1);
|
||||
|
||||
if (existingPending[0]) {
|
||||
return existingPending[0];
|
||||
}
|
||||
|
||||
const existingRunning = await ctx.db
|
||||
.query("agentRuns")
|
||||
.withIndex("by_type_and_status_and_leadId", (q) =>
|
||||
q.eq("type", "audit").eq("status", "running").eq("leadId", args.leadId),
|
||||
q.eq("type", "audit").eq("status", "running").eq("leadId", leadId),
|
||||
)
|
||||
.take(1);
|
||||
|
||||
if (existingPending.length > 0) {
|
||||
return existingPending[0]._id;
|
||||
return existingRunning[0] ?? null;
|
||||
}
|
||||
|
||||
async function getActiveAuditGenerationRun(
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
leadId: Id<"leads">,
|
||||
) {
|
||||
const existingPending = await ctx.db
|
||||
.query("agentRuns")
|
||||
.withIndex("by_type_and_status_and_leadId", (q) =>
|
||||
q
|
||||
.eq("type", "audit_generation")
|
||||
.eq("status", "pending")
|
||||
.eq("leadId", leadId),
|
||||
)
|
||||
.take(1);
|
||||
|
||||
if (existingPending[0]) {
|
||||
return existingPending[0];
|
||||
}
|
||||
if (existingRunning.length > 0) {
|
||||
return existingRunning[0]._id;
|
||||
|
||||
const existingRunning = await ctx.db
|
||||
.query("agentRuns")
|
||||
.withIndex("by_type_and_status_and_leadId", (q) =>
|
||||
q
|
||||
.eq("type", "audit_generation")
|
||||
.eq("status", "running")
|
||||
.eq("leadId", leadId),
|
||||
)
|
||||
.take(1);
|
||||
|
||||
return existingRunning[0] ?? null;
|
||||
}
|
||||
|
||||
async function getLeadAuditStartState(
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
leadId: Id<"leads">,
|
||||
): Promise<AuditStartState> {
|
||||
const lead = await ctx.db.get(leadId);
|
||||
|
||||
if (!lead) {
|
||||
return {
|
||||
leadId,
|
||||
canStart: false,
|
||||
reason: "Lead nicht gefunden.",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lead.priority === "blocked" ||
|
||||
lead.priority === "defer" ||
|
||||
lead.blacklistStatus === "blocked" ||
|
||||
lead.contactStatus === "do_not_contact"
|
||||
) {
|
||||
return {
|
||||
leadId,
|
||||
canStart: false,
|
||||
reason: "Lead ist gesperrt oder zurueckgestellt.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!lead.websiteUrl) {
|
||||
return {
|
||||
leadId,
|
||||
canStart: false,
|
||||
reason: "Keine Website hinterlegt.",
|
||||
};
|
||||
}
|
||||
|
||||
const activeAuditRun =
|
||||
(await getActivePageSpeedAuditRun(ctx, leadId)) ??
|
||||
(await getActiveAuditGenerationRun(ctx, leadId));
|
||||
|
||||
if (activeAuditRun) {
|
||||
return {
|
||||
leadId,
|
||||
canStart: false,
|
||||
reason: "Audit laeuft bereits.",
|
||||
activeRunId: activeAuditRun._id,
|
||||
activeRunStatus: activeAuditRun.status,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
leadId,
|
||||
canStart: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function queueLeadPageSpeedAuditForLead(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
leadId: Id<"leads">;
|
||||
parentRunId?: Id<"agentRuns">;
|
||||
triggeredBy: "internal" | "manual";
|
||||
},
|
||||
): Promise<Id<"agentRuns"> | null> {
|
||||
const state = await getLeadAuditStartState(ctx, args.leadId);
|
||||
|
||||
if (!state.canStart) {
|
||||
return state.activeRunId ?? null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const runId = await ctx.db.insert("agentRuns", {
|
||||
type: "audit",
|
||||
leadId: args.leadId,
|
||||
status: "pending",
|
||||
currentStep: "pagespeed_insights",
|
||||
workflowId: undefined,
|
||||
attempt: 1,
|
||||
maxAttempts: 3,
|
||||
progressStep: 1,
|
||||
progressTotal: 6,
|
||||
progressLabel: "Audit vorbereitet",
|
||||
progressPercent: 17,
|
||||
counters: PAGE_SPEED_COUNTER_TEMPLATE,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -91,7 +205,10 @@ export const queueLeadPageSpeedAudit = internalMutation({
|
||||
await ctx.db.insert("agentRunEvents", {
|
||||
runId,
|
||||
level: "info",
|
||||
message: "PageSpeed-Analyse wurde in die Warteschlange gesetzt.",
|
||||
message:
|
||||
args.triggeredBy === "manual"
|
||||
? "Audit-Start wurde manuell angefordert."
|
||||
: "PageSpeed-Analyse wurde in die Warteschlange gesetzt.",
|
||||
details: [
|
||||
{ label: "Lead", value: args.leadId },
|
||||
...(args.parentRunId ? [{ label: "Parent-Run", value: args.parentRunId }] : []),
|
||||
@@ -101,13 +218,83 @@ export const queueLeadPageSpeedAudit = internalMutation({
|
||||
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.pageSpeedAction.processPageSpeedAudit,
|
||||
internal.auditWorkflow.startLeadAuditWorkflow,
|
||||
{
|
||||
runId,
|
||||
},
|
||||
);
|
||||
|
||||
return runId;
|
||||
}
|
||||
|
||||
export const getLeadAuditStartStates = query({
|
||||
args: {
|
||||
leadIds: v.array(v.id("leads")),
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
leadId: v.id("leads"),
|
||||
canStart: v.boolean(),
|
||||
reason: v.optional(v.string()),
|
||||
activeRunId: v.optional(v.id("agentRuns")),
|
||||
activeRunStatus: v.optional(runStatus),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args): Promise<AuditStartState[]> => {
|
||||
await requireOperator(ctx);
|
||||
|
||||
const states: AuditStartState[] = [];
|
||||
for (const leadId of args.leadIds.slice(0, 120)) {
|
||||
states.push(await getLeadAuditStartState(ctx, leadId));
|
||||
}
|
||||
|
||||
return states;
|
||||
},
|
||||
});
|
||||
|
||||
export const requestLeadAudit = mutation({
|
||||
args: {
|
||||
leadId: v.id("leads"),
|
||||
},
|
||||
returns: v.object({
|
||||
runId: v.union(v.id("agentRuns"), v.null()),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args): Promise<{ runId: Id<"agentRuns"> | null; message: string }> => {
|
||||
await requireOperator(ctx);
|
||||
|
||||
const state = await getLeadAuditStartState(ctx, args.leadId);
|
||||
if (!state.canStart) {
|
||||
return {
|
||||
runId: state.activeRunId ?? null,
|
||||
message: state.reason ?? "Audit kann aktuell nicht gestartet werden.",
|
||||
};
|
||||
}
|
||||
|
||||
const runId = await queueLeadPageSpeedAuditForLead(ctx, {
|
||||
leadId: args.leadId,
|
||||
triggeredBy: "manual",
|
||||
});
|
||||
|
||||
return {
|
||||
runId,
|
||||
message: "Audit-Start wurde manuell angefordert.",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const queueLeadPageSpeedAudit = internalMutation({
|
||||
args: {
|
||||
leadId: v.id("leads"),
|
||||
parentRunId: v.optional(v.id("agentRuns")),
|
||||
},
|
||||
returns: v.union(v.id("agentRuns"), v.null()),
|
||||
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
|
||||
return await queueLeadPageSpeedAuditForLead(ctx, {
|
||||
leadId: args.leadId,
|
||||
parentRunId: args.parentRunId,
|
||||
triggeredBy: "internal",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -148,7 +335,11 @@ export const startPageSpeedAuditRun = internalMutation({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (run.status !== "pending") {
|
||||
if (
|
||||
run.status !== "pending" &&
|
||||
run.status !== "failed" &&
|
||||
run.status !== "running"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -220,6 +411,7 @@ export const startPageSpeedAuditRun = internalMutation({
|
||||
status: "running",
|
||||
currentStep: "pagespeed_insights",
|
||||
startedAt: now,
|
||||
finishedAt: undefined,
|
||||
updatedAt: now,
|
||||
errorSummary: undefined,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use node";
|
||||
|
||||
import { api, internal } from "./_generated/api";
|
||||
import { internal } from "./_generated/api";
|
||||
import { internalAction } from "./_generated/server";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx } from "./_generated/server";
|
||||
@@ -122,7 +122,7 @@ async function queueAuditGenerationAfterPageSpeed(
|
||||
parentRunId: runId,
|
||||
});
|
||||
} catch (auditQueueError) {
|
||||
await ctx.runMutation(api.runs.appendEvent, {
|
||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||
runId,
|
||||
level: "warning",
|
||||
message: "Audit-Generierung konnte nicht in die Warteschlange gesetzt werden.",
|
||||
@@ -143,6 +143,7 @@ async function queueAuditGenerationAfterPageSpeed(
|
||||
export const processPageSpeedAudit = internalAction({
|
||||
args: {
|
||||
runId: v.id("agentRuns"),
|
||||
queueGeneration: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const apiKeyRaw = process.env.PAGESPEED_API_KEY?.trim();
|
||||
@@ -164,7 +165,7 @@ export const processPageSpeedAudit = internalAction({
|
||||
errorSummary,
|
||||
});
|
||||
|
||||
await ctx.runMutation(api.runs.appendEvent, {
|
||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||
runId: args.runId,
|
||||
level: "error",
|
||||
message: "PageSpeed-Analyse fehlgeschlagen.",
|
||||
@@ -185,7 +186,8 @@ export const processPageSpeedAudit = internalAction({
|
||||
let succeededStrategies = 0;
|
||||
|
||||
try {
|
||||
for (const strategy of STRATEGIES) {
|
||||
const strategyResults = await Promise.all(
|
||||
STRATEGIES.map(async (strategy) => {
|
||||
const fetchedAt = Date.now();
|
||||
try {
|
||||
const raw = await fetchPageSpeedResult({
|
||||
@@ -197,7 +199,6 @@ export const processPageSpeedAudit = internalAction({
|
||||
const rawJson = JSON.stringify(raw) ?? "null";
|
||||
const rawJsonBytes = new TextEncoder().encode(rawJson).byteLength;
|
||||
if (rawJsonBytes > MAX_RAW_PAGESPEED_BYTES) {
|
||||
failedStrategies += 1;
|
||||
await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, {
|
||||
leadId: started.lead._id,
|
||||
...(started.auditId ? { auditId: started.auditId } : {}),
|
||||
@@ -210,7 +211,7 @@ export const processPageSpeedAudit = internalAction({
|
||||
fetchedAt,
|
||||
});
|
||||
|
||||
await ctx.runMutation(api.runs.appendEvent, {
|
||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||
runId: args.runId,
|
||||
level: "warning",
|
||||
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
|
||||
@@ -223,7 +224,7 @@ export const processPageSpeedAudit = internalAction({
|
||||
],
|
||||
});
|
||||
|
||||
continue;
|
||||
return "failed" as const;
|
||||
}
|
||||
|
||||
const rawStorageId = await ctx.storage.store(
|
||||
@@ -248,19 +249,18 @@ export const processPageSpeedAudit = internalAction({
|
||||
normalized: toPersistedPageSpeedNormalizedResult(normalized),
|
||||
});
|
||||
|
||||
await ctx.runMutation(api.runs.appendEvent, {
|
||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||
runId: args.runId,
|
||||
level: "info",
|
||||
message: `PageSpeed-Analyse für ${strategy} abgeschlossen.`,
|
||||
details: [{ label: "Strategie", value: strategy }],
|
||||
});
|
||||
succeededStrategies += 1;
|
||||
return "succeeded" as const;
|
||||
} catch (error) {
|
||||
const { errorType, errorSummary } = classifyPageSpeedFailure(
|
||||
error,
|
||||
apiKeyRaw,
|
||||
);
|
||||
failedStrategies += 1;
|
||||
|
||||
await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, {
|
||||
leadId: started.lead._id,
|
||||
@@ -274,7 +274,7 @@ export const processPageSpeedAudit = internalAction({
|
||||
fetchedAt,
|
||||
});
|
||||
|
||||
await ctx.runMutation(api.runs.appendEvent, {
|
||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||
runId: args.runId,
|
||||
level: "warning",
|
||||
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
|
||||
@@ -283,8 +283,15 @@ export const processPageSpeedAudit = internalAction({
|
||||
{ label: "Fehler", value: errorSummary },
|
||||
],
|
||||
});
|
||||
return "failed" as const;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
succeededStrategies = strategyResults.filter(
|
||||
(result) => result === "succeeded",
|
||||
).length;
|
||||
failedStrategies = strategyResults.length - succeededStrategies;
|
||||
|
||||
const status = succeededStrategies > 0 ? "succeeded" : "failed";
|
||||
const errors = failedStrategies;
|
||||
@@ -298,7 +305,9 @@ export const processPageSpeedAudit = internalAction({
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (args.queueGeneration !== false) {
|
||||
await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started);
|
||||
}
|
||||
|
||||
return args.runId;
|
||||
} catch (error) {
|
||||
@@ -310,14 +319,40 @@ export const processPageSpeedAudit = internalAction({
|
||||
errorSummary,
|
||||
});
|
||||
|
||||
await ctx.runMutation(api.runs.appendEvent, {
|
||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||
runId: args.runId,
|
||||
level: "error",
|
||||
message: "PageSpeed-Analyse fehlgeschlagen.",
|
||||
details: [{ label: "Fehler", value: errorSummary, source: "pagespeed_action" }],
|
||||
});
|
||||
if (args.queueGeneration !== false) {
|
||||
await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const processPageSpeedAuditForWorkflow = internalAction({
|
||||
args: {
|
||||
runId: v.id("agentRuns"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Id<"agentRuns">> => {
|
||||
const result = await ctx.runAction(
|
||||
internal.pageSpeedAction.processPageSpeedAudit,
|
||||
{
|
||||
runId: args.runId,
|
||||
queueGeneration: false,
|
||||
},
|
||||
);
|
||||
const run = await ctx.runQuery(internal.runs.getAuditRunForWorkflowInternal, {
|
||||
id: args.runId,
|
||||
});
|
||||
|
||||
if (!result || run?.status === "failed" || run?.status === "canceled") {
|
||||
throw new Error("PageSpeed-Analyse konnte nicht abgeschlossen werden.");
|
||||
}
|
||||
|
||||
return args.runId;
|
||||
},
|
||||
});
|
||||
|
||||
180
convex/runs.ts
180
convex/runs.ts
@@ -6,13 +6,53 @@ import {
|
||||
RUN_TYPES,
|
||||
normalizeListLimit,
|
||||
} from "./domain";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
|
||||
const runType = v.union(...RUN_TYPES.map((type) => v.literal(type)));
|
||||
const runStatus = v.union(...RUN_STATUSES.map((status) => v.literal(status)));
|
||||
const eventLevel = v.union(
|
||||
...RUN_EVENT_LEVELS.map((level) => v.literal(level)),
|
||||
);
|
||||
const appendEventArgs = {
|
||||
runId: v.id("agentRuns"),
|
||||
level: eventLevel,
|
||||
message: v.string(),
|
||||
details: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
label: v.string(),
|
||||
value: v.string(),
|
||||
source: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
type AppendEventArgs = {
|
||||
runId: Id<"agentRuns">;
|
||||
level: (typeof RUN_EVENT_LEVELS)[number];
|
||||
message: string;
|
||||
details?: { label: string; value: string; source?: string }[];
|
||||
};
|
||||
|
||||
const requireOperator = async (ctx: MutationCtx | QueryCtx) => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
throw new Error("Nicht autorisiert.");
|
||||
}
|
||||
};
|
||||
|
||||
async function appendRunEvent(
|
||||
ctx: MutationCtx,
|
||||
args: AppendEventArgs,
|
||||
) {
|
||||
return await ctx.db.insert("agentRunEvents", {
|
||||
...args,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
@@ -24,6 +64,7 @@ export const create = mutation({
|
||||
currentStep: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
const now = Date.now();
|
||||
|
||||
return await ctx.db.insert("agentRuns", {
|
||||
@@ -50,6 +91,7 @@ export const updateStatus = mutation({
|
||||
errorSummary: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
const now = Date.now();
|
||||
const patch: {
|
||||
status: typeof args.status;
|
||||
@@ -85,6 +127,112 @@ export const updateStatus = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const updateProgressInternal = internalMutation({
|
||||
args: {
|
||||
id: v.id("agentRuns"),
|
||||
status: v.optional(runStatus),
|
||||
currentStep: v.optional(v.string()),
|
||||
errorSummary: v.optional(v.string()),
|
||||
workflowId: v.optional(v.string()),
|
||||
attempt: v.optional(v.number()),
|
||||
maxAttempts: v.optional(v.number()),
|
||||
progressStep: v.optional(v.number()),
|
||||
progressTotal: v.optional(v.number()),
|
||||
progressLabel: v.optional(v.string()),
|
||||
progressPercent: v.optional(v.number()),
|
||||
lastRetryReason: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const patch: {
|
||||
status?: (typeof RUN_STATUSES)[number];
|
||||
updatedAt: number;
|
||||
currentStep?: string;
|
||||
errorSummary?: string;
|
||||
workflowId?: string;
|
||||
attempt?: number;
|
||||
maxAttempts?: number;
|
||||
progressStep?: number;
|
||||
progressTotal?: number;
|
||||
progressLabel?: string;
|
||||
progressPercent?: number;
|
||||
lastRetryReason?: string;
|
||||
startedAt?: number;
|
||||
finishedAt?: number;
|
||||
} = {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (args.status !== undefined) {
|
||||
patch.status = args.status;
|
||||
if (args.status === "running") {
|
||||
patch.startedAt = now;
|
||||
patch.finishedAt = undefined;
|
||||
}
|
||||
if (
|
||||
args.status === "succeeded" ||
|
||||
args.status === "failed" ||
|
||||
args.status === "canceled"
|
||||
) {
|
||||
patch.finishedAt = now;
|
||||
}
|
||||
}
|
||||
if (args.currentStep !== undefined) {
|
||||
patch.currentStep = args.currentStep;
|
||||
}
|
||||
if (args.errorSummary !== undefined) {
|
||||
patch.errorSummary = args.errorSummary;
|
||||
}
|
||||
if (args.workflowId !== undefined) {
|
||||
patch.workflowId = args.workflowId;
|
||||
}
|
||||
if (args.attempt !== undefined) {
|
||||
patch.attempt = args.attempt;
|
||||
}
|
||||
if (args.maxAttempts !== undefined) {
|
||||
patch.maxAttempts = args.maxAttempts;
|
||||
}
|
||||
if (args.progressStep !== undefined) {
|
||||
patch.progressStep = args.progressStep;
|
||||
}
|
||||
if (args.progressTotal !== undefined) {
|
||||
patch.progressTotal = args.progressTotal;
|
||||
}
|
||||
if (args.progressLabel !== undefined) {
|
||||
patch.progressLabel = args.progressLabel;
|
||||
}
|
||||
if (args.progressPercent !== undefined) {
|
||||
patch.progressPercent = args.progressPercent;
|
||||
}
|
||||
if (args.lastRetryReason !== undefined) {
|
||||
patch.lastRetryReason = args.lastRetryReason;
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.id, patch);
|
||||
return args.id;
|
||||
},
|
||||
});
|
||||
|
||||
export const getAuditRunForWorkflowInternal = internalQuery({
|
||||
args: {
|
||||
id: v.id("agentRuns"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const run = await ctx.db.get(args.id);
|
||||
if (!run || run.type !== "audit") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
_id: run._id,
|
||||
leadId: run.leadId ?? null,
|
||||
auditId: run.auditId ?? null,
|
||||
status: run.status,
|
||||
currentStep: run.currentStep ?? null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
status: v.optional(runStatus),
|
||||
@@ -92,6 +240,7 @@ export const list = query({
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
const limit = normalizeListLimit(args.limit);
|
||||
|
||||
if (args.type && args.status) {
|
||||
@@ -132,25 +281,17 @@ export const list = query({
|
||||
});
|
||||
|
||||
export const appendEvent = mutation({
|
||||
args: {
|
||||
runId: v.id("agentRuns"),
|
||||
level: eventLevel,
|
||||
message: v.string(),
|
||||
details: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
label: v.string(),
|
||||
value: v.string(),
|
||||
source: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
args: appendEventArgs,
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.insert("agentRunEvents", {
|
||||
...args,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
await requireOperator(ctx);
|
||||
return await appendRunEvent(ctx, args);
|
||||
},
|
||||
});
|
||||
|
||||
export const appendEventInternal = internalMutation({
|
||||
args: appendEventArgs,
|
||||
handler: async (ctx, args) => {
|
||||
return await appendRunEvent(ctx, args);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -160,6 +301,7 @@ export const listEvents = query({
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireOperator(ctx);
|
||||
const limit = normalizeListLimit(args.limit);
|
||||
|
||||
return await ctx.db
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
RUN_EVENT_LEVELS,
|
||||
RUN_STATUSES,
|
||||
RUN_TYPES,
|
||||
USAGE_EVENT_OPERATIONS,
|
||||
USAGE_EVENT_PROVIDERS,
|
||||
} from "./domain";
|
||||
|
||||
const campaignStatus = v.union(v.literal("active"), v.literal("paused"));
|
||||
@@ -85,6 +87,7 @@ const blacklistType = v.union(
|
||||
v.literal("phone"),
|
||||
v.literal("company"),
|
||||
v.literal("google_place_id"),
|
||||
v.literal("source_business_id"),
|
||||
);
|
||||
const websiteEnrichmentPageKind = v.union(
|
||||
v.literal("homepage"),
|
||||
@@ -146,6 +149,12 @@ const pageSpeedErrorType = v.union(
|
||||
v.literal("api_error"),
|
||||
v.literal("unknown"),
|
||||
);
|
||||
const usageEventProvider = v.union(
|
||||
...USAGE_EVENT_PROVIDERS.map((provider) => v.literal(provider)),
|
||||
);
|
||||
const usageEventOperation = v.union(
|
||||
...USAGE_EVENT_OPERATIONS.map((operation) => v.literal(operation)),
|
||||
);
|
||||
const settingsValue = v.union(v.string(), v.number(), v.boolean(), v.null());
|
||||
const auditMetricSummary = v.object({
|
||||
performanceScore: v.optional(v.number()),
|
||||
@@ -172,6 +181,25 @@ const publicAuditOffer = v.object({
|
||||
ctaLabel: v.optional(v.string()),
|
||||
ctaHref: v.optional(v.string()),
|
||||
});
|
||||
const auditFindingEvidenceType = v.union(
|
||||
v.literal("crawl_page"),
|
||||
v.literal("technical_check"),
|
||||
v.literal("screenshot"),
|
||||
v.literal("pagespeed"),
|
||||
v.literal("jina_excerpt"),
|
||||
v.literal("generation_stage"),
|
||||
);
|
||||
const auditFindingEvidenceRef = v.object({
|
||||
id: v.string(),
|
||||
type: auditFindingEvidenceType,
|
||||
label: v.string(),
|
||||
sourceUrl: v.optional(v.string()),
|
||||
});
|
||||
const auditFindingReviewStatus = v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("accepted"),
|
||||
v.literal("rejected"),
|
||||
);
|
||||
const eventDetail = v.object({
|
||||
label: v.string(),
|
||||
value: v.string(),
|
||||
@@ -223,13 +251,17 @@ export default defineSchema({
|
||||
postalCode: v.optional(v.string()),
|
||||
googlePlaceId: v.optional(v.string()),
|
||||
normalizedGooglePlaceId: v.optional(v.string()),
|
||||
sourceBusinessId: v.optional(v.string()),
|
||||
normalizedSourceBusinessId: v.optional(v.string()),
|
||||
googleMapsUrl: v.optional(v.string()),
|
||||
googlePrimaryType: v.optional(v.string()),
|
||||
googleTypes: v.optional(v.array(v.string())),
|
||||
googleRating: v.optional(v.number()),
|
||||
googleUserRatingCount: v.optional(v.number()),
|
||||
googleBusinessStatus: v.optional(v.string()),
|
||||
sourceProvider: v.optional(v.literal("google_places")),
|
||||
sourceProvider: v.optional(
|
||||
v.union(v.literal("google_places"), v.literal("local_business_data")),
|
||||
),
|
||||
sourceFetchedAt: v.optional(v.number()),
|
||||
websiteUrl: v.optional(v.string()),
|
||||
websiteDomain: v.optional(v.string()),
|
||||
@@ -265,6 +297,7 @@ export default defineSchema({
|
||||
"normalizedAddress",
|
||||
])
|
||||
.index("by_normalizedGooglePlaceId", ["normalizedGooglePlaceId"])
|
||||
.index("by_normalizedSourceBusinessId", ["normalizedSourceBusinessId"])
|
||||
.index("by_googlePlaceId", ["googlePlaceId"])
|
||||
.index("by_websiteDomain", ["websiteDomain"])
|
||||
.index("by_normalizedCompanyName", ["normalizedCompanyName"])
|
||||
@@ -282,8 +315,9 @@ export default defineSchema({
|
||||
usedSkills: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
id: v.optional(v.string()),
|
||||
name: v.string(),
|
||||
category: v.string(),
|
||||
category: v.optional(v.string()),
|
||||
version: v.optional(v.string()),
|
||||
source: v.optional(v.string()),
|
||||
}),
|
||||
@@ -333,6 +367,24 @@ export default defineSchema({
|
||||
.index("by_auditId_and_viewport", ["auditId", "viewport"])
|
||||
.index("by_storageId", ["storageId"]),
|
||||
|
||||
auditFindings: defineTable({
|
||||
auditId: v.id("audits"),
|
||||
runId: v.id("agentRuns"),
|
||||
skillId: v.string(),
|
||||
claim: v.string(),
|
||||
recommendation: v.string(),
|
||||
customerBenefit: v.string(),
|
||||
severity: v.union(v.literal(1), v.literal(2), v.literal(3)),
|
||||
confidence: v.number(),
|
||||
evidenceRefs: v.array(auditFindingEvidenceRef),
|
||||
reviewStatus: auditFindingReviewStatus,
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_auditId", ["auditId"])
|
||||
.index("by_runId", ["runId"])
|
||||
.index("by_auditId_and_reviewStatus", ["auditId", "reviewStatus"]),
|
||||
|
||||
pageSpeedResults: defineTable({
|
||||
leadId: v.id("leads"),
|
||||
auditId: v.optional(v.id("audits")),
|
||||
@@ -399,6 +451,39 @@ export default defineSchema({
|
||||
.index("by_stage", ["stage"])
|
||||
.index("by_leadId_and_stage", ["leadId", "stage"]),
|
||||
|
||||
usageEvents: defineTable({
|
||||
provider: usageEventProvider,
|
||||
operation: usageEventOperation,
|
||||
runId: v.optional(v.id("agentRuns")),
|
||||
leadId: v.optional(v.id("leads")),
|
||||
auditId: v.optional(v.id("audits")),
|
||||
estimatedCostUsd: v.number(),
|
||||
tokens: v.optional(
|
||||
v.object({
|
||||
inputTokens: v.optional(v.number()),
|
||||
outputTokens: v.optional(v.number()),
|
||||
promptTokens: v.optional(v.number()),
|
||||
completionTokens: v.optional(v.number()),
|
||||
totalTokens: v.optional(v.number()),
|
||||
cacheReadTokens: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
callCounts: v.optional(
|
||||
v.object({
|
||||
requests: v.optional(v.number()),
|
||||
pages: v.optional(v.number()),
|
||||
screenshots: v.optional(v.number()),
|
||||
lookups: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_runId_and_createdAt", ["runId", "createdAt"])
|
||||
.index("by_leadId_and_createdAt", ["leadId", "createdAt"])
|
||||
.index("by_auditId_and_createdAt", ["auditId", "createdAt"])
|
||||
.index("by_provider_and_createdAt", ["provider", "createdAt"])
|
||||
.index("by_createdAt", ["createdAt"]),
|
||||
|
||||
websiteCrawlPages: defineTable({
|
||||
leadId: v.id("leads"),
|
||||
runId: v.optional(v.id("agentRuns")),
|
||||
@@ -557,6 +642,14 @@ export default defineSchema({
|
||||
finishedAt: v.optional(v.number()),
|
||||
currentStep: v.optional(v.string()),
|
||||
errorSummary: v.optional(v.string()),
|
||||
workflowId: v.optional(v.string()),
|
||||
attempt: v.optional(v.number()),
|
||||
maxAttempts: v.optional(v.number()),
|
||||
progressStep: v.optional(v.number()),
|
||||
progressTotal: v.optional(v.number()),
|
||||
progressLabel: v.optional(v.string()),
|
||||
progressPercent: v.optional(v.number()),
|
||||
lastRetryReason: v.optional(v.string()),
|
||||
counters: v.optional(
|
||||
v.object({
|
||||
leadsFound: v.number(),
|
||||
|
||||
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,
|
||||
normalizeEmailAddress,
|
||||
} from "../lib/lead-discovery-google";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { internalAction, type ActionCtx } from "./_generated/server";
|
||||
|
||||
@@ -30,6 +30,17 @@ const ACTION_TIMEOUT_BUFFER_MS = 5_000;
|
||||
const MAX_PERSISTED_LINKS = 120;
|
||||
const MAX_PERSISTED_EMAIL_CANDIDATES = 40;
|
||||
const SCREENSHOT_MIME_TYPE = "image/png";
|
||||
const MAX_BROWSERLESS_PAGE_BYTES = 750_000;
|
||||
const MAX_BROWSERLESS_LINK_TEXT_CHARS = 180;
|
||||
const BROWSERLESS_CRAWL_PATHS = [
|
||||
"/",
|
||||
"/kontakt",
|
||||
"/impressum",
|
||||
"/leistungen",
|
||||
"/ueber-uns",
|
||||
];
|
||||
const BROWSERLESS_USER_AGENT =
|
||||
"Mozilla/5.0 (compatible; WebDevPipelineBot/1.0; +https://webdev-pipeline.local)";
|
||||
const CHROMIUM_SOURCE_MARKER_FILE = path.join(tmpdir(), "chromium-source.sha256");
|
||||
const CHROMIUM_EXECUTABLE_PATH = path.join(tmpdir(), "chromium");
|
||||
const CHROMIUM_PACK_PATH = path.join(tmpdir(), "chromium-pack");
|
||||
@@ -116,11 +127,41 @@ type ServerlessChromiumModule = {
|
||||
inflate: (filePath: string) => Promise<string>;
|
||||
setupLambdaEnvironment: (baseLibPath: string) => void;
|
||||
};
|
||||
type PlaywrightClosableResource = {
|
||||
close: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function messageFromError(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isPlaywrightTargetClosedError(error: unknown) {
|
||||
const message = messageFromError(error);
|
||||
return /Target page, context or browser has been closed|Target closed|Browser has been closed|Context has been closed|Page has been closed/i.test(
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
async function closePlaywrightResourceSafely(
|
||||
resource: PlaywrightClosableResource | null,
|
||||
label: string,
|
||||
) {
|
||||
if (!resource) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await resource.close();
|
||||
} catch (error) {
|
||||
if (isPlaywrightTargetClosedError(error)) {
|
||||
return;
|
||||
}
|
||||
console.warn(`Playwright cleanup ignored failed close for ${label}.`, {
|
||||
error: messageFromError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function readPositiveIntEnv(key: string, fallback: number) {
|
||||
const raw = process.env[key]?.trim();
|
||||
if (!raw) {
|
||||
@@ -230,6 +271,280 @@ function isGenericBusinessEmail(email: string) {
|
||||
return GENERIC_EMAIL_LOCALS.has(base);
|
||||
}
|
||||
|
||||
function decodeHtmlCodePoint(rawCode: string, radix: number) {
|
||||
const codePoint = Number.parseInt(rawCode, radix);
|
||||
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return String.fromCodePoint(codePoint);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function decodeHtmlText(input: string) {
|
||||
return input
|
||||
.replace(/&#(\d+);/g, (_, code: string) =>
|
||||
decodeHtmlCodePoint(code, 10),
|
||||
)
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, code: string) =>
|
||||
decodeHtmlCodePoint(code, 16),
|
||||
)
|
||||
.replace(/ | | /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() {
|
||||
const [playwrightCore, chromiumPackage] = await Promise.all([
|
||||
import("playwright-core"),
|
||||
@@ -327,7 +642,7 @@ async function captureHomepageScreenshot(
|
||||
mimeType: SCREENSHOT_MIME_TYPE,
|
||||
} satisfies StoredScreenshot;
|
||||
} finally {
|
||||
await page.close();
|
||||
await closePlaywrightResourceSafely(page, "homepage screenshot page");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,7 +743,7 @@ async function crawlPage(
|
||||
hasContactCtaSignal: signals.hasContactCtaSignal,
|
||||
} satisfies PageResult;
|
||||
} finally {
|
||||
await page.close();
|
||||
await closePlaywrightResourceSafely(page, "crawl page");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,9 +773,205 @@ function deduplicateCrawlLinks(links: PersistedCrawlLink[]) {
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
async function processLeadEnrichmentWithoutBrowser(
|
||||
ctx: ActionCtx,
|
||||
args: {
|
||||
runId: Id<"agentRuns">;
|
||||
lead: WebsiteLead;
|
||||
rootUrl: string;
|
||||
timeoutMs: number;
|
||||
maxPages: number;
|
||||
actionStartedAt: number;
|
||||
actionBudget: number;
|
||||
},
|
||||
): Promise<Id<"agentRuns">> {
|
||||
const {
|
||||
runId,
|
||||
lead,
|
||||
rootUrl,
|
||||
timeoutMs,
|
||||
maxPages,
|
||||
actionStartedAt,
|
||||
actionBudget,
|
||||
} = args;
|
||||
|
||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||
runId,
|
||||
level: "warning",
|
||||
message:
|
||||
"Chromium ist nicht konfiguriert; Website-Enrichment nutzt browserlosen Fetch-Fallback.",
|
||||
details: [{ label: "Lead", value: lead._id }],
|
||||
});
|
||||
|
||||
const homepage = await withActionTimeout(
|
||||
crawlPageWithoutBrowser(
|
||||
rootUrl,
|
||||
rootUrl,
|
||||
Math.min(timeoutMs, remainingActionBudgetMs(actionStartedAt, actionBudget)),
|
||||
),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
"Homepage browserlos crawlen",
|
||||
);
|
||||
if (!homepage) {
|
||||
throw new Error("Homepage konnte im browserlosen Fallback nicht geladen werden.");
|
||||
}
|
||||
|
||||
const crawlTargets = makeBrowserlessCrawlTargets(
|
||||
rootUrl,
|
||||
homepage.links.map((link) => link.href),
|
||||
maxPages,
|
||||
);
|
||||
const crawledPages: PageResult[] = [homepage];
|
||||
const crawledUrls = new Set<string>();
|
||||
const normalizedHomepageUrl = normalizeCrawlUrl(homepage.finalUrl, rootUrl);
|
||||
if (normalizedHomepageUrl) {
|
||||
crawledUrls.add(normalizedHomepageUrl);
|
||||
}
|
||||
|
||||
for (const pageUrl of crawlTargets.slice(1)) {
|
||||
const normalizedTarget = normalizeCrawlUrl(pageUrl, rootUrl);
|
||||
if (!normalizedTarget || crawledUrls.has(normalizedTarget)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const crawled = await withActionTimeout(
|
||||
crawlPageWithoutBrowser(
|
||||
normalizedTarget,
|
||||
rootUrl,
|
||||
Math.min(
|
||||
timeoutMs,
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
),
|
||||
),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
`Unterseite browserlos crawlen: ${normalizedTarget}`,
|
||||
);
|
||||
if (crawled) {
|
||||
crawledPages.push(crawled);
|
||||
const normalizedCrawledUrl = normalizeCrawlUrl(crawled.finalUrl, rootUrl);
|
||||
if (normalizedCrawledUrl) {
|
||||
crawledUrls.add(normalizedCrawledUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allLinks: PersistedCrawlLink[] = crawledPages.flatMap((page) =>
|
||||
page.links.map((link) => ({
|
||||
...link,
|
||||
pageUrl: page.finalUrl,
|
||||
})),
|
||||
);
|
||||
const technicalInput = buildTechnicalChecks({
|
||||
rootUrl,
|
||||
finalUrl: homepage.finalUrl,
|
||||
title: homepage.title,
|
||||
metaDescription: homepage.metaDescription,
|
||||
visibleText: homepage.visibleText,
|
||||
checkedUrls: crawledPages.map((page) => page.finalUrl),
|
||||
links: allLinks.map((link) => link.href),
|
||||
});
|
||||
const validCandidates = deduplicateLeadEmailCandidates(
|
||||
crawledPages.flatMap((page) => page.emailCandidates),
|
||||
);
|
||||
const persistedLinks = deduplicateCrawlLinks(allLinks).slice(
|
||||
0,
|
||||
MAX_PERSISTED_LINKS,
|
||||
);
|
||||
const persistedCandidates = validCandidates.slice(
|
||||
0,
|
||||
MAX_PERSISTED_EMAIL_CANDIDATES,
|
||||
);
|
||||
const usable = getUsableContactEmailFromEntries(
|
||||
validCandidates.map((candidate) => ({
|
||||
email: candidate.email,
|
||||
emailSource: candidate.emailSource,
|
||||
contactPerson: candidate.contactPerson,
|
||||
isBusinessContactAddress: candidate.isBusinessContactAddress,
|
||||
})),
|
||||
);
|
||||
|
||||
await ctx.runMutation(internal.websiteEnrichment.persistLeadEnrichmentResult, {
|
||||
runId,
|
||||
leadId: lead._id,
|
||||
pages: crawledPages.map((page) => ({
|
||||
sourceUrl: page.sourceUrl,
|
||||
finalUrl: page.finalUrl,
|
||||
pageKind: page.pageKind,
|
||||
title: page.title,
|
||||
metaDescription: page.metaDescription,
|
||||
headings: page.headings,
|
||||
visibleTextExcerpt: trimExcerpt(page.visibleText),
|
||||
hasContactFormSignal: page.hasContactFormSignal,
|
||||
hasContactCtaSignal: page.hasContactCtaSignal,
|
||||
})),
|
||||
links: persistedLinks.map((link) => ({
|
||||
pageUrl: link.pageUrl,
|
||||
href: link.href,
|
||||
text: link.text,
|
||||
isInternal: link.isInternal,
|
||||
})),
|
||||
emailCandidates: persistedCandidates.map((candidate) => ({
|
||||
email: candidate.email,
|
||||
normalizedEmail: candidate.normalizedEmail,
|
||||
emailSource: candidate.emailSource,
|
||||
sourceUrl: candidate.sourceUrl,
|
||||
contactPerson: candidate.contactPerson ?? undefined,
|
||||
isBusinessContactAddress: candidate.isBusinessContactAddress,
|
||||
isGeneric: candidate.isGeneric,
|
||||
accepted: usable !== null && candidate.normalizedEmail === usable.email,
|
||||
})),
|
||||
screenshots: [],
|
||||
technicalChecks: [
|
||||
{
|
||||
sourceUrl: homepage.sourceUrl,
|
||||
finalUrl: homepage.finalUrl,
|
||||
usesHttps: technicalInput.https,
|
||||
missingTitle: technicalInput.missingTitle,
|
||||
missingMetaDescription: technicalInput.missingMetaDescription,
|
||||
hasVisibleContactPath: technicalInput.hasVisibleContactPath,
|
||||
brokenInternalLinkCount: technicalInput.brokenInternalLinks.length,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (usable) {
|
||||
await ctx.runMutation(internal.websiteEnrichment.patchLeadFromWebsiteEnrichment, {
|
||||
leadId: lead._id,
|
||||
email: usable.email,
|
||||
emailSource: usable.emailSource ?? undefined,
|
||||
contactPerson: usable.contactPerson ?? undefined,
|
||||
currentContactStatus: lead.contactStatus,
|
||||
});
|
||||
} else {
|
||||
await ctx.runMutation(internal.websiteEnrichment.patchLeadFromWebsiteEnrichment, {
|
||||
leadId: lead._id,
|
||||
currentContactStatus: lead.contactStatus,
|
||||
contactStatusReason:
|
||||
"Browserloses Website-Enrichment abgeschlossen, aber kein verwertbarer Kontakt gefunden.",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.websiteEnrichment.finishLeadEnrichmentRun, {
|
||||
runId,
|
||||
status: "succeeded",
|
||||
currentStep: "website_enrichment",
|
||||
errors: 0,
|
||||
});
|
||||
|
||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||
runId,
|
||||
level: "info",
|
||||
message: usable
|
||||
? "Website-Enrichment browserlos mit nutzbarer E-Mail abgeschlossen."
|
||||
: "Website-Enrichment browserlos abgeschlossen, aber ohne nutzbare E-Mail.",
|
||||
});
|
||||
|
||||
return runId;
|
||||
}
|
||||
|
||||
export const processLeadEnrichment = internalAction({
|
||||
args: { runId: v.id("agentRuns") },
|
||||
handler: async (ctx, args) => {
|
||||
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
|
||||
let started: StartedLead | null = null;
|
||||
const runId = args.runId;
|
||||
const actionStartedAt = Date.now();
|
||||
@@ -480,27 +991,6 @@ export const processLeadEnrichment = internalAction({
|
||||
|
||||
const rootUrl = normalizeCrawlUrl(started.lead.websiteUrl);
|
||||
if (!rootUrl) {
|
||||
try {
|
||||
await ctx.runMutation(internal.pageSpeed.queueLeadPageSpeedAudit, {
|
||||
leadId: started.lead._id,
|
||||
parentRunId: runId,
|
||||
});
|
||||
} catch (pageSpeedQueueError) {
|
||||
await ctx.runMutation(api.runs.appendEvent, {
|
||||
runId,
|
||||
level: "warning",
|
||||
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
|
||||
details: [
|
||||
{ label: "Lead", value: started.lead._id },
|
||||
{
|
||||
label: "Fehler",
|
||||
value: messageFromError(pageSpeedQueueError),
|
||||
source: "pagespeed_queue",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.websiteEnrichment.finishLeadEnrichmentRun, {
|
||||
runId,
|
||||
status: "failed",
|
||||
@@ -508,7 +998,7 @@ export const processLeadEnrichment = internalAction({
|
||||
errorSummary: "Ungültige Website-URL.",
|
||||
errors: 1,
|
||||
});
|
||||
await ctx.runMutation(api.runs.appendEvent, {
|
||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||
runId,
|
||||
level: "error",
|
||||
message: "Website-Enrichment fehlgeschlagen: Ungültige Website-URL.",
|
||||
@@ -526,6 +1016,18 @@ export const processLeadEnrichment = internalAction({
|
||||
const timeoutMs = crawlTimeoutMs();
|
||||
const maxPages = crawlMaxPages();
|
||||
|
||||
if (!getChromiumExecutableSource()) {
|
||||
return await processLeadEnrichmentWithoutBrowser(ctx, {
|
||||
runId,
|
||||
lead: started.lead,
|
||||
rootUrl,
|
||||
timeoutMs,
|
||||
maxPages,
|
||||
actionStartedAt,
|
||||
actionBudget,
|
||||
});
|
||||
}
|
||||
|
||||
const { playwrightCore, serverlessChromium } =
|
||||
await withActionTimeout(
|
||||
loadPlaywrightModules(),
|
||||
@@ -797,27 +1299,6 @@ export const processLeadEnrichment = internalAction({
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.runMutation(internal.pageSpeed.queueLeadPageSpeedAudit, {
|
||||
leadId: started.lead._id,
|
||||
parentRunId: runId,
|
||||
});
|
||||
} catch (pageSpeedQueueError) {
|
||||
await ctx.runMutation(api.runs.appendEvent, {
|
||||
runId,
|
||||
level: "warning",
|
||||
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
|
||||
details: [
|
||||
{ label: "Lead", value: started.lead._id },
|
||||
{
|
||||
label: "Fehler",
|
||||
value: messageFromError(pageSpeedQueueError),
|
||||
source: "pagespeed_queue",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.websiteEnrichment.finishLeadEnrichmentRun, {
|
||||
runId,
|
||||
status: "succeeded",
|
||||
@@ -825,7 +1306,7 @@ export const processLeadEnrichment = internalAction({
|
||||
errors: 0,
|
||||
});
|
||||
|
||||
await ctx.runMutation(api.runs.appendEvent, {
|
||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||
runId,
|
||||
level: "info",
|
||||
message: usable
|
||||
@@ -846,7 +1327,7 @@ export const processLeadEnrichment = internalAction({
|
||||
errors: 1,
|
||||
});
|
||||
|
||||
await ctx.runMutation(api.runs.appendEvent, {
|
||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||
runId,
|
||||
level: "error",
|
||||
message: "Website-Enrichment fehlgeschlagen.",
|
||||
@@ -856,26 +1337,6 @@ export const processLeadEnrichment = internalAction({
|
||||
});
|
||||
|
||||
if (started) {
|
||||
try {
|
||||
await ctx.runMutation(internal.pageSpeed.queueLeadPageSpeedAudit, {
|
||||
leadId: started.lead._id,
|
||||
parentRunId: runId,
|
||||
});
|
||||
} catch (pageSpeedQueueError) {
|
||||
await ctx.runMutation(api.runs.appendEvent, {
|
||||
runId,
|
||||
level: "warning",
|
||||
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
|
||||
details: [
|
||||
{ label: "Lead", value: started.lead._id },
|
||||
{
|
||||
label: "Fehler",
|
||||
value: messageFromError(pageSpeedQueueError),
|
||||
source: "pagespeed_queue",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
await ctx.runMutation(internal.websiteEnrichment.patchLeadFromWebsiteEnrichment, {
|
||||
leadId: started.lead._id,
|
||||
currentContactStatus: started.lead.contactStatus,
|
||||
@@ -886,13 +1347,19 @@ export const processLeadEnrichment = internalAction({
|
||||
return null;
|
||||
} finally {
|
||||
if (desktopContext) {
|
||||
await desktopContext.close();
|
||||
await closePlaywrightResourceSafely(
|
||||
desktopContext,
|
||||
"desktop browser context",
|
||||
);
|
||||
}
|
||||
if (mobileContext) {
|
||||
await mobileContext.close();
|
||||
await closePlaywrightResourceSafely(
|
||||
mobileContext,
|
||||
"mobile browser context",
|
||||
);
|
||||
}
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
await closePlaywrightResourceSafely(browser, "browser");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
49
docs/coolify-deployment.md
Normal file
49
docs/coolify-deployment.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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`
|
||||
- `LOCAL_BUSINESS_DATA_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 Local Business Data, 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/**",
|
||||
".test-output/**",
|
||||
"convex/_generated/**",
|
||||
// v2_elemente contains PRD/reference snippets, not runtime source.
|
||||
"v2_elemente/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -60,6 +60,7 @@ export type AuditEvidenceInput = {
|
||||
observedUxSignals: string[];
|
||||
observedContentSignals: string[];
|
||||
observedTechnicalSignals: string[];
|
||||
externalMarkdown?: string;
|
||||
screenshotReferences: Array<{
|
||||
storageId: string;
|
||||
sourceUrl: string;
|
||||
@@ -71,6 +72,20 @@ export type AuditEvidenceInput = {
|
||||
}>;
|
||||
pageSpeedCustomerImplications: string[];
|
||||
selectedSkills: AuditUsedSkill[];
|
||||
evidenceLedger: AuditEvidenceLedgerEntry[];
|
||||
};
|
||||
|
||||
export type AuditEvidenceLedgerEntry = {
|
||||
id: string;
|
||||
type:
|
||||
| "crawl_page"
|
||||
| "technical_check"
|
||||
| "screenshot"
|
||||
| "pagespeed"
|
||||
| "jina_excerpt";
|
||||
label: string;
|
||||
sourceUrl?: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type AuditEvidenceInputArgs = {
|
||||
@@ -80,6 +95,7 @@ export type AuditEvidenceInputArgs = {
|
||||
screenshots?: readonly AuditScreenshotEvidence[];
|
||||
pageSpeedInputs?: readonly PageSpeedMinimalAuditResult[];
|
||||
skillRegistry?: readonly SkillRegistryEntryEvidence[];
|
||||
externalMarkdown?: string;
|
||||
};
|
||||
|
||||
const COMPANY_CONTEXT_LIMIT = 8;
|
||||
@@ -90,6 +106,21 @@ const TECHNICAL_SIGNAL_LIMIT = 6;
|
||||
const PAGESPEED_SIGNAL_LIMIT = 8;
|
||||
const SCREENSHOT_REFERENCE_LIMIT = 8;
|
||||
const SELECTED_SKILLS_LIMIT = 6;
|
||||
const EXTERNAL_MARKDOWN_LIMIT = 4_000;
|
||||
const V3_LOCAL_AUDIT_PRIORITY = new Map(
|
||||
[
|
||||
"visual-design",
|
||||
"impeccable-critique",
|
||||
"contact-conversion",
|
||||
"local-seo-basics",
|
||||
"performance-experience",
|
||||
"mobile-usability",
|
||||
"conversion-copy",
|
||||
"first-impression-clarity",
|
||||
"trust-signals",
|
||||
"accessibility-basics",
|
||||
].map((id, index) => [id, index] as const),
|
||||
);
|
||||
|
||||
const URL_PATTERN = /\bhttps?:\/\/[^\s<>"']+/i;
|
||||
const JSON_BRACKET_PATTERN = /\{[^}]*\}|\[[^\]]*\]/;
|
||||
@@ -97,6 +128,32 @@ const PAGESPEED_NOISE_PATTERN =
|
||||
/\b(?:raw\s*storage\s*id|rawstorageid|lighthouse|pagespeed|score)\b/i;
|
||||
const MACHINE_TOKEN_PATTERN = /\b[a-z\d_-]{24,}\b/i;
|
||||
|
||||
function stableEvidencePart(value: unknown) {
|
||||
const normalized = trimAndNormalize(String(value ?? "").toLowerCase())
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^www\./, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
|
||||
return normalized || "source";
|
||||
}
|
||||
|
||||
function evidenceId(type: AuditEvidenceLedgerEntry["type"], ...parts: unknown[]) {
|
||||
return [type, ...parts.map(stableEvidencePart)].join(":");
|
||||
}
|
||||
|
||||
function addEvidenceLedgerEntry(
|
||||
ledger: AuditEvidenceLedgerEntry[],
|
||||
entry: AuditEvidenceLedgerEntry,
|
||||
) {
|
||||
if (!entry.summary || ledger.some((current) => current.id === entry.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ledger.push(entry);
|
||||
}
|
||||
|
||||
function trimAndNormalize(input: unknown): string {
|
||||
if (typeof input !== "string") {
|
||||
return "";
|
||||
@@ -140,6 +197,19 @@ function sanitizeCustomerText(value: unknown, maxLength = 180): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
function sanitizeExternalMarkdown(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const markdown = value.replace(/\s+/g, " ").trim();
|
||||
if (!markdown) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return markdown.slice(0, EXTERNAL_MARKDOWN_LIMIT);
|
||||
}
|
||||
|
||||
function addUniqueCapped(
|
||||
bucket: string[],
|
||||
input: string,
|
||||
@@ -233,6 +303,77 @@ function selectTopSkill(
|
||||
return toAuditUsedSkill(scored[0]!.candidate);
|
||||
}
|
||||
|
||||
type SkillInputAvailability = {
|
||||
websiteExists: boolean;
|
||||
hasDesktopScreenshot: boolean;
|
||||
hasMobileScreenshot: boolean;
|
||||
hasMarkdown: boolean;
|
||||
hasPageSpeed: boolean;
|
||||
hasDom: boolean;
|
||||
};
|
||||
|
||||
function hasRequiredV3Input(input: string, availability: SkillInputAvailability) {
|
||||
switch (input) {
|
||||
case "desktop_screenshot":
|
||||
return availability.hasDesktopScreenshot;
|
||||
case "mobile_screenshot":
|
||||
return availability.hasMobileScreenshot;
|
||||
case "markdown":
|
||||
return availability.hasMarkdown;
|
||||
case "pagespeed":
|
||||
return availability.hasPageSpeed;
|
||||
case "dom":
|
||||
return availability.hasDom;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function v3SkillApplies(
|
||||
skill: SkillRegistryEntryEvidence,
|
||||
availability: SkillInputAvailability,
|
||||
) {
|
||||
const appliesWhen = skill.appliesWhen ?? "website_exists";
|
||||
const applies =
|
||||
appliesWhen === "always" ||
|
||||
(appliesWhen === "website_exists" && availability.websiteExists) ||
|
||||
(appliesWhen === "has_mobile_screenshot" &&
|
||||
availability.hasMobileScreenshot) ||
|
||||
(appliesWhen === "has_pagespeed" && availability.hasPageSpeed);
|
||||
|
||||
if (!applies) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (skill.inputs ?? []).every((input) =>
|
||||
hasRequiredV3Input(input, availability),
|
||||
);
|
||||
}
|
||||
|
||||
function selectV3Skills(
|
||||
skillRegistry: readonly SkillRegistryEntryEvidence[],
|
||||
availability: SkillInputAvailability,
|
||||
) {
|
||||
return skillRegistry
|
||||
.map((skill, registryIndex) => ({ skill, registryIndex }))
|
||||
.filter(({ skill }) => skill.id && !skill.category)
|
||||
.filter(({ skill }) => v3SkillApplies(skill, availability))
|
||||
.sort((a, b) => {
|
||||
// Keep core local-audit coverage inside the cap; otherwise preserve registry order.
|
||||
const aPriority = V3_LOCAL_AUDIT_PRIORITY.get(a.skill.id ?? "");
|
||||
const bPriority = V3_LOCAL_AUDIT_PRIORITY.get(b.skill.id ?? "");
|
||||
if (aPriority !== undefined || bPriority !== undefined) {
|
||||
return (
|
||||
(aPriority ?? Number.POSITIVE_INFINITY) -
|
||||
(bPriority ?? Number.POSITIVE_INFINITY)
|
||||
);
|
||||
}
|
||||
return a.registryIndex - b.registryIndex;
|
||||
})
|
||||
.slice(0, SELECTED_SKILLS_LIMIT)
|
||||
.map(({ skill }) => toAuditUsedSkill(skill));
|
||||
}
|
||||
|
||||
function buildObservedSignals(
|
||||
crawlPages: readonly AuditCrawlPageEvidence[],
|
||||
technicalChecks: readonly AuditTechnicalCheckEvidence[],
|
||||
@@ -403,8 +544,12 @@ function extractSkills(
|
||||
marketing: boolean;
|
||||
offer: boolean;
|
||||
},
|
||||
availability: SkillInputAvailability,
|
||||
): AuditUsedSkill[] {
|
||||
const selected: AuditUsedSkill[] = [];
|
||||
const selected: AuditUsedSkill[] = selectV3Skills(
|
||||
skillRegistry,
|
||||
availability,
|
||||
);
|
||||
const categoryOrder = ["design", "ux", "copy", "seo", "marketing", "offer"] as const;
|
||||
const evidenceText = {
|
||||
design:
|
||||
@@ -450,6 +595,8 @@ export function buildAuditEvidenceInput(
|
||||
const screenshots = args.screenshots ?? [];
|
||||
const pageSpeedInputs = args.pageSpeedInputs ?? [];
|
||||
const skillRegistry = args.skillRegistry ?? [];
|
||||
const externalMarkdown = sanitizeExternalMarkdown(args.externalMarkdown);
|
||||
const evidenceLedger: AuditEvidenceLedgerEntry[] = [];
|
||||
|
||||
const companyContext: string[] = [];
|
||||
const checkedPages: string[] = [];
|
||||
@@ -515,6 +662,22 @@ export function buildAuditEvidenceInput(
|
||||
}
|
||||
|
||||
addUniqueCapped(checkedPages, label, CHECKED_PAGES_LIMIT);
|
||||
addEvidenceLedgerEntry(evidenceLedger, {
|
||||
id: evidenceId("crawl_page", page.finalUrl ?? page.sourceUrl, page.pageKind),
|
||||
type: "crawl_page",
|
||||
label,
|
||||
...(page.finalUrl ?? page.sourceUrl ? { sourceUrl: page.finalUrl ?? page.sourceUrl ?? undefined } : {}),
|
||||
summary: sanitizeCustomerText(
|
||||
[
|
||||
title ? `Titel: ${title}` : "",
|
||||
page.metaDescription ? `Meta: ${page.metaDescription}` : "",
|
||||
page.visibleTextExcerpt ?? page.visibleText ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | "),
|
||||
260,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (checkedPages.length === 0 && lead.companyName) {
|
||||
@@ -529,6 +692,44 @@ export function buildAuditEvidenceInput(
|
||||
const pageSpeedInputsOutput = buildPageSpeedAuditInputs(pageSpeedInputs);
|
||||
const pageSpeedCustomerImplications: string[] = [];
|
||||
|
||||
for (const check of technicalChecks) {
|
||||
const summary = [
|
||||
check.usesHttps === true ? "HTTPS vorhanden" : "",
|
||||
check.usesHttps === false ? "HTTPS fehlt" : "",
|
||||
check.missingTitle === true ? "Title fehlt" : "",
|
||||
check.missingMetaDescription === true ? "Meta-Description fehlt" : "",
|
||||
check.hasVisibleContactPath === true ? "Kontaktpfad sichtbar" : "",
|
||||
check.brokenInternalLinkCount !== undefined
|
||||
? `Interne Linkfehler: ${check.brokenInternalLinkCount}`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
|
||||
addEvidenceLedgerEntry(evidenceLedger, {
|
||||
id: evidenceId("technical_check", check.finalUrl ?? check.sourceUrl),
|
||||
type: "technical_check",
|
||||
label: `Technik: ${toSafePath(check.finalUrl ?? check.sourceUrl ?? "") || "Seite"}`,
|
||||
...(check.finalUrl ?? check.sourceUrl ? { sourceUrl: check.finalUrl ?? check.sourceUrl ?? undefined } : {}),
|
||||
summary: sanitizeCustomerText(summary, 260),
|
||||
});
|
||||
}
|
||||
|
||||
for (const screenshot of screenshots) {
|
||||
addEvidenceLedgerEntry(evidenceLedger, {
|
||||
id: evidenceId(
|
||||
"screenshot",
|
||||
screenshot.storageId,
|
||||
screenshot.viewport,
|
||||
screenshot.sourceUrl,
|
||||
),
|
||||
type: "screenshot",
|
||||
label: `${screenshot.viewport === "desktop" ? "Desktop" : "Mobil"} Screenshot`,
|
||||
sourceUrl: screenshot.sourceUrl,
|
||||
summary: `${screenshot.viewport} Screenshot ${screenshot.width}x${screenshot.height}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const implication of pageSpeedInputsOutput.customerImplications) {
|
||||
addUniqueCapped(
|
||||
pageSpeedCustomerImplications,
|
||||
@@ -538,10 +739,56 @@ export function buildAuditEvidenceInput(
|
||||
);
|
||||
}
|
||||
|
||||
for (const input of pageSpeedInputs) {
|
||||
const implication = pageSpeedInputsOutput.customerImplications.find(Boolean);
|
||||
addEvidenceLedgerEntry(evidenceLedger, {
|
||||
id: evidenceId("pagespeed", input.strategy, input.sourceUrl, input.status),
|
||||
type: "pagespeed",
|
||||
label: `PageSpeed ${input.strategy}`,
|
||||
sourceUrl: input.sourceUrl,
|
||||
summary: sanitizeCustomerText(
|
||||
implication ??
|
||||
(input.status === "succeeded"
|
||||
? "PageSpeed-Messung erfolgreich"
|
||||
: "PageSpeed-Messung nicht verfügbar"),
|
||||
260,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (externalMarkdown) {
|
||||
addEvidenceLedgerEntry(evidenceLedger, {
|
||||
id: evidenceId("jina_excerpt", externalMarkdown.slice(0, 80)),
|
||||
type: "jina_excerpt",
|
||||
label: "Jina Reader Auszug",
|
||||
summary: sanitizeCustomerText(externalMarkdown, 260),
|
||||
});
|
||||
}
|
||||
|
||||
const selectedSkills = extractSkills(skillRegistry, {
|
||||
...signals.evidenceText,
|
||||
marketing: false,
|
||||
offer: false,
|
||||
}, {
|
||||
websiteExists:
|
||||
Boolean(lead.websiteDomain || lead.websiteUrl) ||
|
||||
crawlPages.length > 0 ||
|
||||
screenshots.length > 0,
|
||||
hasDesktopScreenshot: screenshots.some(
|
||||
(screenshot) => screenshot.viewport === "desktop",
|
||||
),
|
||||
hasMobileScreenshot: screenshots.some(
|
||||
(screenshot) => screenshot.viewport === "mobile",
|
||||
),
|
||||
hasMarkdown:
|
||||
Boolean(externalMarkdown) ||
|
||||
crawlPages.some((page) =>
|
||||
Boolean(page.visibleText || page.visibleTextExcerpt),
|
||||
),
|
||||
hasPageSpeed:
|
||||
pageSpeedInputsOutput.customerImplications.length > 0 ||
|
||||
pageSpeedInputs.some((input) => input.status === "succeeded"),
|
||||
hasDom: crawlPages.length > 0 || technicalChecks.length > 0,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -550,6 +797,7 @@ export function buildAuditEvidenceInput(
|
||||
observedUxSignals: signals.ux,
|
||||
observedContentSignals: signals.content,
|
||||
observedTechnicalSignals: signals.technical,
|
||||
...(externalMarkdown ? { externalMarkdown } : {}),
|
||||
screenshotReferences: screenshotReferences.map((reference) => ({
|
||||
...reference,
|
||||
width: Math.max(reference.width, 0),
|
||||
@@ -561,5 +809,6 @@ export function buildAuditEvidenceInput(
|
||||
PAGESPEED_SIGNAL_LIMIT,
|
||||
),
|
||||
selectedSkills,
|
||||
evidenceLedger,
|
||||
};
|
||||
}
|
||||
|
||||
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([
|
||||
"ich",
|
||||
"mich",
|
||||
@@ -31,6 +33,12 @@ const GERMAN_MARKERS = new Set([
|
||||
"wenn",
|
||||
"für",
|
||||
"bei",
|
||||
"kurz",
|
||||
"kurzer",
|
||||
"hinweis",
|
||||
"zur",
|
||||
"kontaktseite",
|
||||
"webauftritt",
|
||||
]);
|
||||
|
||||
const ENGLISH_MARKERS = new Set([
|
||||
@@ -68,13 +76,14 @@ const ENGLISH_MARKERS = new Set([
|
||||
|
||||
const OBSERVATION_TOKENS = [
|
||||
/\b(mir|ich)\b[^\n]{0,80}\b(aufgefallen|festgestellt|bemerkt|beobachtet|gesehen|sichtbar)\b/i,
|
||||
/\b(erkennt|zeigt|sichtbar|feststell|finde|fällt)\b/i,
|
||||
/\b(erkennt|zeigt|sichtbar|festgestellt|feststellen|feststellbar|finde|fällt)\b/i,
|
||||
/\b(ich sehe|ich habe gesehen|bei der Prüfung)\b/i,
|
||||
];
|
||||
|
||||
const SUGGESTION_TOKENS = [
|
||||
/\b(empfehle|empfiehlt|vorschlage|vorschlagen|schlage vor|könnte helfen|kannst|können wir|sollte|sollten|ich könnte|ich würde|ich empfehle)\b/i,
|
||||
/\b(schlage vor|schlage)\b/i,
|
||||
/\b(?:mein(?:e[rmns]?)?\s+)?(?:konkreter\s+)?vorschlag(?:\s+ist)?\b/i,
|
||||
/\b(ergänzt|ergänzen|anpassen|optimieren|verbessern|prüfen|einbauen|einzusetzen|setzten)\b/i,
|
||||
];
|
||||
|
||||
@@ -119,6 +128,63 @@ const RAW_TECH_PATTERNS = [
|
||||
/\b[0-9a-f]{24}\b/i,
|
||||
];
|
||||
|
||||
const EMAIL_TEMPLATE_PATTERNS = [
|
||||
/\bich habe beobachtet\b/i,
|
||||
/\bmir ist aufgefallen\b/i,
|
||||
/\bich schlage vor\b/i,
|
||||
/\bich empfehle\b/i,
|
||||
];
|
||||
|
||||
const EMAIL_BROCHURE_PATTERNS = [
|
||||
/\bmaßnahmen umsetzen\b/i,
|
||||
/\bconversion[- ]rate steigern\b/i,
|
||||
/\branking positiv beeinflussen\b/i,
|
||||
/\babsprungraten senken\b/i,
|
||||
/\bnachhaltig verbessern\b/i,
|
||||
/\bsignifikant\b/i,
|
||||
/\boptimierungspotenzial(?:e)?\b/i,
|
||||
/\bnutzerzufriedenheit\b/i,
|
||||
/\bsuchmaschinenplatzierung\b/i,
|
||||
];
|
||||
|
||||
const EMAIL_AUDIT_TOPIC_PATTERNS = [
|
||||
/\bmeta[- ]beschreibung\b/i,
|
||||
/\bpage[- ]?speed\b/i,
|
||||
/\bladezeit(?:en)?\b/i,
|
||||
/\bkontaktformular\b/i,
|
||||
/\bcall[- ]to[- ]action\b/i,
|
||||
/\bmobile(?:n|r|s)? gerät/i,
|
||||
/\bdesktop\b/i,
|
||||
/\bh1[- ]?überschrift(?:en)?\b/i,
|
||||
/\bbewertung(?:en)?\b/i,
|
||||
/\bvertrauenssignal(?:e)?\b/i,
|
||||
/\bstrukturierte daten\b/i,
|
||||
];
|
||||
|
||||
const EMAIL_MINI_AUDIT_TRANSITIONS = [
|
||||
/\baußerdem\b/i,
|
||||
/\bzudem\b/i,
|
||||
/\bein weiterer punkt\b/i,
|
||||
/\bschließlich\b/i,
|
||||
/\bdurch die umsetzung\b/i,
|
||||
];
|
||||
|
||||
const EMAIL_LOW_FRICTION_ASK_PATTERNS = [
|
||||
/\bsoll ich ihnen\b/i,
|
||||
/\bwäre (?:das|ein kurzer hinweis)\b/i,
|
||||
/\bdarf ich ihnen\b/i,
|
||||
/\bkann ich ihnen\b/i,
|
||||
/\boffen für\b/i,
|
||||
];
|
||||
|
||||
const INFORMAL_EMAIL_ADDRESS_PATTERNS = [
|
||||
/\bdu\b/i,
|
||||
/\bdir\b/i,
|
||||
/\bdein(?:e[rmns]?)?\b/i,
|
||||
/\beuch\b/i,
|
||||
/\beuer(?:e[rmns]?)?\b/i,
|
||||
];
|
||||
|
||||
export type GermanCopyGuardIssue = {
|
||||
field: string;
|
||||
rule: string;
|
||||
@@ -255,6 +321,178 @@ function hasRawArtifact(value: string): boolean {
|
||||
return RAW_TECH_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function countMatches(value: string, patterns: readonly RegExp[]) {
|
||||
return patterns.reduce(
|
||||
(count, pattern) => count + (pattern.test(value) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function countRegexMatches(value: string, pattern: RegExp) {
|
||||
return value.match(pattern)?.length ?? 0;
|
||||
}
|
||||
|
||||
function countSentences(value: string) {
|
||||
return value
|
||||
.split(/[.!?]+/)
|
||||
.map((sentence) => sentence.trim())
|
||||
.filter(Boolean).length;
|
||||
}
|
||||
|
||||
function countParagraphs(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.split(/\n\s*\n/)
|
||||
.map((paragraph) => paragraph.trim())
|
||||
.filter(Boolean).length;
|
||||
}
|
||||
|
||||
function startsWithTemplateEmailPhrase(value: string) {
|
||||
return new RegExp(
|
||||
String.raw`^\s*(?:(?:guten tag|hallo|sehr geehrte[^,.!?]*|moin)[,.!?\s]+)?(?:${EMAIL_TEMPLATE_PATTERNS.map(
|
||||
(pattern) => pattern.source,
|
||||
).join("|")})`,
|
||||
"i",
|
||||
).test(value);
|
||||
}
|
||||
|
||||
function hasLowFrictionAsk(value: string) {
|
||||
return (
|
||||
value.includes("?") &&
|
||||
EMAIL_LOW_FRICTION_ASK_PATTERNS.some((pattern) => pattern.test(value))
|
||||
);
|
||||
}
|
||||
|
||||
function validateEmailSubjectTone(
|
||||
issues: GermanCopyGuardIssue[],
|
||||
subject: string,
|
||||
) {
|
||||
const trimmed = subject.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const words = tokenizeWords(trimmed);
|
||||
const { subject: subjectRules } = customerToneGuidelines.email;
|
||||
const hasInflatedSubject =
|
||||
/optimierungspotenzial/i.test(trimmed) ||
|
||||
/mehr sichtbarkeit/i.test(trimmed) ||
|
||||
/bessere nutzererfahrung/i.test(trimmed) ||
|
||||
/kundengewinnung/i.test(trimmed) ||
|
||||
/conversion/i.test(trimmed) ||
|
||||
/ranking/i.test(trimmed);
|
||||
|
||||
if (
|
||||
trimmed.length > subjectRules.maxCharacters ||
|
||||
words.length < subjectRules.minWords ||
|
||||
words.length > subjectRules.maxWords ||
|
||||
/:/.test(trimmed) ||
|
||||
hasInflatedSubject
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailSubject",
|
||||
"unnatural_email_subject",
|
||||
"Betreff wirkt zu pitchig, zu lang oder nicht wie eine kurze Erstmail.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateEmailBodyTone(
|
||||
issues: GermanCopyGuardIssue[],
|
||||
body: string,
|
||||
) {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { email } = customerToneGuidelines;
|
||||
const wordCount = tokenizeWords(trimmed).length;
|
||||
if (wordCount < email.wordCount.min || wordCount > email.wordCount.max) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"unnatural_email_length",
|
||||
"E-Mail sollte als Erstkontakt kurz bleiben: 60-130 Wörter.",
|
||||
);
|
||||
}
|
||||
|
||||
if (countSentences(trimmed) > email.maxSentences) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"too_many_email_sentences",
|
||||
"E-Mail enthält zu viele Sätze für eine erste Kontaktaufnahme.",
|
||||
);
|
||||
}
|
||||
|
||||
if (countParagraphs(trimmed) > email.maxParagraphs) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"too_many_email_paragraphs",
|
||||
"E-Mail sollte höchstens zwei kurze Absätze enthalten.",
|
||||
);
|
||||
}
|
||||
|
||||
const templatePhraseCount = EMAIL_TEMPLATE_PATTERNS.reduce(
|
||||
(count, pattern) => count + countRegexMatches(trimmed, new RegExp(pattern.source, "gi")),
|
||||
0,
|
||||
);
|
||||
const firstPersonCount = countRegexMatches(trimmed, /\bich\b/gi);
|
||||
if (
|
||||
startsWithTemplateEmailPhrase(trimmed) ||
|
||||
templatePhraseCount >= 2 ||
|
||||
firstPersonCount > 2
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"formulaic_email_tone",
|
||||
"E-Mail wirkt formelhaft; vermeide wiederholte Ich-habe-/Ich-schlage-vor-Muster.",
|
||||
);
|
||||
}
|
||||
|
||||
if (EMAIL_BROCHURE_PATTERNS.some((pattern) => pattern.test(trimmed))) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"brochure_email_language",
|
||||
"E-Mail klingt nach Broschüre statt nach natürlicher Erstansprache.",
|
||||
);
|
||||
}
|
||||
|
||||
const topicCount = countMatches(trimmed, EMAIL_AUDIT_TOPIC_PATTERNS);
|
||||
const transitionCount = countMatches(trimmed, EMAIL_MINI_AUDIT_TRANSITIONS);
|
||||
if (topicCount >= 4 || transitionCount >= 2) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"email_reads_like_mini_audit",
|
||||
"E-Mail bündelt zu viele Audit-Punkte und sollte höchstens zwei Befunde anreißen.",
|
||||
);
|
||||
}
|
||||
|
||||
if (INFORMAL_EMAIL_ADDRESS_PATTERNS.some((pattern) => pattern.test(trimmed))) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"informal_email_address",
|
||||
"E-Mail sollte unbekannte lokale Betriebe formal mit Sie/Ihnen ansprechen.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasLowFrictionAsk(trimmed)) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"missing_low_friction_ask",
|
||||
"E-Mail sollte mit einer kurzen, leicht beantwortbaren Frage enden.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTextField(
|
||||
issues: GermanCopyGuardIssue[],
|
||||
field: string,
|
||||
@@ -371,10 +609,12 @@ export function validateEmailCopy(email: EmailCopy): GermanCopyGuardResult {
|
||||
const issues: GermanCopyGuardIssue[] = [];
|
||||
|
||||
validateTextField(issues, "emailSubject", email.subject, { skipIfTooShort: true });
|
||||
validateEmailSubjectTone(issues, email.subject);
|
||||
validateTextField(issues, "emailBody", email.body, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
requireIchForm: false,
|
||||
requireObservationAndSuggestion: false,
|
||||
});
|
||||
validateEmailBodyTone(issues, email.body);
|
||||
|
||||
return { passed: issues.length === 0, issues };
|
||||
}
|
||||
@@ -386,7 +626,7 @@ export function validateCallScriptCopy(script: CallScriptCopy): GermanCopyGuardR
|
||||
requireIchForm: true,
|
||||
});
|
||||
validateCallScriptText(issues, "callScript.closeLine", script.closeLine, {
|
||||
requireIchForm: true,
|
||||
requireIchForm: false,
|
||||
});
|
||||
|
||||
script.callScript.forEach((line, index) => {
|
||||
@@ -452,13 +692,15 @@ export function validateCustomerFacingCopy(input: GermanCustomerCopy): GermanCop
|
||||
validateTextField(issues, "emailSubject", input.emailSubject, {
|
||||
skipIfTooShort: true,
|
||||
});
|
||||
validateEmailSubjectTone(issues, input.emailSubject);
|
||||
}
|
||||
|
||||
if (input.emailBody !== undefined) {
|
||||
validateTextField(issues, "emailBody", input.emailBody, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
requireIchForm: false,
|
||||
requireObservationAndSuggestion: false,
|
||||
});
|
||||
validateEmailBodyTone(issues, input.emailBody);
|
||||
}
|
||||
|
||||
if (input.callScript) {
|
||||
|
||||
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";
|
||||
|
||||
export const findingItemSchema = z.object({
|
||||
const nonEmptyTextSchema = z.string().trim().min(1);
|
||||
|
||||
export const legacyFindingItemSchema = z.object({
|
||||
section: z.string(),
|
||||
finding: z.string(),
|
||||
suggestion: z.string(),
|
||||
});
|
||||
|
||||
export const v3FindingItemSchema = z.object({
|
||||
skill_id: nonEmptyTextSchema,
|
||||
observation: nonEmptyTextSchema,
|
||||
customer_benefit: nonEmptyTextSchema,
|
||||
public_phrasing: nonEmptyTextSchema,
|
||||
severity: z.union([z.literal(1), z.literal(2), z.literal(3)]),
|
||||
evidence: nonEmptyTextSchema,
|
||||
applies: z.boolean(),
|
||||
});
|
||||
|
||||
export const findingItemSchema = legacyFindingItemSchema;
|
||||
|
||||
export const auditFindingEvidenceRefSchema = z.object({
|
||||
id: nonEmptyTextSchema,
|
||||
type: z.enum([
|
||||
"crawl_page",
|
||||
"technical_check",
|
||||
"screenshot",
|
||||
"pagespeed",
|
||||
"jina_excerpt",
|
||||
"generation_stage",
|
||||
]),
|
||||
label: nonEmptyTextSchema,
|
||||
sourceUrl: z.string().trim(),
|
||||
});
|
||||
|
||||
export const auditSpecialistFindingSchema = z
|
||||
.object({
|
||||
skillId: nonEmptyTextSchema,
|
||||
claim: nonEmptyTextSchema,
|
||||
recommendation: nonEmptyTextSchema,
|
||||
customerBenefit: nonEmptyTextSchema,
|
||||
severity: z.union([z.literal(1), z.literal(2), z.literal(3)]),
|
||||
confidence: z.number().min(0).max(1),
|
||||
evidenceRefs: z.array(auditFindingEvidenceRefSchema).min(1),
|
||||
applies: z.boolean(),
|
||||
unknowns: z.array(z.string()),
|
||||
})
|
||||
.superRefine((finding, ctx) => {
|
||||
const combined = [
|
||||
finding.claim,
|
||||
finding.recommendation,
|
||||
finding.customerBenefit,
|
||||
].join(" ");
|
||||
if (/\bunbekannt\b|\bunknown\b/i.test(combined)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "unknown-only findings are not valid audit claims",
|
||||
path: ["claim"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const auditSpecialistResultSchema = z.object({
|
||||
status: z.enum(["success", "partial", "skipped", "failed"]),
|
||||
findings: z.array(auditSpecialistFindingSchema),
|
||||
notes: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const auditRejectedFindingSchema = z.object({
|
||||
findingId: nonEmptyTextSchema,
|
||||
skillId: nonEmptyTextSchema,
|
||||
claim: nonEmptyTextSchema,
|
||||
rejectionReason: nonEmptyTextSchema,
|
||||
});
|
||||
|
||||
export const auditEvidenceVerificationSchema = z.object({
|
||||
verifiedFindingIds: z.array(nonEmptyTextSchema),
|
||||
rejectedFindings: z.array(auditRejectedFindingSchema),
|
||||
contradictions: z.array(z.string()),
|
||||
notes: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const internalFindingsSchema = z.object({
|
||||
findings: z.array(findingItemSchema),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
export const auditClassificationSchema = z.object({
|
||||
findings: z.array(v3FindingItemSchema).min(1),
|
||||
summary: nonEmptyTextSchema,
|
||||
usedSkills: z.array(nonEmptyTextSchema).nullable(),
|
||||
});
|
||||
|
||||
export const auditGenerationResultSchema = z.object({
|
||||
findings: z.array(v3FindingItemSchema).min(1),
|
||||
usedSkills: z.array(nonEmptyTextSchema).min(1),
|
||||
publicAuditText: nonEmptyTextSchema,
|
||||
finalSummary: nonEmptyTextSchema,
|
||||
emailSubject: nonEmptyTextSchema,
|
||||
emailBody: nonEmptyTextSchema,
|
||||
phoneScript: nonEmptyTextSchema,
|
||||
ctaType: z.enum(["anruf", "termin", "rueckruf"]),
|
||||
});
|
||||
|
||||
export const auditSummarySchema = z.object({
|
||||
summary: z.string(),
|
||||
keyFindings: z.array(z.string()),
|
||||
@@ -36,23 +128,43 @@ export const callScriptSchema = z.object({
|
||||
|
||||
export const followUpDraftSchema = z.object({
|
||||
message: z.string(),
|
||||
followInDays: z.number().int().min(0).optional(),
|
||||
goals: z.array(z.string()).optional(),
|
||||
followInDays: z.number().int().min(0).nullable(),
|
||||
goals: z.array(z.string()).nullable(),
|
||||
});
|
||||
|
||||
export const qualityReviewRevisedCopySchema = z.object({
|
||||
publicSummary: nonEmptyTextSchema,
|
||||
publicBody: nonEmptyTextSchema,
|
||||
emailSubject: nonEmptyTextSchema,
|
||||
emailBody: nonEmptyTextSchema,
|
||||
phoneScript: callScriptSchema,
|
||||
followUpDraft: followUpDraftSchema,
|
||||
});
|
||||
|
||||
export const qualityReviewSchema = z.object({
|
||||
isValid: z.boolean(),
|
||||
severity: z.enum(["ok", "warning", "unsafe"]),
|
||||
issues: z.array(z.string()),
|
||||
suggestions: z.array(z.string()),
|
||||
notes: z.array(z.string()).optional(),
|
||||
rewriteRequired: z.boolean(),
|
||||
revisedCopy: qualityReviewRevisedCopySchema.nullable(),
|
||||
notes: z.array(z.string()).nullable(),
|
||||
});
|
||||
|
||||
export type FindingItem = z.infer<typeof findingItemSchema>;
|
||||
export type V3FindingItem = z.infer<typeof v3FindingItemSchema>;
|
||||
export type AuditFindingEvidenceRef = z.infer<typeof auditFindingEvidenceRefSchema>;
|
||||
export type AuditSpecialistFinding = z.infer<typeof auditSpecialistFindingSchema>;
|
||||
export type AuditSpecialistResult = z.infer<typeof auditSpecialistResultSchema>;
|
||||
export type AuditEvidenceVerification = z.infer<typeof auditEvidenceVerificationSchema>;
|
||||
export type InternalFindings = z.infer<typeof internalFindingsSchema>;
|
||||
export type AuditClassification = z.infer<typeof auditClassificationSchema>;
|
||||
export type AuditGenerationResult = z.infer<typeof auditGenerationResultSchema>;
|
||||
export type AuditSummary = z.infer<typeof auditSummarySchema>;
|
||||
export type PublicAuditText = z.infer<typeof publicAuditTextSchema>;
|
||||
export type EmailDraft = z.infer<typeof emailDraftSchema>;
|
||||
export type EmailSubject = z.infer<typeof emailSubjectSchema>;
|
||||
export type CallScript = z.infer<typeof callScriptSchema>;
|
||||
export type FollowUpDraft = z.infer<typeof followUpDraftSchema>;
|
||||
export type QualityReviewRevisedCopy = z.infer<typeof qualityReviewRevisedCopySchema>;
|
||||
export type QualityReview = z.infer<typeof qualityReviewSchema>;
|
||||
|
||||
75
lib/audits/progress.ts
Normal file
75
lib/audits/progress.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export const AUDIT_PROGRESS_TOTAL_STEPS = 6;
|
||||
|
||||
export type AuditProgress = {
|
||||
step: number;
|
||||
total: number;
|
||||
label: string;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
const fallbackProgress: AuditProgress = {
|
||||
step: 1,
|
||||
total: AUDIT_PROGRESS_TOTAL_STEPS,
|
||||
label: "Audit vorbereitet",
|
||||
percent: 17,
|
||||
};
|
||||
|
||||
const progressByStep: Record<string, AuditProgress> = {
|
||||
audit_prepared: fallbackProgress,
|
||||
pagespeed_insights: {
|
||||
step: 2,
|
||||
total: AUDIT_PROGRESS_TOTAL_STEPS,
|
||||
label: "Messe PageSpeed",
|
||||
percent: 33,
|
||||
},
|
||||
website_signals: {
|
||||
step: 3,
|
||||
total: AUDIT_PROGRESS_TOTAL_STEPS,
|
||||
label: "Sammle Website-Signale",
|
||||
percent: 50,
|
||||
},
|
||||
classification: {
|
||||
step: 4,
|
||||
total: AUDIT_PROGRESS_TOTAL_STEPS,
|
||||
label: "Bewerte Befunde",
|
||||
percent: 67,
|
||||
},
|
||||
evidenceVerifier: {
|
||||
step: 4,
|
||||
total: AUDIT_PROGRESS_TOTAL_STEPS,
|
||||
label: "Bewerte Befunde",
|
||||
percent: 67,
|
||||
},
|
||||
multimodalAudit: {
|
||||
step: 4,
|
||||
total: AUDIT_PROGRESS_TOTAL_STEPS,
|
||||
label: "Bewerte Befunde",
|
||||
percent: 67,
|
||||
},
|
||||
germanCopy: {
|
||||
step: 5,
|
||||
total: AUDIT_PROGRESS_TOTAL_STEPS,
|
||||
label: "Erstelle Texte",
|
||||
percent: 83,
|
||||
},
|
||||
qualityReview: {
|
||||
step: 6,
|
||||
total: AUDIT_PROGRESS_TOTAL_STEPS,
|
||||
label: "Speichere Audit",
|
||||
percent: 100,
|
||||
},
|
||||
persist_audit: {
|
||||
step: 6,
|
||||
total: AUDIT_PROGRESS_TOTAL_STEPS,
|
||||
label: "Speichere Audit",
|
||||
percent: 100,
|
||||
},
|
||||
};
|
||||
|
||||
export function getAuditProgressForStep(step: string | null | undefined) {
|
||||
if (!step) {
|
||||
return fallbackProgress;
|
||||
}
|
||||
|
||||
return progressByStep[step] ?? fallbackProgress;
|
||||
}
|
||||
@@ -86,6 +86,7 @@ export type LeadFunnelOutreach = {
|
||||
sendStatus?: OutreachSendStatus | null;
|
||||
responseStatus?: OutreachResponseStatus | null;
|
||||
salesStatus?: OutreachSalesStatus | null;
|
||||
doNotContactUntil?: number | null;
|
||||
};
|
||||
|
||||
export type LeadFunnelInput = {
|
||||
@@ -103,6 +104,7 @@ export type LeadFunnelInput = {
|
||||
contactPerson?: string | null;
|
||||
websiteDomain?: string | null;
|
||||
outreach?: LeadFunnelOutreach | null;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
export type LeadFunnelCard = {
|
||||
@@ -303,6 +305,14 @@ function getLeadNextAction(lead: LeadFunnelInput): string {
|
||||
const stageId = getLeadFunnelStageId(lead);
|
||||
|
||||
if (stageId === "deferred") {
|
||||
if (
|
||||
lead.outreach?.salesStatus === "do_not_pursue" &&
|
||||
typeof lead.outreach.doNotContactUntil === "number" &&
|
||||
(lead.now ?? Date.now()) >= lead.outreach.doNotContactUntil
|
||||
) {
|
||||
return "Erneut prüfen";
|
||||
}
|
||||
|
||||
return "Zurückstellung prüfen";
|
||||
}
|
||||
|
||||
@@ -353,9 +363,9 @@ export const pipelineStages: PipelineStage[] = [
|
||||
},
|
||||
{
|
||||
title: "Lead-Recherche",
|
||||
description: "Neue Places-Quellen, Kontaktluecken und Dubletten.",
|
||||
description: "Neue Firmendaten, Kontaktquellen und Dubletten.",
|
||||
count: 18,
|
||||
meta: "5 Leads brauchen E-Mail-Quelle",
|
||||
meta: "Audits starten nach Freigabe",
|
||||
icon: UsersRound,
|
||||
},
|
||||
{
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -235,6 +235,8 @@ type GooglePlaceContactEmailSource = {
|
||||
isBusinessContactAddress?: boolean;
|
||||
};
|
||||
|
||||
export type LeadDiscoverySourceProvider = "google_places" | "local_business_data";
|
||||
|
||||
type GooglePlaceApiPlace = {
|
||||
id?: string;
|
||||
displayName?: GooglePlaceDisplayName;
|
||||
@@ -256,6 +258,7 @@ export type GooglePlacesApiResponse = {
|
||||
|
||||
export type GooglePlaceCandidate = {
|
||||
placeId: string;
|
||||
sourceBusinessId?: string | null;
|
||||
businessName: string;
|
||||
address: string;
|
||||
websiteUrl: string | null;
|
||||
@@ -272,7 +275,7 @@ export type GooglePlaceCandidate = {
|
||||
googleTypes: string[];
|
||||
googlePrimaryType: string | null;
|
||||
googleMapsUrl: string | null;
|
||||
sourceProvider: "google_places";
|
||||
sourceProvider: LeadDiscoverySourceProvider;
|
||||
sourceFetchedAt: number;
|
||||
};
|
||||
|
||||
@@ -501,6 +504,7 @@ export function normalizePlacesResponse(
|
||||
|
||||
export type ExistingLeadLike = {
|
||||
googlePlaceId?: string | null;
|
||||
sourceBusinessId?: string | null;
|
||||
websiteDomain?: string | null;
|
||||
email?: string | null;
|
||||
companyName?: string | null;
|
||||
@@ -509,13 +513,13 @@ export type ExistingLeadLike = {
|
||||
};
|
||||
|
||||
export type BlacklistRow = {
|
||||
type: "domain" | "email" | "phone" | "company" | "google_place_id";
|
||||
type: "domain" | "email" | "phone" | "company" | "google_place_id" | "source_business_id";
|
||||
value: string;
|
||||
normalizedValue: string;
|
||||
};
|
||||
|
||||
export type BlacklistLookupValue = {
|
||||
type: "domain" | "email" | "phone" | "company" | "google_place_id";
|
||||
type: "domain" | "email" | "phone" | "company" | "google_place_id" | "source_business_id";
|
||||
normalizedValue: string;
|
||||
};
|
||||
|
||||
@@ -560,6 +564,10 @@ export function getBlacklistLookupValues(
|
||||
type: "google_place_id",
|
||||
normalizedValue: normalizeDomain(candidate.placeId),
|
||||
},
|
||||
{
|
||||
type: "source_business_id",
|
||||
normalizedValue: normalizeDomain(candidate.sourceBusinessId ?? candidate.placeId),
|
||||
},
|
||||
{
|
||||
type: "domain",
|
||||
normalizedValue: normalizeDomain(candidate.websiteDomain),
|
||||
@@ -588,16 +596,22 @@ export function isDuplicateCandidate(
|
||||
existing: ExistingLeadLike[],
|
||||
): boolean {
|
||||
const candidatePlaceId = normalizeDomain(candidate.placeId);
|
||||
const candidateSourceBusinessId = normalizeDomain(
|
||||
candidate.sourceBusinessId ?? candidate.placeId,
|
||||
);
|
||||
const candidateDomain = normalizeDomain(candidate.websiteDomain);
|
||||
const candidateEmails = getCandidateEmailValues(candidate);
|
||||
|
||||
return existing.some((entry) => {
|
||||
const entryPlaceId = normalizeDomain(entry.googlePlaceId);
|
||||
const entrySourceBusinessId = normalizeDomain(entry.sourceBusinessId);
|
||||
const entryDomain = normalizeDomain(entry.websiteDomain);
|
||||
const entryEmail = normalizeEmailAddress(entry.email);
|
||||
|
||||
return (
|
||||
(candidatePlaceId && entryPlaceId === candidatePlaceId) ||
|
||||
(candidateSourceBusinessId &&
|
||||
entrySourceBusinessId === candidateSourceBusinessId) ||
|
||||
(candidateDomain && entryDomain === candidateDomain) ||
|
||||
candidateEmails.some(
|
||||
(candidateEmail) => candidateEmail && entryEmail === candidateEmail,
|
||||
@@ -638,6 +652,9 @@ export function getBlacklistMatches(
|
||||
blacklistRows: BlacklistRow[],
|
||||
) {
|
||||
const candidatePlaceId = normalizeDomain(candidate.placeId);
|
||||
const candidateSourceBusinessId = normalizeDomain(
|
||||
candidate.sourceBusinessId ?? candidate.placeId,
|
||||
);
|
||||
const candidateDomain = normalizeDomain(candidate.websiteDomain);
|
||||
const candidateCompany = normalizeText(candidate.businessName);
|
||||
const candidatePhone = normalizePhone(candidate.phone);
|
||||
@@ -650,6 +667,11 @@ export function getBlacklistMatches(
|
||||
switch (row.type) {
|
||||
case "google_place_id":
|
||||
return candidatePlaceId !== "" && row.normalizedValue === candidatePlaceId;
|
||||
case "source_business_id":
|
||||
return (
|
||||
candidateSourceBusinessId !== "" &&
|
||||
row.normalizedValue === candidateSourceBusinessId
|
||||
);
|
||||
case "domain":
|
||||
return candidateDomain !== "" && row.normalizedValue === candidateDomain;
|
||||
case "company":
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user