405 lines
12 KiB
TypeScript
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,
|
|
};
|
|
});
|
|
}
|