Files
HOALedgerIQ_Website/server.js
olsch01 8a369f6a57 Add calculator UX enhancements and submission tracking
- Button loading spinner + disable on submit to prevent duplicate AI calls
- Email field and opt-in consent checkbox on calculator form (checked by default)
- Privacy assurance blurb below opt-in
- Refinement blurb on results screen explaining live cash-flow optimization
- calc_submissions SQLite table stores form inputs, computed results, AI text, email, opt-in
- GET /api/calc-submissions endpoint (x-admin-key protected) to retrieve submissions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 10:58:19 -04:00

272 lines
10 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');
// ── 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.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 });
});
// POST /api/calculate — AI-powered investment recommendation
app.post('/api/calculate', async (req, res) => {
function saveCalcSubmission(aiRecommendation) {
try {
insertCalcSubmission.run({
email: email?.trim() || null,
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);
}
}
if (!aiClient) {
saveCalcSubmission(null);
return res.status(503).json({ error: 'AI service not configured.' });
}
const {
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
email, optIn, totalPotential, opInterest, resInterest,
} = req.body ?? {};
if (!homesites || !annualIncome) {
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
}
const fmt = n => '$' + Math.round(n).toLocaleString();
const freqLabel = { monthly: 'monthly', quarterly: 'quarterly', annually: 'annual' }[paymentFreq] || 'monthly';
const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || '';
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
Annual dues income: ${fmt(annualIncome)} (collected ${freqLabel})
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 could earn interest between collection and expense periods
3. A realistic estimated annual interest income potential
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`);
});