Add PostgreSQL schema and migration runner

All tables per PRD data model with default config seeds.
Migration runner tracks applied migrations in DB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 19:03:57 -04:00
parent 83abac52f6
commit adebe10f52
4 changed files with 156 additions and 4 deletions

View File

@@ -0,0 +1,51 @@
const fs = require('fs');
const path = require('path');
async function runMigrations(pool) {
// Create migrations tracking table if it doesn't exist
await pool.query(`
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Read all .sql files in this directory, sorted by name
const migrationsDir = __dirname;
const sqlFiles = fs
.readdirSync(migrationsDir)
.filter((f) => f.endsWith('.sql'))
.sort();
for (const file of sqlFiles) {
// Check if this migration has already been applied
const { rows } = await pool.query(
'SELECT id FROM migrations WHERE name = $1',
[file]
);
if (rows.length > 0) {
continue; // already applied
}
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
// Run the migration in a transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(sql);
await client.query('INSERT INTO migrations (name) VALUES ($1)', [file]);
await client.query('COMMIT');
console.log(`Migration applied: ${file}`);
} catch (err) {
await client.query('ROLLBACK');
throw new Error(`Migration failed for ${file}: ${err.message}`);
} finally {
client.release();
}
}
}
module.exports = { runMigrations };

View File

@@ -0,0 +1,92 @@
-- App configuration (key/value store)
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Seed default config
INSERT INTO config (key, value) VALUES
('paycheck1_day', '1'),
('paycheck2_day', '15'),
('paycheck1_gross', '0'),
('paycheck1_net', '0'),
('paycheck2_gross', '0'),
('paycheck2_net', '0')
ON CONFLICT (key) DO NOTHING;
-- Bill definitions
CREATE TABLE IF NOT EXISTS bills (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
due_day INTEGER NOT NULL CHECK (due_day BETWEEN 1 AND 31),
assigned_paycheck INTEGER NOT NULL CHECK (assigned_paycheck IN (1, 2)),
category TEXT NOT NULL DEFAULT 'General',
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Paycheck instances per period
CREATE TABLE IF NOT EXISTS paychecks (
id SERIAL PRIMARY KEY,
period_year INTEGER NOT NULL,
period_month INTEGER NOT NULL CHECK (period_month BETWEEN 1 AND 12),
paycheck_number INTEGER NOT NULL CHECK (paycheck_number IN (1, 2)),
pay_date DATE NOT NULL,
gross NUMERIC(10,2) NOT NULL DEFAULT 0,
net NUMERIC(10,2) NOT NULL DEFAULT 0,
UNIQUE (period_year, period_month, paycheck_number)
);
-- Bills assigned to a paycheck period
CREATE TABLE IF NOT EXISTS paycheck_bills (
id SERIAL PRIMARY KEY,
paycheck_id INTEGER NOT NULL REFERENCES paychecks(id) ON DELETE CASCADE,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
amount_override NUMERIC(10,2),
paid BOOLEAN NOT NULL DEFAULT FALSE,
paid_at TIMESTAMPTZ,
UNIQUE (paycheck_id, bill_id)
);
-- Savings goals
CREATE TABLE IF NOT EXISTS savings_goals (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
assigned_paycheck INTEGER NOT NULL CHECK (assigned_paycheck IN (1, 2)),
active BOOLEAN NOT NULL DEFAULT TRUE
);
-- One-time expenses
CREATE TABLE IF NOT EXISTS one_time_expenses (
id SERIAL PRIMARY KEY,
paycheck_id INTEGER NOT NULL REFERENCES paychecks(id) ON DELETE CASCADE,
name TEXT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
paid BOOLEAN NOT NULL DEFAULT FALSE,
paid_at TIMESTAMPTZ
);
-- Variable expense categories
CREATE TABLE IF NOT EXISTS expense_categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
-- Seed default categories
INSERT INTO expense_categories (name) VALUES
('Groceries'), ('Gas'), ('Dining'), ('Entertainment'), ('Medical'), ('Other')
ON CONFLICT (name) DO NOTHING;
-- Actual spending log
CREATE TABLE IF NOT EXISTS actuals (
id SERIAL PRIMARY KEY,
paycheck_id INTEGER NOT NULL REFERENCES paychecks(id) ON DELETE CASCADE,
category_id INTEGER REFERENCES expense_categories(id),
bill_id INTEGER REFERENCES bills(id),
amount NUMERIC(10,2) NOT NULL,
note TEXT,
date DATE NOT NULL DEFAULT CURRENT_DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -1,7 +1,12 @@
const { Pool } = require('pg'); const { Pool } = require('pg');
const { runMigrations } = require('../../db/migrations/001_initial_schema');
const pool = new Pool({ const pool = new Pool({
connectionString: process.env.DATABASE_URL, connectionString: process.env.DATABASE_URL,
}); });
module.exports = pool; async function initialize() {
await runMigrations(pool);
}
module.exports = { pool, runMigrations, initialize };

View File

@@ -3,6 +3,7 @@ const express = require('express');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
const healthRouter = require('./routes/health'); const healthRouter = require('./routes/health');
const db = require('./db');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -22,6 +23,9 @@ app.get('*', (req, res) => {
res.sendFile(path.join(clientDist, 'index.html')); res.sendFile(path.join(clientDist, 'index.html'));
}); });
app.listen(PORT, () => { (async () => {
await db.initialize();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`); console.log(`Server running on port ${PORT}`);
}); });
})();