/** * 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`); });