117 lines
3.7 KiB
TypeScript
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();
|
|
},
|
|
});
|