/** * convex/leads.ts * * Lead-Anlage mit Dubletten- und Blacklist-Prüfung. Die eigentliche * Recherche-Orchestrierung liegt in campaigns.ts (Action); hier sind die * deterministischen DB-Operationen. */ import { v } from "convex/values"; import { query, internalMutation } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; import { computePriority, extractDomain } from "./lib/googlePlaces"; const placeArg = v.object({ placeId: v.string(), name: v.string(), address: v.optional(v.string()), phone: v.optional(v.string()), website: v.optional(v.string()), rating: v.optional(v.number()), }); /** * Legt einen Lead aus einem Places-Ergebnis an — sofern keine Dublette und * nicht geblockt. Idempotent über (orgId, googlePlaceId). * Rückgabe: Status, damit der Lauf zählen kann. */ export const upsertLeadFromPlace = internalMutation({ args: { orgId: v.id("organizations"), campaignId: v.id("campaigns"), niche: v.string(), place: placeArg, leadCostUsd: v.number(), }, returns: v.object({ status: v.union( v.literal("inserted"), v.literal("duplicate"), v.literal("blocked"), ), leadId: v.optional(v.id("leads")), }), handler: async (ctx, { orgId, campaignId, niche, place, leadCostUsd }) => { // 1) Dublette? (gleicher Place in dieser Org) const existing = await ctx.db .query("leads") .withIndex("by_org_place", (q) => q.eq("orgId", orgId).eq("googlePlaceId", place.placeId), ) .first(); if (existing) return { status: "duplicate" as const }; // 2) Blacklist? (Place-ID, Domain, Telefon, Firmenname) const domain = extractDomain(place.website); const candidates: string[] = [place.placeId, place.name]; if (domain) candidates.push(domain); if (place.phone) candidates.push(place.phone); for (const value of candidates) { const hit = await ctx.db .query("blacklist") .withIndex("by_org_value", (q) => q.eq("orgId", orgId).eq("value", value), ) .first(); if (hit) return { status: "blocked" as const }; } // 3) Anlegen. E-Mail wird hier NICHT geraten — sie wird (falls überhaupt) // später aus einer öffentlich ausgewiesenen Kontaktadresse gewonnen. // Ohne Telefon → "Kontakt fehlt". const contactStatus = place.phone ? "new" : "contact_missing"; const leadId: Id<"leads"> = await ctx.db.insert("leads", { orgId, campaignId, companyName: place.name, niche, address: place.address, googlePlaceId: place.placeId, source: "google_places", domain, phone: place.phone, email: undefined, contactPerson: undefined, priority: computePriority({ ...place, domain }), contactStatus, duplicateStatus: "unique", blocklistStatus: "clear", googleRating: place.rating, // nur intern }); // 4) Verbrauch protokollieren (Abrechnung + reale Unit-Economics) await ctx.db.insert("usageEvents", { orgId, type: "lead_lookup", leadId, costEstimateUsd: leadCostUsd, callCounts: { google: 1 }, }); return { status: "inserted" as const, leadId }; }, }); /** Leads einer Kampagne, nach Priorität sortiert (fürs Dashboard). */ export const listByCampaign = query({ args: { campaignId: v.id("campaigns") }, handler: async (ctx, { campaignId }) => { // Hinweis: orgId-Scoping erfolgt im aufrufenden Auth-Layer; zusätzlich // sollte hier die Org des Aufrufers gegen campaign.orgId geprüft werden. return await ctx.db .query("leads") .withIndex("by_campaign", (q) => q.eq("campaignId", campaignId)) .collect(); }, });