Files
pitchfast/v2_elemente/leads.ts

117 lines
3.7 KiB
TypeScript

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