import pg from 'pg' 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 config ( key TEXT PRIMARY KEY, value JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Location categories: Rooms, Kitchen, Public Areas, External, Garden, ... -- is_rooms marks the category whose locations sync from NewBook and -- participate in the occupancy filter / out-of-order push. CREATE TABLE IF NOT EXISTS location_categories ( id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, sort_order INT NOT NULL DEFAULT 0, is_rooms BOOLEAN NOT NULL DEFAULT FALSE ); CREATE TABLE IF NOT EXISTS locations ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, category_id INT NOT NULL REFERENCES location_categories(id), source TEXT NOT NULL DEFAULT 'manual', -- manual | newbook newbook_site_id TEXT UNIQUE, active BOOLEAN NOT NULL DEFAULT TRUE, sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS assets ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, location_id INT NOT NULL REFERENCES locations(id), make_model TEXT, serial_no TEXT, install_date DATE, notes TEXT, active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS contractors ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, company TEXT, phone TEXT, email TEXT, address TEXT, notes TEXT, active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS contractor_docs ( id SERIAL PRIMARY KEY, contractor_id INT NOT NULL REFERENCES contractors(id) ON DELETE CASCADE, file_name TEXT NOT NULL, file_path TEXT NOT NULL, mime_type TEXT, file_size INT, doc_type TEXT, -- e.g. Liability insurance, Gas Safe cert expiry_date DATE, uploaded_by TEXT, uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Recurring task templates. Scheduler spawns a task when next_due arrives. CREATE TABLE IF NOT EXISTS task_templates ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, description TEXT, location_id INT NOT NULL REFERENCES locations(id), asset_id INT REFERENCES assets(id), priority TEXT NOT NULL DEFAULT 'medium', unusable BOOLEAN NOT NULL DEFAULT FALSE, assigned_type TEXT NOT NULL DEFAULT 'staff', -- staff | contractor assigned_to TEXT, assigned_to_name TEXT, contractor_id INT REFERENCES contractors(id), interval_value INT NOT NULL DEFAULT 1, interval_unit TEXT NOT NULL DEFAULT 'months', -- days | weeks | months next_due DATE NOT NULL, template_notes TEXT NOT NULL DEFAULT '', -- carried onto every future occurrence active BOOLEAN NOT NULL DEFAULT TRUE, created_by TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS tasks ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, description TEXT, location_id INT NOT NULL REFERENCES locations(id), asset_id INT REFERENCES assets(id), template_id INT REFERENCES task_templates(id), priority TEXT NOT NULL DEFAULT 'medium', -- low | medium | high | urgent status TEXT NOT NULL DEFAULT 'submitted', unusable BOOLEAN NOT NULL DEFAULT FALSE, hold_until DATE, due_date DATE, assigned_type TEXT NOT NULL DEFAULT 'staff', -- staff | contractor assigned_to TEXT, assigned_to_name TEXT, contractor_id INT REFERENCES contractors(id), created_by TEXT, created_by_name TEXT, completed_by TEXT, completed_by_name TEXT, completed_at TIMESTAMPTZ, cost NUMERIC(10,2), cost_notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS tasks_status_idx ON tasks (status); CREATE INDEX IF NOT EXISTS tasks_location_idx ON tasks (location_id); CREATE INDEX IF NOT EXISTS tasks_assigned_idx ON tasks (assigned_to); CREATE INDEX IF NOT EXISTS tasks_completed_idx ON tasks (completed_at DESC); CREATE TABLE IF NOT EXISTS task_photos ( id SERIAL PRIMARY KEY, task_id INT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, file_name TEXT NOT NULL, file_path TEXT NOT NULL, mime_type TEXT, file_size INT, stage TEXT NOT NULL DEFAULT 'report', -- report | progress | resolution uploaded_by TEXT, uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Audit trail + comment thread per task. CREATE TABLE IF NOT EXISTS task_events ( id SERIAL PRIMARY KEY, task_id INT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, event_type TEXT NOT NULL, -- created | status_change | reassigned | comment | photo | cost | reopened | edited from_status TEXT, to_status TEXT, note TEXT, user_name TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS task_events_task_idx ON task_events (task_id, created_at); CREATE TABLE IF NOT EXISTS push_subscriptions ( id SERIAL PRIMARY KEY, user_email TEXT NOT NULL, endpoint TEXT NOT NULL UNIQUE, p256dh TEXT NOT NULL, auth TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS push_subs_email_idx ON push_subscriptions (user_email); `) await seedDefaults() } async function seedDefaults() { const categories = [ { name: 'Rooms', sort: 1, is_rooms: true }, { name: 'Kitchen', sort: 2, is_rooms: false }, { name: 'Public Areas', sort: 3, is_rooms: false }, { name: 'External', sort: 4, is_rooms: false }, { name: 'Garden', sort: 5, is_rooms: false }, ] for (const c of categories) { await pool.query( `INSERT INTO location_categories (name, sort_order, is_rooms) VALUES ($1, $2, $3) ON CONFLICT (name) DO NOTHING`, [c.name, c.sort, c.is_rooms] ) } const defaults = { default_assigned_type: 'staff', default_assignee: '', // staff email default_assignee_name: '', default_contractor_id: null, urgent_notify_email: '', notify_on_assign: true, notify_on_urgent: true, } for (const [key, value] of Object.entries(defaults)) { await pool.query( `INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, [key, JSON.stringify(value)] ) } } export async function getConfig() { const { rows } = await pool.query('SELECT key, value FROM config') return Object.fromEntries(rows.map(r => [r.key, r.value])) }