Add follow-up status tracking slice
This commit is contained in:
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
89
lib/outreach-follow-up.ts
Normal 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" };
|
||||
}
|
||||
34
tests/outreach-follow-up-source.test.ts
Normal file
34
tests/outreach-follow-up-source.test.ts
Normal 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/);
|
||||
});
|
||||
94
tests/outreach-follow-up.test.ts
Normal file
94
tests/outreach-follow-up.test.ts
Normal 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" },
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user