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>
204 lines
7.5 KiB
JavaScript
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]))
|
|
}
|