apps/web/src/lib/leads.ts

1import type { UserProfile } from '@local/ai'
2import { getBusinessEmailValidationMessage } from '@local/forms/business-email'
3import { agendaEmailHtml } from './agendaEmail'
4import type { LeadAttribution } from './leadAttribution'
5
6/**
7 * Zapier hand-off: the single destination for everything the chat collects.
8 * Unleash's Zap fans out downstream (Salesforce lead creation, Postmark
9 * agenda emails) — our side only submits, with a `type` discriminator.
10 * No-ops when the env var is unset.
11 */
12
13const LOG_PREFIX = '[Leads]'
14
15export const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
16
17/**
18 * Salesforce Intent picklist values for AI-originated leads.
19 * Suffix: UNW = Unleash World, UNA = Unleash America, FB = show unknown.
20 * Priority: Startup / Spex trump more specific delegate intents when those
21 * commercial categories are determined.
22 */
23export const LEAD_INTENTS = [
24	'Startup UNW',
25	'Startup UNA',
26	'Startup FB',
27	'Spex UNW',
28	'Spex UNA',
29	'Spex FB',
30	'Delegate UNW',
31	'Delegate UNA',
32	'Delegate FB',
33	'Delegate Agenda UNW',
34	'Delegate Agenda UNA',
35	'Delegate Agenda FB',
36	'Delegate Brochure UNW',
37	'Delegate Brochure UNA',
38	'Delegate Brochure FB',
39	'Register Interest UNW',
40	'Register Interest UNA',
41	'Register Interest FB',
42] as const
43
44export type LeadIntent = (typeof LEAD_INTENTS)[number]
45
46export type IntentShow = 'UNW' | 'UNA' | 'FB'
47
48export function isLeadIntent(value: unknown): value is LeadIntent {
49	return (
50		typeof value === 'string' &&
51		(LEAD_INTENTS as readonly string[]).includes(value)
52	)
53}
54
55/** Map page path → show suffix used in Intent values. */
56export function intentShowFromLocation(location?: string): IntentShow {
57	if (!location) return 'FB'
58	if (
59		location.startsWith('/events/unleash-america') ||
60		location.startsWith('/events/unleash-miami') ||
61		location.startsWith('/events/unleash-vegas')
62	)
63		return 'UNA'
64	if (
65		location.startsWith('/events/unleash-world') ||
66		location.startsWith('/events/unleash-paris')
67	)
68		return 'UNW'
69	return 'FB'
70}
71
72/** Build a typed Intent when only the category + page are known. */
73export function leadIntentFor(
74	category:
75		| 'Startup'
76		| 'Spex'
77		| 'Delegate'
78		| 'Delegate Agenda'
79		| 'Delegate Brochure'
80		| 'Register Interest',
81	location?: string,
82): LeadIntent {
83	return `${category} ${intentShowFromLocation(location)}` as LeadIntent
84}
85
86async function submitToZapier(
87	payload: Record<string, unknown>,
88): Promise<{ ok: boolean; error?: string }> {
89	const url = process.env.ZAPIER_LEAD_WEBHOOK_URL?.trim()
90	if (!url) {
91		console.warn(`${LOG_PREFIX} ZAPIER_LEAD_WEBHOOK_URL not set; not sent`)
92		return { ok: false, error: 'Lead pipeline not configured' }
93	}
94	try {
95		const response = await fetch(url, {
96			method: 'POST',
97			signal: AbortSignal.timeout(15_000),
98			headers: { 'Content-Type': 'application/json' },
99			body: JSON.stringify({
100				source: 'unleash-ai-chat',
101				collected_at: new Date().toISOString(),
102				...payload,
103			}),
104		})
105		if (!response.ok) {
106			throw new Error(`Zapier hook responded ${response.status}`)
107		}
108		return { ok: true }
109	} catch (error) {
110		const message = error instanceof Error ? error.message : String(error)
111		console.error(`${LOG_PREFIX} submission failed`, error)
112		return { ok: false, error: message }
113	}
114}
115
116export type LeadSubmission = {
117	attribution?: LeadAttribution
118	profile: UserProfile
119	chatId: string
120	location?: string
121	/** 'chat-tool' = agent-confirmed capture; 'profile-extraction' = async safety net. Zap dedupes on chat_id+email. */
122	trigger: 'chat-tool' | 'profile-extraction'
123	enquiry?: string
124	/** Salesforce Intent picklist value — required for every lead submission. */
125	intent: LeadIntent
126	/** Visitor ticked the consent checkbox in the chat (server-verified per request). */
127	consent?: boolean
128}
129
130/** Commercial/delegate lead → Zapier (→ Salesforce on the Unleash side). */
131export async function submitLeadToZapier(
132	lead: LeadSubmission,
133): Promise<{ ok: boolean; error?: string }> {
134	// Only structurally valid emails reach the lead pipeline (SoW §7.3).
135	if (!lead.profile.email || !EMAIL_SHAPE.test(lead.profile.email)) {
136		console.warn(`${LOG_PREFIX} lead skipped: invalid or missing email`, {
137			chatId: lead.chatId,
138		})
139		return { ok: false, error: 'Invalid or missing email address' }
140	}
141	const emailError = getBusinessEmailValidationMessage(lead.profile.email)
142	if (emailError) return { ok: false, error: emailError }
143	if (!isLeadIntent(lead.intent)) {
144		console.warn(`${LOG_PREFIX} lead skipped: invalid or missing intent`, {
145			chatId: lead.chatId,
146			intent: lead.intent,
147		})
148		return { ok: false, error: 'Invalid or missing intent' }
149	}
150	return submitToZapier({
151		type: 'lead',
152		opt_in: false,
153		...lead.attribution,
154		decision_pillars: lead.profile.decisionPillars,
155		trigger: lead.trigger,
156		consent: lead.consent ?? false,
157		chat_id: lead.chatId,
158		location: lead.location || undefined,
159		enquiry: lead.enquiry || undefined,
160		intent: lead.intent,
161		name: lead.profile.name,
162		email: lead.profile.email,
163		role: lead.profile.role,
164		company: lead.profile.company,
165		interests: lead.profile.interests,
166		stage: lead.profile.stage,
167	})
168}
169
170export type AgendaEmailSubmission = {
171	attribution?: LeadAttribution
172	decisionPillars?: UserProfile['decisionPillars']
173	chatId: string
174	location?: string
175	name: string
176	email: string
177	agenda: string
178	event?: string
179	optIn?: boolean
180	consent?: boolean
181	/** Defaults to Delegate Agenda + show derived from location when omitted. */
182	intent?: LeadIntent
183	/**
184	 * Profile context known server-side (extracted across the conversation).
185	 * Passed through so the downstream Zap/CRM gets role, company, and
186	 * interests even though the agenda tool itself only collects name+email.
187	 */
188	role?: string
189	company?: string
190	interests?: string[]
191}
192
193/** Personalised agenda → Zapier (→ Postmark email + lead on the Unleash side). */
194export async function submitAgendaEmailToZapier(
195	submission: AgendaEmailSubmission,
196): Promise<{ ok: boolean; error?: string }> {
197	if (!EMAIL_SHAPE.test(submission.email)) {
198		return { ok: false, error: 'Invalid email address' }
199	}
200	const emailError = getBusinessEmailValidationMessage(submission.email)
201	if (emailError) return { ok: false, error: emailError }
202	if (!submission.agenda.trim()) {
203		return { ok: false, error: 'Agenda content is empty' }
204	}
205	const intent =
206		submission.intent ?? leadIntentFor('Delegate Agenda', submission.location)
207	if (!isLeadIntent(intent)) {
208		return { ok: false, error: 'Invalid or missing intent' }
209	}
210	return submitToZapier({
211		type: 'agenda-email',
212		...submission.attribution,
213		decision_pillars: submission.decisionPillars,
214		chat_id: submission.chatId,
215		location: submission.location || undefined,
216		name: submission.name,
217		email: submission.email,
218		event: submission.event || undefined,
219		opt_in: submission.optIn ?? false,
220		consent: submission.consent ?? false,
221		intent,
222		role: submission.role || undefined,
223		company: submission.company || undefined,
224		interests: submission.interests?.length ? submission.interests : undefined,
225		agenda: submission.agenda.slice(0, 20_000),
226		agenda_html: agendaEmailHtml(submission.agenda.slice(0, 20_000)),
227	})
228}
229