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 apps ADD COLUMN IF NOT EXISTS max_session_hours INTEGER`) 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', 'Operations', '10.10.10.112', 3080), ('kitchen', 'Kitchen', 'Invoice/GP, recipes, menus and kitchen management', '/kitchen', 'ChefHat', '#0d9488', 'Kitchen', '10.10.10.110', 3080), ('cashup', 'Cash Up', 'Hotel daily cashing up', '/cashup', 'Banknote', '#6b2d8b', 'Finance', '10.10.10.117', 3083), ('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), ('history', 'History', 'Hotel and restaurant actuals history charts', '/forecasting/history', 'History', '#0077b6', 'Finance', NULL, NULL), ('rates', 'Rate Monitor', 'Competitor rate monitoring and direct booking engine rates', '/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), ('reports', 'Reports', 'Custom reports for NewBook, ResOS, SambaPOS and internal data', '/reports', 'BarChart2', '#1d4ed8', 'Management', '10.10.10.122', 3080), ('kds', 'Kitchen Display', 'SambaPOS ticket feed and course flow display', '/kds', 'Monitor', '#0d9488', 'Kitchen', '10.10.10.125', 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 restaurant_bookings app — inactive until settings configures the Hosted Tables URL. // ON CONFLICT DO NOTHING so restarts never reset base_path/active managed by settings. await pool.query(` INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, active) VALUES ('restaurant_bookings', 'Table Bookings', 'Hosted table reservation system', '/bookings', 'UtensilsCrossed', '#1a1a2e', 'Restaurant', FALSE) ON CONFLICT (slug) DO NOTHING `) // 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_accuracy', 'View Accuracy', 'View model accuracy metrics and backtesting', 2), ('manage_sync', 'Manage Data Sync', 'Trigger Newbook / Resos data syncs manually', 3), ('settings', 'Manage Settings', 'Configure Newbook credentials and sync schedules', 4) ) 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 rates 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_own_rates', 'View Bookability', 'View own hotel rate and tariff availability', 1), ('view_competitors', 'View Market View', 'View competitor Booking.com rates', 2), ('view_direct_rates', 'View Direct Rates', 'View competitor direct booking engine rates', 3), ('rate_analysis', 'Rate Analysis', 'Drill into competitor pricing structure', 4), ('manage_scraper', 'Manage Scraper', 'Configure scraper, trigger manual scrapes', 5), ('manage_hotels', 'Manage Competitors', 'Classify and configure competitor hotels', 6) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'rates' ON CONFLICT (app_id, slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, sort_order = EXCLUDED.sort_order `) // Seed reports 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 & Run Reports', 'Browse and run all custom reports', 1), ('export', 'Export to CSV', 'Download report results as a CSV file', 2), ('edit', 'Edit Directors Forecast', 'Edit pickup/dry/wet overrides and save forecast snapshots', 3) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'reports' ON CONFLICT (app_id, slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, sort_order = EXCLUDED.sort_order `) // Seed kitchen 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 app', 'Access the kitchen app and dashboard', 1), ('invoices', 'View invoices', 'View invoice list, details and search', 2), ('invoices_manage', 'Manage invoices', 'Upload, edit, approve and delete invoices', 3), ('disputes', 'Disputes', 'Open, manage and resolve invoice disputes', 4), ('logbook', 'Wastage logbook', 'Record and view wastage logbook entries', 5), ('orders', 'Purchase orders', 'Create and manage purchase orders', 6), ('recipes', 'Recipes', 'View and edit recipes, ingredients and allergens', 7), ('menus', 'Menus', 'Build, edit and publish menus and dishes', 8), ('manage_flags', 'Manage flags', 'Review and dismiss food compliance and allergen flags', 9), ('settings', 'Manage settings', 'App settings: integrations, API keys, SambaPOS config', 10) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'kitchen' ON CONFLICT (app_id, slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, sort_order = EXCLUDED.sort_order `) // Seed KDS 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 board', 'View the KDS ticket board', 1), ('manage', 'Manage courses', 'Call away, mark sent and clear courses on live tickets', 2), ('settings', 'Manage settings', 'KDS timer thresholds, SambaPOS GraphQL config', 3) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'kds' ON CONFLICT (app_id, slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, sort_order = EXCLUDED.sort_order `) // Grant Staff role KDS view + manage (needed during service; settings stays 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 = 'kds') WHERE r.slug = 'staff' AND ac.slug IN ('view', 'manage') 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 wages app + capabilities (management/finance only — no default Staff grants) await pool.query(` INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) VALUES ('wages', 'Wage Costs', 'Live wage cost reporting — weekly, monthly, and rolling history vs budget and net sales', '/wages', 'DollarSign', '#065f46', 'Finance', '10.10.10.124', 3080) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, base_path = EXCLUDED.base_path, icon = EXCLUDED.icon, theme_color = EXCLUDED.theme_color, category = EXCLUDED.category, internal_host = EXCLUDED.internal_host, internal_port = EXCLUDED.internal_port `) 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 Reports', 'View all wage cost reports (weekly, monthly, rolling)', 1), ('budget', 'Edit Budgets', 'Set monthly wage budget targets', 2), ('sync', 'Manual Sync', 'Trigger a Workforce API data sync or backfill', 3), ('settings', 'Settings', 'App settings, API configuration and department filter', 4) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'wages' ON CONFLICT (app_id, slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, sort_order = EXCLUDED.sort_order `) // Seed utilities app + capabilities (no default Staff grants — admin assigns via portal) await pool.query(` INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) VALUES ('utilities', 'Utilities', 'Meter readings, tariffs and energy cost tracking', '/utilities', 'Zap', '#1e6091', 'Hotel', '10.10.10.127', 3080) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, base_path = EXCLUDED.base_path, icon = EXCLUDED.icon, theme_color = EXCLUDED.theme_color, category = EXCLUDED.category, internal_host = EXCLUDED.internal_host, internal_port = EXCLUDED.internal_port `) 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 ('readings', 'Enter Readings', 'Enter manual meter readings and view reading history', 1), ('meters', 'Manage Meters', 'Create/edit categories, meters, locations and images', 2), ('tariffs', 'Manage Tariffs', 'Create/edit tariffs, rate windows, standing charges and CCL', 3), ('reports', 'View Reports', 'View consumption and cost reports', 4), ('estimates', 'View Estimates', 'View and adjust period cost estimates', 5), ('settings', 'Settings', 'App settings', 6) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'utilities' ON CONFLICT (app_id, slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, sort_order = EXCLUDED.sort_order `) // Seed hvac app + capabilities (no default Staff grants — admin assigns via portal). // LXC 128 — 127 was already claimed by 'utilities' by the time this was built. await pool.query(` INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) VALUES ('hvac', 'HVAC', 'Room heating control — NewBook-driven TRV scheduling, aircon and boiler (phased)', '/hvac', 'Thermometer', '#c1440e', 'Hotel', '10.10.10.128', 3080) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, base_path = EXCLUDED.base_path, icon = EXCLUDED.icon, theme_color = EXCLUDED.theme_color, category = EXCLUDED.category, internal_host = EXCLUDED.internal_host, internal_port = EXCLUDED.internal_port `) 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', 'View zone dashboard, device status and activity', 1), ('control', 'Manual Override', 'Force a zone''s temperature and disable auto mode', 2), ('schedule_edit', 'Edit Schedules', 'Adjust per-zone temps, offsets and auto mode', 3), ('manage_devices', 'Manage Devices', 'Discover, map, photograph devices; sync zones from NewBook', 4), ('public_area_control', 'Public Area Control', 'Central control of public-area zones (Phase 3)', 5), ('boiler_view', 'Boiler — View', 'View boiler controller status (Phase 4)', 6), ('boiler_control', 'Boiler — Control', 'Adjust boiler weather-compensation / pump disable (Phase 4)',7), ('settings', 'Settings', 'Configure hvac app settings', 8) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'hvac' 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}`) } } }