feat: Zoho CRM forwarding layer with OAuth2 token management (v0.4.6)

This commit is contained in:
null 2026-05-13 18:28:56 -05:00
parent 4ac0fa250d
commit 6bfd804313
3 changed files with 97 additions and 2 deletions

View File

@ -120,7 +120,7 @@ Version numbers correlate directly to the active phase:
- ~~SQLite persistence verified~~
- ~~Client-side validation + Sonner feedback~~
- ~~Server-side validation + input sanitization~~
- Optional Zoho forwarding layer
- ~~Optional Zoho forwarding layer~~ ✅
- Backend/API hardening as needed
- **Phase 5 — Verification + Release Readiness**: `0.5.x`

View File

@ -1,7 +1,7 @@
{
"name": "queuenorth-website",
"private": true,
"version": "0.4.5",
"version": "0.4.6",
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"node server/index.js\"",

View File

@ -105,6 +105,98 @@ const supportSchema = z.object({
}).transform((val) => val?.toLowerCase() ?? undefined).optional().or(z.literal('').transform(() => undefined)),
})
// --- Zoho CRM Forwarding (best-effort, fire-and-forget) ---
const ZOHO_ENABLED = process.env.ZOHO_ENABLED === 'true'
const ZOHO_API_DOMAIN = process.env.ZOHO_API_DOMAIN || 'https://www.zohoapis.com'
const ZOHO_CLIENT_ID = process.env.ZOHO_CLIENT_ID || ''
const ZOHO_CLIENT_SECRET = process.env.ZOHO_CLIENT_SECRET || ''
const ZOHO_REFRESH_TOKEN = process.env.ZOHO_REFRESH_TOKEN || ''
const ZOHO_REDIRECT_URI = process.env.ZOHO_REDIRECT_URI || ''
// In-memory access token cache
let zohoAccessToken = null
let zohoTokenExpiry = 0
async function getZohoAccessToken() {
// Return cached token if still valid (with 60s buffer)
if (zohoAccessToken && Date.now() < zohoTokenExpiry - 60000) {
return zohoAccessToken
}
try {
const url = `${ZOHO_API_DOMAIN}/oauth/v2/token`
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: ZOHO_CLIENT_ID,
client_secret: ZOHO_CLIENT_SECRET,
refresh_token: ZOHO_REFRESH_TOKEN,
redirect_uri: ZOHO_REDIRECT_URI,
})
const response = await fetch(`${url}?${params.toString()}`, { method: 'POST' })
const data = await response.json()
if (data.access_token) {
zohoAccessToken = data.access_token
zohoTokenExpiry = Date.now() + (data.expires_in || 3600) * 1000
console.log('[Zoho] Access token acquired, expires in', data.expires_in || 3600, 'seconds')
return zohoAccessToken
} else {
console.error('[Zoho] Token exchange failed:', JSON.stringify(data))
return null
}
} catch (err) {
console.error('[Zoho] Token acquisition error:', err.message)
return null
}
}
async function forwardToZoho(leadData) {
if (!ZOHO_ENABLED) return
try {
const accessToken = await getZohoAccessToken()
if (!accessToken) {
console.error('[Zoho] No access token available, skipping lead forwarding')
return
}
const url = `${ZOHO_API_DOMAIN}/crm/v8/Leads`
const payload = {
data: [
{
Company: leadData.company || '',
Last_Name: leadData.name || 'Unknown',
Email: leadData.email || '',
Phone: leadData.phone || '',
Zip_Code: leadData.zip || '',
Description: leadData.message || '',
Service_Interest: leadData.service_interest || '',
},
],
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Zoho-oauthtoken ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (response.ok) {
const result = await response.json()
console.log('[Zoho] Lead forwarded successfully:', result.data?.[0]?.details?.id || 'no id returned')
} else {
const text = await response.text()
console.error(`[Zoho] Lead forwarding failed (${response.status}):`, text)
}
} catch (err) {
console.error('[Zoho] Forwarding error:', err.message)
}
}
// --- API Routes ---
// Health check
@ -156,6 +248,9 @@ app.post('/api/leads', (req, res) => {
sanitized.service_interest || null
)
// Fire-and-forget Zoho forwarding (best-effort, non-blocking)
forwardToZoho(sanitized)
res.json({ success: true, message: 'Thanks! We\'ll be in touch shortly.' })
} catch (err) {
console.error('Error submitting lead:', err)