Maintenance log book app — initial scaffold

Multi-department fault log: NewBook-synced room locations + manual
locations with categories, six-state task flow (submitted/in progress/
hold-parts/hold-later/temporary fix/fixed), photos per stage, priorities
with unusable flag and per-task NewBook out-of-order push, costs on
resolve, comment/audit thread, recurring task templates with
note-to-template carryover, asset register, contractor register with
document attachments, staff/contractor allocation, occupancy-aware
summary filter, searchable history with CSV export, email notifications.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-03 21:28:57 +00:00
commit 6ca395097e
47 changed files with 6727 additions and 0 deletions

195
backend/src/db.js Normal file
View file

@ -0,0 +1,195 @@
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,
newbook_blocked 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 | newbook_block | newbook_unblock | 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);
`)
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,
newbook_block_status: 'Maintenance',
newbook_unblock_status: 'Dirty',
}
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]))
}