fix(zoho): fix OAuth token endpoint, improve lead field mapping, add upsert

- Fix critical bug: token refresh now uses ZOHO_ACCOUNTS_DOMAIN
  (accounts.zoho.com) instead of API domain (www.zohoapis.com).
  The OAuth token endpoint lives on a different domain.
- Remove unnecessary redirect_uri from refresh token request
- Add ZOHO_ACCOUNTS_DOMAIN env var (separate from API domain)
- Split contact name into First_Name/Last_Name for Zoho schema
- Replace Service_Interest (non-standard field) with Description
  + Lead_Source: Website (standard picklist value)
- Switch from Insert to Upsert API with duplicate_check_fields:
  [Email] so duplicate submissions update instead of error
- Add trigger: ['workflow'] for explicit workflow control
- Add token refresh retry (1 retry on transient failure)
- Add ZOHO_CASES_ENABLED env var for future Cases forwarding
- Update .env.example with full Zoho config documentation
- Update FUTURE.md with detailed Phase 7 Zoho integration plan
- Remove obsolete ZOHO_REDIRECT_URI from Dockerfile
This commit is contained in:
null 2026-05-17 18:37:10 -05:00
parent f1823bcc4b
commit debde23ab7
3 changed files with 57 additions and 16 deletions

View File

@ -3,3 +3,14 @@
NODE_ENV=production
SERVER_PORT=3001
# Zoho CRM Integration
# Set ZOHO_ENABLED=true to forward leads/support to Zoho CRM
# Get credentials from https://api-console.zoho.com → Self Client
ZOHO_ENABLED=false
ZOHO_API_DOMAIN=https://www.zohoapis.com
ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
ZOHO_CLIENT_ID=
ZOHO_CLIENT_SECRET=
ZOHO_REFRESH_TOKEN=
ZOHO_CASES_ENABLED=false

View File

@ -45,10 +45,11 @@ ENV CORS_ORIGIN=*
ENV LOG_LEVEL=info
ENV ZOHO_ENABLED=false
ENV ZOHO_API_DOMAIN=https://www.zohoapis.com
ENV ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
ENV ZOHO_CLIENT_ID=
ENV ZOHO_CLIENT_SECRET=
ENV ZOHO_REFRESH_TOKEN=
ENV ZOHO_REDIRECT_URI=
ENV ZOHO_CASES_ENABLED=false
# Create app directory structure
RUN mkdir -p /app/db /app/logs

View File

@ -213,10 +213,10 @@ const supportSchema = z.object({
// --- 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_ACCOUNTS_DOMAIN = process.env.ZOHO_ACCOUNTS_DOMAIN || 'https://accounts.zoho.com'
const ZOHO_CLIENT_ID = process.env.ZOHO_CLIENT_ID || null
const ZOHO_CLIENT_SECRET = process.env.ZOHO_CLIENT_SECRET || null
const ZOHO_REFRESH_TOKEN = process.env.ZOHO_REFRESH_TOKEN || null
const ZOHO_REDIRECT_URI = process.env.ZOHO_REDIRECT_URI || ''
// In-memory access token cache
let zohoAccessToken = null
@ -232,13 +232,14 @@ async function getZohoAccessToken() {
}
try {
const url = `${ZOHO_API_DOMAIN}/oauth/v2/token`
// Token endpoint is on the ACCOUNTS domain, NOT the API domain
// US: accounts.zoho.com | EU: accounts.zoho.eu | IN: accounts.zoho.in
const url = `${ZOHO_ACCOUNTS_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 controller = new AbortController()
@ -297,26 +298,51 @@ async function forwardToZoho(leadData) {
return
}
const accessToken = await getZohoAccessToken()
let accessToken = await getZohoAccessToken()
if (!accessToken) {
log.warn("[Zoho] No access token available, skipping lead forwarding")
return
// Retry once — token refresh can fail transiently
log.warn('[Zoho] First token refresh failed, retrying...')
// Clear cached token to force a fresh attempt
zohoAccessToken = null
zohoTokenExpiry = 0
accessToken = await getZohoAccessToken()
if (!accessToken) {
log.warn('[Zoho] No access token available after retry, skipping lead forwarding')
return
}
}
// Issue #8: Prevent double-slash in URL path
const url = `${ZOHO_API_DOMAIN.replace(/\/$/, "")}/crm/v8/Leads`
// Use upsert to handle duplicates gracefully (insert new or update existing by email)
const url = `${ZOHO_API_DOMAIN.replace(/\/$/, "")}/crm/v8/Leads/upsert`
// Split full name into First_Name / Last_Name for Zoho
// Zoho requires Last_Name (mandatory), First_Name is optional
const nameParts = (leadData.name || '').trim().split(/\s+/)
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : (nameParts[0] || 'Unknown')
const firstName = nameParts.length > 1 ? nameParts[0] : ''
// Build Description with service interest appended for Zoho visibility
const descriptionParts = []
if (leadData.message) descriptionParts.push(leadData.message)
if (leadData.service_interest) descriptionParts.push(`Service Interest: ${leadData.service_interest}`)
const description = descriptionParts.join('\n\n')
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 || null,
First_Name: firstName || undefined,
Last_Name: lastName,
Company: leadData.company || '',
Email: leadData.email || '',
Phone: leadData.phone || '',
Zip_Code: leadData.zip || '',
Description: description || '',
Lead_Source: 'Website',
},
],
duplicate_check_fields: ['Email'],
trigger: ['workflow'],
}
const controller = new AbortController()
@ -552,7 +578,10 @@ app.listen(PORT, () => {
log.info(`Server running on http://localhost:${PORT}`)
log.info(`Health check: http://localhost:${PORT}/api/health`)
if (ZOHO_ENABLED) {
log.info(`Zoho CRM forwarding: ENABLED (domain: ${ZOHO_API_DOMAIN})`)
log.info(`Zoho CRM forwarding: ENABLED`)
log.info(`Zoho API domain: ${ZOHO_API_DOMAIN}`)
log.info(`Zoho Accounts domain: ${ZOHO_ACCOUNTS_DOMAIN}`)
log.info(`Zoho Cases forwarding: ${process.env.ZOHO_CASES_ENABLED === 'true' ? 'ENABLED' : 'DISABLED'}`)
} else {
log.info('Zoho CRM forwarding: DISABLED (set ZOHO_ENABLED=true to enable)')
}