Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/leads/captureLead.server.ts
T

179 lines
6.4 KiB
TypeScript

/**
* `captureLead` — idempotent upsert helper for the marketing capture endpoint
* (Phase 05 #78).
*
* Behaviour:
* - When no Lead exists for the email: insert a new row, populate
* first-touch UTM/referrer/landingPage attribution, store first
* captured `LeadEvent` of type `captured`.
* - When a Lead exists: update `lastTouchAt` + `lastIpHash` + `userAgent`,
* merge missing optional profile fields (firstName, lastName, phone,
* company), but NEVER overwrite already-set attribution columns (first-
* touch semantics). Append a `re-captured` LeadEvent.
*
* The helper never throws on duplicate-email — the unique index guarantees
* idempotency. Validation is the caller's responsibility (Zod-parsed input).
*/
import { createHash } from "node:crypto";
import type { Prisma } from "@toolctrl/db/prisma-browser";
import { db } from "~/utils/db.server";
export interface CaptureLeadInput {
email: string;
firstName?: string | null;
lastName?: string | null;
phone?: string | null;
company?: string | null;
source?: string | null;
/** Attribution fields. Treated as first-touch — set only on insert. */
attribution?: {
utmSource?: string | null;
utmMedium?: string | null;
utmCampaign?: string | null;
utmTerm?: string | null;
utmContent?: string | null;
gclid?: string | null;
fbclid?: string | null;
referrer?: string | null;
landingPage?: string | null;
};
ip?: string | null;
userAgent?: string | null;
}
export interface CaptureLeadResult {
/** Persisted (or already-existing) lead id. */
leadId: string;
/** `true` when a new row was inserted, `false` when an existing row was touched. */
created: boolean;
}
export function hashIp(ip: string | null | undefined): string | null {
if (!ip) return null;
return createHash("sha256").update(ip).digest("hex").slice(0, 32);
}
export async function captureLead(input: CaptureLeadInput): Promise<CaptureLeadResult> {
const email = input.email.trim().toLowerCase();
const ipHash = hashIp(input.ip);
const ua = input.userAgent ? input.userAgent.slice(0, 500) : null;
const attr = input.attribution ?? {};
const existing = await db.lead.findUnique({ where: { email }, select: { id: true } });
if (!existing) {
const lead = await db.lead.create({
data: {
email,
firstName: trim(input.firstName),
lastName: trim(input.lastName),
phone: trim(input.phone),
company: trim(input.company),
source: trim(input.source),
utmSource: trim(attr.utmSource),
utmMedium: trim(attr.utmMedium),
utmCampaign: trim(attr.utmCampaign),
utmTerm: trim(attr.utmTerm),
utmContent: trim(attr.utmContent),
gclid: trim(attr.gclid),
fbclid: trim(attr.fbclid),
referrer: trim(attr.referrer),
landingPage: trim(attr.landingPage),
lastIpHash: ipHash,
userAgent: ua,
events: {
create: {
type: "captured",
payload: serialisePayload({
source: trim(input.source),
attribution: cleanAttribution(attr),
}),
},
},
},
select: { id: true },
});
return { leadId: lead.id, created: true };
}
// Existing — only fill blanks, never overwrite. First-touch attribution wins.
const updateData: Prisma.LeadUpdateInput = {
lastTouchAt: new Date(),
lastIpHash: ipHash,
userAgent: ua,
firstName: nonNullSetIfMissing(input.firstName),
lastName: nonNullSetIfMissing(input.lastName),
phone: nonNullSetIfMissing(input.phone),
company: nonNullSetIfMissing(input.company),
events: {
create: {
type: "re-captured",
payload: serialisePayload({
source: trim(input.source),
attribution: cleanAttribution(attr),
}),
},
},
};
// Only conditionally fill blanks — Prisma update treats `undefined` as
// "leave alone", and `null` as "set to NULL" which we explicitly do NOT want.
// We re-fetch the row only for fields that need conditional patching to
// avoid extra reads; the merge logic happens in the next query batch.
await db.lead.update({ where: { id: existing.id }, data: updateData });
// Best-effort fill-blanks: only touches columns where the existing value is
// NULL and the incoming value is set. This is two queries in the dup-case
// which keeps the common path simple.
await fillBlankProfileFields(existing.id, input);
return { leadId: existing.id, created: false };
}
function trim(v: string | null | undefined): string | null {
if (v === null || v === undefined) return null;
const t = String(v).trim();
return t === "" ? null : t;
}
function nonNullSetIfMissing(_v: string | null | undefined): undefined {
// Returning `undefined` so Prisma keeps the existing value untouched.
// The fill-blanks step (`fillBlankProfileFields`) handles actual patches.
return undefined;
}
async function fillBlankProfileFields(leadId: string, input: CaptureLeadInput): Promise<void> {
const row = await db.lead.findUnique({
where: { id: leadId },
select: { firstName: true, lastName: true, phone: true, company: true },
});
if (!row) return;
const patch: Prisma.LeadUpdateInput = {};
if (row.firstName == null && trim(input.firstName)) patch.firstName = trim(input.firstName);
if (row.lastName == null && trim(input.lastName)) patch.lastName = trim(input.lastName);
if (row.phone == null && trim(input.phone)) patch.phone = trim(input.phone);
if (row.company == null && trim(input.company)) patch.company = trim(input.company);
if (Object.keys(patch).length === 0) return;
await db.lead.update({ where: { id: leadId }, data: patch });
}
function cleanAttribution(attr: NonNullable<CaptureLeadInput["attribution"]>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(attr)) {
const t = trim(v ?? null);
if (t) out[k] = t;
}
return out;
}
function serialisePayload(obj: Record<string, unknown>): Prisma.InputJsonValue {
// Drop top-level keys with empty / undefined values for compact JSON storage.
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (v === null || v === undefined) continue;
if (typeof v === "object" && Object.keys(v as Record<string, unknown>).length === 0) continue;
out[k] = v;
}
return out as Prisma.InputJsonValue;
}