feat: build audit outreach review workspace

This commit is contained in:
Matthias
2026-06-05 16:47:22 +02:00
parent 1feccb9bdf
commit 5a42c637c6
15 changed files with 1786 additions and 38 deletions

View File

@@ -1,10 +1,11 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
import { OutreachReviewWorkspace } from "@/components/outreach/outreach-review-workspace";
export default function OutreachPage() {
return (
<DashboardPlaceholderPage
description="E-Mail-Entwürfe, Telefon-Skripte und manuelle Versandfreigaben folgen in TASK-13 und TASK-14."
title="Review"
/>
<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">
<OutreachReviewWorkspace />
</div>
</main>
);
}

View File

@@ -4,7 +4,7 @@ title: Build the audit and outreach review workspace
status: In Progress
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 12:13'
updated_date: '2026-06-05 14:21'
labels:
- mvp
- review
@@ -26,20 +26,23 @@ Create the internal review workspace where Matthias can inspect and edit the fin
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Review workspace shows lead details, contact sources, priority reason, contact strategy, audit summary, used skills, and raw/source detail toggles
- [ ] #2 Audit content can be edited and manually approved before the public page shows customer-facing content
- [ ] #3 Email subject and body are editable and generated as exactly one recommended version by default
- [ ] #4 Phone script is available for Erst anrufen and Kontakt fehlt leads when a phone number exists
- [ ] #5 Freigabe offen state clearly separates Audit veröffentlichen from E-Mail freigeben und senden
- [x] #1 Review workspace shows lead details, contact sources, priority reason, contact strategy, audit summary, used skills, and raw/source detail toggles
- [x] #2 Audit content can be edited and manually approved before the public page shows customer-facing content
- [x] #3 Email subject and body are editable and generated as exactly one recommended version by default
- [x] #4 Phone script is available for Erst anrufen and Kontakt fehlt leads when a phone number exists
- [x] #5 Freigabe offen state clearly separates Audit veröffentlichen from E-Mail freigeben und senden
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Wire PageSpeed completion into audit_generation queue
2. Verify handoff with regression tests
3. Build review workspace UI and edit/approval flows
4. Verify state transitions back into dashboard/funnel
1. Orchestrator updates TASK-13 plan and coordinates only; no direct feature coding.
2. Worker A (gpt-5.5 medium) uses TDD to add Convex outreach review contracts: listReviewWorkspace, saveReviewDraft, approveEmailDraft.
3. Worker B (gpt-5.5 medium) uses TDD to replace /dashboard/outreach placeholder with the review workspace UI using the new contracts.
4. Worker C (gpt-5.5 medium) uses TDD to separate Audit veröffentlichen from E-Mail freigeben and keep sending out of TASK-13.
5. Worker D (gpt-5.5 medium) uses TDD to cover phone-script visibility and funnel/review state regressions.
6. Spec and code-quality reviewer agents review each worker output before the next dependent slice proceeds.
7. Orchestrator runs final verification: pnpm test, pnpm exec tsc --noEmit, pnpm lint, pnpm build; then updates Backlog notes and checked ACs without marking Done.
<!-- SECTION:PLAN:END -->
## Implementation Notes
@@ -48,4 +51,8 @@ Create the internal review workspace where Matthias can inspect and edit the fin
Starting TASK-13 with the missing PageSpeed-to-audit-generation handoff so generated audit content exists for the review workspace.
Implemented first TASK-13 prerequisite: PageSpeed completion now queues audit_generation for the same lead via internal.auditGeneration.queueLeadAuditGeneration. Queue failures are logged as warnings and do not fail the PageSpeed run. Verified with pnpm test (245/245), pnpm exec tsc --noEmit, pnpm lint (0 errors, existing generated warnings), and pnpm build using .env.local.
2026-06-05: Expanded TASK-13 into subagent-driven, test-driven execution plan on branch codex-task-13-review-workspace. Orchestrator will not hand-code feature patches; workers use gpt-5.5 medium and RED/GREEN tests.
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 -->

View File

@@ -0,0 +1,75 @@
---
id: TASK-28
title: Diagnose dashboard initial-load retry loop
status: In Progress
assignee: []
created_date: '2026-06-05 13:46'
updated_date: '2026-06-05 14:01'
labels: []
dependencies: []
priority: high
ordinal: 30000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Find the root cause of the repeated dashboard requests on initial load, especially the repeated GET /dashboard/leads entries, and implement a targeted fix only after reproducing and tracing the loop.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Root cause is identified with evidence from the relevant dashboard/auth/navigation code
- [x] #2 A minimal fix prevents repeated dashboard/leads requests on initial load
- [x] #3 Relevant tests or verification commands are run
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Read provided logs and identify repeated route pattern
2. Trace dashboard auth, routing, and navigation layers
3. Reproduce the repeated requests locally or via tests
4. Confirm the root cause with the smallest evidence-producing change
5. Implement one targeted fix
6. Run focused verification and update acceptance criteria
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Evidence gathered:
- User-provided log repeatedly shows successful GET /dashboard/leads during dashboard use.
- Existing Next dev log shows a hydration failure in components/dashboard-theme.tsx:88 inside DashboardThemeToggle during /dashboard rendering: server rendered Moon/aria-pressed=false while client rendered Sun/aria-pressed=true.
- Next local docs confirm client/server render differences during hydration cause the tree to be regenerated.
- Separate WIP issue observed: /dashboard/outreach imports a missing component, which can also produce repeated dev overlay errors, but the initial dashboard hydration error is the targeted root cause for this task.
Implemented targeted fix:
- DashboardThemeProvider now uses useSyncExternalStore with a stable server snapshot of light, preventing the server/client icon and aria-pressed mismatch on initial dashboard hydration.
- Added tests/dashboard-theme.test.ts to guard against reintroducing localStorage reads in the initial render path.
Verification:
- node --test .test-output/tests/dashboard-theme.test.js passes.
- pnpm test compiles and includes the new dashboard theme test as passing, but the full run still fails in existing TASK-13 outreach WIP test OutreachReviewWorkspace uses the review workspace API and required controls.
- pnpm lint no longer reports components/dashboard-theme.tsx; it still fails in existing components/outreach/outreach-review-workspace.tsx WIP.
Additional verification note:
- pnpm exec tsc --noEmit fails in existing components/outreach/outreach-review-workspace.tsx WIP with type mismatches and missing fields; this is separate from the dashboard theme hydration fix and was already part of unrelated TASK-13 worktree changes.
User retest on 2026-06-05 falsified the first hydration-only fix. New evidence: pnpm dev still logs repeated GET /dashboard/leads every roughly 300-400ms with 200 responses, with proxy.ts taking ~165-522ms each time, followed by one get-session and two convex token requests. Re-entering systematic debugging; no more fixes until request initiator is identified.
Added temporary development-only proxy instrumentation for /dashboard/leads request classification. It logs non-sensitive request headers: accept, rsc, next-router-prefetch, next-router-segment-prefetch, next-hmr-refresh, next-url, sec-fetch-mode, purpose, referer, state-tree presence, and user-agent. Remove after confirming requester.
Corrected root cause after user retest and header instrumentation:
- First hydration hypothesis was incomplete and did not stop the request fan-out.
- Development-only proxy header instrumentation showed real browser /dashboard/leads requests were same-origin CORS fetches with next-url set to the current dashboard route, not document reloads, HMR refreshes, or server redirect loops.
- Code search showed the repeated target originates from visible Next Link surfaces: dashboard sidebar nav plus many LeadFunnelCard action links that can share href /dashboard/leads. Next App Router prefetches visible links, and each protected prefetch crosses proxy.ts and isAuthenticated(), producing many 200 GET /dashboard/leads entries.
Implemented fix:
- Set prefetch={false} on DashboardSidebar nav links and LeadFunnelCard action links to keep click navigation but stop automatic protected-route prefetch fan-out.
- Removed temporary proxy/fetch diagnostics.
- Added tests/dashboard-prefetch.test.ts to lock this behavior.
Verification:
- pnpm exec tsc -p tsconfig.test.json passes.
- node --test .test-output/tests/dashboard-prefetch.test.js .test-output/tests/dashboard-theme.test.js passes.
- pnpm test passes 260/260.
- pnpm lint passes with existing generated/unused warnings only, no errors.
<!-- SECTION:NOTES:END -->

View File

@@ -55,6 +55,7 @@ export function DashboardSidebar() {
)}
href={item.href}
key={item.href}
prefetch={false}
>
<Icon className="size-4" />
<span>{item.label}</span>

View File

@@ -6,7 +6,7 @@ import {
type ReactNode,
useContext,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import { Button } from "@/components/ui/button";
@@ -20,34 +20,49 @@ type DashboardThemeContextValue = {
};
const storageKey = "webdev-dashboard-theme";
const themeChangeEvent = "webdev-dashboard-theme-change";
const DashboardThemeContext =
createContext<DashboardThemeContextValue | null>(null);
export function DashboardThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<DashboardTheme>(() => {
if (typeof window === "undefined") {
return "light";
}
function isDashboardTheme(value: string | null): value is DashboardTheme {
return value === "dark" || value === "light";
}
function getStoredDashboardTheme(): DashboardTheme {
const storedTheme = window.localStorage.getItem(storageKey);
if (storedTheme === "dark" || storedTheme === "light") {
return storedTheme;
}
return isDashboardTheme(storedTheme) ? storedTheme : "light";
}
function getServerDashboardTheme(): DashboardTheme {
return "light";
});
}
function subscribeToDashboardTheme(onStoreChange: () => void) {
window.addEventListener("storage", onStoreChange);
window.addEventListener(themeChangeEvent, onStoreChange);
return () => {
window.removeEventListener("storage", onStoreChange);
window.removeEventListener(themeChangeEvent, onStoreChange);
};
}
export function DashboardThemeProvider({ children }: { children: ReactNode }) {
const theme = useSyncExternalStore(
subscribeToDashboardTheme,
getStoredDashboardTheme,
getServerDashboardTheme,
);
const value = useMemo<DashboardThemeContextValue>(
() => ({
theme,
toggleTheme: () => {
setTheme((currentTheme) => {
const nextTheme = currentTheme === "dark" ? "light" : "dark";
const nextTheme = theme === "dark" ? "light" : "dark";
window.localStorage.setItem(storageKey, nextTheme);
return nextTheme;
});
window.dispatchEvent(new Event(themeChangeEvent));
},
}),
[theme],

View File

@@ -170,6 +170,7 @@ function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
<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"
href={stageActionHref[card.stageId]}
prefetch={false}
>
{card.nextAction}
<ArrowRight className="size-4" />

View File

@@ -0,0 +1,647 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "convex/react";
import type { FunctionReturnType } from "convex/server";
import { ChevronDown, ChevronRight, ExternalLink, MailCheck, Save } from "lucide-react";
import Link from "next/link";
import { api } from "@/convex/_generated/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
type ReviewWorkspaceListResult = FunctionReturnType<
typeof api.outreach.listReviewWorkspace
>;
type ReviewWorkspaceItem = NonNullable<ReviewWorkspaceListResult>[number];
type UsedSkill = ReviewWorkspaceItem["usedSkills"][number];
type DraftState = {
auditBody: string;
auditSummary: string;
emailBody: string;
emailSubject: string;
followUpDraft: string;
phoneScript: string;
};
const emptyDraft: DraftState = {
auditBody: "",
auditSummary: "",
emailBody: "",
emailSubject: "",
followUpDraft: "",
phoneScript: "",
};
const textAreaClassName =
"min-h-24 w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50";
function getDraft(record: ReviewWorkspaceItem): DraftState {
const outreach = record.latestOutreach;
return {
auditBody: record.audit?.publicBody ?? "",
auditSummary: record.audit?.publicSummary ?? "",
emailBody: outreach?.emailBody ?? "",
emailSubject: outreach?.emailSubject ?? "",
followUpDraft: outreach?.followUpDraft ?? "",
phoneScript: outreach?.phoneScript ?? "",
};
}
function compactText(value?: string | null, fallback = "Offen") {
const trimmed = value?.trim();
return trimmed ? trimmed : fallback;
}
function formatStrategy(strategy?: string | null) {
const labels: Record<string, string> = {
call_first: "Erst anrufen",
defer: "Zurückstellen",
do_not_contact: "Nicht kontaktieren",
email_first: "Erst E-Mail",
};
return strategy ? labels[strategy] ?? strategy : "Strategie offen";
}
function formatRaw(value: unknown) {
if (value === undefined || value === null) {
return "Keine Rohdaten vorhanden.";
}
return JSON.stringify(value, null, 2);
}
function skillLabel(skill: UsedSkill) {
const name = compactText(skill?.name, "Skill");
return skill.category ? `${name} · ${skill.category}` : name;
}
function DetailToggle({
isOpen,
label,
onClick,
}: {
isOpen: boolean;
label: string;
onClick: () => void;
}) {
const Icon = isOpen ? ChevronDown : ChevronRight;
return (
<Button
aria-expanded={isOpen}
onClick={onClick}
size="sm"
type="button"
variant="outline"
>
<Icon className="size-3.5" />
{label}
</Button>
);
}
function FieldPair({ label, value }: { label: string; value?: string | null }) {
return (
<div className="min-w-0">
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
<dd className="mt-1 break-words text-sm">{compactText(value)}</dd>
</div>
);
}
function WorkspaceLoading() {
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
</header>
<div className="space-y-3">
{Array.from({ length: 3 }, (_, index) => (
<Skeleton className="h-56 rounded-lg" key={index} />
))}
</div>
</section>
);
}
export function OutreachReviewWorkspace() {
const records = useQuery(api.outreach.listReviewWorkspace, { limit: 100 });
const saveReviewDraft = useMutation(api.outreach.saveReviewDraft);
const approveEmailDraft = useMutation(api.outreach.approveEmailDraft);
const savePublicAuditContent = useMutation(api.audits.savePublicAuditContent);
const publishPublicAudit = useMutation(api.audits.publishPublicAudit);
const [drafts, setDrafts] = useState<Record<string, DraftState>>({});
const [openSources, setOpenSources] = useState<Record<string, boolean>>({});
const [openRaw, setOpenRaw] = useState<Record<string, boolean>>({});
const [busyAction, setBusyAction] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const rows = useMemo(() => records ?? [], [records]);
if (records === undefined) {
return <WorkspaceLoading />;
}
if (rows.length === 0) {
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
</header>
<Card>
<CardContent className="p-4">
<p className="text-sm font-medium">Keine offenen Reviews</p>
<p className="mt-1 text-sm text-muted-foreground">
Sobald Audit- und Outreach-Entwürfe bereitstehen, erscheinen sie hier.
</p>
</CardContent>
</Card>
</section>
);
}
const updateDraft = (
id: string,
field: keyof DraftState,
value: string,
) => {
const record = rows.find((row) => row.id === id);
setDrafts((current) => ({
...current,
[id]: {
...(current[id] ?? (record ? getDraft(record) : emptyDraft)),
[field]: value,
},
}));
};
const saveAudit = async (record: ReviewWorkspaceItem) => {
const auditId = record.audit?._id;
if (!auditId) {
setNotice("Audit kann ohne Audit-ID nicht gespeichert werden.");
return;
}
setBusyAction(`${record.id}:audit-save`);
setNotice(null);
try {
const draft = drafts[record.id] ?? getDraft(record);
await savePublicAuditContent({
id: auditId,
publicBody: draft.auditBody,
publicSummary: draft.auditSummary,
});
setNotice("Audit-Änderungen gespeichert.");
} catch {
setNotice("Audit-Änderungen konnten nicht gespeichert werden.");
} finally {
setBusyAction(null);
}
};
const publishAudit = async (record: ReviewWorkspaceItem) => {
const auditId = record.audit?._id;
if (!auditId) {
setNotice("Audit kann ohne Audit-ID nicht veröffentlicht werden.");
return;
}
setBusyAction(`${record.id}:audit-publish`);
setNotice(null);
try {
const draft = drafts[record.id] ?? getDraft(record);
await savePublicAuditContent({
id: auditId,
publicBody: draft.auditBody,
publicSummary: draft.auditSummary,
});
await publishPublicAudit({ id: auditId });
setNotice("Audit veröffentlicht.");
} catch {
setNotice("Audit konnte nicht veröffentlicht werden.");
} finally {
setBusyAction(null);
}
};
const saveOutreach = async (record: ReviewWorkspaceItem) => {
const outreach = record.latestOutreach;
if (!outreach) {
setNotice("Outreach-Entwurf kann ohne Outreach-ID nicht gespeichert werden.");
return;
}
const draft = drafts[record.id] ?? getDraft(record);
const strategy = outreach.strategy;
const hasCallablePhone =
Boolean(record.lead?.phone) &&
(strategy === "call_first" ||
record.lead?.contactStatus === "missing_contact");
setBusyAction(`${record.id}:outreach-save`);
setNotice(null);
try {
await saveReviewDraft({
id: outreach._id,
strategy,
emailBody: draft.emailBody,
emailSubject: draft.emailSubject,
followUpDraft: draft.followUpDraft,
...(hasCallablePhone ? { phoneScript: draft.phoneScript } : {}),
});
setNotice("Outreach-Entwurf gespeichert.");
} catch {
setNotice("Outreach-Entwurf konnte nicht gespeichert werden.");
} finally {
setBusyAction(null);
}
};
const approveEmail = async (record: ReviewWorkspaceItem) => {
const outreach = record.latestOutreach;
if (!outreach) {
setNotice("E-Mail kann ohne Outreach-ID nicht freigegeben werden.");
return;
}
const draft = drafts[record.id] ?? getDraft(record);
const strategy = outreach.strategy;
const hasCallablePhone =
Boolean(record.lead?.phone) &&
(strategy === "call_first" ||
record.lead?.contactStatus === "missing_contact");
setBusyAction(`${record.id}:email-approval`);
setNotice(null);
try {
await saveReviewDraft({
id: outreach._id,
strategy,
emailBody: draft.emailBody,
emailSubject: draft.emailSubject,
followUpDraft: draft.followUpDraft,
...(hasCallablePhone ? { phoneScript: draft.phoneScript } : {}),
});
await approveEmailDraft({ id: outreach._id });
setNotice("E-Mail freigegeben.");
} catch {
setNotice("E-Mail konnte nicht freigegeben werden.");
} finally {
setBusyAction(null);
}
};
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
<p className="max-w-3xl text-sm text-muted-foreground">
Audits, E-Mail-Empfehlung und Telefonnotizen prüfen, bevor etwas öffentlich
wird oder eine Freigabe erhält.
</p>
</header>
{notice ? (
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm">{notice}</p>
) : null}
<div className="space-y-3">
{rows.map((record) => {
const draft = drafts[record.id] ?? getDraft(record);
const lead = record.lead;
const audit = record.audit;
const outreach = record.latestOutreach;
const strategy = outreach?.strategy;
const contactSources = [
lead.email ? `E-Mail: ${lead.email}` : null,
lead.phone ? `Telefon: ${lead.phone}` : null,
...record.sourceSummaries.emailCandidates.map(
(candidate) =>
`${candidate.email} · ${candidate.emailSource}${
candidate.accepted ? " · akzeptiert" : ""
}`,
),
].filter((source): source is string => Boolean(source));
const skills = record.usedSkills;
const hasCallablePhone =
Boolean(lead?.phone) &&
(strategy === "call_first" ||
lead?.contactStatus === "missing_contact");
const publicAuditHref = audit?.slug ? `/audit/${audit.slug}` : null;
return (
<Card className="overflow-hidden" key={record.id}>
<CardHeader className="gap-3 border-b bg-muted/20 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-1">
<CardTitle className="break-words text-lg">
{compactText(lead?.companyName, "Unbenannter Lead")}
</CardTitle>
<p className="break-all text-sm text-muted-foreground">
{compactText(
lead?.websiteDomain ?? lead?.websiteUrl,
"Keine Domain",
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">{formatStrategy(strategy)}</Badge>
<Badge variant="outline">
{compactText(lead?.contactStatus, "Kontaktstatus offen")}
</Badge>
<Badge variant="outline">
{compactText(audit?.status, "Auditstatus offen")}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="space-y-5 p-4">
<section className="grid gap-4 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div className="space-y-3">
<h2 className="text-sm font-semibold">Lead-Details</h2>
<dl className="grid gap-3 sm:grid-cols-2">
<FieldPair label="Nische" value={lead?.niche} />
<FieldPair
label="Ort"
value={[lead?.postalCode, lead?.city].filter(Boolean).join(" ")}
/>
<FieldPair label="Ansprechperson" value={lead?.contactPerson} />
<FieldPair label="E-Mail" value={lead?.email} />
<FieldPair label="Telefon" value={lead?.phone} />
<FieldPair label="Quelle" value={lead?.googleMapsUrl} />
</dl>
<div className="space-y-1">
<h3 className="text-xs font-medium text-muted-foreground">
Prioritätsgrund
</h3>
<p className="break-words text-sm">
{compactText(lead?.priorityReason)}
</p>
</div>
<div className="space-y-1">
<h3 className="text-xs font-medium text-muted-foreground">
Kontaktstrategie
</h3>
<p className="text-sm">{formatStrategy(strategy)}</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold">Audit-Zusammenfassung</h2>
{publicAuditHref ? (
<Button asChild size="sm" type="button" variant="outline">
<Link href={publicAuditHref}>
<ExternalLink className="size-3.5" />
Public-Audit
</Link>
</Button>
) : (
<span className="text-sm text-muted-foreground">
Public-Audit ohne Slug
</span>
)}
</div>
<label className="block space-y-1">
<span className="text-xs font-medium text-muted-foreground">
Public-Audit Slug
</span>
<span className="block break-all text-sm">
{compactText(audit?.slug, "Slug offen")}
</span>
</label>
<label className="block space-y-1">
<span className="text-xs font-medium text-muted-foreground">
Audit Kurzfassung
</span>
<textarea
className={cn(textAreaClassName, "min-h-20")}
onChange={(event) =>
updateDraft(record.id, "auditSummary", event.target.value)
}
value={draft.auditSummary}
/>
</label>
<label className="block space-y-1">
<span className="text-xs font-medium text-muted-foreground">
Audit öffentlicher Text
</span>
<textarea
className={cn(textAreaClassName, "min-h-28")}
onChange={(event) =>
updateDraft(record.id, "auditBody", event.target.value)
}
value={draft.auditBody}
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
disabled={busyAction === `${record.id}:audit-save`}
onClick={() => saveAudit(record)}
size="sm"
type="button"
variant="outline"
>
<Save className="size-3.5" />
Änderungen speichern
</Button>
<Button
disabled={busyAction === `${record.id}:audit-publish`}
onClick={() => publishAudit(record)}
size="sm"
type="button"
>
Audit veröffentlichen
</Button>
</div>
</div>
</section>
<section className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3">
<h2 className="text-sm font-semibold">Empfohlene E-Mail</h2>
<label className="block space-y-1">
<span className="text-xs font-medium text-muted-foreground">
E-Mail-Betreff
</span>
<Input
aria-label="E-Mail-Betreff"
onChange={(event) =>
updateDraft(record.id, "emailSubject", event.target.value)
}
value={draft.emailSubject}
/>
</label>
<label className="block space-y-1">
<span className="text-xs font-medium text-muted-foreground">
E-Mail-Text
</span>
<textarea
aria-label="E-Mail-Text"
className={cn(textAreaClassName, "min-h-40")}
onChange={(event) =>
updateDraft(record.id, "emailBody", event.target.value)
}
value={draft.emailBody}
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
disabled={busyAction === `${record.id}:outreach-save`}
onClick={() => saveOutreach(record)}
size="sm"
type="button"
variant="outline"
>
<Save className="size-3.5" />
Änderungen speichern
</Button>
<Button
disabled={busyAction === `${record.id}:email-approval`}
onClick={() => approveEmail(record)}
size="sm"
type="button"
>
<MailCheck className="size-3.5" />
E-Mail freigeben
</Button>
</div>
</div>
<div className="space-y-3">
<h2 className="text-sm font-semibold">Telefon & Follow-up</h2>
{hasCallablePhone ? (
<label className="block space-y-1">
<span className="text-xs font-medium text-muted-foreground">
Telefon-Skript
</span>
<textarea
className={cn(textAreaClassName, "min-h-32")}
onChange={(event) =>
updateDraft(record.id, "phoneScript", event.target.value)
}
value={draft.phoneScript}
/>
</label>
) : (
<p className="rounded-md border bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
Kein Telefon-Skript erforderlich.
</p>
)}
<label className="block space-y-1">
<span className="text-xs font-medium text-muted-foreground">
Follow-up-Draft
</span>
<textarea
className={cn(textAreaClassName, "min-h-28")}
onChange={(event) =>
updateDraft(record.id, "followUpDraft", event.target.value)
}
value={draft.followUpDraft}
/>
</label>
</div>
</section>
<section className="space-y-3">
<div className="flex flex-wrap gap-2">
<DetailToggle
isOpen={Boolean(openSources[record.id])}
label="Quellen anzeigen"
onClick={() =>
setOpenSources((current) => ({
...current,
[record.id]: !current[record.id],
}))
}
/>
<DetailToggle
isOpen={Boolean(openRaw[record.id])}
label="Raw anzeigen"
onClick={() =>
setOpenRaw((current) => ({
...current,
[record.id]: !current[record.id],
}))
}
/>
</div>
{openSources[record.id] ? (
<div className="grid gap-3 rounded-md border bg-muted/20 p-3 lg:grid-cols-2">
<div className="min-w-0 space-y-2">
<h2 className="text-sm font-semibold">Kontaktquellen</h2>
{contactSources.length > 0 ? (
<ul className="space-y-1 text-sm">
{contactSources.map((source, index) => (
<li className="break-words" key={`${source}-${index}`}>
{source}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Keine Kontaktquellen hinterlegt.
</p>
)}
</div>
<div className="min-w-0 space-y-2">
<h2 className="text-sm font-semibold">Verwendete Skills</h2>
{skills.length > 0 ? (
<div className="flex flex-wrap gap-2">
{skills.map((skill, index) => (
<Badge key={index} variant="outline">
{skillLabel(skill)}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Keine Skills dokumentiert.
</p>
)}
</div>
</div>
) : null}
{openRaw[record.id] ? (
<pre className="max-h-72 overflow-auto rounded-md border bg-muted/20 p-3 text-xs whitespace-pre-wrap">
{formatRaw({
audit,
auditGenerations: record.auditGenerations,
latestOutreach: outreach,
lead,
skillSummaries: record.skillSummaries,
sourceSummaries: record.sourceSummaries,
})}
</pre>
) : null}
</section>
</CardContent>
</Card>
);
})}
</div>
</section>
);
}

View File

@@ -2,6 +2,8 @@ import { v } from "convex/values";
import { normalizeListLimit } from "./domain";
import { internalMutation, mutation, query } from "./_generated/server";
import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
const strategy = v.union(
v.literal("call_first"),
@@ -10,6 +12,175 @@ const strategy = v.union(
v.literal("do_not_contact"),
);
const REVIEW_JOIN_LIMIT = 4;
const requireOperator = async (ctx: QueryCtx | MutationCtx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Nicht autorisiert.");
}
return identity;
};
const latestOutreachForLead = async (
ctx: QueryCtx,
leadId: Id<"leads">,
) => {
const rows = await ctx.db
.query("outreachRecords")
.withIndex("by_leadId", (q) => q.eq("leadId", leadId))
.order("desc")
.take(1);
return rows[0] ?? null;
};
const latestAuditForLead = async (ctx: QueryCtx, leadId: Id<"leads">) => {
const rows = await ctx.db
.query("audits")
.withIndex("by_leadId", (q) => q.eq("leadId", leadId))
.order("desc")
.take(1);
return rows[0] ?? null;
};
const loadReviewRow = async (
ctx: QueryCtx,
lead: Doc<"leads">,
reviewOutreach: Doc<"outreachRecords"> | null,
) => {
const latestOutreach = reviewOutreach ?? await latestOutreachForLead(ctx, lead._id);
const audit = latestOutreach?.auditId
? await ctx.db.get(latestOutreach.auditId)
: await latestAuditForLead(ctx, lead._id);
const auditGenerations = audit
? await ctx.db
.query("auditGenerations")
.withIndex("by_auditId", (q) => q.eq("auditId", audit._id))
.order("desc")
.take(REVIEW_JOIN_LIMIT)
: await ctx.db
.query("auditGenerations")
.withIndex("by_leadId", (q) => q.eq("leadId", lead._id))
.order("desc")
.take(REVIEW_JOIN_LIMIT);
const pageSpeedResults = audit
? await ctx.db
.query("pageSpeedResults")
.withIndex("by_auditId", (q) => q.eq("auditId", audit._id))
.order("desc")
.take(REVIEW_JOIN_LIMIT)
: await ctx.db
.query("pageSpeedResults")
.withIndex("by_leadId", (q) => q.eq("leadId", lead._id))
.order("desc")
.take(REVIEW_JOIN_LIMIT);
const crawlPages = await ctx.db
.query("websiteCrawlPages")
.withIndex("by_leadId_and_createdAt", (q) => q.eq("leadId", lead._id))
.order("desc")
.take(REVIEW_JOIN_LIMIT);
const emailCandidates = await ctx.db
.query("websiteEmailCandidates")
.withIndex("by_leadId", (q) => q.eq("leadId", lead._id))
.order("desc")
.take(REVIEW_JOIN_LIMIT);
return {
id: lead._id,
lead: {
id: lead._id,
companyName: lead.companyName,
niche: lead.niche ?? null,
address: lead.address ?? null,
city: lead.city ?? null,
postalCode: lead.postalCode ?? null,
websiteUrl: lead.websiteUrl ?? null,
websiteDomain: lead.websiteDomain ?? null,
email: lead.email ?? null,
normalizedEmail: lead.normalizedEmail ?? null,
phone: lead.phone ?? null,
normalizedPhone: lead.normalizedPhone ?? null,
contactPerson: lead.contactPerson ?? null,
priority: lead.priority,
priorityReason: lead.priorityReason ?? null,
contactStatus: lead.contactStatus,
contactStatusReason: lead.contactStatusReason ?? null,
duplicateStatus: lead.duplicateStatus,
duplicateReason: lead.duplicateReason ?? null,
blacklistStatus: lead.blacklistStatus,
blacklistReason: lead.blacklistReason ?? null,
notes: lead.notes ?? null,
googleMapsUrl: lead.googleMapsUrl ?? null,
googleRating: lead.googleRating ?? null,
googleUserRatingCount: lead.googleUserRatingCount ?? null,
updatedAt: lead.updatedAt,
},
latestOutreach: latestOutreach,
audit: audit,
auditGenerations: auditGenerations.map((generation) => ({
id: generation._id,
stage: generation.stage,
status: generation.status,
modelProfile: generation.modelProfile,
modelId: generation.modelId,
errorSummary: generation.errorSummary ?? null,
finishReason: generation.finishReason ?? null,
parsedJson: generation.parsedJson ?? null,
createdAt: generation.createdAt,
updatedAt: generation.updatedAt,
})),
usedSkills: audit?.usedSkills ?? [],
skillSummaries: audit?.skillSummaries ?? [],
sourceSummaries: {
pageSpeedResults: pageSpeedResults.map((result) => ({
id: result._id,
strategy: result.strategy,
status: result.status,
sourceUrl: result.sourceUrl,
finalUrl: result.finalUrl ?? null,
errorType: result.errorType ?? null,
errorSummary: result.errorSummary ?? null,
normalized: result.normalized ?? null,
fetchedAt: result.fetchedAt,
createdAt: result.createdAt,
})),
crawlPages: crawlPages.map((page) => ({
id: page._id,
sourceUrl: page.sourceUrl,
finalUrl: page.finalUrl,
pageKind: page.pageKind,
title: page.title ?? null,
metaDescription: page.metaDescription ?? null,
headings: page.headings.slice(0, REVIEW_JOIN_LIMIT),
visibleTextExcerpt: page.visibleTextExcerpt ?? null,
hasContactFormSignal: page.hasContactFormSignal,
hasContactCtaSignal: page.hasContactCtaSignal,
createdAt: page.createdAt,
})),
emailCandidates: emailCandidates.map((candidate) => ({
id: candidate._id,
email: candidate.email,
normalizedEmail: candidate.normalizedEmail,
emailSource: candidate.emailSource,
sourceUrl: candidate.sourceUrl,
contactPerson: candidate.contactPerson ?? null,
isBusinessContactAddress: candidate.isBusinessContactAddress,
isGeneric: candidate.isGeneric,
accepted: candidate.accepted,
createdAt: candidate.createdAt,
})),
},
sortAt: Math.max(
lead.updatedAt,
latestOutreach?.updatedAt ?? 0,
audit?.updatedAt ?? 0,
),
};
};
export const create = mutation({
args: {
leadId: v.id("leads"),
@@ -21,6 +192,23 @@ export const create = mutation({
followUpDraft: v.optional(v.string()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const lead = await ctx.db.get(args.leadId);
if (!lead) {
throw new Error("Lead wurde nicht gefunden.");
}
if (args.auditId) {
const audit = await ctx.db.get(args.auditId);
if (!audit) {
throw new Error("Audit wurde nicht gefunden.");
}
if (audit.leadId !== args.leadId) {
throw new Error("Audit gehoert nicht zu diesem Lead.");
}
}
const now = Date.now();
return await ctx.db.insert("outreachRecords", {
@@ -48,6 +236,21 @@ export const upsertFromAuditGeneration = internalMutation({
handler: async (ctx, args) => {
const now = Date.now();
const lead = await ctx.db.get(args.leadId);
if (!lead) {
throw new Error("Lead wurde nicht gefunden.");
}
if (args.auditId) {
const audit = await ctx.db.get(args.auditId);
if (!audit) {
throw new Error("Audit wurde nicht gefunden.");
}
if (audit.leadId !== args.leadId) {
throw new Error("Audit gehoert nicht zu diesem Lead.");
}
}
const existing = await ctx.db
.query("outreachRecords")
.withIndex("by_leadId", (q) => q.eq("leadId", args.leadId))
@@ -56,11 +259,20 @@ export const upsertFromAuditGeneration = internalMutation({
if (existing.length > 0) {
const current = existing[0]!;
if (args.auditId) {
await ctx.db.patch(current._id, { auditId: args.auditId });
if (current.sendStatus === "sent") {
return await ctx.db.insert("outreachRecords", {
...args,
approvalStatus: "draft",
sendStatus: "not_sent",
responseStatus: "none",
salesStatus: "follow_up_planned",
createdAt: now,
updatedAt: now,
});
}
await ctx.db.patch(current._id, {
...(args.auditId !== undefined ? { auditId: args.auditId } : {}),
strategy: args.strategy,
...(args.phoneScript !== undefined ? { phoneScript: args.phoneScript } : {}),
...(args.emailSubject !== undefined
@@ -70,6 +282,7 @@ export const upsertFromAuditGeneration = internalMutation({
...(args.followUpDraft !== undefined
? { followUpDraft: args.followUpDraft }
: {}),
approvalStatus: "draft",
updatedAt: now,
});
@@ -88,6 +301,204 @@ export const upsertFromAuditGeneration = internalMutation({
},
});
export const listReviewWorkspace = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const limit = normalizeListLimit(args.limit);
const candidateLimit = Math.min(limit * 10, 300);
const outreachReadyLeads = await ctx.db
.query("leads")
.withIndex("by_contactStatus_and_updatedAt", (q) =>
q.eq("contactStatus", "outreach_ready"),
)
.order("desc")
.take(candidateLimit);
const draftNotSentOutreach = await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus_and_sendStatus_and_updatedAt", (q) =>
q.eq("approvalStatus", "draft").eq("sendStatus", "not_sent"),
)
.order("desc")
.take(candidateLimit);
const draftQueuedOutreach = await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus_and_sendStatus_and_updatedAt", (q) =>
q.eq("approvalStatus", "draft").eq("sendStatus", "queued"),
)
.order("desc")
.take(candidateLimit);
const draftFailedOutreach = await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus_and_sendStatus_and_updatedAt", (q) =>
q.eq("approvalStatus", "draft").eq("sendStatus", "failed"),
)
.order("desc")
.take(candidateLimit);
const approvedNotSentOutreach = await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus_and_sendStatus_and_updatedAt", (q) =>
q.eq("approvalStatus", "approved").eq("sendStatus", "not_sent"),
)
.order("desc")
.take(candidateLimit);
const approvedQueuedOutreach = await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus_and_sendStatus_and_updatedAt", (q) =>
q.eq("approvalStatus", "approved").eq("sendStatus", "queued"),
)
.order("desc")
.take(candidateLimit);
const approvedFailedOutreach = await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus_and_sendStatus_and_updatedAt", (q) =>
q.eq("approvalStatus", "approved").eq("sendStatus", "failed"),
)
.order("desc")
.take(candidateLimit);
const leadCandidates = new Map<
Id<"leads">,
{ lead: Doc<"leads">; outreach: Doc<"outreachRecords"> | null }
>();
for (const lead of outreachReadyLeads) {
leadCandidates.set(lead._id, { lead, outreach: null });
}
const reviewOutreach = [
...draftNotSentOutreach,
...draftQueuedOutreach,
...draftFailedOutreach,
...approvedNotSentOutreach,
...approvedQueuedOutreach,
...approvedFailedOutreach,
]
.filter((outreach) =>
(outreach.approvalStatus === "draft" ||
outreach.approvalStatus === "approved") &&
outreach.sendStatus !== "sent"
)
.sort((a, b) => b.updatedAt - a.updatedAt);
for (const outreach of reviewOutreach) {
const lead = await ctx.db.get(outreach.leadId);
if (!lead) {
continue;
}
const existing = leadCandidates.get(lead._id);
if (!existing || (existing.outreach?.updatedAt ?? 0) < outreach.updatedAt) {
leadCandidates.set(lead._id, { lead, outreach });
}
}
const rows = await Promise.all(
[...leadCandidates.values()].map(({ lead, outreach }) =>
loadReviewRow(ctx, lead, outreach),
),
);
return rows
.sort((a, b) => b.sortAt - a.sortAt)
.slice(0, limit)
.map(({ sortAt, ...row }) => (void sortAt, row));
},
});
export const saveReviewDraft = mutation({
args: {
id: v.id("outreachRecords"),
strategy: strategy,
phoneScript: v.optional(v.string()),
emailSubject: v.optional(v.string()),
emailBody: v.optional(v.string()),
followUpDraft: v.optional(v.string()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const outreach = await ctx.db.get(args.id);
if (!outreach) {
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
}
if (outreach.sendStatus === "sent") {
throw new Error("Gesendete Outreach-Datensaetze koennen nicht bearbeitet werden.");
}
const now = Date.now();
await ctx.db.patch(args.id, {
strategy: args.strategy,
...(args.phoneScript !== undefined ? { phoneScript: args.phoneScript } : {}),
...(args.emailSubject !== undefined
? { emailSubject: args.emailSubject }
: {}),
...(args.emailBody !== undefined ? { emailBody: args.emailBody } : {}),
...(args.followUpDraft !== undefined
? { followUpDraft: args.followUpDraft }
: {}),
approvalStatus: "draft",
updatedAt: now,
});
return { id: args.id, approvalStatus: "draft", updatedAt: now };
},
});
export const approveEmailDraft = mutation({
args: {
id: v.id("outreachRecords"),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const outreach = await ctx.db.get(args.id);
if (!outreach) {
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
}
if (outreach.sendStatus === "sent") {
throw new Error("Gesendete Outreach-Datensaetze koennen nicht freigegeben werden.");
}
const lead = await ctx.db.get(outreach.leadId);
if (!lead) {
throw new Error("Lead wurde nicht gefunden.");
}
const recipient = lead.email?.trim();
const subject = outreach.emailSubject?.trim();
const body = outreach.emailBody?.trim();
if (!recipient) {
throw new Error("Empfaenger-E-Mail fehlt.");
}
if (!subject) {
throw new Error("E-Mail-Betreff fehlt.");
}
if (!body) {
throw new Error("E-Mail-Text fehlt.");
}
const audit = outreach.auditId ? await ctx.db.get(outreach.auditId) : null;
const now = Date.now();
await ctx.db.patch(args.id, {
approvalStatus: "approved",
updatedAt: now,
});
return {
id: args.id,
recipient: recipient,
subject: subject,
auditSlug: audit?.slug ?? null,
approvalStatus: "approved",
updatedAt: now,
};
},
});
export const list = query({
args: {
leadId: v.optional(v.id("leads")),
@@ -97,6 +508,8 @@ export const list = query({
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const limit = normalizeListLimit(args.limit);
if (args.leadId) {

View File

@@ -253,6 +253,7 @@ export default defineSchema({
.index("by_campaignId", ["campaignId"])
.index("by_discoveryRunId", ["discoveryRunId"])
.index("by_contactStatus", ["contactStatus"])
.index("by_contactStatus_and_updatedAt", ["contactStatus", "updatedAt"])
.index("by_normalizedEmail", ["normalizedEmail"])
.index("by_normalizedPhone", ["normalizedPhone"])
.index("by_normalizedCompanyName_and_normalizedAddress", [
@@ -490,7 +491,14 @@ export default defineSchema({
.index("by_leadId", ["leadId"])
.index("by_auditId", ["auditId"])
.index("by_approvalStatus", ["approvalStatus"])
.index("by_sendStatus", ["sendStatus"]),
.index("by_approvalStatus_and_updatedAt", ["approvalStatus", "updatedAt"])
.index("by_approvalStatus_and_sendStatus_and_updatedAt", [
"approvalStatus",
"sendStatus",
"updatedAt",
])
.index("by_sendStatus", ["sendStatus"])
.index("by_sendStatus_and_updatedAt", ["sendStatus", "updatedAt"]),
blacklistEntries: defineTable({
type: blacklistType,

View File

@@ -286,7 +286,8 @@ function getLeadFunnelStageId(lead: LeadFunnelInput): LeadFunnelStageId {
if (
lead.contactStatus === "outreach_ready" ||
lead.outreach?.approvalStatus === "draft"
lead.outreach?.approvalStatus === "draft" ||
lead.outreach?.approvalStatus === "approved"
) {
return "review_open";
}

View File

@@ -146,6 +146,37 @@ test("groupLeadFunnelCards derives review, follow-up, and deferred columns witho
);
});
test("groupLeadFunnelCards keeps approved unsent outreach in the review-open funnel", () => {
const groups = groupLeadFunnelCards([
{
id: "lead-approved-unsent",
companyName: "Optik Meyer",
city: "Freiburg",
priority: "medium",
contactStatus: "new",
blacklistStatus: "clear",
outreach: {
approvalStatus: "approved",
sendStatus: "not_sent",
responseStatus: "none",
salesStatus: "follow_up_planned",
},
},
]);
assert.deepEqual(
groups.map((group) => [group.stage.id, group.cards.map((card) => card.id)]),
[
["missing_contact", []],
["audit_ready", []],
["review_open", ["lead-approved-unsent"]],
["contacted", []],
["follow_up", []],
["deferred", []],
],
);
});
test("toLeadFunnelCard maps blocked priority to deferred stage with blocker label", () => {
const card = toLeadFunnelCard({
id: "lead-blocked",

View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
test("dashboard sidebar links do not prefetch protected routes", async () => {
const source = await readFile(
join(process.cwd(), "components", "dashboard-sidebar.tsx"),
"utf8",
);
const linkMatch = source.match(/<Link[\s\S]*?href=\{item\.href\}[\s\S]*?>/);
assert.ok(linkMatch, "Dashboard sidebar should render dashboard nav Links.");
assert.match(linkMatch[0], /prefetch=\{false\}/);
});
test("lead funnel card action links do not fan out prefetches", async () => {
const source = await readFile(
join(process.cwd(), "components", "lead-funnel-board.tsx"),
"utf8",
);
const actionLinkMatch = source.match(
/<Link[\s\S]*?href=\{stageActionHref\[card\.stageId\]\}[\s\S]*?>/,
);
assert.ok(actionLinkMatch, "Lead funnel cards should link to stage actions.");
assert.match(actionLinkMatch[0], /prefetch=\{false\}/);
});

View 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 dashboardThemePath = join(
process.cwd(),
"components",
"dashboard-theme.tsx",
);
test("DashboardThemeProvider keeps server and first client render stable", async () => {
const source = await readFile(dashboardThemePath, "utf8");
assert.match(source, /useSyncExternalStore\(/);
assert.match(source, /function getServerDashboardTheme\(\): DashboardTheme \{/);
assert.match(source, /return "light";/);
assert.doesNotMatch(
source,
/useState<DashboardTheme>\(\(\) => \{[\s\S]*?localStorage/,
);
assert.doesNotMatch(source, /setTheme\(/);
});

View File

@@ -0,0 +1,331 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import ts from "typescript";
const outreachPath = join(process.cwd(), "convex", "outreach.ts");
const schemaPath = join(process.cwd(), "convex", "schema.ts");
const outreachSource = existsSync(outreachPath)
? readFileSync(outreachPath, "utf8")
: "";
const schemaSource = existsSync(schemaPath)
? readFileSync(schemaPath, "utf8")
: "";
const sourceFile = ts.createSourceFile(
"outreach.ts",
outreachSource,
ts.ScriptTarget.ES2022,
true,
ts.ScriptKind.TS,
);
function getExportedConstNames(file: ts.SourceFile) {
const names = new Set<string>();
const visit = (node: ts.Node) => {
if (ts.isVariableStatement(node)) {
const isExported = node.modifiers?.some(
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
);
const isConst = Boolean(node.declarationList.flags & ts.NodeFlags.Const);
if (isExported && isConst) {
for (const declaration of node.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(file, visit);
return names;
}
function extractExportSource(name: string) {
const marker = `export const ${name} = `;
const declarationIndex = outreachSource.indexOf(marker);
assert.notEqual(declarationIndex, -1, `Expected declaration for ${name}.`);
const openBraceIndex = outreachSource.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < outreachSource.length; index += 1) {
const char = outreachSource[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(end, -1, `Expected balanced braces for ${name}.`);
return outreachSource.slice(openBraceIndex, end + 1);
}
function hasPattern(source: string, pattern: RegExp, message: string) {
assert.equal(pattern.test(source), true, message);
}
function lacksPattern(source: string, pattern: RegExp, message: string) {
assert.equal(pattern.test(source), false, message);
}
test("outreach review module exports authenticated review contracts", () => {
assert.equal(existsSync(outreachPath), true, "outreach.ts should be present.");
const exports = getExportedConstNames(sourceFile);
for (const exportName of [
"listReviewWorkspace",
"saveReviewDraft",
"approveEmailDraft",
]) {
assert.equal(exports.has(exportName), true, `Expected export: ${exportName}`);
}
hasPattern(
outreachSource,
/const requireOperator\s*=\s*async\s*\(\s*ctx:\s*(?:QueryCtx\s*\|\s*MutationCtx|MutationCtx\s*\|\s*QueryCtx)\s*\)/,
"Module should define a local requireOperator helper usable from queries and mutations.",
);
hasPattern(
outreachSource,
/ctx\.auth\.getUserIdentity\(\)/,
"requireOperator should derive the operator identity from Convex auth.",
);
hasPattern(
outreachSource,
/Nicht autorisiert/,
"Unauthenticated review calls should fail clearly.",
);
});
test("listReviewWorkspace is bounded, authenticated, and joins review context", () => {
const listSource = extractExportSource("listReviewWorkspace");
const reviewSource = `${listSource}\n${outreachSource}`;
hasPattern(outreachSource, /export const listReviewWorkspace = query\(/, "Review workspace should be a public query.");
hasPattern(listSource, /requireOperator\(ctx\)/, "Review workspace should require auth.");
hasPattern(listSource, /limit:\s*v\.optional\(v\.number\(\)\)/, "Review workspace should accept optional limit.");
hasPattern(listSource, /normalizeListLimit\(args\.limit\)/, "Review workspace should normalize the requested limit.");
lacksPattern(listSource, /\.collect\(/, "Review workspace must not use unbounded collect().");
hasPattern(
listSource,
/query\("leads"\)[\s\S]*?withIndex\("by_contactStatus_and_updatedAt"[\s\S]*?eq\("contactStatus",\s*"outreach_ready"\)[\s\S]*?\.order\("desc"\)[\s\S]*?\.take\(candidateLimit\)/,
"Review workspace should include newest outreach-ready leads via contactStatus+updatedAt.",
);
for (const [approvalStatus, sendStatus] of [
["draft", "not_sent"],
["draft", "queued"],
["draft", "failed"],
["approved", "not_sent"],
["approved", "queued"],
["approved", "failed"],
]) {
hasPattern(
listSource,
new RegExp(
`query\\("outreachRecords"\\)[\\s\\S]*?withIndex\\("by_approvalStatus_and_sendStatus_and_updatedAt"[\\s\\S]*?eq\\("approvalStatus",\\s*"${approvalStatus}"\\)[\\s\\S]*?eq\\("sendStatus",\\s*"${sendStatus}"\\)[\\s\\S]*?\\.order\\("desc"\\)[\\s\\S]*?\\.take\\(candidateLimit\\)`,
),
`Review workspace should fetch newest ${approvalStatus}/${sendStatus} outreach via combined eligibility+updatedAt index.`,
);
}
lacksPattern(
listSource,
/withIndex\("by_approvalStatus_and_updatedAt"/,
"Review workspace should not depend on approval-only bounded windows for outreach eligibility.",
);
lacksPattern(
listSource,
/withIndex\("by_sendStatus_and_updatedAt"/,
"Review workspace should not depend on send-only bounded windows for outreach eligibility.",
);
hasPattern(
listSource,
/\.\.\.draftNotSentOutreach[\s\S]*\.\.\.draftQueuedOutreach[\s\S]*\.\.\.draftFailedOutreach[\s\S]*\.\.\.approvedNotSentOutreach[\s\S]*\.\.\.approvedQueuedOutreach[\s\S]*\.\.\.approvedFailedOutreach/,
"Review workspace should combine only eligible approval/send-status candidate windows.",
);
hasPattern(
listSource,
/approvalStatus\s*===\s*"draft"[\s\S]*?approvalStatus\s*===\s*"approved"/,
"Review workspace should include draft and approved unsent outreach records.",
);
hasPattern(
listSource,
/sendStatus\s*!==\s*"sent"/,
"Review workspace should exclude sent outreach records.",
);
hasPattern(
listSource,
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.sortAt\s*-\s*a\.sortAt\s*\)/,
"Review rows should be newest first.",
);
hasPattern(listSource, /slice\(0,\s*limit\)/, "Review rows should be capped to the normalized limit.");
for (const tableName of [
"audits",
"auditGenerations",
"pageSpeedResults",
"websiteCrawlPages",
"websiteEmailCandidates",
]) {
hasPattern(
reviewSource,
new RegExp(`query\\("${tableName}"\\)[\\s\\S]*?\\.take\\(\\s*\\d+\\s*\\)`),
`${tableName} join should be bounded with take(n).`,
);
}
for (const fieldName of [
"lead",
"latestOutreach",
"audit",
"auditGenerations",
"usedSkills",
"skillSummaries",
"sourceSummaries",
"pageSpeedResults",
"crawlPages",
"emailCandidates",
]) {
hasPattern(
reviewSource,
new RegExp(`${fieldName}:`),
`Review rows should include ${fieldName}.`,
);
}
});
test("schema defines recency indexes for outreach review bounded reads", () => {
assert.equal(existsSync(schemaPath), true, "schema.ts should be present.");
for (const [indexName, fieldsPattern] of [
["by_contactStatus_and_updatedAt", String.raw`\[\s*"contactStatus",\s*"updatedAt",?\s*\]`],
["by_approvalStatus_and_updatedAt", String.raw`\[\s*"approvalStatus",\s*"updatedAt",?\s*\]`],
["by_sendStatus_and_updatedAt", String.raw`\[\s*"sendStatus",\s*"updatedAt",?\s*\]`],
[
"by_approvalStatus_and_sendStatus_and_updatedAt",
String.raw`\[\s*"approvalStatus",\s*"sendStatus",\s*"updatedAt",?\s*\]`,
],
]) {
hasPattern(
schemaSource,
new RegExp(`\\.index\\("${indexName}",\\s*${fieldsPattern}`),
`Schema should define ${indexName} for newest-first bounded review reads.`,
);
}
});
test("upsertFromAuditGeneration preserves review boundaries for generated copy", () => {
const upsertSource = extractExportSource("upsertFromAuditGeneration");
hasPattern(
outreachSource,
/export const upsertFromAuditGeneration = internalMutation\(/,
"upsertFromAuditGeneration should remain an internal mutation.",
);
hasPattern(upsertSource, /ctx\.db\.get\(args\.leadId\)/, "upsert should verify the lead exists.");
hasPattern(upsertSource, /!lead/, "upsert should reject missing leads.");
hasPattern(upsertSource, /ctx\.db\.get\(args\.auditId\)/, "upsert should load provided audits.");
hasPattern(upsertSource, /!audit/, "upsert should reject missing audits.");
hasPattern(
upsertSource,
/audit\.leadId\s*!==\s*args\.leadId/,
"upsert should reject auditId values that belong to a different lead.",
);
hasPattern(
upsertSource,
/current\.sendStatus\s*===\s*"sent"[\s\S]*?ctx\.db\.insert\(\s*"outreachRecords"/,
"upsert should create a new draft record instead of patching a sent outreach record.",
);
hasPattern(
upsertSource,
/ctx\.db\.patch\(current\._id,[\s\S]*approvalStatus:\s*"draft"/,
"Generated copy changes should reset existing unsent outreach records to draft.",
);
hasPattern(
upsertSource,
/approvalStatus:\s*"draft"[\s\S]*sendStatus:\s*"not_sent"/,
"New generated outreach records should start as unsent drafts.",
);
});
test("sensitive public outreach exports require operators and validate references", () => {
const createSource = extractExportSource("create");
const listSource = extractExportSource("list");
hasPattern(createSource, /requireOperator\(ctx\)/, "create should require operator auth.");
hasPattern(listSource, /requireOperator\(ctx\)/, "list should require operator auth.");
hasPattern(createSource, /ctx\.db\.get\(args\.leadId\)/, "create should verify the lead exists.");
hasPattern(createSource, /!lead/, "create should reject missing leads.");
hasPattern(createSource, /ctx\.db\.get\(args\.auditId\)/, "create should load provided audits.");
hasPattern(createSource, /!audit/, "create should reject missing audits.");
hasPattern(
createSource,
/audit\.leadId\s*!==\s*args\.leadId/,
"create should reject auditId values that belong to a different lead.",
);
});
test("saveReviewDraft validates editable fields and never edits sent records", () => {
const saveSource = extractExportSource("saveReviewDraft");
hasPattern(outreachSource, /export const saveReviewDraft = mutation\(/, "saveReviewDraft should be a mutation.");
hasPattern(saveSource, /requireOperator\(ctx\)/, "saveReviewDraft should require auth.");
hasPattern(saveSource, /id:\s*v\.id\("outreachRecords"\)/, "saveReviewDraft should validate the outreach id.");
hasPattern(saveSource, /strategy:\s*strategy/, "saveReviewDraft should validate strategy with the shared strategy validator.");
for (const fieldName of [
"phoneScript",
"emailSubject",
"emailBody",
"followUpDraft",
]) {
hasPattern(
saveSource,
new RegExp(`${fieldName}:\\s*v\\.optional\\(v\\.string\\(\\)\\)`),
`${fieldName} should be optional editable copy.`,
);
}
hasPattern(saveSource, /ctx\.db\.get\(args\.id\)/, "saveReviewDraft should load the outreach record.");
hasPattern(saveSource, /!outreach/, "saveReviewDraft should reject missing outreach.");
hasPattern(saveSource, /outreach\.sendStatus\s*===\s*"sent"/, "saveReviewDraft should reject sent outreach records.");
hasPattern(saveSource, /approvalStatus:\s*"draft"/, "Saving edits should reset approval to draft.");
hasPattern(saveSource, /updatedAt:\s*now/, "Saving edits should stamp updatedAt.");
hasPattern(saveSource, /ctx\.db\.patch\(args\.id/, "saveReviewDraft should patch the existing outreach record.");
});
test("approveEmailDraft validates approval prerequisites and preserves send separation", () => {
const approveSource = extractExportSource("approveEmailDraft");
hasPattern(outreachSource, /export const approveEmailDraft = mutation\(/, "approveEmailDraft should be a mutation.");
hasPattern(approveSource, /requireOperator\(ctx\)/, "approveEmailDraft should require auth.");
hasPattern(approveSource, /id:\s*v\.id\("outreachRecords"\)/, "approveEmailDraft should validate the outreach id.");
hasPattern(approveSource, /ctx\.db\.get\(args\.id\)/, "approveEmailDraft should load the outreach record.");
hasPattern(approveSource, /!outreach/, "approveEmailDraft should reject missing outreach.");
hasPattern(approveSource, /outreach\.sendStatus\s*===\s*"sent"/, "approveEmailDraft should reject sent outreach records.");
hasPattern(approveSource, /ctx\.db\.get\(outreach\.leadId\)/, "approveEmailDraft should load the linked lead.");
hasPattern(approveSource, /lead\.email\?\.trim\(\)/, "approveEmailDraft should require a trimmed recipient email.");
hasPattern(approveSource, /outreach\.emailSubject\?\.trim\(\)/, "approveEmailDraft should require a trimmed subject.");
hasPattern(approveSource, /outreach\.emailBody\?\.trim\(\)/, "approveEmailDraft should require a trimmed body.");
hasPattern(approveSource, /approvalStatus:\s*"approved"/, "Approval should mark only the approval status.");
hasPattern(approveSource, /updatedAt:\s*now/, "Approval should stamp updatedAt.");
hasPattern(approveSource, /recipient:/, "Approval should return recipient context.");
hasPattern(approveSource, /subject:/, "Approval should return subject context.");
hasPattern(approveSource, /auditSlug:/, "Approval should return audit slug context when available.");
lacksPattern(approveSource, /sendStatus\s*:/, "Approval must not alter sendStatus.");
lacksPattern(approveSource, /ctx\.scheduler|runAfter|runMutation|nodemailer|smtp|smpp|sendEmail/i, "Approval must not queue or send email/SMPP/Nodemailer work.");
});

View File

@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const outreachPagePath = join(
process.cwd(),
"app",
"dashboard",
"outreach",
"page.tsx",
);
const outreachWorkspacePath = join(
process.cwd(),
"components",
"outreach",
"outreach-review-workspace.tsx",
);
function extractConstFunction(source: string, name: string) {
const declaration = `const ${name} = async`;
const start = source.indexOf(declaration);
assert.ok(start >= 0, `${name} handler should exist.`);
const firstBrace = source.indexOf("{", start);
assert.ok(firstBrace >= 0, `${name} handler should have a body.`);
let depth = 0;
for (let index = firstBrace; index < source.length; index += 1) {
const char = source[index];
if (char === "{") {
depth += 1;
}
if (char === "}") {
depth -= 1;
if (depth === 0) {
return source.slice(start, index + 1);
}
}
}
assert.fail(`${name} handler body should close.`);
}
test("/dashboard/outreach mounts the outreach review workspace", async () => {
const source = await readFile(outreachPagePath, "utf8");
assert.doesNotMatch(source, /DashboardPlaceholderPage/);
assert.match(source, /OutreachReviewWorkspace/);
assert.match(
source,
/@\/components\/outreach\/outreach-review-workspace/,
);
});
test("OutreachReviewWorkspace uses the review workspace API and required controls", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
assert.match(source, /api\.outreach(?:\s+as\s+OutreachApi\))?\.listReviewWorkspace/);
assert.match(source, /limit:\s*100/);
assert.match(source, /api\.outreach(?:\s+as\s+OutreachApi\))?\.saveReviewDraft/);
assert.match(source, /api\.outreach(?:\s+as\s+OutreachApi\))?\.approveEmailDraft/);
assert.match(source, /api\.audits\.savePublicAuditContent/);
assert.match(source, /api\.audits\.publishPublicAudit/);
[
"Lead-Details",
"Kontaktquellen",
"Prioritätsgrund",
"Kontaktstrategie",
"Audit-Zusammenfassung",
"Public-Audit",
"Verwendete Skills",
"Quellen anzeigen",
"Raw anzeigen",
"E-Mail-Betreff",
"E-Mail-Text",
"Telefon-Skript",
"Follow-up-Draft",
].forEach((label) => assert.match(source, new RegExp(label)));
});
test("OutreachReviewWorkspace keeps exactly one recommended email subject and body editor", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
assert.equal((source.match(/aria-label="E-Mail-Betreff"/g) ?? []).length, 1);
assert.equal((source.match(/aria-label="E-Mail-Text"/g) ?? []).length, 1);
assert.equal((source.match(/<Input\b/g) ?? []).length, 1);
assert.doesNotMatch(source, /Version\s*[23]|Alternative|Variante/);
});
test("OutreachReviewWorkspace separates audit publication from email approval", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
assert.match(source, /Audit veröffentlichen/);
assert.match(source, /Änderungen speichern/);
assert.match(source, /E-Mail freigeben/);
assert.doesNotMatch(source, /E-Mail freigeben und senden/);
assert.doesNotMatch(source, /api\.outreach\.(send|sendEmail|sendDraft)/);
const auditPublishIndex = source.indexOf("Audit veröffentlichen");
const auditSaveIndex = source.indexOf("Änderungen speichern");
const emailApprovalIndex = source.indexOf("E-Mail freigeben");
assert.ok(auditPublishIndex >= 0);
assert.ok(auditSaveIndex >= 0);
assert.ok(emailApprovalIndex >= 0);
assert.ok(
Math.abs(auditPublishIndex - auditSaveIndex) <
Math.abs(auditPublishIndex - emailApprovalIndex),
"Audit actions should be grouped closer to each other than to email approval.",
);
});
test("OutreachReviewWorkspace gates phone scripts to call-first or missing-contact leads with phone numbers", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
assert.match(source, /hasCallablePhone/);
assert.match(source, /strategy\s*===\s*"call_first"/);
assert.match(source, /lead\?\.contactStatus\s*===\s*"missing_contact"/);
assert.match(source, /Kein Telefon-Skript erforderlich/);
});
test("approveEmail saves the visible outreach draft before approving it", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
const handler = extractConstFunction(source, "approveEmail");
const draftIndex = handler.indexOf("const draft = drafts[record.id] ?? getDraft(record)");
const saveIndex = handler.indexOf("await saveReviewDraft");
const approveIndex = handler.indexOf("await approveEmailDraft");
assert.ok(draftIndex >= 0, "Approval should read the current local draft.");
assert.ok(saveIndex >= 0, "Approval should persist the current draft first.");
assert.ok(approveIndex >= 0, "Approval should still call approveEmailDraft.");
assert.ok(
draftIndex < saveIndex && saveIndex < approveIndex,
"Approval should read draft, save it, then approve.",
);
assert.match(handler, /emailSubject:\s*draft\.emailSubject/);
assert.match(handler, /emailBody:\s*draft\.emailBody/);
assert.match(handler, /followUpDraft:\s*draft\.followUpDraft/);
});
test("publishAudit saves the visible audit draft before publishing it", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
const handler = extractConstFunction(source, "publishAudit");
const draftIndex = handler.indexOf("const draft = drafts[record.id] ?? getDraft(record)");
const saveIndex = handler.indexOf("await savePublicAuditContent");
const publishIndex = handler.indexOf("await publishPublicAudit");
assert.ok(draftIndex >= 0, "Publishing should read the current local audit draft.");
assert.ok(saveIndex >= 0, "Publishing should save the public audit content first.");
assert.ok(publishIndex >= 0, "Publishing should still call publishPublicAudit.");
assert.ok(
draftIndex < saveIndex && saveIndex < publishIndex,
"Publishing should read draft, save it, then publish.",
);
assert.match(handler, /publicSummary:\s*draft\.auditSummary/);
assert.match(handler, /publicBody:\s*draft\.auditBody/);
});