Finalize metrics verification and backlog updates
This commit is contained in:
18
app/api/internal/rybbit/campaign/route.ts
Normal file
18
app/api/internal/rybbit/campaign/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { fetchRybbitCampaignAnalytics } from "@/lib/rybbit-analytics";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const result = await fetchRybbitCampaignAnalytics({
|
||||||
|
apiUrl: process.env.RYBBIT_API_URL,
|
||||||
|
apiKey: process.env.RYBBIT_API_KEY,
|
||||||
|
siteId: process.env.NEXT_PUBLIC_RYBBIT_SITE_ID,
|
||||||
|
startDate: url.searchParams.get("startDate") ?? undefined,
|
||||||
|
endDate: url.searchParams.get("endDate") ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return Response.json({ ok: false, error: result.error, data: result.data });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ ok: true, data: result.data });
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ title: Add follow-up and manual sales status tracking
|
|||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:14'
|
created_date: '2026-06-03 19:14'
|
||||||
updated_date: '2026-06-05 19:30'
|
updated_date: '2026-06-05 19:49'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- sales
|
- sales
|
||||||
@@ -25,11 +25,11 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 After an initial send, a single follow-up draft and suggested due date are created
|
- [x] #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
|
- [x] #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
|
- [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
|
||||||
- [ ] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts
|
- [x] #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] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -46,4 +46,6 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
|
|||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
Started implementation pass for tasks 15-19 and 27. TASK-27 note says it is superseded by TASK-13, so this pass will verify the existing PageSpeed-to-audit-generation handoff rather than implement it separately.
|
Started implementation pass for tasks 15-19 and 27. TASK-27 note says it is superseded by TASK-13, so this pass will verify the existing PageSpeed-to-audit-generation handoff rather than implement it separately.
|
||||||
|
|
||||||
|
Implemented and verified follow-up draft creation after send, manual approval boundaries for follow-up records, manual sales status labels/mutation, reply/no-interest suppression, and 12-month do-not-contact recheck visibility. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: Orchestrate recurring Convex agent jobs and audit lifecycle
|
|||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:14'
|
created_date: '2026-06-03 19:14'
|
||||||
updated_date: '2026-06-05 19:30'
|
updated_date: '2026-06-05 19:49'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- convex
|
- convex
|
||||||
@@ -27,11 +27,11 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Convex cron or scheduled functions trigger active campaigns according to cadence
|
- [x] #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
|
- [x] #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
|
- [x] #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
|
- [x] #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] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -48,4 +48,6 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
|
|||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
Started implementation pass for recurring Convex agent jobs, run locking, logs, and audit lifecycle.
|
Started implementation pass for recurring Convex agent jobs, run locking, logs, and audit lifecycle.
|
||||||
|
|
||||||
|
Implemented and verified Convex crons, due-campaign runner, single-active-run guard, visible campaign run logs, and audit lifecycle notification/deactivation controls. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: Add Rybbit audit analytics dashboard
|
|||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:14'
|
created_date: '2026-06-03 19:14'
|
||||||
updated_date: '2026-06-05 19:30'
|
updated_date: '2026-06-05 19:49'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- analytics
|
- analytics
|
||||||
@@ -25,11 +25,11 @@ Display anonymous analytics for generated public audit pages inside the internal
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes
|
- [x] #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
|
- [x] #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] #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
|
- [ ] #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
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -46,4 +46,6 @@ Display anonymous analytics for generated public audit pages inside the internal
|
|||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
Started implementation pass for Rybbit public-audit tracking and dashboard analytics surfaces.
|
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.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: Add MVP quality gates and operational polish
|
|||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:15'
|
created_date: '2026-06-03 19:15'
|
||||||
updated_date: '2026-06-05 19:30'
|
updated_date: '2026-06-05 19:49'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- quality
|
- quality
|
||||||
@@ -27,11 +27,11 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Core UI text is German and organized so future i18n is feasible
|
- [x] #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
|
- [x] #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
|
- [x] #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
|
- [x] #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] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -48,4 +48,6 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
|
|||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
Started implementation pass for MVP quality gates, error observability, verification notes, and deployment readiness.
|
Started implementation pass for MVP quality gates, error observability, verification notes, and deployment readiness.
|
||||||
|
|
||||||
|
Implemented and verified German operational readiness surfaces, secret-safe integration status rows, verification notes for critical flows, and Coolify deployment notes. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: Add campaign performance metrics
|
|||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:15'
|
created_date: '2026-06-03 19:15'
|
||||||
updated_date: '2026-06-05 19:30'
|
updated_date: '2026-06-05 19:49'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- analytics
|
- analytics
|
||||||
@@ -27,11 +27,11 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses
|
- [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
|
||||||
- [ ] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe
|
- [x] #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
|
- [x] #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
|
- [x] #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] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -48,4 +48,6 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
|
|||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
Started implementation pass for campaign performance metrics and filters.
|
Started implementation pass for campaign performance metrics and filters.
|
||||||
|
|
||||||
|
Implemented and verified lightweight campaign metrics query/dashboard, filter contract, run detail rows, and Rybbit-derived audit opens/CTA clicks alongside Convex metrics. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: Trigger audit generation after PageSpeed audit
|
|||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-05 12:10'
|
created_date: '2026-06-05 12:10'
|
||||||
updated_date: '2026-06-05 19:30'
|
updated_date: '2026-06-05 19:49'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -19,10 +19,10 @@ Wire the existing AI audit generation queue into the current automated flow so c
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Successful PageSpeed audit runs queue audit generation for the lead
|
- [x] #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
|
- [x] #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
|
- [x] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs
|
||||||
- [ ] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
|
- [x] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Notes
|
## 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.
|
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.
|
Started verification pass. Implementation notes say TASK-27 is superseded by TASK-13, so only regression coverage and existing handoff will be checked.
|
||||||
|
|
||||||
|
Verified existing PageSpeed-to-audit-generation handoff in pageSpeedAction. Successful and failure paths queue audit generation for the started lead, queue failures are warning-logged, existing queueLeadAuditGeneration dedupe remains in place, and regression source tests pass. Verification: pnpm test 305/305; pnpm lint 0 errors.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "convex/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 { api } from "@/convex/_generated/api";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -30,6 +30,12 @@ const metricLabels: Record<string, string> = {
|
|||||||
|
|
||||||
export function AnalyticsDashboard() {
|
export function AnalyticsDashboard() {
|
||||||
const dashboard = useQuery(api.campaignMetrics.getDashboard, { limit: 20 });
|
const dashboard = useQuery(api.campaignMetrics.getDashboard, { limit: 20 });
|
||||||
|
const [rybbitData, setRybbitData] = useState<{
|
||||||
|
auditOpens: number;
|
||||||
|
ctaClicks: number;
|
||||||
|
outboundClicks: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [rybbitError, setRybbitError] = useState<string | null>(null);
|
||||||
const metricEntries = useMemo(() => {
|
const metricEntries = useMemo(() => {
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
return [];
|
return [];
|
||||||
@@ -38,6 +44,31 @@ export function AnalyticsDashboard() {
|
|||||||
return Object.entries(dashboard.metrics).filter(([key]) => key in metricLabels);
|
return Object.entries(dashboard.metrics).filter(([key]) => key in metricLabels);
|
||||||
}, [dashboard]);
|
}, [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) {
|
if (dashboard === undefined) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
@@ -130,6 +161,10 @@ export function AnalyticsDashboard() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||||
<p>Rybbit-Daten konnten nicht geladen werden, wenn API-URL, Site-ID oder API-Key fehlen.</p>
|
<p>Rybbit-Daten konnten nicht geladen werden, wenn API-URL, Site-ID oder API-Key fehlen.</p>
|
||||||
|
{rybbitError ? <p className="text-destructive">{rybbitError}</p> : null}
|
||||||
|
<p>Audit-Öffnungen: {rybbitData?.auditOpens ?? dashboard.metrics.rybbitAuditOpens}</p>
|
||||||
|
<p>CTA-Klicks: {rybbitData?.ctaClicks ?? dashboard.metrics.rybbitCtaClicks}</p>
|
||||||
|
<p>Website-Link-Klicks: {rybbitData?.outboundClicks ?? 0}</p>
|
||||||
<p>Public-Audit Tracking läuft nur auf veröffentlichten Audit-Seiten.</p>
|
<p>Public-Audit Tracking läuft nur auf veröffentlichten Audit-Seiten.</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ export const listFunnel = query({
|
|||||||
sendStatus: latestOutreach.sendStatus,
|
sendStatus: latestOutreach.sendStatus,
|
||||||
responseStatus: latestOutreach.responseStatus,
|
responseStatus: latestOutreach.responseStatus,
|
||||||
salesStatus: latestOutreach.salesStatus,
|
salesStatus: latestOutreach.salesStatus,
|
||||||
|
doNotContactUntil: latestOutreach.doNotContactUntil ?? null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export type LeadFunnelOutreach = {
|
|||||||
sendStatus?: OutreachSendStatus | null;
|
sendStatus?: OutreachSendStatus | null;
|
||||||
responseStatus?: OutreachResponseStatus | null;
|
responseStatus?: OutreachResponseStatus | null;
|
||||||
salesStatus?: OutreachSalesStatus | null;
|
salesStatus?: OutreachSalesStatus | null;
|
||||||
|
doNotContactUntil?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LeadFunnelInput = {
|
export type LeadFunnelInput = {
|
||||||
@@ -103,6 +104,7 @@ export type LeadFunnelInput = {
|
|||||||
contactPerson?: string | null;
|
contactPerson?: string | null;
|
||||||
websiteDomain?: string | null;
|
websiteDomain?: string | null;
|
||||||
outreach?: LeadFunnelOutreach | null;
|
outreach?: LeadFunnelOutreach | null;
|
||||||
|
now?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LeadFunnelCard = {
|
export type LeadFunnelCard = {
|
||||||
@@ -303,6 +305,14 @@ function getLeadNextAction(lead: LeadFunnelInput): string {
|
|||||||
const stageId = getLeadFunnelStageId(lead);
|
const stageId = getLeadFunnelStageId(lead);
|
||||||
|
|
||||||
if (stageId === "deferred") {
|
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";
|
return "Zurückstellung prüfen";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export type AuditRybbitSummary = {
|
|||||||
deviceTypes: string[];
|
deviceTypes: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignRybbitSummary = {
|
||||||
|
auditOpens: number;
|
||||||
|
ctaClicks: number;
|
||||||
|
outboundClicks: number;
|
||||||
|
};
|
||||||
|
|
||||||
type FetchLike = (
|
type FetchLike = (
|
||||||
input: string | URL,
|
input: string | URL,
|
||||||
init?: RequestInit,
|
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[] {
|
function normalizeEventsPayload(payload: unknown): RybbitEvent[] {
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
return payload.filter((event): event is RybbitEvent => typeof event === "object" && event !== null);
|
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([]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ test("analytics dashboard renders filters, Convex metrics, and Rybbit error stat
|
|||||||
assert.doesNotMatch(pageSource, /DashboardPlaceholderPage/);
|
assert.doesNotMatch(pageSource, /DashboardPlaceholderPage/);
|
||||||
assert.match(pageSource, /AnalyticsDashboard/);
|
assert.match(pageSource, /AnalyticsDashboard/);
|
||||||
assert.match(componentSource, /api\.campaignMetrics\.getDashboard/);
|
assert.match(componentSource, /api\.campaignMetrics\.getDashboard/);
|
||||||
|
assert.match(componentSource, /\/api\/internal\/rybbit\/campaign/);
|
||||||
assert.match(componentSource, /Kampagne/);
|
assert.match(componentSource, /Kampagne/);
|
||||||
assert.match(componentSource, /Nische/);
|
assert.match(componentSource, /Nische/);
|
||||||
assert.match(componentSource, /PLZ/);
|
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, /Status/);
|
||||||
assert.match(componentSource, /Zeitraum/);
|
assert.match(componentSource, /Zeitraum/);
|
||||||
assert.match(componentSource, /Rybbit-Daten konnten nicht geladen werden/);
|
assert.match(componentSource, /Rybbit-Daten konnten nicht geladen werden/);
|
||||||
|
assert.match(componentSource, /Audit-Öffnungen/);
|
||||||
|
assert.match(componentSource, /CTA-Klicks/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -192,6 +192,27 @@ test("toLeadFunnelCard maps blocked priority to deferred stage with blocker labe
|
|||||||
assert.equal(card.nextAction, "Zurückstellung prüfen");
|
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", () => {
|
test("dashboard-model exposes stable lead label helpers for UI mapping", () => {
|
||||||
assert.deepEqual(leadPriorityOptions, [
|
assert.deepEqual(leadPriorityOptions, [
|
||||||
"high",
|
"high",
|
||||||
|
|||||||
@@ -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, /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/);
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildRybbitEventsUrl,
|
buildRybbitEventsUrl,
|
||||||
|
summarizeCampaignRybbitEvents,
|
||||||
summarizeAuditRybbitEvents,
|
summarizeAuditRybbitEvents,
|
||||||
type RybbitEvent,
|
type RybbitEvent,
|
||||||
} from "../lib/rybbit-analytics";
|
} from "../lib/rybbit-analytics";
|
||||||
@@ -70,3 +71,19 @@ test("summarizeAuditRybbitEvents returns graceful empty metrics", () => {
|
|||||||
deviceTypes: [],
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user