Add follow-up status tracking slice

This commit is contained in:
2026-06-05 21:35:55 +02:00
parent 807532a0a4
commit 3f148bcec2
11 changed files with 395 additions and 11 deletions

View File

@@ -1,9 +1,10 @@
---
id: TASK-15
title: Add follow-up and manual sales status tracking
status: To Do
status: In Progress
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:30'
labels:
- mvp
- sales
@@ -40,3 +41,9 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
4. Add rules to stop follow-ups when manually marked answered or not interested.
5. Add 12-month recheck behavior for Nicht erneut kontaktieren.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- 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.
<!-- SECTION:NOTES:END -->

View File

@@ -1,9 +1,10 @@
---
id: TASK-16
title: Orchestrate recurring Convex agent jobs and audit lifecycle
status: To Do
status: In Progress
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:30'
labels:
- mvp
- convex
@@ -42,3 +43,9 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
4. Add run logs and dashboard-visible status updates.
5. Add audit lifecycle checks for 30-day notification, 60-day deactivation, and reactivation.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for recurring Convex agent jobs, run locking, logs, and audit lifecycle.
<!-- SECTION:NOTES:END -->

View File

@@ -1,9 +1,10 @@
---
id: TASK-17
title: Add Rybbit audit analytics dashboard
status: To Do
status: In Progress
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:30'
labels:
- mvp
- analytics
@@ -40,3 +41,9 @@ Display anonymous analytics for generated public audit pages inside the internal
4. Build campaign-level analytics summaries.
5. Add graceful loading, caching if useful, and error states for API failures.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for Rybbit public-audit tracking and dashboard analytics surfaces.
<!-- SECTION:NOTES:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-18
title: Add MVP quality gates and operational polish
status: To Do
status: In Progress
assignee: []
created_date: '2026-06-03 19:15'
updated_date: '2026-06-03 19:15'
updated_date: '2026-06-05 19:30'
labels:
- mvp
- quality
@@ -43,3 +43,9 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
4. Add smoke tests or documented verification flows for critical MVP paths.
5. Document Coolify deployment requirements, env vars, Playwright dependencies, and operational caveats.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for MVP quality gates, error observability, verification notes, and deployment readiness.
<!-- SECTION:NOTES:END -->

View File

@@ -1,9 +1,10 @@
---
id: TASK-19
title: Add campaign performance metrics
status: To Do
status: In Progress
assignee: []
created_date: '2026-06-03 19:15'
updated_date: '2026-06-05 19:30'
labels:
- mvp
- analytics
@@ -42,3 +43,9 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
4. Merge Rybbit API-derived audit activity into the visible analytics where available.
5. Add empty/error states and verify metrics update after lead, audit, send, and status changes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for campaign performance metrics and filters.
<!-- SECTION:NOTES:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-27
title: Trigger audit generation after PageSpeed audit
status: To Do
status: In Progress
assignee: []
created_date: '2026-06-05 12:10'
updated_date: '2026-06-05 12:12'
updated_date: '2026-06-05 19:30'
labels: []
dependencies: []
priority: high
@@ -29,4 +29,6 @@ Wire the existing AI audit generation queue into the current automated flow so c
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->

View File

@@ -1,5 +1,10 @@
import { v } from "convex/values";
import {
DO_NOT_CONTACT_RECHECK_MS,
FOLLOW_UP_DUE_DELAY_MS,
shouldCreateFollowUpDraftAfterSend,
} from "../lib/outreach-follow-up";
import { normalizeListLimit } from "./domain";
import { internalMutation, mutation, query } from "./_generated/server";
import type { Doc, Id } from "./_generated/dataModel";
@@ -11,6 +16,19 @@ const strategy = v.union(
v.literal("defer"),
v.literal("do_not_contact"),
);
const manualSalesStatus = v.union(
v.literal("follow_up_planned"),
v.literal("follow_up_sent"),
v.literal("reply_received"),
v.literal("not_interested"),
v.literal("later"),
v.literal("meeting_scheduled"),
v.literal("proposal_requested"),
v.literal("proposal_sent"),
v.literal("won"),
v.literal("lost"),
v.literal("do_not_pursue"),
);
const REVIEW_JOIN_LIMIT = 4;
@@ -189,6 +207,9 @@ type OutreachRecordInsertArgs = {
emailSubject?: string;
emailBody?: string;
followUpDraft?: string;
followUpDueAt?: number;
parentOutreachId?: Id<"outreachRecords">;
salesStatus?: "follow_up_planned" | "follow_up_sent";
now: number;
};
@@ -201,10 +222,12 @@ const buildOutreachRecordsInsertPayload = (args: OutreachRecordInsertArgs) => {
emailSubject?: string;
emailBody?: string;
followUpDraft?: string;
followUpDueAt?: number;
parentOutreachId?: Id<"outreachRecords">;
approvalStatus: "draft";
sendStatus: "not_sent";
responseStatus: "none";
salesStatus: "follow_up_planned";
salesStatus: "follow_up_planned" | "follow_up_sent";
createdAt: number;
updatedAt: number;
} = {
@@ -213,7 +236,7 @@ const buildOutreachRecordsInsertPayload = (args: OutreachRecordInsertArgs) => {
approvalStatus: "draft",
sendStatus: "not_sent",
responseStatus: "none",
salesStatus: "follow_up_planned",
salesStatus: args.salesStatus ?? "follow_up_planned",
createdAt: args.now,
updatedAt: args.now,
};
@@ -233,10 +256,55 @@ const buildOutreachRecordsInsertPayload = (args: OutreachRecordInsertArgs) => {
if (args.followUpDraft !== undefined) {
payload.followUpDraft = args.followUpDraft;
}
if (args.followUpDueAt !== undefined) {
payload.followUpDueAt = args.followUpDueAt;
}
if (args.parentOutreachId !== undefined) {
payload.parentOutreachId = args.parentOutreachId;
}
return payload;
};
async function createFollowUpDraftAfterInitialSend(
ctx: MutationCtx,
outreach: Doc<"outreachRecords">,
sentAt: number,
) {
const existingFollowUps = await ctx.db
.query("outreachRecords")
.withIndex("by_parentOutreachId", (q) => q.eq("parentOutreachId", outreach._id))
.take(1);
if (
!shouldCreateFollowUpDraftAfterSend({
existingFollowUpOutreachCount: existingFollowUps.length,
followUpDraft: outreach.followUpDraft,
salesStatus: outreach.salesStatus,
sendStatus: "sent",
})
) {
return null;
}
return await ctx.db.insert(
"outreachRecords",
buildOutreachRecordsInsertPayload({
leadId: outreach.leadId,
auditId: outreach.auditId,
strategy: "email_first",
emailSubject: outreach.emailSubject
? `Kurze Nachfrage: ${outreach.emailSubject}`
: "Kurze Nachfrage zum Website-Audit",
emailBody: outreach.followUpDraft,
followUpDraft: outreach.followUpDraft,
followUpDueAt: sentAt + FOLLOW_UP_DUE_DELAY_MS,
parentOutreachId: outreach._id,
now: Date.now(),
}),
);
}
export const create = mutation({
args: {
leadId: v.id("leads"),
@@ -671,6 +739,7 @@ export const recordEmailSendSuccess = internalMutation({
await ctx.db.patch(args.id, {
sendStatus: "sent",
sentAt: args.sentAt,
...(outreach.parentOutreachId ? { salesStatus: "follow_up_sent" as const } : {}),
updatedAt: now,
});
@@ -729,6 +798,64 @@ export const recordEmailSendSuccess = internalMutation({
}
await ctx.db.insert("outreachSendAttempts", attempt);
await createFollowUpDraftAfterInitialSend(ctx, outreach, args.sentAt);
},
});
export const updateManualSalesStatus = mutation({
args: {
id: v.id("outreachRecords"),
salesStatus: manualSalesStatus,
},
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.");
}
const now = Date.now();
const outreachPatch: {
salesStatus: typeof args.salesStatus;
responseStatus?: "none" | "manual_reply_recorded" | "no_interest" | "follow_up_needed";
doNotContactUntil?: number;
updatedAt: number;
} = {
salesStatus: args.salesStatus,
updatedAt: now,
};
const leadPatch: {
contactStatus?: "contacted" | "replied" | "do_not_contact";
updatedAt: number;
} = {
updatedAt: now,
};
if (args.salesStatus === "reply_received") {
outreachPatch.responseStatus = "manual_reply_recorded";
leadPatch.contactStatus = "replied";
}
if (args.salesStatus === "not_interested") {
outreachPatch.responseStatus = "no_interest";
leadPatch.contactStatus = "contacted";
}
if (args.salesStatus === "do_not_pursue") {
outreachPatch.responseStatus = "no_interest";
outreachPatch.doNotContactUntil = now + DO_NOT_CONTACT_RECHECK_MS;
leadPatch.contactStatus = "do_not_contact";
}
await ctx.db.patch(args.id, outreachPatch);
await ctx.db.patch(outreach.leadId, leadPatch);
return {
id: args.id,
salesStatus: args.salesStatus,
doNotContactUntil: outreachPatch.doNotContactUntil ?? null,
};
},
});

View File

@@ -484,11 +484,14 @@ export default defineSchema({
emailSubject: v.optional(v.string()),
emailBody: v.optional(v.string()),
followUpDraft: v.optional(v.string()),
followUpDueAt: v.optional(v.number()),
parentOutreachId: v.optional(v.id("outreachRecords")),
approvalStatus: outreachApprovalStatus,
sendStatus: outreachSendStatus,
sentAt: v.optional(v.number()),
responseStatus: outreachResponseStatus,
salesStatus: outreachSalesStatus,
doNotContactUntil: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
@@ -502,7 +505,8 @@ export default defineSchema({
"updatedAt",
])
.index("by_sendStatus", ["sendStatus"])
.index("by_sendStatus_and_updatedAt", ["sendStatus", "updatedAt"]),
.index("by_sendStatus_and_updatedAt", ["sendStatus", "updatedAt"])
.index("by_parentOutreachId", ["parentOutreachId"]),
outreachSendAttempts: defineTable({
outreachId: v.id("outreachRecords"),

89
lib/outreach-follow-up.ts Normal file
View File

@@ -0,0 +1,89 @@
import type {
OutreachResponseStatus,
OutreachSalesStatus,
OutreachSendStatus,
} from "./dashboard-model";
export const FOLLOW_UP_DUE_DELAY_MS = 7 * 24 * 60 * 60 * 1000;
export const DO_NOT_CONTACT_RECHECK_MS = 365 * 24 * 60 * 60 * 1000;
export type FollowUpPromptState = "not_ready" | "pending" | "due" | "suppressed";
export const manualSalesStatusLabels: Record<OutreachSalesStatus, string> = {
follow_up_planned: "Follow-up geplant",
follow_up_sent: "Follow-up gesendet",
reply_received: "Antwort erhalten",
not_interested: "Kein Interesse",
later: "Später wieder melden",
meeting_scheduled: "Gespräch vereinbart",
proposal_requested: "Angebot angefragt",
proposal_sent: "Angebot gesendet",
won: "Auftrag gewonnen",
lost: "Auftrag verloren",
do_not_pursue: "Nicht weiter verfolgen",
};
const suppressingSalesStatuses = new Set<OutreachSalesStatus>([
"reply_received",
"not_interested",
"do_not_pursue",
"follow_up_sent",
]);
const suppressingResponseStatuses = new Set<OutreachResponseStatus>([
"manual_reply_recorded",
"no_interest",
]);
export function getManualSalesStatusLabel(status: OutreachSalesStatus) {
return manualSalesStatusLabels[status];
}
export function shouldCreateFollowUpDraftAfterSend(input: {
existingFollowUpOutreachCount: number;
followUpDraft?: string | null;
salesStatus: OutreachSalesStatus;
sendStatus: OutreachSendStatus;
}) {
return (
input.sendStatus === "sent" &&
input.salesStatus === "follow_up_planned" &&
input.existingFollowUpOutreachCount === 0 &&
Boolean(input.followUpDraft?.trim())
);
}
export function getFollowUpPromptState(input: {
followUpDueAt?: number | null;
responseStatus: OutreachResponseStatus;
salesStatus: OutreachSalesStatus;
now: number;
}): FollowUpPromptState {
if (
suppressingSalesStatuses.has(input.salesStatus) ||
suppressingResponseStatuses.has(input.responseStatus)
) {
return "suppressed";
}
if (typeof input.followUpDueAt !== "number") {
return "not_ready";
}
return input.now >= input.followUpDueAt ? "due" : "pending";
}
export function getDoNotContactRecheckState(input: {
doNotContactUntil?: number | null;
now: number;
}) {
if (typeof input.doNotContactUntil !== "number") {
return { status: "none" as const, label: "Offen" };
}
if (input.now >= input.doNotContactUntil) {
return { status: "recheck" as const, label: "Erneut prüfen" };
}
return { status: "blocked" as const, label: "Nicht erneut kontaktieren" };
}

View File

@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
const outreachSource = readFileSync(
join(process.cwd(), "convex", "outreach.ts"),
"utf8",
);
const schemaSource = readFileSync(
join(process.cwd(), "convex", "schema.ts"),
"utf8",
);
test("outreach schema stores follow-up due and do-not-contact recheck dates as optional migration-safe fields", () => {
assert.match(schemaSource, /followUpDueAt:\s*v\.optional\(v\.number\(\)\)/);
assert.match(schemaSource, /parentOutreachId:\s*v\.optional\(v\.id\("outreachRecords"\)\)/);
assert.match(schemaSource, /doNotContactUntil:\s*v\.optional\(v\.number\(\)\)/);
});
test("successful initial sends create one follow-up draft for manual approval", () => {
assert.match(outreachSource, /createFollowUpDraftAfterInitialSend/);
assert.match(outreachSource, /followUpDueAt:\s*sentAt\s*\+\s*FOLLOW_UP_DUE_DELAY_MS/);
assert.match(outreachSource, /approvalStatus:\s*"draft"[\s\S]*sendStatus:\s*"not_sent"/);
assert.match(outreachSource, /parentOutreachId:\s*outreach\._id/);
});
test("manual sales status mutation updates lead suppression states", () => {
assert.match(outreachSource, /export const updateManualSalesStatus = mutation/);
assert.match(outreachSource, /salesStatus:\s*manualSalesStatus/);
assert.match(outreachSource, /reply_received[\s\S]*leadPatch\.contactStatus\s*=\s*"replied"/);
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/);
});

View File

@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DO_NOT_CONTACT_RECHECK_MS,
FOLLOW_UP_DUE_DELAY_MS,
getManualSalesStatusLabel,
getFollowUpPromptState,
getDoNotContactRecheckState,
shouldCreateFollowUpDraftAfterSend,
} from "../lib/outreach-follow-up";
test("manual sales statuses expose the German MVP labels", () => {
assert.equal(getManualSalesStatusLabel("reply_received"), "Antwort erhalten");
assert.equal(getManualSalesStatusLabel("not_interested"), "Kein Interesse");
assert.equal(getManualSalesStatusLabel("later"), "Später wieder melden");
assert.equal(getManualSalesStatusLabel("meeting_scheduled"), "Gespräch vereinbart");
assert.equal(getManualSalesStatusLabel("proposal_requested"), "Angebot angefragt");
assert.equal(getManualSalesStatusLabel("proposal_sent"), "Angebot gesendet");
assert.equal(getManualSalesStatusLabel("won"), "Auftrag gewonnen");
assert.equal(getManualSalesStatusLabel("lost"), "Auftrag verloren");
assert.equal(getManualSalesStatusLabel("do_not_pursue"), "Nicht weiter verfolgen");
assert.equal(getManualSalesStatusLabel("follow_up_planned"), "Follow-up geplant");
assert.equal(getManualSalesStatusLabel("follow_up_sent"), "Follow-up gesendet");
});
test("initial send creates exactly one pending follow-up window", () => {
const sentAt = Date.UTC(2026, 5, 5);
assert.equal(
shouldCreateFollowUpDraftAfterSend({
existingFollowUpOutreachCount: 0,
followUpDraft: "Kurze Nachfrage",
salesStatus: "follow_up_planned",
sendStatus: "sent",
}),
true,
);
assert.equal(
getFollowUpPromptState({
followUpDueAt: sentAt + FOLLOW_UP_DUE_DELAY_MS,
responseStatus: "none",
salesStatus: "follow_up_planned",
now: sentAt + FOLLOW_UP_DUE_DELAY_MS,
}),
"due",
);
});
test("answers and no-interest statuses suppress pending follow-up prompts", () => {
const dueAt = Date.UTC(2026, 5, 12);
for (const salesStatus of ["reply_received", "not_interested"] as const) {
assert.equal(
getFollowUpPromptState({
followUpDueAt: dueAt,
responseStatus: "none",
salesStatus,
now: dueAt + 1,
}),
"suppressed",
);
}
assert.equal(
getFollowUpPromptState({
followUpDueAt: dueAt,
responseStatus: "manual_reply_recorded",
salesStatus: "follow_up_planned",
now: dueAt + 1,
}),
"suppressed",
);
});
test("do-not-contact blocks outreach for twelve months before recheck", () => {
const markedAt = Date.UTC(2026, 0, 1);
const recheckAt = markedAt + DO_NOT_CONTACT_RECHECK_MS;
assert.deepEqual(
getDoNotContactRecheckState({
doNotContactUntil: recheckAt,
now: recheckAt - 1,
}),
{ status: "blocked", label: "Nicht erneut kontaktieren" },
);
assert.deepEqual(
getDoNotContactRecheckState({
doNotContactUntil: recheckAt,
now: recheckAt,
}),
{ status: "recheck", label: "Erneut prüfen" },
);
});