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

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.`);
}
}