2026-05-12 01:04:17 -05:00
import express from 'express'
import path from 'path'
import { fileURLToPath } from 'url'
2026-05-12 01:57:55 -05:00
import { existsSync , mkdirSync , chmodSync } 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 } )
// Try to set writable permissions, ignore if running as non-root
try { chmodSync ( dbDir , 0o755 ) } catch ( e ) { }
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
const rateLimitMax = parseInt ( process . env . RATE _LIMIT _PER _MINUTE || '5' , 10 )
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) ---
const cspDirectives = {
defaultSrc : [ "'self'" ] ,
scriptSrc : [ "'self'" ] ,
styleSrc : [ "'self'" , "'unsafe-inline'" ] ,
fontSrc : [ "'self'" , 'https://fonts.gstatic.com' ] ,
imgSrc : [ "'self'" , 'data:' ] ,
connectSrc : [ "'self'" ] ,
objectSrc : [ "'none'" ] ,
baseUri : [ "'self'" ] ,
formAction : [ "'self'" ] ,
}
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' )
// --- CORS Configuration ---
const corsOrigin = process . env . CORS _ORIGIN || '*' // Default to * for development
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-12 01:04:17 -05:00
// Middleware
2026-05-13 18:18:07 -05:00
app . use ( express . json ( { limit : '1mb' } ) )
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
app . use ( express . static ( path . join ( _ _dirname , '../dist' ) ) )
// --- Database ---
const db = sqlite3 ( dbPath )
// Initialize schema
const initSchema = ( ) => {
// Leads table
db . exec ( `
CREATE TABLE IF NOT EXISTS leads (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
company TEXT NOT NULL ,
name TEXT NOT NULL ,
email TEXT NOT NULL ,
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
return sanitized . substring ( 0 , maxLength )
}
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-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-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'
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 _SECRET = process . env . ZOHO _CLIENT _SECRET || ''
const ZOHO _REFRESH _TOKEN = process . env . ZOHO _REFRESH _TOKEN || ''
const ZOHO _REDIRECT _URI = process . env . ZOHO _REDIRECT _URI || ''
// In-memory access token cache
let zohoAccessToken = null
let zohoTokenExpiry = 0
async function getZohoAccessToken ( ) {
// Return cached token if still valid (with 60s buffer)
if ( zohoAccessToken && Date . now ( ) < zohoTokenExpiry - 60000 ) {
return zohoAccessToken
}
try {
const url = ` ${ ZOHO _API _DOMAIN } /oauth/v2/token `
const params = new URLSearchParams ( {
grant _type : 'refresh_token' ,
client _id : ZOHO _CLIENT _ID ,
client _secret : ZOHO _CLIENT _SECRET ,
refresh _token : ZOHO _REFRESH _TOKEN ,
redirect _uri : ZOHO _REDIRECT _URI ,
} )
const response = await fetch ( ` ${ url } ? ${ params . toString ( ) } ` , { method : 'POST' } )
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
try {
const accessToken = await getZohoAccessToken ( )
if ( ! accessToken ) {
2026-05-13 18:31:52 -05:00
log . warn ( '[Zoho] No access token available, skipping lead forwarding' )
2026-05-13 18:28:56 -05:00
return
}
const url = ` ${ ZOHO _API _DOMAIN } /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 || '' ,
} ,
] ,
}
const response = await fetch ( url , {
method : 'POST' ,
headers : {
'Authorization' : ` Zoho-oauthtoken ${ accessToken } ` ,
'Content-Type' : 'application/json' ,
} ,
body : JSON . stringify ( payload ) ,
} )
if ( response . ok ) {
const result = await response . json ( )
2026-05-13 18:31:52 -05:00
log . info ( '[Zoho] Lead forwarded successfully:' , result . data ? . [ 0 ] ? . details ? . id || 'no id returned' )
2026-05-13 18:28:56 -05:00
} else {
const text = await response . text ( )
2026-05-13 18:31:52 -05:00
log . error ( ` [Zoho] Lead forwarding failed ( ${ response . status } ): ` , text )
2026-05-13 18:28:56 -05:00
}
} catch ( err ) {
2026-05-13 18:31:52 -05:00
log . error ( '[Zoho] Forwarding error:' , err . message )
2026-05-13 18:28:56 -05:00
}
}
2026-05-12 01:04:17 -05:00
// --- API Routes ---
// Health check
app . get ( '/api/health' , ( req , res ) => {
res . json ( { status : 'ok' , timestamp : new Date ( ) . toISOString ( ) } )
} )
// Submit lead
app . post ( '/api/leads' , ( req , res ) => {
try {
const parsed = leadSchema . safeParse ( req . body )
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 , {
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)
forwardToZoho ( sanitized )
2026-05-12 01:04:17 -05:00
res . json ( { success : true , message : 'Thanks! We\'ll be in touch shortly.' } )
} catch ( err ) {
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
app . post ( '/api/support' , ( req , res ) => {
try {
const parsed = supportSchema . safeParse ( req . body )
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-12 01:04:17 -05:00
res . json ( { success : true , message : 'Thanks! We\'ll get back to you soon.' } )
} 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' } )
}
} )
// --- Start Server ---
const PORT = process . env . SERVER _PORT || 3001
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 ) {
log . info ( ` Zoho CRM forwarding: ENABLED (domain: ${ ZOHO _API _DOMAIN } ) ` )
} 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
} )