From 5a42c637c6c295944fdcd867b49a6084daa71ffd Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 5 Jun 2026 16:47:22 +0200 Subject: [PATCH 1/2] feat: build audit outreach review workspace --- app/dashboard/outreach/page.tsx | 11 +- ...the-audit-and-outreach-review-workspace.md | 27 +- ...gnose-dashboard-initial-load-retry-loop.md | 75 ++ components/dashboard-sidebar.tsx | 1 + components/dashboard-theme.tsx | 53 +- components/lead-funnel-board.tsx | 1 + .../outreach/outreach-review-workspace.tsx | 647 ++++++++++++++++++ convex/outreach.ts | 417 ++++++++++- convex/schema.ts | 10 +- lib/dashboard-model.ts | 3 +- tests/dashboard-model.test.ts | 31 + tests/dashboard-prefetch.test.ts | 30 + tests/dashboard-theme.test.ts | 23 + tests/outreach-review-contract.test.ts | 331 +++++++++ tests/outreach-review-workspace-ui.test.ts | 164 +++++ 15 files changed, 1786 insertions(+), 38 deletions(-) create mode 100644 backlog/tasks/task-28 - Diagnose-dashboard-initial-load-retry-loop.md create mode 100644 components/outreach/outreach-review-workspace.tsx create mode 100644 tests/dashboard-prefetch.test.ts create mode 100644 tests/dashboard-theme.test.ts create mode 100644 tests/outreach-review-contract.test.ts create mode 100644 tests/outreach-review-workspace-ui.test.ts diff --git a/app/dashboard/outreach/page.tsx b/app/dashboard/outreach/page.tsx index 4e385ea..372730d 100644 --- a/app/dashboard/outreach/page.tsx +++ b/app/dashboard/outreach/page.tsx @@ -1,10 +1,11 @@ -import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; +import { OutreachReviewWorkspace } from "@/components/outreach/outreach-review-workspace"; export default function OutreachPage() { return ( - +
+
+ +
+
); } diff --git a/backlog/tasks/task-13 - Build-the-audit-and-outreach-review-workspace.md b/backlog/tasks/task-13 - Build-the-audit-and-outreach-review-workspace.md index 59c0809..b057bbb 100644 --- a/backlog/tasks/task-13 - Build-the-audit-and-outreach-review-workspace.md +++ b/backlog/tasks/task-13 - Build-the-audit-and-outreach-review-workspace.md @@ -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 -- [ ] #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 ## Implementation Plan -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. ## 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. diff --git a/backlog/tasks/task-28 - Diagnose-dashboard-initial-load-retry-loop.md b/backlog/tasks/task-28 - Diagnose-dashboard-initial-load-retry-loop.md new file mode 100644 index 0000000..a5b80d6 --- /dev/null +++ b/backlog/tasks/task-28 - Diagnose-dashboard-initial-load-retry-loop.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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 + + +## Implementation Plan + + +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 + + +## Implementation Notes + + +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. + diff --git a/components/dashboard-sidebar.tsx b/components/dashboard-sidebar.tsx index df78cf3..fd5ced7 100644 --- a/components/dashboard-sidebar.tsx +++ b/components/dashboard-sidebar.tsx @@ -55,6 +55,7 @@ export function DashboardSidebar() { )} href={item.href} key={item.href} + prefetch={false} > {item.label} diff --git a/components/dashboard-theme.tsx b/components/dashboard-theme.tsx index 8f04d5b..34a68ac 100644 --- a/components/dashboard-theme.tsx +++ b/components/dashboard-theme.tsx @@ -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(null); +function isDashboardTheme(value: string | null): value is DashboardTheme { + return value === "dark" || value === "light"; +} + +function getStoredDashboardTheme(): DashboardTheme { + const storedTheme = window.localStorage.getItem(storageKey); + + 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, setTheme] = useState(() => { - if (typeof window === "undefined") { - return "light"; - } - - const storedTheme = window.localStorage.getItem(storageKey); - - if (storedTheme === "dark" || storedTheme === "light") { - return storedTheme; - } - - return "light"; - }); + const theme = useSyncExternalStore( + subscribeToDashboardTheme, + getStoredDashboardTheme, + getServerDashboardTheme, + ); const value = useMemo( () => ({ theme, toggleTheme: () => { - setTheme((currentTheme) => { - const nextTheme = currentTheme === "dark" ? "light" : "dark"; - window.localStorage.setItem(storageKey, nextTheme); - return nextTheme; - }); + const nextTheme = theme === "dark" ? "light" : "dark"; + window.localStorage.setItem(storageKey, nextTheme); + window.dispatchEvent(new Event(themeChangeEvent)); }, }), [theme], diff --git a/components/lead-funnel-board.tsx b/components/lead-funnel-board.tsx index 1330097..bbec255 100644 --- a/components/lead-funnel-board.tsx +++ b/components/lead-funnel-board.tsx @@ -170,6 +170,7 @@ function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) { {card.nextAction} diff --git a/components/outreach/outreach-review-workspace.tsx b/components/outreach/outreach-review-workspace.tsx new file mode 100644 index 0000000..f8a44a6 --- /dev/null +++ b/components/outreach/outreach-review-workspace.tsx @@ -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[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 = { + 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 ( + + ); +} + +function FieldPair({ label, value }: { label: string; value?: string | null }) { + return ( +
+
{label}
+
{compactText(value)}
+
+ ); +} + +function WorkspaceLoading() { + return ( +
+
+

Interne Outreach-Prüfung

+

Review Workspace

+
+
+ {Array.from({ length: 3 }, (_, index) => ( + + ))} +
+
+ ); +} + +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>({}); + const [openSources, setOpenSources] = useState>({}); + const [openRaw, setOpenRaw] = useState>({}); + const [busyAction, setBusyAction] = useState(null); + const [notice, setNotice] = useState(null); + + const rows = useMemo(() => records ?? [], [records]); + + if (records === undefined) { + return ; + } + + if (rows.length === 0) { + return ( +
+
+

Interne Outreach-Prüfung

+

Review Workspace

+
+ + +

Keine offenen Reviews

+

+ Sobald Audit- und Outreach-Entwürfe bereitstehen, erscheinen sie hier. +

+
+
+
+ ); + } + + 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 ( +
+
+

Interne Outreach-Prüfung

+

Review Workspace

+

+ Audits, E-Mail-Empfehlung und Telefonnotizen prüfen, bevor etwas öffentlich + wird oder eine Freigabe erhält. +

+
+ + {notice ? ( +

{notice}

+ ) : null} + +
+ {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 ( + + +
+
+ + {compactText(lead?.companyName, "Unbenannter Lead")} + +

+ {compactText( + lead?.websiteDomain ?? lead?.websiteUrl, + "Keine Domain", + )} +

+
+
+ {formatStrategy(strategy)} + + {compactText(lead?.contactStatus, "Kontaktstatus offen")} + + + {compactText(audit?.status, "Auditstatus offen")} + +
+
+
+ + +
+
+

Lead-Details

+
+ + + + + + +
+
+

+ Prioritätsgrund +

+

+ {compactText(lead?.priorityReason)} +

+
+
+

+ Kontaktstrategie +

+

{formatStrategy(strategy)}

+
+
+ +
+
+

Audit-Zusammenfassung

+ {publicAuditHref ? ( + + ) : ( + + Public-Audit ohne Slug + + )} +
+ + + +