maintenance/backend/src/db.js
jtricerolph 43809b7460 Add Web Push notifications for task assignment
When a task is created or reassigned to a staff member, a push notification
is sent to any devices they have subscribed on. Keys are auto-generated on
first boot and stored in the config table (no manual VAPID setup needed).

- Backend: web-push dep, push_subscriptions table, lib/push.js (VAPID +
  send), routes/push.js (vapid-key / subscribe / unsubscribe endpoints),
  push notify wired into task-core createTask and tasks PATCH reassignment
- Frontend: switched vite-plugin-pwa to injectManifest strategy, custom
  sw.js handles precache + push event + notificationclick, usePushSubscription
  hook requests permission and registers subscription on login

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-20 22:59:16 +00:00

204 lines
7.5 KiB
JavaScript

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]))
}