Add follow-up status tracking slice
This commit is contained in:
@@ -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 -->
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
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