diff --git a/db/migrations/001_initial_schema.js b/db/migrations/001_initial_schema.js new file mode 100644 index 0000000..ae6293c --- /dev/null +++ b/db/migrations/001_initial_schema.js @@ -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 }; diff --git a/db/migrations/001_initial_schema.sql b/db/migrations/001_initial_schema.sql new file mode 100644 index 0000000..a1ba3d0 --- /dev/null +++ b/db/migrations/001_initial_schema.sql @@ -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() +); diff --git a/server/src/db.js b/server/src/db.js index f18ec8e..128e503 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -1,7 +1,12 @@ const { Pool } = require('pg'); +const { runMigrations } = require('../../db/migrations/001_initial_schema'); const pool = new Pool({ connectionString: process.env.DATABASE_URL, }); -module.exports = pool; +async function initialize() { + await runMigrations(pool); +} + +module.exports = { pool, runMigrations, initialize }; diff --git a/server/src/index.js b/server/src/index.js index 1eb1215..ce355ce 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -3,6 +3,7 @@ const express = require('express'); const cors = require('cors'); const path = require('path'); const healthRouter = require('./routes/health'); +const db = require('./db'); const app = express(); const PORT = process.env.PORT || 3000; @@ -22,6 +23,9 @@ app.get('*', (req, res) => { res.sendFile(path.join(clientDist, 'index.html')); }); -app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); -}); +(async () => { + await db.initialize(); + app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + }); +})();