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,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,
};
},
});