384 lines
13 KiB
TypeScript
384 lines
13 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 six 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. A single pending OWNER invitation plus its transactional-mail
|
|
* outbox row.
|
|
* 3. Lead row update: `status` advances to `trial` (default) or
|
|
* `customer` (when caller requests it), `convertedToTenantId` +
|
|
* `convertedAt` populated.
|
|
* 4. `LeadEvent` of type `converted` appended for the activity
|
|
* timeline.
|
|
* 5. `TenantAcquisition` row inserted with first-touch attribution
|
|
* snapshot copied from the Lead.
|
|
* 6. `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 { randomUUID } from "node:crypto";
|
|
|
|
import { Prisma } from "@hivegrid/db/prisma-browser";
|
|
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@hivegrid/db/outbox";
|
|
import { convertSelfServiceMarketingLeadCapability } from "@hivegrid/db/pretenant-security.server";
|
|
import { getRlsExecutionContext, runWithIsolatedUserRlsContext, runWithRlsContext } from "@hivegrid/db/rls-context.server";
|
|
import { db } from "~/utils/db.server";
|
|
import { TENANT_PENDING_OWNER_REASON } from "~/domain/tenant-bootstrap/tenantBootstrap.constants";
|
|
import {
|
|
issueReservedOwnerInvitationWithTx,
|
|
type ReservedOwnerInvitationDraft,
|
|
} from "~/domain/tenant-bootstrap/ownerInvitationIssuance.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;
|
|
/** Prepared capability + mail content; persisted atomically with conversion. */
|
|
ownerInvitation: ReservedOwnerInvitationDraft;
|
|
/**
|
|
* 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;
|
|
userId: string;
|
|
expectedEmail: string;
|
|
targetStatus?: "trial";
|
|
conversionPath?: "self-service";
|
|
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 = "trial" as const;
|
|
const conversionPath = "self-service" as const;
|
|
|
|
return runWithRlsContext({ tenantId: input.tenantId, userId: input.userId }, () =>
|
|
db.$transaction(async (tx) => {
|
|
const lead = await convertSelfServiceMarketingLeadCapability(
|
|
{
|
|
leadId: input.leadId,
|
|
tenantId: input.tenantId,
|
|
userId: input.userId,
|
|
expectedEmail: input.expectedEmail,
|
|
planId: input.planId,
|
|
},
|
|
tx as never
|
|
);
|
|
if (!lead) throw new LeadNotFoundError(input.leadId);
|
|
if (lead.convertedToTenantId !== input.tenantId) {
|
|
throw new LeadAlreadyConvertedError(lead.leadId, lead.convertedToTenantId);
|
|
}
|
|
if (lead.alreadyConverted) {
|
|
return {
|
|
tenantId: input.tenantId,
|
|
leadId: lead.leadId,
|
|
status,
|
|
alreadyConverted: true,
|
|
outboxEventId: null,
|
|
};
|
|
}
|
|
|
|
const outboxEvent = await publishOutboxEvent(tx as unknown as OutboxPublisherTransaction, {
|
|
tenantId: input.tenantId,
|
|
type: buildLeadConversionEventType(status),
|
|
payload: {
|
|
leadId: lead.leadId,
|
|
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: lead.convertedAt,
|
|
} as Prisma.InputJsonValue,
|
|
dispatchKey: `lead-convert:${lead.leadId}`,
|
|
});
|
|
|
|
return {
|
|
tenantId: input.tenantId,
|
|
leadId: lead.leadId,
|
|
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";
|
|
const tenantId = randomUUID();
|
|
|
|
const transaction = async (tx: Prisma.TransactionClient): Promise<ConvertLeadResult> => {
|
|
// Serialize every conversion attempt for this lead before reading its
|
|
// conversion state. The lock is transaction-scoped, so a concurrent
|
|
// request observes the committed winner instead of creating an orphaned
|
|
// ownerless tenant and overwriting convertedToTenantId.
|
|
const conversionLockKey = `lead-convert:${input.leadId}`;
|
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${conversionLockKey}, 0))`;
|
|
|
|
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);
|
|
assertLeadOwnerInvitation(input, lead);
|
|
|
|
// 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: {
|
|
id: tenantId,
|
|
name: input.tenant.name,
|
|
slug: input.tenant.slug,
|
|
icon: input.tenant.icon ?? undefined,
|
|
active: false,
|
|
deactivatedReason: TENANT_PENDING_OWNER_REASON,
|
|
inboundAddresses: {
|
|
create: [{ address: input.tenant.inboundAddress }],
|
|
},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
await issueReservedOwnerInvitationWithTx(tx, {
|
|
tenantId: tenant.id,
|
|
...input.ownerInvitation,
|
|
});
|
|
|
|
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,
|
|
};
|
|
};
|
|
const execute = () => runWithRlsContext({ tenantId, userId: input.actorId ?? undefined }, () => db.$transaction(transaction));
|
|
const current = getRlsExecutionContext();
|
|
const actorUserId = input.actorId ?? current?.userId;
|
|
return current?.userId && current.userId === actorUserId && (current.tenantId || current.transaction)
|
|
? runWithIsolatedUserRlsContext(current.userId, execute)
|
|
: execute();
|
|
}
|
|
|
|
function assertLeadOwnerInvitation(
|
|
input: ConvertLeadInput,
|
|
lead: { email: string; firstName: string | null; lastName: string | null }
|
|
): void {
|
|
const firstName = lead.firstName?.trim() ?? "";
|
|
const lastName = lead.lastName?.trim() ?? "";
|
|
if (
|
|
!firstName ||
|
|
!lastName ||
|
|
input.ownerInvitation.issuanceKey !== `lead:${input.leadId}` ||
|
|
input.ownerInvitation.email.trim().toLowerCase() !== lead.email.trim().toLowerCase() ||
|
|
input.ownerInvitation.firstName.trim() !== firstName ||
|
|
input.ownerInvitation.lastName.trim() !== lastName ||
|
|
input.ownerInvitation.fromUserId !== (input.actorId ?? null)
|
|
) {
|
|
throw new LeadOwnerInvitationMismatchError(input.leadId);
|
|
}
|
|
}
|
|
|
|
export class LeadOwnerInvitationMismatchError extends Error {
|
|
override readonly name = "LeadOwnerInvitationMismatchError";
|
|
constructor(leadId: string) {
|
|
super(`Lead ${leadId} owner invitation does not match the conversion request.`);
|
|
}
|
|
}
|