- Delete broken barrel exports ui/index.jsx and ui/all.jsx (#53) - Remove duplicate QueryClient instance and dead queryClient.js (#55) - Remove unused queryClient import/export from api.js (#55) - Move timeoutMiddleware before catch-all routes so it actually fires (#54) - Fix async error handling in forwardToZoho - add .catch() (#56) - Add ZOHO_CLIENT_ID to credential guard, normalize defaults to null (#57) (batch 0.6.4)
This commit is contained in:
parent
9cdc299ade
commit
4f3e20b7a0
121
server/index.js
121
server/index.js
|
|
@ -213,7 +213,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_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_CLIENT_ID = process.env.ZOHO_CLIENT_ID || ''
|
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 || ''
|
const ZOHO_REDIRECT_URI = process.env.ZOHO_REDIRECT_URI || ''
|
||||||
|
|
@ -291,69 +291,65 @@ async function getZohoAccessToken() {
|
||||||
async function forwardToZoho(leadData) {
|
async function forwardToZoho(leadData) {
|
||||||
if (!ZOHO_ENABLED) return
|
if (!ZOHO_ENABLED) return
|
||||||
|
|
||||||
// Issue #2: Short-circuit if Zoho credentials are missing
|
// Short-circuit if Zoho credentials are missing
|
||||||
if (!ZOHO_CLIENT_SECRET || !ZOHO_REFRESH_TOKEN) {
|
if (!ZOHO_CLIENT_ID || !ZOHO_CLIENT_SECRET || !ZOHO_REFRESH_TOKEN) {
|
||||||
log.warn('[Zoho] Skipping forwarding - ZOHO_CLIENT_SECRET or ZOHO_REFRESH_TOKEN not configured')
|
log.warn("[Zoho] Skipping forwarding - ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, or ZOHO_REFRESH_TOKEN not configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accessToken = await getZohoAccessToken()
|
||||||
|
if (!accessToken) {
|
||||||
|
log.warn("[Zoho] No access token available, skipping lead forwarding")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue #8: Prevent double-slash in URL path
|
||||||
|
const url = `${ZOHO_API_DOMAIN.replace(/\/$/, "")}/crm/v8/Leads`
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), ZOHO_TIMEOUT_MS)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accessToken = await getZohoAccessToken()
|
const response = await fetch(url, {
|
||||||
if (!accessToken) {
|
method: "POST",
|
||||||
log.warn('[Zoho] No access token available, skipping lead forwarding')
|
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] Lead forwarding failed (${response.status}):`, text)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue #8: Prevent double-slash in URL path
|
const result = await response.json()
|
||||||
const url = `${ZOHO_API_DOMAIN.replace(/\/$/, '')}/crm/v8/Leads`
|
log.info("[Zoho] Lead forwarded successfully:", result.data?.[0]?.details?.id || "no id returned")
|
||||||
const payload = {
|
} catch (fetchErr) {
|
||||||
data: [
|
if (fetchErr.name === "AbortError") {
|
||||||
{
|
log.warn("[Zoho] Lead forwarding timed out after", ZOHO_TIMEOUT_MS, "ms")
|
||||||
Company: leadData.company || '',
|
} else {
|
||||||
Last_Name: leadData.name || 'Unknown',
|
log.error("[Zoho] Forwarding error:", fetchErr.message)
|
||||||
Email: leadData.email || '',
|
|
||||||
Phone: leadData.phone || '',
|
|
||||||
Zip_Code: leadData.zip || '',
|
|
||||||
Description: leadData.message || '',
|
|
||||||
Service_Interest: leadData.service_interest || null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
const controller = new AbortController()
|
clearTimeout(timeoutId)
|
||||||
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] Lead forwarding failed (${response.status}):`, text)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
log.info('[Zoho] Lead forwarded successfully:', result.data?.[0]?.details?.id || 'no id returned')
|
|
||||||
} catch (fetchErr) {
|
|
||||||
if (fetchErr.name === 'AbortError') {
|
|
||||||
log.warn('[Zoho] Lead forwarding timed out after', ZOHO_TIMEOUT_MS, 'ms')
|
|
||||||
} else {
|
|
||||||
log.error('[Zoho] Forwarding error:', fetchErr.message)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log.error('[Zoho] Forwarding error:', err.message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,7 +414,7 @@ app.post('/api/leads', express.json({ limit: '1mb' }), (req, res) => {
|
||||||
log.info(`Lead submitted: ${sanitized.email} from ${sanitized.company} (id: ${result.lastInsertRowid})`)
|
log.info(`Lead submitted: ${sanitized.email} from ${sanitized.company} (id: ${result.lastInsertRowid})`)
|
||||||
|
|
||||||
// Fire-and-forget Zoho forwarding (best-effort, non-blocking)
|
// Fire-and-forget Zoho forwarding (best-effort, non-blocking)
|
||||||
forwardToZoho(sanitized)
|
forwardToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message))
|
||||||
|
|
||||||
res.json({ success: true, message: "Thanks! We'll be in touch shortly." })
|
res.json({ success: true, message: "Thanks! We'll be in touch shortly." })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -428,11 +424,7 @@ app.post('/api/leads', express.json({ limit: '1mb' }), (req, res) => {
|
||||||
log.warn(`Duplicate lead email: ${sanitized.email}`)
|
log.warn(`Duplicate lead email: ${sanitized.email}`)
|
||||||
|
|
||||||
// Still forward to Zoho (non-blocking) for existing leads
|
// Still forward to Zoho (non-blocking) for existing leads
|
||||||
try {
|
forwardToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message))
|
||||||
forwardToZoho(sanitized)
|
|
||||||
} catch (zohoErr) {
|
|
||||||
log.warn(`[Zoho] Skipped forwarding for duplicate lead: ${sanitized.email}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'Duplicate lead',
|
error: 'Duplicate lead',
|
||||||
|
|
@ -534,8 +526,6 @@ const timeoutMiddleware = (req, res, next) => {
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(timeoutMiddleware)
|
|
||||||
|
|
||||||
// --- Global error handlers ---
|
// --- Global error handlers ---
|
||||||
process.on('uncaughtException', (err) => {
|
process.on('uncaughtException', (err) => {
|
||||||
log.error('Uncaught exception:', err.message)
|
log.error('Uncaught exception:', err.message)
|
||||||
|
|
@ -554,6 +544,9 @@ process.on('unhandledRejection', (reason, promise) => {
|
||||||
// --- Start Server ---
|
// --- Start Server ---
|
||||||
const PORT = process.env.SERVER_PORT || 3001
|
const PORT = process.env.SERVER_PORT || 3001
|
||||||
|
|
||||||
|
// Register timeout middleware BEFORE catch-all routes
|
||||||
|
app.use(timeoutMiddleware)
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
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`)
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { Button } from './Button.jsx'
|
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card.jsx'
|
|
||||||
import { Input } from './Input.jsx'
|
|
||||||
import { Textarea } from './Textarea.jsx'
|
|
||||||
import { Select } from './Select.jsx'
|
|
||||||
import { Badge } from './Badge.jsx'
|
|
||||||
import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetClose, SheetFooter, SheetOverlay } from './Sheet.jsx'
|
|
||||||
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from './Dialog.jsx'
|
|
||||||
|
|
||||||
export {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
Input,
|
|
||||||
Textarea,
|
|
||||||
Select,
|
|
||||||
Badge,
|
|
||||||
Sheet,
|
|
||||||
SheetTrigger,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetDescription,
|
|
||||||
SheetClose,
|
|
||||||
SheetFooter,
|
|
||||||
SheetOverlay,
|
|
||||||
Dialog,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogClose,
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
export { default as Button } from './Button.jsx'
|
|
||||||
export { default as Card } from './Card.jsx'
|
|
||||||
export { default as CardContent } from './CardContent.jsx'
|
|
||||||
export { default as CardHeader } from './CardHeader.jsx'
|
|
||||||
export { default as CardTitle } from './CardTitle.jsx'
|
|
||||||
export { default as CardDescription } from './CardDescription.jsx'
|
|
||||||
export { default as Input } from './Input.jsx'
|
|
||||||
export { default as Textarea } from './Textarea.jsx'
|
|
||||||
export { default as Select } from './Select.jsx'
|
|
||||||
export { default as Badge } from './Badge.jsx'
|
|
||||||
export { default as Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetClose, SheetFooter, SheetOverlay } from './Sheet.jsx'
|
|
||||||
export { default as Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from './Dialog.jsx'
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { queryClient } from './queryClient'
|
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||||
|
|
||||||
// Exponential backoff retry helper
|
// Exponential backoff retry helper
|
||||||
|
|
@ -92,5 +90,3 @@ export const api = {
|
||||||
}, { maxRetries: 3, baseDelay: 1000 })
|
}, { maxRetries: 3, baseDelay: 1000 })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export { queryClient }
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { QueryClient } from '@tanstack/react-query'
|
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Loading…
Reference in New Issue