179 lines
6.4 KiB
TypeScript
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;
|
|
}
|