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 8d31eab..d7b9d9a 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 @@ -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. + +## Implementation Notes + + +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. + 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 e283392..340b591 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 @@ -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. + +## Implementation Notes + + +Started implementation pass for recurring Convex agent jobs, run locking, logs, and audit lifecycle. + 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 81925c1..54d2ab2 100644 --- a/backlog/tasks/task-17 - Add-Rybbit-audit-analytics-dashboard.md +++ b/backlog/tasks/task-17 - Add-Rybbit-audit-analytics-dashboard.md @@ -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. + +## Implementation Notes + + +Started implementation pass for Rybbit public-audit tracking and dashboard analytics surfaces. + 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 65c55ca..8e4fffe 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 @@ -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. + +## Implementation Notes + + +Started implementation pass for MVP quality gates, error observability, verification notes, and deployment readiness. + diff --git a/backlog/tasks/task-19 - Add-campaign-performance-metrics.md b/backlog/tasks/task-19 - Add-campaign-performance-metrics.md index b738fda..d1c84a9 100644 --- a/backlog/tasks/task-19 - Add-campaign-performance-metrics.md +++ b/backlog/tasks/task-19 - Add-campaign-performance-metrics.md @@ -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. + +## Implementation Notes + + +Started implementation pass for campaign performance metrics and filters. + 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 cc97176..ce38fde 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 @@ -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 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. diff --git a/convex/outreach.ts b/convex/outreach.ts index 7f4fdd5..f21a190 100644 --- a/convex/outreach.ts +++ b/convex/outreach.ts @@ -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, + }; }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 18c5d92..3edcf9c 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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"), diff --git a/lib/outreach-follow-up.ts b/lib/outreach-follow-up.ts new file mode 100644 index 0000000..bee4b33 --- /dev/null +++ b/lib/outreach-follow-up.ts @@ -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 = { + 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([ + "reply_received", + "not_interested", + "do_not_pursue", + "follow_up_sent", +]); + +const suppressingResponseStatuses = new Set([ + "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" }; +} diff --git a/tests/outreach-follow-up-source.test.ts b/tests/outreach-follow-up-source.test.ts new file mode 100644 index 0000000..3145044 --- /dev/null +++ b/tests/outreach-follow-up-source.test.ts @@ -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/); +}); diff --git a/tests/outreach-follow-up.test.ts b/tests/outreach-follow-up.test.ts new file mode 100644 index 0000000..2bcb969 --- /dev/null +++ b/tests/outreach-follow-up.test.ts @@ -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" }, + ); +});