Files
HOALedgerIQ_Website/server.js
olsch01 ec0b8feac5 Fix AI prompt ambiguity around annual dues income vs. collection frequency
Clarify that annualIncome is the total per year, and payment frequency
describes the cash flow timing of installments — not a multiplier on income.
Include per-installment dollar amount so the model understands cash float correctly.

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

275 lines
11 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 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`);
});