Spam submissions were still getting through with placeholder data. Two more content filters on the public form endpoints: - Reject emails from reserved documentation domains (example.com/.org/ /.net/.edu and subdomains) and reserved TLDs (.test/.example/.invalid/ localhost). testing@example.com and friends are never real leads. - Reject a homesites count below 10. Real associations are larger; the junk uses 0/1/2. Both are validated server-side in security.js (validateEmail gains a domain blocklist, new validateHomesites) and mirrored client-side in app.js for immediate feedback. The homesites input min attribute goes from 1 to 10. Blocked submissions return 400 with a `field` hint and store nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
318 lines
13 KiB
JavaScript
318 lines
13 KiB
JavaScript
/**
|
|
* 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');
|
|
const OpenAI = require('openai');
|
|
|
|
const security = require('./security');
|
|
|
|
// ── Config ──────────────────────────────────────────────
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// ── AI client (OpenAI-compatible) ────────────────────────
|
|
const AI_API_URL = process.env.AI_API_URL || 'https://api.openai.com/v1';
|
|
const AI_API_KEY = process.env.AI_API_KEY || '';
|
|
const AI_MODEL = process.env.AI_MODEL || 'gpt-4o-mini';
|
|
const AI_DEBUG = process.env.AI_DEBUG === 'true';
|
|
|
|
const aiClient = AI_API_KEY
|
|
? new OpenAI({ apiKey: AI_API_KEY, baseURL: AI_API_URL })
|
|
: null;
|
|
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'))
|
|
);
|
|
`);
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS calc_submissions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT,
|
|
opt_in INTEGER DEFAULT 1,
|
|
homesites REAL,
|
|
property_type TEXT,
|
|
annual_income REAL,
|
|
payment_freq TEXT,
|
|
reserve_funds REAL,
|
|
interest_2025 REAL,
|
|
total_potential REAL,
|
|
op_interest REAL,
|
|
res_interest REAL,
|
|
ai_recommendation TEXT,
|
|
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 insertCalcSubmission = db.prepare(`
|
|
INSERT INTO calc_submissions
|
|
(email, opt_in, homesites, property_type, annual_income, payment_freq,
|
|
reserve_funds, interest_2025, total_potential, op_interest, res_interest, ai_recommendation)
|
|
VALUES
|
|
(@email, @optIn, @homesites, @propertyType, @annualIncome, @paymentFreq,
|
|
@reserveFunds, @interest2025, @totalPotential, @opInterest, @resInterest, @aiRecommendation)
|
|
`);
|
|
|
|
const getAllCalcSubmissions = db.prepare(`
|
|
SELECT * FROM calc_submissions ORDER BY created_at DESC
|
|
`);
|
|
|
|
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.set('trust proxy', 1); // behind nginx — needed for correct client IPs
|
|
app.use(express.json({ limit: '32kb' }));
|
|
app.use(express.static(__dirname)); // serve the marketing site
|
|
|
|
// GET /api/form-config — public config the forms need (CAPTCHA site key)
|
|
app.get('/api/form-config', (_req, res) => {
|
|
res.set('Cache-Control', 'no-store');
|
|
res.json(security.publicConfig());
|
|
});
|
|
|
|
// GET /api/form-token — single-use, signed token proving the form was loaded
|
|
app.get('/api/form-token', (_req, res) => {
|
|
res.set('Cache-Control', 'no-store');
|
|
res.json({ token: security.issueFormToken() });
|
|
});
|
|
|
|
// 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 ?? {};
|
|
|
|
// Coerce defensively: a non-string (array, object, number) would otherwise
|
|
// blow up on .trim() and surface as a 500 instead of a 400.
|
|
const str = v => (typeof v === 'string' ? v.trim() : '');
|
|
|
|
// Validate required fields
|
|
if (!str(firstName) || !str(lastName) || !str(email)) {
|
|
return res.status(400).json({ error: 'firstName, lastName, and email are required.' });
|
|
}
|
|
if (!str(orgName)) {
|
|
return res.status(400).json({ error: 'Organization name is required.' });
|
|
}
|
|
if (!str(state)) {
|
|
return res.status(400).json({ error: 'State is required.' });
|
|
}
|
|
|
|
// Strict email format check — see security.validateEmail
|
|
const emailCheck = security.validateEmail(email);
|
|
if (!emailCheck.ok) {
|
|
return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
|
|
}
|
|
const cleanEmail = emailCheck.email;
|
|
|
|
// Check for duplicate
|
|
const existing = findByEmail.get(cleanEmail);
|
|
if (existing) {
|
|
return res.status(409).json({ error: 'This email is already on the list.', id: existing.id });
|
|
}
|
|
|
|
try {
|
|
const info = insertLead.run({
|
|
firstName: str(firstName),
|
|
lastName: str(lastName),
|
|
email: cleanEmail,
|
|
orgName: str(orgName) || null,
|
|
state: str(state) || 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 });
|
|
});
|
|
|
|
// POST /api/calculate — AI-powered investment recommendation
|
|
app.post('/api/calculate', async (req, res) => {
|
|
function saveCalcSubmission(aiRecommendation) {
|
|
try {
|
|
insertCalcSubmission.run({
|
|
email: cleanEmail,
|
|
optIn: optIn ? 1 : 0,
|
|
homesites: homesites || null,
|
|
propertyType: propertyType || null,
|
|
annualIncome: annualIncome || null,
|
|
paymentFreq: paymentFreq || null,
|
|
reserveFunds: reserveFunds || null,
|
|
interest2025: interest2025 || null,
|
|
totalPotential: totalPotential || null,
|
|
opInterest: opInterest || null,
|
|
resInterest: resInterest || null,
|
|
aiRecommendation: aiRecommendation || null,
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to save calc submission:', err.message);
|
|
}
|
|
}
|
|
|
|
// ── Abuse protection: CAPTCHA, honeypot, form token, rate limit ──
|
|
// Runs before anything is written to the DB or sent to the AI provider.
|
|
const guard = await security.guardSubmission(req);
|
|
if (!guard.ok) {
|
|
return res.status(guard.status).json({ error: guard.error, blocked: true });
|
|
}
|
|
|
|
const {
|
|
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
|
|
email, optIn, totalPotential, opInterest, resInterest,
|
|
} = req.body ?? {};
|
|
|
|
// Email is optional here, but if one is supplied it must be a real address.
|
|
// Nothing is stored or sent onward until it passes.
|
|
const emailCheck = security.validateEmail(email, { required: false });
|
|
if (!emailCheck.ok) {
|
|
console.warn(`[abuse] rejected email from ${guard.ip}: ${JSON.stringify(String(email).slice(0, 120))}`);
|
|
return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
|
|
}
|
|
const cleanEmail = emailCheck.email;
|
|
|
|
if (!annualIncome) {
|
|
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
|
|
}
|
|
|
|
// Homesites must be a real community size; tiny/placeholder values are spam.
|
|
const homesitesCheck = security.validateHomesites(homesites);
|
|
if (!homesitesCheck.ok) {
|
|
console.warn(`[abuse] rejected homesites from ${guard.ip}: ${JSON.stringify(homesites)}`);
|
|
return res.status(400).json({ error: security.messageFor(homesitesCheck.reason), field: 'homesites' });
|
|
}
|
|
|
|
if (!aiClient) {
|
|
saveCalcSubmission(null);
|
|
return res.status(503).json({ error: 'AI service not configured.' });
|
|
}
|
|
|
|
const fmt = n => '$' + Math.round(n).toLocaleString();
|
|
const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || '';
|
|
const freqDivisor = { monthly: 12, quarterly: 4, annually: 1 }[paymentFreq] || 12;
|
|
const installmentAmt = annualIncome / freqDivisor;
|
|
const freqDesc = { monthly: 'monthly installments', quarterly: 'quarterly installments', annually: 'one lump-sum annual payment' }[paymentFreq] || 'monthly installments';
|
|
|
|
const prompt = `You are a conservative HOA financial advisor. Given the following community data, provide a brief (3-4 sentence) plain-English investment income recommendation. Use only conservative, realistic estimates. Do not speculate beyond what the data supports.
|
|
|
|
Community: ${homesites}-unit ${typeLabel} association
|
|
Total annual dues income: ${fmt(annualIncome)} per year
|
|
Dues collection schedule: collected in ${freqDesc} of approximately ${fmt(installmentAmt)} per cycle (this affects operating cash flow timing, not the total annual amount)
|
|
Reserve fund balance: ${fmt(reserveFunds || 0)}
|
|
Interest income earned in 2025: ${fmt(interest2025 || 0)}
|
|
|
|
Provide a recommendation focused on:
|
|
1. How much of the reserve funds could conservatively be invested and in what vehicle (e.g. CD ladder, money market, T-bills)
|
|
2. How much operating cash float could earn interest between each collection cycle and upcoming expenses, given the ${freqDesc} schedule
|
|
3. A realistic estimated annual interest income potential based on the full ${fmt(annualIncome)} annual dues total
|
|
4. A single sentence comparing that to their 2025 actual if provided
|
|
|
|
Keep the tone professional and factual. No bullet points — flowing paragraph only.`;
|
|
|
|
if (AI_DEBUG) {
|
|
console.log('[AI_DEBUG] model:', AI_MODEL);
|
|
console.log('[AI_DEBUG] prompt:', prompt);
|
|
}
|
|
|
|
try {
|
|
const completion = await aiClient.chat.completions.create({
|
|
model: AI_MODEL,
|
|
max_tokens: 300,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
});
|
|
|
|
const text = completion.choices[0]?.message?.content ?? '';
|
|
|
|
if (AI_DEBUG) console.log('[AI_DEBUG] response:', text);
|
|
|
|
saveCalcSubmission(text);
|
|
res.json({ recommendation: text });
|
|
} catch (err) {
|
|
console.error('AI API error:', err.message);
|
|
saveCalcSubmission(null);
|
|
res.status(502).json({ error: 'AI service unavailable. Showing estimated result.' });
|
|
}
|
|
});
|
|
|
|
// GET /api/calc-submissions — internal: list all calculator submissions
|
|
app.get('/api/calc-submissions', (req, res) => {
|
|
const secret = req.headers['x-admin-key'];
|
|
if (!secret || secret !== process.env.ADMIN_KEY) {
|
|
return res.status(401).json({ error: 'Unauthorized.' });
|
|
}
|
|
const rows = getAllCalcSubmissions.all();
|
|
res.json({ count: rows.length, submissions: rows });
|
|
});
|
|
|
|
// 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`);
|
|
});
|