317 lines
9.9 KiB
JavaScript
317 lines
9.9 KiB
JavaScript
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[^>]*>.*?<\/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`)
|
|
})
|