2026-05-12 01:04:17 -05:00
import express from 'express'
import path from 'path'
import { fileURLToPath } from 'url'
2026-05-17 21:34:39 -05:00
import { existsSync , mkdirSync } from 'fs'
2026-05-12 01:04:17 -05:00
import sqlite3 from 'better-sqlite3'
import z from 'zod'
2026-05-13 18:37:32 -05:00
import rateLimit from 'express-rate-limit'
import helmet from 'helmet'
import cors from 'cors'
2026-05-12 01:04:17 -05:00
// --- Setup ---
const _ _filename = fileURLToPath ( import . meta . url )
const _ _dirname = path . dirname ( _ _filename )
const app = express ( )
2026-05-13 18:37:32 -05:00
// Trust first proxy (Docker/reverse proxy) for correct client IP in rate limiting
app . set ( 'trust proxy' , 1 )
2026-05-12 01:04:17 -05:00
const dbPath = path . join ( _ _dirname , '../db/queuenorth.db' )
2026-05-12 01:57:55 -05:00
const dbDir = path . dirname ( dbPath )
2026-05-12 01:04:17 -05:00
// Create db directory if it doesn't exist
2026-05-12 01:57:55 -05:00
if ( ! existsSync ( dbDir ) ) {
mkdirSync ( dbDir , { recursive : true } )
2026-05-12 01:04:17 -05:00
}
2026-05-13 18:31:52 -05:00
// --- Logger ---
const LOG _LEVELS = { error : 0 , warn : 1 , info : 2 , debug : 3 }
const currentLevel = LOG _LEVELS [ process . env . LOG _LEVEL ? . toLowerCase ( ) ] ? ? LOG _LEVELS . info
const log = {
info : ( ... args ) => { if ( currentLevel >= LOG _LEVELS . info ) console . log ( ` [ ${ new Date ( ) . toISOString ( ) } ] INFO ` , ... args ) } ,
warn : ( ... args ) => { if ( currentLevel >= LOG _LEVELS . warn ) console . warn ( ` [ ${ new Date ( ) . toISOString ( ) } ] WARN ` , ... args ) } ,
error : ( ... args ) => { if ( currentLevel >= LOG _LEVELS . error ) console . error ( ` [ ${ new Date ( ) . toISOString ( ) } ] ERROR ` , ... args ) } ,
debug : ( ... args ) => { if ( currentLevel >= LOG _LEVELS . debug ) console . debug ( ` [ ${ new Date ( ) . toISOString ( ) } ] DEBUG ` , ... args ) } ,
}
2026-05-13 18:37:32 -05:00
// --- Rate Limiting ---
const rateLimitWindowMs = 60 * 1000 // 1 minute
2026-05-17 15:46:59 -05:00
const rateLimitMax = ( ( ) => {
const val = parseInt ( process . env . RATE _LIMIT _PER _MINUTE || '5' , 10 )
if ( isNaN ( val ) || val < 1 ) {
log . warn ( '[RateLimit] Invalid RATE_LIMIT_PER_MINUTE, defaulting to 5' )
return 5
}
return val
} ) ( )
2026-05-13 18:37:32 -05:00
const apiLimiter = rateLimit ( {
windowMs : rateLimitWindowMs ,
max : rateLimitMax ,
standardHeaders : true ,
legacyHeaders : false ,
handler : ( req , res ) => {
log . warn ( ` Rate limit exceeded for IP: ${ req . ip } ` )
res . status ( 429 ) . json ( {
error : 'Too Many Requests' ,
message : 'Please try again later.' ,
retryAfter : Math . ceil ( rateLimitWindowMs / 1000 ) ,
} )
} ,
} )
// --- Security Headers (Helmet) ---
2026-05-17 21:53:39 -05:00
const isDev = process . env . NODE _ENV === 'development'
2026-05-13 18:37:32 -05:00
const cspDirectives = {
defaultSrc : [ "'self'" ] ,
scriptSrc : [ "'self'" ] ,
2026-05-17 16:10:10 -05:00
styleSrc : [ "'self'" , 'https://fonts.googleapis.com' ] ,
2026-05-13 18:37:32 -05:00
fontSrc : [ "'self'" , 'https://fonts.gstatic.com' ] ,
imgSrc : [ "'self'" , 'data:' ] ,
2026-05-17 21:53:39 -05:00
connectSrc : isDev ? [ "'self'" , 'ws://localhost:*' ] : [ "'self'" ] ,
2026-05-13 18:37:32 -05:00
objectSrc : [ "'none'" ] ,
baseUri : [ "'self'" ] ,
formAction : [ "'self'" ] ,
}
2026-05-17 22:33:11 -05:00
// Note: connectSrc currently allows 'self' only. Zoho API calls are server-to-server
// and are not affected by CSP. If client-side Zoho calls are added in the future,
// add Zoho domains here (e.g., 'https://www.zohoapis.com', 'https://accounts.zoho.com')
2026-05-13 18:37:32 -05:00
app . use ( helmet ( {
contentSecurityPolicy : {
directives : cspDirectives ,
} ,
crossOriginEmbedderPolicy : false , // Prevent CSP issues with embedded content
crossOriginOpenerPolicy : false ,
crossOriginResourcePolicy : { policy : 'same-origin' } ,
dnsPrefetchControl : { allow : false } ,
frameguard : { action : 'deny' } ,
hidePoweredBy : true ,
hsts : { maxAge : 31536000 , includeSubDomains : true } ,
ieNoOpen : true ,
noSniff : true ,
originAgentCluster : true ,
permittedCrossDomainPolicies : { permittedPolicies : 'none' } ,
referrerPolicy : { policy : 'same-origin' } ,
xssFilter : true ,
} ) )
log . info ( '[Security] Helmet enabled with CSP configured' )
2026-05-17 22:33:11 -05:00
// Redirect HTTP to HTTPS in production
if ( process . env . NODE _ENV === 'production' ) {
app . use ( ( req , res , next ) => {
if ( req . headers [ 'x-forwarded-proto' ] === 'http' ) {
return res . redirect ( 301 , ` https:// ${ req . headers . host } ${ req . url } ` )
}
next ( )
} )
}
2026-05-13 18:37:32 -05:00
// --- CORS Configuration ---
2026-05-17 21:53:39 -05:00
const corsOrigin = process . env . CORS _ORIGIN || 'https://queuenorth.com' // Default to production domain
2026-05-13 18:37:32 -05:00
const corsConfig = cors ( {
origin : corsOrigin === '*' ? corsOrigin : ( corsOrigin === 'null' ? undefined : corsOrigin ) ,
methods : [ 'GET' , 'POST' , 'PUT' , 'DELETE' , 'OPTIONS' ] ,
allowedHeaders : [ 'Content-Type' , 'Authorization' ] ,
exposedHeaders : [ 'X-RateLimit-Remaining' , 'X-RateLimit-Reset' ] ,
maxAge : 86400 , // 24 hours
credentials : true ,
} )
app . use ( corsConfig )
log . info ( ` [CORS] Enabled with origin: ${ corsOrigin } ` )
2026-05-17 15:46:59 -05:00
// Middleware — JSON body parsing only on POST routes (issue #14)
2026-05-13 18:18:07 -05:00
app . use ( express . urlencoded ( { extended : true , limit : '1mb' } ) )
2026-05-13 18:31:52 -05:00
2026-05-13 18:37:32 -05:00
// Rate limiting for API routes only
app . use ( '/api' , apiLimiter )
2026-05-13 18:31:52 -05:00
// Request logging middleware
app . use ( ( req , res , next ) => {
const start = Date . now ( )
res . on ( 'finish' , ( ) => {
const ms = Date . now ( ) - start
const level = res . statusCode >= 500 ? 'error' : res . statusCode >= 400 ? 'warn' : 'info'
log [ level ] ( ` ${ req . method } ${ req . originalUrl } ${ res . statusCode } ${ ms } ms ` )
} )
next ( )
} )
2026-05-12 01:04:17 -05:00
// --- Database ---
const db = sqlite3 ( dbPath )
// Initialize schema
const initSchema = ( ) => {
2026-05-17 21:34:39 -05:00
// Check if leads table exists and needs UNIQUE constraint migration
const tableExists = db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='leads'" ) . get ( )
if ( tableExists ) {
// Check if UNIQUE constraint already exists on email
const pragma = db . prepare ( "PRAGMA table_info(leads)" ) . all ( )
const emailCol = pragma . find ( col => col . name === 'email' )
if ( emailCol && ! emailCol . pk ) {
// UNIQUE constraint doesn't exist, need to add it via migration
log . info ( '[DB] Adding UNIQUE constraint on leads.email via migration' )
// Migrate leads table to add UNIQUE constraint
db . exec (
[
'CREATE TABLE IF NOT EXISTS leads_new (' ,
' id INTEGER PRIMARY KEY AUTOINCREMENT,' ,
' company TEXT NOT NULL,' ,
' name TEXT NOT NULL,' ,
' email TEXT NOT NULL UNIQUE,' ,
' phone TEXT,' ,
' zip TEXT,' ,
' message TEXT,' ,
' service_interest TEXT,' ,
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP' ,
')'
] . join ( '\n' )
)
// Copy existing data (deduplicate - keep first occurrence per email)
db . exec (
[
'INSERT OR IGNORE INTO leads_new (id, company, name, email, phone, zip, message, service_interest, created_at)' ,
'SELECT id, company, name, email, phone, zip, message, service_interest, created_at' ,
'FROM leads'
] . join ( '\n' )
)
// Drop old table
db . exec ( 'DROP TABLE leads' )
// Rename new table
db . exec ( 'ALTER TABLE leads_new RENAME TO leads' )
log . info ( '[DB] UNIQUE constraint added on leads.email' )
}
}
// Leads table (now with UNIQUE constraint on email, either from migration or fresh)
2026-05-12 01:04:17 -05:00
db . exec ( `
CREATE TABLE IF NOT EXISTS leads (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
company TEXT NOT NULL ,
name TEXT NOT NULL ,
2026-05-17 21:34:39 -05:00
email TEXT NOT NULL UNIQUE ,
2026-05-12 01:04:17 -05:00
phone TEXT ,
zip TEXT ,
message TEXT ,
service _interest TEXT ,
created _at DATETIME DEFAULT CURRENT _TIMESTAMP
)
` )
// Support requests table
db . exec ( `
CREATE TABLE IF NOT EXISTS support _requests (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
name TEXT NOT NULL ,
company TEXT NOT NULL ,
email TEXT NOT NULL ,
phone TEXT ,
issue TEXT NOT NULL ,
priority TEXT DEFAULT 'medium' ,
created _at DATETIME DEFAULT CURRENT _TIMESTAMP
)
` )
}
initSchema ( )
2026-05-13 18:18:07 -05:00
// --- Sanitization Helper ---
const sanitizeString = ( input , maxLength ) => {
if ( typeof input !== 'string' ) return input
// Trim whitespace
let sanitized = input . trim ( )
// Remove HTML/script tags to prevent XSS
sanitized = sanitized . replace ( /<script[^>]*>.*?<\/script>/gi , '' )
sanitized = sanitized . replace ( /<[^>]*>/g , '' )
// Truncate to max length
2026-05-17 14:44:34 -05:00
sanitized = sanitized . substring ( 0 , maxLength )
// Convert empty strings to undefined so they become NULL in DB
return sanitized === '' ? undefined : sanitized
2026-05-13 18:18:07 -05:00
}
const sanitizePayload = ( data , fields ) => {
const result = { ... data }
for ( const [ field , maxLength ] of Object . entries ( fields ) ) {
if ( result [ field ] !== undefined ) {
result [ field ] = sanitizeString ( result [ field ] , maxLength )
}
}
return result
}
2026-05-12 01:04:17 -05:00
// --- Validation Schemas ---
const leadSchema = z . object ( {
2026-05-13 18:18:07 -05:00
company : z . string ( ) . min ( 1 , 'Company name is required' ) . trim ( ) . max ( 200 , 'Company name must be 200 characters or less' ) ,
name : z . string ( ) . min ( 1 , 'Name is required' ) . trim ( ) . max ( 100 , 'Name must be 100 characters or less' ) ,
email : z . string ( ) . email ( 'Valid email is required' ) . trim ( ) . max ( 254 , 'Email must be 254 characters or less' ) ,
phone : z . string ( ) . trim ( ) . max ( 50 , 'Phone must be 50 characters or less' ) . optional ( ) . or ( z . literal ( '' ) . transform ( ( ) => undefined ) ) ,
zip : z . string ( ) . trim ( ) . max ( 10 , 'ZIP code must be 10 characters or less' ) . optional ( ) . or ( z . literal ( '' ) . transform ( ( ) => undefined ) ) ,
message : z . string ( ) . trim ( ) . max ( 5000 , 'Message must be 5000 characters or less' ) . optional ( ) . or ( z . literal ( '' ) . transform ( ( ) => undefined ) ) ,
service _interest : z . string ( ) . trim ( ) . max ( 50 , 'Service interest must be 50 characters or less' ) . optional ( ) . or ( z . literal ( '' ) . transform ( ( ) => undefined ) ) ,
2026-05-17 21:51:53 -05:00
company _website : z . string ( ) . optional ( ) , // Honeypot field - bots fill this, humans don't see it
2026-05-12 01:04:17 -05:00
} )
const supportSchema = z . object ( {
2026-05-13 18:18:07 -05:00
name : z . string ( ) . min ( 1 , 'Name is required' ) . trim ( ) . max ( 100 , 'Name must be 100 characters or less' ) ,
company : z . string ( ) . min ( 1 , 'Company name is required' ) . trim ( ) . max ( 200 , 'Company name must be 200 characters or less' ) ,
email : z . string ( ) . email ( 'Valid email is required' ) . trim ( ) . max ( 254 , 'Email must be 254 characters or less' ) ,
phone : z . string ( ) . trim ( ) . max ( 50 , 'Phone must be 50 characters or less' ) . optional ( ) . or ( z . literal ( '' ) . transform ( ( ) => undefined ) ) ,
issue : z . string ( ) . min ( 10 , 'Please provide at least 10 characters describing your issue' ) . trim ( ) . max ( 5000 , 'Issue description must be 5000 characters or less' ) ,
priority : z . enum ( [ 'low' , 'medium' , 'high' ] , {
errorMap : ( ) => ( { message : 'Priority must be low, medium, or high' } ) ,
} ) . transform ( ( val ) => val ? . toLowerCase ( ) ? ? undefined ) . optional ( ) . or ( z . literal ( '' ) . transform ( ( ) => undefined ) ) ,
2026-05-17 21:51:53 -05:00
company _website : z . string ( ) . optional ( ) , // Honeypot field - bots fill this, humans don't see it
2026-05-12 01:04:17 -05:00
} )
2026-05-13 18:28:56 -05:00
// --- Zoho CRM Forwarding (best-effort, fire-and-forget) ---
const ZOHO _ENABLED = process . env . ZOHO _ENABLED === 'true'
2026-05-17 19:27:04 -05:00
const ZOHO _CASES _ENABLED = process . env . ZOHO _CASES _ENABLED === 'true'
2026-05-13 18:28:56 -05:00
const ZOHO _API _DOMAIN = process . env . ZOHO _API _DOMAIN || 'https://www.zohoapis.com'
2026-05-17 18:37:10 -05:00
const ZOHO _ACCOUNTS _DOMAIN = process . env . ZOHO _ACCOUNTS _DOMAIN || 'https://accounts.zoho.com'
2026-05-17 17:46:54 -05:00
const ZOHO _CLIENT _ID = process . env . ZOHO _CLIENT _ID || null
2026-05-17 15:01:04 -05:00
const ZOHO _CLIENT _SECRET = process . env . ZOHO _CLIENT _SECRET || null
const ZOHO _REFRESH _TOKEN = process . env . ZOHO _REFRESH _TOKEN || null
2026-05-13 18:28:56 -05:00
// In-memory access token cache
let zohoAccessToken = null
let zohoTokenExpiry = 0
2026-05-17 15:18:24 -05:00
// 10 second timeout for all Zoho API calls
const ZOHO _TIMEOUT _MS = 10000
2026-05-13 18:28:56 -05:00
async function getZohoAccessToken ( ) {
// Return cached token if still valid (with 60s buffer)
if ( zohoAccessToken && Date . now ( ) < zohoTokenExpiry - 60000 ) {
return zohoAccessToken
}
try {
2026-05-17 18:37:10 -05:00
// 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 `
2026-05-13 18:28:56 -05:00
const params = new URLSearchParams ( {
grant _type : 'refresh_token' ,
client _id : ZOHO _CLIENT _ID ,
client _secret : ZOHO _CLIENT _SECRET ,
refresh _token : ZOHO _REFRESH _TOKEN ,
} )
2026-05-17 15:18:24 -05:00
const controller = new AbortController ( )
const timeoutId = setTimeout ( ( ) => controller . abort ( ) , ZOHO _TIMEOUT _MS )
let response
try {
response = await fetch ( url , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/x-www-form-urlencoded' } ,
body : params . toString ( ) ,
signal : controller . signal ,
} )
} catch ( err ) {
if ( err . name === 'AbortError' ) {
log . warn ( '[Zoho] Token fetch timed out after' , ZOHO _TIMEOUT _MS , 'ms' )
} else {
log . error ( '[Zoho] Token fetch error:' , err . message )
}
clearTimeout ( timeoutId )
return null
} finally {
clearTimeout ( timeoutId )
}
// Issue #3: Check response.ok before parsing JSON
if ( ! response . ok ) {
const text = await response . text ( )
log . error ( ` [Zoho] Token fetch failed ( ${ response . status } ): ` , text )
return null
}
2026-05-13 18:28:56 -05:00
const data = await response . json ( )
if ( data . access _token ) {
zohoAccessToken = data . access _token
zohoTokenExpiry = Date . now ( ) + ( data . expires _in || 3600 ) * 1000
2026-05-13 18:31:52 -05:00
log . info ( '[Zoho] Access token acquired, expires in' , data . expires _in || 3600 , 'seconds' )
2026-05-13 18:28:56 -05:00
return zohoAccessToken
} else {
2026-05-13 18:31:52 -05:00
log . error ( '[Zoho] Token exchange failed:' , JSON . stringify ( data ) )
2026-05-13 18:28:56 -05:00
return null
}
} catch ( err ) {
2026-05-13 18:31:52 -05:00
log . error ( '[Zoho] Token acquisition error:' , err . message )
2026-05-13 18:28:56 -05:00
return null
}
}
async function forwardToZoho ( leadData ) {
if ( ! ZOHO _ENABLED ) return
2026-05-17 17:46:54 -05:00
// Short-circuit if Zoho credentials are missing
if ( ! ZOHO _CLIENT _ID || ! ZOHO _CLIENT _SECRET || ! ZOHO _REFRESH _TOKEN ) {
log . warn ( "[Zoho] Skipping forwarding - ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, or ZOHO_REFRESH_TOKEN not configured" )
2026-05-17 15:01:04 -05:00
return
}
2026-05-17 18:37:10 -05:00
let accessToken = await getZohoAccessToken ( )
2026-05-17 17:46:54 -05:00
if ( ! accessToken ) {
2026-05-17 18:37:10 -05:00
// 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
}
2026-05-17 17:46:54 -05:00
}
2026-05-13 18:28:56 -05:00
2026-05-17 17:46:54 -05:00
// Issue #8: Prevent double-slash in URL path
2026-05-17 18:37:10 -05:00
// 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' )
2026-05-17 17:46:54 -05:00
const payload = {
data : [
{
2026-05-17 18:37:10 -05:00
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' ,
2026-05-17 17:46:54 -05:00
} ,
] ,
2026-05-17 18:37:10 -05:00
duplicate _check _fields : [ 'Email' ] ,
trigger : [ 'workflow' ] ,
2026-05-17 17:46:54 -05:00
}
2026-05-13 18:28:56 -05:00
2026-05-17 17:46:54 -05:00
const controller = new AbortController ( )
const timeoutId = setTimeout ( ( ) => controller . abort ( ) , ZOHO _TIMEOUT _MS )
2026-05-17 15:01:04 -05:00
2026-05-17 17:46:54 -05:00
try {
const response = await fetch ( url , {
method : "POST" ,
headers : {
"Authorization" : ` Zoho-oauthtoken ${ accessToken } ` ,
"Content-Type" : "application/json" ,
} ,
body : JSON . stringify ( payload ) ,
signal : controller . signal ,
} )
2026-05-17 15:01:04 -05:00
2026-05-17 17:46:54 -05:00
// 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
}
2026-05-13 18:28:56 -05:00
2026-05-17 17:46:54 -05:00
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 )
2026-05-13 18:28:56 -05:00
}
2026-05-17 17:46:54 -05:00
} finally {
clearTimeout ( timeoutId )
2026-05-13 18:28:56 -05:00
}
}
2026-05-17 19:27:04 -05:00
// --- 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 )
}
}
2026-05-12 01:04:17 -05:00
// --- API Routes ---
// Health check
app . get ( '/api/health' , ( req , res ) => {
2026-05-13 19:59:19 -05:00
try {
// Verify DB connection by executing a simple query
db . prepare ( 'SELECT 1' ) . get ( )
res . json ( { status : 'ok' , db : 'ok' , timestamp : new Date ( ) . toISOString ( ) } )
} catch ( err ) {
log . error ( 'Health check DB verification failed:' , err . message )
res . status ( 503 ) . json ( { error : 'Service unavailable' , db : 'error' , timestamp : new Date ( ) . toISOString ( ) } )
}
2026-05-12 01:04:17 -05:00
} )
// Submit lead
2026-05-17 15:46:59 -05:00
app . post ( '/api/leads' , express . json ( { limit : '1mb' } ) , ( req , res ) => {
2026-05-17 21:51:53 -05:00
// Honeypot check - if filled, it's a bot
if ( req . body . company _website ) {
log . info ( '[Spam] Honeypot triggered, ignoring submission' )
// Return success to bot so it doesn't retry
return res . json ( { success : true , message : "Thanks! We'll be in touch shortly." } )
}
2026-05-17 18:03:55 -05:00
let sanitized
2026-05-12 01:04:17 -05:00
try {
const parsed = leadSchema . safeParse ( req . body )
2026-05-13 19:59:19 -05:00
2026-05-12 01:04:17 -05:00
if ( ! parsed . success ) {
2026-05-13 18:18:07 -05:00
const fieldErrors = { }
for ( const issue of parsed . error . issues ) {
if ( issue . path [ 0 ] ) {
fieldErrors [ issue . path [ 0 ] ] = issue . message
}
}
2026-05-12 01:04:17 -05:00
return res . status ( 400 ) . json ( {
error : 'Validation failed' ,
2026-05-13 18:18:07 -05:00
fields : fieldErrors ,
2026-05-12 01:04:17 -05:00
} )
}
2026-05-13 18:18:07 -05:00
// Sanitize parsed data before insert (trim, strip tags, truncate)
2026-05-17 18:03:55 -05:00
sanitized = sanitizePayload ( parsed . data , {
2026-05-13 18:18:07 -05:00
company : 200 ,
name : 100 ,
email : 254 ,
phone : 50 ,
zip : 10 ,
message : 5000 ,
service _interest : 50 ,
} )
2026-05-12 01:04:17 -05:00
const stmt = db . prepare ( `
INSERT INTO leads ( company , name , email , phone , zip , message , service _interest )
VALUES ( ? , ? , ? , ? , ? , ? , ? )
` )
2026-05-13 18:31:52 -05:00
const result = stmt . run (
2026-05-13 18:18:07 -05:00
sanitized . company ,
sanitized . name ,
sanitized . email ,
sanitized . phone || null ,
sanitized . zip || null ,
sanitized . message || null ,
sanitized . service _interest || null
2026-05-12 01:04:17 -05:00
)
2026-05-13 18:31:52 -05:00
log . info ( ` Lead submitted: ${ sanitized . email } from ${ sanitized . company } (id: ${ result . lastInsertRowid } ) ` )
2026-05-13 18:28:56 -05:00
// Fire-and-forget Zoho forwarding (best-effort, non-blocking)
2026-05-17 17:46:54 -05:00
forwardToZoho ( sanitized ) . catch ( err => log . error ( '[Zoho] Forwarding error:' , err . message ) )
2026-05-13 18:28:56 -05:00
2026-05-13 19:59:19 -05:00
res . json ( { success : true , message : "Thanks! We'll be in touch shortly." } )
2026-05-12 01:04:17 -05:00
} catch ( err ) {
2026-05-17 14:44:34 -05:00
// Issue #6: Handle duplicate email error with 409 Conflict
const errorMsg = err . message ? . toLowerCase ( ) || ''
if ( errorMsg . includes ( 'unique constraint' ) || errorMsg . includes ( 'duplicate' ) ) {
log . warn ( ` Duplicate lead email: ${ sanitized . email } ` )
// Still forward to Zoho (non-blocking) for existing leads
2026-05-17 17:46:54 -05:00
forwardToZoho ( sanitized ) . catch ( err => log . error ( '[Zoho] Forwarding error:' , err . message ) )
2026-05-17 14:44:34 -05:00
return res . status ( 409 ) . json ( {
error : 'Duplicate lead' ,
message : 'A lead with this email already exists'
} )
}
2026-05-13 18:31:52 -05:00
log . error ( 'Error submitting lead:' , err )
2026-05-12 01:04:17 -05:00
res . status ( 500 ) . json ( { error : 'Failed to submit lead' } )
}
} )
// Submit support request
2026-05-17 15:46:59 -05:00
app . post ( '/api/support' , express . json ( { limit : '1mb' } ) , ( req , res ) => {
2026-05-17 21:51:53 -05:00
// Honeypot check - if filled, it's a bot
if ( req . body . company _website ) {
log . info ( '[Spam] Honeypot triggered, ignoring submission' )
// Return success to bot so it doesn't retry
return res . json ( { success : true , message : "Thanks! We'll get back to you soon." } )
}
2026-05-12 01:04:17 -05:00
try {
const parsed = supportSchema . safeParse ( req . body )
2026-05-13 19:59:19 -05:00
2026-05-12 01:04:17 -05:00
if ( ! parsed . success ) {
2026-05-13 18:18:07 -05:00
const fieldErrors = { }
for ( const issue of parsed . error . issues ) {
if ( issue . path [ 0 ] ) {
fieldErrors [ issue . path [ 0 ] ] = issue . message
}
}
2026-05-12 01:04:17 -05:00
return res . status ( 400 ) . json ( {
error : 'Validation failed' ,
2026-05-13 18:18:07 -05:00
fields : fieldErrors ,
2026-05-12 01:04:17 -05:00
} )
}
2026-05-13 18:18:07 -05:00
// Sanitize parsed data before insert (trim, strip tags, truncate)
const sanitized = sanitizePayload ( parsed . data , {
name : 100 ,
company : 200 ,
email : 254 ,
phone : 50 ,
issue : 5000 ,
priority : 10 ,
} )
2026-05-12 01:04:17 -05:00
const stmt = db . prepare ( `
INSERT INTO support _requests ( name , company , email , phone , issue , priority )
VALUES ( ? , ? , ? , ? , ? , ? )
` )
2026-05-13 18:31:52 -05:00
const result = stmt . run (
2026-05-13 18:18:07 -05:00
sanitized . name ,
sanitized . company ,
sanitized . email ,
sanitized . phone || null ,
sanitized . issue ,
sanitized . priority || 'medium'
2026-05-12 01:04:17 -05:00
)
2026-05-13 18:31:52 -05:00
log . info ( ` Support request submitted: ${ sanitized . email } from ${ sanitized . company } priority= ${ sanitized . priority || 'medium' } (id: ${ result . lastInsertRowid } ) ` )
2026-05-17 19:27:04 -05:00
// Fire-and-forget Zoho Cases forwarding (best-effort, non-blocking)
forwardSupportToZoho ( sanitized ) . catch ( err => log . error ( '[Zoho Cases] Forwarding error:' , err . message ) )
2026-05-13 19:59:19 -05:00
res . json ( { success : true , message : "Thanks! We'll get back to you soon." } )
2026-05-12 01:04:17 -05:00
} catch ( err ) {
2026-05-13 18:31:52 -05:00
log . error ( 'Error submitting support request:' , err )
2026-05-12 01:04:17 -05:00
res . status ( 500 ) . json ( { error : 'Failed to submit support request' } )
}
} )
2026-05-13 19:59:19 -05:00
// --- Request timeout middleware (30 seconds) ---
const REQUEST _TIMEOUT _MS = 30000
const timeoutMiddleware = ( req , res , next ) => {
const timeout = setTimeout ( ( ) => {
if ( ! res . headersSent ) {
log . warn ( ` Request timeout: ${ req . method } ${ req . originalUrl } ` )
res . status ( 504 ) . json ( { error : 'Request timeout' } )
}
} , REQUEST _TIMEOUT _MS )
res . on ( 'finish' , ( ) => clearTimeout ( timeout ) )
res . on ( 'close' , ( ) => clearTimeout ( timeout ) )
next ( )
}
// --- Global error handlers ---
process . on ( 'uncaughtException' , ( err ) => {
log . error ( 'Uncaught exception:' , err . message )
log . error ( 'Stack:' , err . stack )
log . error ( 'Shutting down due to uncaught exception...' )
process . exit ( 1 )
} )
process . on ( 'unhandledRejection' , ( reason , promise ) => {
log . error ( 'Unhandled rejection at:' , promise )
log . error ( 'Reason:' , reason )
log . error ( 'Shutting down due to unhandled rejection...' )
process . exit ( 1 )
} )
2026-05-12 01:04:17 -05:00
// --- Start Server ---
const PORT = process . env . SERVER _PORT || 3001
2026-05-17 17:46:54 -05:00
// Register timeout middleware BEFORE catch-all routes
app . use ( timeoutMiddleware )
2026-05-17 18:03:55 -05:00
// --- 404 catch-all for API routes (must be after all API routes) ---
app . use ( ( req , res , next ) => {
if ( req . path . startsWith ( '/api' ) ) {
log . warn ( ` API route not found: ${ req . method } ${ req . originalUrl } ` )
return res . status ( 404 ) . json ( { error : 'Not found' } )
}
next ( )
} )
// Static file serving for SPA
app . use ( express . static ( path . join ( _ _dirname , '../dist' ) ) )
// SPA catch-all — serve index.html for any non-API, non-asset route
// This lets React Router handle client-side routing
app . get ( '*' , ( req , res , next ) => {
// Skip API routes (already handled above) and requests for static assets
if ( req . path . startsWith ( '/api/' ) || req . path . includes ( '.' ) ) {
return next ( )
}
res . sendFile ( path . join ( _ _dirname , '../dist/index.html' ) )
} )
2026-05-12 01:04:17 -05:00
app . listen ( PORT , ( ) => {
2026-05-13 18:31:52 -05:00
log . info ( ` Server running on http://localhost: ${ PORT } ` )
log . info ( ` Health check: http://localhost: ${ PORT } /api/health ` )
if ( ZOHO _ENABLED ) {
2026-05-17 18:37:10 -05:00
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' } ` )
2026-05-13 18:31:52 -05:00
} else {
log . info ( 'Zoho CRM forwarding: DISABLED (set ZOHO_ENABLED=true to enable)' )
}
2026-05-13 18:37:32 -05:00
log . info ( ` Rate limiting: ${ rateLimitMax } requests per ${ rateLimitWindowMs / 1000 } seconds ` )
log . info ( ` Security headers: Helmet enabled with CSP configured ` )
log . info ( ` CORS origin: ${ corsOrigin } ` )
2026-05-12 01:04:17 -05:00
} )