From debde23ab7081956091f649fdf6b1c90dd733355 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 17 May 2026 18:37:10 -0500 Subject: [PATCH] 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 --- .env.example | 11 +++++++++ Dockerfile | 3 ++- server/index.js | 59 ++++++++++++++++++++++++++++++++++++------------- 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 53184fe..1c8b84f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index 560edb6..c08d22c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/server/index.js b/server/index.js index 52b9830..3d6c33f 100644 --- a/server/index.js +++ b/server/index.js @@ -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)') }