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:
parent
f1823bcc4b
commit
debde23ab7
11
.env.example
11
.env.example
|
|
@ -3,3 +3,14 @@
|
||||||
|
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
SERVER_PORT=3001
|
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
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,11 @@ ENV CORS_ORIGIN=*
|
||||||
ENV LOG_LEVEL=info
|
ENV LOG_LEVEL=info
|
||||||
ENV ZOHO_ENABLED=false
|
ENV ZOHO_ENABLED=false
|
||||||
ENV ZOHO_API_DOMAIN=https://www.zohoapis.com
|
ENV ZOHO_API_DOMAIN=https://www.zohoapis.com
|
||||||
|
ENV ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
|
||||||
ENV ZOHO_CLIENT_ID=
|
ENV ZOHO_CLIENT_ID=
|
||||||
ENV ZOHO_CLIENT_SECRET=
|
ENV ZOHO_CLIENT_SECRET=
|
||||||
ENV ZOHO_REFRESH_TOKEN=
|
ENV ZOHO_REFRESH_TOKEN=
|
||||||
ENV ZOHO_REDIRECT_URI=
|
ENV ZOHO_CASES_ENABLED=false
|
||||||
|
|
||||||
# Create app directory structure
|
# Create app directory structure
|
||||||
RUN mkdir -p /app/db /app/logs
|
RUN mkdir -p /app/db /app/logs
|
||||||
|
|
|
||||||
|
|
@ -213,10 +213,10 @@ const supportSchema = z.object({
|
||||||
// --- Zoho CRM Forwarding (best-effort, fire-and-forget) ---
|
// --- Zoho CRM Forwarding (best-effort, fire-and-forget) ---
|
||||||
const ZOHO_ENABLED = process.env.ZOHO_ENABLED === 'true'
|
const ZOHO_ENABLED = process.env.ZOHO_ENABLED === 'true'
|
||||||
const ZOHO_API_DOMAIN = process.env.ZOHO_API_DOMAIN || 'https://www.zohoapis.com'
|
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_ID = process.env.ZOHO_CLIENT_ID || null
|
||||||
const ZOHO_CLIENT_SECRET = process.env.ZOHO_CLIENT_SECRET || null
|
const ZOHO_CLIENT_SECRET = process.env.ZOHO_CLIENT_SECRET || null
|
||||||
const ZOHO_REFRESH_TOKEN = process.env.ZOHO_REFRESH_TOKEN || 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
|
// In-memory access token cache
|
||||||
let zohoAccessToken = null
|
let zohoAccessToken = null
|
||||||
|
|
@ -232,13 +232,14 @@ async function getZohoAccessToken() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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({
|
const params = new URLSearchParams({
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
client_id: ZOHO_CLIENT_ID,
|
client_id: ZOHO_CLIENT_ID,
|
||||||
client_secret: ZOHO_CLIENT_SECRET,
|
client_secret: ZOHO_CLIENT_SECRET,
|
||||||
refresh_token: ZOHO_REFRESH_TOKEN,
|
refresh_token: ZOHO_REFRESH_TOKEN,
|
||||||
redirect_uri: ZOHO_REDIRECT_URI,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
@ -297,26 +298,51 @@ async function forwardToZoho(leadData) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await getZohoAccessToken()
|
let accessToken = await getZohoAccessToken()
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
log.warn("[Zoho] No access token available, skipping lead forwarding")
|
// Retry once — token refresh can fail transiently
|
||||||
return
|
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
|
// 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 = {
|
const payload = {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
Company: leadData.company || "",
|
First_Name: firstName || undefined,
|
||||||
Last_Name: leadData.name || "Unknown",
|
Last_Name: lastName,
|
||||||
Email: leadData.email || "",
|
Company: leadData.company || '',
|
||||||
Phone: leadData.phone || "",
|
Email: leadData.email || '',
|
||||||
Zip_Code: leadData.zip || "",
|
Phone: leadData.phone || '',
|
||||||
Description: leadData.message || "",
|
Zip_Code: leadData.zip || '',
|
||||||
Service_Interest: leadData.service_interest || null,
|
Description: description || '',
|
||||||
|
Lead_Source: 'Website',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
duplicate_check_fields: ['Email'],
|
||||||
|
trigger: ['workflow'],
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
@ -552,7 +578,10 @@ app.listen(PORT, () => {
|
||||||
log.info(`Server running on http://localhost:${PORT}`)
|
log.info(`Server running on http://localhost:${PORT}`)
|
||||||
log.info(`Health check: http://localhost:${PORT}/api/health`)
|
log.info(`Health check: http://localhost:${PORT}/api/health`)
|
||||||
if (ZOHO_ENABLED) {
|
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 {
|
} else {
|
||||||
log.info('Zoho CRM forwarding: DISABLED (set ZOHO_ENABLED=true to enable)')
|
log.info('Zoho CRM forwarding: DISABLED (set ZOHO_ENABLED=true to enable)')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue