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 id: TASK-15
title: Add follow-up and manual sales status tracking title: Add follow-up and manual sales status tracking
status: To Do 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'
labels: labels:
- mvp - mvp
- sales - 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. 4. Add rules to stop follow-ups when manually marked answered or not interested.
5. Add 12-month recheck behavior for Nicht erneut kontaktieren. 5. Add 12-month recheck behavior for Nicht erneut kontaktieren.
<!-- SECTION:PLAN:END --> <!-- 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 id: TASK-16
title: Orchestrate recurring Convex agent jobs and audit lifecycle title: Orchestrate recurring Convex agent jobs and audit lifecycle
status: To Do 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'
labels: labels:
- mvp - mvp
- convex - 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. 4. Add run logs and dashboard-visible status updates.
5. Add audit lifecycle checks for 30-day notification, 60-day deactivation, and reactivation. 5. Add audit lifecycle checks for 30-day notification, 60-day deactivation, and reactivation.
<!-- SECTION:PLAN:END --> <!-- 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 id: TASK-17
title: Add Rybbit audit analytics dashboard title: Add Rybbit audit analytics dashboard
status: To Do 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'
labels: labels:
- mvp - mvp
- analytics - analytics
@@ -40,3 +41,9 @@ Display anonymous analytics for generated public audit pages inside the internal
4. Build campaign-level analytics summaries. 4. Build campaign-level analytics summaries.
5. Add graceful loading, caching if useful, and error states for API failures. 5. Add graceful loading, caching if useful, and error states for API failures.
<!-- SECTION:PLAN:END --> <!-- 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 id: TASK-18
title: Add MVP quality gates and operational polish title: Add MVP quality gates and operational polish
status: To Do status: In Progress
assignee: [] assignee: []
created_date: '2026-06-03 19:15' created_date: '2026-06-03 19:15'
updated_date: '2026-06-03 19:15' updated_date: '2026-06-05 19:30'
labels: labels:
- mvp - mvp
- quality - 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. 4. Add smoke tests or documented verification flows for critical MVP paths.
5. Document Coolify deployment requirements, env vars, Playwright dependencies, and operational caveats. 5. Document Coolify deployment requirements, env vars, Playwright dependencies, and operational caveats.
<!-- SECTION:PLAN:END --> <!-- 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 id: TASK-19
title: Add campaign performance metrics title: Add campaign performance metrics
status: To Do 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'
labels: labels:
- mvp - mvp
- analytics - 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. 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. 5. Add empty/error states and verify metrics update after lead, audit, send, and status changes.
<!-- SECTION:PLAN:END --> <!-- 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 id: TASK-27
title: Trigger audit generation after PageSpeed audit title: Trigger audit generation after PageSpeed audit
status: To Do status: In Progress
assignee: [] assignee: []
created_date: '2026-06-05 12:10' created_date: '2026-06-05 12:10'
updated_date: '2026-06-05 12:12' updated_date: '2026-06-05 19:30'
labels: [] labels: []
dependencies: [] dependencies: []
priority: high priority: high
@@ -29,4 +29,6 @@ Wire the existing AI audit generation queue into the current automated flow so c
<!-- SECTION:NOTES:BEGIN --> <!-- 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. 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 --> <!-- SECTION:NOTES:END -->

View File

@@ -1,5 +1,10 @@
import { v } from "convex/values"; 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 { normalizeListLimit } from "./domain";
import { internalMutation, mutation, query } from "./_generated/server"; import { internalMutation, mutation, query } from "./_generated/server";
import type { Doc, Id } from "./_generated/dataModel"; import type { Doc, Id } from "./_generated/dataModel";
@@ -11,6 +16,19 @@ const strategy = v.union(
v.literal("defer"), v.literal("defer"),
v.literal("do_not_contact"), 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; const REVIEW_JOIN_LIMIT = 4;
@@ -189,6 +207,9 @@ type OutreachRecordInsertArgs = {
emailSubject?: string; emailSubject?: string;
emailBody?: string; emailBody?: string;
followUpDraft?: string; followUpDraft?: string;
followUpDueAt?: number;
parentOutreachId?: Id<"outreachRecords">;
salesStatus?: "follow_up_planned" | "follow_up_sent";
now: number; now: number;
}; };
@@ -201,10 +222,12 @@ const buildOutreachRecordsInsertPayload = (args: OutreachRecordInsertArgs) => {
emailSubject?: string; emailSubject?: string;
emailBody?: string; emailBody?: string;
followUpDraft?: string; followUpDraft?: string;
followUpDueAt?: number;
parentOutreachId?: Id<"outreachRecords">;
approvalStatus: "draft"; approvalStatus: "draft";
sendStatus: "not_sent"; sendStatus: "not_sent";
responseStatus: "none"; responseStatus: "none";
salesStatus: "follow_up_planned"; salesStatus: "follow_up_planned" | "follow_up_sent";
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} = { } = {
@@ -213,7 +236,7 @@ const buildOutreachRecordsInsertPayload = (args: OutreachRecordInsertArgs) => {
approvalStatus: "draft", approvalStatus: "draft",
sendStatus: "not_sent", sendStatus: "not_sent",
responseStatus: "none", responseStatus: "none",
salesStatus: "follow_up_planned", salesStatus: args.salesStatus ?? "follow_up_planned",
createdAt: args.now, createdAt: args.now,
updatedAt: args.now, updatedAt: args.now,
}; };
@@ -233,10 +256,55 @@ const buildOutreachRecordsInsertPayload = (args: OutreachRecordInsertArgs) => {
if (args.followUpDraft !== undefined) { if (args.followUpDraft !== undefined) {
payload.followUpDraft = args.followUpDraft; payload.followUpDraft = args.followUpDraft;
} }
if (args.followUpDueAt !== undefined) {
payload.followUpDueAt = args.followUpDueAt;
}
if (args.parentOutreachId !== undefined) {
payload.parentOutreachId = args.parentOutreachId;
}
return payload; 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({ export const create = mutation({
args: { args: {
leadId: v.id("leads"), leadId: v.id("leads"),
@@ -671,6 +739,7 @@ export const recordEmailSendSuccess = internalMutation({
await ctx.db.patch(args.id, { await ctx.db.patch(args.id, {
sendStatus: "sent", sendStatus: "sent",
sentAt: args.sentAt, sentAt: args.sentAt,
...(outreach.parentOutreachId ? { salesStatus: "follow_up_sent" as const } : {}),
updatedAt: now, updatedAt: now,
}); });
@@ -729,6 +798,64 @@ export const recordEmailSendSuccess = internalMutation({
} }
await ctx.db.insert("outreachSendAttempts", attempt); 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()), emailSubject: v.optional(v.string()),
emailBody: v.optional(v.string()), emailBody: v.optional(v.string()),
followUpDraft: v.optional(v.string()), followUpDraft: v.optional(v.string()),
followUpDueAt: v.optional(v.number()),
parentOutreachId: v.optional(v.id("outreachRecords")),
approvalStatus: outreachApprovalStatus, approvalStatus: outreachApprovalStatus,
sendStatus: outreachSendStatus, sendStatus: outreachSendStatus,
sentAt: v.optional(v.number()), sentAt: v.optional(v.number()),
responseStatus: outreachResponseStatus, responseStatus: outreachResponseStatus,
salesStatus: outreachSalesStatus, salesStatus: outreachSalesStatus,
doNotContactUntil: v.optional(v.number()),
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
}) })
@@ -502,7 +505,8 @@ export default defineSchema({
"updatedAt", "updatedAt",
]) ])
.index("by_sendStatus", ["sendStatus"]) .index("by_sendStatus", ["sendStatus"])
.index("by_sendStatus_and_updatedAt", ["sendStatus", "updatedAt"]), .index("by_sendStatus_and_updatedAt", ["sendStatus", "updatedAt"])
.index("by_parentOutreachId", ["parentOutreachId"]),
outreachSendAttempts: defineTable({ outreachSendAttempts: defineTable({
outreachId: v.id("outreachRecords"), 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" },
);
});