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:
51
db/migrations/001_initial_schema.js
Normal file
51
db/migrations/001_initial_schema.js
Normal 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 };
|
||||||
92
db/migrations/001_initial_schema.sql
Normal file
92
db/migrations/001_initial_schema.sql
Normal 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()
|
||||||
|
);
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await db.initialize();
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server running on port ${PORT}`);
|
console.log(`Server running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user