import express from 'express' import path from 'path' import { fileURLToPath } from 'url' import { existsSync, mkdirSync, chmodSync } from 'fs' import sqlite3 from 'better-sqlite3' import z from 'zod' // --- Setup --- const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const app = express() const dbPath = path.join(__dirname, '../db/queuenorth.db') const dbDir = path.dirname(dbPath) // Create db directory if it doesn't exist if (!existsSync(dbDir)) { mkdirSync(dbDir, { recursive: true }) // Try to set writable permissions, ignore if running as non-root try { chmodSync(dbDir, 0o755) } catch (e) {} } // Middleware app.use(express.json({ limit: '1mb' })) app.use(express.urlencoded({ extended: true, limit: '1mb' })) 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() // --- 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>/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 } // --- Validation Schemas --- const leadSchema = z.object({ 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)), }) const supportSchema = z.object({ 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)), }) // --- 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 console.log('[Zoho] Access token acquired, expires in', data.expires_in || 3600, 'seconds') return zohoAccessToken } else { console.error('[Zoho] Token exchange failed:', JSON.stringify(data)) return null } } catch (err) { console.error('[Zoho] Token acquisition error:', err.message) return null } } async function forwardToZoho(leadData) { if (!ZOHO_ENABLED) return try { const accessToken = await getZohoAccessToken() if (!accessToken) { console.error('[Zoho] No access token available, skipping lead forwarding') 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() console.log('[Zoho] Lead forwarded successfully:', result.data?.[0]?.details?.id || 'no id returned') } else { const text = await response.text() console.error(`[Zoho] Lead forwarding failed (${response.status}):`, text) } } catch (err) { console.error('[Zoho] Forwarding error:', err.message) } } // --- 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) { const fieldErrors = {} for (const issue of parsed.error.issues) { if (issue.path[0]) { fieldErrors[issue.path[0]] = issue.message } } return res.status(400).json({ error: 'Validation failed', fields: fieldErrors, }) } // 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, }) const stmt = db.prepare(` INSERT INTO leads (company, name, email, phone, zip, message, service_interest) VALUES (?, ?, ?, ?, ?, ?, ?) `) stmt.run( sanitized.company, sanitized.name, sanitized.email, sanitized.phone || null, sanitized.zip || null, sanitized.message || null, sanitized.service_interest || null ) // Fire-and-forget Zoho forwarding (best-effort, non-blocking) forwardToZoho(sanitized) res.json({ success: true, message: 'Thanks! We\'ll be in touch shortly.' }) } catch (err) { console.error('Error submitting lead:', err) 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) { const fieldErrors = {} for (const issue of parsed.error.issues) { if (issue.path[0]) { fieldErrors[issue.path[0]] = issue.message } } return res.status(400).json({ error: 'Validation failed', fields: fieldErrors, }) } // 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, }) const stmt = db.prepare(` INSERT INTO support_requests (name, company, email, phone, issue, priority) VALUES (?, ?, ?, ?, ?, ?) `) stmt.run( sanitized.name, sanitized.company, sanitized.email, sanitized.phone || null, sanitized.issue, sanitized.priority || 'medium' ) res.json({ success: true, message: 'Thanks! We\'ll get back to you soon.' }) } catch (err) { console.error('Error submitting support request:', err) res.status(500).json({ error: 'Failed to submit support request' }) } }) // --- Start Server --- const PORT = process.env.SERVER_PORT || 3001 app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`) console.log(`Health check: http://localhost:${PORT}/api/health`) })