- 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
This commit is contained in:
parent
debde23ab7
commit
2923ef0d50
|
|
@ -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=<YOUR_AUTH_CODE>" \
|
||||||
|
-d "client_id=<YOUR_CLIENT_ID>" \
|
||||||
|
-d "client_secret=<YOUR_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=<from Step 1>
|
||||||
|
ZOHO_CLIENT_SECRET=<from Step 1>
|
||||||
|
ZOHO_REFRESH_TOKEN=<from Step 3>
|
||||||
|
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).
|
||||||
|
|
@ -212,6 +212,7 @@ 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_CASES_ENABLED = process.env.ZOHO_CASES_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_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
|
||||||
|
|
@ -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 ---
|
// --- API Routes ---
|
||||||
|
|
||||||
// Health check
|
// 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})`)
|
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." })
|
res.json({ success: true, message: "Thanks! We'll get back to you soon." })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('Error submitting support request:', err)
|
log.error('Error submitting support request:', err)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue