Initial commit of existing project
This commit is contained in:
137
server.js
Normal file
137
server.js
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* HOA LedgerIQ — Lead Capture Backend
|
||||
* Stack: Node.js + Express + better-sqlite3
|
||||
*
|
||||
* Start: node server.js
|
||||
* Leads DB: ./data/leads.db
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
// ── Config ──────────────────────────────────────────────
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const DB_DIR = path.join(__dirname, 'data');
|
||||
const DB_PATH = path.join(DB_DIR, 'leads.db');
|
||||
|
||||
// ── DB setup ─────────────────────────────────────────────
|
||||
fs.mkdirSync(DB_DIR, { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS leads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
org_name TEXT,
|
||||
state TEXT,
|
||||
role TEXT,
|
||||
unit_count TEXT,
|
||||
beta_interest INTEGER DEFAULT 0,
|
||||
source TEXT DEFAULT 'landing_page',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Migrate existing DBs: add new columns if they don't exist yet
|
||||
const cols = db.pragma('table_info(leads)').map(c => c.name);
|
||||
if (!cols.includes('org_name')) db.exec('ALTER TABLE leads ADD COLUMN org_name TEXT');
|
||||
if (!cols.includes('state')) db.exec('ALTER TABLE leads ADD COLUMN state TEXT');
|
||||
if (!cols.includes('beta_interest')) db.exec('ALTER TABLE leads ADD COLUMN beta_interest INTEGER DEFAULT 0');
|
||||
|
||||
// Prepared statements
|
||||
const insertLead = db.prepare(`
|
||||
INSERT INTO leads (first_name, last_name, email, org_name, state, role, unit_count, beta_interest, source)
|
||||
VALUES (@firstName, @lastName, @email, @orgName, @state, @role, @unitCount, @betaInterest, @source)
|
||||
`);
|
||||
|
||||
const findByEmail = db.prepare(`SELECT id FROM leads WHERE email = ? LIMIT 1`);
|
||||
|
||||
const getAllLeads = db.prepare(`
|
||||
SELECT id, first_name, last_name, email, org_name, state, role, unit_count, beta_interest, source, created_at
|
||||
FROM leads
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
// ── App ───────────────────────────────────────────────────
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.static(__dirname)); // serve the marketing site
|
||||
|
||||
// POST /api/leads — capture a new preview sign-up
|
||||
app.post('/api/leads', (req, res) => {
|
||||
const { firstName, lastName, email, orgName, state, role, unitCount, betaInterest, source } = req.body ?? {};
|
||||
|
||||
// Validate required fields
|
||||
if (!firstName?.trim() || !lastName?.trim() || !email?.trim()) {
|
||||
return res.status(400).json({ error: 'firstName, lastName, and email are required.' });
|
||||
}
|
||||
if (!orgName?.trim()) {
|
||||
return res.status(400).json({ error: 'Organization name is required.' });
|
||||
}
|
||||
if (!state?.trim()) {
|
||||
return res.status(400).json({ error: 'State is required.' });
|
||||
}
|
||||
|
||||
// Simple email format check
|
||||
const emailRx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRx.test(email.trim())) {
|
||||
return res.status(400).json({ error: 'Invalid email address.' });
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = findByEmail.get(email.trim().toLowerCase());
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'This email is already on the list.', id: existing.id });
|
||||
}
|
||||
|
||||
try {
|
||||
const info = insertLead.run({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
email: email.trim().toLowerCase(),
|
||||
orgName: orgName?.trim() ?? null,
|
||||
state: state?.trim() ?? null,
|
||||
role: role ?? null,
|
||||
unitCount: unitCount ?? null,
|
||||
betaInterest: betaInterest ? 1 : 0,
|
||||
source: source ?? 'landing_page',
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, id: info.lastInsertRowid });
|
||||
} catch (err) {
|
||||
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
return res.status(409).json({ error: 'This email is already on the list.' });
|
||||
}
|
||||
console.error('DB error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/leads — internal: list all leads (add auth before exposing publicly)
|
||||
app.get('/api/leads', (req, res) => {
|
||||
const secret = req.headers['x-admin-key'];
|
||||
if (!secret || secret !== process.env.ADMIN_KEY) {
|
||||
return res.status(401).json({ error: 'Unauthorized.' });
|
||||
}
|
||||
const leads = getAllLeads.all();
|
||||
res.json({ count: leads.length, leads });
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (_req, res) => res.json({ status: 'ok', ts: new Date().toISOString() }));
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────
|
||||
app.listen(PORT, () => {
|
||||
console.log(`\n HOA LedgerIQ server running at http://localhost:${PORT}`);
|
||||
console.log(` Leads DB: ${DB_PATH}\n`);
|
||||
});
|
||||
Reference in New Issue
Block a user