import pg from 'pg' import { hashPassword } from './jwt.js' const { Pool } = pg export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) export async function initDb() { await pool.query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL, password_hash TEXT NOT NULL, offsite_allowed BOOLEAN NOT NULL DEFAULT FALSE, active BOOLEAN NOT NULL DEFAULT TRUE, is_admin BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS apps ( id SERIAL PRIMARY KEY, slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, description TEXT, base_path TEXT NOT NULL, icon TEXT NOT NULL DEFAULT 'ClipboardList', theme_color TEXT NOT NULL DEFAULT '#1e3a5f', category VARCHAR(100), internal_host TEXT, internal_port INTEGER DEFAULT 3080, active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS user_app_perms ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, app_id INTEGER NOT NULL REFERENCES apps(id) ON DELETE CASCADE, granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, app_id) ); CREATE TABLE IF NOT EXISTS roles ( id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, slug TEXT UNIQUE NOT NULL, description TEXT, is_default BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS role_app_perms ( role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, app_id INTEGER NOT NULL REFERENCES apps(id) ON DELETE CASCADE, PRIMARY KEY (role_id, app_id) ); CREATE TABLE IF NOT EXISTS user_roles ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'workforce_department', 'default')), PRIMARY KEY (user_id, role_id) ); CREATE TABLE IF NOT EXISTS workforce_department_roles ( department_id TEXT NOT NULL PRIMARY KEY, department_name TEXT NOT NULL, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS pending_registrations ( id SERIAL PRIMARY KEY, email TEXT NOT NULL, wf_user_id TEXT NOT NULL, wf_name TEXT NOT NULL, pin_hash TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Per-app granular capabilities. An app declares the capabilities it exposes -- (e.g. cashup → finalise, reports). The JWT carries them as ":". CREATE TABLE IF NOT EXISTS app_capabilities ( id SERIAL PRIMARY KEY, app_id INTEGER NOT NULL REFERENCES apps(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, description TEXT, sort_order INTEGER NOT NULL DEFAULT 0, UNIQUE (app_id, slug) ); CREATE TABLE IF NOT EXISTS role_capabilities ( role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, capability_id INTEGER NOT NULL REFERENCES app_capabilities(id) ON DELETE CASCADE, PRIMARY KEY (role_id, capability_id) ); CREATE TABLE IF NOT EXISTS user_capabilities ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, capability_id INTEGER NOT NULL REFERENCES app_capabilities(id) ON DELETE CASCADE, granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, capability_id) ); `) // Migrations for existing installs await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS category VARCHAR(100)`) await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS internal_host TEXT`) await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS internal_port INTEGER DEFAULT 3080`) await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS workforce_user_id TEXT`) await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS users_workforce_user_id_idx ON users (workforce_user_id) WHERE workforce_user_id IS NOT NULL`) await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS pending_reg_email_idx ON pending_registrations (email)`) // Seed built-in apps — internal_host/port used by management for health checks and auto-deploy await pool.query(` INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) VALUES ('noticeboard', 'Noticeboard', 'Staff notices and announcements', '/notices', 'ClipboardList', '#1e3a5f', NULL, '10.10.10.112', 3080), ('kitchen', 'Kitchen Flash','Invoice processing and GP tracking', '/kitchen', 'ChefHat', '#e85d04', 'Kitchen', '10.10.10.110', 3080), ('cashup', 'Cash Up', 'Hotel daily cashing up', '/cashup', 'Banknote', '#6b2d8b', 'Finance', '10.10.10.117', 3083), ('housekeeping','Housekeeping', 'Room status and task management', '/hk', 'BedDouble', '#2d6a4f', 'Hotel', '10.10.10.114', 3080), ('hk-planner', 'Rota Check', 'Housekeeping workload and hours planning', '/hk-planner', 'CalendarClock', '#2d6a4f', 'Housekeeping', '10.10.10.118', 3080), ('twin-optimiser', 'Twin Optimiser', 'Identify twin room opportunities from booking grid', '/twin-optimiser', 'LayoutGrid', '#c9841a', 'Housekeeping', '10.10.10.119', 3080), ('forecasting', 'Forecasting', 'Revenue forecasting and reporting', '/forecasting', 'TrendingUp', '#0077b6', 'Finance', '10.10.10.113', 3080), ('rates', 'Rate Scraper', 'Competitor rate monitoring', '/rates', 'Tag', '#7b4f00', 'Finance', '10.10.10.115', 3080), ('maintenance', 'Maintenance', 'Maintenance log book — faults, recurring tasks, assets and contractors', '/maintenance', 'Wrench', '#b45309', 'Operations', '10.10.10.121', 3080) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, icon = EXCLUDED.icon, category = EXCLUDED.category, internal_host = EXCLUDED.internal_host, internal_port = EXCLUDED.internal_port `) // Seed default Staff role await pool.query(` INSERT INTO roles (name, slug, description, is_default) VALUES ('Staff', 'staff', 'Default role assigned to all self-registered employees', TRUE) ON CONFLICT (slug) DO NOTHING `) // Seed cashup capabilities (idempotent). 'count' is the baseline everyone // with app access should have; the rest are additive privilege gates. await pool.query(` INSERT INTO app_capabilities (app_id, slug, name, description, sort_order) SELECT a.id, c.slug, c.name, c.description, c.sort_order FROM apps a CROSS JOIN (VALUES ('count', 'Count cash ups', 'Create and edit draft daily cash ups', 1), ('finalise', 'Finalise cash ups', 'Submit final, delete drafts, bulk-finalise', 2), ('history', 'View history', 'View the cash up history list', 3), ('reports', 'Weekly report', 'Weekly / multi-day report and debtors', 4), ('cash_summary', 'Cash summary', 'Cash summary by denomination across date range', 5), ('floats', 'Manage floats', 'Float ledger: petty cash and change tin', 6), ('safe_count', 'Safe count', 'Safe cash count', 7), ('settings', 'Manage settings', 'App settings: Newbook config, GL columns, thresholds', 8) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'cashup' ON CONFLICT (app_id, slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, sort_order = EXCLUDED.sort_order `) // Non-breaking initial seed: grant the default Staff role every cashup capability // except 'settings' only when Staff has no cashup caps yet (first deploy). await pool.query(` INSERT INTO role_capabilities (role_id, capability_id) SELECT r.id, ac.id FROM roles r JOIN app_capabilities ac ON ac.app_id = (SELECT id FROM apps WHERE slug = 'cashup') JOIN apps a ON a.id = ac.app_id WHERE r.slug = 'staff' AND ac.slug IN ('count', 'finalise', 'history', 'reports', 'cash_summary', 'floats', 'safe_count') AND NOT EXISTS ( SELECT 1 FROM role_capabilities rc JOIN app_capabilities ac2 ON ac2.id = rc.capability_id WHERE rc.role_id = r.id AND ac2.app_id = a.id ) ON CONFLICT DO NOTHING `) // Additive migration: grant Staff the three new split caps individually if // not already present (handles existing installs that already had the old caps). await pool.query(` INSERT INTO role_capabilities (role_id, capability_id) SELECT r.id, ac.id FROM roles r JOIN app_capabilities ac ON ac.app_id = (SELECT id FROM apps WHERE slug = 'cashup') WHERE r.slug = 'staff' AND ac.slug IN ('history', 'cash_summary', 'safe_count') AND NOT EXISTS ( SELECT 1 FROM role_capabilities rc WHERE rc.role_id = r.id AND rc.capability_id = ac.id ) ON CONFLICT DO NOTHING `) // Seed hk-planner capabilities await pool.query(` INSERT INTO app_capabilities (app_id, slug, name, description, sort_order) SELECT a.id, c.slug, c.name, c.description, c.sort_order FROM apps a CROSS JOIN (VALUES ('planner', 'Use planner', 'View occupancy, adjust pickup, staff rota, time requirements', 1), ('settings', 'Category settings', 'Reorder and hide Newbook room categories', 2) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'hk-planner' ON CONFLICT (app_id, slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, sort_order = EXCLUDED.sort_order `) // Grant Staff role the planner cap by default (settings is admin-only) await pool.query(` INSERT INTO role_capabilities (role_id, capability_id) SELECT r.id, ac.id FROM roles r JOIN app_capabilities ac ON ac.app_id = (SELECT id FROM apps WHERE slug = 'hk-planner') JOIN apps a ON a.id = ac.app_id WHERE r.slug = 'staff' AND ac.slug IN ('planner') AND NOT EXISTS ( SELECT 1 FROM role_capabilities rc JOIN app_capabilities ac2 ON ac2.id = rc.capability_id WHERE rc.role_id = r.id AND ac2.app_id = a.id ) ON CONFLICT DO NOTHING `) // Seed forecasting capabilities await pool.query(` INSERT INTO app_capabilities (app_id, slug, name, description, sort_order) SELECT a.id, c.slug, c.name, c.description, c.sort_order FROM apps a CROSS JOIN (VALUES ('view', 'View Forecasts', 'View dashboard, forecasts, and history', 1), ('view_bookability', 'View Bookability', 'View the rate / bookability matrix', 2), ('view_competitor_rates', 'View Competitors', 'View competitor rate scraping and matrix', 3), ('view_accuracy', 'View Accuracy', 'View model accuracy metrics and backtesting', 4), ('manage_sync', 'Manage Data Sync', 'Trigger Newbook / Resos data syncs manually', 5), ('settings', 'Manage Settings', 'Configure Newbook credentials and sync schedules', 6) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'forecasting' ON CONFLICT (app_id, slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, sort_order = EXCLUDED.sort_order `) // Seed first admin user if table is empty const { rows } = await pool.query('SELECT COUNT(*) FROM users') if (parseInt(rows[0].count) === 0) { const email = process.env.ADMIN_EMAIL const password = process.env.ADMIN_PASSWORD if (email && password) { await pool.query( `INSERT INTO users (email, name, password_hash, is_admin, offsite_allowed) VALUES ($1, $2, $3, true, true)`, [email, 'Admin', await hashPassword(password)] ) console.log(`Created initial admin: ${email}`) } } }