submitForm.ts210 lines · main
1'use server'
2
3import { CRMClient, type CRMConfig } from '../../crm'
4import type { GoFormCrmConfig } from '../schemas'
5
6export interface FormSubmitResult {
7 success: boolean
8 errors: string[]
9}
10
11/** Hidden field added by MarketingForm — bots fill it, humans never see it. */
12const HONEYPOT_FIELD = 'website'
13
14/** Minimum milliseconds a form must be on the page before a submission is accepted. */
15const MIN_FORM_RENDER_MS = 3000
16
17// Enable debug logging in local dev and on Vercel preview/development deployments
18const isDebug =
19 process.env.NODE_ENV === 'development' ||
20 process.env.VERCEL_ENV === 'preview' ||
21 process.env.VERCEL_ENV === 'development'
22
23function debug(message: string, data?: unknown) {
24 if (!isDebug) return
25 if (data !== undefined) {
26 console.log(`[go/form] ${message}`, JSON.stringify(data, null, 2))
27 } else {
28 console.log(`[go/form] ${message}`)
29 }
30}
31
32function buildCrmConfig(crm: GoFormCrmConfig): CRMConfig {
33 const config: CRMConfig = {}
34
35 if (crm.hubspot) {
36 const hubspotPortalId = process.env.HUBSPOT_PORTAL_ID
37 if (!hubspotPortalId) throw new Error('HUBSPOT_PORTAL_ID env var is not set')
38 config.hubspot = { portalId: hubspotPortalId, formGuid: crm.hubspot.formGuid }
39 }
40
41 if (crm.customerio) {
42 const customerioSiteId = process.env.CUSTOMERIO_SITE_ID
43 const customerioApiKey = process.env.CUSTOMERIO_API_KEY
44 if (!customerioSiteId) throw new Error('CUSTOMERIO_SITE_ID env var is not set')
45 if (!customerioApiKey) throw new Error('CUSTOMERIO_API_KEY env var is not set')
46 config.customerio = { siteId: customerioSiteId, apiKey: customerioApiKey }
47 }
48
49 if (crm.notion) {
50 const notionApiKey = process.env.NOTION_FORMS_API_KEY
51 if (!notionApiKey) throw new Error('NOTION_FORMS_API_KEY env var is not set')
52 config.notion = { apiKey: notionApiKey }
53 }
54
55 return config
56}
57
58/**
59 * Submit form values to the configured CRM providers (HubSpot, Customer.io, Notion).
60 *
61 * Credentials are read from environment variables:
62 * - HubSpot: HUBSPOT_PORTAL_ID
63 * - Customer.io: CUSTOMERIO_SITE_ID, CUSTOMERIO_API_KEY
64 * - Notion: NOTION_FORMS_API_KEY
65 *
66 * Per-form config (formGuid, event name, database_id, field mappings) lives in the page definition.
67 */
68export async function submitFormAction(
69 crm: GoFormCrmConfig,
70 values: Record<string, string>,
71 context?: { pageUri?: string; pageName?: string; honeypot?: string; formMountedAt?: number }
72): Promise<FormSubmitResult> {
73 debug('Form submission received', { crm, values, context })
74
75 // Anti-spam: honeypot tripped or form submitted suspiciously fast. Return a
76 // fake success so bots think they got through and don't retry with variations.
77 const honeypot = context?.honeypot ?? values[HONEYPOT_FIELD] ?? ''
78 if (honeypot.trim() !== '') {
79 console.warn('[go/form] Rejected submission: honeypot tripped', {
80 pageUri: context?.pageUri,
81 })
82 return { success: true, errors: [] }
83 }
84
85 if (
86 typeof context?.formMountedAt === 'number' &&
87 Date.now() - context.formMountedAt < MIN_FORM_RENDER_MS
88 ) {
89 console.warn('[go/form] Rejected submission: form submitted too quickly', {
90 pageUri: context.pageUri,
91 elapsedMs: Date.now() - context.formMountedAt,
92 })
93 return { success: true, errors: [] }
94 }
95
96 try {
97 // The honeypot field is never part of the real payload, even if a real
98 // form happens to include a `website` field name.
99 const { [HONEYPOT_FIELD]: _honeypot, ...payloadValues } = values
100
101 // Detect the email value from common field names
102 const email =
103 payloadValues['email'] ??
104 payloadValues['workEmail'] ??
105 payloadValues['work_email'] ??
106 payloadValues['emailAddress'] ??
107 payloadValues['email_address'] ??
108 ''
109
110 if (!email) {
111 debug('Submission rejected: no email field found in values')
112 return { success: false, errors: ['An email field is required for form submission.'] }
113 }
114
115 let client: CRMClient
116 try {
117 const crmConfig = buildCrmConfig(crm)
118 debug('CRM config built', {
119 providers: Object.keys(crmConfig),
120 hubspot: crm.hubspot ? { formGuid: crm.hubspot.formGuid } : undefined,
121 customerio: crm.customerio ? { event: crm.customerio.event } : undefined,
122 notion: crm.notion ? { database_id: crm.notion.database_id } : undefined,
123 })
124 client = new CRMClient(crmConfig)
125 } catch (err: any) {
126 debug('CRM config error', { error: err.message })
127 return { success: false, errors: [err.message] }
128 }
129
130 // Build HubSpot fields: apply optional field name mapping
131 let hubspotFields: Record<string, string> | undefined
132 let consent: string | undefined
133 if (crm.hubspot) {
134 const fieldMap = crm.hubspot.fieldMap ?? {}
135 hubspotFields = {}
136 for (const [formField, value] of Object.entries(payloadValues)) {
137 const hsField = fieldMap[formField] ?? formField
138 hubspotFields[hsField] = value
139 }
140 consent = crm.hubspot.consent
141 debug('HubSpot payload', { hubspotFields, consent, context })
142 }
143
144 // Build Customer.io profile attributes from the profileMap
145 let customerioProfile: Record<string, unknown> | undefined
146 if (crm.customerio?.profileMap) {
147 customerioProfile = {}
148 for (const [formField, attrName] of Object.entries(crm.customerio.profileMap)) {
149 customerioProfile[attrName] = payloadValues[formField]
150 }
151 }
152 if (crm.customerio) {
153 debug('Customer.io payload', {
154 event: crm.customerio.event,
155 properties: payloadValues,
156 customerioProfile,
157 })
158 }
159
160 // Build Notion page properties: map form fields via columnMap, then merge staticProperties
161 let notion: { databaseId: string; properties: Record<string, unknown> } | undefined
162 if (crm.notion) {
163 const columnMap = crm.notion.columnMap ?? {}
164 const properties: Record<string, unknown> = {}
165 for (const [formField, columnName] of Object.entries(columnMap)) {
166 if (formField in payloadValues) {
167 properties[columnName] = payloadValues[formField]
168 }
169 }
170 if (crm.notion.staticProperties) {
171 Object.assign(properties, crm.notion.staticProperties)
172 }
173 notion = { databaseId: crm.notion.database_id, properties }
174 debug('Notion payload', { databaseId: crm.notion.database_id, properties })
175 }
176
177 const { errors } = await client.submitEvent({
178 email,
179 hubspotFields,
180 context: context && { pageUri: context.pageUri, pageName: context.pageName },
181 consent,
182 event: crm.customerio?.event,
183 properties: crm.customerio
184 ? { ...(payloadValues as Record<string, unknown>), ...crm.customerio.staticProperties }
185 : undefined,
186 customerioProfile,
187 notion,
188 })
189
190 if (errors.length > 0) {
191 debug(
192 'CRM submission errors',
193 errors.map((e) => e.message)
194 )
195 return { success: false, errors: errors.map((e) => e.message) }
196 }
197
198 debug('Submission successful')
199 return { success: true, errors: [] }
200 } catch (err: any) {
201 // Catch any unexpected error so the client always gets a FormSubmitResult
202 console.error('[go/form] Unexpected error during form submission:', err)
203 return {
204 success: false,
205 errors: [
206 isDebug ? `Unexpected error: ${err.message}` : 'Something went wrong. Please try again.',
207 ],
208 }
209 }
210}