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