Surface audit generations on dashboard audits
This commit is contained in:
116
v2_elemente/leads.ts
Normal file
116
v2_elemente/leads.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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();
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user