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