Compare commits
2 Commits
f00c5a3193
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ff4c572157 | |||
| 21c7e4c9a4 |
@@ -96,7 +96,7 @@ export default defineSchema({
|
|||||||
userId: v.string(),
|
userId: v.string(),
|
||||||
message: v.string(),
|
message: v.string(),
|
||||||
read: v.boolean(),
|
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) => {
|
handler: async (ctx, args) => {
|
||||||
return await ctx.db
|
return await ctx.db
|
||||||
.query("notifications")
|
.query("notifications")
|
||||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
.withIndex("by_user_read", (q) =>
|
||||||
.filter((q) => q.eq(q.field("read"), false))
|
q.eq("userId", args.userId).eq("read", false),
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -208,6 +209,8 @@ Note the reference path shape: a function in
|
|||||||
- If the component needs pagination, use `paginator` from `convex-helpers`
|
- If the component needs pagination, use `paginator` from `convex-helpers`
|
||||||
instead of built-in `.paginate()`, because `.paginate()` does not work across
|
instead of built-in `.paginate()`, because `.paginate()` does not work across
|
||||||
the component boundary.
|
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
|
- Add `args` and `returns` validators to all public component functions, because
|
||||||
the component boundary requires explicit type contracts.
|
the component boundary requires explicit type contracts.
|
||||||
|
|
||||||
@@ -263,14 +266,14 @@ export const sendNotification = mutation({
|
|||||||
```ts
|
```ts
|
||||||
// Bad: parent app table IDs are not valid component validators
|
// Bad: parent app table IDs are not valid component validators
|
||||||
args: {
|
args: {
|
||||||
userId: v.id("users");
|
userId: v.id("users"),
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// Good: treat parent-owned IDs as strings at the boundary
|
// Good: treat parent-owned IDs as strings at the boundary
|
||||||
args: {
|
args: {
|
||||||
userId: v.string();
|
userId: v.string(),
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ for Convex schema and data migrations.
|
|||||||
users: defineTable({
|
users: defineTable({
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
role: v.optional(v.union(v.literal("user"), v.literal("admin"))),
|
role: v.optional(v.union(v.literal("user"), v.literal("admin"))),
|
||||||
});
|
}).index("by_role", ["role"]);
|
||||||
|
|
||||||
// Migration: backfill the field
|
// Migration: backfill the field
|
||||||
export const addDefaultRole = migrations.define({
|
export const addDefaultRole = migrations.define({
|
||||||
@@ -225,7 +225,7 @@ export const verifyMigration = query({
|
|||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const remaining = await ctx.db
|
const remaining = await ctx.db
|
||||||
.query("users")
|
.query("users")
|
||||||
.filter((q) => q.eq(q.field("role"), undefined))
|
.withIndex("by_role", (q) => q.eq("role", undefined))
|
||||||
.take(10);
|
.take(10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ CONVEX_DEPLOYMENT=
|
|||||||
NEXT_PUBLIC_CONVEX_SITE_URL=
|
NEXT_PUBLIC_CONVEX_SITE_URL=
|
||||||
BETTER_AUTH_SECRET=
|
BETTER_AUTH_SECRET=
|
||||||
|
|
||||||
# Google APIs
|
# Lead discovery
|
||||||
GOOGLE_GEOCODING_API_KEY=
|
LOCAL_BUSINESS_DATA_API_KEY=
|
||||||
GOOGLE_PLACES_API_KEY=
|
|
||||||
|
# Audit diagnostics
|
||||||
PAGESPEED_API_KEY=
|
PAGESPEED_API_KEY=
|
||||||
PAGESPEED_TIMEOUT_MS=60000
|
PAGESPEED_TIMEOUT_MS=60000
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
@@ -4,69 +4,117 @@ import {
|
|||||||
reviewQueue,
|
reviewQueue,
|
||||||
} from "@/lib/dashboard-model";
|
} from "@/lib/dashboard-model";
|
||||||
import { LeadFunnelBoard } from "@/components/lead-funnel-board";
|
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() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<main className="px-4 py-5 sm:px-6 lg:px-8">
|
<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">
|
<div className="mx-auto flex w-full max-w-[92rem] flex-col gap-6">
|
||||||
<header className="flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
<header className="agency-panel overflow-hidden">
|
||||||
<div>
|
<div className="grid gap-5 p-5 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,0.42fr)] lg:p-6">
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<div className="min-w-0">
|
||||||
Interner Arbeitsbereich
|
<p className="agency-kicker">SaaS Workspace · Agency Evidence Desk</p>
|
||||||
</p>
|
<h1 className="mt-3 max-w-4xl font-heading text-4xl font-semibold tracking-normal text-foreground">
|
||||||
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
|
Review, Evidence und Outreach-Sicherheit in einem Arbeitsfluss.
|
||||||
Pipeline-Übersicht
|
</h1>
|
||||||
</h1>
|
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
WebDev Pipeline hilft Agenturen, lokale Chancen zu finden,
|
||||||
Recherche, Audit-Freigabe und Outreach bleiben eng gekoppelt:
|
konkrete Belege zu sammeln und Outreach erst nach sauberer
|
||||||
wenige gute Leads, manuelle Prüfung, kein automatischer Versand.
|
Freigabe zu starten.
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">MVP intern</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{dashboardKpis.map((kpi) => (
|
|
||||||
<article
|
|
||||||
className="rounded-lg border bg-card p-4 text-card-foreground"
|
|
||||||
key={kpi.label}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-muted-foreground">{kpi.label}</p>
|
|
||||||
<p className="mt-3 text-3xl font-semibold tracking-normal">
|
|
||||||
{kpi.value}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm leading-5 text-muted-foreground">
|
|
||||||
{kpi.detail}
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y">
|
|
||||||
|
<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 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) => (
|
{reviewQueue.map((item) => (
|
||||||
<article
|
<article
|
||||||
className="grid gap-2 p-4 sm:grid-cols-[1fr_auto]"
|
className="rounded-md border border-border/75 bg-background/60 p-3"
|
||||||
key={`${item.title}-${item.company}`}
|
key={`${item.title}-${item.company}`}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h3 className="text-sm font-medium">{item.title}</h3>
|
<div className="min-w-0">
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<h3 className="text-sm font-semibold">{item.title}</h3>
|
||||||
{item.company}
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
</p>
|
{item.company}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="mt-1 size-4 shrink-0 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-sm text-sm leading-6 text-muted-foreground sm:text-right">
|
<p className="mt-2 text-sm leading-5 text-muted-foreground">
|
||||||
{item.detail}
|
{item.detail}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
@@ -74,30 +122,60 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-4 text-card-foreground">
|
<section
|
||||||
<h2 className="text-base font-semibold tracking-normal">
|
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"
|
||||||
Betriebsmodus
|
aria-label="Pipeline-Kennzahlen"
|
||||||
</h2>
|
>
|
||||||
<div className="mt-4 grid gap-3">
|
{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">
|
||||||
|
{kpi.detail}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<LeadFunnelBoard />
|
||||||
|
|
||||||
|
<section className="agency-panel p-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="agency-kicker">Workspace Health</p>
|
||||||
|
<h2 className="mt-1 font-heading text-xl font-semibold">
|
||||||
|
Safety Ledger
|
||||||
|
</h2>
|
||||||
|
</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) => {
|
{pipelineHealth.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
key={item.label}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2 text-sm font-medium">
|
<span className="inline-flex items-center gap-2 text-sm font-semibold">
|
||||||
<Icon className="size-4 text-muted-foreground" />
|
<Icon className="size-4 text-primary" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-right text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{item.value}
|
{item.value}
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
223
app/globals.css
223
app/globals.css
@@ -7,9 +7,9 @@
|
|||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--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-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -49,72 +49,90 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.967 0.017 96);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.175 0.033 178);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(0.991 0.009 104);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.175 0.033 178);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(0.991 0.009 104);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.175 0.033 178);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.31 0.083 177);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.986 0.014 104);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.91 0.056 82);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.255 0.052 71);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.917 0.023 102);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.42 0.04 168);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.61 0.111 242);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.982 0.012 104);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.82 0.029 104);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.86 0.026 104);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.54 0.095 177);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.31 0.083 177);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.61 0.111 242);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.68 0.137 82);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.57 0.12 30);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.48 0.08 296);
|
||||||
--radius: 0.625rem;
|
--radius: 0.45rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.214 0.034 181);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.94 0.02 103);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.76 0.112 157);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.145 0.024 185);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.285 0.041 181);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.96 0.018 103);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.94 0.018 103 / 15%);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--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 {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.17 0.02 185);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.948 0.016 142);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.215 0.024 187);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.948 0.016 142);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.215 0.024 187);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.948 0.016 142);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.76 0.112 157);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.145 0.024 185);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.314 0.044 80);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.94 0.04 93);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.27 0.023 185);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.735 0.028 165);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.77 0.121 82);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.17 0.02 185);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(0.94 0.018 160 / 13%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(0.94 0.018 160 / 17%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.76 0.112 157);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.76 0.112 157);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.77 0.121 82);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.68 0.095 245);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.7 0.12 32);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.72 0.095 296);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.13 0.018 187);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.92 0.016 142);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.76 0.112 157);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.145 0.024 185);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.24 0.028 186);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.94 0.016 142);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(0.94 0.018 160 / 12%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--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 {
|
@layer base {
|
||||||
@@ -123,8 +141,85 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
font-feature-settings: "cv02", "cv03", "cv04", "ss03";
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@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
|
id: TASK-13
|
||||||
title: Build the audit and outreach review workspace
|
title: Build the audit and outreach review workspace
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:14'
|
created_date: '2026-06-03 19:14'
|
||||||
updated_date: '2026-06-05 14:21'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- review
|
- 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.
|
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 -->
|
<!-- 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
|
id: TASK-15
|
||||||
title: Add follow-up and manual sales status tracking
|
title: Add follow-up and manual sales status tracking
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:14'
|
created_date: '2026-06-03 19:14'
|
||||||
updated_date: '2026-06-05 19:49'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- sales
|
- sales
|
||||||
@@ -49,3 +49,9 @@ Started implementation pass for tasks 15-19 and 27. TASK-27 note says it is supe
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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
|
id: TASK-16
|
||||||
title: Orchestrate recurring Convex agent jobs and audit lifecycle
|
title: Orchestrate recurring Convex agent jobs and audit lifecycle
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:14'
|
created_date: '2026-06-03 19:14'
|
||||||
updated_date: '2026-06-05 19:49'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- convex
|
- convex
|
||||||
@@ -51,3 +51,9 @@ Started implementation pass for recurring Convex agent jobs, run locking, logs,
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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
|
id: TASK-17
|
||||||
title: Add Rybbit audit analytics dashboard
|
title: Add Rybbit audit analytics dashboard
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:14'
|
created_date: '2026-06-03 19:14'
|
||||||
updated_date: '2026-06-05 19:50'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- analytics
|
- analytics
|
||||||
@@ -51,3 +51,9 @@ Implemented public-audit-only Rybbit tracking, on-demand Rybbit API routes for a
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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
|
id: TASK-18
|
||||||
title: Add MVP quality gates and operational polish
|
title: Add MVP quality gates and operational polish
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:15'
|
created_date: '2026-06-03 19:15'
|
||||||
updated_date: '2026-06-05 19:49'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- quality
|
- quality
|
||||||
@@ -51,3 +51,9 @@ Started implementation pass for MVP quality gates, error observability, verifica
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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
|
id: TASK-19
|
||||||
title: Add campaign performance metrics
|
title: Add campaign performance metrics
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:15'
|
created_date: '2026-06-03 19:15'
|
||||||
updated_date: '2026-06-05 19:49'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- analytics
|
- analytics
|
||||||
@@ -51,3 +51,9 @@ 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.
|
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 -->
|
<!-- 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
|
id: TASK-21
|
||||||
title: Replace oversized Convex browser runtime dependency
|
title: Replace oversized Convex browser runtime dependency
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-04 15:30'
|
created_date: '2026-06-04 15:30'
|
||||||
updated_date: '2026-06-04 16:41'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
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.
|
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 -->
|
<!-- 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
|
id: TASK-22
|
||||||
title: Add source assertions for Convex AL2023 Chromium lib setup
|
title: Add source assertions for Convex AL2023 Chromium lib setup
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-04 16:37'
|
created_date: '2026-06-04 16:37'
|
||||||
updated_date: '2026-06-04 16:41'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
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.
|
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 -->
|
<!-- 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
|
id: TASK-23
|
||||||
title: Improve website email extraction
|
title: Improve website email extraction
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-04 17:28'
|
created_date: '2026-06-04 17:28'
|
||||||
updated_date: '2026-06-04 17:34'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
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.
|
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 -->
|
<!-- 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
|
id: TASK-24
|
||||||
title: Improve crawler handling for Bock Rechtsanwaelte edge cases
|
title: Improve crawler handling for Bock Rechtsanwaelte edge cases
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-04 18:04'
|
created_date: '2026-06-04 18:04'
|
||||||
updated_date: '2026-06-04 18:09'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
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.
|
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 -->
|
<!-- 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
|
id: TASK-25
|
||||||
title: Harden website enrichment against Convex action runtime aborts
|
title: Harden website enrichment against Convex action runtime aborts
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-05 06:59'
|
created_date: '2026-06-05 06:59'
|
||||||
updated_date: '2026-06-05 07:04'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
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.
|
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 -->
|
<!-- 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
|
id: TASK-27
|
||||||
title: Trigger audit generation after PageSpeed audit
|
title: Trigger audit generation after PageSpeed audit
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-05 12:10'
|
created_date: '2026-06-05 12:10'
|
||||||
updated_date: '2026-06-05 19:49'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -34,3 +34,9 @@ Started verification pass. Implementation notes say TASK-27 is superseded by TAS
|
|||||||
|
|
||||||
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.
|
Verified existing PageSpeed-to-audit-generation handoff in pageSpeedAction. Successful and failure paths queue audit generation for the started lead, queue failures are warning-logged, existing queueLeadAuditGeneration dedupe remains in place, and regression source tests pass. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## 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-29
|
id: TASK-29
|
||||||
title: Surface audit generations on dashboard audits
|
title: Surface audit generations on dashboard audits
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-05 20:30'
|
created_date: '2026-06-05 20:30'
|
||||||
updated_date: '2026-06-05 22:45'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -40,3 +40,9 @@ Show audit-generation pipeline data on /dashboard/audits when final audits rows
|
|||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- 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.
|
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 -->
|
<!-- 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-30
|
id: TASK-30
|
||||||
title: Externalisiere die persönliche Audit-Pipeline
|
title: Externalisiere die persönliche Audit-Pipeline
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 18:44'
|
created_date: '2026-06-06 18:44'
|
||||||
updated_date: '2026-06-07 20:27'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -74,3 +74,9 @@ Orchestrator final verification: AC #1 checked after external Capture/Generation
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-31
|
id: TASK-31
|
||||||
title: Require auth for usage event reads
|
title: Require auth for usage event reads
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 20:27'
|
created_date: '2026-06-06 20:27'
|
||||||
updated_date: '2026-06-06 20:31'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -41,3 +41,9 @@ RED: pnpm test -- tests/usage-events-source.test.ts is blocked by pre-existing t
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-32
|
id: TASK-32
|
||||||
title: Wire v3 skill registry into audit generation
|
title: Wire v3 skill registry into audit generation
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 20:27'
|
created_date: '2026-06-06 20:27'
|
||||||
updated_date: '2026-06-06 20:36'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -42,3 +42,9 @@ RED: pnpm test tests/audit-generation-action-source.test.ts tests/ai-schemas.tes
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-33
|
id: TASK-33
|
||||||
title: Fix v3 live wiring quality issues
|
title: Fix v3 live wiring quality issues
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 20:41'
|
created_date: '2026-06-06 20:41'
|
||||||
updated_date: '2026-06-06 20:47'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -42,3 +42,9 @@ RED: tsc passed. node --test .test-output/tests/audit-evidence.test.js .test-out
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-34
|
id: TASK-34
|
||||||
title: Harden v3 selection and Convex payloads
|
title: Harden v3 selection and Convex payloads
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 20:54'
|
created_date: '2026-06-06 20:54'
|
||||||
updated_date: '2026-06-06 21:03'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -40,3 +40,9 @@ Fix v3 quality review issues by removing explicit undefined values from Convex m
|
|||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- 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.
|
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 -->
|
<!-- 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-35
|
id: TASK-35
|
||||||
title: Remove remaining undefined audit generation payloads
|
title: Remove remaining undefined audit generation payloads
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 21:06'
|
created_date: '2026-06-06 21:06'
|
||||||
updated_date: '2026-06-06 21:13'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -42,3 +42,9 @@ RED: tsc passed. Focused node --test .test-output/tests/audit-generation-action-
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-36
|
id: TASK-36
|
||||||
title: Remove optional helper undefined args
|
title: Remove optional helper undefined args
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 21:15'
|
created_date: '2026-06-06 21:15'
|
||||||
updated_date: '2026-06-06 21:23'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -42,3 +42,9 @@ RED: tsc passed. Focused node --test .test-output/tests/audit-generation-action-
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-37
|
id: TASK-37
|
||||||
title: Prioritize v3 local audit skills
|
title: Prioritize v3 local audit skills
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 21:30'
|
created_date: '2026-06-06 21:30'
|
||||||
updated_date: '2026-06-06 21:38'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -42,3 +42,9 @@ RED: pnpm exec tsc -p tsconfig.test.json passed. Focused node --test .test-outpu
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-38
|
id: TASK-38
|
||||||
title: Add ScreenshotOne missing-key run warning
|
title: Add ScreenshotOne missing-key run warning
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 21:41'
|
created_date: '2026-06-06 21:41'
|
||||||
updated_date: '2026-06-06 21:46'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -42,3 +42,9 @@ RED verified: pnpm exec tsc -p tsconfig.test.json passed, then node --test .test
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-39
|
id: TASK-39
|
||||||
title: Secure Convex operator APIs
|
title: Secure Convex operator APIs
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 21:52'
|
created_date: '2026-06-06 21:52'
|
||||||
updated_date: '2026-06-06 22:00'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -32,3 +32,9 @@ Worker I audit slice: Added source-contract coverage for audit admin auth guards
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-40
|
id: TASK-40
|
||||||
title: Behebe abschliessende Lint-Blocker
|
title: Behebe abschliessende Lint-Blocker
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 22:10'
|
created_date: '2026-06-06 22:10'
|
||||||
updated_date: '2026-06-06 22:15'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -38,3 +38,9 @@ Fix the final lint blockers after the v2 pipeline implementation without changin
|
|||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- 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.
|
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 -->
|
<!-- 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-41
|
id: TASK-41
|
||||||
title: Repariere Convex-Typecheck fuer Usage Events
|
title: Repariere Convex-Typecheck fuer Usage Events
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 22:13'
|
created_date: '2026-06-06 22:13'
|
||||||
updated_date: '2026-06-06 22:16'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -45,3 +45,9 @@ Verification/results:
|
|||||||
- `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 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.
|
- `pnpm test` exits 0: 363 tests passed, 0 failed.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- 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-42
|
id: TASK-42
|
||||||
title: Scope v2 Referenzdateien aus dem Typecheck
|
title: Scope v2 Referenzdateien aus dem Typecheck
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-06 22:16'
|
created_date: '2026-06-06 22:16'
|
||||||
updated_date: '2026-06-06 22:18'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -41,3 +41,9 @@ Reproduced pnpm exec tsc --noEmit failure: production tsconfig includes v2_eleme
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-43
|
id: TASK-43
|
||||||
title: Stabilisiere Website-Enrichment ohne Playwright-Abbruch
|
title: Stabilisiere Website-Enrichment ohne Playwright-Abbruch
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-07 19:40'
|
created_date: '2026-06-07 19:40'
|
||||||
updated_date: '2026-06-07 20:57'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -48,3 +48,9 @@ Follow-up fix: The live Convex run j9737mz0tkgdbg6mzjxjd1w7018878b1 failed becau
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-44
|
id: TASK-44
|
||||||
title: Port audit pipeline fully into the MVP
|
title: Port audit pipeline fully into the MVP
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-07 21:16'
|
created_date: '2026-06-07 21:16'
|
||||||
updated_date: '2026-06-07 21:34'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -44,3 +44,9 @@ Implemented GREEN slice after RED tests: added bundled MVP v3 audit skill regist
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-45
|
id: TASK-45
|
||||||
title: Show audit evidence on detail pages
|
title: Show audit evidence on detail pages
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-07 21:50'
|
created_date: '2026-06-07 21:50'
|
||||||
updated_date: '2026-06-07 22:01'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -41,3 +41,9 @@ Fix the audit detail view so stored checked pages and compact website-enrichment
|
|||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- 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.
|
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 -->
|
<!-- 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-46
|
id: TASK-46
|
||||||
title: Add Convex specialist fan-out audit pipeline
|
title: Add Convex specialist fan-out audit pipeline
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-08 09:04'
|
created_date: '2026-06-08 09:04'
|
||||||
updated_date: '2026-06-08 09:19'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -46,3 +46,9 @@ RED: pnpm exec tsc -p tsconfig.test.json fails because AuditEvidenceInput has no
|
|||||||
|
|
||||||
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.
|
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 -->
|
<!-- 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-47
|
id: TASK-47
|
||||||
title: Fix evidence verifier audit generation failure
|
title: Fix evidence verifier audit generation failure
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-08 09:35'
|
created_date: '2026-06-08 09:35'
|
||||||
updated_date: '2026-06-08 10:07'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -46,3 +46,9 @@ Second live failure root cause: after the strict schema fix, specialist stages s
|
|||||||
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.
|
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.
|
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 -->
|
<!-- 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-48
|
id: TASK-48
|
||||||
title: Integrate impeccable critique into audit pipeline
|
title: Integrate impeccable critique into audit pipeline
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-08 12:02'
|
created_date: '2026-06-08 12:02'
|
||||||
updated_date: '2026-06-08 12:10'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -41,3 +41,9 @@ Extend the evidence-first audit pipeline with design critique/impeccable-style v
|
|||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- 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.
|
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 -->
|
<!-- 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-49
|
id: TASK-49
|
||||||
title: Improve audit outreach email tone
|
title: Improve audit outreach email tone
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-08 19:30'
|
created_date: '2026-06-08 19:30'
|
||||||
updated_date: '2026-06-08 19:48'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -41,3 +41,9 @@ Add evidence-first, collegial-direct tonal guidelines for generated outreach ema
|
|||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- 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.
|
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 -->
|
<!-- 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-50
|
id: TASK-50
|
||||||
title: Refactor dashboard views into compact cards
|
title: Refactor dashboard views into compact cards
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-08 19:56'
|
created_date: '2026-06-08 19:56'
|
||||||
updated_date: '2026-06-08 20:21'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -50,3 +50,9 @@ 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.
|
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 -->
|
<!-- 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
|
id: TASK-8
|
||||||
title: Implement Playwright website crawling and screenshot capture
|
title: Implement Playwright website crawling and screenshot capture
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:13'
|
created_date: '2026-06-03 19:13'
|
||||||
updated_date: '2026-06-04 18:09'
|
updated_date: '2026-06-10 19:27'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- audit
|
- 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.
|
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 -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed per explicit user request while switching project tracking to pitchfast.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useQuery } from "convex/react";
|
import { useMutation, useQuery } from "convex/react";
|
||||||
import { FunctionReturnType } from "convex/server";
|
import { FunctionReturnType } from "convex/server";
|
||||||
import { Activity, Files, SquarePen } from "lucide-react";
|
import { Activity, Files, FileSearch, RotateCcw, SquarePen } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
@@ -80,9 +81,9 @@ function getStageLabel(stage: string) {
|
|||||||
function AuditsBoardLoading() {
|
function AuditsBoardLoading() {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<header className="space-y-2">
|
<header className="agency-panel space-y-2 p-4">
|
||||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
<p className="agency-kicker">Evidence Dossier</p>
|
||||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
|
||||||
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
|
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
|
||||||
</header>
|
</header>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
@@ -96,7 +97,9 @@ function AuditsBoardLoading() {
|
|||||||
|
|
||||||
export function AuditsBoard() {
|
export function AuditsBoard() {
|
||||||
const dashboardRows = useQuery(api.audits.listDashboardRows, { limit: 100 });
|
const dashboardRows = useQuery(api.audits.listDashboardRows, { limit: 100 });
|
||||||
|
const retryAuditRun = useMutation(api.audits.retryAuditRun);
|
||||||
const [activeFilter, setActiveFilter] = useState<AuditStatusFilter>("all");
|
const [activeFilter, setActiveFilter] = useState<AuditStatusFilter>("all");
|
||||||
|
const [retryingRunId, setRetryingRunId] = useState<string | null>(null);
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (!dashboardRows) {
|
if (!dashboardRows) {
|
||||||
return [];
|
return [];
|
||||||
@@ -141,6 +144,14 @@ export function AuditsBoard() {
|
|||||||
{ label: "Pipeline", value: "generation", count: statusCounts.generation },
|
{ label: "Pipeline", value: "generation", count: statusCounts.generation },
|
||||||
{ label: "Fehlgeschlagen", value: "failed", count: statusCounts.failed },
|
{ 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) {
|
if (dashboardRows === undefined) {
|
||||||
return <AuditsBoardLoading />;
|
return <AuditsBoardLoading />;
|
||||||
@@ -149,12 +160,12 @@ export function AuditsBoard() {
|
|||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<header className="space-y-2">
|
<header className="agency-panel space-y-2 p-4">
|
||||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
<p className="agency-kicker">Evidence Dossier</p>
|
||||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Card>
|
<Card className="agency-panel">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h2 className="text-sm font-medium">Noch keine Audits</h2>
|
<h2 className="text-sm font-medium">Noch keine Audits</h2>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -169,16 +180,20 @@ export function AuditsBoard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<header className="space-y-2">
|
<header className="agency-panel space-y-2 p-4">
|
||||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
<p className="agency-kicker">Evidence Dossier</p>
|
||||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2" aria-label="Audit-Filter">
|
<div className="flex flex-wrap gap-2" aria-label="Audit-Filter">
|
||||||
{auditStatusFilters.map((filter) => (
|
{auditStatusFilters.map((filter) => (
|
||||||
<button
|
<button
|
||||||
aria-pressed={activeFilter === filter.value}
|
aria-pressed={activeFilter === filter.value}
|
||||||
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
|
className="agency-tab"
|
||||||
key={filter.value}
|
key={filter.value}
|
||||||
onClick={() => setActiveFilter(filter.value)}
|
onClick={() => setActiveFilter(filter.value)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -199,14 +214,15 @@ export function AuditsBoard() {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
aria-labelledby={rowTitleId}
|
aria-labelledby={rowTitleId}
|
||||||
className="flex min-w-0 flex-col"
|
className="agency-panel flex min-w-0 flex-col overflow-hidden"
|
||||||
key={row.id}
|
key={row.id}
|
||||||
>
|
>
|
||||||
<CardHeader className="gap-3">
|
<CardHeader className="gap-3">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<CardDescription>
|
<CardDescription className="inline-flex items-center gap-2">
|
||||||
{row.kind === "audit" ? "Audit" : "Pipeline"}
|
<FileSearch className="size-3.5" aria-hidden="true" />
|
||||||
|
{row.kind === "audit" ? "Audit Evidence" : "Pipeline Evidence"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<CardTitle className="mt-1 break-words text-base" id={rowTitleId}>
|
<CardTitle className="mt-1 break-words text-base" id={rowTitleId}>
|
||||||
{row.title}
|
{row.title}
|
||||||
@@ -222,11 +238,11 @@ export function AuditsBoard() {
|
|||||||
|
|
||||||
<CardContent className="flex flex-1 flex-col gap-4">
|
<CardContent className="flex flex-1 flex-col gap-4">
|
||||||
<div className="grid gap-3 text-sm">
|
<div className="grid gap-3 text-sm">
|
||||||
<div className="min-w-0">
|
<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="text-xs font-medium text-muted-foreground">Domain</p>
|
||||||
<p className="mt-1 break-all">{row.checkedDomain}</p>
|
<p className="mt-1 break-all">{row.checkedDomain}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<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">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
{row.kind === "audit" ? "Seiten" : "Phase"}
|
{row.kind === "audit" ? "Seiten" : "Phase"}
|
||||||
</p>
|
</p>
|
||||||
@@ -244,28 +260,75 @@ export function AuditsBoard() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<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="text-xs font-medium text-muted-foreground">Slug</p>
|
||||||
<p className="mt-1 break-words text-muted-foreground">
|
<p className="mt-1 break-words text-muted-foreground">
|
||||||
{row.kind === "generation" ? `Run ${row.runId}` : row.title}
|
{row.kind === "generation" ? `Run ${row.runId}` : row.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{row.kind === "generation" && row.errorSummary ? (
|
{row.kind === "generation" && row.errorSummary ? (
|
||||||
<p className="break-words rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
<p className="break-words rounded-md border border-destructive/30 bg-[var(--danger-soft)] px-3 py-2 text-xs text-destructive">
|
||||||
{row.errorSummary}
|
{row.errorSummary}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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>
|
||||||
|
|
||||||
<div className="mt-auto flex justify-end">
|
<div className="mt-auto flex justify-end">
|
||||||
{row.kind === "audit" ? (
|
{row.kind === "audit" ? (
|
||||||
<Link
|
<Link
|
||||||
className="inline-flex min-h-8 items-center gap-1 rounded-md px-2 text-sm text-primary hover:bg-muted"
|
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}
|
href={row.detailHref}
|
||||||
>
|
>
|
||||||
<SquarePen className="size-4" aria-hidden="true" />
|
<SquarePen className="size-4" aria-hidden="true" />
|
||||||
Öffnen
|
Öffnen
|
||||||
</Link>
|
</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">
|
<span className="inline-flex min-h-8 items-center text-sm text-muted-foreground">
|
||||||
Pipeline läuft
|
Pipeline läuft
|
||||||
@@ -277,7 +340,7 @@ export function AuditsBoard() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{visibleRows.length === 0 ? (
|
{visibleRows.length === 0 ? (
|
||||||
<Card className="sm:col-span-2 xl:col-span-3">
|
<Card className="agency-panel sm:col-span-2 xl:col-span-3">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Keine Treffer</CardTitle>
|
<CardTitle>Keine Treffer</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
|
|||||||
@@ -1,11 +1,40 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { authClient } from "@/lib/auth-client";
|
||||||
import { Button } from "@/components/ui/button";
|
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() {
|
export function AuthEntry() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
@@ -37,34 +66,42 @@ export function AuthEntry() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-dvh items-center justify-center bg-background px-6 py-10">
|
<main className="dashboard-canvas flex min-h-dvh items-center justify-center 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]">
|
<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 p-6 md:border-b-0 md:border-r lg:p-8">
|
<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>
|
||||||
<div className="mb-8 inline-flex size-10 items-center justify-center rounded-lg border bg-background">
|
<div className="mb-8 inline-flex items-center gap-3">
|
||||||
<LockKeyhole className="size-5" />
|
<div className="flex size-11 items-center justify-center rounded-md bg-primary font-heading text-sm font-black text-primary-foreground">
|
||||||
|
WP
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-heading text-sm font-semibold">
|
||||||
|
WebDev Pipeline
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Agency Evidence Desk
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<p className="agency-kicker">
|
||||||
WebDev Pipeline MVP
|
SaaS Workspace
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-4 max-w-xl text-3xl font-semibold tracking-normal sm:text-4xl">
|
<h1 className="mt-4 max-w-xl font-heading text-3xl font-semibold tracking-normal sm:text-4xl">
|
||||||
Lokale Webdesign-Leads recherchieren, auditieren und freigeben.
|
Lokale Chancen belegen, prüfen und erst dann kontaktieren.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 max-w-lg text-sm leading-6 text-muted-foreground sm:text-base">
|
<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,
|
Melde dich an, um Kampagnen, Lead-Qualität, Audit-Evidence und
|
||||||
Audit-Freigaben und Outreach-Schritte in einem Arbeitsbereich zu
|
Outreach-Freigaben in einem geschützten Kunden-Workspace zu steuern.
|
||||||
steuern.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="grid gap-4 pt-8 sm:grid-cols-3">
|
<dl className="grid gap-4 pt-8 sm:grid-cols-3">
|
||||||
{[
|
{authSignals.map(({ icon: Icon, label, value }) => (
|
||||||
["Recherche", "Google Places Quellen und Kontaktluecken."],
|
<div className="rounded-md border border-border/75 bg-background/60 p-3" key={label}>
|
||||||
["Audit", "Website-Potenzial und Review-Status."],
|
<dt className="inline-flex items-center gap-2 text-sm font-semibold">
|
||||||
["Outreach", "Manuelle Freigabe vor Versand."],
|
<Icon className="size-4 text-primary" />
|
||||||
].map(([label, value]) => (
|
{label}
|
||||||
<div key={label}>
|
</dt>
|
||||||
<dt className="text-sm font-medium">{label}</dt>
|
|
||||||
<dd className="mt-1 text-sm leading-5 text-muted-foreground">
|
<dd className="mt-1 text-sm leading-5 text-muted-foreground">
|
||||||
{value}
|
{value}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -73,10 +110,10 @@ export function AuthEntry() {
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</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">
|
<div className="mx-auto w-full max-w-sm">
|
||||||
<h2 className="text-2xl font-semibold tracking-normal">
|
<h2 className="font-heading text-2xl font-semibold tracking-normal">
|
||||||
Admin Login
|
Workspace Login
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||||
Melde dich mit E-Mail und Passwort an.
|
Melde dich mit E-Mail und Passwort an.
|
||||||
@@ -88,7 +125,7 @@ export function AuthEntry() {
|
|||||||
<input
|
<input
|
||||||
name="email"
|
name="email"
|
||||||
type="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"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
placeholder="admin@firma.de"
|
placeholder="admin@firma.de"
|
||||||
@@ -99,7 +136,7 @@ export function AuthEntry() {
|
|||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
type="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"
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
|
|||||||
@@ -20,17 +20,22 @@ type BlacklistType =
|
|||||||
| "email"
|
| "email"
|
||||||
| "phone"
|
| "phone"
|
||||||
| "company"
|
| "company"
|
||||||
| "google_place_id";
|
| "google_place_id"
|
||||||
|
| "source_business_id";
|
||||||
|
|
||||||
const blacklistTypeOptions: BlacklistType[] = [
|
const blacklistTypeOptions: BlacklistType[] = [
|
||||||
"domain",
|
"domain",
|
||||||
"email",
|
"email",
|
||||||
"phone",
|
"phone",
|
||||||
"company",
|
"company",
|
||||||
|
"source_business_id",
|
||||||
"google_place_id",
|
"google_place_id",
|
||||||
];
|
];
|
||||||
|
|
||||||
function labelForType(type: BlacklistType): string {
|
function labelForType(type: BlacklistType): string {
|
||||||
|
if (type === "source_business_id") {
|
||||||
|
return "Source Business ID";
|
||||||
|
}
|
||||||
if (type === "google_place_id") {
|
if (type === "google_place_id") {
|
||||||
return "Google Place ID";
|
return "Google Place ID";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export function CampaignFormDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Wähle Kategorie, PLZ, Radius und Limits je Kampagne.
|
Wähle Kategorie, PLZ, Radius und Lead-Limit je Kampagne.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<DialogCloseButton />
|
<DialogCloseButton />
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -315,53 +315,28 @@ export function CampaignFormDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<FormField
|
||||||
<FormField
|
control={control}
|
||||||
control={control}
|
name="maxNewLeadsPerRun"
|
||||||
name="maxNewLeadsPerRun"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem>
|
<FormLabel>Max. neue Leads</FormLabel>
|
||||||
<FormLabel>Max. neue Leads</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<Input
|
||||||
<Input
|
value={field.value ?? ""}
|
||||||
value={field.value ?? ""}
|
type="number"
|
||||||
type="number"
|
inputMode="numeric"
|
||||||
inputMode="numeric"
|
min={1}
|
||||||
min={1}
|
onChange={(event) => {
|
||||||
onChange={(event) => {
|
const value = Number(event.target.value);
|
||||||
const value = Number(event.target.value);
|
field.onChange(Number.isFinite(value) ? value : 0);
|
||||||
field.onChange(Number.isFinite(value) ? value : 0);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</FormControl>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
<FormMessage />
|
</FormItem>
|
||||||
</FormItem>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<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
|
<FormField
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery } from "convex/react";
|
import { useMutation, useQuery } from "convex/react";
|
||||||
import { FunctionReturnType } from "convex/server";
|
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 { api } from "@/convex/_generated/api";
|
||||||
import { Id } from "@/convex/_generated/dataModel";
|
import { Id } from "@/convex/_generated/dataModel";
|
||||||
@@ -256,12 +256,16 @@ export function CampaignsBoard() {
|
|||||||
onSubmit={submitCampaign}
|
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>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Lokale Kampagnenverwaltung</p>
|
<p className="agency-kicker">Controlled Sourcing</p>
|
||||||
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
|
<h1 className="mt-2 font-heading text-3xl font-semibold tracking-normal">
|
||||||
Kampagnen
|
Kampagnen
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Button onClick={openCreateDialog} className="justify-start sm:w-auto">
|
<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}
|
{actionLabel ? <p className="text-sm" role="status">{actionLabel}</p> : null}
|
||||||
|
|
||||||
{campaignsSorted.length === 0 ? (
|
{campaignsSorted.length === 0 ? (
|
||||||
<Card>
|
<Card className="agency-panel">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Keine Kampagnen</CardTitle>
|
<CardTitle>Keine Kampagnen</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -289,7 +293,11 @@ export function CampaignsBoard() {
|
|||||||
const campaignTitleId = `campaign-title-${campaign._id}`;
|
const campaignTitleId = `campaign-title-${campaign._id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card aria-labelledby={campaignTitleId} key={campaign._id}>
|
<Card
|
||||||
|
aria-labelledby={campaignTitleId}
|
||||||
|
className="agency-panel overflow-hidden"
|
||||||
|
key={campaign._id}
|
||||||
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -308,23 +316,26 @@ export function CampaignsBoard() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="grid gap-2 text-sm">
|
<CardContent className="grid gap-3 text-sm">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<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">
|
<div className="inline-flex items-center gap-1 text-muted-foreground">
|
||||||
<MapPin className="size-3" />
|
<MapPin className="size-3" />
|
||||||
<span>{campaign.postalCode}</span>
|
<span>{campaign.postalCode}</span>
|
||||||
</div>
|
</div>
|
||||||
<span>{campaign.radiusKm} km</span>
|
<span className="font-semibold">{campaign.radiusKm} km</span>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="bg-border" />
|
<Separator className="bg-border" />
|
||||||
<div>
|
<div className="grid gap-2 rounded-md border border-border/75 bg-background/60 p-3">
|
||||||
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p>
|
<p className="inline-flex items-center gap-2 font-medium">
|
||||||
<p>
|
<Clock3 className="size-3.5 text-primary" />
|
||||||
Limits: L {campaign.maxNewLeadsPerRun}, A{" "}
|
Cadence: {recurrenceLabel[campaign.recurrence]}
|
||||||
{campaign.maxAuditsPerRun}
|
</p>
|
||||||
|
<p className="inline-flex items-center gap-2 font-medium">
|
||||||
|
<ShieldCheck className="size-3.5 text-primary" />
|
||||||
|
Lead-Limit: {campaign.maxNewLeadsPerRun}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="grid gap-1 rounded-md bg-muted/45 p-3">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
|
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
|
||||||
</p>
|
</p>
|
||||||
@@ -373,7 +384,7 @@ export function CampaignsBoard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card className="agency-panel">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Aktuelle Run-Logs</CardTitle>
|
<CardTitle>Aktuelle Run-Logs</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -387,7 +398,7 @@ export function CampaignsBoard() {
|
|||||||
<p className="text-muted-foreground">Noch keine Kampagnenläufe.</p>
|
<p className="text-muted-foreground">Noch keine Kampagnenläufe.</p>
|
||||||
) : (
|
) : (
|
||||||
visibleRuns.map((run) => (
|
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">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{statusLabel[run.status] ?? run.status}
|
{statusLabel[run.status] ?? run.status}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { LogOut } from "lucide-react";
|
import { CheckCircle2, LogOut, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -20,21 +20,41 @@ export function DashboardSidebar() {
|
|||||||
const { data: session, isPending } = authClient.useSession();
|
const { data: session, isPending } = authClient.useSession();
|
||||||
|
|
||||||
return (
|
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">
|
<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-16 items-center gap-3 border-b px-4">
|
<div className="flex h-[5.25rem] items-center gap-3 border-b border-sidebar-border px-4">
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
<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%)]">
|
||||||
W
|
WP
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold">WebDev Pipeline</p>
|
<p className="truncate font-heading text-sm font-semibold">
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
WebDev Pipeline
|
||||||
Akquise Workspace
|
</p>
|
||||||
|
<p className="truncate text-xs font-medium text-muted-foreground">
|
||||||
|
Agency Evidence Desk
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<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"
|
aria-label="Dashboard navigation"
|
||||||
>
|
>
|
||||||
{dashboardNavigation.map((item) => {
|
{dashboardNavigation.map((item) => {
|
||||||
@@ -48,9 +68,9 @@ export function DashboardSidebar() {
|
|||||||
<Link
|
<Link
|
||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={cn(
|
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
|
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",
|
: "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
)}
|
)}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
@@ -64,13 +84,13 @@ export function DashboardSidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t p-3 md:mt-auto">
|
<div className="border-t border-sidebar-border p-3 md:mt-auto">
|
||||||
<div className="mb-3 rounded-lg border bg-background p-3 md:block">
|
<div className="mb-3 rounded-md border border-sidebar-border bg-sidebar-accent p-3 md:block">
|
||||||
<p className="truncate text-sm font-medium">
|
<p className="truncate text-sm font-semibold">
|
||||||
{isPending ? "Lade..." : session?.user?.name ?? "Admin"}
|
{isPending ? "Lade..." : session?.user?.name ?? "Workspace"}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<p className="truncate text-xs font-medium text-muted-foreground">
|
||||||
{session?.user?.email ?? "admin@local"}
|
{session?.user?.email ?? "team@workspace"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function DashboardThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
<div
|
<div
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-dvh bg-background text-foreground md:flex",
|
"dashboard-canvas min-h-dvh text-foreground md:flex",
|
||||||
theme === "dark" && "dark",
|
theme === "dark" && "dark",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
import { useQuery } from "convex/react";
|
import { useQuery } from "convex/react";
|
||||||
import type { FunctionReturnType } from "convex/server";
|
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 Link from "next/link";
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
@@ -24,6 +33,42 @@ const stageActionHref: Record<LeadFunnelStageId, string> = {
|
|||||||
deferred: "/dashboard/leads",
|
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() {
|
export function LeadFunnelBoard() {
|
||||||
const leads: LeadFunnelQueryResult | undefined = useQuery(
|
const leads: LeadFunnelQueryResult | undefined = useQuery(
|
||||||
api.leads.listFunnel,
|
api.leads.listFunnel,
|
||||||
@@ -40,14 +85,14 @@ export function LeadFunnelBoard() {
|
|||||||
if (totalCards === 0) {
|
if (totalCards === 0) {
|
||||||
return (
|
return (
|
||||||
<section
|
<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"
|
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
|
Lead-Funnel
|
||||||
</p>
|
</p>
|
||||||
<h2
|
<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"
|
id="lead-funnel-heading"
|
||||||
>
|
>
|
||||||
Noch keine Leads im Arbeitsfluss
|
Noch keine Leads im Arbeitsfluss
|
||||||
@@ -61,62 +106,85 @@ export function LeadFunnelBoard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
<p className="agency-kicker">Lead Workflow</p>
|
||||||
<h2
|
<h2
|
||||||
className="text-xl font-semibold tracking-normal"
|
className="mt-1 font-heading text-xl font-semibold tracking-normal"
|
||||||
id="lead-funnel-heading"
|
id="lead-funnel-heading"
|
||||||
>
|
>
|
||||||
Lead-Funnel
|
Evidence Pipeline
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
{totalCards} Leads nach Kontaktlage, Audit-Stand und nächster
|
{totalCards} Leads nach nächster Entscheidung, Beleglage und
|
||||||
manueller Aktion.
|
Outreach-Sicherheit.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<p className="rounded-md bg-[var(--surface-review)] px-2.5 py-1 text-sm font-bold text-secondary-foreground">
|
||||||
Kein automatischer Versand
|
Human approval required
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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) => (
|
{groups.map((group) => (
|
||||||
<section
|
<LeadFunnelStageView group={group} key={group.stage.id} />
|
||||||
className="flex min-h-[24rem] flex-col rounded-lg border bg-card text-card-foreground"
|
))}
|
||||||
key={group.stage.id}
|
</div>
|
||||||
aria-labelledby={`${group.stage.id}-heading`}
|
</section>
|
||||||
>
|
);
|
||||||
<div className="border-b p-3">
|
}
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<h3
|
|
||||||
className="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">
|
|
||||||
{group.stage.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 p-2">
|
function LeadFunnelStageView({
|
||||||
{group.cards.length > 0 ? (
|
group,
|
||||||
group.cards.map((card) => (
|
}: {
|
||||||
<LeadFunnelCardView card={card} key={card.id} />
|
group: ReturnType<typeof groupLeadFunnelCards>[number];
|
||||||
))
|
}) {
|
||||||
) : (
|
const visual = stageVisuals[group.stage.id];
|
||||||
<p className="rounded-md border border-dashed p-3 text-xs leading-5 text-muted-foreground">
|
const Icon = visual.icon;
|
||||||
Keine Leads in dieser Spalte.
|
|
||||||
</p>
|
return (
|
||||||
)}
|
<section
|
||||||
</div>
|
className="rounded-md border border-border/80 bg-background/55"
|
||||||
</section>
|
aria-labelledby={`${group.stage.id}-heading`}
|
||||||
))}
|
>
|
||||||
|
<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="mt-1 text-sm font-semibold"
|
||||||
|
id={`${group.stage.id}-heading`}
|
||||||
|
>
|
||||||
|
{group.stage.title}
|
||||||
|
</h3>
|
||||||
|
<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 ? (
|
||||||
|
group.cards.map((card) => (
|
||||||
|
<LeadFunnelCardView card={card} key={card.id} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -125,7 +193,7 @@ export function LeadFunnelBoard() {
|
|||||||
function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
|
function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
|
||||||
return (
|
return (
|
||||||
<article
|
<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`}
|
aria-labelledby={`${card.id}-company`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@@ -143,7 +211,7 @@ function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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"
|
card.priorityLabel === "Hoch"
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted text-muted-foreground",
|
: "bg-muted text-muted-foreground",
|
||||||
@@ -159,16 +227,16 @@ function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<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}
|
{card.contactStatusLabel}
|
||||||
</span>
|
</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}
|
{card.contactDetail}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<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]}
|
href={stageActionHref[card.stageId]}
|
||||||
prefetch={false}
|
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">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||||
{Array.from({ length: 6 }, (_, index) => (
|
{Array.from({ length: 6 }, (_, index) => (
|
||||||
<div
|
<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}
|
key={index}
|
||||||
>
|
>
|
||||||
<div className="h-5 w-28 rounded-md bg-muted" />
|
<div className="h-5 w-28 rounded-md bg-muted" />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation, useQuery } from "convex/react";
|
import { useMutation, useQuery } from "convex/react";
|
||||||
import { FunctionReturnType } from "convex/server";
|
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 { api } from "@/convex/_generated/api";
|
||||||
import { Id } from "@/convex/_generated/dataModel";
|
import { Id } from "@/convex/_generated/dataModel";
|
||||||
@@ -39,6 +39,10 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
|
|
||||||
type LeadsListResult = FunctionReturnType<typeof api.leads.list>;
|
type LeadsListResult = FunctionReturnType<typeof api.leads.list>;
|
||||||
type LeadRow = NonNullable<LeadsListResult>[number];
|
type LeadRow = NonNullable<LeadsListResult>[number];
|
||||||
|
type AuditStartStatesResult = FunctionReturnType<
|
||||||
|
typeof api.pageSpeed.getLeadAuditStartStates
|
||||||
|
>;
|
||||||
|
type AuditStartState = NonNullable<AuditStartStatesResult>[number];
|
||||||
|
|
||||||
type LeadReviewDraft = {
|
type LeadReviewDraft = {
|
||||||
priority: LeadPriority;
|
priority: LeadPriority;
|
||||||
@@ -80,6 +84,33 @@ function normalizeTextInput(value: string): string | undefined {
|
|||||||
return next.length > 0 ? next : 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 {
|
function contactSourceLabel(lead: LeadRow): string {
|
||||||
if (lead.sourceProvider) {
|
if (lead.sourceProvider) {
|
||||||
return lead.sourceProvider;
|
return lead.sourceProvider;
|
||||||
@@ -139,6 +170,45 @@ function duplicateBadgeVariant(
|
|||||||
return "outline";
|
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() {
|
export function LeadsReviewTable() {
|
||||||
const leads = useQuery(api.leads.list, { limit: 120 });
|
const leads = useQuery(api.leads.list, { limit: 120 });
|
||||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||||
@@ -151,6 +221,21 @@ export function LeadsReviewTable() {
|
|||||||
|
|
||||||
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
|
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
|
||||||
}, [leads]);
|
}, [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(() => {
|
const filteredLeads = useMemo(() => {
|
||||||
if (activeFilter === "high") {
|
if (activeFilter === "high") {
|
||||||
return sortedLeads.filter((lead) => lead.priority === "high");
|
return sortedLeads.filter((lead) => lead.priority === "high");
|
||||||
@@ -178,16 +263,20 @@ export function LeadsReviewTable() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
|
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-2">
|
<div className="agency-panel mx-auto flex w-full max-w-7xl flex-col gap-2 p-4">
|
||||||
<p className="text-sm text-muted-foreground">Leads Review</p>
|
<p className="agency-kicker">Lead Intake</p>
|
||||||
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
|
<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>
|
||||||
|
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-wrap gap-2" aria-label="Lead-Filter">
|
<div className="mx-auto flex w-full max-w-7xl flex-wrap gap-2" aria-label="Lead-Filter">
|
||||||
{leadStatusFilters.map((filter) => (
|
{leadStatusFilters.map((filter) => (
|
||||||
<button
|
<button
|
||||||
aria-pressed={activeFilter === filter.value}
|
aria-pressed={activeFilter === filter.value}
|
||||||
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
|
className="agency-tab"
|
||||||
key={filter.value}
|
key={filter.value}
|
||||||
onClick={() => setActiveFilter(filter.value)}
|
onClick={() => setActiveFilter(filter.value)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -201,7 +290,7 @@ export function LeadsReviewTable() {
|
|||||||
<div className="mx-auto grid w-full max-w-7xl gap-3">
|
<div className="mx-auto grid w-full max-w-7xl gap-3">
|
||||||
{leads === undefined ? (
|
{leads === undefined ? (
|
||||||
Array.from({ length: 4 }, (_, index) => (
|
Array.from({ length: 4 }, (_, index) => (
|
||||||
<Card key={index}>
|
<Card className="agency-panel" key={index}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="h-5 w-2/3 rounded-md bg-muted" />
|
<div className="h-5 w-2/3 rounded-md bg-muted" />
|
||||||
<div className="h-4 w-1/2 rounded-md bg-muted" />
|
<div className="h-4 w-1/2 rounded-md bg-muted" />
|
||||||
@@ -210,7 +299,7 @@ export function LeadsReviewTable() {
|
|||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
) : sortedLeads.length === 0 ? (
|
) : sortedLeads.length === 0 ? (
|
||||||
<Card>
|
<Card className="agency-panel">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<p className="text-sm font-medium">Keine Leads vorhanden</p>
|
<p className="text-sm font-medium">Keine Leads vorhanden</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -219,7 +308,7 @@ export function LeadsReviewTable() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
) : filteredLeads.length === 0 ? (
|
) : filteredLeads.length === 0 ? (
|
||||||
<Card>
|
<Card className="agency-panel">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<p className="text-sm font-medium">Keine Treffer</p>
|
<p className="text-sm font-medium">Keine Treffer</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -232,6 +321,8 @@ export function LeadsReviewTable() {
|
|||||||
<LeadReviewRow
|
<LeadReviewRow
|
||||||
key={lead._id}
|
key={lead._id}
|
||||||
lead={lead}
|
lead={lead}
|
||||||
|
auditStartState={auditStartStateByLeadId.get(lead._id)}
|
||||||
|
auditStartStateLoading={auditStartStates === undefined}
|
||||||
onActionMessage={setActionMessage}
|
onActionMessage={setActionMessage}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -249,9 +340,13 @@ export function LeadsReviewTable() {
|
|||||||
|
|
||||||
function LeadReviewRow({
|
function LeadReviewRow({
|
||||||
lead,
|
lead,
|
||||||
|
auditStartState,
|
||||||
|
auditStartStateLoading,
|
||||||
onActionMessage,
|
onActionMessage,
|
||||||
}: {
|
}: {
|
||||||
lead: LeadRow;
|
lead: LeadRow;
|
||||||
|
auditStartState?: AuditStartState;
|
||||||
|
auditStartStateLoading: boolean;
|
||||||
onActionMessage: (value: string) => void;
|
onActionMessage: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
@@ -270,16 +365,28 @@ function LeadReviewRow({
|
|||||||
}));
|
}));
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isBlocking, setIsBlocking] = useState(false);
|
const [isBlocking, setIsBlocking] = useState(false);
|
||||||
|
const [isStartingAudit, setIsStartingAudit] = useState(false);
|
||||||
const [rowMessage, setRowMessage] = useState<string | null>(null);
|
const [rowMessage, setRowMessage] = useState<string | null>(null);
|
||||||
const reviewUpdate = useMutation(api.leads.reviewUpdate);
|
const reviewUpdate = useMutation(api.leads.reviewUpdate);
|
||||||
|
const requestLeadAudit = useMutation(api.pageSpeed.requestLeadAudit);
|
||||||
|
|
||||||
const location = formatLocation(lead);
|
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 = [
|
const reasonParts = [
|
||||||
lead.priorityReason,
|
lead.priorityReason,
|
||||||
lead.contactStatusReason,
|
lead.contactStatusReason,
|
||||||
lead.duplicateReason,
|
lead.duplicateReason,
|
||||||
lead.blacklistReason,
|
lead.blacklistReason,
|
||||||
].filter((item): item is string => Boolean(item));
|
].filter((item): item is string => Boolean(item));
|
||||||
|
const manualAuditDisabledReason = auditStartDisabledReason({
|
||||||
|
lead,
|
||||||
|
auditStartState,
|
||||||
|
isLoading: auditStartStateLoading,
|
||||||
|
isStarting: isStartingAudit,
|
||||||
|
});
|
||||||
|
|
||||||
const update = async (
|
const update = async (
|
||||||
payload?: Omit<LeadReviewPayload, "id">,
|
payload?: Omit<LeadReviewPayload, "id">,
|
||||||
@@ -342,6 +449,28 @@ function LeadReviewRow({
|
|||||||
setIsBlocking(false);
|
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>(
|
const updateDraft = <T extends keyof LeadReviewDraft>(
|
||||||
field: T,
|
field: T,
|
||||||
value: LeadReviewDraft[T],
|
value: LeadReviewDraft[T],
|
||||||
@@ -364,7 +493,7 @@ function LeadReviewRow({
|
|||||||
const blacklistStatusId = `lead-blacklist-status-${lead._id}`;
|
const blacklistStatusId = `lead-blacklist-status-${lead._id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card aria-labelledby={titleId}>
|
<Card aria-labelledby={titleId} className="agency-panel overflow-hidden">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="grid min-w-0 gap-2">
|
<div className="grid min-w-0 gap-2">
|
||||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
||||||
@@ -398,27 +527,62 @@ function LeadReviewRow({
|
|||||||
<div className="grid min-w-0 gap-1 text-xs text-muted-foreground">
|
<div className="grid min-w-0 gap-1 text-xs text-muted-foreground">
|
||||||
<p className="inline-flex min-w-0 items-center gap-1">
|
<p className="inline-flex min-w-0 items-center gap-1">
|
||||||
<Mail className="size-3 shrink-0" />
|
<Mail className="size-3 shrink-0" />
|
||||||
<span className="max-w-full min-w-0 break-all">
|
{emailHref ? (
|
||||||
{lead.email || "Keine E-Mail"}
|
<a className="max-w-full min-w-0 break-all hover:text-foreground hover:underline" href={emailHref}>
|
||||||
</span>
|
{lead.email}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="max-w-full min-w-0 break-all">Keine E-Mail</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{lead.phone ? (
|
{lead.phone && phoneHref ? (
|
||||||
<p className="inline-flex min-w-0 items-center gap-1">
|
<p className="inline-flex min-w-0 items-center gap-1">
|
||||||
<Phone className="size-3 shrink-0" />
|
<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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="truncate max-w-full">
|
<p className="truncate max-w-full">
|
||||||
Quelle: {contactSourceLabel(lead)}
|
Quelle: {contactSourceLabel(lead)}
|
||||||
</p>
|
</p>
|
||||||
{lead.websiteDomain ? (
|
{websiteHref && websiteLabel ? (
|
||||||
<p className="truncate max-w-full">Domain: {lead.websiteDomain}</p>
|
<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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ import { useMemo, useState } from "react";
|
|||||||
|
|
||||||
import { useAction, useMutation, useQuery } from "convex/react";
|
import { useAction, useMutation, useQuery } from "convex/react";
|
||||||
import type { FunctionReturnType } from "convex/server";
|
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 Link from "next/link";
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
@@ -176,9 +185,9 @@ function FieldPair({ label, value }: { label: string; value?: string | null }) {
|
|||||||
function WorkspaceLoading() {
|
function WorkspaceLoading() {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<header className="space-y-2">
|
<header className="agency-panel space-y-2 p-4">
|
||||||
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
|
<p className="agency-kicker">Approval Bench</p>
|
||||||
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
<h1 className="font-heading text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||||
</header>
|
</header>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Array.from({ length: 3 }, (_, index) => (
|
{Array.from({ length: 3 }, (_, index) => (
|
||||||
@@ -244,11 +253,11 @@ export function OutreachReviewWorkspace() {
|
|||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<header className="space-y-2">
|
<header className="agency-panel space-y-2 p-4">
|
||||||
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
|
<p className="agency-kicker">Approval Bench</p>
|
||||||
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
<h1 className="font-heading text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||||
</header>
|
</header>
|
||||||
<Card>
|
<Card className="agency-panel">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-sm font-medium">Keine offenen Reviews</p>
|
<p className="text-sm font-medium">Keine offenen Reviews</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
@@ -482,9 +491,9 @@ export function OutreachReviewWorkspace() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<header className="space-y-2">
|
<header className="agency-panel space-y-2 p-4">
|
||||||
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
|
<p className="agency-kicker">Approval Bench</p>
|
||||||
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
<h1 className="font-heading text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||||
Audits, E-Mail-Empfehlung und Telefonnotizen prüfen, bevor etwas öffentlich
|
Audits, E-Mail-Empfehlung und Telefonnotizen prüfen, bevor etwas öffentlich
|
||||||
wird oder eine Freigabe erhält.
|
wird oder eine Freigabe erhält.
|
||||||
@@ -492,7 +501,7 @@ export function OutreachReviewWorkspace() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{notice ? (
|
{notice ? (
|
||||||
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm" role="status">{notice}</p>
|
<p className="rounded-md border border-border/75 bg-muted/30 px-3 py-2 text-sm" role="status">{notice}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -560,7 +569,7 @@ export function OutreachReviewWorkspace() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Senden
|
Final senden
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={closeEmailConfirmation} size="sm" type="button" variant="outline">
|
<Button onClick={closeEmailConfirmation} size="sm" type="button" variant="outline">
|
||||||
Abbrechen
|
Abbrechen
|
||||||
@@ -570,14 +579,15 @@ export function OutreachReviewWorkspace() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<section className="space-y-3" aria-label="Review-Queue">
|
<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">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 className="text-sm font-semibold">Review-Queue</h2>
|
<h2 className="text-sm font-semibold">Review-Queue</h2>
|
||||||
<div className="flex flex-wrap gap-2" aria-label="Review-Filter">
|
<div className="flex flex-wrap gap-2" aria-label="Review-Filter">
|
||||||
{reviewStatusFilters.map((filter) => (
|
{reviewStatusFilters.map((filter) => (
|
||||||
<button
|
<button
|
||||||
aria-pressed={activeFilter === filter.value}
|
aria-pressed={activeFilter === filter.value}
|
||||||
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
|
className="agency-tab"
|
||||||
key={filter.value}
|
key={filter.value}
|
||||||
onClick={() => setActiveFilter(filter.value)}
|
onClick={() => setActiveFilter(filter.value)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -589,7 +599,7 @@ export function OutreachReviewWorkspace() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3">
|
||||||
{filteredRows.map((record) => {
|
{filteredRows.map((record) => {
|
||||||
const lead = record.lead;
|
const lead = record.lead;
|
||||||
const audit = record.audit;
|
const audit = record.audit;
|
||||||
@@ -602,8 +612,10 @@ export function OutreachReviewWorkspace() {
|
|||||||
<Card
|
<Card
|
||||||
aria-labelledby={queueTitleId}
|
aria-labelledby={queueTitleId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-0 flex-col",
|
"flex min-w-0 flex-col overflow-hidden",
|
||||||
selectedRecord?.id === record.id ? "border-foreground" : "",
|
selectedRecord?.id === record.id
|
||||||
|
? "border-primary bg-[var(--surface-evidence)]"
|
||||||
|
: "bg-background/60",
|
||||||
)}
|
)}
|
||||||
key={record.id}
|
key={record.id}
|
||||||
>
|
>
|
||||||
@@ -656,7 +668,7 @@ export function OutreachReviewWorkspace() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{filteredRows.length === 0 ? (
|
{filteredRows.length === 0 ? (
|
||||||
<Card className="lg:col-span-2 xl:col-span-3">
|
<Card className="agency-panel">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Keine Treffer</CardTitle>
|
<CardTitle>Keine Treffer</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -695,8 +707,8 @@ export function OutreachReviewWorkspace() {
|
|||||||
const publicAuditHref = audit?.slug ? `/audit/${audit.slug}` : null;
|
const publicAuditHref = audit?.slug ? `/audit/${audit.slug}` : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden" key={record.id}>
|
<Card className="agency-panel overflow-hidden" key={record.id}>
|
||||||
<CardHeader className="gap-3 border-b bg-muted/20 p-4">
|
<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="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<CardTitle className="break-words text-lg">
|
<CardTitle className="break-words text-lg">
|
||||||
@@ -719,11 +731,49 @@ export function OutreachReviewWorkspace() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-5 p-4">
|
<CardContent className="space-y-5 p-4">
|
||||||
<section className="grid gap-4 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
<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>
|
<h2 className="text-sm font-semibold">Lead-Details</h2>
|
||||||
<dl className="grid gap-3 sm:grid-cols-2">
|
<dl className="grid gap-3 sm:grid-cols-2">
|
||||||
<FieldPair label="Nische" value={lead?.niche} />
|
<FieldPair label="Nische" value={lead?.niche} />
|
||||||
@@ -752,7 +802,7 @@ export function OutreachReviewWorkspace() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h2 className="text-sm font-semibold">Audit-Zusammenfassung</h2>
|
<h2 className="text-sm font-semibold">Audit-Zusammenfassung</h2>
|
||||||
{publicAuditHref ? (
|
{publicAuditHref ? (
|
||||||
@@ -828,7 +878,7 @@ export function OutreachReviewWorkspace() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
<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>
|
<h2 className="text-sm font-semibold">Empfohlene E-Mail</h2>
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
@@ -877,12 +927,12 @@ export function OutreachReviewWorkspace() {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<MailCheck className="size-3.5" />
|
<MailCheck className="size-3.5" />
|
||||||
E-Mail freigeben und senden
|
E-Mail freigeben
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<h2 className="text-sm font-semibold">Telefon & Follow-up</h2>
|
||||||
{hasCallablePhone ? (
|
{hasCallablePhone ? (
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
@@ -997,6 +1047,7 @@ export function OutreachReviewWorkspace() {
|
|||||||
);
|
);
|
||||||
})() : null}
|
})() : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border-transparent bg-primary text-primary-foreground",
|
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:
|
outline:
|
||||||
"text-foreground border-border bg-background hover:bg-muted/40",
|
"border-border/90 bg-background/70 text-foreground hover:bg-muted/50",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground",
|
"border-transparent bg-destructive text-destructive-foreground",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { Slot } from "radix-ui"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
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:
|
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:
|
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",
|
"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:
|
ghost:
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ const Card = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -21,7 +24,7 @@ const CardHeader = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -33,7 +36,10 @@ const CardTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<h3
|
<h3
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
"guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57",
|
"guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57",
|
||||||
"agentsMdSectionHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
|
"agentsMdSectionHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
|
||||||
"claudeMdHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
|
"claudeMdHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
|
||||||
"agentSkillsSha": "294d4f05edb5e7b57f3c534b79dd00e8e3d7b60d"
|
"agentSkillsSha": "7a6fcc6882f344577a34365fdadbd0f8f8c467d7"
|
||||||
}
|
}
|
||||||
|
|||||||
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -11,6 +11,7 @@
|
|||||||
import type * as auditGeneration from "../auditGeneration.js";
|
import type * as auditGeneration from "../auditGeneration.js";
|
||||||
import type * as auditGenerationAction from "../auditGenerationAction.js";
|
import type * as auditGenerationAction from "../auditGenerationAction.js";
|
||||||
import type * as auditInputs from "../auditInputs.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 audits from "../audits.js";
|
||||||
import type * as blacklist from "../blacklist.js";
|
import type * as blacklist from "../blacklist.js";
|
||||||
import type * as campaignMetrics from "../campaignMetrics.js";
|
import type * as campaignMetrics from "../campaignMetrics.js";
|
||||||
@@ -42,6 +43,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
auditGeneration: typeof auditGeneration;
|
auditGeneration: typeof auditGeneration;
|
||||||
auditGenerationAction: typeof auditGenerationAction;
|
auditGenerationAction: typeof auditGenerationAction;
|
||||||
auditInputs: typeof auditInputs;
|
auditInputs: typeof auditInputs;
|
||||||
|
auditWorkflow: typeof auditWorkflow;
|
||||||
audits: typeof audits;
|
audits: typeof audits;
|
||||||
blacklist: typeof blacklist;
|
blacklist: typeof blacklist;
|
||||||
campaignMetrics: typeof campaignMetrics;
|
campaignMetrics: typeof campaignMetrics;
|
||||||
@@ -92,4 +94,6 @@ export declare const internal: FilterApi<
|
|||||||
|
|
||||||
export declare const components: {
|
export declare const components: {
|
||||||
betterAuth: import("../betterAuth/_generated/component.js").ComponentApi<"betterAuth">;
|
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">;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -205,8 +205,8 @@ const auditGenerationUsage = v.object({
|
|||||||
|
|
||||||
const secretHints = [
|
const secretHints = [
|
||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
"GOOGLE_PLACES_API_KEY",
|
"LOCAL_BUSINESS_DATA_API_KEY",
|
||||||
"GOOGLE_GEOCODING_API_KEY",
|
"RAPIDAPI_KEY",
|
||||||
"PAGESPEED_API_KEY",
|
"PAGESPEED_API_KEY",
|
||||||
"SMTP_PASSWORD",
|
"SMTP_PASSWORD",
|
||||||
"SMTP_HOST",
|
"SMTP_HOST",
|
||||||
@@ -350,6 +350,7 @@ export const queueLeadAuditGeneration = internalMutation({
|
|||||||
leadId: v.id("leads"),
|
leadId: v.id("leads"),
|
||||||
auditId: v.optional(v.id("audits")),
|
auditId: v.optional(v.id("audits")),
|
||||||
parentRunId: v.optional(v.id("agentRuns")),
|
parentRunId: v.optional(v.id("agentRuns")),
|
||||||
|
scheduleAction: v.optional(v.boolean()),
|
||||||
},
|
},
|
||||||
returns: v.union(v.id("agentRuns"), v.null()),
|
returns: v.union(v.id("agentRuns"), v.null()),
|
||||||
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
|
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
|
||||||
@@ -418,13 +419,15 @@ export const queueLeadAuditGeneration = internalMutation({
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.scheduler.runAfter(
|
if (args.scheduleAction !== false) {
|
||||||
0,
|
await ctx.scheduler.runAfter(
|
||||||
internal.auditGenerationAction.processAuditGeneration,
|
0,
|
||||||
{
|
internal.auditGenerationAction.processAuditGeneration,
|
||||||
runId,
|
{
|
||||||
},
|
runId,
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return runId;
|
return runId;
|
||||||
},
|
},
|
||||||
@@ -460,7 +463,11 @@ export const startAuditGenerationRun = internalMutation({
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const run = await ctx.db.get(args.runId);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +518,7 @@ export const startAuditGenerationRun = internalMutation({
|
|||||||
status: "running",
|
status: "running",
|
||||||
currentStep: "audit_generation",
|
currentStep: "audit_generation",
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
|
finishedAt: undefined,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
errorSummary: undefined,
|
errorSummary: undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
qualityReviewSchema,
|
qualityReviewSchema,
|
||||||
type AuditSpecialistFinding,
|
type AuditSpecialistFinding,
|
||||||
type AuditSpecialistResult,
|
type AuditSpecialistResult,
|
||||||
|
type QualityReview,
|
||||||
|
type QualityReviewRevisedCopy,
|
||||||
} from "../lib/ai/schemas";
|
} from "../lib/ai/schemas";
|
||||||
import {
|
import {
|
||||||
validateCustomerFacingCopy,
|
validateCustomerFacingCopy,
|
||||||
@@ -32,6 +34,7 @@ import {
|
|||||||
type ScreenshotOneRequest,
|
type ScreenshotOneRequest,
|
||||||
} from "../lib/external-audit-services";
|
} from "../lib/external-audit-services";
|
||||||
import { type AuditUsedSkill } from "../lib/skills-registry";
|
import { type AuditUsedSkill } from "../lib/skills-registry";
|
||||||
|
import { getAuditProgressForStep } from "../lib/audits/progress";
|
||||||
import { internal } from "./_generated/api";
|
import { internal } from "./_generated/api";
|
||||||
import type { Id } from "./_generated/dataModel";
|
import type { Id } from "./_generated/dataModel";
|
||||||
import {
|
import {
|
||||||
@@ -98,8 +101,8 @@ function sanitizeAndCapString(value: string | undefined, maxBytes: number) {
|
|||||||
|
|
||||||
const secretHints = [
|
const secretHints = [
|
||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
"GOOGLE_PLACES_API_KEY",
|
"LOCAL_BUSINESS_DATA_API_KEY",
|
||||||
"GOOGLE_GEOCODING_API_KEY",
|
"RAPIDAPI_KEY",
|
||||||
"PAGESPEED_API_KEY",
|
"PAGESPEED_API_KEY",
|
||||||
"SMTP_PASSWORD",
|
"SMTP_PASSWORD",
|
||||||
"SMTP_HOST",
|
"SMTP_HOST",
|
||||||
@@ -281,6 +284,44 @@ type GermanCopyOutput = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function applyRevisedCopy(
|
||||||
|
currentCopy: GermanCopyOutput,
|
||||||
|
revisedCopy: QualityReviewRevisedCopy,
|
||||||
|
): GermanCopyOutput {
|
||||||
|
return {
|
||||||
|
...currentCopy,
|
||||||
|
publicSummary: revisedCopy.publicSummary,
|
||||||
|
publicBody: revisedCopy.publicBody,
|
||||||
|
emailSubject: revisedCopy.emailSubject,
|
||||||
|
emailBody: revisedCopy.emailBody,
|
||||||
|
phoneScript: {
|
||||||
|
openingLine: revisedCopy.phoneScript.openingLine,
|
||||||
|
callScript: revisedCopy.phoneScript.callScript,
|
||||||
|
closeLine: revisedCopy.phoneScript.closeLine,
|
||||||
|
},
|
||||||
|
followUpDraft: {
|
||||||
|
message: revisedCopy.followUpDraft.message,
|
||||||
|
...(revisedCopy.followUpDraft.followInDays !== null
|
||||||
|
? { followInDays: revisedCopy.followUpDraft.followInDays }
|
||||||
|
: {}),
|
||||||
|
...(revisedCopy.followUpDraft.goals !== null
|
||||||
|
? { goals: revisedCopy.followUpDraft.goals }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function germanCopyGuardTelemetry(guardResult: GermanCopyGuardResult) {
|
||||||
|
return {
|
||||||
|
passed: guardResult.passed,
|
||||||
|
issues: guardResult.issues.map((issue) => ({
|
||||||
|
field: issue.field,
|
||||||
|
rule: issue.rule,
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type MultimodalContentPart =
|
type MultimodalContentPart =
|
||||||
| {
|
| {
|
||||||
type: "text";
|
type: "text";
|
||||||
@@ -554,7 +595,11 @@ function buildQualityReviewPrompt(
|
|||||||
`Öffentlicher Text: ${germanCopy.publicBody}`,
|
`Öffentlicher Text: ${germanCopy.publicBody}`,
|
||||||
`Email-Betreff: ${germanCopy.emailSubject}`,
|
`Email-Betreff: ${germanCopy.emailSubject}`,
|
||||||
`Email-Text: ${germanCopy.emailBody}`,
|
`Email-Text: ${germanCopy.emailBody}`,
|
||||||
"Antworte als JSON mit isValid, issues, suggestions, notes.",
|
"Wenn die Copy nur stilistische oder leichte fachliche Hinweise hat, nutze severity warning und rewriteRequired true.",
|
||||||
|
"Wenn eine Korrektur sinnvoll ist, liefere revisedCopy vollständig mit publicSummary, publicBody, emailSubject, emailBody, phoneScript und followUpDraft.",
|
||||||
|
"Wenn keine Korrektur nötig ist, setze rewriteRequired false und revisedCopy null.",
|
||||||
|
"Nutze severity unsafe nur für harte Risiken wie falsche Sprache, erfundene Behauptungen, aggressive Tonalität oder Rohdaten-Leaks.",
|
||||||
|
"Antworte als JSON mit isValid, severity, issues, suggestions, rewriteRequired, revisedCopy und notes.",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,6 +675,27 @@ async function appendRunEvent(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateRootAuditProgress(
|
||||||
|
ctx: ActionCtx,
|
||||||
|
rootRunId: Id<"agentRuns"> | undefined,
|
||||||
|
currentStep: string,
|
||||||
|
) {
|
||||||
|
if (!rootRunId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = getAuditProgressForStep(currentStep);
|
||||||
|
await ctx.runMutation(internal.runs.updateProgressInternal, {
|
||||||
|
id: rootRunId,
|
||||||
|
status: "running",
|
||||||
|
currentStep,
|
||||||
|
progressStep: progress.step,
|
||||||
|
progressTotal: progress.total,
|
||||||
|
progressLabel: progress.label,
|
||||||
|
progressPercent: progress.percent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAuditSkillRegistry(
|
async function loadAuditSkillRegistry(
|
||||||
ctx: ActionCtx,
|
ctx: ActionCtx,
|
||||||
runId: Id<"agentRuns">,
|
runId: Id<"agentRuns">,
|
||||||
@@ -1204,6 +1270,7 @@ function getValidMediaType(mimeType: string) {
|
|||||||
export const processAuditGeneration = internalAction({
|
export const processAuditGeneration = internalAction({
|
||||||
args: {
|
args: {
|
||||||
runId: v.id("agentRuns"),
|
runId: v.id("agentRuns"),
|
||||||
|
rootRunId: v.optional(v.id("agentRuns")),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
let started:
|
let started:
|
||||||
@@ -1345,6 +1412,7 @@ export const processAuditGeneration = internalAction({
|
|||||||
MAX_PROMPT_BYTES,
|
MAX_PROMPT_BYTES,
|
||||||
);
|
);
|
||||||
currentStep = "classification";
|
currentStep = "classification";
|
||||||
|
await updateRootAuditProgress(ctx, args.rootRunId, currentStep);
|
||||||
await persistAuditStage({
|
await persistAuditStage({
|
||||||
ctx,
|
ctx,
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
@@ -1545,6 +1613,7 @@ export const processAuditGeneration = internalAction({
|
|||||||
const verifierSystemPrompt =
|
const verifierSystemPrompt =
|
||||||
"Du bist EvidenceQA. Verifiziere Befunde streng gegen belegte Evidence-Refs.";
|
"Du bist EvidenceQA. Verifiziere Befunde streng gegen belegte Evidence-Refs.";
|
||||||
currentStep = "evidenceVerifier";
|
currentStep = "evidenceVerifier";
|
||||||
|
await updateRootAuditProgress(ctx, args.rootRunId, currentStep);
|
||||||
|
|
||||||
await persistAuditStage({
|
await persistAuditStage({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -1690,6 +1759,7 @@ export const processAuditGeneration = internalAction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentStep = "multimodalAudit";
|
currentStep = "multimodalAudit";
|
||||||
|
await updateRootAuditProgress(ctx, args.rootRunId, currentStep);
|
||||||
|
|
||||||
const validScreenshotParts = screenshotParts.filter(
|
const validScreenshotParts = screenshotParts.filter(
|
||||||
(part): part is MultimodalFilePart => part !== null,
|
(part): part is MultimodalFilePart => part !== null,
|
||||||
@@ -1844,6 +1914,7 @@ export const processAuditGeneration = internalAction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentStep = "germanCopy";
|
currentStep = "germanCopy";
|
||||||
|
await updateRootAuditProgress(ctx, args.rootRunId, currentStep);
|
||||||
// Stage 3: german copy generation
|
// Stage 3: german copy generation
|
||||||
const germanSystemPrompt =
|
const germanSystemPrompt =
|
||||||
"Du bist fachlicher Texter für lokale Unternehmen im B2B-Kontext.";
|
"Du bist fachlicher Texter für lokale Unternehmen im B2B-Kontext.";
|
||||||
@@ -1856,61 +1927,65 @@ export const processAuditGeneration = internalAction({
|
|||||||
const safeGermanPrompt = sanitizeAndCapString(germanPrompt, MAX_PROMPT_BYTES);
|
const safeGermanPrompt = sanitizeAndCapString(germanPrompt, MAX_PROMPT_BYTES);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const publicSummaryResult = await generateObject({
|
const [
|
||||||
model: provider(germanCopyProfile.modelId),
|
publicSummaryResult,
|
||||||
system: germanSystemPrompt,
|
germanBodyResult,
|
||||||
schema: publicAuditTextSchema,
|
germanSubjectResult,
|
||||||
prompt: safeGermanPrompt
|
germanEmailResult,
|
||||||
? `${safeGermanPrompt}\nAusgabe für publicSummary`
|
germanCallScriptResult,
|
||||||
: "Ausgabe für publicSummary",
|
germanFollowUpResult,
|
||||||
temperature: germanCopyProfile.temperature,
|
] = await Promise.all([
|
||||||
maxOutputTokens: germanCopyProfile.maxTokens,
|
generateObject({
|
||||||
});
|
model: provider(germanCopyProfile.modelId),
|
||||||
|
system: germanSystemPrompt,
|
||||||
const germanBodyResult = await generateObject({
|
schema: publicAuditTextSchema,
|
||||||
model: provider(germanCopyProfile.modelId),
|
prompt: safeGermanPrompt
|
||||||
system: germanSystemPrompt,
|
? `${safeGermanPrompt}\nAusgabe für publicSummary`
|
||||||
schema: publicAuditTextSchema,
|
: "Ausgabe für publicSummary",
|
||||||
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für publicBody`,
|
temperature: germanCopyProfile.temperature,
|
||||||
temperature: germanCopyProfile.temperature,
|
maxOutputTokens: germanCopyProfile.maxTokens,
|
||||||
maxOutputTokens: germanCopyProfile.maxTokens,
|
}),
|
||||||
});
|
generateObject({
|
||||||
|
model: provider(germanCopyProfile.modelId),
|
||||||
const germanSubjectResult = await generateObject({
|
system: germanSystemPrompt,
|
||||||
model: provider(germanCopyProfile.modelId),
|
schema: publicAuditTextSchema,
|
||||||
system: germanSystemPrompt,
|
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für publicBody`,
|
||||||
schema: emailSubjectSchema,
|
temperature: germanCopyProfile.temperature,
|
||||||
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für emailSubject`,
|
maxOutputTokens: germanCopyProfile.maxTokens,
|
||||||
temperature: germanCopyProfile.temperature,
|
}),
|
||||||
maxOutputTokens: germanCopyProfile.maxTokens,
|
generateObject({
|
||||||
});
|
model: provider(germanCopyProfile.modelId),
|
||||||
|
system: germanSystemPrompt,
|
||||||
const germanEmailResult = await generateObject({
|
schema: emailSubjectSchema,
|
||||||
model: provider(germanCopyProfile.modelId),
|
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für emailSubject`,
|
||||||
system: germanSystemPrompt,
|
temperature: germanCopyProfile.temperature,
|
||||||
schema: emailDraftSchema,
|
maxOutputTokens: germanCopyProfile.maxTokens,
|
||||||
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für emailBody`,
|
}),
|
||||||
temperature: germanCopyProfile.temperature,
|
generateObject({
|
||||||
maxOutputTokens: germanCopyProfile.maxTokens,
|
model: provider(germanCopyProfile.modelId),
|
||||||
});
|
system: germanSystemPrompt,
|
||||||
|
schema: emailDraftSchema,
|
||||||
const germanCallScriptResult = await generateObject({
|
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für emailBody`,
|
||||||
model: provider(germanCopyProfile.modelId),
|
temperature: germanCopyProfile.temperature,
|
||||||
system: germanSystemPrompt,
|
maxOutputTokens: germanCopyProfile.maxTokens,
|
||||||
schema: callScriptSchema,
|
}),
|
||||||
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für callScript`,
|
generateObject({
|
||||||
temperature: germanCopyProfile.temperature,
|
model: provider(germanCopyProfile.modelId),
|
||||||
maxOutputTokens: germanCopyProfile.maxTokens,
|
system: germanSystemPrompt,
|
||||||
});
|
schema: callScriptSchema,
|
||||||
|
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für callScript`,
|
||||||
const germanFollowUpResult = await generateObject({
|
temperature: germanCopyProfile.temperature,
|
||||||
model: provider(germanCopyProfile.modelId),
|
maxOutputTokens: germanCopyProfile.maxTokens,
|
||||||
system: germanSystemPrompt,
|
}),
|
||||||
schema: followUpDraftSchema,
|
generateObject({
|
||||||
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für followUpDraft`,
|
model: provider(germanCopyProfile.modelId),
|
||||||
temperature: germanCopyProfile.temperature,
|
system: germanSystemPrompt,
|
||||||
maxOutputTokens: germanCopyProfile.maxTokens,
|
schema: followUpDraftSchema,
|
||||||
});
|
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für followUpDraft`,
|
||||||
|
temperature: germanCopyProfile.temperature,
|
||||||
|
maxOutputTokens: germanCopyProfile.maxTokens,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const publicSummary = publicSummaryResult.object.publicText ?? "";
|
const publicSummary = publicSummaryResult.object.publicText ?? "";
|
||||||
const publicBody = germanBodyResult.object.publicText ?? "";
|
const publicBody = germanBodyResult.object.publicText ?? "";
|
||||||
@@ -2015,42 +2090,89 @@ export const processAuditGeneration = internalAction({
|
|||||||
},
|
},
|
||||||
followUp: germanCopyOutput.followUpDraft.message,
|
followUp: germanCopyOutput.followUpDraft.message,
|
||||||
});
|
});
|
||||||
|
const deterministicGuard = germanCopyGuardTelemetry(guardResult);
|
||||||
|
|
||||||
// Stage 4: final quality review
|
// Stage 4: final quality review
|
||||||
const qualityPrompt = buildQualityReviewPrompt(
|
let qualityPrompt = buildQualityReviewPrompt(
|
||||||
verifiedFindingsText,
|
verifiedFindingsText,
|
||||||
germanCopyOutput,
|
germanCopyOutput,
|
||||||
);
|
);
|
||||||
const safeQualityPrompt = sanitizeAndCapString(qualityPrompt, MAX_PROMPT_BYTES);
|
let safeQualityPrompt = sanitizeAndCapString(qualityPrompt, MAX_PROMPT_BYTES);
|
||||||
const qualitySystemPrompt =
|
const qualitySystemPrompt =
|
||||||
"Du prüfst die erzeugten Inhalte als Qualitätssicherung.";
|
"Du prüfst die erzeugten Inhalte als Qualitätssicherung.";
|
||||||
|
|
||||||
currentStep = "qualityReview";
|
currentStep = "qualityReview";
|
||||||
|
await updateRootAuditProgress(ctx, args.rootRunId, currentStep);
|
||||||
try {
|
try {
|
||||||
const qualityResult = await generateObject({
|
let finalQualityReview: QualityReview | null = null;
|
||||||
model: provider(qualityReviewProfile.modelId),
|
let qualityFinishReason: string | undefined;
|
||||||
system: qualitySystemPrompt,
|
let rewriteApplied = false;
|
||||||
schema: qualityReviewSchema,
|
let copyReviewAttempts = 0;
|
||||||
prompt: safeQualityPrompt ?? "",
|
const qualityReviewUsages: Array<OpenRouterUsage | undefined> = [];
|
||||||
temperature: qualityReviewProfile.temperature,
|
|
||||||
maxOutputTokens: qualityReviewProfile.maxTokens,
|
|
||||||
});
|
|
||||||
|
|
||||||
qualityPassed = qualityResult.object.isValid && guardResult.passed;
|
while (copyReviewAttempts < 2) {
|
||||||
|
copyReviewAttempts += 1;
|
||||||
|
const qualityResult = await generateObject({
|
||||||
|
model: provider(qualityReviewProfile.modelId),
|
||||||
|
system: qualitySystemPrompt,
|
||||||
|
schema: qualityReviewSchema,
|
||||||
|
prompt: safeQualityPrompt ?? "",
|
||||||
|
temperature: qualityReviewProfile.temperature,
|
||||||
|
maxOutputTokens: qualityReviewProfile.maxTokens,
|
||||||
|
});
|
||||||
|
|
||||||
|
finalQualityReview = qualityResult.object;
|
||||||
|
qualityFinishReason = qualityResult.finishReason;
|
||||||
|
qualityReviewUsages.push(qualityResult.usage);
|
||||||
|
|
||||||
|
if (
|
||||||
|
copyReviewAttempts === 1 &&
|
||||||
|
qualityResult.object.rewriteRequired &&
|
||||||
|
qualityResult.object.revisedCopy
|
||||||
|
) {
|
||||||
|
germanCopyOutput = applyRevisedCopy(
|
||||||
|
germanCopyOutput,
|
||||||
|
qualityResult.object.revisedCopy,
|
||||||
|
);
|
||||||
|
rewriteApplied = true;
|
||||||
|
await appendRunEvent(ctx, {
|
||||||
|
runId: args.runId,
|
||||||
|
level: "warning",
|
||||||
|
message: "Copy-Review hat korrigiert.",
|
||||||
|
details: qualityResult.object.issues.slice(0, 4).map((issue) => ({
|
||||||
|
label: "Hinweis",
|
||||||
|
value: issue,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
qualityPrompt = buildQualityReviewPrompt(
|
||||||
|
verifiedFindingsText,
|
||||||
|
germanCopyOutput,
|
||||||
|
);
|
||||||
|
safeQualityPrompt = sanitizeAndCapString(
|
||||||
|
qualityPrompt,
|
||||||
|
MAX_PROMPT_BYTES,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalQualityReview) {
|
||||||
|
throw new Error("Copy-Review konnte nicht ausgewertet werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
qualityPassed =
|
||||||
|
finalQualityReview.isValid && finalQualityReview.severity === "ok";
|
||||||
|
|
||||||
const qualityPayload = {
|
const qualityPayload = {
|
||||||
isValid: qualityResult.object.isValid && guardResult.passed,
|
...finalQualityReview,
|
||||||
issues: [
|
rewriteApplied,
|
||||||
...qualityResult.object.issues,
|
reviewAttempts: copyReviewAttempts,
|
||||||
...guardResult.issues.map(
|
deterministicGuard,
|
||||||
(issue) => `${issue.field}: ${issue.message}`,
|
finalDecision: qualityPassed ? "approved" : "stored_with_warnings",
|
||||||
),
|
|
||||||
],
|
|
||||||
suggestions: qualityResult.object.suggestions,
|
|
||||||
notes: qualityResult.object.notes ?? [],
|
|
||||||
};
|
};
|
||||||
const qualityErrorSummary =
|
|
||||||
"Qualitätsprüfung hat Inhalte als ungenügend markiert.";
|
|
||||||
|
|
||||||
await persistAuditStage({
|
await persistAuditStage({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -2067,43 +2189,26 @@ export const processAuditGeneration = internalAction({
|
|||||||
MAX_RAW_RESPONSE_BYTES,
|
MAX_RAW_RESPONSE_BYTES,
|
||||||
),
|
),
|
||||||
parsedJson: sanitizeAndCapParsedJson(qualityPayload),
|
parsedJson: sanitizeAndCapParsedJson(qualityPayload),
|
||||||
...withStageUsage(qualityResult.usage),
|
...withStageUsage(aggregateOpenRouterUsage(qualityReviewUsages)),
|
||||||
status: qualityPassed ? "succeeded" : "failed",
|
status: "succeeded",
|
||||||
finishReason: qualityResult.finishReason,
|
...(qualityFinishReason ? { finishReason: qualityFinishReason } : {}),
|
||||||
...(!qualityPassed ? { errorSummary: qualityErrorSummary } : {}),
|
|
||||||
});
|
});
|
||||||
await recordOpenRouterUsage(ctx, {
|
await recordOpenRouterUsage(ctx, {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
leadId: started.lead._id,
|
leadId: started.lead._id,
|
||||||
...(auditId ? { auditId } : {}),
|
...(auditId ? { auditId } : {}),
|
||||||
usage: qualityResult.usage,
|
usage: aggregateOpenRouterUsage(qualityReviewUsages),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!qualityPassed) {
|
if (!qualityPassed || !guardResult.passed) {
|
||||||
const message =
|
|
||||||
"Qualitätsprüfung und German-Copy-Guard haben nicht bestanden.";
|
|
||||||
await appendRunEvent(ctx, {
|
await appendRunEvent(ctx, {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
level: "warning",
|
level: "warning",
|
||||||
message,
|
message: "Copy-Review mit Hinweisen abgeschlossen.",
|
||||||
});
|
details: [
|
||||||
await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, {
|
...finalQualityReview.issues,
|
||||||
runId: args.runId,
|
...guardResult.issues.map((issue) => issue.message),
|
||||||
status: "failed",
|
].slice(0, 4).map((issue) => ({
|
||||||
currentStep: "qualityReview",
|
|
||||||
errors: errors + 1,
|
|
||||||
errorSummary: message,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!qualityResult.object.isValid) {
|
|
||||||
await appendRunEvent(ctx, {
|
|
||||||
runId: args.runId,
|
|
||||||
level: "warning",
|
|
||||||
message:
|
|
||||||
"Qualitätsprüfung hat Review-Hinweise gemeldet; German-Copy-Guard bestanden.",
|
|
||||||
details: qualityResult.object.issues.slice(0, 4).map((issue) => ({
|
|
||||||
label: "Hinweis",
|
label: "Hinweis",
|
||||||
value: issue,
|
value: issue,
|
||||||
})),
|
})),
|
||||||
@@ -2288,3 +2393,25 @@ export const processAuditGeneration = internalAction({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const processAuditGenerationForWorkflow = internalAction({
|
||||||
|
args: {
|
||||||
|
runId: v.id("agentRuns"),
|
||||||
|
rootRunId: v.id("agentRuns"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args): Promise<Id<"agentRuns">> => {
|
||||||
|
const result = await ctx.runAction(
|
||||||
|
internal.auditGenerationAction.processAuditGeneration,
|
||||||
|
{
|
||||||
|
runId: args.runId,
|
||||||
|
rootRunId: args.rootRunId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("Audit-Generierung konnte nicht abgeschlossen werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return args.runId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
113
convex/audits.ts
113
convex/audits.ts
@@ -1,9 +1,11 @@
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
import { normalizeListLimit } from "./domain";
|
import { normalizeListLimit } from "./domain";
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
import { internalMutation, mutation, query } from "./_generated/server";
|
import { internalMutation, mutation, query } from "./_generated/server";
|
||||||
import type { Doc, Id } from "./_generated/dataModel";
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
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;
|
export const AUDIT_REVIEW_NOTICE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const DETAIL_EVIDENCE_LIMIT = 50;
|
const DETAIL_EVIDENCE_LIMIT = 50;
|
||||||
@@ -63,12 +65,27 @@ type AuditDashboardRow =
|
|||||||
id: Id<"agentRuns">;
|
id: Id<"agentRuns">;
|
||||||
runId: Id<"agentRuns">;
|
runId: Id<"agentRuns">;
|
||||||
leadId: Id<"leads"> | null;
|
leadId: Id<"leads"> | null;
|
||||||
|
runType: Doc<"agentRuns">["type"];
|
||||||
title: string;
|
title: string;
|
||||||
checkedDomain: string;
|
checkedDomain: string;
|
||||||
status: Doc<"agentRuns">["status"];
|
status: Doc<"agentRuns">["status"];
|
||||||
latestStage: string;
|
latestStage: string;
|
||||||
stageStatus: Doc<"agentRuns">["status"];
|
stageStatus: Doc<"agentRuns">["status"];
|
||||||
errorSummary: string | null;
|
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;
|
pageCount: number;
|
||||||
checkedPages: string[];
|
checkedPages: string[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
@@ -104,6 +121,38 @@ const latestGenerationStage = (stages: Doc<"auditGenerations">[]) => {
|
|||||||
return [...stages].sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
|
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 normalizeComparableAuditUrl = (value: string | null | undefined) => {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -727,6 +776,31 @@ export const list = query({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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({
|
export const listDashboardRows = query({
|
||||||
args: {
|
args: {
|
||||||
limit: v.optional(v.number()),
|
limit: v.optional(v.number()),
|
||||||
@@ -771,29 +845,50 @@ export const listDashboardRows = query({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
const generationRuns = await ctx.db
|
||||||
.query("agentRuns")
|
.query("agentRuns")
|
||||||
.withIndex("by_type", (q) => q.eq("type", "audit_generation"))
|
.withIndex("by_type", (q) => q.eq("type", "audit_generation"))
|
||||||
.order("desc")
|
.order("desc")
|
||||||
.take(limit);
|
.take(limit);
|
||||||
|
|
||||||
for (const run of generationRuns) {
|
for (const run of [...rootAuditRuns, ...generationRuns]) {
|
||||||
if (!run.leadId) {
|
if (!run.leadId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
run.type === "audit_generation" &&
|
||||||
|
rootAuditRunLeadIds.has(run.leadId)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const directFinalAudit = run.auditId ? await ctx.db.get(run.auditId) : null;
|
const directFinalAudit = run.auditId ? await ctx.db.get(run.auditId) : null;
|
||||||
const leadFinalAudits = await ctx.db
|
const leadFinalAudits = await ctx.db
|
||||||
.query("audits")
|
.query("audits")
|
||||||
.withIndex("by_leadId", (q) => q.eq("leadId", run.leadId as Id<"leads">))
|
.withIndex("by_leadId", (q) => q.eq("leadId", run.leadId as Id<"leads">))
|
||||||
.take(1);
|
.take(1);
|
||||||
|
|
||||||
|
const shouldHideBehindFinalAudit =
|
||||||
|
run.status === "succeeded" || run.type === "audit_generation";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
finalAuditRunIds.has(run._id) ||
|
(shouldHideBehindFinalAudit && finalAuditRunIds.has(run._id)) ||
|
||||||
(run.auditId && finalAuditIds.has(run.auditId)) ||
|
(shouldHideBehindFinalAudit && run.auditId && finalAuditIds.has(run.auditId)) ||
|
||||||
directFinalAudit ||
|
(shouldHideBehindFinalAudit && directFinalAudit) ||
|
||||||
finalAuditLeadIds.has(run.leadId) ||
|
(shouldHideBehindFinalAudit && finalAuditLeadIds.has(run.leadId)) ||
|
||||||
leadFinalAudits.length > 0
|
(shouldHideBehindFinalAudit && leadFinalAudits.length > 0)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -806,18 +901,24 @@ export const listDashboardRows = query({
|
|||||||
const latestStage = latestGenerationStage(stages);
|
const latestStage = latestGenerationStage(stages);
|
||||||
const lead = await ctx.db.get(run.leadId);
|
const lead = await ctx.db.get(run.leadId);
|
||||||
const checkedDomain = domainFromLead(lead);
|
const checkedDomain = domainFromLead(lead);
|
||||||
|
const progress = progressForRun(run, latestStage);
|
||||||
|
const retry = retryForRun(run);
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
kind: "generation",
|
kind: "generation",
|
||||||
id: run._id,
|
id: run._id,
|
||||||
runId: run._id,
|
runId: run._id,
|
||||||
leadId: run.leadId,
|
leadId: run.leadId,
|
||||||
|
runType: run.type,
|
||||||
title: lead?.companyName ?? checkedDomain,
|
title: lead?.companyName ?? checkedDomain,
|
||||||
checkedDomain,
|
checkedDomain,
|
||||||
status: run.status,
|
status: run.status,
|
||||||
latestStage: latestStage?.stage ?? run.currentStep ?? "audit_generation",
|
latestStage: latestStage?.stage ?? run.currentStep ?? "audit_generation",
|
||||||
stageStatus: latestStage?.status ?? run.status,
|
stageStatus: latestStage?.status ?? run.status,
|
||||||
errorSummary: run.errorSummary ?? latestStage?.errorSummary ?? null,
|
errorSummary: run.errorSummary ?? latestStage?.errorSummary ?? null,
|
||||||
|
progress,
|
||||||
|
retry,
|
||||||
|
canRetry: retry.canRetry,
|
||||||
pageCount: 0,
|
pageCount: 0,
|
||||||
checkedPages: [],
|
checkedPages: [],
|
||||||
createdAt: run.createdAt,
|
createdAt: run.createdAt,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const blacklistType = v.union(
|
|||||||
v.literal("phone"),
|
v.literal("phone"),
|
||||||
v.literal("company"),
|
v.literal("company"),
|
||||||
v.literal("google_place_id"),
|
v.literal("google_place_id"),
|
||||||
|
v.literal("source_business_id"),
|
||||||
);
|
);
|
||||||
|
|
||||||
type BlacklistType =
|
type BlacklistType =
|
||||||
@@ -24,7 +25,8 @@ type BlacklistType =
|
|||||||
| "email"
|
| "email"
|
||||||
| "phone"
|
| "phone"
|
||||||
| "company"
|
| "company"
|
||||||
| "google_place_id";
|
| "google_place_id"
|
||||||
|
| "source_business_id";
|
||||||
|
|
||||||
const BLACKLIST_APPLY_BATCH_SIZE = 100;
|
const BLACKLIST_APPLY_BATCH_SIZE = 100;
|
||||||
const BLACKLIST_REVIEW_NOTE_PREFIX =
|
const BLACKLIST_REVIEW_NOTE_PREFIX =
|
||||||
@@ -51,6 +53,7 @@ type LeadMatchingFieldsPatch = Partial<
|
|||||||
| "normalizedCompanyName"
|
| "normalizedCompanyName"
|
||||||
| "normalizedAddress"
|
| "normalizedAddress"
|
||||||
| "normalizedGooglePlaceId"
|
| "normalizedGooglePlaceId"
|
||||||
|
| "normalizedSourceBusinessId"
|
||||||
>
|
>
|
||||||
> & {
|
> & {
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -138,6 +141,13 @@ function getLeadMatchQuery(
|
|||||||
.withIndex("by_normalizedGooglePlaceId", (q) =>
|
.withIndex("by_normalizedGooglePlaceId", (q) =>
|
||||||
q.eq("normalizedGooglePlaceId", normalizedValue),
|
q.eq("normalizedGooglePlaceId", normalizedValue),
|
||||||
);
|
);
|
||||||
|
case "source_business_id":
|
||||||
|
return () =>
|
||||||
|
ctx.db
|
||||||
|
.query("leads")
|
||||||
|
.withIndex("by_normalizedSourceBusinessId", (q) =>
|
||||||
|
q.eq("normalizedSourceBusinessId", normalizedValue),
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -152,6 +162,9 @@ function buildLeadMatchingFieldsPatch(lead: Doc<"leads">) {
|
|||||||
const normalizedCompanyName = normalizeText(lead.companyName);
|
const normalizedCompanyName = normalizeText(lead.companyName);
|
||||||
const normalizedAddress = normalizeText(lead.address);
|
const normalizedAddress = normalizeText(lead.address);
|
||||||
const normalizedGooglePlaceId = normalizeDomain(lead.googlePlaceId);
|
const normalizedGooglePlaceId = normalizeDomain(lead.googlePlaceId);
|
||||||
|
const normalizedSourceBusinessId = normalizeDomain(
|
||||||
|
lead.sourceBusinessId ?? lead.googlePlaceId,
|
||||||
|
);
|
||||||
|
|
||||||
if (!lead.normalizedEmail && normalizedEmail) {
|
if (!lead.normalizedEmail && normalizedEmail) {
|
||||||
patch.normalizedEmail = normalizedEmail;
|
patch.normalizedEmail = normalizedEmail;
|
||||||
@@ -168,6 +181,9 @@ function buildLeadMatchingFieldsPatch(lead: Doc<"leads">) {
|
|||||||
if (!lead.normalizedGooglePlaceId && normalizedGooglePlaceId) {
|
if (!lead.normalizedGooglePlaceId && normalizedGooglePlaceId) {
|
||||||
patch.normalizedGooglePlaceId = normalizedGooglePlaceId;
|
patch.normalizedGooglePlaceId = normalizedGooglePlaceId;
|
||||||
}
|
}
|
||||||
|
if (!lead.normalizedSourceBusinessId && normalizedSourceBusinessId) {
|
||||||
|
patch.normalizedSourceBusinessId = normalizedSourceBusinessId;
|
||||||
|
}
|
||||||
|
|
||||||
return Object.keys(patch).length > 1 ? patch : null;
|
return Object.keys(patch).length > 1 ? patch : null;
|
||||||
}
|
}
|
||||||
@@ -200,6 +216,7 @@ function normalizeBlacklistValue(type: BlacklistType, value: string) {
|
|||||||
return normalizePhone(trimmed);
|
return normalizePhone(trimmed);
|
||||||
case "domain":
|
case "domain":
|
||||||
case "google_place_id":
|
case "google_place_id":
|
||||||
|
case "source_business_id":
|
||||||
return normalizeDomain(trimmed);
|
return normalizeDomain(trimmed);
|
||||||
case "company":
|
case "company":
|
||||||
return normalizeText(trimmed);
|
return normalizeText(trimmed);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { defineApp } from "convex/server";
|
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";
|
import betterAuth from "./betterAuth/convex.config";
|
||||||
|
|
||||||
const app = defineApp();
|
const app = defineApp();
|
||||||
|
|
||||||
app.use(betterAuth);
|
app.use(betterAuth);
|
||||||
|
app.use(workflow);
|
||||||
|
app.use(auditWorkpool, { name: "auditWorkpool" });
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const SECRET_KEY_PATTERNS = [
|
|||||||
/credential/i,
|
/credential/i,
|
||||||
/smtp/i,
|
/smtp/i,
|
||||||
/openrouter/i,
|
/openrouter/i,
|
||||||
|
/rapidapi/i,
|
||||||
|
/local[_-]?business[_-]?data/i,
|
||||||
/google[_-]?(geocoding|places)?/i,
|
/google[_-]?(geocoding|places)?/i,
|
||||||
/pagespeed/i,
|
/pagespeed/i,
|
||||||
/rybbit/i,
|
/rybbit/i,
|
||||||
@@ -77,6 +79,7 @@ export const BLACKLIST_TYPES = [
|
|||||||
"phone",
|
"phone",
|
||||||
"company",
|
"company",
|
||||||
"google_place_id",
|
"google_place_id",
|
||||||
|
"source_business_id",
|
||||||
] as const;
|
] as const;
|
||||||
export const RUN_TYPES = [
|
export const RUN_TYPES = [
|
||||||
"campaign",
|
"campaign",
|
||||||
@@ -131,6 +134,7 @@ export const USAGE_EVENT_PROVIDERS = [
|
|||||||
"jina",
|
"jina",
|
||||||
"pagespeed",
|
"pagespeed",
|
||||||
"google_places",
|
"google_places",
|
||||||
|
"local_business_data",
|
||||||
] as const;
|
] as const;
|
||||||
export const USAGE_EVENT_OPERATIONS = [
|
export const USAGE_EVENT_OPERATIONS = [
|
||||||
"audit_capture",
|
"audit_capture",
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GOOGLE_PLACES_FIELD_MASK,
|
|
||||||
buildGeocodingUrl,
|
|
||||||
getBlacklistLookupValues,
|
getBlacklistLookupValues,
|
||||||
getBlacklistMatches,
|
getBlacklistMatches,
|
||||||
getCandidateEmailValues,
|
getCandidateEmailValues,
|
||||||
getPlacesSearchSpec,
|
getUsableContactEmail,
|
||||||
normalizeDomain,
|
normalizeDomain,
|
||||||
normalizePhone,
|
normalizePhone,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
normalizePlacesResponse,
|
|
||||||
parseGeocodingResponse,
|
|
||||||
} from "../lib/lead-discovery-google";
|
} from "../lib/lead-discovery-google";
|
||||||
|
import {
|
||||||
|
LOCAL_BUSINESS_DATA_HOST,
|
||||||
|
getLocalBusinessSearchSpec,
|
||||||
|
normalizeLocalBusinessSearchResponse,
|
||||||
|
} from "../lib/lead-discovery-local-business";
|
||||||
import {
|
import {
|
||||||
buildLeadDiscoveryLeadRecord,
|
buildLeadDiscoveryLeadRecord,
|
||||||
buildLeadDiscoveryCounters,
|
buildLeadDiscoveryCounters,
|
||||||
getLeadDiscoveryPriority,
|
getLeadDiscoveryPriority,
|
||||||
shouldScheduleWebsiteEnrichment,
|
|
||||||
} from "../lib/lead-discovery-run";
|
} from "../lib/lead-discovery-run";
|
||||||
import { calculateNextRunAt } from "../lib/campaign-scheduling";
|
import { calculateNextRunAt } from "../lib/campaign-scheduling";
|
||||||
|
|
||||||
@@ -26,12 +26,21 @@ import { Doc, Id } from "./_generated/dataModel";
|
|||||||
import { internalAction, internalMutation } from "./_generated/server";
|
import { internalAction, internalMutation } from "./_generated/server";
|
||||||
|
|
||||||
type CampaignDoc = Doc<"campaigns">;
|
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 nullableString = v.union(v.string(), v.null());
|
||||||
const nullableNumber = v.union(v.number(), v.null());
|
const nullableNumber = v.union(v.number(), v.null());
|
||||||
|
|
||||||
const candidateValidator = v.object({
|
const candidateValidator = v.object({
|
||||||
placeId: v.string(),
|
placeId: v.string(),
|
||||||
|
sourceBusinessId: v.optional(nullableString),
|
||||||
businessName: v.string(),
|
businessName: v.string(),
|
||||||
address: v.string(),
|
address: v.string(),
|
||||||
websiteUrl: nullableString,
|
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(),
|
sourceFetchedAt: v.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +110,7 @@ async function fetchJson(url: string, init?: RequestInit) {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
throw new Error(
|
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 {
|
try {
|
||||||
const geocodingApiKey = getRequiredEnv("GOOGLE_GEOCODING_API_KEY");
|
const localBusinessDataApiKey = getRequiredEnv("LOCAL_BUSINESS_DATA_API_KEY");
|
||||||
const placesApiKey = getRequiredEnv("GOOGLE_PLACES_API_KEY");
|
|
||||||
const campaign = started.campaign;
|
const campaign = started.campaign;
|
||||||
const fetchedAt = Date.now();
|
const searchSpec = getLocalBusinessSearchSpec({
|
||||||
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({
|
|
||||||
categoryMode: campaign.categoryMode,
|
categoryMode: campaign.categoryMode,
|
||||||
category: campaign.category,
|
category: campaign.category,
|
||||||
customSearchTerm: campaign.customSearchTerm,
|
customSearchTerm: campaign.customSearchTerm,
|
||||||
postalCode: campaign.postalCode,
|
postalCode: campaign.postalCode,
|
||||||
radiusKm: campaign.radiusKm,
|
maxNewLeads: campaign.maxNewLeadsPerRun,
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
});
|
});
|
||||||
const placesJson = await fetchJson(
|
const localBusinessJson = await fetchJson(searchSpec.url, {
|
||||||
`https://places.googleapis.com/v1/places:${searchSpec.endpoint}`,
|
method: "GET",
|
||||||
{
|
headers: {
|
||||||
method: "POST",
|
"X-RapidAPI-Key": localBusinessDataApiKey,
|
||||||
headers: {
|
"X-RapidAPI-Host": LOCAL_BUSINESS_DATA_HOST,
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Goog-Api-Key": placesApiKey,
|
|
||||||
"X-Goog-FieldMask": GOOGLE_PLACES_FIELD_MASK,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(searchSpec.body),
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
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) {
|
if (candidates.length === 0) {
|
||||||
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
|
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
level: "warning",
|
level: "warning",
|
||||||
message: "Google Places lieferte keine Ergebnisse.",
|
message: "Local Business Data lieferte keine Ergebnisse.",
|
||||||
details: [
|
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;
|
skippedDuplicates: number;
|
||||||
skippedBlacklisted: number;
|
skippedBlacklisted: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
websiteEnrichmentQueue: Array<{
|
|
||||||
leadId: Id<"leads">;
|
|
||||||
companyName: string;
|
|
||||||
website: string;
|
|
||||||
}>;
|
|
||||||
} = await ctx.runMutation(internal.leadDiscovery.persistDiscoveredLeads, {
|
} = await ctx.runMutation(internal.leadDiscovery.persistDiscoveredLeads, {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
campaignId: campaign._id,
|
campaignId: campaign._id,
|
||||||
@@ -229,31 +201,6 @@ export const processCampaignRun = internalAction({
|
|||||||
candidates,
|
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, {
|
await ctx.runMutation(internal.leadDiscovery.finishCampaignRun, {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
status: "succeeded",
|
status: "succeeded",
|
||||||
@@ -423,11 +370,6 @@ export const persistDiscoveredLeads = internalMutation({
|
|||||||
let skippedDuplicates = 0;
|
let skippedDuplicates = 0;
|
||||||
let skippedBlacklisted = 0;
|
let skippedBlacklisted = 0;
|
||||||
let errors = 0;
|
let errors = 0;
|
||||||
const websiteEnrichmentQueue: Array<{
|
|
||||||
leadId: Id<"leads">;
|
|
||||||
companyName: string;
|
|
||||||
website: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const candidate of args.candidates) {
|
for (const candidate of args.candidates) {
|
||||||
if (leadsCreated >= args.maxNewLeads) {
|
if (leadsCreated >= args.maxNewLeads) {
|
||||||
@@ -446,7 +388,7 @@ export const persistDiscoveredLeads = internalMutation({
|
|||||||
await ctx.db.insert("agentRunEvents", {
|
await ctx.db.insert("agentRunEvents", {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
level: "warning",
|
level: "warning",
|
||||||
message: "Google-Places-Ergebnis ohne Unternehmensname übersprungen.",
|
message: "Lead-Recherche-Ergebnis ohne Unternehmensname übersprungen.",
|
||||||
details: [{ label: "Place ID", value: candidate.placeId }],
|
details: [{ label: "Place ID", value: candidate.placeId }],
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
@@ -454,6 +396,9 @@ export const persistDiscoveredLeads = internalMutation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedPlaceId = normalizeDomain(candidate.placeId);
|
const normalizedPlaceId = normalizeDomain(candidate.placeId);
|
||||||
|
const normalizedSourceBusinessId = normalizeDomain(
|
||||||
|
candidate.sourceBusinessId ?? candidate.placeId,
|
||||||
|
);
|
||||||
const normalizedDomain = normalizeDomain(candidate.websiteDomain);
|
const normalizedDomain = normalizeDomain(candidate.websiteDomain);
|
||||||
const normalizedEmails = getCandidateEmailValues(candidate);
|
const normalizedEmails = getCandidateEmailValues(candidate);
|
||||||
const normalizedPhone = normalizePhone(candidate.phone);
|
const normalizedPhone = normalizePhone(candidate.phone);
|
||||||
@@ -476,6 +421,15 @@ export const persistDiscoveredLeads = internalMutation({
|
|||||||
.take(1)
|
.take(1)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const duplicateBySourceBusinessId = normalizedSourceBusinessId
|
||||||
|
? await ctx.db
|
||||||
|
.query("leads")
|
||||||
|
.withIndex("by_normalizedSourceBusinessId", (q) =>
|
||||||
|
q.eq("normalizedSourceBusinessId", normalizedSourceBusinessId),
|
||||||
|
)
|
||||||
|
.take(1)
|
||||||
|
: [];
|
||||||
|
|
||||||
const duplicateByEmailRows = [];
|
const duplicateByEmailRows = [];
|
||||||
for (const email of normalizedEmails) {
|
for (const email of normalizedEmails) {
|
||||||
const rows = await ctx.db
|
const rows = await ctx.db
|
||||||
@@ -487,17 +441,84 @@ export const persistDiscoveredLeads = internalMutation({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
duplicateByPlaceId.length > 0 ||
|
duplicateByPlaceId.length > 0 ||
|
||||||
|
duplicateBySourceBusinessId.length > 0 ||
|
||||||
duplicateByDomain.length > 0 ||
|
duplicateByDomain.length > 0 ||
|
||||||
duplicateByEmailRows.length > 0
|
duplicateByEmailRows.length > 0
|
||||||
) {
|
) {
|
||||||
skippedDuplicates += 1;
|
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", {
|
await ctx.db.insert("agentRunEvents", {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
level: "info",
|
level: "info",
|
||||||
message: "Doppelter Lead übersprungen.",
|
message: "Doppelter Lead übersprungen.",
|
||||||
details: [
|
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(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
@@ -569,13 +590,16 @@ export const persistDiscoveredLeads = internalMutation({
|
|||||||
const priorityResult = getLeadDiscoveryPriority({
|
const priorityResult = getLeadDiscoveryPriority({
|
||||||
isDuplicate: !!probableDuplicateLead,
|
isDuplicate: !!probableDuplicateLead,
|
||||||
hasWebsite,
|
hasWebsite,
|
||||||
hasWebsiteSignal: false, // plain Google-Places website hint maps to medium priority.
|
hasWebsiteSignal: false,
|
||||||
});
|
});
|
||||||
const isDuplicateCandidate = !!probableDuplicateLead;
|
const isDuplicateCandidate = !!probableDuplicateLead;
|
||||||
|
|
||||||
if (normalizedPlaceId) {
|
if (normalizedPlaceId) {
|
||||||
lead.normalizedGooglePlaceId = normalizedPlaceId;
|
lead.normalizedGooglePlaceId = normalizedPlaceId;
|
||||||
}
|
}
|
||||||
|
if (normalizedSourceBusinessId) {
|
||||||
|
lead.normalizedSourceBusinessId = normalizedSourceBusinessId;
|
||||||
|
}
|
||||||
if (normalizedPhone !== "") {
|
if (normalizedPhone !== "") {
|
||||||
lead.normalizedPhone = normalizedPhone;
|
lead.normalizedPhone = normalizedPhone;
|
||||||
}
|
}
|
||||||
@@ -596,20 +620,21 @@ export const persistDiscoveredLeads = internalMutation({
|
|||||||
|
|
||||||
const leadId = await ctx.db.insert("leads", lead);
|
const leadId = await ctx.db.insert("leads", lead);
|
||||||
leadsCreated += 1;
|
leadsCreated += 1;
|
||||||
if (shouldScheduleWebsiteEnrichment(lead)) {
|
|
||||||
websiteEnrichmentQueue.push({
|
|
||||||
leadId,
|
|
||||||
companyName: lead.companyName,
|
|
||||||
website: lead.websiteDomain ?? lead.websiteUrl ?? "unbekannt",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await ctx.db.insert("agentRunEvents", {
|
await ctx.db.insert("agentRunEvents", {
|
||||||
runId: args.runId,
|
runId: args.runId,
|
||||||
level: "info",
|
level: "info",
|
||||||
message: "Lead aus Google Places gespeichert.",
|
message: "Lead aus Local Business Data gespeichert.",
|
||||||
details: [
|
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(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
@@ -634,7 +659,6 @@ export const persistDiscoveredLeads = internalMutation({
|
|||||||
skippedDuplicates,
|
skippedDuplicates,
|
||||||
skippedBlacklisted,
|
skippedBlacklisted,
|
||||||
errors,
|
errors,
|
||||||
websiteEnrichmentQueue,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -263,7 +263,10 @@ export const create = mutation({
|
|||||||
googleRating: v.optional(v.number()),
|
googleRating: v.optional(v.number()),
|
||||||
googleUserRatingCount: v.optional(v.number()),
|
googleUserRatingCount: v.optional(v.number()),
|
||||||
googleBusinessStatus: v.optional(v.string()),
|
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")),
|
||||||
|
),
|
||||||
|
sourceBusinessId: v.optional(v.string()),
|
||||||
sourceFetchedAt: v.optional(v.number()),
|
sourceFetchedAt: v.optional(v.number()),
|
||||||
websiteUrl: v.optional(v.string()),
|
websiteUrl: v.optional(v.string()),
|
||||||
websiteDomain: v.optional(v.string()),
|
websiteDomain: v.optional(v.string()),
|
||||||
@@ -285,6 +288,7 @@ export const create = mutation({
|
|||||||
duplicateOfLeadId: v.optional(v.id("leads")),
|
duplicateOfLeadId: v.optional(v.id("leads")),
|
||||||
blacklistStatus: v.optional(leadBlacklistStatus),
|
blacklistStatus: v.optional(leadBlacklistStatus),
|
||||||
normalizedGooglePlaceId: v.optional(v.string()),
|
normalizedGooglePlaceId: v.optional(v.string()),
|
||||||
|
normalizedSourceBusinessId: v.optional(v.string()),
|
||||||
notes: v.optional(v.string()),
|
notes: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -298,6 +302,7 @@ export const create = mutation({
|
|||||||
normalizedCompanyName: args.normalizedCompanyName,
|
normalizedCompanyName: args.normalizedCompanyName,
|
||||||
normalizedAddress: args.normalizedAddress,
|
normalizedAddress: args.normalizedAddress,
|
||||||
normalizedGooglePlaceId: args.normalizedGooglePlaceId,
|
normalizedGooglePlaceId: args.normalizedGooglePlaceId,
|
||||||
|
normalizedSourceBusinessId: args.normalizedSourceBusinessId,
|
||||||
priority: args.priority ?? "medium",
|
priority: args.priority ?? "medium",
|
||||||
contactStatus: args.contactStatus ?? "new",
|
contactStatus: args.contactStatus ?? "new",
|
||||||
duplicateStatus: args.duplicateStatus ?? "unchecked",
|
duplicateStatus: args.duplicateStatus ?? "unchecked",
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { internal } from "./_generated/api";
|
import { internal } from "./_generated/api";
|
||||||
import type { Doc, Id } from "./_generated/dataModel";
|
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";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
const PAGE_SPEED_COUNTER_TEMPLATE = {
|
const PAGE_SPEED_COUNTER_TEMPLATE = {
|
||||||
@@ -17,6 +22,13 @@ type PageSpeedLead = Pick<
|
|||||||
> & {
|
> & {
|
||||||
websiteUrl: string;
|
websiteUrl: string;
|
||||||
};
|
};
|
||||||
|
type AuditStartState = {
|
||||||
|
leadId: Id<"leads">;
|
||||||
|
canStart: boolean;
|
||||||
|
reason?: string;
|
||||||
|
activeRunId?: Id<"agentRuns">;
|
||||||
|
activeRunStatus?: Doc<"agentRuns">["status"];
|
||||||
|
};
|
||||||
|
|
||||||
const runStatus = v.union(
|
const runStatus = v.union(
|
||||||
v.literal("pending"),
|
v.literal("pending"),
|
||||||
@@ -39,6 +51,238 @@ const pageSpeedErrorType = v.union(
|
|||||||
v.literal("unknown"),
|
v.literal("unknown"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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", 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", leadId),
|
||||||
|
)
|
||||||
|
.take(1);
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert("agentRunEvents", {
|
||||||
|
runId,
|
||||||
|
level: "info",
|
||||||
|
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 }] : []),
|
||||||
|
],
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.scheduler.runAfter(
|
||||||
|
0,
|
||||||
|
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({
|
export const queueLeadPageSpeedAudit = internalMutation({
|
||||||
args: {
|
args: {
|
||||||
leadId: v.id("leads"),
|
leadId: v.id("leads"),
|
||||||
@@ -46,68 +290,11 @@ export const queueLeadPageSpeedAudit = internalMutation({
|
|||||||
},
|
},
|
||||||
returns: v.union(v.id("agentRuns"), v.null()),
|
returns: v.union(v.id("agentRuns"), v.null()),
|
||||||
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
|
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
|
||||||
const now = Date.now();
|
return await queueLeadPageSpeedAuditForLead(ctx, {
|
||||||
const lead = await ctx.db.get(args.leadId);
|
|
||||||
|
|
||||||
if (!lead || lead.priority === "blocked" || lead.priority === "defer") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lead.websiteUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
.take(1);
|
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
.take(1);
|
|
||||||
|
|
||||||
if (existingPending.length > 0) {
|
|
||||||
return existingPending[0]._id;
|
|
||||||
}
|
|
||||||
if (existingRunning.length > 0) {
|
|
||||||
return existingRunning[0]._id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runId = await ctx.db.insert("agentRuns", {
|
|
||||||
type: "audit",
|
|
||||||
leadId: args.leadId,
|
leadId: args.leadId,
|
||||||
status: "pending",
|
parentRunId: args.parentRunId,
|
||||||
currentStep: "pagespeed_insights",
|
triggeredBy: "internal",
|
||||||
counters: PAGE_SPEED_COUNTER_TEMPLATE,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.db.insert("agentRunEvents", {
|
|
||||||
runId,
|
|
||||||
level: "info",
|
|
||||||
message: "PageSpeed-Analyse wurde in die Warteschlange gesetzt.",
|
|
||||||
details: [
|
|
||||||
{ label: "Lead", value: args.leadId },
|
|
||||||
...(args.parentRunId ? [{ label: "Parent-Run", value: args.parentRunId }] : []),
|
|
||||||
],
|
|
||||||
createdAt: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
await ctx.scheduler.runAfter(
|
|
||||||
0,
|
|
||||||
internal.pageSpeedAction.processPageSpeedAudit,
|
|
||||||
{
|
|
||||||
runId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return runId;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,7 +335,11 @@ export const startPageSpeedAuditRun = internalMutation({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (run.status !== "pending") {
|
if (
|
||||||
|
run.status !== "pending" &&
|
||||||
|
run.status !== "failed" &&
|
||||||
|
run.status !== "running"
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +411,7 @@ export const startPageSpeedAuditRun = internalMutation({
|
|||||||
status: "running",
|
status: "running",
|
||||||
currentStep: "pagespeed_insights",
|
currentStep: "pagespeed_insights",
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
|
finishedAt: undefined,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
errorSummary: undefined,
|
errorSummary: undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ async function queueAuditGenerationAfterPageSpeed(
|
|||||||
export const processPageSpeedAudit = internalAction({
|
export const processPageSpeedAudit = internalAction({
|
||||||
args: {
|
args: {
|
||||||
runId: v.id("agentRuns"),
|
runId: v.id("agentRuns"),
|
||||||
|
queueGeneration: v.optional(v.boolean()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const apiKeyRaw = process.env.PAGESPEED_API_KEY?.trim();
|
const apiKeyRaw = process.env.PAGESPEED_API_KEY?.trim();
|
||||||
@@ -185,19 +186,82 @@ export const processPageSpeedAudit = internalAction({
|
|||||||
let succeededStrategies = 0;
|
let succeededStrategies = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const strategy of STRATEGIES) {
|
const strategyResults = await Promise.all(
|
||||||
const fetchedAt = Date.now();
|
STRATEGIES.map(async (strategy) => {
|
||||||
try {
|
const fetchedAt = Date.now();
|
||||||
const raw = await fetchPageSpeedResult({
|
try {
|
||||||
url: sourceUrl,
|
const raw = await fetchPageSpeedResult({
|
||||||
strategy,
|
url: sourceUrl,
|
||||||
apiKey,
|
strategy,
|
||||||
timeoutMs,
|
apiKey,
|
||||||
});
|
timeoutMs,
|
||||||
const rawJson = JSON.stringify(raw) ?? "null";
|
});
|
||||||
const rawJsonBytes = new TextEncoder().encode(rawJson).byteLength;
|
const rawJson = JSON.stringify(raw) ?? "null";
|
||||||
if (rawJsonBytes > MAX_RAW_PAGESPEED_BYTES) {
|
const rawJsonBytes = new TextEncoder().encode(rawJson).byteLength;
|
||||||
failedStrategies += 1;
|
if (rawJsonBytes > MAX_RAW_PAGESPEED_BYTES) {
|
||||||
|
await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, {
|
||||||
|
leadId: started.lead._id,
|
||||||
|
...(started.auditId ? { auditId: started.auditId } : {}),
|
||||||
|
runId: args.runId,
|
||||||
|
strategy,
|
||||||
|
status: "failed",
|
||||||
|
sourceUrl,
|
||||||
|
errorType: "api_error",
|
||||||
|
errorSummary: RAW_PAGESPEED_BYTES_SUMMARY,
|
||||||
|
fetchedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
|
runId: args.runId,
|
||||||
|
level: "warning",
|
||||||
|
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
|
||||||
|
details: [
|
||||||
|
{ label: "Strategie", value: strategy },
|
||||||
|
{
|
||||||
|
label: "Fehler",
|
||||||
|
value: RAW_PAGESPEED_BYTES_SUMMARY,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return "failed" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawStorageId = await ctx.storage.store(
|
||||||
|
new Blob([rawJson], { type: "application/json" }),
|
||||||
|
);
|
||||||
|
const normalized = normalizePageSpeedResult({
|
||||||
|
strategy,
|
||||||
|
sourceUrl,
|
||||||
|
raw,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, {
|
||||||
|
leadId: started.lead._id,
|
||||||
|
...(started.auditId ? { auditId: started.auditId } : {}),
|
||||||
|
runId: args.runId,
|
||||||
|
strategy,
|
||||||
|
status: "succeeded",
|
||||||
|
sourceUrl,
|
||||||
|
finalUrl: normalized.finalUrl,
|
||||||
|
rawStorageId,
|
||||||
|
fetchedAt,
|
||||||
|
normalized: toPersistedPageSpeedNormalizedResult(normalized),
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
||||||
|
runId: args.runId,
|
||||||
|
level: "info",
|
||||||
|
message: `PageSpeed-Analyse für ${strategy} abgeschlossen.`,
|
||||||
|
details: [{ label: "Strategie", value: strategy }],
|
||||||
|
});
|
||||||
|
return "succeeded" as const;
|
||||||
|
} catch (error) {
|
||||||
|
const { errorType, errorSummary } = classifyPageSpeedFailure(
|
||||||
|
error,
|
||||||
|
apiKeyRaw,
|
||||||
|
);
|
||||||
|
|
||||||
await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, {
|
await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, {
|
||||||
leadId: started.lead._id,
|
leadId: started.lead._id,
|
||||||
...(started.auditId ? { auditId: started.auditId } : {}),
|
...(started.auditId ? { auditId: started.auditId } : {}),
|
||||||
@@ -205,8 +269,8 @@ export const processPageSpeedAudit = internalAction({
|
|||||||
strategy,
|
strategy,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
errorType: "api_error",
|
errorType,
|
||||||
errorSummary: RAW_PAGESPEED_BYTES_SUMMARY,
|
errorSummary,
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,75 +280,18 @@ export const processPageSpeedAudit = internalAction({
|
|||||||
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
|
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
|
||||||
details: [
|
details: [
|
||||||
{ label: "Strategie", value: strategy },
|
{ label: "Strategie", value: strategy },
|
||||||
{
|
{ label: "Fehler", value: errorSummary },
|
||||||
label: "Fehler",
|
|
||||||
value: RAW_PAGESPEED_BYTES_SUMMARY,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
return "failed" as const;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const rawStorageId = await ctx.storage.store(
|
succeededStrategies = strategyResults.filter(
|
||||||
new Blob([rawJson], { type: "application/json" }),
|
(result) => result === "succeeded",
|
||||||
);
|
).length;
|
||||||
const normalized = normalizePageSpeedResult({
|
failedStrategies = strategyResults.length - succeededStrategies;
|
||||||
strategy,
|
|
||||||
sourceUrl,
|
|
||||||
raw,
|
|
||||||
});
|
|
||||||
|
|
||||||
await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, {
|
|
||||||
leadId: started.lead._id,
|
|
||||||
...(started.auditId ? { auditId: started.auditId } : {}),
|
|
||||||
runId: args.runId,
|
|
||||||
strategy,
|
|
||||||
status: "succeeded",
|
|
||||||
sourceUrl,
|
|
||||||
finalUrl: normalized.finalUrl,
|
|
||||||
rawStorageId,
|
|
||||||
fetchedAt,
|
|
||||||
normalized: toPersistedPageSpeedNormalizedResult(normalized),
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
} catch (error) {
|
|
||||||
const { errorType, errorSummary } = classifyPageSpeedFailure(
|
|
||||||
error,
|
|
||||||
apiKeyRaw,
|
|
||||||
);
|
|
||||||
failedStrategies += 1;
|
|
||||||
|
|
||||||
await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, {
|
|
||||||
leadId: started.lead._id,
|
|
||||||
...(started.auditId ? { auditId: started.auditId } : {}),
|
|
||||||
runId: args.runId,
|
|
||||||
strategy,
|
|
||||||
status: "failed",
|
|
||||||
sourceUrl,
|
|
||||||
errorType,
|
|
||||||
errorSummary,
|
|
||||||
fetchedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
|
||||||
runId: args.runId,
|
|
||||||
level: "warning",
|
|
||||||
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
|
|
||||||
details: [
|
|
||||||
{ label: "Strategie", value: strategy },
|
|
||||||
{ label: "Fehler", value: errorSummary },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = succeededStrategies > 0 ? "succeeded" : "failed";
|
const status = succeededStrategies > 0 ? "succeeded" : "failed";
|
||||||
const errors = failedStrategies;
|
const errors = failedStrategies;
|
||||||
@@ -298,7 +305,9 @@ export const processPageSpeedAudit = internalAction({
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started);
|
if (args.queueGeneration !== false) {
|
||||||
|
await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started);
|
||||||
|
}
|
||||||
|
|
||||||
return args.runId;
|
return args.runId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -316,8 +325,34 @@ export const processPageSpeedAudit = internalAction({
|
|||||||
message: "PageSpeed-Analyse fehlgeschlagen.",
|
message: "PageSpeed-Analyse fehlgeschlagen.",
|
||||||
details: [{ label: "Fehler", value: errorSummary, source: "pagespeed_action" }],
|
details: [{ label: "Fehler", value: errorSummary, source: "pagespeed_action" }],
|
||||||
});
|
});
|
||||||
await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started);
|
if (args.queueGeneration !== false) {
|
||||||
|
await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started);
|
||||||
|
}
|
||||||
return null;
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
108
convex/runs.ts
108
convex/runs.ts
@@ -7,7 +7,7 @@ import {
|
|||||||
normalizeListLimit,
|
normalizeListLimit,
|
||||||
} from "./domain";
|
} from "./domain";
|
||||||
import type { Id } from "./_generated/dataModel";
|
import type { Id } from "./_generated/dataModel";
|
||||||
import { internalMutation, mutation, query } from "./_generated/server";
|
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
|
||||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||||
|
|
||||||
const runType = v.union(...RUN_TYPES.map((type) => v.literal(type)));
|
const runType = v.union(...RUN_TYPES.map((type) => v.literal(type)));
|
||||||
@@ -127,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({
|
export const list = query({
|
||||||
args: {
|
args: {
|
||||||
status: v.optional(runStatus),
|
status: v.optional(runStatus),
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ const blacklistType = v.union(
|
|||||||
v.literal("phone"),
|
v.literal("phone"),
|
||||||
v.literal("company"),
|
v.literal("company"),
|
||||||
v.literal("google_place_id"),
|
v.literal("google_place_id"),
|
||||||
|
v.literal("source_business_id"),
|
||||||
);
|
);
|
||||||
const websiteEnrichmentPageKind = v.union(
|
const websiteEnrichmentPageKind = v.union(
|
||||||
v.literal("homepage"),
|
v.literal("homepage"),
|
||||||
@@ -250,13 +251,17 @@ export default defineSchema({
|
|||||||
postalCode: v.optional(v.string()),
|
postalCode: v.optional(v.string()),
|
||||||
googlePlaceId: v.optional(v.string()),
|
googlePlaceId: v.optional(v.string()),
|
||||||
normalizedGooglePlaceId: v.optional(v.string()),
|
normalizedGooglePlaceId: v.optional(v.string()),
|
||||||
|
sourceBusinessId: v.optional(v.string()),
|
||||||
|
normalizedSourceBusinessId: v.optional(v.string()),
|
||||||
googleMapsUrl: v.optional(v.string()),
|
googleMapsUrl: v.optional(v.string()),
|
||||||
googlePrimaryType: v.optional(v.string()),
|
googlePrimaryType: v.optional(v.string()),
|
||||||
googleTypes: v.optional(v.array(v.string())),
|
googleTypes: v.optional(v.array(v.string())),
|
||||||
googleRating: v.optional(v.number()),
|
googleRating: v.optional(v.number()),
|
||||||
googleUserRatingCount: v.optional(v.number()),
|
googleUserRatingCount: v.optional(v.number()),
|
||||||
googleBusinessStatus: v.optional(v.string()),
|
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()),
|
sourceFetchedAt: v.optional(v.number()),
|
||||||
websiteUrl: v.optional(v.string()),
|
websiteUrl: v.optional(v.string()),
|
||||||
websiteDomain: v.optional(v.string()),
|
websiteDomain: v.optional(v.string()),
|
||||||
@@ -292,6 +297,7 @@ export default defineSchema({
|
|||||||
"normalizedAddress",
|
"normalizedAddress",
|
||||||
])
|
])
|
||||||
.index("by_normalizedGooglePlaceId", ["normalizedGooglePlaceId"])
|
.index("by_normalizedGooglePlaceId", ["normalizedGooglePlaceId"])
|
||||||
|
.index("by_normalizedSourceBusinessId", ["normalizedSourceBusinessId"])
|
||||||
.index("by_googlePlaceId", ["googlePlaceId"])
|
.index("by_googlePlaceId", ["googlePlaceId"])
|
||||||
.index("by_websiteDomain", ["websiteDomain"])
|
.index("by_websiteDomain", ["websiteDomain"])
|
||||||
.index("by_normalizedCompanyName", ["normalizedCompanyName"])
|
.index("by_normalizedCompanyName", ["normalizedCompanyName"])
|
||||||
@@ -636,6 +642,14 @@ export default defineSchema({
|
|||||||
finishedAt: v.optional(v.number()),
|
finishedAt: v.optional(v.number()),
|
||||||
currentStep: v.optional(v.string()),
|
currentStep: v.optional(v.string()),
|
||||||
errorSummary: 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(
|
counters: v.optional(
|
||||||
v.object({
|
v.object({
|
||||||
leadsFound: v.number(),
|
leadsFound: v.number(),
|
||||||
|
|||||||
@@ -951,27 +951,6 @@ async function processLeadEnrichmentWithoutBrowser(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await ctx.runMutation(internal.pageSpeed.queueLeadPageSpeedAudit, {
|
|
||||||
leadId: lead._id,
|
|
||||||
parentRunId: runId,
|
|
||||||
});
|
|
||||||
} catch (pageSpeedQueueError) {
|
|
||||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
|
||||||
runId,
|
|
||||||
level: "warning",
|
|
||||||
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
|
|
||||||
details: [
|
|
||||||
{ label: "Lead", value: lead._id },
|
|
||||||
{
|
|
||||||
label: "Fehler",
|
|
||||||
value: messageFromError(pageSpeedQueueError),
|
|
||||||
source: "pagespeed_queue",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.runMutation(internal.websiteEnrichment.finishLeadEnrichmentRun, {
|
await ctx.runMutation(internal.websiteEnrichment.finishLeadEnrichmentRun, {
|
||||||
runId,
|
runId,
|
||||||
status: "succeeded",
|
status: "succeeded",
|
||||||
@@ -1012,27 +991,6 @@ export const processLeadEnrichment = internalAction({
|
|||||||
|
|
||||||
const rootUrl = normalizeCrawlUrl(started.lead.websiteUrl);
|
const rootUrl = normalizeCrawlUrl(started.lead.websiteUrl);
|
||||||
if (!rootUrl) {
|
if (!rootUrl) {
|
||||||
try {
|
|
||||||
await ctx.runMutation(internal.pageSpeed.queueLeadPageSpeedAudit, {
|
|
||||||
leadId: started.lead._id,
|
|
||||||
parentRunId: runId,
|
|
||||||
});
|
|
||||||
} catch (pageSpeedQueueError) {
|
|
||||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
|
||||||
runId,
|
|
||||||
level: "warning",
|
|
||||||
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
|
|
||||||
details: [
|
|
||||||
{ label: "Lead", value: started.lead._id },
|
|
||||||
{
|
|
||||||
label: "Fehler",
|
|
||||||
value: messageFromError(pageSpeedQueueError),
|
|
||||||
source: "pagespeed_queue",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.runMutation(internal.websiteEnrichment.finishLeadEnrichmentRun, {
|
await ctx.runMutation(internal.websiteEnrichment.finishLeadEnrichmentRun, {
|
||||||
runId,
|
runId,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
@@ -1341,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(internal.runs.appendEventInternal, {
|
|
||||||
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, {
|
await ctx.runMutation(internal.websiteEnrichment.finishLeadEnrichmentRun, {
|
||||||
runId,
|
runId,
|
||||||
status: "succeeded",
|
status: "succeeded",
|
||||||
@@ -1400,26 +1337,6 @@ export const processLeadEnrichment = internalAction({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (started) {
|
if (started) {
|
||||||
try {
|
|
||||||
await ctx.runMutation(internal.pageSpeed.queueLeadPageSpeedAudit, {
|
|
||||||
leadId: started.lead._id,
|
|
||||||
parentRunId: runId,
|
|
||||||
});
|
|
||||||
} catch (pageSpeedQueueError) {
|
|
||||||
await ctx.runMutation(internal.runs.appendEventInternal, {
|
|
||||||
runId,
|
|
||||||
level: "warning",
|
|
||||||
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
|
|
||||||
details: [
|
|
||||||
{ label: "Lead", value: started.lead._id },
|
|
||||||
{
|
|
||||||
label: "Fehler",
|
|
||||||
value: messageFromError(pageSpeedQueueError),
|
|
||||||
source: "pagespeed_queue",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await ctx.runMutation(internal.websiteEnrichment.patchLeadFromWebsiteEnrichment, {
|
await ctx.runMutation(internal.websiteEnrichment.patchLeadFromWebsiteEnrichment, {
|
||||||
leadId: started.lead._id,
|
leadId: started.lead._id,
|
||||||
currentContactStatus: started.lead.contactStatus,
|
currentContactStatus: started.lead.contactStatus,
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ Set production values in Coolify and Convex secrets, not in source code.
|
|||||||
- `NEXT_PUBLIC_CONVEX_SITE_URL`
|
- `NEXT_PUBLIC_CONVEX_SITE_URL`
|
||||||
- `CONVEX_DEPLOYMENT`
|
- `CONVEX_DEPLOYMENT`
|
||||||
- `BETTER_AUTH_SECRET`
|
- `BETTER_AUTH_SECRET`
|
||||||
- `GOOGLE_GEOCODING_API_KEY`
|
- `LOCAL_BUSINESS_DATA_API_KEY`
|
||||||
- `GOOGLE_PLACES_API_KEY`
|
|
||||||
- `PAGESPEED_API_KEY`
|
- `PAGESPEED_API_KEY`
|
||||||
- `PAGESPEED_TIMEOUT_MS`
|
- `PAGESPEED_TIMEOUT_MS`
|
||||||
- `OPENROUTER_API_KEY`
|
- `OPENROUTER_API_KEY`
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Diese Checkliste ist die wiederholbare manuelle Prüfung für die kritischen MVP
|
|||||||
## Audit-Generierung
|
## Audit-Generierung
|
||||||
|
|
||||||
1. Lead mit Website durch externe Audit-Services laufen lassen.
|
1. Lead mit Website durch externe Audit-Services laufen lassen.
|
||||||
2. Prüfen, dass Google, PageSpeed, OpenRouter und ScreenshotOne als serverseitig verwaltete Provider konfiguriert sind.
|
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.
|
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.
|
4. Im Outreach Review Workspace prüfen, dass Audit-Text, Quellen und Skills sichtbar sind.
|
||||||
|
|
||||||
|
|||||||
@@ -132,10 +132,22 @@ export const followUpDraftSchema = z.object({
|
|||||||
goals: z.array(z.string()).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({
|
export const qualityReviewSchema = z.object({
|
||||||
isValid: z.boolean(),
|
isValid: z.boolean(),
|
||||||
|
severity: z.enum(["ok", "warning", "unsafe"]),
|
||||||
issues: z.array(z.string()),
|
issues: z.array(z.string()),
|
||||||
suggestions: z.array(z.string()),
|
suggestions: z.array(z.string()),
|
||||||
|
rewriteRequired: z.boolean(),
|
||||||
|
revisedCopy: qualityReviewRevisedCopySchema.nullable(),
|
||||||
notes: z.array(z.string()).nullable(),
|
notes: z.array(z.string()).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,4 +166,5 @@ export type EmailDraft = z.infer<typeof emailDraftSchema>;
|
|||||||
export type EmailSubject = z.infer<typeof emailSubjectSchema>;
|
export type EmailSubject = z.infer<typeof emailSubjectSchema>;
|
||||||
export type CallScript = z.infer<typeof callScriptSchema>;
|
export type CallScript = z.infer<typeof callScriptSchema>;
|
||||||
export type FollowUpDraft = z.infer<typeof followUpDraftSchema>;
|
export type FollowUpDraft = z.infer<typeof followUpDraftSchema>;
|
||||||
|
export type QualityReviewRevisedCopy = z.infer<typeof qualityReviewRevisedCopySchema>;
|
||||||
export type QualityReview = z.infer<typeof qualityReviewSchema>;
|
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;
|
||||||
|
}
|
||||||
@@ -363,9 +363,9 @@ export const pipelineStages: PipelineStage[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Lead-Recherche",
|
title: "Lead-Recherche",
|
||||||
description: "Neue Places-Quellen, Kontaktluecken und Dubletten.",
|
description: "Neue Firmendaten, Kontaktquellen und Dubletten.",
|
||||||
count: 18,
|
count: 18,
|
||||||
meta: "5 Leads brauchen E-Mail-Quelle",
|
meta: "Audits starten nach Freigabe",
|
||||||
icon: UsersRound,
|
icon: UsersRound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ type GooglePlaceContactEmailSource = {
|
|||||||
isBusinessContactAddress?: boolean;
|
isBusinessContactAddress?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LeadDiscoverySourceProvider = "google_places" | "local_business_data";
|
||||||
|
|
||||||
type GooglePlaceApiPlace = {
|
type GooglePlaceApiPlace = {
|
||||||
id?: string;
|
id?: string;
|
||||||
displayName?: GooglePlaceDisplayName;
|
displayName?: GooglePlaceDisplayName;
|
||||||
@@ -256,6 +258,7 @@ export type GooglePlacesApiResponse = {
|
|||||||
|
|
||||||
export type GooglePlaceCandidate = {
|
export type GooglePlaceCandidate = {
|
||||||
placeId: string;
|
placeId: string;
|
||||||
|
sourceBusinessId?: string | null;
|
||||||
businessName: string;
|
businessName: string;
|
||||||
address: string;
|
address: string;
|
||||||
websiteUrl: string | null;
|
websiteUrl: string | null;
|
||||||
@@ -272,7 +275,7 @@ export type GooglePlaceCandidate = {
|
|||||||
googleTypes: string[];
|
googleTypes: string[];
|
||||||
googlePrimaryType: string | null;
|
googlePrimaryType: string | null;
|
||||||
googleMapsUrl: string | null;
|
googleMapsUrl: string | null;
|
||||||
sourceProvider: "google_places";
|
sourceProvider: LeadDiscoverySourceProvider;
|
||||||
sourceFetchedAt: number;
|
sourceFetchedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -501,6 +504,7 @@ export function normalizePlacesResponse(
|
|||||||
|
|
||||||
export type ExistingLeadLike = {
|
export type ExistingLeadLike = {
|
||||||
googlePlaceId?: string | null;
|
googlePlaceId?: string | null;
|
||||||
|
sourceBusinessId?: string | null;
|
||||||
websiteDomain?: string | null;
|
websiteDomain?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
companyName?: string | null;
|
companyName?: string | null;
|
||||||
@@ -509,13 +513,13 @@ export type ExistingLeadLike = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type BlacklistRow = {
|
export type BlacklistRow = {
|
||||||
type: "domain" | "email" | "phone" | "company" | "google_place_id";
|
type: "domain" | "email" | "phone" | "company" | "google_place_id" | "source_business_id";
|
||||||
value: string;
|
value: string;
|
||||||
normalizedValue: string;
|
normalizedValue: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BlacklistLookupValue = {
|
export type BlacklistLookupValue = {
|
||||||
type: "domain" | "email" | "phone" | "company" | "google_place_id";
|
type: "domain" | "email" | "phone" | "company" | "google_place_id" | "source_business_id";
|
||||||
normalizedValue: string;
|
normalizedValue: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -560,6 +564,10 @@ export function getBlacklistLookupValues(
|
|||||||
type: "google_place_id",
|
type: "google_place_id",
|
||||||
normalizedValue: normalizeDomain(candidate.placeId),
|
normalizedValue: normalizeDomain(candidate.placeId),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "source_business_id",
|
||||||
|
normalizedValue: normalizeDomain(candidate.sourceBusinessId ?? candidate.placeId),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "domain",
|
type: "domain",
|
||||||
normalizedValue: normalizeDomain(candidate.websiteDomain),
|
normalizedValue: normalizeDomain(candidate.websiteDomain),
|
||||||
@@ -588,16 +596,22 @@ export function isDuplicateCandidate(
|
|||||||
existing: ExistingLeadLike[],
|
existing: ExistingLeadLike[],
|
||||||
): boolean {
|
): boolean {
|
||||||
const candidatePlaceId = normalizeDomain(candidate.placeId);
|
const candidatePlaceId = normalizeDomain(candidate.placeId);
|
||||||
|
const candidateSourceBusinessId = normalizeDomain(
|
||||||
|
candidate.sourceBusinessId ?? candidate.placeId,
|
||||||
|
);
|
||||||
const candidateDomain = normalizeDomain(candidate.websiteDomain);
|
const candidateDomain = normalizeDomain(candidate.websiteDomain);
|
||||||
const candidateEmails = getCandidateEmailValues(candidate);
|
const candidateEmails = getCandidateEmailValues(candidate);
|
||||||
|
|
||||||
return existing.some((entry) => {
|
return existing.some((entry) => {
|
||||||
const entryPlaceId = normalizeDomain(entry.googlePlaceId);
|
const entryPlaceId = normalizeDomain(entry.googlePlaceId);
|
||||||
|
const entrySourceBusinessId = normalizeDomain(entry.sourceBusinessId);
|
||||||
const entryDomain = normalizeDomain(entry.websiteDomain);
|
const entryDomain = normalizeDomain(entry.websiteDomain);
|
||||||
const entryEmail = normalizeEmailAddress(entry.email);
|
const entryEmail = normalizeEmailAddress(entry.email);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(candidatePlaceId && entryPlaceId === candidatePlaceId) ||
|
(candidatePlaceId && entryPlaceId === candidatePlaceId) ||
|
||||||
|
(candidateSourceBusinessId &&
|
||||||
|
entrySourceBusinessId === candidateSourceBusinessId) ||
|
||||||
(candidateDomain && entryDomain === candidateDomain) ||
|
(candidateDomain && entryDomain === candidateDomain) ||
|
||||||
candidateEmails.some(
|
candidateEmails.some(
|
||||||
(candidateEmail) => candidateEmail && entryEmail === candidateEmail,
|
(candidateEmail) => candidateEmail && entryEmail === candidateEmail,
|
||||||
@@ -638,6 +652,9 @@ export function getBlacklistMatches(
|
|||||||
blacklistRows: BlacklistRow[],
|
blacklistRows: BlacklistRow[],
|
||||||
) {
|
) {
|
||||||
const candidatePlaceId = normalizeDomain(candidate.placeId);
|
const candidatePlaceId = normalizeDomain(candidate.placeId);
|
||||||
|
const candidateSourceBusinessId = normalizeDomain(
|
||||||
|
candidate.sourceBusinessId ?? candidate.placeId,
|
||||||
|
);
|
||||||
const candidateDomain = normalizeDomain(candidate.websiteDomain);
|
const candidateDomain = normalizeDomain(candidate.websiteDomain);
|
||||||
const candidateCompany = normalizeText(candidate.businessName);
|
const candidateCompany = normalizeText(candidate.businessName);
|
||||||
const candidatePhone = normalizePhone(candidate.phone);
|
const candidatePhone = normalizePhone(candidate.phone);
|
||||||
@@ -650,6 +667,11 @@ export function getBlacklistMatches(
|
|||||||
switch (row.type) {
|
switch (row.type) {
|
||||||
case "google_place_id":
|
case "google_place_id":
|
||||||
return candidatePlaceId !== "" && row.normalizedValue === candidatePlaceId;
|
return candidatePlaceId !== "" && row.normalizedValue === candidatePlaceId;
|
||||||
|
case "source_business_id":
|
||||||
|
return (
|
||||||
|
candidateSourceBusinessId !== "" &&
|
||||||
|
row.normalizedValue === candidateSourceBusinessId
|
||||||
|
);
|
||||||
case "domain":
|
case "domain":
|
||||||
return candidateDomain !== "" && row.normalizedValue === candidateDomain;
|
return candidateDomain !== "" && row.normalizedValue === candidateDomain;
|
||||||
case "company":
|
case "company":
|
||||||
|
|||||||
317
lib/lead-discovery-local-business.ts
Normal file
317
lib/lead-discovery-local-business.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import {
|
||||||
|
normalizeEmailAddress,
|
||||||
|
type GooglePlaceCandidate,
|
||||||
|
} from "./lead-discovery-google";
|
||||||
|
|
||||||
|
export const LOCAL_BUSINESS_DATA_HOST = "local-business-data.p.rapidapi.com";
|
||||||
|
export const LOCAL_BUSINESS_DATA_SEARCH_ENDPOINT =
|
||||||
|
`https://${LOCAL_BUSINESS_DATA_HOST}/search`;
|
||||||
|
export const LOCAL_BUSINESS_DATA_MAX_RESULTS = 500;
|
||||||
|
|
||||||
|
type CampaignLike = {
|
||||||
|
categoryMode?: "preset" | "custom";
|
||||||
|
category?: string | null;
|
||||||
|
customSearchTerm?: string | null;
|
||||||
|
postalCode: string;
|
||||||
|
maxNewLeads: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LocalBusinessDataError = {
|
||||||
|
message?: string;
|
||||||
|
code?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LocalBusinessDataBusiness = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type LocalBusinessDataResponse = {
|
||||||
|
status?: string;
|
||||||
|
request_id?: string;
|
||||||
|
error?: LocalBusinessDataError;
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeSearchTerm(value?: string | null) {
|
||||||
|
return value?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampSearchLimit(limit: number) {
|
||||||
|
if (!Number.isFinite(limit)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.max(Math.floor(limit), 1), LOCAL_BUSINESS_DATA_MAX_RESULTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(...values: unknown[]) {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(...values: unknown[]) {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArrayValue(...values: unknown[]) {
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
|
result.push(value.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
if (typeof item === "string" && item.trim().length > 0) {
|
||||||
|
result.push(item.trim());
|
||||||
|
} else if (item && typeof item === "object") {
|
||||||
|
const email = stringValue(
|
||||||
|
(item as Record<string, unknown>).email,
|
||||||
|
(item as Record<string, unknown>).value,
|
||||||
|
);
|
||||||
|
if (email) {
|
||||||
|
result.push(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(result)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: unknown) {
|
||||||
|
return value && typeof value === "object"
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWebsiteDomain(input?: string | null) {
|
||||||
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(input);
|
||||||
|
return url.hostname.toLowerCase().replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const url = new URL(`https://${input}`);
|
||||||
|
return url.hostname.toLowerCase().replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWebsiteUrl(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(value).toString();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return new URL(`https://${value}`).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBusinesses(data: unknown): LocalBusinessDataBusiness[] {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.filter(
|
||||||
|
(item): item is LocalBusinessDataBusiness =>
|
||||||
|
item !== null && typeof item === "object",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
const record = data as Record<string, unknown>;
|
||||||
|
for (const key of ["businesses", "results", "items"]) {
|
||||||
|
const value = record[key];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter(
|
||||||
|
(item): item is LocalBusinessDataBusiness =>
|
||||||
|
item !== null && typeof item === "object",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalBusinessSearchUrl({
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
language = "de",
|
||||||
|
region = "de",
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
limit: number;
|
||||||
|
language?: string;
|
||||||
|
region?: string;
|
||||||
|
}) {
|
||||||
|
const url = new URL(LOCAL_BUSINESS_DATA_SEARCH_ENDPOINT);
|
||||||
|
|
||||||
|
url.searchParams.set("query", query);
|
||||||
|
url.searchParams.set("limit", String(clampSearchLimit(limit)));
|
||||||
|
url.searchParams.set("language", language);
|
||||||
|
url.searchParams.set("region", region);
|
||||||
|
url.searchParams.set("extract_emails_and_contacts", "true");
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalBusinessSearchSpec(campaignLike: CampaignLike) {
|
||||||
|
const category = normalizeSearchTerm(campaignLike.category);
|
||||||
|
const baseTerm =
|
||||||
|
campaignLike.categoryMode === "custom" || category === "Anderes"
|
||||||
|
? normalizeSearchTerm(campaignLike.customSearchTerm)
|
||||||
|
: category;
|
||||||
|
const query = `${baseTerm || "Unternehmen"} in ${campaignLike.postalCode} Deutschland`;
|
||||||
|
const limit = clampSearchLimit(campaignLike.maxNewLeads);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
url: buildLocalBusinessSearchUrl({ query, limit }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalBusinessSearchResponse(
|
||||||
|
response: LocalBusinessDataResponse,
|
||||||
|
fetchedAt: number,
|
||||||
|
): GooglePlaceCandidate[] {
|
||||||
|
if (!response || response.status === "ERROR") {
|
||||||
|
const code = response?.error?.code ?? "unknown";
|
||||||
|
const message = response?.error?.message ?? "Unknown Local Business Data error.";
|
||||||
|
throw new Error(`Local Business Data API error ${code}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const businesses = extractBusinesses(response.data);
|
||||||
|
|
||||||
|
return businesses.flatMap((business) => {
|
||||||
|
const sourceBusinessId = stringValue(
|
||||||
|
business.business_id,
|
||||||
|
business.businessId,
|
||||||
|
business.id,
|
||||||
|
);
|
||||||
|
const placeId = stringValue(
|
||||||
|
business.place_id,
|
||||||
|
business.google_place_id,
|
||||||
|
business.google_id,
|
||||||
|
business.googleId,
|
||||||
|
sourceBusinessId,
|
||||||
|
);
|
||||||
|
const businessName = stringValue(
|
||||||
|
business.name,
|
||||||
|
business.business_name,
|
||||||
|
business.title,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!placeId || !businessName) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const websiteUrl = normalizeWebsiteUrl(
|
||||||
|
stringValue(business.website, business.site, business.url),
|
||||||
|
);
|
||||||
|
const emailsAndContacts = objectValue(business.emails_and_contacts);
|
||||||
|
const directEmails = stringArrayValue(
|
||||||
|
business.email,
|
||||||
|
business.emails,
|
||||||
|
business.contact_emails,
|
||||||
|
business.contacts,
|
||||||
|
emailsAndContacts?.emails,
|
||||||
|
);
|
||||||
|
const seenEmails = new Set<string>();
|
||||||
|
const contactEmails = directEmails.flatMap((email) => {
|
||||||
|
const normalizedEmail = normalizeEmailAddress(email);
|
||||||
|
|
||||||
|
if (!normalizedEmail || seenEmails.has(normalizedEmail)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
seenEmails.add(normalizedEmail);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
email: normalizedEmail,
|
||||||
|
emailSource: "local_business_data",
|
||||||
|
isBusinessContactAddress:
|
||||||
|
normalizedEmail.split("@")[0]?.includes(".") === false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
placeId,
|
||||||
|
sourceBusinessId: sourceBusinessId ?? placeId,
|
||||||
|
businessName,
|
||||||
|
address:
|
||||||
|
stringValue(
|
||||||
|
business.full_address,
|
||||||
|
business.address,
|
||||||
|
business.formatted_address,
|
||||||
|
) ?? "",
|
||||||
|
websiteUrl,
|
||||||
|
websiteDomain: normalizeWebsiteDomain(websiteUrl),
|
||||||
|
phone:
|
||||||
|
stringValue(
|
||||||
|
business.phone_number,
|
||||||
|
business.phone,
|
||||||
|
business.telephone,
|
||||||
|
) ?? null,
|
||||||
|
email: contactEmails[0]?.email ?? null,
|
||||||
|
emailSource: contactEmails[0]?.emailSource ?? null,
|
||||||
|
contactEmails,
|
||||||
|
rating: numberValue(business.rating, business.google_rating),
|
||||||
|
userRatingCount: numberValue(
|
||||||
|
business.review_count,
|
||||||
|
business.reviews_count,
|
||||||
|
business.user_ratings_total,
|
||||||
|
),
|
||||||
|
businessStatus:
|
||||||
|
stringValue(business.business_status, business.status) ?? null,
|
||||||
|
googleTypes: stringArrayValue(
|
||||||
|
business.types,
|
||||||
|
business.subtypes,
|
||||||
|
business.category,
|
||||||
|
),
|
||||||
|
googlePrimaryType:
|
||||||
|
stringValue(business.type, business.primary_type, business.category) ??
|
||||||
|
null,
|
||||||
|
googleMapsUrl:
|
||||||
|
stringValue(
|
||||||
|
business.google_maps_url,
|
||||||
|
business.google_maps_link,
|
||||||
|
business.maps_url,
|
||||||
|
) ?? null,
|
||||||
|
sourceProvider: "local_business_data",
|
||||||
|
sourceFetchedAt: fetchedAt,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -142,12 +142,14 @@ export function buildLeadDiscoveryLeadRecord<
|
|||||||
googleRating?: number;
|
googleRating?: number;
|
||||||
googleUserRatingCount?: number;
|
googleUserRatingCount?: number;
|
||||||
googleBusinessStatus?: string;
|
googleBusinessStatus?: string;
|
||||||
sourceProvider: "google_places";
|
sourceProvider: "google_places" | "local_business_data";
|
||||||
|
sourceBusinessId?: string;
|
||||||
sourceFetchedAt: number;
|
sourceFetchedAt: number;
|
||||||
websiteUrl?: string;
|
websiteUrl?: string;
|
||||||
websiteDomain?: string;
|
websiteDomain?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
normalizedGooglePlaceId?: string;
|
normalizedGooglePlaceId?: string;
|
||||||
|
normalizedSourceBusinessId?: string;
|
||||||
normalizedEmail?: string;
|
normalizedEmail?: string;
|
||||||
normalizedPhone?: string;
|
normalizedPhone?: string;
|
||||||
normalizedCompanyName?: string;
|
normalizedCompanyName?: string;
|
||||||
@@ -191,6 +193,7 @@ export function buildLeadDiscoveryLeadRecord<
|
|||||||
const googleRating = optionalNumber(input.candidate.rating);
|
const googleRating = optionalNumber(input.candidate.rating);
|
||||||
const googleUserRatingCount = optionalNumber(input.candidate.userRatingCount);
|
const googleUserRatingCount = optionalNumber(input.candidate.userRatingCount);
|
||||||
const googleBusinessStatus = optionalString(input.candidate.businessStatus);
|
const googleBusinessStatus = optionalString(input.candidate.businessStatus);
|
||||||
|
const sourceBusinessId = optionalString(input.candidate.sourceBusinessId);
|
||||||
const websiteUrl = optionalString(input.candidate.websiteUrl);
|
const websiteUrl = optionalString(input.candidate.websiteUrl);
|
||||||
const websiteDomain = optionalString(input.candidate.websiteDomain);
|
const websiteDomain = optionalString(input.candidate.websiteDomain);
|
||||||
const phone = optionalString(input.candidate.phone);
|
const phone = optionalString(input.candidate.phone);
|
||||||
@@ -225,6 +228,9 @@ export function buildLeadDiscoveryLeadRecord<
|
|||||||
if (googleBusinessStatus !== undefined) {
|
if (googleBusinessStatus !== undefined) {
|
||||||
lead.googleBusinessStatus = googleBusinessStatus;
|
lead.googleBusinessStatus = googleBusinessStatus;
|
||||||
}
|
}
|
||||||
|
if (sourceBusinessId !== undefined) {
|
||||||
|
lead.sourceBusinessId = sourceBusinessId;
|
||||||
|
}
|
||||||
if (websiteUrl !== undefined) {
|
if (websiteUrl !== undefined) {
|
||||||
lead.websiteUrl = websiteUrl;
|
lead.websiteUrl = websiteUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type IntegrationReadinessStatus = "configured" | "missing";
|
|||||||
|
|
||||||
export type IntegrationReadinessDefinition = {
|
export type IntegrationReadinessDefinition = {
|
||||||
id:
|
id:
|
||||||
| "google"
|
| "local_business_data"
|
||||||
| "pagespeed"
|
| "pagespeed"
|
||||||
| "openrouter"
|
| "openrouter"
|
||||||
| "screenshotone"
|
| "screenshotone"
|
||||||
@@ -22,10 +22,11 @@ export type IntegrationReadinessRow = IntegrationReadinessDefinition & {
|
|||||||
|
|
||||||
export const integrationReadinessDefinitions: IntegrationReadinessDefinition[] = [
|
export const integrationReadinessDefinitions: IntegrationReadinessDefinition[] = [
|
||||||
{
|
{
|
||||||
id: "google",
|
id: "local_business_data",
|
||||||
label: "Google",
|
label: "Local Business Data",
|
||||||
requiredEnv: ["GOOGLE_GEOCODING_API_KEY", "GOOGLE_PLACES_API_KEY"],
|
requiredEnv: ["LOCAL_BUSINESS_DATA_API_KEY"],
|
||||||
errorSurface: "Run-Events der Lead-Recherche zeigen Google-Fehler.",
|
errorSurface:
|
||||||
|
"Run-Events der Lead-Recherche zeigen Local-Business-Data-Fehler.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pagespeed",
|
id: "pagespeed",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/better-auth": "^0.12.2",
|
"@convex-dev/better-auth": "^0.12.2",
|
||||||
|
"@convex-dev/workflow": "^0.4.4",
|
||||||
|
"@convex-dev/workpool": "^0.4.7",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@openrouter/ai-sdk-provider": "^2.9.0",
|
"@openrouter/ai-sdk-provider": "^2.9.0",
|
||||||
"@sparticuz/chromium-min": "^149.0.0",
|
"@sparticuz/chromium-min": "^149.0.0",
|
||||||
|
|||||||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@@ -11,6 +11,12 @@ importers:
|
|||||||
'@convex-dev/better-auth':
|
'@convex-dev/better-auth':
|
||||||
specifier: ^0.12.2
|
specifier: ^0.12.2
|
||||||
version: 0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(@opentelemetry/api@1.9.1)(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)
|
version: 0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(@opentelemetry/api@1.9.1)(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)
|
||||||
|
'@convex-dev/workflow':
|
||||||
|
specifier: ^0.4.4
|
||||||
|
version: 0.4.4(@convex-dev/workpool@0.4.7(convex-helpers@0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3))(convex@1.40.0(react@19.2.4)))(convex-helpers@0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3))(convex@1.40.0(react@19.2.4))
|
||||||
|
'@convex-dev/workpool':
|
||||||
|
specifier: ^0.4.7
|
||||||
|
version: 0.4.7(convex-helpers@0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3))(convex@1.40.0(react@19.2.4))
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.4.0(react-hook-form@7.77.0(react@19.2.4))
|
version: 5.4.0(react-hook-form@7.77.0(react@19.2.4))
|
||||||
@@ -337,6 +343,19 @@ packages:
|
|||||||
convex: ^1.25.0
|
convex: ^1.25.0
|
||||||
react: ^18.3.1 || ^19.0.0
|
react: ^18.3.1 || ^19.0.0
|
||||||
|
|
||||||
|
'@convex-dev/workflow@0.4.4':
|
||||||
|
resolution: {integrity: sha512-ZQfVspAAxG4zZJEep2qaRtupw8OewwMezq6KNKaXKjo/gA+YffS9bXz13x+L/TSt9/Lb6gioae6Y9PDrqh7xQg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@convex-dev/workpool': ^0.4.4
|
||||||
|
convex: ^1.36.1
|
||||||
|
convex-helpers: ^0.1.99
|
||||||
|
|
||||||
|
'@convex-dev/workpool@0.4.7':
|
||||||
|
resolution: {integrity: sha512-4O3VKcJXqYZ9icDgKdVPxjDGUAFK3oG0hbUwLcyYMYgsvVKlZDhvZRmczqSBZHLyrCGPpf925byh0dBigCfAGA==}
|
||||||
|
peerDependencies:
|
||||||
|
convex: ^1.31.7
|
||||||
|
convex-helpers: ^0.1.94
|
||||||
|
|
||||||
'@dotenvx/dotenvx@1.71.0':
|
'@dotenvx/dotenvx@1.71.0':
|
||||||
resolution: {integrity: sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag==}
|
resolution: {integrity: sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2071,6 +2090,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
|
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
async-channel@0.2.0:
|
||||||
|
resolution: {integrity: sha512-BJyjI/sfKlyijaBt2hbOSxT28xGNtLR0QLzAKO1Hlnv5BULY7sAoYoTPW3lfr1ZIC7y+FxabxO9T8GXpyoofGg==}
|
||||||
|
|
||||||
async-function@1.0.0:
|
async-function@1.0.0:
|
||||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4638,6 +4660,18 @@ snapshots:
|
|||||||
- hono
|
- hono
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
|
'@convex-dev/workflow@0.4.4(@convex-dev/workpool@0.4.7(convex-helpers@0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3))(convex@1.40.0(react@19.2.4)))(convex-helpers@0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3))(convex@1.40.0(react@19.2.4))':
|
||||||
|
dependencies:
|
||||||
|
'@convex-dev/workpool': 0.4.7(convex-helpers@0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3))(convex@1.40.0(react@19.2.4))
|
||||||
|
async-channel: 0.2.0
|
||||||
|
convex: 1.40.0(react@19.2.4)
|
||||||
|
convex-helpers: 0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3)
|
||||||
|
|
||||||
|
'@convex-dev/workpool@0.4.7(convex-helpers@0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3))(convex@1.40.0(react@19.2.4))':
|
||||||
|
dependencies:
|
||||||
|
convex: 1.40.0(react@19.2.4)
|
||||||
|
convex-helpers: 0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3)
|
||||||
|
|
||||||
'@dotenvx/dotenvx@1.71.0':
|
'@dotenvx/dotenvx@1.71.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 11.1.0
|
commander: 11.1.0
|
||||||
@@ -6267,6 +6301,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
async-channel@0.2.0: {}
|
||||||
|
|
||||||
async-function@1.0.0: {}
|
async-function@1.0.0: {}
|
||||||
|
|
||||||
available-typed-arrays@1.0.7:
|
available-typed-arrays@1.0.7:
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
"source": "get-convex/agent-skills",
|
"source": "get-convex/agent-skills",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/convex-create-component/SKILL.md",
|
"skillPath": "skills/convex-create-component/SKILL.md",
|
||||||
"computedHash": "25b6f56cc6afa4237aa191f5bfa5b86f68b70dc7f1195b86d027bd85346cff41"
|
"computedHash": "012acb639fccc22a47e89ef69941689f9328ac9ff5b872d77af6328407ec8876"
|
||||||
},
|
},
|
||||||
"convex-migration-helper": {
|
"convex-migration-helper": {
|
||||||
"source": "get-convex/agent-skills",
|
"source": "get-convex/agent-skills",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/convex-migration-helper/SKILL.md",
|
"skillPath": "skills/convex-migration-helper/SKILL.md",
|
||||||
"computedHash": "47ad936c1977eecca736211fe4efb171b53c412b49cffc42267095276a8df36d"
|
"computedHash": "1ed5ef230d484e9884d881ddbd15158f0070382f8c6097364cb8ed2ec458decd"
|
||||||
},
|
},
|
||||||
"convex-performance-audit": {
|
"convex-performance-audit": {
|
||||||
"source": "get-convex/agent-skills",
|
"source": "get-convex/agent-skills",
|
||||||
|
|||||||
@@ -139,16 +139,22 @@ test("structured output schemas avoid optional top-level fields for OpenAI stric
|
|||||||
() =>
|
() =>
|
||||||
qualityReviewSchema.parse({
|
qualityReviewSchema.parse({
|
||||||
isValid: true,
|
isValid: true,
|
||||||
|
severity: "ok",
|
||||||
issues: [],
|
issues: [],
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
|
rewriteRequired: false,
|
||||||
|
revisedCopy: null,
|
||||||
}),
|
}),
|
||||||
/notes|invalid|required/i,
|
/notes|invalid|required/i,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
qualityReviewSchema.parse({
|
qualityReviewSchema.parse({
|
||||||
isValid: true,
|
isValid: true,
|
||||||
|
severity: "ok",
|
||||||
issues: [],
|
issues: [],
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
|
rewriteRequired: false,
|
||||||
|
revisedCopy: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
}).notes,
|
}).notes,
|
||||||
null,
|
null,
|
||||||
@@ -338,8 +344,11 @@ test("outreach schemas parse German customer-facing payloads", () => {
|
|||||||
});
|
});
|
||||||
const qualityParsed = qualityReviewSchema.parse({
|
const qualityParsed = qualityReviewSchema.parse({
|
||||||
isValid: true,
|
isValid: true,
|
||||||
|
severity: "ok",
|
||||||
issues: [],
|
issues: [],
|
||||||
suggestions: ["Mehr Kundennutzen konkret beschreiben."],
|
suggestions: ["Mehr Kundennutzen konkret beschreiben."],
|
||||||
|
rewriteRequired: false,
|
||||||
|
revisedCopy: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,6 +359,46 @@ test("outreach schemas parse German customer-facing payloads", () => {
|
|||||||
assert.equal(Array.isArray(qualityParsed.suggestions), true);
|
assert.equal(Array.isArray(qualityParsed.suggestions), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("quality review schema accepts one-shot revised copy payloads", () => {
|
||||||
|
const parsed: QualityReview = qualityReviewSchema.parse({
|
||||||
|
isValid: false,
|
||||||
|
severity: "warning",
|
||||||
|
issues: ["Betreff klingt noch etwas generisch."],
|
||||||
|
suggestions: ["Betreff konkreter machen."],
|
||||||
|
rewriteRequired: true,
|
||||||
|
revisedCopy: {
|
||||||
|
publicSummary: "Mir ist aufgefallen, dass die mobile Seite etwas traege wirkt.",
|
||||||
|
publicBody:
|
||||||
|
"Mein Vorschlag waere, zuerst die sichtbaren Ladebremsen der Startseite zu pruefen.",
|
||||||
|
emailSubject: "Kurzer Hinweis zur mobilen Seite",
|
||||||
|
emailBody:
|
||||||
|
"Guten Tag, mir ist beim Blick auf Ihre Website aufgefallen, dass die mobile Seite etwas traege wirkt.",
|
||||||
|
phoneScript: {
|
||||||
|
openingLine: "Guten Tag, hier ist Matthias Meister.",
|
||||||
|
callScript: [
|
||||||
|
"Mir ist bei Ihrer mobilen Website ein konkreter Ladezeitpunkt aufgefallen.",
|
||||||
|
"Mein Vorschlag waere, diesen Punkt kurz zu priorisieren.",
|
||||||
|
],
|
||||||
|
closeLine: "Soll ich Ihnen den Hinweis kurz per E-Mail senden?",
|
||||||
|
},
|
||||||
|
followUpDraft: {
|
||||||
|
message:
|
||||||
|
"Ich wollte kurz nachfassen, ob der Hinweis zur mobilen Seite fuer Sie relevant ist.",
|
||||||
|
followInDays: 7,
|
||||||
|
goals: ["kurze Rueckmeldung", "Interesse klaeren"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notes: ["Ein Rewrite ist sinnvoll."],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.rewriteRequired, true);
|
||||||
|
assert.equal(parsed.revisedCopy?.emailSubject, "Kurzer Hinweis zur mobilen Seite");
|
||||||
|
assert.deepEqual(parsed.revisedCopy?.followUpDraft.goals, [
|
||||||
|
"kurze Rueckmeldung",
|
||||||
|
"Interesse klaeren",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("schema-inferred types are exported for Convex action wiring", () => {
|
test("schema-inferred types are exported for Convex action wiring", () => {
|
||||||
const typedFindings: InternalFindings = {
|
const typedFindings: InternalFindings = {
|
||||||
findings: [
|
findings: [
|
||||||
@@ -393,8 +442,11 @@ test("schema-inferred types are exported for Convex action wiring", () => {
|
|||||||
|
|
||||||
const typedQuality: QualityReview = {
|
const typedQuality: QualityReview = {
|
||||||
isValid: true,
|
isValid: true,
|
||||||
|
severity: "ok",
|
||||||
issues: [],
|
issues: [],
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
|
rewriteRequired: false,
|
||||||
|
revisedCopy: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -93,10 +93,10 @@ test("auditGenerationAction exports processAuditGeneration with runId validator"
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
hasPattern(
|
hasPattern(
|
||||||
actionSource,
|
actionSource,
|
||||||
/processAuditGeneration\s*=\s*internalAction\(\s*{\s*args:\s*{\s*runId:\s*v\.id\(\s*["']agentRuns["']\s*\)\s*,?\s*}/,
|
/processAuditGeneration\s*=\s*internalAction\(\s*{\s*args:\s*{[\s\S]*?runId:\s*v\.id\(\s*["']agentRuns["']\s*\)[\s\S]*?rootRunId:\s*v\.optional\(v\.id\(\s*["']agentRuns["']\s*\)\)/,
|
||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
"processAuditGeneration should validate runId: v.id(\"agentRuns\")",
|
"processAuditGeneration should validate runId and optional rootRunId as agentRuns IDs",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,18 +280,38 @@ test("German copy prompt uses first-contact email tone guidelines without a new
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("quality review blocks when model review or German copy guard fails", () => {
|
test("quality review can rewrite copy once without making copy feedback a hard failure", () => {
|
||||||
const qualityPromptSource = extractFunctionSource("buildQualityReviewPrompt");
|
const qualityPromptSource = extractFunctionSource("buildQualityReviewPrompt");
|
||||||
|
|
||||||
assert.match(
|
assert.doesNotMatch(
|
||||||
actionSource,
|
actionSource,
|
||||||
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
|
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
|
||||||
"qualityPassed should require both model review validity and German copy guard.",
|
"Copy quality feedback should not be a hard AND-gate with the deterministic German copy guard.",
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
actionSource,
|
actionSource,
|
||||||
/qualityPassed\s*=\s*guardResult\.passed\s*;/,
|
/qualityPassed\s*=\s*guardResult\.passed\s*;/,
|
||||||
"qualityPassed must not ignore the model quality review.",
|
"The deterministic German copy guard should not be the quality pass condition.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
actionSource,
|
||||||
|
/rewriteRequired[\s\S]*revisedCopy[\s\S]*applyRevisedCopy/,
|
||||||
|
"Quality review should be able to request one revised copy and apply it before persistence.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
actionSource,
|
||||||
|
/copyReviewAttempts\s*<\s*2/,
|
||||||
|
"Quality review should run at most the initial review plus one rewrite review.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
actionSource,
|
||||||
|
/message:\s*["']Copy-Review hat korrigiert\.["']/,
|
||||||
|
"A successful rewrite should be visible as a warning event.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
actionSource,
|
||||||
|
/message:\s*["']Copy-Review mit Hinweisen abgeschlossen\.["']/,
|
||||||
|
"Remaining copy feedback should be stored as warning telemetry.",
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
qualityPromptSource,
|
qualityPromptSource,
|
||||||
@@ -308,6 +328,11 @@ test("quality review blocks when model review or German copy guard fails", () =>
|
|||||||
/verified findings|verifizierte Befunde/i,
|
/verified findings|verifizierte Befunde/i,
|
||||||
"Quality review should keep concrete claims tied to verified findings.",
|
"Quality review should keep concrete claims tied to verified findings.",
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
qualityPromptSource,
|
||||||
|
/revisedCopy|rewriteRequired/,
|
||||||
|
"Quality review prompt should ask for revised copy when rewrite is needed.",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("action handles post-start failure paths in action-level catch", () => {
|
test("action handles post-start failure paths in action-level catch", () => {
|
||||||
@@ -536,27 +561,21 @@ test("action handles missing screenshots with warning event fallback", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("action runs german copy guard and blocks outreach-ready on validation failure", () => {
|
test("action keeps German copy guard as telemetry without blocking outreach-ready", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hasPattern(actionSource, /validateCustomerFacingCopy/),
|
hasPattern(actionSource, /validateCustomerFacingCopy/),
|
||||||
true,
|
true,
|
||||||
"Action should run German copy validation",
|
"Action should still run German copy validation for telemetry.",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.doesNotMatch(
|
||||||
hasPattern(
|
actionSource,
|
||||||
actionSource,
|
/guardResult\.passed[\s\S]{0,500}finishAuditGenerationRun[\s\S]{0,250}status:\s*["']failed["']/,
|
||||||
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
|
"German copy guard findings should not finish the audit generation as failed.",
|
||||||
),
|
|
||||||
true,
|
|
||||||
"Model QA and deterministic German copy guard failures should hard-block the audit run.",
|
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.match(
|
||||||
hasPattern(
|
actionSource,
|
||||||
actionSource,
|
/guardTelemetry|deterministicGuard/,
|
||||||
/qualityPassed\s*=\s*guardResult\.passed\s*;/,
|
"German copy guard output should be persisted as telemetry in the quality payload.",
|
||||||
),
|
|
||||||
false,
|
|
||||||
"Action must not ignore the model QA validity flag.",
|
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hasPattern(actionSource, /internal\.leads\.reviewUpdateInternal/),
|
hasPattern(actionSource, /internal\.leads\.reviewUpdateInternal/),
|
||||||
|
|||||||
41
tests/audit-progress.test.ts
Normal file
41
tests/audit-progress.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUDIT_PROGRESS_TOTAL_STEPS,
|
||||||
|
getAuditProgressForStep,
|
||||||
|
} from "../lib/audits/progress";
|
||||||
|
|
||||||
|
test("audit progress mapping exposes stable customer-facing progress steps", () => {
|
||||||
|
assert.equal(AUDIT_PROGRESS_TOTAL_STEPS, 6);
|
||||||
|
|
||||||
|
assert.deepEqual(getAuditProgressForStep("pagespeed_insights"), {
|
||||||
|
step: 2,
|
||||||
|
total: 6,
|
||||||
|
label: "Messe PageSpeed",
|
||||||
|
percent: 33,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(getAuditProgressForStep("qualityReview"), {
|
||||||
|
step: 6,
|
||||||
|
total: 6,
|
||||||
|
label: "Speichere Audit",
|
||||||
|
percent: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("audit progress mapping falls back safely for historical runs", () => {
|
||||||
|
assert.deepEqual(getAuditProgressForStep(undefined), {
|
||||||
|
step: 1,
|
||||||
|
total: 6,
|
||||||
|
label: "Audit vorbereitet",
|
||||||
|
percent: 17,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(getAuditProgressForStep("some_old_step"), {
|
||||||
|
step: 1,
|
||||||
|
total: 6,
|
||||||
|
label: "Audit vorbereitet",
|
||||||
|
percent: 17,
|
||||||
|
});
|
||||||
|
});
|
||||||
92
tests/audit-workflow-source.test.ts
Normal file
92
tests/audit-workflow-source.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const source = (relativePath: string) => {
|
||||||
|
return readFileSync(path.join(process.cwd(), ...relativePath.split("/")), "utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileExists = (relativePath: string) => {
|
||||||
|
return existsSync(path.join(process.cwd(), ...relativePath.split("/")));
|
||||||
|
};
|
||||||
|
|
||||||
|
test("Convex Workflow and Workpool dependencies and components are registered", () => {
|
||||||
|
const packageSource = source("package.json");
|
||||||
|
const configSource = source("convex/convex.config.ts");
|
||||||
|
|
||||||
|
assert.match(packageSource, /"@convex-dev\/workflow"/);
|
||||||
|
assert.match(packageSource, /"@convex-dev\/workpool"/);
|
||||||
|
assert.match(configSource, /from\s+["@']@convex-dev\/workflow\/convex\.config["@']/);
|
||||||
|
assert.match(configSource, /from\s+["@']@convex-dev\/workpool\/convex\.config["@']/);
|
||||||
|
assert.match(configSource, /app\.use\(workflow/);
|
||||||
|
assert.match(configSource, /app\.use\(auditWorkpool/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("audit workflow defines durable workflow manager with retrying workpool options", () => {
|
||||||
|
assert.equal(fileExists("convex/auditWorkflow.ts"), true);
|
||||||
|
const workflowSource = source("convex/auditWorkflow.ts");
|
||||||
|
|
||||||
|
assert.match(workflowSource, /WorkflowManager/);
|
||||||
|
assert.match(workflowSource, /components\.workflow/);
|
||||||
|
assert.match(workflowSource, /workpoolOptions/);
|
||||||
|
assert.match(workflowSource, /maxParallelism:\s*3/);
|
||||||
|
assert.match(workflowSource, /retryActionsByDefault:\s*true/);
|
||||||
|
assert.match(workflowSource, /maxAttempts:\s*3/);
|
||||||
|
assert.match(workflowSource, /initialBackoffMs:\s*1000/);
|
||||||
|
assert.match(workflowSource, /base:\s*2/);
|
||||||
|
assert.match(workflowSource, /step\.runAction/);
|
||||||
|
assert.match(workflowSource, /step\.runMutation/);
|
||||||
|
assert.match(workflowSource, /Promise\.all/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requestLeadAudit creates a visible agentRun and starts the workflow", () => {
|
||||||
|
const pageSpeedSource = source("convex/pageSpeed.ts");
|
||||||
|
|
||||||
|
assert.match(pageSpeedSource, /internal\.auditWorkflow\.startLeadAuditWorkflow/);
|
||||||
|
assert.match(pageSpeedSource, /type:\s*"audit"/);
|
||||||
|
assert.match(pageSpeedSource, /progressLabel:\s*"Audit vorbereitet"/);
|
||||||
|
assert.match(pageSpeedSource, /workflowId/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workflow PageSpeed start accepts root runs already marked running", () => {
|
||||||
|
const pageSpeedSource = source("convex/pageSpeed.ts");
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
pageSpeedSource,
|
||||||
|
/run\.status\s*!==\s*"pending"[\s\S]*run\.status\s*!==\s*"failed"[\s\S]*run\.status\s*!==\s*"running"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workflow failure progress stays on the failing step instead of jumping to quality review", () => {
|
||||||
|
const workflowSource = source("convex/auditWorkflow.ts");
|
||||||
|
const catchIndex = workflowSource.indexOf("} catch (error)");
|
||||||
|
assert.notEqual(catchIndex, -1, "Expected workflow catch block.");
|
||||||
|
const nextExportIndex = workflowSource.indexOf("export const startLeadAuditWorkflow", catchIndex);
|
||||||
|
assert.notEqual(nextExportIndex, -1, "Expected workflow catch block end.");
|
||||||
|
const catchSource = workflowSource.slice(catchIndex, nextExportIndex);
|
||||||
|
|
||||||
|
assert.doesNotMatch(catchSource, /progressPatch\(args\.runId,\s*"qualityReview"\)/);
|
||||||
|
assert.doesNotMatch(catchSource, /progressStep|progressTotal|progressLabel|progressPercent/);
|
||||||
|
assert.match(catchSource, /status:\s*"failed"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("audit dashboard query includes root audit runs and exposes progress and retry fields", () => {
|
||||||
|
const auditsSource = source("convex/audits.ts");
|
||||||
|
|
||||||
|
assert.match(auditsSource, /\.eq\("type",\s*"audit"\)/);
|
||||||
|
assert.match(auditsSource, /kind:\s*"generation"/);
|
||||||
|
assert.match(auditsSource, /runType/);
|
||||||
|
assert.match(auditsSource, /progress:/);
|
||||||
|
assert.match(auditsSource, /retry:/);
|
||||||
|
assert.match(auditsSource, /canRetry/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("audit retry mutation restarts final failed or canceled runs through workflow", () => {
|
||||||
|
const auditsSource = source("convex/audits.ts");
|
||||||
|
|
||||||
|
assert.match(auditsSource, /export const retryAuditRun = mutation/);
|
||||||
|
assert.match(auditsSource, /requireOperator\(ctx\)/);
|
||||||
|
assert.match(auditsSource, /status !== "failed"[\s\S]*status !== "canceled"/);
|
||||||
|
assert.match(auditsSource, /internal\.auditWorkflow\.restartAuditWorkflow/);
|
||||||
|
});
|
||||||
@@ -32,7 +32,12 @@ test("AuditsBoard keeps audit detail links and non-clickable pipeline cards", as
|
|||||||
assert.match(source, /row\.kind === "audit"/);
|
assert.match(source, /row\.kind === "audit"/);
|
||||||
assert.match(source, /href=\{row\.detailHref\}/);
|
assert.match(source, /href=\{row\.detailHref\}/);
|
||||||
assert.match(source, /Öffnen/);
|
assert.match(source, /Öffnen/);
|
||||||
assert.match(source, /Pipeline läuft/);
|
assert.match(source, /row\.progress/);
|
||||||
|
assert.match(source, /progressbar/);
|
||||||
|
assert.match(source, /row\.retry/);
|
||||||
|
assert.match(source, /Versuch/);
|
||||||
|
assert.match(source, /Erneut starten/);
|
||||||
|
assert.match(source, /retryAuditRun/);
|
||||||
assert.match(source, /getGenerationStatusLabel\(row\)/);
|
assert.match(source, /getGenerationStatusLabel\(row\)/);
|
||||||
assert.match(source, /row\.errorSummary/);
|
assert.match(source, /row\.errorSummary/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -122,3 +122,24 @@ test("audits dashboard query suppresses generation rows once a final audit exist
|
|||||||
"Generation rows should surface run or stage errors.",
|
"Generation rows should surface run or stage errors.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("audits dashboard query hides child generation rows behind root audit runs", async () => {
|
||||||
|
const auditsSource = await source("convex/audits.ts");
|
||||||
|
const querySource = extractExportSource(auditsSource, "listDashboardRows");
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
querySource,
|
||||||
|
/rootAuditRunLeadIds/,
|
||||||
|
"Query should track lead ids that already have root audit runs.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
querySource,
|
||||||
|
/run\.type\s*===\s*"audit_generation"[\s\S]*rootAuditRunLeadIds\.has\(run\.leadId\)/,
|
||||||
|
"Child audit_generation rows should be skipped when a root audit run for the same lead is already visible.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
querySource,
|
||||||
|
/continue/,
|
||||||
|
"Child generation rows should be skipped instead of rendered as a duplicate card.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
23
tests/campaign-form-dialog-source.test.ts
Normal file
23
tests/campaign-form-dialog-source.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const campaignFormDialogPath = join(
|
||||||
|
process.cwd(),
|
||||||
|
"components",
|
||||||
|
"campaigns",
|
||||||
|
"campaign-form-dialog.tsx",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("CampaignFormDialog presents campaigns as lead discovery only", async () => {
|
||||||
|
const source = await readFile(campaignFormDialogPath, "utf8");
|
||||||
|
|
||||||
|
assert.match(source, /Max\. neue Leads/);
|
||||||
|
assert.doesNotMatch(source, /Max\. Audits/);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/maxAuditsPerRun:\s*campaign\.maxAuditsPerRun\s*\?\?\s*campaignFormDefaults\.maxAuditsPerRun/,
|
||||||
|
);
|
||||||
|
assert.match(source, /maxAuditsPerRun:\s*values\.maxAuditsPerRun/);
|
||||||
|
});
|
||||||
@@ -29,6 +29,8 @@ test("campaign board renders campaigns as responsive cards", async () => {
|
|||||||
assert.match(source, /openEditDialog\(campaign\)/);
|
assert.match(source, /openEditDialog\(campaign\)/);
|
||||||
assert.match(source, /toggleCampaign\(campaign\)/);
|
assert.match(source, /toggleCampaign\(campaign\)/);
|
||||||
assert.match(source, /runCampaign\(campaign\)/);
|
assert.match(source, /runCampaign\(campaign\)/);
|
||||||
|
assert.match(source, /Lead-Limit:\s*\{campaign\.maxNewLeadsPerRun\}/);
|
||||||
|
assert.doesNotMatch(source, /Limits:\s*L/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("campaign board surfaces recent run logs", async () => {
|
test("campaign board surfaces recent run logs", async () => {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ test("settings metadata rejects secret-like keys", () => {
|
|||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
"smtp.password",
|
"smtp.password",
|
||||||
"googlePlacesToken",
|
"googlePlacesToken",
|
||||||
|
"LOCAL_BUSINESS_DATA_API_KEY",
|
||||||
|
"rapidapi.token",
|
||||||
"provider_secret",
|
"provider_secret",
|
||||||
"convex credential",
|
"convex credential",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ test("duplicate detection uses placeId and websiteDomain", () => {
|
|||||||
const existingLeads = [
|
const existingLeads = [
|
||||||
{
|
{
|
||||||
googlePlaceId: "dup-1",
|
googlePlaceId: "dup-1",
|
||||||
|
sourceBusinessId: "business-dup-1",
|
||||||
websiteDomain: "other.de",
|
websiteDomain: "other.de",
|
||||||
email: "blocked@example.de",
|
email: "blocked@example.de",
|
||||||
},
|
},
|
||||||
@@ -233,6 +234,31 @@ test("duplicate detection uses placeId and websiteDomain", () => {
|
|||||||
googlePrimaryType: null,
|
googlePrimaryType: null,
|
||||||
googleMapsUrl: null,
|
googleMapsUrl: null,
|
||||||
sourceProvider: "google_places",
|
sourceProvider: "google_places",
|
||||||
|
sourceBusinessId: "business-other",
|
||||||
|
sourceFetchedAt: 0,
|
||||||
|
},
|
||||||
|
existingLeads,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isDuplicateCandidate(
|
||||||
|
{
|
||||||
|
placeId: "none",
|
||||||
|
businessName: "Test",
|
||||||
|
address: "A",
|
||||||
|
websiteUrl: null,
|
||||||
|
websiteDomain: null,
|
||||||
|
phone: null,
|
||||||
|
rating: null,
|
||||||
|
userRatingCount: null,
|
||||||
|
businessStatus: null,
|
||||||
|
googleTypes: [],
|
||||||
|
googlePrimaryType: null,
|
||||||
|
googleMapsUrl: null,
|
||||||
|
sourceProvider: "local_business_data",
|
||||||
|
sourceBusinessId: "business-dup-1",
|
||||||
sourceFetchedAt: 0,
|
sourceFetchedAt: 0,
|
||||||
},
|
},
|
||||||
existingLeads,
|
existingLeads,
|
||||||
@@ -439,9 +465,10 @@ test("probable duplicates are detected by normalized company+address or normaliz
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blacklist matches include google_place_id, domain, company and phone", () => {
|
test("blacklist matches include source ids, domain, company and phone", () => {
|
||||||
const candidate = {
|
const candidate = {
|
||||||
placeId: "place-blacklisted",
|
placeId: "place-blacklisted",
|
||||||
|
sourceBusinessId: "business-blacklisted",
|
||||||
businessName: "Muster GmbH",
|
businessName: "Muster GmbH",
|
||||||
address: "A",
|
address: "A",
|
||||||
websiteUrl: "https://www.Blocked.de",
|
websiteUrl: "https://www.Blocked.de",
|
||||||
@@ -461,6 +488,7 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
|
|||||||
|
|
||||||
assert.deepEqual(getBlacklistLookupValues(candidate), [
|
assert.deepEqual(getBlacklistLookupValues(candidate), [
|
||||||
{ type: "google_place_id", normalizedValue: "place-blacklisted" },
|
{ type: "google_place_id", normalizedValue: "place-blacklisted" },
|
||||||
|
{ type: "source_business_id", normalizedValue: "business-blacklisted" },
|
||||||
{ type: "domain", normalizedValue: "blocked.de" },
|
{ type: "domain", normalizedValue: "blocked.de" },
|
||||||
{ type: "company", normalizedValue: "muster gmbh" },
|
{ type: "company", normalizedValue: "muster gmbh" },
|
||||||
{ type: "phone", normalizedValue: "4930555123" },
|
{ type: "phone", normalizedValue: "4930555123" },
|
||||||
@@ -477,6 +505,11 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
|
|||||||
value: "place-blacklisted",
|
value: "place-blacklisted",
|
||||||
normalizedValue: "place-blacklisted",
|
normalizedValue: "place-blacklisted",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "source_business_id",
|
||||||
|
value: "business-blacklisted",
|
||||||
|
normalizedValue: "business-blacklisted",
|
||||||
|
},
|
||||||
{ type: "domain", value: "blocked.de", normalizedValue: "blocked.de" },
|
{ type: "domain", value: "blocked.de", normalizedValue: "blocked.de" },
|
||||||
{ type: "company", value: "Muster GmbH", normalizedValue: "muster gmbh" },
|
{ type: "company", value: "Muster GmbH", normalizedValue: "muster gmbh" },
|
||||||
{ type: "phone", value: "+49 30 555 123", normalizedValue: "4930555123" },
|
{ type: "phone", value: "+49 30 555 123", normalizedValue: "4930555123" },
|
||||||
@@ -498,7 +531,15 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
|
|||||||
const matchTypes = matches.map((match) => match.type).sort();
|
const matchTypes = matches.map((match) => match.type).sort();
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
matchTypes,
|
matchTypes,
|
||||||
["company", "domain", "google_place_id", "phone", "phone", "email"].sort(),
|
[
|
||||||
|
"company",
|
||||||
|
"domain",
|
||||||
|
"google_place_id",
|
||||||
|
"source_business_id",
|
||||||
|
"phone",
|
||||||
|
"phone",
|
||||||
|
"email",
|
||||||
|
].sort(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -522,6 +563,7 @@ test("company normalization for blacklist lookup uses text normalization", () =>
|
|||||||
|
|
||||||
assert.deepEqual(getBlacklistLookupValues(candidate), [
|
assert.deepEqual(getBlacklistLookupValues(candidate), [
|
||||||
{ type: "google_place_id", normalizedValue: "place-company-spaces" },
|
{ type: "google_place_id", normalizedValue: "place-company-spaces" },
|
||||||
|
{ type: "source_business_id", normalizedValue: "place-company-spaces" },
|
||||||
{ type: "company", normalizedValue: "muster gmbh" },
|
{ type: "company", normalizedValue: "muster gmbh" },
|
||||||
{ type: "phone", normalizedValue: "4930555123" },
|
{ type: "phone", normalizedValue: "4930555123" },
|
||||||
{ type: "phone", normalizedValue: "+49 30 555 123" },
|
{ type: "phone", normalizedValue: "+49 30 555 123" },
|
||||||
|
|||||||
152
tests/lead-discovery-local-business.test.ts
Normal file
152
tests/lead-discovery-local-business.test.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LOCAL_BUSINESS_DATA_HOST,
|
||||||
|
buildLocalBusinessSearchUrl,
|
||||||
|
getLocalBusinessSearchSpec,
|
||||||
|
normalizeLocalBusinessSearchResponse,
|
||||||
|
} from "../lib/lead-discovery-local-business";
|
||||||
|
|
||||||
|
test("Local Business Data search URL uses RapidAPI host-compatible query params", () => {
|
||||||
|
const url = new URL(
|
||||||
|
buildLocalBusinessSearchUrl({
|
||||||
|
query: "Anwalt in 10115 Deutschland",
|
||||||
|
limit: 9999,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(url.origin, `https://${LOCAL_BUSINESS_DATA_HOST}`);
|
||||||
|
assert.equal(url.pathname, "/search");
|
||||||
|
assert.equal(url.searchParams.get("query"), "Anwalt in 10115 Deutschland");
|
||||||
|
assert.equal(url.searchParams.get("limit"), "500");
|
||||||
|
assert.equal(url.searchParams.get("language"), "de");
|
||||||
|
assert.equal(url.searchParams.get("region"), "de");
|
||||||
|
assert.equal(url.searchParams.get("extract_emails_and_contacts"), "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Local Business Data campaign spec builds the SaaS lead-discovery query from niche and PLZ", () => {
|
||||||
|
const spec = getLocalBusinessSearchSpec({
|
||||||
|
categoryMode: "custom",
|
||||||
|
category: "Anderes",
|
||||||
|
customSearchTerm: "Webdesigner fuer Restaurants",
|
||||||
|
postalCode: "79098",
|
||||||
|
maxNewLeads: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(spec.query, "Webdesigner fuer Restaurants in 79098 Deutschland");
|
||||||
|
assert.equal(spec.limit, 8);
|
||||||
|
assert.match(spec.url, /extract_emails_and_contacts=true/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Local Business Data response normalizes direct business emails into lead candidates", () => {
|
||||||
|
const candidates = normalizeLocalBusinessSearchResponse(
|
||||||
|
{
|
||||||
|
status: "OK",
|
||||||
|
request_id: "req-123",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
business_id: "biz-1",
|
||||||
|
place_id: "place-1",
|
||||||
|
name: "Kanzlei Beispiel",
|
||||||
|
full_address: "Musterstrasse 1, 10115 Berlin",
|
||||||
|
website: "https://www.beispiel-kanzlei.de/kontakt",
|
||||||
|
phone_number: "+49 30 123456",
|
||||||
|
rating: 4.7,
|
||||||
|
review_count: 31,
|
||||||
|
business_status: "OPERATIONAL",
|
||||||
|
types: ["lawyer"],
|
||||||
|
google_maps_url: "https://maps.google.com/?cid=biz-1",
|
||||||
|
emails: ["Herr.Bewerber@beispiel-kanzlei.de", "Info@Beispiel-Kanzlei.de"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
1717480000000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(candidates.length, 1);
|
||||||
|
assert.equal(candidates[0]?.sourceProvider, "local_business_data");
|
||||||
|
assert.equal(candidates[0]?.sourceBusinessId, "biz-1");
|
||||||
|
assert.equal(candidates[0]?.placeId, "place-1");
|
||||||
|
assert.equal(candidates[0]?.businessName, "Kanzlei Beispiel");
|
||||||
|
assert.equal(candidates[0]?.websiteDomain, "beispiel-kanzlei.de");
|
||||||
|
assert.equal(candidates[0]?.contactEmails?.length, 2);
|
||||||
|
assert.equal(candidates[0]?.contactEmails?.[1]?.email, "info@beispiel-kanzlei.de");
|
||||||
|
assert.equal(candidates[0]?.contactEmails?.[1]?.emailSource, "local_business_data");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Local Business Data response reads extracted emails from emails_and_contacts", () => {
|
||||||
|
const candidates = normalizeLocalBusinessSearchResponse(
|
||||||
|
{
|
||||||
|
status: "OK",
|
||||||
|
request_id: "req-nested-email",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
business_id: "biz-nested",
|
||||||
|
name: "PORZIG Immobilien GmbH",
|
||||||
|
full_address: "Silberstrasse 18, 08451 Crimmitschau",
|
||||||
|
website: "https://porzig.info",
|
||||||
|
phone_number: "+49 3762 759775",
|
||||||
|
emails_and_contacts: {
|
||||||
|
emails: [
|
||||||
|
"info@porzig.info",
|
||||||
|
"Info@Porzig.Info",
|
||||||
|
"makler@porzig.info",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
1717480000003,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(candidates.length, 1);
|
||||||
|
assert.equal(candidates[0]?.contactEmails?.length, 2);
|
||||||
|
assert.equal(candidates[0]?.email, "info@porzig.info");
|
||||||
|
assert.equal(candidates[0]?.emailSource, "local_business_data");
|
||||||
|
assert.deepEqual(
|
||||||
|
candidates[0]?.contactEmails?.map((entry) => entry.email),
|
||||||
|
["info@porzig.info", "makler@porzig.info"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Local Business Data response accepts object-wrapped business arrays", () => {
|
||||||
|
const candidates = normalizeLocalBusinessSearchResponse(
|
||||||
|
{
|
||||||
|
status: "OK",
|
||||||
|
request_id: "req-456",
|
||||||
|
data: {
|
||||||
|
businesses: [
|
||||||
|
{
|
||||||
|
business_id: "biz-2",
|
||||||
|
name: "Malerbetrieb Beispiel",
|
||||||
|
address: "Hauptstrasse 2, 79098 Freiburg",
|
||||||
|
site: "https://maler.example",
|
||||||
|
phone: "+49 761 123",
|
||||||
|
email: "kontakt@maler.example",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
1717480000001,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(candidates.length, 1);
|
||||||
|
assert.equal(candidates[0]?.placeId, "biz-2");
|
||||||
|
assert.equal(candidates[0]?.email, "kontakt@maler.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Local Business Data response throws provider error messages", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
normalizeLocalBusinessSearchResponse(
|
||||||
|
{
|
||||||
|
status: "ERROR",
|
||||||
|
request_id: "req-error",
|
||||||
|
error: { message: "Missing query", code: 400 },
|
||||||
|
},
|
||||||
|
1717480000002,
|
||||||
|
),
|
||||||
|
/Local Business Data API error 400: Missing query/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -61,24 +61,49 @@ test("persistDiscoveredLeads does not schedule website enrichment jobs directly"
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("processCampaignRun schedules website enrichment after lead persistence", () => {
|
test("persistDiscoveredLeads backfills missing emails on duplicate re-runs only", () => {
|
||||||
|
const source = extractExportSource("persistDiscoveredLeads");
|
||||||
|
|
||||||
|
assert.match(source, /getUsableContactEmail\(candidate\)/);
|
||||||
|
assert.match(source, /duplicateLeadForEmailBackfill/);
|
||||||
|
assert.match(source, /ctx\.db\.patch\(\s*duplicateLeadForEmailBackfill\._id/);
|
||||||
|
assert.match(source, /normalizedEmail:\s*usableEmail\.email/);
|
||||||
|
assert.match(source, /email:\s*usableEmail\.email/);
|
||||||
|
assert.match(source, /contactStatus\s*=\s*"new"/);
|
||||||
|
assert.match(source, /contactStatus\s*!==\s*"do_not_contact"/);
|
||||||
|
assert.match(source, /blacklistStatus\s*!==\s*"blocked"/);
|
||||||
|
assert.match(source, /duplicateByEmailRows\.length\s*===\s*0/);
|
||||||
|
assert.doesNotMatch(source, /internal\.websiteEnrichment\.queueLeadEnrichment/);
|
||||||
|
assert.doesNotMatch(source, /internal\.pageSpeed\.queueLeadPageSpeedAudit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("processCampaignRun uses Local Business Data and does not schedule website enrichment", () => {
|
||||||
const source = extractExportSource("processCampaignRun");
|
const source = extractExportSource("processCampaignRun");
|
||||||
|
|
||||||
const persistIndex = source.indexOf(
|
const persistIndex = source.indexOf(
|
||||||
"internal.leadDiscovery.persistDiscoveredLeads",
|
"internal.leadDiscovery.persistDiscoveredLeads",
|
||||||
);
|
);
|
||||||
const queueCall = source.indexOf("internal.websiteEnrichment.queueLeadEnrichment");
|
const queueCall = source.indexOf("internal.websiteEnrichment.queueLeadEnrichment");
|
||||||
const eventMessageIndex = source.indexOf("Website-Kontaktanreicherung geplant.");
|
|
||||||
|
|
||||||
assert.notEqual(persistIndex, -1, "processCampaignRun should persist discovered leads");
|
assert.notEqual(persistIndex, -1, "processCampaignRun should persist discovered leads");
|
||||||
assert.notEqual(queueCall, -1, "processCampaignRun should schedule website enrichment");
|
assert.equal(
|
||||||
assert.notEqual(eventMessageIndex, -1, "processCampaignRun should append enrichment schedule events");
|
queueCall,
|
||||||
assert.ok(
|
-1,
|
||||||
persistIndex < queueCall,
|
"Campaign discovery must not schedule website enrichment in the SaaS flow",
|
||||||
"processCampaignRun should schedule enrichment after persistence succeeds",
|
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.equal(
|
||||||
queueCall < eventMessageIndex,
|
source.includes("GOOGLE_GEOCODING_API_KEY") || source.includes("GOOGLE_PLACES_API_KEY"),
|
||||||
"processCampaignRun should append enrichment event after scheduling",
|
false,
|
||||||
|
"Campaign discovery should no longer require Google lead-discovery keys",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/LOCAL_BUSINESS_DATA_API_KEY/,
|
||||||
|
"Campaign discovery should read the Local Business Data API key",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/local_business_data/,
|
||||||
|
"Campaign discovery should record Local Business Data as the source",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,25 +80,34 @@ test("LeadsReviewTable uses compact card summaries with modal review details", a
|
|||||||
"Location should use overflow-safe text classes in compact card.",
|
"Location should use overflow-safe text classes in compact card.",
|
||||||
);
|
);
|
||||||
|
|
||||||
const emailSpanMatch = source.match(
|
const emailAnchorMatch = source.match(
|
||||||
/<span className="([^"]+)">\s*\{lead\.email \|\| "Keine E-Mail"\}\s*<\/span>/,
|
/<a className="([^"]+)" href=\{emailHref\}>\s*\{lead\.email\}\s*<\/a>/,
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
emailSpanMatch !== null &&
|
emailAnchorMatch !== null &&
|
||||||
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(
|
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(
|
||||||
emailSpanMatch[1],
|
emailAnchorMatch[1],
|
||||||
),
|
),
|
||||||
"Lead email should use overflow-safe text classes in compact card.",
|
"Lead email should use an overflow-safe mailto link in compact card.",
|
||||||
);
|
);
|
||||||
|
assert.match(source, /<span className="[^"]*">\s*Keine E-Mail\s*<\/span>/);
|
||||||
|
|
||||||
const phoneSpanMatch = source.match(
|
const phoneAnchorMatch = source.match(
|
||||||
/<span className="([^"]+)">\s*\{lead\.phone\}\s*<\/span>/,
|
/<a className="([^"]+)" href=\{phoneHref\}>\s*\{lead\.phone\}\s*<\/a>/,
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
phoneSpanMatch !== null &&
|
phoneAnchorMatch !== null &&
|
||||||
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(phoneSpanMatch[1]),
|
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(phoneAnchorMatch[1]),
|
||||||
"Lead phone should use overflow-safe text classes in compact card.",
|
"Lead phone should use an overflow-safe tel link in compact card.",
|
||||||
);
|
);
|
||||||
|
assert.match(source, /toEmailHref/);
|
||||||
|
assert.match(source, /mailto:\$\{normalizedEmail\}/);
|
||||||
|
assert.match(source, /toPhoneHref/);
|
||||||
|
assert.match(source, /tel:\$\{dialablePhone\}/);
|
||||||
|
assert.match(source, /toWebsiteHref/);
|
||||||
|
assert.match(source, /href=\{websiteHref\}/);
|
||||||
|
assert.match(source, /target="_blank"/);
|
||||||
|
assert.match(source, /rel="noreferrer"/);
|
||||||
|
|
||||||
assert.match(source, /Kontaktstatus/);
|
assert.match(source, /Kontaktstatus/);
|
||||||
assert.match(source, /Review-E-Mail/);
|
assert.match(source, /Review-E-Mail/);
|
||||||
@@ -122,3 +131,15 @@ test("LeadsReviewTable exposes count filters and live status feedback", async ()
|
|||||||
assert.match(source, /role="status"/);
|
assert.match(source, /role="status"/);
|
||||||
assert.match(source, /role="alert"/);
|
assert.match(source, /role="alert"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("LeadsReviewTable exposes explicit manual audit start action", async () => {
|
||||||
|
const source = await readFile(leadsReviewPath, "utf8");
|
||||||
|
|
||||||
|
assert.match(source, /api\.pageSpeed\.requestLeadAudit/);
|
||||||
|
assert.match(source, /api\.pageSpeed\.getLeadAuditStartStates/);
|
||||||
|
assert.match(source, /Audit starten/);
|
||||||
|
assert.match(source, /auditStartDisabledReason/);
|
||||||
|
assert.match(source, /Website fehlt|Keine Website/);
|
||||||
|
assert.match(source, /Audit l(?:ä|ae)uft|Audit läuft/);
|
||||||
|
assert.doesNotMatch(source, /api\.websiteEnrichment\.queueLeadEnrichment/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ test("integration readiness covers all MVP providers", () => {
|
|||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
integrationReadinessDefinitions.map((definition) => definition.id),
|
integrationReadinessDefinitions.map((definition) => definition.id),
|
||||||
[
|
[
|
||||||
"google",
|
"local_business_data",
|
||||||
"pagespeed",
|
"pagespeed",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
"screenshotone",
|
"screenshotone",
|
||||||
@@ -24,24 +24,21 @@ test("integration readiness covers all MVP providers", () => {
|
|||||||
|
|
||||||
test("integration readiness reports missing configuration without leaking values", () => {
|
test("integration readiness reports missing configuration without leaking values", () => {
|
||||||
const rows = getIntegrationReadiness({
|
const rows = getIntegrationReadiness({
|
||||||
GOOGLE_GEOCODING_API_KEY: "secret-google",
|
LOCAL_BUSINESS_DATA_API_KEY: "secret-local-business",
|
||||||
GOOGLE_PLACES_API_KEY: "secret-places",
|
|
||||||
PAGESPEED_API_KEY: "",
|
PAGESPEED_API_KEY: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const google = rows.find((row) => row.id === "google");
|
const localBusinessData = rows.find((row) => row.id === "local_business_data");
|
||||||
const pageSpeed = rows.find((row) => row.id === "pagespeed");
|
const pageSpeed = rows.find((row) => row.id === "pagespeed");
|
||||||
|
|
||||||
assert.equal(google?.status, "configured");
|
assert.equal(localBusinessData?.status, "configured");
|
||||||
assert.equal(pageSpeed?.status, "missing");
|
assert.equal(pageSpeed?.status, "missing");
|
||||||
assert.equal(JSON.stringify(rows).includes("secret-google"), false);
|
assert.equal(JSON.stringify(rows).includes("secret-local-business"), false);
|
||||||
assert.equal(JSON.stringify(rows).includes("secret-places"), false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("integration readiness treats ScreenshotOne as required and Jina as optional", () => {
|
test("integration readiness treats ScreenshotOne as required and Jina as optional", () => {
|
||||||
const rows = getIntegrationReadiness({
|
const rows = getIntegrationReadiness({
|
||||||
GOOGLE_GEOCODING_API_KEY: "secret-google",
|
LOCAL_BUSINESS_DATA_API_KEY: "secret-local-business",
|
||||||
GOOGLE_PLACES_API_KEY: "secret-places",
|
|
||||||
PAGESPEED_API_KEY: "secret-pagespeed",
|
PAGESPEED_API_KEY: "secret-pagespeed",
|
||||||
PAGESPEED_TIMEOUT_MS: "60000",
|
PAGESPEED_TIMEOUT_MS: "60000",
|
||||||
OPENROUTER_API_KEY: "secret-openrouter",
|
OPENROUTER_API_KEY: "secret-openrouter",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user