apps/web/src/lib/handoffContact.ts

1import type { ChatMessage, UserProfile } from '@local/ai'
2import { normalizeEmailEscapes } from '@local/ai/components/workflows'
3import { HANDOFF_CONSENT } from '@local/config/ai-consent'
4import { getBusinessEmailValidationMessage } from '@local/forms/business-email'
5
6/** Flag an explicitly supplied malformed email before asking for another field. */
7export function malformedContactEmail(message: string): string | undefined {
8	const text = normalizeEmailEscapes(message)
9	const candidate =
10		text.match(/\S+@\S+/)?.[0]?.replace(/[.!;]$/, '') ||
11		text.match(
12			/\bemail(?: it)?(?: address)? (?:to|is|:)\s+([\w.-]+\.[\w.-]+)/i,
13		)?.[1]
14	if (candidate && !/^[^\s@]+@[^\s@]+\.[^\s@,]+$/.test(candidate))
15		return 'That email address is not in a valid format. Please provide the full address with an @ and domain, for example name@example.com.'
16	return undefined
17}
18
19/** Shared requirements for ticket, sponsorship and agenda handoffs. */
20export function missingContactQuestion(
21	profile: UserProfile | undefined,
22	_agenda = false,
23): string | undefined {
24	if (!profile?.company?.trim()) return 'What company are you with?'
25	if (!profile.role?.trim()) return 'What is your job role?'
26	if (!profile.name?.trim()) return 'What name should I use?'
27	if (!profile.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profile.email))
28		return 'What work email address should I use?'
29	return getBusinessEmailValidationMessage(profile.email) || undefined
30}
31
32/** Tool arguments must come from visitor statements or an already saved profile. */
33export function groundedContact(
34	profile: UserProfile | undefined,
35	proposed: UserProfile,
36	history: ChatMessage[],
37): UserProfile {
38	const contact: UserProfile = { ...profile }
39	for (const field of ['name', 'email', 'company', 'role'] as const) {
40		const value = proposed[field]?.trim()
41		if (!value) continue
42		const evidenced = history.some((turn) => {
43			if (turn.role !== 'user') return false
44			const text = normalizeEmailEscapes(turn.text)
45			// An email local part/domain does not establish a name or company.
46			const source = field === 'email' ? text : text.replace(/\S+@\S+/g, '')
47			return source.toLowerCase().includes(value.toLowerCase())
48		})
49		if (evidenced) contact[field] = value
50	}
51	return contact
52}
53
54export function agendaContactReply(
55	profile: UserProfile | undefined,
56	consentStatement: string = HANDOFF_CONSENT.agenda,
57): string {
58	return (
59		missingContactQuestion(profile, true) ||
60		`I’ll email your selected agenda to ${profile?.name}, ${profile?.email} (${profile?.role}, ${profile?.company}).\n\n${consentStatement}`
61	)
62}
63
64/** A short answer to a single contact-field question is explicit visitor data. */
65export function contactAnswer(
66	profile: UserProfile,
67	question: string,
68	message: string,
69): UserProfile {
70	const value = message.trim()
71	if (
72		!value ||
73		value.length > 200 ||
74		/[?@\n]/.test(value) ||
75		/^(?:yes|no|no thanks|not now|cancel|skip|thanks|sure|i don.t know|prefer not|why|what|how|can |could )/i.test(
76			value,
77		)
78	)
79		return profile
80	const field = /^What company are you with(?: for [^?]+)?\?$/i.test(
81		question.trim(),
82	)
83		? 'company'
84		: /^What is your job role(?: for [^?]+)?\?$/i.test(question.trim())
85			? 'role'
86			: /^What name should I use(?: for [^?]+)?\?$/i.test(question.trim())
87				? 'name'
88				: undefined
89	if (!field) return profile
90	// Longer sentences are left to extraction; this path handles literal answers like UNLEASH.
91	if (
92		value.split(/\s+/).length > 8 ||
93		/\b(?:i am|i work|my name|my (?:job )?role|my company|actually|instead)\b/i.test(
94			value,
95		)
96	)
97		return profile
98	return { ...profile, [field]: value }
99}
100
101/** A final confirmation must refer to the current recipient, including corrections. */
102export function hasContactSummary(
103	text: string,
104	profile: UserProfile | undefined,
105): boolean {
106	const normalized = normalizeEmailEscapes(text).toLowerCase()
107	return (['name', 'email', 'company', 'role'] as const).every((field) => {
108		const value = profile?.[field]?.trim().toLowerCase()
109		return Boolean(value && normalized.includes(value))
110	})
111}
112