Files
webdev-pipeline/convex/leads.ts

402 lines
12 KiB
TypeScript

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")),
discoveryRunId: v.optional(v.id("agentRuns")),
companyName: v.string(),
niche: v.optional(v.string()),
address: v.optional(v.string()),
city: v.optional(v.string()),
postalCode: v.optional(v.string()),
googlePlaceId: v.optional(v.string()),
googleMapsUrl: v.optional(v.string()),
googlePrimaryType: v.optional(v.string()),
googleTypes: v.optional(v.array(v.string())),
googleRating: v.optional(v.number()),
googleUserRatingCount: v.optional(v.number()),
googleBusinessStatus: v.optional(v.string()),
sourceProvider: v.optional(v.literal("google_places")),
sourceFetchedAt: v.optional(v.number()),
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()),
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()),
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) => {
const now = Date.now();
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: 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) => {
return await ctx.db.get(args.id);
},
});
export const list = query({
args: {
campaignId: v.optional(v.id("campaigns")),
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"),
),
),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = normalizeListLimit(args.limit);
if (args.campaignId) {
const campaignId = args.campaignId;
return await ctx.db
.query("leads")
.withIndex("by_campaignId", (q) => q.eq("campaignId", campaignId))
.order("desc")
.take(limit);
}
if (args.contactStatus) {
const contactStatus = args.contactStatus;
return await ctx.db
.query("leads")
.withIndex("by_contactStatus", (q) =>
q.eq("contactStatus", contactStatus),
)
.order("desc")
.take(limit);
}
return await ctx.db.query("leads").order("desc").take(limit);
},
});
export const listFunnel = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = normalizeListLimit(args.limit);
const leads = await ctx.db.query("leads").order("desc").take(limit);
return await Promise.all(
leads.map(async (lead) => {
const outreach = await ctx.db
.query("outreachRecords")
.withIndex("by_leadId", (q) => q.eq("leadId", lead._id))
.order("desc")
.take(1);
const latestOutreach = outreach[0] ?? null;
return {
id: lead._id,
companyName: lead.companyName,
niche: lead.niche ?? null,
address: lead.address ?? null,
city: lead.city ?? null,
postalCode: lead.postalCode ?? null,
priority: lead.priority,
contactStatus: lead.contactStatus,
blacklistStatus: lead.blacklistStatus,
email: lead.email ?? null,
phone: lead.phone ?? null,
contactPerson: lead.contactPerson ?? null,
websiteDomain: lead.websiteDomain ?? null,
outreach: latestOutreach
? {
approvalStatus: latestOutreach.approvalStatus,
sendStatus: latestOutreach.sendStatus,
responseStatus: latestOutreach.responseStatus,
salesStatus: latestOutreach.salesStatus,
}
: null,
};
}),
);
},
});