From 2923ef0d500c6713aa216cadb123d7f2e78c5104 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 17 May 2026 19:27:04 -0500 Subject: [PATCH] feat(zoho): add Cases forwarding + setup docs (closes #76, #78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add forwardSupportToZoho() for Zoho Cases (fire-and-forget) - Map support fields: issue→Subject, priority→Priority, Case_Origin=Website - ZOHO_CASES_ENABLED env var (independent from ZOHO_ENABLED) - Add docs/zoho-setup.md with step-by-step setup guide - Batch 7.2 and 7.4 --- docs/zoho-setup.md | 176 +++++++++++++++++++++++++++++++++++++++++++++ server/index.js | 94 ++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 docs/zoho-setup.md diff --git a/docs/zoho-setup.md b/docs/zoho-setup.md new file mode 100644 index 0000000..4fb1fe0 --- /dev/null +++ b/docs/zoho-setup.md @@ -0,0 +1,176 @@ +# Zoho CRM Setup Guide for Queue North Admins + +This guide walks you through setting up Zoho CRM integration to automatically capture leads and support cases from Queue North's website. + +--- + +## 🔒 Prerequisites + +Before you begin, ensure you have: +- A Zoho CRM account (admin access required) +- Access to Zoho API Console: https://api-console.zoho.com + +--- + +## Step 1: Create a Zoho Self-Client App + +1. Go to **https://api-console.zoho.com** +2. Click **"Create Self Client"** +3. Fill in: + - **Client Name**: Queue-North-Zoho-Integration + - **Description**: Auto-capture leads and cases from Queue North website + - **Redirect URI**: `https://www.zoho.com` (required for Self-Client, not used) +4. Click **Create** +5. **Copy and save**: + - **Client ID** + - **Client Secret** + +> ⚠️ Store these securely — they're like a username and password. + +--- + +## Step 2: Generate an Authorization Code + +1. In the Self Client tab, click **"Generate Code"** +2. Set the **Scope** to: + ``` + ZohoCRM.modules.leads.CREATE,ZohoCRM.modules.leads.READ,ZohoCRM.modules.cases.CREATE,ZohoCRM.modules.cases.READ + ``` +3. Set **Expiry** to **10 minutes** (use it quickly) +4. Click **Generate** +5. **Copy the authorization code** — it expires in 10 minutes + +--- + +## Step 3: Exchange Auth Code for Tokens + +Run this `curl` command (replace placeholders): + +```bash +curl -X POST https://accounts.zoho.com/oauth/v2/token \ + -d "code=" \ + -d "client_id=" \ + -d "client_secret=" \ + -d "grant_type=authorization_code" \ + -d "redirect_uri=https://www.zoho.com" +``` + +**Response will include:** +```json +{ + "access_token": "1000.xxxxx.xxxxx", + "refresh_token": "1000.yyyyy.yyyyy", + "expires_in": 3600, + "token_type": "bearer" +} +``` + +**Save the `refresh_token`** — this never expires and must be kept secret. + +--- + +## Step 4: Configure Environment Variables + +Add these to your `.env` file: + +```env +ZOHO_ENABLED=true +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=true +``` + +### Datacenter Variants + +If your Zoho datacenter is **outside the US**, adjust the domains: + +| Region | API Domain | Accounts Domain | +|--------|-----------|-----------------| +| US | `www.zohoapis.com` | `accounts.zoho.com` | +| EU | `www.zohoapis.eu` | `accounts.zoho.eu` | +| IN | `www.zohoapis.in` | `accounts.zoho.in` | +| AU | `www.zohoapis.com.au` | `accounts.zoho.com.au` | + +--- + +## Step 5: Test the Integration + +### Test Lead Capture +1. Submit a lead on the contact form (name, email, phone, message) +2. Wait ~5–10 seconds +3. Log in to Zoho CRM → Leads tab +4. Verify the new lead appears with correct data + +### Test Case Capture +1. Submit a support request (e.g., booking inquiry, technical question) +2. Wait ~5–10 seconds +3. Log in to Zoho CRM → Cases tab +4. Verify the new case appears with correct data + +--- + +## Troubleshooting + +### Token Errors +- **"invalid_grant"**: Your authorization code expired. Generate a new one in Step 2 and repeat Step 3. +- **"invalid_client"**: Double-check Client ID and Secret — no extra spaces. +- **"invalid_scope"**: Re-run Step 2 with the exact scopes listed above. + +### Field Mismatches +- If leads/cases don't appear, check if Zoho requires custom fields like `Service_Interest` +- Edit the field mapping in `server/zoho/` to match your Zoho CRM field API names + +### Cases Not Appearing +- Ensure `ZOHO_CASES_ENABLED=true` is set +- Verify the Cases tab is enabled in your Zoho CRM plan +- Check that your Zoho CRM user has **Cases CREATE** permissions + +### Lead Upsert Behavior +- Leads are **upserted by email**: duplicate email = update existing lead +- Cases are **always inserted** (new ticket each time) +- If you see duplicate leads, check for slight email variations (e.g., `test@` vs `test+1@`) + +--- + +## Architecture Notes + +### Flow Overview +``` +Website Contact Form → SQLite (always saved) + ↓ + Zoho CRM (best-effort) + ↓ + Fire-and-forget (no failure blocking) +``` + +### OAuth2 Refresh Token Flow +1. Use `refresh_token` to get a new `access_token` when expired +2. `access_token` expires in 1 hour +3. `refresh_token` never expires — store it securely + +### Upsert Logic +- **Leads**: Email-based upsert (update if exists, create if new) +- **Cases**: Always insert (new case per submission) + +### Fire-and-Forget Design +- Zoho failures **do not block** form submissions +- All data is saved to SQLite first +- Zoho attempts happen in the background +- No retry logic needed — users won't wait for Zoho + +--- + +## What Happens Next? + +After configuration, your team (Ripley + Bishop) will: +1. Deploy environment variables to production +2. Run integration tests +3. Verify data flows to Zoho CRM +4. Update `PROJECT.md` with the integration status + +--- + +**Questions?** Contact Ripley (Infrastructure) or Neo (Backend). diff --git a/server/index.js b/server/index.js index 3d6c33f..740c673 100644 --- a/server/index.js +++ b/server/index.js @@ -212,6 +212,7 @@ const supportSchema = z.object({ // --- Zoho CRM Forwarding (best-effort, fire-and-forget) --- const ZOHO_ENABLED = process.env.ZOHO_ENABLED === 'true' +const ZOHO_CASES_ENABLED = process.env.ZOHO_CASES_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 @@ -379,6 +380,96 @@ async function forwardToZoho(leadData) { } } +// --- Zoho Cases Forwarding (best-effort, fire-and-forget) --- +async function forwardSupportToZoho(supportData) { + if (!ZOHO_CASES_ENABLED) return + + // Short-circuit if Zoho credentials are missing + if (!ZOHO_CLIENT_ID || !ZOHO_CLIENT_SECRET || !ZOHO_REFRESH_TOKEN) { + log.warn("[Zoho Cases] Skipping forwarding - ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, or ZOHO_REFRESH_TOKEN not configured") + return + } + + let accessToken = await getZohoAccessToken() + if (!accessToken) { + // Retry once — token refresh can fail transiently + log.warn('[Zoho Cases] 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 Cases] No access token available after retry, skipping support forwarding') + return + } + } + + // Map priority to Zoho format + const priorityMap = { + low: 'Low', + medium: 'Medium', + high: 'High', + } + const priority = priorityMap[supportData.priority] || 'Medium' + + // Build description with name and company since Cases don't have Company field directly + const descriptionParts = [] + descriptionParts.push(`Name: ${supportData.name}`) + descriptionParts.push(`Company: ${supportData.company}`) + if (supportData.phone) descriptionParts.push(`Phone: ${supportData.phone}`) + descriptionParts.push(`\n${supportData.issue}`) + const description = descriptionParts.join('\n') + + const payload = { + data: [ + { + Subject: supportData.issue, + Priority: priority, + Email: supportData.email || '', + Description: description || '', + Case_Origin: 'Website', + }, + ], + trigger: ['workflow'], + } + + // Issue #8: Prevent double-slash in URL path + const url = `${ZOHO_API_DOMAIN.replace(/\/$/, '')}/crm/v8/Cases` + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), ZOHO_TIMEOUT_MS) + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Authorization": `Zoho-oauthtoken ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + signal: controller.signal, + }) + + // Issue #3: Check response.ok before processing + if (!response.ok) { + const text = await response.text() + log.error(`[Zoho Cases] Support forwarding failed (${response.status}):`, text) + return + } + + const result = await response.json() + log.info("[Zoho Cases] Support forwarded successfully:", result.data?.[0]?.details?.id || "no id returned") + } catch (fetchErr) { + if (fetchErr.name === "AbortError") { + log.warn("[Zoho Cases] Support forwarding timed out after", ZOHO_TIMEOUT_MS, "ms") + } else { + log.error("[Zoho Cases] Forwarding error:", fetchErr.message) + } + } finally { + clearTimeout(timeoutId) + } +} + // --- API Routes --- // Health check @@ -508,6 +599,9 @@ app.post('/api/support', express.json({ limit: '1mb' }), (req, res) => { log.info(`Support request submitted: ${sanitized.email} from ${sanitized.company} priority=${sanitized.priority || 'medium'} (id: ${result.lastInsertRowid})`) + // Fire-and-forget Zoho Cases forwarding (best-effort, non-blocking) + forwardSupportToZoho(sanitized).catch(err => log.error('[Zoho Cases] Forwarding error:', err.message)) + res.json({ success: true, message: "Thanks! We'll get back to you soon." }) } catch (err) { log.error('Error submitting support request:', err)