From f069b74b08e6c63065d963a53c7f5ca9a40599d4 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 5 Jun 2026 21:49:57 +0200 Subject: [PATCH] Finalize metrics verification and backlog updates --- app/api/internal/rybbit/campaign/route.ts | 18 +++++ ...low-up-and-manual-sales-status-tracking.md | 14 ++-- ...g-Convex-agent-jobs-and-audit-lifecycle.md | 14 ++-- ... - Add-Rybbit-audit-analytics-dashboard.md | 12 +-- ...VP-quality-gates-and-operational-polish.md | 14 ++-- ...k-19 - Add-campaign-performance-metrics.md | 14 ++-- ...-audit-generation-after-PageSpeed-audit.md | 12 +-- components/analytics/analytics-dashboard.tsx | 39 +++++++++- convex/leads.ts | 1 + lib/dashboard-model.ts | 10 +++ lib/rybbit-analytics.ts | 78 +++++++++++++++++++ tests/analytics-source.test.ts | 3 + tests/dashboard-model.test.ts | 21 +++++ tests/outreach-follow-up-source.test.ts | 9 +++ tests/rybbit-analytics.test.ts | 17 ++++ 15 files changed, 240 insertions(+), 36 deletions(-) create mode 100644 app/api/internal/rybbit/campaign/route.ts diff --git a/app/api/internal/rybbit/campaign/route.ts b/app/api/internal/rybbit/campaign/route.ts new file mode 100644 index 0000000..0fc4e3b --- /dev/null +++ b/app/api/internal/rybbit/campaign/route.ts @@ -0,0 +1,18 @@ +import { fetchRybbitCampaignAnalytics } from "@/lib/rybbit-analytics"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const result = await fetchRybbitCampaignAnalytics({ + apiUrl: process.env.RYBBIT_API_URL, + apiKey: process.env.RYBBIT_API_KEY, + siteId: process.env.NEXT_PUBLIC_RYBBIT_SITE_ID, + startDate: url.searchParams.get("startDate") ?? undefined, + endDate: url.searchParams.get("endDate") ?? undefined, + }); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error, data: result.data }); + } + + return Response.json({ ok: true, data: result.data }); +} diff --git a/backlog/tasks/task-15 - Add-follow-up-and-manual-sales-status-tracking.md b/backlog/tasks/task-15 - Add-follow-up-and-manual-sales-status-tracking.md index d7b9d9a..21fddab 100644 --- a/backlog/tasks/task-15 - Add-follow-up-and-manual-sales-status-tracking.md +++ b/backlog/tasks/task-15 - Add-follow-up-and-manual-sales-status-tracking.md @@ -4,7 +4,7 @@ title: Add follow-up and manual sales status tracking status: In Progress assignee: [] created_date: '2026-06-03 19:14' -updated_date: '2026-06-05 19:30' +updated_date: '2026-06-05 19:49' labels: - mvp - sales @@ -25,11 +25,11 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse ## Acceptance Criteria -- [ ] #1 After an initial send, a single follow-up draft and suggested due date are created -- [ ] #2 Follow-up sending requires manual review and approval, just like the first email -- [ ] #3 Manual statuses exist for Antwort erhalten, Kein Interesse, Später wieder melden, Gespräch vereinbart, Angebot angefragt, Angebot gesendet, Auftrag gewonnen, Auftrag verloren, Nicht weiter verfolgen, Follow-up geplant, and Follow-up gesendet -- [ ] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts -- [ ] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen +- [x] #1 After an initial send, a single follow-up draft and suggested due date are created +- [x] #2 Follow-up sending requires manual review and approval, just like the first email +- [x] #3 Manual statuses exist for Antwort erhalten, Kein Interesse, Später wieder melden, Gespräch vereinbart, Angebot angefragt, Angebot gesendet, Auftrag gewonnen, Auftrag verloren, Nicht weiter verfolgen, Follow-up geplant, and Follow-up gesendet +- [x] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts +- [x] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen ## Implementation Plan @@ -46,4 +46,6 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse Started implementation pass for tasks 15-19 and 27. TASK-27 note says it is superseded by TASK-13, so this pass will verify the existing PageSpeed-to-audit-generation handoff rather than implement it separately. + +Implemented and verified follow-up draft creation after send, manual approval boundaries for follow-up records, manual sales status labels/mutation, reply/no-interest suppression, and 12-month do-not-contact recheck visibility. Verification: pnpm test 305/305; pnpm lint 0 errors. diff --git a/backlog/tasks/task-16 - Orchestrate-recurring-Convex-agent-jobs-and-audit-lifecycle.md b/backlog/tasks/task-16 - Orchestrate-recurring-Convex-agent-jobs-and-audit-lifecycle.md index 340b591..4a9fc7f 100644 --- a/backlog/tasks/task-16 - Orchestrate-recurring-Convex-agent-jobs-and-audit-lifecycle.md +++ b/backlog/tasks/task-16 - Orchestrate-recurring-Convex-agent-jobs-and-audit-lifecycle.md @@ -4,7 +4,7 @@ title: Orchestrate recurring Convex agent jobs and audit lifecycle status: In Progress assignee: [] created_date: '2026-06-03 19:14' -updated_date: '2026-06-05 19:30' +updated_date: '2026-06-05 19:49' labels: - mvp - convex @@ -27,11 +27,11 @@ Implement the scheduled and manual background workflow using Convex. The MVP per ## Acceptance Criteria -- [ ] #1 Convex cron or scheduled functions trigger active campaigns according to cadence -- [ ] #2 Jetzt ausführen starts a campaign run immediately only when no other agent run is active -- [ ] #3 Cron skips or queues safely when an agent run is already active, with visible run logs -- [ ] #4 Published audits older than 30 days create dashboard notifications asking whether to keep active -- [ ] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated +- [x] #1 Convex cron or scheduled functions trigger active campaigns according to cadence +- [x] #2 Jetzt ausführen starts a campaign run immediately only when no other agent run is active +- [x] #3 Cron skips or queues safely when an agent run is already active, with visible run logs +- [x] #4 Published audits older than 30 days create dashboard notifications asking whether to keep active +- [x] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated ## Implementation Plan @@ -48,4 +48,6 @@ Implement the scheduled and manual background workflow using Convex. The MVP per Started implementation pass for recurring Convex agent jobs, run locking, logs, and audit lifecycle. + +Implemented and verified Convex crons, due-campaign runner, single-active-run guard, visible campaign run logs, and audit lifecycle notification/deactivation controls. Verification: pnpm test 305/305; pnpm lint 0 errors. diff --git a/backlog/tasks/task-17 - Add-Rybbit-audit-analytics-dashboard.md b/backlog/tasks/task-17 - Add-Rybbit-audit-analytics-dashboard.md index 54d2ab2..29e4cd5 100644 --- a/backlog/tasks/task-17 - Add-Rybbit-audit-analytics-dashboard.md +++ b/backlog/tasks/task-17 - Add-Rybbit-audit-analytics-dashboard.md @@ -4,7 +4,7 @@ title: Add Rybbit audit analytics dashboard status: In Progress assignee: [] created_date: '2026-06-03 19:14' -updated_date: '2026-06-05 19:30' +updated_date: '2026-06-05 19:49' labels: - mvp - analytics @@ -25,11 +25,11 @@ Display anonymous analytics for generated public audit pages inside the internal ## Acceptance Criteria -- [ ] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes -- [ ] #2 Dashboard can fetch Rybbit API data for pageviews, custom events, and outbound link clicks for audit pages -- [ ] #3 Per-audit analytics show opened yes/no, view count, last view, CTA clicks, website-link clicks, and device type where available +- [x] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes +- [x] #2 Dashboard can fetch Rybbit API data for pageviews, custom events, and outbound link clicks for audit pages +- [x] #3 Per-audit analytics show opened yes/no, view count, last view, CTA clicks, website-link clicks, and device type where available - [ ] #4 Campaign analytics aggregate audit opens and CTA activity by campaign, niche, region, and timeframe -- [ ] #5 Rybbit API failures are shown gracefully and do not break the rest of the dashboard +- [x] #5 Rybbit API failures are shown gracefully and do not break the rest of the dashboard ## Implementation Plan @@ -46,4 +46,6 @@ Display anonymous analytics for generated public audit pages inside the internal Started implementation pass for Rybbit public-audit tracking and dashboard analytics surfaces. + +Implemented public-audit-only Rybbit tracking, on-demand Rybbit API routes for audit/campaign activity, per-audit summary helper, dashboard Rybbit error handling, and campaign-level overall Rybbit signals. AC4 remains open for full grouping by campaign/niche/region/timeframe because Rybbit events still need a stronger audit-to-campaign join model. Verification: pnpm test 305/305; pnpm lint 0 errors. diff --git a/backlog/tasks/task-18 - Add-MVP-quality-gates-and-operational-polish.md b/backlog/tasks/task-18 - Add-MVP-quality-gates-and-operational-polish.md index 8e4fffe..de5b7cd 100644 --- a/backlog/tasks/task-18 - Add-MVP-quality-gates-and-operational-polish.md +++ b/backlog/tasks/task-18 - Add-MVP-quality-gates-and-operational-polish.md @@ -4,7 +4,7 @@ title: Add MVP quality gates and operational polish status: In Progress assignee: [] created_date: '2026-06-03 19:15' -updated_date: '2026-06-05 19:30' +updated_date: '2026-06-05 19:49' labels: - mvp - quality @@ -27,11 +27,11 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access ## Acceptance Criteria -- [ ] #1 Core UI text is German and organized so future i18n is feasible -- [ ] #2 No secrets are stored in source code, dashboard-editable records, logs, prompts, or raw LLM history -- [ ] #3 Dashboard surfaces integration errors for Google, PageSpeed, OpenRouter, Playwright, SMTP, Convex jobs, and Rybbit -- [ ] #4 Critical user flows have basic tests or repeatable verification notes: login, campaign run, audit generation, approval, send, follow-up, analytics -- [ ] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions +- [x] #1 Core UI text is German and organized so future i18n is feasible +- [x] #2 No secrets are stored in source code, dashboard-editable records, logs, prompts, or raw LLM history +- [x] #3 Dashboard surfaces integration errors for Google, PageSpeed, OpenRouter, Playwright, SMTP, Convex jobs, and Rybbit +- [x] #4 Critical user flows have basic tests or repeatable verification notes: login, campaign run, audit generation, approval, send, follow-up, analytics +- [x] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions ## Implementation Plan @@ -48,4 +48,6 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access Started implementation pass for MVP quality gates, error observability, verification notes, and deployment readiness. + +Implemented and verified German operational readiness surfaces, secret-safe integration status rows, verification notes for critical flows, and Coolify deployment notes. Verification: pnpm test 305/305; pnpm lint 0 errors. diff --git a/backlog/tasks/task-19 - Add-campaign-performance-metrics.md b/backlog/tasks/task-19 - Add-campaign-performance-metrics.md index d1c84a9..a5d9818 100644 --- a/backlog/tasks/task-19 - Add-campaign-performance-metrics.md +++ b/backlog/tasks/task-19 - Add-campaign-performance-metrics.md @@ -4,7 +4,7 @@ title: Add campaign performance metrics status: In Progress assignee: [] created_date: '2026-06-03 19:15' -updated_date: '2026-06-05 19:30' +updated_date: '2026-06-05 19:49' labels: - mvp - analytics @@ -27,11 +27,11 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve ## Acceptance Criteria -- [ ] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses -- [ ] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe -- [ ] #3 Campaign run detail shows new leads, skipped duplicates, blacklisted/skipped leads, errors, and audits generated -- [ ] #4 Rybbit-derived audit opens and CTA clicks are shown alongside Convex sales funnel metrics -- [ ] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard +- [x] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses +- [x] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe +- [x] #3 Campaign run detail shows new leads, skipped duplicates, blacklisted/skipped leads, errors, and audits generated +- [x] #4 Rybbit-derived audit opens and CTA clicks are shown alongside Convex sales funnel metrics +- [x] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard ## Implementation Plan @@ -48,4 +48,6 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve 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. diff --git a/backlog/tasks/task-27 - Trigger-audit-generation-after-PageSpeed-audit.md b/backlog/tasks/task-27 - Trigger-audit-generation-after-PageSpeed-audit.md index ce38fde..9a2ecc2 100644 --- a/backlog/tasks/task-27 - Trigger-audit-generation-after-PageSpeed-audit.md +++ b/backlog/tasks/task-27 - Trigger-audit-generation-after-PageSpeed-audit.md @@ -4,7 +4,7 @@ title: Trigger audit generation after PageSpeed audit status: In Progress assignee: [] created_date: '2026-06-05 12:10' -updated_date: '2026-06-05 19:30' +updated_date: '2026-06-05 19:49' labels: [] dependencies: [] priority: high @@ -19,10 +19,10 @@ Wire the existing AI audit generation queue into the current automated flow so c ## Acceptance Criteria -- [ ] #1 Successful PageSpeed audit runs queue audit generation for the lead -- [ ] #2 Failed PageSpeed audit runs still queue audit generation when a lead was started so partial evidence can produce an audit -- [ ] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs -- [ ] #4 Regression tests cover the PageSpeed-to-audit-generation handoff +- [x] #1 Successful PageSpeed audit runs queue audit generation for the lead +- [x] #2 Failed PageSpeed audit runs still queue audit generation when a lead was started so partial evidence can produce an audit +- [x] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs +- [x] #4 Regression tests cover the PageSpeed-to-audit-generation handoff ## Implementation Notes @@ -31,4 +31,6 @@ Wire the existing AI audit generation queue into the current automated flow so c Created accidentally while implementing the PageSpeed-to-audit-generation handoff. Superseded by TASK-13 because the handoff is a prerequisite for the audit/outreach review workspace. Do not implement separately. Started verification pass. Implementation notes say TASK-27 is superseded by TASK-13, so only regression coverage and existing handoff will be checked. + +Verified existing PageSpeed-to-audit-generation handoff in pageSpeedAction. Successful and failure paths queue audit generation for the started lead, queue failures are warning-logged, existing queueLeadAuditGeneration dedupe remains in place, and regression source tests pass. Verification: pnpm test 305/305; pnpm lint 0 errors. diff --git a/components/analytics/analytics-dashboard.tsx b/components/analytics/analytics-dashboard.tsx index a5dcce7..7d6e70b 100644 --- a/components/analytics/analytics-dashboard.tsx +++ b/components/analytics/analytics-dashboard.tsx @@ -1,8 +1,8 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "convex/react"; -import { Activity, BarChart3, Filter, MousePointerClick } from "lucide-react"; +import { Activity, Filter, MousePointerClick } from "lucide-react"; import { api } from "@/convex/_generated/api"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -30,6 +30,12 @@ const metricLabels: Record = { export function AnalyticsDashboard() { const dashboard = useQuery(api.campaignMetrics.getDashboard, { limit: 20 }); + const [rybbitData, setRybbitData] = useState<{ + auditOpens: number; + ctaClicks: number; + outboundClicks: number; + } | null>(null); + const [rybbitError, setRybbitError] = useState(null); const metricEntries = useMemo(() => { if (!dashboard) { return []; @@ -38,6 +44,31 @@ export function AnalyticsDashboard() { return Object.entries(dashboard.metrics).filter(([key]) => key in metricLabels); }, [dashboard]); + useEffect(() => { + let isMounted = true; + + fetch("/api/internal/rybbit/campaign") + .then(async (response) => { + const payload = await response.json(); + if (!isMounted) { + return; + } + if (!payload.ok) { + setRybbitError("Rybbit-Daten konnten nicht geladen werden."); + } + setRybbitData(payload.data ?? null); + }) + .catch(() => { + if (isMounted) { + setRybbitError("Rybbit-Daten konnten nicht geladen werden."); + } + }); + + return () => { + isMounted = false; + }; + }, []); + if (dashboard === undefined) { return (
@@ -130,6 +161,10 @@ export function AnalyticsDashboard() {

Rybbit-Daten konnten nicht geladen werden, wenn API-URL, Site-ID oder API-Key fehlen.

+ {rybbitError ?

{rybbitError}

: null} +

Audit-Öffnungen: {rybbitData?.auditOpens ?? dashboard.metrics.rybbitAuditOpens}

+

CTA-Klicks: {rybbitData?.ctaClicks ?? dashboard.metrics.rybbitCtaClicks}

+

Website-Link-Klicks: {rybbitData?.outboundClicks ?? 0}

Public-Audit Tracking läuft nur auf veröffentlichten Audit-Seiten.

diff --git a/convex/leads.ts b/convex/leads.ts index 08b1294..11a5367 100644 --- a/convex/leads.ts +++ b/convex/leads.ts @@ -392,6 +392,7 @@ export const listFunnel = query({ sendStatus: latestOutreach.sendStatus, responseStatus: latestOutreach.responseStatus, salesStatus: latestOutreach.salesStatus, + doNotContactUntil: latestOutreach.doNotContactUntil ?? null, } : null, }; diff --git a/lib/dashboard-model.ts b/lib/dashboard-model.ts index 54b2a7d..14afc37 100644 --- a/lib/dashboard-model.ts +++ b/lib/dashboard-model.ts @@ -86,6 +86,7 @@ export type LeadFunnelOutreach = { sendStatus?: OutreachSendStatus | null; responseStatus?: OutreachResponseStatus | null; salesStatus?: OutreachSalesStatus | null; + doNotContactUntil?: number | null; }; export type LeadFunnelInput = { @@ -103,6 +104,7 @@ export type LeadFunnelInput = { contactPerson?: string | null; websiteDomain?: string | null; outreach?: LeadFunnelOutreach | null; + now?: number; }; export type LeadFunnelCard = { @@ -303,6 +305,14 @@ function getLeadNextAction(lead: LeadFunnelInput): string { const stageId = getLeadFunnelStageId(lead); if (stageId === "deferred") { + if ( + lead.outreach?.salesStatus === "do_not_pursue" && + typeof lead.outreach.doNotContactUntil === "number" && + (lead.now ?? Date.now()) >= lead.outreach.doNotContactUntil + ) { + return "Erneut prüfen"; + } + return "Zurückstellung prüfen"; } diff --git a/lib/rybbit-analytics.ts b/lib/rybbit-analytics.ts index aee512c..3590b36 100644 --- a/lib/rybbit-analytics.ts +++ b/lib/rybbit-analytics.ts @@ -18,6 +18,12 @@ export type AuditRybbitSummary = { deviceTypes: string[]; }; +export type CampaignRybbitSummary = { + auditOpens: number; + ctaClicks: number; + outboundClicks: number; +}; + type FetchLike = ( input: string | URL, init?: RequestInit, @@ -117,6 +123,23 @@ export function summarizeAuditRybbitEvents( }; } +export function summarizeCampaignRybbitEvents( + events: RybbitEvent[], +): CampaignRybbitSummary { + const auditEvents = events.filter((event) => eventPath(event).startsWith("/audit/")); + + return { + auditOpens: auditEvents.filter((event) => event.type === "pageview").length, + ctaClicks: auditEvents.filter((event) => { + return event.type === "custom_event" && eventName(event) === "audit_cta_click"; + }).length, + outboundClicks: auditEvents.filter((event) => { + return event.type === "outbound_link" || + eventName(event) === "audit_website_link_click"; + }).length, + }; +} + function normalizeEventsPayload(payload: unknown): RybbitEvent[] { if (Array.isArray(payload)) { return payload.filter((event): event is RybbitEvent => typeof event === "object" && event !== null); @@ -193,3 +216,58 @@ export async function fetchRybbitAuditAnalytics(input: { }; } } + +export async function fetchRybbitCampaignAnalytics(input: { + apiUrl?: string; + apiKey?: string; + siteId?: string; + startDate?: string; + endDate?: string; + fetchImpl?: FetchLike; +}) { + if (!input.apiUrl || !input.apiKey || !input.siteId) { + return { + ok: false as const, + error: "Rybbit ist nicht vollständig konfiguriert.", + data: summarizeCampaignRybbitEvents([]), + }; + } + + try { + const response = await (input.fetchImpl ?? fetch)( + buildRybbitEventsUrl({ + apiUrl: input.apiUrl, + siteId: input.siteId, + startDate: input.startDate, + endDate: input.endDate, + }), + { + headers: { + Authorization: `Bearer ${input.apiKey}`, + }, + }, + ); + + if (!response.ok) { + const body = await response.text(); + return { + ok: false as const, + error: `Rybbit API Fehler ${response.status}: ${body.slice(0, 160)}`, + data: summarizeCampaignRybbitEvents([]), + }; + } + + return { + ok: true as const, + data: summarizeCampaignRybbitEvents( + normalizeEventsPayload(await response.json()), + ), + }; + } catch (error) { + return { + ok: false as const, + error: error instanceof Error ? error.message : String(error), + data: summarizeCampaignRybbitEvents([]), + }; + } +} diff --git a/tests/analytics-source.test.ts b/tests/analytics-source.test.ts index 38c12f7..b45e99c 100644 --- a/tests/analytics-source.test.ts +++ b/tests/analytics-source.test.ts @@ -61,6 +61,7 @@ test("analytics dashboard renders filters, Convex metrics, and Rybbit error stat assert.doesNotMatch(pageSource, /DashboardPlaceholderPage/); assert.match(pageSource, /AnalyticsDashboard/); assert.match(componentSource, /api\.campaignMetrics\.getDashboard/); + assert.match(componentSource, /\/api\/internal\/rybbit\/campaign/); assert.match(componentSource, /Kampagne/); assert.match(componentSource, /Nische/); assert.match(componentSource, /PLZ/); @@ -69,4 +70,6 @@ test("analytics dashboard renders filters, Convex metrics, and Rybbit error stat assert.match(componentSource, /Status/); assert.match(componentSource, /Zeitraum/); assert.match(componentSource, /Rybbit-Daten konnten nicht geladen werden/); + assert.match(componentSource, /Audit-Öffnungen/); + assert.match(componentSource, /CTA-Klicks/); }); diff --git a/tests/dashboard-model.test.ts b/tests/dashboard-model.test.ts index 41c158b..c209fb4 100644 --- a/tests/dashboard-model.test.ts +++ b/tests/dashboard-model.test.ts @@ -192,6 +192,27 @@ test("toLeadFunnelCard maps blocked priority to deferred stage with blocker labe assert.equal(card.nextAction, "Zurückstellung prüfen"); }); +test("toLeadFunnelCard shows do-not-contact rechecks after the block window", () => { + const card = toLeadFunnelCard({ + id: "lead-recheck", + companyName: "Agentur Recheck", + priority: "medium", + contactStatus: "do_not_contact", + blacklistStatus: "clear", + outreach: { + approvalStatus: "approved", + sendStatus: "sent", + responseStatus: "no_interest", + salesStatus: "do_not_pursue", + doNotContactUntil: Date.UTC(2026, 0, 1), + }, + now: Date.UTC(2026, 0, 2), + }); + + assert.equal(card.stageId, "deferred"); + assert.equal(card.nextAction, "Erneut prüfen"); +}); + test("dashboard-model exposes stable lead label helpers for UI mapping", () => { assert.deepEqual(leadPriorityOptions, [ "high", diff --git a/tests/outreach-follow-up-source.test.ts b/tests/outreach-follow-up-source.test.ts index 3145044..e0b6091 100644 --- a/tests/outreach-follow-up-source.test.ts +++ b/tests/outreach-follow-up-source.test.ts @@ -32,3 +32,12 @@ test("manual sales status mutation updates lead suppression states", () => { assert.match(outreachSource, /not_interested[\s\S]*outreachPatch\.responseStatus\s*=\s*"no_interest"/); assert.match(outreachSource, /do_not_pursue[\s\S]*outreachPatch\.doNotContactUntil\s*=\s*now\s*\+\s*DO_NOT_CONTACT_RECHECK_MS/); }); + +test("lead funnel query exposes do-not-contact recheck dates", () => { + const leadsSource = readFileSync( + join(process.cwd(), "convex", "leads.ts"), + "utf8", + ); + + assert.match(leadsSource, /doNotContactUntil:\s*latestOutreach\.doNotContactUntil/); +}); diff --git a/tests/rybbit-analytics.test.ts b/tests/rybbit-analytics.test.ts index 79939a3..91ff208 100644 --- a/tests/rybbit-analytics.test.ts +++ b/tests/rybbit-analytics.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { buildRybbitEventsUrl, + summarizeCampaignRybbitEvents, summarizeAuditRybbitEvents, type RybbitEvent, } from "../lib/rybbit-analytics"; @@ -70,3 +71,19 @@ test("summarizeAuditRybbitEvents returns graceful empty metrics", () => { deviceTypes: [], }); }); + +test("summarizeCampaignRybbitEvents aggregates public audit activity", () => { + assert.deepEqual( + summarizeCampaignRybbitEvents([ + { type: "pageview", pathname: "/audit/a" }, + { type: "pageview", pathname: "/dashboard" }, + { type: "custom_event", event_name: "audit_cta_click", pathname: "/audit/a" }, + { type: "outbound_link", pathname: "/audit/a" }, + ]), + { + auditOpens: 1, + ctaClicks: 1, + outboundClicks: 1, + }, + ); +});