feat: add lead qualification workflow
This commit is contained in:
244
convex/leads.ts
244
convex/leads.ts
@@ -1,8 +1,93 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { getUsableContactEmailFromEntries } from "../lib/lead-discovery-google";
|
||||
import { normalizeListLimit } from "./domain";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
||||
type LeadDoc = Doc<"leads">;
|
||||
|
||||
type LeadReviewContactPatch = {
|
||||
email: string;
|
||||
normalizedEmail: string;
|
||||
emailSource?: string;
|
||||
contactPerson?: string;
|
||||
};
|
||||
|
||||
type BuildReviewContactPatchResult = {
|
||||
patch?: LeadReviewContactPatch;
|
||||
setContactStatus?: LeadDoc["contactStatus"];
|
||||
};
|
||||
|
||||
type LeadReviewPatch = {
|
||||
updatedAt: number;
|
||||
priority?: LeadDoc["priority"];
|
||||
priorityReason?: string;
|
||||
contactStatus?: LeadDoc["contactStatus"];
|
||||
contactStatusReason?: string;
|
||||
notes?: string;
|
||||
duplicateStatus?: LeadDoc["duplicateStatus"];
|
||||
duplicateReason?: string;
|
||||
duplicateOfLeadId?: Id<"leads">;
|
||||
blacklistStatus?: LeadDoc["blacklistStatus"];
|
||||
blacklistReason?: string;
|
||||
email?: string;
|
||||
normalizedEmail?: string;
|
||||
emailSource?: string;
|
||||
contactPerson?: string;
|
||||
};
|
||||
|
||||
function buildReviewContactPatch(args: {
|
||||
email?: string;
|
||||
emailSource?: string;
|
||||
contactPerson?: string;
|
||||
isBusinessContactAddress?: boolean;
|
||||
explicitContactStatus?: boolean;
|
||||
currentContactStatus?: "new" | "missing_contact" | "audit_ready" | "outreach_ready" | "contacted" | "replied" | "do_not_contact";
|
||||
}): BuildReviewContactPatchResult | null {
|
||||
if (args.email === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usable = getUsableContactEmailFromEntries([
|
||||
{
|
||||
email: args.email,
|
||||
emailSource: args.emailSource,
|
||||
contactPerson: args.contactPerson,
|
||||
isBusinessContactAddress: args.isBusinessContactAddress,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!usable) {
|
||||
return {
|
||||
setContactStatus: "missing_contact",
|
||||
};
|
||||
}
|
||||
|
||||
const patch: LeadReviewContactPatch = {
|
||||
email: usable.email,
|
||||
normalizedEmail: usable.email,
|
||||
};
|
||||
|
||||
if (usable.emailSource !== null) {
|
||||
patch.emailSource = usable.emailSource;
|
||||
}
|
||||
|
||||
if (usable.contactPerson !== null) {
|
||||
patch.contactPerson = usable.contactPerson;
|
||||
}
|
||||
|
||||
const setContactStatus =
|
||||
!args.explicitContactStatus && args.currentContactStatus === "missing_contact"
|
||||
? "new"
|
||||
: undefined;
|
||||
|
||||
return ({
|
||||
patch,
|
||||
setContactStatus,
|
||||
});
|
||||
}
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
campaignId: v.optional(v.id("campaigns")),
|
||||
@@ -24,6 +109,10 @@ export const create = mutation({
|
||||
websiteUrl: v.optional(v.string()),
|
||||
websiteDomain: v.optional(v.string()),
|
||||
phone: v.optional(v.string()),
|
||||
normalizedEmail: v.optional(v.string()),
|
||||
normalizedPhone: v.optional(v.string()),
|
||||
normalizedCompanyName: v.optional(v.string()),
|
||||
normalizedAddress: v.optional(v.string()),
|
||||
email: v.optional(v.string()),
|
||||
emailSource: v.optional(v.string()),
|
||||
contactPerson: v.optional(v.string()),
|
||||
@@ -33,8 +122,10 @@ export const create = mutation({
|
||||
v.literal("medium"),
|
||||
v.literal("low"),
|
||||
v.literal("defer"),
|
||||
v.literal("blocked"),
|
||||
),
|
||||
),
|
||||
priorityReason: v.optional(v.string()),
|
||||
contactStatus: v.optional(
|
||||
v.union(
|
||||
v.literal("new"),
|
||||
@@ -46,6 +137,20 @@ export const create = mutation({
|
||||
v.literal("do_not_contact"),
|
||||
),
|
||||
),
|
||||
contactStatusReason: v.optional(v.string()),
|
||||
duplicateStatus: v.optional(
|
||||
v.union(
|
||||
v.literal("unchecked"),
|
||||
v.literal("unique"),
|
||||
v.literal("possible_duplicate"),
|
||||
v.literal("duplicate"),
|
||||
),
|
||||
),
|
||||
duplicateReason: v.optional(v.string()),
|
||||
blacklistReason: v.optional(v.string()),
|
||||
duplicateOfLeadId: v.optional(v.id("leads")),
|
||||
blacklistStatus: v.optional(v.union(v.literal("clear"), v.literal("blocked"))),
|
||||
normalizedGooglePlaceId: v.optional(v.string()),
|
||||
notes: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -53,16 +158,151 @@ export const create = mutation({
|
||||
|
||||
return await ctx.db.insert("leads", {
|
||||
...args,
|
||||
normalizedEmail: args.normalizedEmail,
|
||||
normalizedPhone: args.normalizedPhone,
|
||||
normalizedCompanyName: args.normalizedCompanyName,
|
||||
normalizedAddress: args.normalizedAddress,
|
||||
normalizedGooglePlaceId: args.normalizedGooglePlaceId,
|
||||
priority: args.priority ?? "medium",
|
||||
contactStatus: args.contactStatus ?? "new",
|
||||
duplicateStatus: "unchecked",
|
||||
blacklistStatus: "clear",
|
||||
duplicateStatus: args.duplicateStatus ?? "unchecked",
|
||||
blacklistStatus: args.blacklistStatus ?? "clear",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const reviewUpdate = mutation({
|
||||
args: {
|
||||
id: v.id("leads"),
|
||||
priority: v.optional(
|
||||
v.union(
|
||||
v.literal("high"),
|
||||
v.literal("medium"),
|
||||
v.literal("low"),
|
||||
v.literal("defer"),
|
||||
v.literal("blocked"),
|
||||
),
|
||||
),
|
||||
priorityReason: v.optional(v.string()),
|
||||
contactStatus: v.optional(
|
||||
v.union(
|
||||
v.literal("new"),
|
||||
v.literal("missing_contact"),
|
||||
v.literal("audit_ready"),
|
||||
v.literal("outreach_ready"),
|
||||
v.literal("contacted"),
|
||||
v.literal("replied"),
|
||||
v.literal("do_not_contact"),
|
||||
),
|
||||
),
|
||||
contactStatusReason: v.optional(v.string()),
|
||||
notes: v.optional(v.string()),
|
||||
duplicateStatus: v.optional(
|
||||
v.union(
|
||||
v.literal("unchecked"),
|
||||
v.literal("unique"),
|
||||
v.literal("possible_duplicate"),
|
||||
v.literal("duplicate"),
|
||||
),
|
||||
),
|
||||
duplicateReason: v.optional(v.string()),
|
||||
blacklistStatus: v.optional(v.union(v.literal("clear"), v.literal("blocked"))),
|
||||
blacklistReason: v.optional(v.string()),
|
||||
duplicateOfLeadId: v.optional(v.id("leads")),
|
||||
applyBlacklist: v.optional(v.boolean()),
|
||||
reviewEmail: v.optional(v.string()),
|
||||
reviewEmailSource: v.optional(v.string()),
|
||||
reviewContactPerson: v.optional(v.string()),
|
||||
reviewIsBusinessContactAddress: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const lead = await ctx.db.get(args.id);
|
||||
|
||||
if (!lead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const patch: LeadReviewPatch = {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (args.priority !== undefined) {
|
||||
patch.priority = args.priority;
|
||||
}
|
||||
if (args.priorityReason !== undefined) {
|
||||
patch.priorityReason = args.priorityReason;
|
||||
}
|
||||
if (args.contactStatus !== undefined) {
|
||||
patch.contactStatus = args.contactStatus;
|
||||
}
|
||||
if (args.contactStatusReason !== undefined) {
|
||||
patch.contactStatusReason = args.contactStatusReason;
|
||||
}
|
||||
if (args.notes !== undefined) {
|
||||
patch.notes = args.notes;
|
||||
}
|
||||
if (args.duplicateStatus !== undefined) {
|
||||
patch.duplicateStatus = args.duplicateStatus;
|
||||
}
|
||||
if (args.duplicateReason !== undefined) {
|
||||
patch.duplicateReason = args.duplicateReason;
|
||||
}
|
||||
if (args.duplicateOfLeadId !== undefined) {
|
||||
patch.duplicateOfLeadId = args.duplicateOfLeadId;
|
||||
}
|
||||
|
||||
if (args.applyBlacklist) {
|
||||
patch.blacklistStatus = "blocked";
|
||||
if (args.blacklistReason !== undefined) {
|
||||
patch.blacklistReason = args.blacklistReason;
|
||||
} else if (lead.blacklistReason === undefined) {
|
||||
patch.blacklistReason = "Manuell in der Review als Sperrgrund gesetzt.";
|
||||
}
|
||||
if (args.priority === undefined || args.priority !== "blocked") {
|
||||
patch.priority = "blocked";
|
||||
}
|
||||
} else if (args.applyBlacklist === false && args.blacklistStatus !== undefined) {
|
||||
patch.blacklistStatus = args.blacklistStatus;
|
||||
patch.blacklistReason = args.blacklistReason;
|
||||
} else if (args.blacklistStatus !== undefined) {
|
||||
patch.blacklistStatus = args.blacklistStatus;
|
||||
patch.blacklistReason = args.blacklistReason;
|
||||
}
|
||||
|
||||
const reviewContactPatch = buildReviewContactPatch({
|
||||
email: args.reviewEmail,
|
||||
emailSource: args.reviewEmailSource,
|
||||
contactPerson: args.reviewContactPerson,
|
||||
isBusinessContactAddress: args.reviewIsBusinessContactAddress,
|
||||
explicitContactStatus: args.contactStatus !== undefined,
|
||||
currentContactStatus: lead.contactStatus,
|
||||
});
|
||||
|
||||
if (reviewContactPatch?.patch) {
|
||||
Object.assign(patch, reviewContactPatch.patch);
|
||||
}
|
||||
|
||||
if (
|
||||
reviewContactPatch !== null &&
|
||||
reviewContactPatch.setContactStatus !== undefined &&
|
||||
args.contactStatus === undefined
|
||||
) {
|
||||
patch.contactStatus = reviewContactPatch.setContactStatus;
|
||||
}
|
||||
|
||||
if (args.blacklistReason !== undefined && patch.blacklistStatus === undefined) {
|
||||
patch.blacklistStatus = "blocked";
|
||||
patch.blacklistReason = args.blacklistReason;
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.id, patch);
|
||||
return args.id;
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { id: v.id("leads") },
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
Reference in New Issue
Block a user