- #4: Replace su-exec with USER nodejs in Dockerfile (P0) - #6: Add UNIQUE constraint on leads.email with migration (P1) - #10: Consistent NULL handling for optional fields (P1) - Hero section rewrite: B2B value proposition, prominent 8x8 badge - Clean up .bak file left by agent
This commit is contained in:
parent
851759ae5e
commit
7d476f36e8
15
Dockerfile
15
Dockerfile
|
|
@ -40,9 +40,8 @@ ENV ZOHO_REDIRECT_URI=
|
||||||
# Create app directory structure
|
# Create app directory structure
|
||||||
RUN mkdir -p /app/db /app/logs
|
RUN mkdir -p /app/db /app/logs
|
||||||
|
|
||||||
# Copy entrypoint script
|
# Set permissions for db directory (before USER switch)
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/
|
RUN chown -R nodejs:nodejs /app/db /app/logs
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
||||||
|
|
||||||
# Copy from builder - built artifacts and package manifests
|
# Copy from builder - built artifacts and package manifests
|
||||||
COPY --from=builder /app/package.json /app/package-lock.json* ./
|
COPY --from=builder /app/package.json /app/package-lock.json* ./
|
||||||
|
|
@ -52,17 +51,15 @@ COPY --from=builder /app/server ./server
|
||||||
# Install production dependencies only in runtime stage
|
# Install production dependencies only in runtime stage
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Install su-exec for switching to non-root user
|
|
||||||
RUN apk add --no-cache su-exec && \
|
|
||||||
rm -rf /var/cache/apk/*
|
|
||||||
|
|
||||||
# Expose backend port
|
# Expose backend port
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
|
# Switch to non-root user (standard approach, no su-exec needed)
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
# Health check using Node 20 built-in fetch (no wget required)
|
# Health check using Node 20 built-in fetch (no wget required)
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
CMD node -e "fetch('http://localhost:3001/api/health').then(r => r.ok ? 0 : 1).catch(() => 1)" || exit 1
|
CMD node -e "fetch('http://localhost:3001/api/health').then(r => r.ok ? 0 : 1).catch(() => 1)" || exit 1
|
||||||
|
|
||||||
# Run the Express server via entrypoint (runs as root, then switches to nodejs)
|
# Run the Express server
|
||||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
|
||||||
CMD ["node", "server/index.js"]
|
CMD ["node", "server/index.js"]
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo "[$(date -Iseconds)] ERROR $1" >&2
|
||||||
|
}
|
||||||
|
|
||||||
# Create directories if they don't exist
|
# Create directories if they don't exist
|
||||||
mkdir -p /app/db
|
mkdir -p /app/db
|
||||||
mkdir -p /app/logs
|
mkdir -p /app/logs
|
||||||
|
|
@ -13,5 +17,12 @@ mkdir -p /app/logs
|
||||||
chmod 777 /app/db
|
chmod 777 /app/db
|
||||||
chmod 777 /app/logs
|
chmod 777 /app/logs
|
||||||
|
|
||||||
|
# Issue #4: Check if nodejs user exists - if not, this is a Docker build error
|
||||||
|
if ! id nodejs >/dev/null 2>&1; then
|
||||||
|
log_error "nodejs user does not exist - this is a Docker build error"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Run the Express server as nodejs user
|
# Run the Express server as nodejs user
|
||||||
|
# Issue #4: Exit with error code 1 if su-exec fails instead of falling back to root
|
||||||
exec su-exec nodejs node server/index.js
|
exec su-exec nodejs node server/index.js
|
||||||
|
|
|
||||||
113
server/db.js
113
server/db.js
|
|
@ -3,6 +3,17 @@ import path from 'path'
|
||||||
import { existsSync, mkdirSync, chmodSync } from 'fs'
|
import { existsSync, mkdirSync, chmodSync } from 'fs'
|
||||||
import sqlite3 from 'better-sqlite3'
|
import sqlite3 from 'better-sqlite3'
|
||||||
|
|
||||||
|
// --- 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) },
|
||||||
|
}
|
||||||
|
|
||||||
const dbPath = path.join(new URL(import.meta.url).pathname, '..', 'db', 'queuenorth.db')
|
const dbPath = path.join(new URL(import.meta.url).pathname, '..', 'db', 'queuenorth.db')
|
||||||
const dbDir = path.dirname(dbPath)
|
const dbDir = path.dirname(dbPath)
|
||||||
|
|
||||||
|
|
@ -22,34 +33,86 @@ export const db = sqlite3(dbPath)
|
||||||
|
|
||||||
// Initialize schema
|
// Initialize schema
|
||||||
export const initSchema = () => {
|
export const initSchema = () => {
|
||||||
// Leads table
|
// Issue #6: Add UNIQUE constraint on leads.email with migration support
|
||||||
db.exec(`
|
const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='leads'").get()
|
||||||
CREATE TABLE IF NOT EXISTS leads (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
if (tableExists) {
|
||||||
company TEXT NOT NULL,
|
// Check if UNIQUE constraint already exists
|
||||||
name TEXT NOT NULL,
|
const pragma = db.prepare("PRAGMA table_info(leads)").all()
|
||||||
email TEXT NOT NULL,
|
const emailCol = pragma.find(col => col.name === 'email')
|
||||||
phone TEXT,
|
|
||||||
zip TEXT,
|
if (!emailCol || !emailCol.notnull) {
|
||||||
message TEXT,
|
// UNIQUE constraint doesn't exist, need to add it via migration
|
||||||
service_interest TEXT,
|
log.info('[DB] Adding UNIQUE constraint on leads.email via migration')
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
|
// 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')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New table - create with UNIQUE constraint from the start
|
||||||
|
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 UNIQUE,',
|
||||||
|
' phone TEXT,',
|
||||||
|
' zip TEXT,',
|
||||||
|
' message TEXT,',
|
||||||
|
' service_interest TEXT,',
|
||||||
|
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP',
|
||||||
|
')'
|
||||||
|
].join('\n')
|
||||||
)
|
)
|
||||||
`)
|
}
|
||||||
|
|
||||||
// Support requests table
|
// Support requests table
|
||||||
db.exec(`
|
db.exec(
|
||||||
CREATE TABLE IF NOT EXISTS support_requests (
|
[
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
'CREATE TABLE IF NOT EXISTS support_requests (',
|
||||||
name TEXT NOT NULL,
|
' id INTEGER PRIMARY KEY AUTOINCREMENT,',
|
||||||
company TEXT NOT NULL,
|
' name TEXT NOT NULL,',
|
||||||
email TEXT NOT NULL,
|
' company TEXT NOT NULL,',
|
||||||
phone TEXT,
|
' email TEXT NOT NULL,',
|
||||||
issue TEXT NOT NULL,
|
' phone TEXT,',
|
||||||
priority TEXT DEFAULT 'medium',
|
' issue TEXT NOT NULL,',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
' priority TEXT DEFAULT \'medium\',',
|
||||||
)
|
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP',
|
||||||
`)
|
')'
|
||||||
|
].join('\n')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
initSchema()
|
initSchema()
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,9 @@ const sanitizeString = (input, maxLength) => {
|
||||||
sanitized = sanitized.replace(/<script[^>]*>.*?<\/script>/gi, '')
|
sanitized = sanitized.replace(/<script[^>]*>.*?<\/script>/gi, '')
|
||||||
sanitized = sanitized.replace(/<[^>]*>/g, '')
|
sanitized = sanitized.replace(/<[^>]*>/g, '')
|
||||||
// Truncate to max length
|
// Truncate to max length
|
||||||
return sanitized.substring(0, maxLength)
|
sanitized = sanitized.substring(0, maxLength)
|
||||||
|
// Convert empty strings to undefined so they become NULL in DB
|
||||||
|
return sanitized === '' ? undefined : sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizePayload = (data, fields) => {
|
const sanitizePayload = (data, fields) => {
|
||||||
|
|
@ -359,6 +361,24 @@ app.post('/api/leads', (req, res) => {
|
||||||
|
|
||||||
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) {
|
||||||
|
// 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
|
||||||
|
try {
|
||||||
|
forwardToZoho(sanitized)
|
||||||
|
} catch (zohoErr) {
|
||||||
|
log.warn(`[Zoho] Skipped forwarding for duplicate lead: ${sanitized.email}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Duplicate lead',
|
||||||
|
message: 'A lead with this email already exists'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
log.error('Error submitting lead:', err)
|
log.error('Error submitting lead:', err)
|
||||||
res.status(500).json({ error: 'Failed to submit lead' })
|
res.status(500).json({ error: 'Failed to submit lead' })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,29 +11,40 @@ const Home = () => {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="bg-primary-navy text-white py-16 md:py-24">
|
<section className="bg-gradient-to-br from-primary-navy via-primary-navy to-teal-900 text-white py-16 md:py-24">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6">
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6">
|
||||||
Modern Communications Infrastructure Without the Vendor Noise
|
Reliable Business Communications — Without the Runaround
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl md:text-2xl text-section-alt mb-8 max-w-2xl">
|
<p className="text-xl md:text-2xl text-section-alt mb-8 max-w-2xl">
|
||||||
We deliver UCaaS, Contact Center, deployment, and managed lifecycle support for SMB and enterprise organizations.
|
We handle your phones, internet, and IT so you can focus on running your business. 8x8 Certified Partner with 25+ years of proven reliability.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Button variant="default" size="lg" className="bg-white text-primary-navy hover:bg-gray-100">
|
<Button variant="default" size="lg" className="bg-white text-primary-navy hover:bg-gray-100" onClick={() => navigate('/contact')}>
|
||||||
Request Consultation
|
Schedule Consultation
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="lg" className="border-white text-white hover:bg-white/10">
|
<Button variant="outline" size="lg" className="border-white text-white hover:bg-white/10" onClick={() => navigate('/services')}>
|
||||||
Explore Services
|
View Services
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10 flex flex-wrap gap-4">
|
<div className="mt-10 flex flex-wrap gap-4">
|
||||||
<span className="px-4 py-2 bg-white/10 rounded-full text-sm font-medium">8x8 Certified Partner</span>
|
<div className="flex items-center gap-2 px-4 py-2 bg-white/20 rounded-lg text-sm font-medium">
|
||||||
<span className="px-4 py-2 bg-white/10 rounded-full text-sm font-medium">Veteran Owned</span>
|
<img src="/assets/8x8_Logo_White.svg" alt="8x8" className="h-5 w-5" />
|
||||||
<span className="px-4 py-2 bg-white/10 rounded-full text-sm font-numeric">25+ Years Experience</span>
|
<span>8x8 Certified Partner</span>
|
||||||
<span className="px-4 py-2 bg-white/10 rounded-full text-sm font-medium">SMB to Enterprise</span>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-white/20 rounded-lg text-sm font-medium">
|
||||||
|
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center text-primary-navy font-bold">V</div>
|
||||||
|
<span>Veteran Owned</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-white/20 rounded-lg text-sm font-numeric">
|
||||||
|
<span>25+</span>
|
||||||
|
<span className="text-sm">Years Experience</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-white/20 rounded-lg text-sm font-medium">
|
||||||
|
<span>SMB to Enterprise</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue