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

405 lines
12 KiB
TypeScript

/**
* Atomic Lead-to-Tenant conversion service (Marketing-funnel Phase 2,
* Phase 05 #79).
*
* `convertLeadToTenant` runs inside a single Prisma `$transaction` so the
* following five effects either all commit or all roll back together:
*
* 1. New Tenant row (via direct prisma.create — we cannot reuse the
* higher-level `createTenant` helper because it runs outside a
* transaction. Slug + inboundAddress allocation is delegated to the
* pre-computed values passed in by the caller).
* 2. Lead row update: `status` advances to `trial` (default) or
* `customer` (when caller requests it), `convertedToTenantId` +
* `convertedAt` populated.
* 3. `LeadEvent` of type `converted` appended for the activity
* timeline.
* 4. `TenantAcquisition` row inserted with first-touch attribution
* snapshot copied from the Lead.
* 5. `OutboxEvent` published — `marketing.lead.convertedtotrial` or
* `marketing.lead.convertedtocustomer` — picked up downstream by
* the marketing-outbound worker handler to fan out webhooks (Slack,
* Discord, Mailchimp, HubSpot, Brevo, custom).
*
* Idempotency: the outbox dispatchKey is `lead-convert:<leadId>` so a
* re-run after partial failure produces the same outbox row (publishOutboxEvent
* returns the existing event on unique-constraint conflict). The Lead's
* `convertedToTenantId` check up-front turns repeated conversion attempts
* into no-ops, returning the already-created Tenant + Lead.
*/
import { Prisma } from "@toolctrl/db/prisma-browser";
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@toolctrl/db/outbox";
import { db } from "~/utils/db.server";
export type ConvertedStatus = "trial" | "customer";
export type ConversionPath = "self-service" | "admin-override" | "api";
export interface ConvertLeadInput {
leadId: string;
/** Lead-status target after conversion. Defaults to `trial`. */
targetStatus?: ConvertedStatus;
/** Free-text path label for the TenantAcquisition row. */
conversionPath?: ConversionPath;
/** Pre-computed tenant params — caller resolves slug and inbound address. */
tenant: {
name: string;
slug: string;
icon?: string | null;
/** Pre-computed inbound address (e.g. via `getAvailableTenantInboundAddress`). */
inboundAddress: string;
};
/** Optional acting admin user id when conversionPath === "admin-override". */
actorId?: string | null;
/**
* Optional plan-id (SubscriptionProduct.id) carried through to the
* webhook payload — does NOT enrol the tenant into a subscription. The
* existing `addToTrialOrFreePlan` post-register flow handles that
* separately so we don't accidentally double-bill.
*/
planId?: string | null;
}
export interface ConvertLeadResult {
tenantId: string;
leadId: string;
status: ConvertedStatus;
alreadyConverted: boolean;
/** Outbox event id (useful for tests). */
outboxEventId: string | null;
}
export interface RecordExistingTenantLeadConversionInput {
leadId: string;
tenantId: string;
targetStatus?: ConvertedStatus;
conversionPath?: ConversionPath;
actorId?: string | null;
planId?: string | null;
}
export class LeadNotFoundError extends Error {
override readonly name = "LeadNotFoundError";
constructor(leadId: string) {
super(`Lead ${leadId} not found`);
}
}
export class LeadAlreadyConvertedError extends Error {
override readonly name = "LeadAlreadyConvertedError";
constructor(
leadId: string,
public readonly convertedToTenantId: string
) {
super(`Lead ${leadId} is already converted.`);
}
}
export class LeadConversionDisabledError extends Error {
override readonly name = "LeadConversionDisabledError";
constructor() {
super("Lead conversion is disabled (LEAD_CONVERSION_ENABLED=false).");
}
}
export const MARKETING_LEAD_EVENT_PREFIX = "marketing.lead";
export function buildLeadConversionEventType(status: ConvertedStatus): string {
return status === "customer"
? `${MARKETING_LEAD_EVENT_PREFIX}.convertedtocustomer`
: `${MARKETING_LEAD_EVENT_PREFIX}.convertedtotrial`;
}
export async function recordExistingTenantLeadConversion(input: RecordExistingTenantLeadConversionInput): Promise<ConvertLeadResult> {
if (process.env.LEAD_CONVERSION_ENABLED === "false") {
throw new LeadConversionDisabledError();
}
const status: ConvertedStatus = input.targetStatus ?? "trial";
const conversionPath: ConversionPath = input.conversionPath ?? "self-service";
return await db.$transaction(async (tx) => {
const lead = await tx.lead.findUnique({
where: { id: input.leadId },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
company: true,
source: true,
utmSource: true,
utmMedium: true,
utmCampaign: true,
utmTerm: true,
utmContent: true,
gclid: true,
fbclid: true,
referrer: true,
landingPage: true,
firstSeenAt: true,
convertedToTenantId: true,
},
});
if (!lead) throw new LeadNotFoundError(input.leadId);
if (lead.convertedToTenantId) {
if (lead.convertedToTenantId !== input.tenantId) {
throw new LeadAlreadyConvertedError(lead.id, lead.convertedToTenantId);
}
return {
tenantId: input.tenantId,
leadId: lead.id,
status,
alreadyConverted: true,
outboxEventId: null,
};
}
const now = new Date();
await tx.lead.update({
where: { id: lead.id },
data: {
status,
convertedToTenantId: input.tenantId,
convertedAt: now,
},
});
await tx.leadEvent.create({
data: {
leadId: lead.id,
type: "converted",
actorId: input.actorId ?? null,
payload: {
tenantId: input.tenantId,
conversionPath,
targetStatus: status,
planId: input.planId ?? null,
} as Prisma.InputJsonValue,
},
});
await tx.tenantAcquisition.upsert({
where: { tenantId: input.tenantId },
create: {
tenantId: input.tenantId,
leadId: lead.id,
source: lead.source ?? conversionPath,
utmSource: lead.utmSource,
utmMedium: lead.utmMedium,
utmCampaign: lead.utmCampaign,
utmTerm: lead.utmTerm,
utmContent: lead.utmContent,
gclid: lead.gclid,
fbclid: lead.fbclid,
referrer: lead.referrer,
landingPage: lead.landingPage,
capturedAt: lead.firstSeenAt,
convertedAt: now,
conversionPath,
customFields:
input.planId != null
? ({ planId: input.planId } as Prisma.InputJsonValue)
: Prisma.JsonNull,
},
update: {
leadId: lead.id,
source: lead.source ?? conversionPath,
utmSource: lead.utmSource,
utmMedium: lead.utmMedium,
utmCampaign: lead.utmCampaign,
utmTerm: lead.utmTerm,
utmContent: lead.utmContent,
gclid: lead.gclid,
fbclid: lead.fbclid,
referrer: lead.referrer,
landingPage: lead.landingPage,
capturedAt: lead.firstSeenAt,
convertedAt: now,
conversionPath,
customFields:
input.planId != null
? ({ planId: input.planId } as Prisma.InputJsonValue)
: Prisma.JsonNull,
},
});
const outboxEvent = await publishOutboxEvent(tx as unknown as OutboxPublisherTransaction, {
tenantId: input.tenantId,
type: buildLeadConversionEventType(status),
payload: {
leadId: lead.id,
tenantId: input.tenantId,
targetStatus: status,
conversionPath,
planId: input.planId ?? null,
email: lead.email,
firstName: lead.firstName,
lastName: lead.lastName,
company: lead.company,
utm: {
source: lead.utmSource,
medium: lead.utmMedium,
campaign: lead.utmCampaign,
term: lead.utmTerm,
content: lead.utmContent,
},
referrer: lead.referrer,
landingPage: lead.landingPage,
convertedAt: now.toISOString(),
} as Prisma.InputJsonValue,
dispatchKey: `lead-convert:${lead.id}`,
});
return {
tenantId: input.tenantId,
leadId: lead.id,
status,
alreadyConverted: false,
outboxEventId: outboxEvent.id,
};
});
}
export async function convertLeadToTenant(input: ConvertLeadInput): Promise<ConvertLeadResult> {
if (process.env.LEAD_CONVERSION_ENABLED === "false") {
throw new LeadConversionDisabledError();
}
const status: ConvertedStatus = input.targetStatus ?? "trial";
const conversionPath: ConversionPath = input.conversionPath ?? "self-service";
return await db.$transaction(async (tx) => {
const lead = await tx.lead.findUnique({
where: { id: input.leadId },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
company: true,
source: true,
utmSource: true,
utmMedium: true,
utmCampaign: true,
utmTerm: true,
utmContent: true,
gclid: true,
fbclid: true,
referrer: true,
landingPage: true,
firstSeenAt: true,
status: true,
convertedToTenantId: true,
},
});
if (!lead) throw new LeadNotFoundError(input.leadId);
// Idempotent re-run: already-converted leads return existing tenant.
if (lead.convertedToTenantId) {
return {
tenantId: lead.convertedToTenantId,
leadId: lead.id,
status,
alreadyConverted: true,
outboxEventId: null,
};
}
const tenant = await tx.tenant.create({
data: {
name: input.tenant.name,
slug: input.tenant.slug,
icon: input.tenant.icon ?? undefined,
inboundAddresses: {
create: [{ address: input.tenant.inboundAddress }],
},
},
select: { id: true },
});
const now = new Date();
await tx.lead.update({
where: { id: lead.id },
data: {
status,
convertedToTenantId: tenant.id,
convertedAt: now,
},
});
await tx.leadEvent.create({
data: {
leadId: lead.id,
type: "converted",
actorId: input.actorId ?? null,
payload: {
tenantId: tenant.id,
tenantSlug: input.tenant.slug,
conversionPath,
targetStatus: status,
planId: input.planId ?? null,
} as Prisma.InputJsonValue,
},
});
await tx.tenantAcquisition.create({
data: {
tenantId: tenant.id,
leadId: lead.id,
source: lead.source ?? conversionPath,
utmSource: lead.utmSource,
utmMedium: lead.utmMedium,
utmCampaign: lead.utmCampaign,
utmTerm: lead.utmTerm,
utmContent: lead.utmContent,
gclid: lead.gclid,
fbclid: lead.fbclid,
referrer: lead.referrer,
landingPage: lead.landingPage,
capturedAt: lead.firstSeenAt,
convertedAt: now,
conversionPath,
customFields:
input.planId != null
? ({ planId: input.planId } as Prisma.InputJsonValue)
: Prisma.JsonNull,
},
});
const eventType = buildLeadConversionEventType(status);
const outboxEvent = await publishOutboxEvent(tx as unknown as OutboxPublisherTransaction, {
tenantId: tenant.id,
type: eventType,
payload: {
leadId: lead.id,
tenantId: tenant.id,
targetStatus: status,
conversionPath,
planId: input.planId ?? null,
email: lead.email,
firstName: lead.firstName,
lastName: lead.lastName,
company: lead.company,
utm: {
source: lead.utmSource,
medium: lead.utmMedium,
campaign: lead.utmCampaign,
term: lead.utmTerm,
content: lead.utmContent,
},
referrer: lead.referrer,
landingPage: lead.landingPage,
convertedAt: now.toISOString(),
} as Prisma.InputJsonValue,
dispatchKey: `lead-convert:${lead.id}`,
});
return {
tenantId: tenant.id,
leadId: lead.id,
status,
alreadyConverted: false,
outboxEventId: outboxEvent.id,
};
});
}