428 lines
12 KiB
TypeScript
428 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 {
|
|
internalMutation,
|
|
internalQuery,
|
|
mutation,
|
|
query,
|
|
} from "./_generated/server";
|
|
import type { MutationCtx, QueryCtx } 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;
|
|
};
|
|
|
|
type LeadReviewUpdateArgs = {
|
|
id: Id<"leads">;
|
|
priority?: LeadDoc["priority"];
|
|
priorityReason?: string;
|
|
contactStatus?: LeadDoc["contactStatus"];
|
|
contactStatusReason?: string;
|
|
notes?: string;
|
|
duplicateStatus?: LeadDoc["duplicateStatus"];
|
|
duplicateReason?: string;
|
|
blacklistStatus?: LeadDoc["blacklistStatus"];
|
|
blacklistReason?: string;
|
|
duplicateOfLeadId?: Id<"leads">;
|
|
applyBlacklist?: boolean;
|
|
reviewEmail?: string;
|
|
reviewEmailSource?: string;
|
|
reviewContactPerson?: string;
|
|
reviewIsBusinessContactAddress?: boolean;
|
|
};
|
|
|
|
const leadPriority = v.union(
|
|
v.literal("high"),
|
|
v.literal("medium"),
|
|
v.literal("low"),
|
|
v.literal("defer"),
|
|
v.literal("blocked"),
|
|
);
|
|
const leadContactStatus = 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"),
|
|
);
|
|
const leadDuplicateStatus = v.union(
|
|
v.literal("unchecked"),
|
|
v.literal("unique"),
|
|
v.literal("possible_duplicate"),
|
|
v.literal("duplicate"),
|
|
);
|
|
const leadBlacklistStatus = v.union(v.literal("clear"), v.literal("blocked"));
|
|
const reviewUpdateArgs = {
|
|
id: v.id("leads"),
|
|
priority: v.optional(leadPriority),
|
|
priorityReason: v.optional(v.string()),
|
|
contactStatus: v.optional(leadContactStatus),
|
|
contactStatusReason: v.optional(v.string()),
|
|
notes: v.optional(v.string()),
|
|
duplicateStatus: v.optional(leadDuplicateStatus),
|
|
duplicateReason: v.optional(v.string()),
|
|
blacklistStatus: v.optional(leadBlacklistStatus),
|
|
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()),
|
|
};
|
|
|
|
const requireOperator = async (ctx: MutationCtx | QueryCtx) => {
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
if (!identity) {
|
|
throw new Error("Nicht autorisiert.");
|
|
}
|
|
};
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
async function reviewUpdateLead(ctx: MutationCtx, args: LeadReviewUpdateArgs) {
|
|
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 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.union(v.literal("google_places"), v.literal("local_business_data")),
|
|
),
|
|
sourceBusinessId: v.optional(v.string()),
|
|
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(leadPriority),
|
|
priorityReason: v.optional(v.string()),
|
|
contactStatus: v.optional(leadContactStatus),
|
|
contactStatusReason: v.optional(v.string()),
|
|
duplicateStatus: v.optional(leadDuplicateStatus),
|
|
duplicateReason: v.optional(v.string()),
|
|
blacklistReason: v.optional(v.string()),
|
|
duplicateOfLeadId: v.optional(v.id("leads")),
|
|
blacklistStatus: v.optional(leadBlacklistStatus),
|
|
normalizedGooglePlaceId: v.optional(v.string()),
|
|
normalizedSourceBusinessId: v.optional(v.string()),
|
|
notes: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await requireOperator(ctx);
|
|
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,
|
|
normalizedSourceBusinessId: args.normalizedSourceBusinessId,
|
|
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: reviewUpdateArgs,
|
|
handler: async (ctx, args) => {
|
|
await requireOperator(ctx);
|
|
return await reviewUpdateLead(ctx, args);
|
|
},
|
|
});
|
|
|
|
export const reviewUpdateInternal = internalMutation({
|
|
args: reviewUpdateArgs,
|
|
handler: async (ctx, args) => {
|
|
return await reviewUpdateLead(ctx, args);
|
|
},
|
|
});
|
|
|
|
export const get = query({
|
|
args: { id: v.id("leads") },
|
|
handler: async (ctx, args) => {
|
|
await requireOperator(ctx);
|
|
return await ctx.db.get(args.id);
|
|
},
|
|
});
|
|
|
|
export const getInternal = internalQuery({
|
|
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(leadContactStatus),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await requireOperator(ctx);
|
|
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) => {
|
|
await requireOperator(ctx);
|
|
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,
|
|
doNotContactUntil: latestOutreach.doNotContactUntil ?? null,
|
|
}
|
|
: null,
|
|
};
|
|
}),
|
|
);
|
|
},
|
|
});
|