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

8
backend/Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev
COPY src ./src
RUN mkdir -p /app/uploads
EXPOSE 3001
CMD ["node", "src/index.js"]

19
backend/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "hnf-maintenance-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"nodemailer": "^6.9.16",
"pg": "^8.13.1"
}
}

57
backend/src/auth.js Normal file
View file

@ -0,0 +1,57 @@
import { jwtVerify } from 'jose'
import { isOnsite } from './ip-check.js'
const APP_SLUG = process.env.APP_SLUG || 'maintenance'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
export async function requireAuth(request, reply) {
const token = request.cookies?.hnf_session
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
let payload
try {
const { payload: p } = await jwtVerify(token, secret)
payload = p
} catch {
return reply.status(401).send({ error: 'Invalid session' })
}
if (!payload.apps?.includes(APP_SLUG)) {
return reply.status(403).send({ error: 'No permission for this app' })
}
if (!payload.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) {
return reply.status(403).send({ error: 'Access restricted to site network' })
}
}
const prefix = `${APP_SLUG}:`
let caps
if (Array.isArray(payload.caps)) {
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
} else {
// Legacy token — grant basic caps until re-login
caps = ['view', 'report']
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
caps,
}
}
export function hasCap(request, cap) {
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
}
export function requireCap(cap) {
return async (request, reply) => {
if (!hasCap(request, cap)) {
return reply.status(403).send({ error: `Missing capability: ${cap}` })
}
}
}

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

54
backend/src/index.js Normal file
View file

@ -0,0 +1,54 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import multipart from '@fastify/multipart'
import staticFiles from '@fastify/static'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { initDb } from './db.js'
import { startScheduler } from './lib/scheduler.js'
import { locationRoutes } from './routes/locations.js'
import { taskRoutes } from './routes/tasks.js'
import { photoRoutes } from './routes/photos.js'
import { historyRoutes } from './routes/history.js'
import { assetRoutes } from './routes/assets.js'
import { contractorRoutes } from './routes/contractors.js'
import { templateRoutes } from './routes/templates.js'
import { configRoutes } from './routes/config.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const UPLOADS_DIR = join(__dirname, '..', 'uploads')
const app = Fastify({ logger: true, trustProxy: true })
await app.register(cookie)
await app.register(cors, {
origin: process.env.CORS_ORIGIN || false,
credentials: true,
})
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } })
await app.register(staticFiles, {
root: UPLOADS_DIR,
prefix: '/api/uploads/',
decorateReply: false,
})
app.get('/health', async () => ({ status: 'healthy' }))
await app.register(locationRoutes)
await app.register(taskRoutes)
await app.register(photoRoutes, { uploadsDir: UPLOADS_DIR })
await app.register(historyRoutes)
await app.register(assetRoutes)
await app.register(contractorRoutes, { uploadsDir: UPLOADS_DIR })
await app.register(templateRoutes)
await app.register(configRoutes)
try {
await initDb()
startScheduler(app)
await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

80
backend/src/ip-check.js Normal file
View file

@ -0,0 +1,80 @@
import dns from 'dns/promises'
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
const TTL = 5 * 60 * 1000
const cache = new Map()
const PUBLIC_IP_URLS = [
'https://api.ipify.org',
'https://ifconfig.co/ip',
'https://icanhazip.com',
]
function normalizeIP(ip) {
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
}
function isIPv4(s) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
}
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/')
if (!isIPv4(ip) || !isIPv4(range)) return false
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
return (toInt(ip) & mask) === (toInt(range) & mask)
}
async function fetchPublicIP() {
for (const url of PUBLIC_IP_URLS) {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 4000)
const res = await fetch(url, { signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) continue
const ip = (await res.text()).trim()
if (isIPv4(ip)) return ip
} catch {
// try next
}
}
return null
}
async function resolveDynamic(key, resolver) {
const hit = cache.get(key)
if (hit && Date.now() < hit.expiry) return hit.ip
const ip = await resolver()
if (ip) {
cache.set(key, { ip, expiry: Date.now() + TTL })
return ip
}
return hit ? hit.ip : null
}
export async function isOnsite(requestIP) {
if (matchers.length === 0 || matchers.includes('disabled')) return true
const ip = normalizeIP(requestIP)
if (!ip) return false
for (const m of matchers) {
if (m === 'auto') {
const pub = await resolveDynamic('auto', fetchPublicIP)
if (pub && ip === pub) return true
} else if (m.includes('/')) {
if (ipInCidr(ip, m)) return true
} else if (/[a-zA-Z]/.test(m)) {
const resolved = await resolveDynamic(m, async () => {
try { return (await dns.resolve4(m))[0] } catch { return null }
})
if (resolved && ip === resolved) return true
} else {
if (ip === m) return true
}
}
return false
}

79
backend/src/lib/mailer.js Normal file
View file

@ -0,0 +1,79 @@
import nodemailer from 'nodemailer'
const SETTINGS_URL = process.env.SETTINGS_URL || ''
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
let _smtpCache = null // { config, expires_at }
let _transporter = null
async function getSmtpConfig() {
if (_smtpCache && Date.now() < _smtpCache.expires_at) return _smtpCache.config
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/smtp`, {
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`Failed to fetch SMTP config from settings: ${res.status}`)
const config = await res.json()
if (!config.host) throw new Error('SMTP not configured in settings')
_smtpCache = { config, expires_at: Date.now() + 5 * 60_000 }
_transporter = null
return config
}
async function getTransporter() {
if (_transporter) return _transporter
const config = await getSmtpConfig()
const port = parseInt(config.port || '587')
_transporter = nodemailer.createTransport({
host: config.host,
port,
secure: port === 465,
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
})
return _transporter
}
const HOTEL_NAME = process.env.VITE_HOTEL_NAME || 'Hotel'
// Fire-and-forget: email failure must never block a task write.
async function send(to, subject, text) {
if (!to) return
try {
const config = await getSmtpConfig()
const transport = await getTransporter()
await transport.sendMail({
from: config.from || `"${HOTEL_NAME} Maintenance" <noreply@localhost>`,
to,
subject,
text,
})
} catch (err) {
console.error(`Maintenance mail to ${to} failed: ${err.message}`)
}
}
function taskSummary(task, locationName) {
const lines = [
`Task: ${task.title}`,
`Location: ${locationName}`,
`Priority: ${task.priority}`,
]
if (task.description) lines.push('', task.description)
return lines.join('\n')
}
export function notifyAssignment(task, locationName, toEmail) {
return send(
toEmail,
`[Maintenance] Assigned to you: ${task.title}`,
`A maintenance task has been assigned to you.\n\n${taskSummary(task, locationName)}`
)
}
export function notifyUrgent(task, locationName, toEmail) {
return send(
toEmail,
`[Maintenance] URGENT: ${task.title}`,
`An urgent maintenance task has been logged.\n\n${taskSummary(task, locationName)}${task.unusable ? '\n\nLocation flagged as UNUSABLE.' : ''}`
)
}

View file

@ -0,0 +1,71 @@
const API_BASE = 'https://api.newbook.cloud/rest/'
async function getCredentials() {
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/newbook`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching NewBook credentials`)
const s = await res.json()
return {
username: s.username || '',
password: s.password || '',
apiKey: s.api_key || '',
region: s.region || 'eu',
}
}
async function callApi(endpoint, data = {}) {
const creds = await getCredentials()
if (!creds.username || !creds.password || !creds.apiKey) {
throw new Error('NewBook API credentials not configured')
}
const locationId = process.env.NEWBOOK_LOCATION_ID
const body = { ...data, region: creds.region, api_key: creds.apiKey }
if (locationId) body.location_id = locationId
const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64')
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 30000)
try {
const res = await fetch(API_BASE + endpoint, {
method: 'POST',
signal: ctrl.signal,
headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}` },
body: JSON.stringify(body),
})
clearTimeout(timer)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`NewBook API ${res.status}: ${text.slice(0, 200)}`)
}
return await res.json()
} catch (err) {
clearTimeout(timer)
throw err
}
}
// Fetch all sites (rooms): site_id, site_name, site_status, site_category_id, site_category_name.
export async function fetchSites() {
const res = await callApi('sites_list', {})
return res?.data ?? []
}
// Fetch bookings spanning a date range (list_type 'all' includes arrived/confirmed/etc).
export async function fetchBookings(fromDate, toDate) {
const res = await callApi('bookings_list', {
period_from: `${fromDate} 00:00:00`,
period_to: `${toDate} 23:59:59`,
list_type: 'all',
})
return res?.data ?? []
}
// Update room status in NewBook. NewBook expects 'status' parameter (not 'site_status').
export async function updateSiteStatus(siteId, status) {
return callApi('sites_update', { site_id: siteId, status })
}

View file

@ -0,0 +1,67 @@
import { pool } from '../db.js'
import { createTask, logEvent } from './task-core.js'
const CHECK_INTERVAL_MS = 60 * 60 * 1000 // hourly
function addInterval(date, value, unit) {
const d = new Date(date)
if (unit === 'days') d.setDate(d.getDate() + value)
else if (unit === 'weeks') d.setDate(d.getDate() + value * 7)
else d.setMonth(d.getMonth() + value)
return d
}
function isoDate(d) {
return d.toISOString().slice(0, 10)
}
// Spawn one task per due template, then advance next_due past today so a
// backlog after downtime produces a single task, not one per missed occurrence.
export async function spawnDueTemplates(log = console) {
const today = isoDate(new Date())
const { rows: due } = await pool.query(
`SELECT * FROM task_templates WHERE active = TRUE AND next_due <= $1 ORDER BY id`,
[today]
)
for (const tpl of due) {
try {
const task = await createTask({
title: tpl.title,
description: tpl.description,
location_id: tpl.location_id,
asset_id: tpl.asset_id,
template_id: tpl.id,
priority: tpl.priority,
unusable: tpl.unusable,
due_date: tpl.next_due,
assigned_type: tpl.assigned_type,
assigned_to: tpl.assigned_to,
assigned_to_name: tpl.assigned_to_name,
contractor_id: tpl.contractor_id,
}, { email: null, name: 'Scheduler' })
// Notes accumulated from previous occurrences appear in the task thread.
if (tpl.template_notes) {
await logEvent(task.id, 'comment', {
note: `Notes from previous occurrences:\n${tpl.template_notes}`,
userName: 'Scheduler',
})
}
let next = addInterval(tpl.next_due, tpl.interval_value, tpl.interval_unit)
while (isoDate(next) <= today) next = addInterval(next, tpl.interval_value, tpl.interval_unit)
await pool.query('UPDATE task_templates SET next_due = $1 WHERE id = $2', [isoDate(next), tpl.id])
log.info?.(`Scheduler spawned task ${task.id} from template ${tpl.id} (${tpl.title})`)
} catch (err) {
log.error?.(`Scheduler failed for template ${tpl.id}: ${err.message}`)
}
}
return due.length
}
export function startScheduler(app) {
spawnDueTemplates(app.log).catch(err => app.log.error(err))
setInterval(() => spawnDueTemplates(app.log).catch(err => app.log.error(err)), CHECK_INTERVAL_MS)
}

View file

@ -0,0 +1,80 @@
import { pool, getConfig } from '../db.js'
import { notifyAssignment, notifyUrgent } from './mailer.js'
export const PRIORITIES = ['low', 'medium', 'high', 'urgent']
export const STATUSES = ['submitted', 'in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed']
// Legal state transitions. temporary_fix still counts as open.
export const TRANSITIONS = {
submitted: ['in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
in_progress: ['submitted', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
hold_parts: ['submitted', 'in_progress', 'hold_scheduled', 'temporary_fix', 'fixed'],
hold_scheduled: ['submitted', 'in_progress', 'hold_parts', 'temporary_fix', 'fixed'],
temporary_fix: ['submitted', 'in_progress', 'fixed'],
fixed: ['submitted'], // reopen only
}
export async function logEvent(taskId, eventType, { fromStatus = null, toStatus = null, note = null, userName = null } = {}) {
await pool.query(
`INSERT INTO task_events (task_id, event_type, from_status, to_status, note, user_name)
VALUES ($1, $2, $3, $4, $5, $6)`,
[taskId, eventType, fromStatus, toStatus, note, userName]
)
}
// Shared by the tasks route and the recurring-template scheduler.
// input.assigned_* fall back to the configured defaults when absent.
export async function createTask(input, actor) {
const config = await getConfig()
let assignedType = input.assigned_type || config.default_assigned_type || 'staff'
let assignedTo = input.assigned_to ?? null
let assignedToName = input.assigned_to_name ?? null
let contractorId = input.contractor_id ?? null
if (!input.assigned_type && assignedTo == null && contractorId == null) {
if (assignedType === 'contractor' && config.default_contractor_id) {
contractorId = config.default_contractor_id
} else if (config.default_assignee) {
assignedType = 'staff'
assignedTo = config.default_assignee
assignedToName = config.default_assignee_name || config.default_assignee
}
}
if (assignedType === 'contractor') { assignedTo = null; assignedToName = null }
else contractorId = null
const { rows } = await pool.query(
`INSERT INTO tasks (title, description, location_id, asset_id, template_id, priority, unusable,
due_date, assigned_type, assigned_to, assigned_to_name, contractor_id,
created_by, created_by_name)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
[
input.title, input.description || null, input.location_id, input.asset_id || null,
input.template_id || null, input.priority || 'medium', input.unusable === true,
input.due_date || null, assignedType, assignedTo, assignedToName, contractorId,
actor.email, actor.name,
]
)
const task = rows[0]
await logEvent(task.id, 'created', { toStatus: 'submitted', userName: actor.name })
const { rows: locRows } = await pool.query('SELECT name FROM locations WHERE id = $1', [task.location_id])
const locationName = locRows[0]?.name || `#${task.location_id}`
// Notifications are fire-and-forget; mailer swallows errors.
if (config.notify_on_assign) {
if (task.assigned_type === 'staff' && task.assigned_to && task.assigned_to !== actor.email) {
notifyAssignment(task, locationName, task.assigned_to)
} else if (task.assigned_type === 'contractor' && task.contractor_id) {
const { rows: c } = await pool.query('SELECT email FROM contractors WHERE id = $1', [task.contractor_id])
if (c[0]?.email) notifyAssignment(task, locationName, c[0].email)
}
}
if (config.notify_on_urgent && task.priority === 'urgent' && config.urgent_notify_email) {
notifyUrgent(task, locationName, config.urgent_notify_email)
}
return task
}

View file

@ -0,0 +1,75 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function assetRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/assets — register with location names + open task counts
app.get('/api/assets', { preHandler: requireCap('view') }, async (req) => {
const includeInactive = req.query.include_inactive === 'true'
const { rows } = await pool.query(
`SELECT a.*, l.name AS location_name, c.name AS category_name,
(SELECT COUNT(*)::int FROM tasks t WHERE t.asset_id = a.id AND t.status != 'fixed') AS open_tasks,
(SELECT COUNT(*)::int FROM task_templates tp WHERE tp.asset_id = a.id AND tp.active = TRUE) AS recurring_count
FROM assets a
JOIN locations l ON l.id = a.location_id
JOIN location_categories c ON c.id = l.category_id
${includeInactive ? '' : 'WHERE a.active = TRUE'}
ORDER BY l.name, a.name`
)
return rows
})
// GET /api/assets/:id — detail with task history + linked recurring templates
app.get('/api/assets/:id', { preHandler: requireCap('view') }, async (req, reply) => {
const { rows } = await pool.query(
`SELECT a.*, l.name AS location_name FROM assets a JOIN locations l ON l.id = a.location_id WHERE a.id = $1`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Asset not found' })
const { rows: tasks } = await pool.query(
`SELECT t.id, t.title, t.status, t.priority, t.created_at, t.completed_at, t.completed_by_name
FROM tasks t WHERE t.asset_id = $1 ORDER BY t.created_at DESC LIMIT 100`,
[req.params.id]
)
const { rows: templates } = await pool.query(
`SELECT id, title, interval_value, interval_unit, next_due, active
FROM task_templates WHERE asset_id = $1 ORDER BY next_due`,
[req.params.id]
)
return { ...rows[0], tasks, templates }
})
app.post('/api/assets', { preHandler: requireCap('manage_assets') }, async (req, reply) => {
const b = req.body || {}
if (!b.name || !b.location_id) return reply.status(400).send({ error: 'name and location_id required' })
const { rows } = await pool.query(
`INSERT INTO assets (name, location_id, make_model, serial_no, install_date, notes)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[b.name, b.location_id, b.make_model || null, b.serial_no || null, b.install_date || null, b.notes || null]
)
return rows[0]
})
app.patch('/api/assets/:id', { preHandler: requireCap('manage_assets') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM assets WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Asset not found' })
const a = existing[0]
const b = req.body || {}
const { rows } = await pool.query(
`UPDATE assets SET name = $1, location_id = $2, make_model = $3, serial_no = $4,
install_date = $5, notes = $6, active = $7 WHERE id = $8 RETURNING *`,
[
b.name ?? a.name, b.location_id ?? a.location_id,
b.make_model !== undefined ? b.make_model : a.make_model,
b.serial_no !== undefined ? b.serial_no : a.serial_no,
b.install_date !== undefined ? b.install_date : a.install_date,
b.notes !== undefined ? b.notes : a.notes,
b.active ?? a.active,
req.params.id,
]
)
return rows[0]
})
}

View file

@ -0,0 +1,26 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function configRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/config — all config keys as a flat object
app.get('/api/config', { preHandler: requireCap('view') }, async () => {
const { rows } = await pool.query('SELECT key, value FROM config ORDER BY key')
return Object.fromEntries(rows.map(r => [r.key, r.value]))
})
// PUT /api/config/:key — update a single config key
app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => {
const { key } = req.params
const { value } = req.body || {}
if (value === undefined) return reply.status(400).send({ error: 'value required' })
await pool.query(
`INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
[key, JSON.stringify(value)]
)
return { ok: true }
})
}

View file

@ -0,0 +1,126 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { createWriteStream } from 'fs'
import { mkdir, unlink } from 'fs/promises'
import { randomUUID } from 'crypto'
import { extname, join } from 'path'
const ALLOWED_DOCS = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf']
export async function contractorRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir
app.addHook('preHandler', requireAuth)
// GET /api/contractors — list (view cap: needed for the allocation selector)
app.get('/api/contractors', { preHandler: requireCap('view') }, async (req) => {
const includeInactive = req.query.include_inactive === 'true'
const { rows } = await pool.query(
`SELECT c.*,
(SELECT COUNT(*)::int FROM tasks t WHERE t.contractor_id = c.id AND t.status != 'fixed') AS open_tasks,
(SELECT COUNT(*)::int FROM contractor_docs d WHERE d.contractor_id = c.id) AS doc_count,
(SELECT MIN(d.expiry_date) FROM contractor_docs d
WHERE d.contractor_id = c.id AND d.expiry_date IS NOT NULL) AS earliest_doc_expiry
FROM contractors c
${includeInactive ? '' : 'WHERE c.active = TRUE'}
ORDER BY c.name`
)
return rows
})
// GET /api/contractors/:id — detail with docs + recent tasks
app.get('/api/contractors/:id', { preHandler: requireCap('view') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM contractors WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Contractor not found' })
const { rows: docs } = await pool.query(
'SELECT * FROM contractor_docs WHERE contractor_id = $1 ORDER BY uploaded_at DESC', [req.params.id]
)
const { rows: tasks } = await pool.query(
`SELECT t.id, t.title, t.status, t.priority, t.created_at, t.completed_at, l.name AS location_name
FROM tasks t JOIN locations l ON l.id = t.location_id
WHERE t.contractor_id = $1 ORDER BY t.created_at DESC LIMIT 50`,
[req.params.id]
)
return { ...rows[0], docs, tasks }
})
app.post('/api/contractors', { preHandler: requireCap('manage_contractors') }, async (req, reply) => {
const b = req.body || {}
if (!b.name) return reply.status(400).send({ error: 'name required' })
const { rows } = await pool.query(
`INSERT INTO contractors (name, company, phone, email, address, notes)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[b.name, b.company || null, b.phone || null, b.email || null, b.address || null, b.notes || null]
)
return rows[0]
})
app.patch('/api/contractors/:id', { preHandler: requireCap('manage_contractors') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM contractors WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Contractor not found' })
const c = existing[0]
const b = req.body || {}
const { rows } = await pool.query(
`UPDATE contractors SET name = $1, company = $2, phone = $3, email = $4, address = $5, notes = $6, active = $7
WHERE id = $8 RETURNING *`,
[
b.name ?? c.name,
b.company !== undefined ? b.company : c.company,
b.phone !== undefined ? b.phone : c.phone,
b.email !== undefined ? b.email : c.email,
b.address !== undefined ? b.address : c.address,
b.notes !== undefined ? b.notes : c.notes,
b.active ?? c.active,
req.params.id,
]
)
return rows[0]
})
// POST /api/contractors/:id/docs — multipart: file + doc_type + optional expiry_date
app.post('/api/contractors/:id/docs', { preHandler: requireCap('manage_contractors') }, async (req, reply) => {
const contractorId = parseInt(req.params.id)
const { rows } = await pool.query('SELECT id FROM contractors WHERE id = $1', [contractorId])
if (!rows.length) return reply.status(404).send({ error: 'Contractor not found' })
let fileData = null, docType = null, expiryDate = null
for await (const part of req.parts()) {
if (part.type === 'file') {
if (!ALLOWED_DOCS.includes(part.mimetype)) {
return reply.status(400).send({ error: 'Only JPEG, PNG, WebP and PDF files are allowed' })
}
const ext = extname(part.filename) || '.bin'
const filename = randomUUID() + ext
const dir = join(UPLOADS_DIR, 'contractors', String(contractorId))
await mkdir(dir, { recursive: true })
let size = 0
const dest = createWriteStream(join(dir, filename))
for await (const chunk of part.file) { dest.write(chunk); size += chunk.length }
await new Promise(r => dest.end(r))
fileData = { filename: part.filename, mimetype: part.mimetype, savedAs: filename, size }
} else {
const val = String(await part.value || '')
if (part.fieldname === 'doc_type') docType = val || null
if (part.fieldname === 'expiry_date') expiryDate = val || null
}
}
if (!fileData?.savedAs) return reply.status(400).send({ error: 'No file uploaded' })
const filePath = `/contractors/${contractorId}/${fileData.savedAs}`
const { rows: ins } = await pool.query(
`INSERT INTO contractor_docs (contractor_id, file_name, file_path, mime_type, file_size, doc_type, expiry_date, uploaded_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[contractorId, fileData.filename, filePath, fileData.mimetype, fileData.size, docType, expiryDate, req.user.email]
)
return ins[0]
})
app.delete('/api/contractor-docs/:id', { preHandler: requireCap('manage_contractors') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM contractor_docs WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
await unlink(join(UPLOADS_DIR, rows[0].file_path)).catch(() => {})
await pool.query('DELETE FROM contractor_docs WHERE id = $1', [req.params.id])
return { ok: true }
})
}

View file

@ -0,0 +1,102 @@
import { requireAuth, requireCap, hasCap } from '../auth.js'
import { pool } from '../db.js'
const HISTORY_SELECT = `
SELECT t.*,
l.name AS location_name,
c.id AS category_id, c.name AS category_name,
a.name AS asset_name,
ct.name AS contractor_name,
(SELECT COUNT(*)::int FROM task_photos p WHERE p.task_id = t.id) AS photo_count,
EXTRACT(EPOCH FROM (t.completed_at - t.created_at)) / 86400.0 AS days_to_fix
FROM tasks t
JOIN locations l ON l.id = t.location_id
JOIN location_categories c ON c.id = l.category_id
LEFT JOIN assets a ON a.id = t.asset_id
LEFT JOIN contractors ct ON ct.id = t.contractor_id
`
function buildWhere(q, push) {
const clauses = []
if (q.include_temporary === 'true') clauses.push(`t.status IN ('fixed', 'temporary_fix')`)
else clauses.push(`t.status = 'fixed'`)
if (q.q) {
const term = push(`%${q.q}%`)
clauses.push(`(t.title ILIKE ${term} OR t.description ILIKE ${term} OR l.name ILIKE ${term})`)
}
if (q.from) clauses.push(`t.completed_at >= ${push(q.from)}`)
if (q.to) clauses.push(`t.completed_at < (${push(q.to)}::date + 1)`)
if (q.category_id) clauses.push(`c.id = ${push(parseInt(q.category_id))}`)
if (q.location_id) clauses.push(`t.location_id = ${push(parseInt(q.location_id))}`)
if (q.asset_id) clauses.push(`t.asset_id = ${push(parseInt(q.asset_id))}`)
return clauses
}
export async function historyRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/history — searchable closed tasks + totals
app.get('/api/history', { preHandler: requireCap('view') }, async (req) => {
const vals = []
const push = v => { vals.push(v); return `$${vals.length}` }
const clauses = buildWhere(req.query, push)
const limit = Math.min(parseInt(req.query.limit) || 100, 500)
const offset = parseInt(req.query.offset) || 0
const sql = `${HISTORY_SELECT} WHERE ${clauses.join(' AND ')}
ORDER BY t.completed_at DESC LIMIT ${limit} OFFSET ${offset}`
let { rows } = await pool.query(sql, vals)
const { rows: totals } = await pool.query(
`SELECT COUNT(*)::int AS count,
COALESCE(SUM(t.cost), 0)::numeric(12,2) AS total_cost,
ROUND(AVG(EXTRACT(EPOCH FROM (t.completed_at - t.created_at)) / 86400.0)::numeric, 1) AS avg_days_to_fix
FROM tasks t
JOIN locations l ON l.id = t.location_id
JOIN location_categories c ON c.id = l.category_id
WHERE ${clauses.join(' AND ')}`,
vals
)
const showCosts = hasCap(req, 'costs')
if (!showCosts) rows = rows.map(({ cost, cost_notes, ...rest }) => rest)
return {
tasks: rows,
totals: {
count: totals[0].count,
total_cost: showCosts ? totals[0].total_cost : null,
avg_days_to_fix: totals[0].avg_days_to_fix,
},
}
})
// GET /api/history/export — CSV of the same filtered set
app.get('/api/history/export', { preHandler: requireCap('view') }, async (req, reply) => {
const vals = []
const push = v => { vals.push(v); return `$${vals.length}` }
const clauses = buildWhere(req.query, push)
const { rows } = await pool.query(
`${HISTORY_SELECT} WHERE ${clauses.join(' AND ')} ORDER BY t.completed_at DESC LIMIT 5000`,
vals
)
const showCosts = hasCap(req, 'costs')
const cols = ['id', 'title', 'location_name', 'category_name', 'asset_name', 'priority', 'status',
'created_by_name', 'created_at', 'completed_by_name', 'completed_at', 'days_to_fix']
if (showCosts) cols.push('cost', 'cost_notes')
const esc = v => {
if (v == null) return ''
const s = String(v)
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
const csv = [cols.join(',')]
for (const r of rows) csv.push(cols.map(c => esc(r[c])).join(','))
reply.header('Content-Type', 'text/csv')
reply.header('Content-Disposition', 'attachment; filename="maintenance-history.csv"')
return csv.join('\n')
})
}

View file

@ -0,0 +1,123 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { fetchSites } from '../lib/newbook.js'
export async function locationRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/locations — all locations grouped with categories
app.get('/api/locations', { preHandler: requireCap('view') }, async () => {
const { rows: categories } = await pool.query(
'SELECT * FROM location_categories ORDER BY sort_order, name'
)
const { rows: locations } = await pool.query(
`SELECT l.*, c.name AS category_name, c.is_rooms
FROM locations l JOIN location_categories c ON c.id = l.category_id
ORDER BY c.sort_order, l.sort_order, l.name`
)
return { categories, locations }
})
// POST /api/locations — create manual location
app.post('/api/locations', { preHandler: requireCap('manage_locations') }, async (req, reply) => {
const { name, category_id, sort_order } = req.body || {}
if (!name || !category_id) return reply.status(400).send({ error: 'name and category_id required' })
const { rows } = await pool.query(
`INSERT INTO locations (name, category_id, source, sort_order)
VALUES ($1, $2, 'manual', $3) RETURNING *`,
[name, category_id, sort_order || 0]
)
return rows[0]
})
// PATCH /api/locations/:id — rename / recategorise / activate / order
app.patch('/api/locations/:id', { preHandler: requireCap('manage_locations') }, async (req, reply) => {
const { name, category_id, active, sort_order } = req.body || {}
const { rows: existing } = await pool.query('SELECT * FROM locations WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Location not found' })
const loc = existing[0]
const { rows } = await pool.query(
`UPDATE locations SET name = $1, category_id = $2, active = $3, sort_order = $4 WHERE id = $5 RETURNING *`,
[
name ?? loc.name,
category_id ?? loc.category_id,
active ?? loc.active,
sort_order ?? loc.sort_order,
req.params.id,
]
)
return rows[0]
})
// POST /api/locations/sync-newbook — upsert NewBook sites into the rooms category
app.post('/api/locations/sync-newbook', { preHandler: requireCap('manage_locations') }, async (req, reply) => {
const { rows: cats } = await pool.query(
'SELECT id FROM location_categories WHERE is_rooms = TRUE ORDER BY sort_order LIMIT 1'
)
if (!cats.length) return reply.status(400).send({ error: 'No rooms category configured' })
const roomsCategoryId = cats[0].id
let sites
try {
sites = await fetchSites()
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
let created = 0, updated = 0
for (const site of sites) {
const siteId = String(site.site_id)
const name = site.site_name || `Room ${siteId}`
const res = await pool.query(
`INSERT INTO locations (name, category_id, source, newbook_site_id, sort_order)
VALUES ($1, $2, 'newbook', $3, $4)
ON CONFLICT (newbook_site_id) DO UPDATE SET name = EXCLUDED.name, active = TRUE
RETURNING (xmax = 0) AS inserted`,
[name, roomsCategoryId, siteId, parseInt(site.site_order) || 0]
)
res.rows[0].inserted ? created++ : updated++
}
// Rooms no longer in NewBook are deactivated, not deleted (history keeps its FK).
const siteIds = sites.map(s => String(s.site_id))
if (siteIds.length) {
await pool.query(
`UPDATE locations SET active = FALSE
WHERE source = 'newbook' AND NOT (newbook_site_id = ANY($1))`,
[siteIds]
)
}
return { ok: true, created, updated, total: sites.length }
})
// Category management
app.post('/api/categories', { preHandler: requireCap('manage_locations') }, async (req, reply) => {
const { name, sort_order, is_rooms } = req.body || {}
if (!name) return reply.status(400).send({ error: 'name required' })
const { rows } = await pool.query(
`INSERT INTO location_categories (name, sort_order, is_rooms) VALUES ($1, $2, $3) RETURNING *`,
[name, sort_order || 0, is_rooms === true]
)
return rows[0]
})
app.patch('/api/categories/:id', { preHandler: requireCap('manage_locations') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM location_categories WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Category not found' })
const cat = existing[0]
const { name, sort_order, is_rooms } = req.body || {}
const { rows } = await pool.query(
`UPDATE location_categories SET name = $1, sort_order = $2, is_rooms = $3 WHERE id = $4 RETURNING *`,
[name ?? cat.name, sort_order ?? cat.sort_order, is_rooms ?? cat.is_rooms, req.params.id]
)
return rows[0]
})
app.delete('/api/categories/:id', { preHandler: requireCap('manage_locations') }, async (req, reply) => {
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM locations WHERE category_id = $1', [req.params.id])
if (rows[0].n > 0) return reply.status(409).send({ error: 'Category has locations — move them first' })
await pool.query('DELETE FROM location_categories WHERE id = $1', [req.params.id])
return { ok: true }
})
}

View file

@ -0,0 +1,70 @@
import { requireAuth, requireCap, hasCap } from '../auth.js'
import { pool } from '../db.js'
import { logEvent } from '../lib/task-core.js'
import { createWriteStream } from 'fs'
import { mkdir, unlink } from 'fs/promises'
import { randomUUID } from 'crypto'
import { extname, join } from 'path'
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
const STAGES = ['report', 'progress', 'resolution']
export async function photoRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir
app.addHook('preHandler', requireAuth)
// POST /api/tasks/:id/photos — multipart: file + optional stage field
app.post('/api/tasks/:id/photos', { preHandler: requireCap('report') }, async (req, reply) => {
const taskId = parseInt(req.params.id)
const { rows } = await pool.query('SELECT id FROM tasks WHERE id = $1', [taskId])
if (!rows.length) return reply.status(404).send({ error: 'Task not found' })
let fileData = null, stage = 'report'
for await (const part of req.parts()) {
if (part.type === 'file') {
fileData = part
// must consume the file stream inside the loop — save it now
if (!ALLOWED_IMAGES.includes(part.mimetype)) {
return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' })
}
const ext = extname(part.filename) || '.jpg'
const filename = randomUUID() + ext
const dir = join(UPLOADS_DIR, 'tasks', String(taskId))
await mkdir(dir, { recursive: true })
let size = 0
const dest = createWriteStream(join(dir, filename))
for await (const chunk of part.file) { dest.write(chunk); size += chunk.length }
await new Promise(r => dest.end(r))
fileData = { filename: part.filename, mimetype: part.mimetype, savedAs: filename, size }
} else {
const val = await part.value
if (part.fieldname === 'stage' && STAGES.includes(String(val))) stage = String(val)
}
}
if (!fileData?.savedAs) return reply.status(400).send({ error: 'No file uploaded' })
const filePath = `/tasks/${taskId}/${fileData.savedAs}`
const { rows: ins } = await pool.query(
`INSERT INTO task_photos (task_id, file_name, file_path, mime_type, file_size, stage, uploaded_by)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[taskId, fileData.filename, filePath, fileData.mimetype, fileData.size, stage, req.user.email]
)
await logEvent(taskId, 'photo', { note: `Photo added (${stage})`, userName: req.user.name })
return ins[0]
})
// DELETE /api/photos/:id — uploader or update cap
app.delete('/api/photos/:id', { preHandler: requireCap('report') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM task_photos WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
const photo = rows[0]
if (photo.uploaded_by !== req.user.email && !hasCap(req, 'update')) {
return reply.status(403).send({ error: 'Can only delete your own photos' })
}
await unlink(join(UPLOADS_DIR, photo.file_path)).catch(() => {})
await pool.query('DELETE FROM task_photos WHERE id = $1', [req.params.id])
return { ok: true }
})
}

315
backend/src/routes/tasks.js Normal file
View file

@ -0,0 +1,315 @@
import { requireAuth, requireCap, hasCap } from '../auth.js'
import { pool, getConfig } from '../db.js'
import { createTask, logEvent, TRANSITIONS, PRIORITIES, STATUSES } from '../lib/task-core.js'
import { fetchBookings, updateSiteStatus } from '../lib/newbook.js'
import { notifyAssignment } from '../lib/mailer.js'
const PRIORITY_ORDER = `CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END`
const TASK_SELECT = `
SELECT t.*,
l.name AS location_name, l.source AS location_source, l.newbook_site_id,
c.id AS category_id, c.name AS category_name, c.is_rooms,
a.name AS asset_name,
ct.name AS contractor_name, ct.company AS contractor_company,
(SELECT COUNT(*)::int FROM task_photos p WHERE p.task_id = t.id) AS photo_count
FROM tasks t
JOIN locations l ON l.id = t.location_id
JOIN location_categories c ON c.id = l.category_id
LEFT JOIN assets a ON a.id = t.asset_id
LEFT JOIN contractors ct ON ct.id = t.contractor_id
`
function stripCosts(task) {
const { cost, cost_notes, ...rest } = task
return rest
}
async function fetchOccupiedSiteIds() {
const today = new Date().toISOString().slice(0, 10)
const bookings = await fetchBookings(today, today)
const occupied = new Set()
for (const b of bookings) {
if (String(b.booking_status).toLowerCase() === 'arrived' && b.site_id != null) {
occupied.add(String(b.site_id))
}
}
return occupied
}
export async function taskRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/tasks — open tasks with filters
// ?status=csv &category_id= &location_id= &priority= &assigned_to= &q= &unoccupied=true
app.get('/api/tasks', { preHandler: requireCap('view') }, async (req, reply) => {
const q = req.query
const clauses = []
const vals = []
const push = v => { vals.push(v); return `$${vals.length}` }
if (q.status) {
const statuses = String(q.status).split(',').filter(s => STATUSES.includes(s))
if (statuses.length) clauses.push(`t.status = ANY(${push(statuses)})`)
else clauses.push(`t.status != 'fixed'`)
} else {
clauses.push(`t.status != 'fixed'`) // default: open tasks
}
if (q.category_id) clauses.push(`c.id = ${push(parseInt(q.category_id))}`)
if (q.location_id) clauses.push(`t.location_id = ${push(parseInt(q.location_id))}`)
if (q.asset_id) clauses.push(`t.asset_id = ${push(parseInt(q.asset_id))}`)
if (q.priority && PRIORITIES.includes(q.priority)) clauses.push(`t.priority = ${push(q.priority)}`)
if (q.assigned_to) clauses.push(`t.assigned_to = ${push(q.assigned_to)}`)
if (q.contractor_id) clauses.push(`t.contractor_id = ${push(parseInt(q.contractor_id))}`)
if (q.q) {
const term = push(`%${q.q}%`)
clauses.push(`(t.title ILIKE ${term} OR t.description ILIKE ${term})`)
}
const sql = `${TASK_SELECT} WHERE ${clauses.join(' AND ')} ORDER BY ${PRIORITY_ORDER}, t.created_at ASC`
let { rows } = await pool.query(sql, vals)
// "Unoccupied rooms only": keep only rooms-category tasks whose NewBook room
// has no in-house (arrived) booking right now.
if (q.unoccupied === 'true') {
let occupied
try {
occupied = await fetchOccupiedSiteIds()
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
rows = rows.filter(t => t.is_rooms && t.newbook_site_id && !occupied.has(t.newbook_site_id))
}
if (!hasCap(req, 'costs')) rows = rows.map(stripCosts)
return rows
})
// GET /api/tasks/:id — full detail with photos + event thread
app.get('/api/tasks/:id', { preHandler: requireCap('view') }, async (req, reply) => {
const { rows } = await pool.query(`${TASK_SELECT} WHERE t.id = $1`, [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Task not found' })
let task = rows[0]
const { rows: photos } = await pool.query(
'SELECT * FROM task_photos WHERE task_id = $1 ORDER BY uploaded_at', [task.id]
)
const { rows: events } = await pool.query(
'SELECT * FROM task_events WHERE task_id = $1 ORDER BY created_at', [task.id]
)
let template = null
if (task.template_id) {
const { rows: tpl } = await pool.query(
'SELECT id, title, interval_value, interval_unit, next_due, active FROM task_templates WHERE id = $1',
[task.template_id]
)
template = tpl[0] || null
}
if (!hasCap(req, 'costs')) task = stripCosts(task)
return { ...task, photos, events, template }
})
// POST /api/tasks — create
app.post('/api/tasks', { preHandler: requireCap('report') }, async (req, reply) => {
const b = req.body || {}
if (!b.title || !b.location_id) return reply.status(400).send({ error: 'title and location_id required' })
if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'Invalid priority' })
const { rows: loc } = await pool.query('SELECT id FROM locations WHERE id = $1 AND active = TRUE', [b.location_id])
if (!loc.length) return reply.status(400).send({ error: 'Unknown or inactive location' })
const task = await createTask(b, { email: req.user.email, name: req.user.name })
return task
})
// PATCH /api/tasks/:id — edit fields, change status, reassign
app.patch('/api/tasks/:id', { preHandler: requireCap('update') }, async (req, reply) => {
const b = req.body || {}
const { rows: existing } = await pool.query('SELECT * FROM tasks WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Task not found' })
const task = existing[0]
const userName = req.user.name
// Status transition
if (b.status && b.status !== task.status) {
if (!STATUSES.includes(b.status)) return reply.status(400).send({ error: 'Invalid status' })
if (!TRANSITIONS[task.status]?.includes(b.status)) {
return reply.status(409).send({ error: `Cannot move from ${task.status} to ${b.status}` })
}
// Resolution statuses must go through /resolve so completed-by/cost are captured
if (['temporary_fix', 'fixed'].includes(b.status)) {
return reply.status(400).send({ error: 'Use /resolve to mark temporary_fix or fixed' })
}
const isReopen = ['fixed', 'temporary_fix'].includes(task.status) && b.status === 'submitted'
await logEvent(task.id, isReopen ? 'reopened' : 'status_change', {
fromStatus: task.status, toStatus: b.status, note: b.note || null, userName,
})
if (isReopen) {
await pool.query(
`UPDATE tasks SET completed_by = NULL, completed_by_name = NULL, completed_at = NULL WHERE id = $1`,
[task.id]
)
}
}
// Reassignment
const reassigning = b.assigned_type !== undefined || b.assigned_to !== undefined || b.contractor_id !== undefined
if (reassigning) {
const newType = b.assigned_type || task.assigned_type
let note
if (newType === 'contractor') {
const { rows: c } = await pool.query('SELECT name, company, email FROM contractors WHERE id = $1', [b.contractor_id])
if (!c.length) return reply.status(400).send({ error: 'Unknown contractor' })
note = `Assigned to contractor: ${c[0].name}${c[0].company ? ` (${c[0].company})` : ''}`
const config = await getConfig()
if (config.notify_on_assign && c[0].email) {
const { rows: l } = await pool.query('SELECT name FROM locations WHERE id = $1', [task.location_id])
notifyAssignment(task, l[0]?.name || '', c[0].email)
}
} else {
note = b.assigned_to ? `Assigned to ${b.assigned_to_name || b.assigned_to}` : 'Unassigned'
const config = await getConfig()
if (config.notify_on_assign && b.assigned_to && b.assigned_to !== task.assigned_to && b.assigned_to !== req.user.email) {
const { rows: l } = await pool.query('SELECT name FROM locations WHERE id = $1', [task.location_id])
notifyAssignment(task, l[0]?.name || '', b.assigned_to)
}
}
await logEvent(task.id, 'reassigned', { note, userName })
}
const newType = b.assigned_type || task.assigned_type
const { rows } = await pool.query(
`UPDATE tasks SET
title = $1, description = $2, location_id = $3, asset_id = $4,
priority = $5, status = $6, unusable = $7, hold_until = $8, due_date = $9,
assigned_type = $10, assigned_to = $11, assigned_to_name = $12, contractor_id = $13,
updated_at = NOW()
WHERE id = $14 RETURNING *`,
[
b.title ?? task.title,
b.description ?? task.description,
b.location_id ?? task.location_id,
b.asset_id !== undefined ? b.asset_id : task.asset_id,
(b.priority && PRIORITIES.includes(b.priority)) ? b.priority : task.priority,
(b.status && TRANSITIONS[task.status]?.includes(b.status) && !['temporary_fix', 'fixed'].includes(b.status)) ? b.status : task.status,
b.unusable ?? task.unusable,
b.hold_until !== undefined ? b.hold_until : task.hold_until,
b.due_date !== undefined ? b.due_date : task.due_date,
newType,
newType === 'contractor' ? null : (b.assigned_to !== undefined ? b.assigned_to : task.assigned_to),
newType === 'contractor' ? null : (b.assigned_to_name !== undefined ? b.assigned_to_name : task.assigned_to_name),
newType === 'contractor' ? (b.contractor_id !== undefined ? b.contractor_id : task.contractor_id) : null,
task.id,
]
)
return rows[0]
})
// POST /api/tasks/:id/resolve — temporary_fix or fixed, with completed-by and cost
app.post('/api/tasks/:id/resolve', { preHandler: requireCap('resolve') }, async (req, reply) => {
const b = req.body || {}
const status = b.status
if (!['temporary_fix', 'fixed'].includes(status)) {
return reply.status(400).send({ error: 'status must be temporary_fix or fixed' })
}
const { rows: existing } = await pool.query('SELECT * FROM tasks WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Task not found' })
const task = existing[0]
if (!TRANSITIONS[task.status]?.includes(status)) {
return reply.status(409).send({ error: `Cannot move from ${task.status} to ${status}` })
}
const completedBy = b.completed_by || req.user.email
const completedByName = b.completed_by_name || (b.completed_by ? b.completed_by : req.user.name)
const cost = hasCap(req, 'costs') && b.cost != null && b.cost !== '' ? b.cost : null
const { rows } = await pool.query(
`UPDATE tasks SET status = $1, completed_by = $2, completed_by_name = $3, completed_at = NOW(),
cost = COALESCE($4, cost), cost_notes = COALESCE($5, cost_notes), updated_at = NOW()
WHERE id = $6 RETURNING *`,
[status, completedBy, completedByName, cost, b.cost_notes || null, task.id]
)
await logEvent(task.id, 'status_change', {
fromStatus: task.status, toStatus: status,
note: b.note || null, userName: req.user.name,
})
if (cost != null) {
await logEvent(task.id, 'cost', { note: `Cost recorded: £${cost}${b.cost_notes ? `${b.cost_notes}` : ''}`, userName: req.user.name })
}
return rows[0]
})
// POST /api/tasks/:id/comments — note on the thread; optionally append to the recurring template
app.post('/api/tasks/:id/comments', { preHandler: requireCap('report') }, async (req, reply) => {
const { note, add_to_template } = req.body || {}
if (!note) return reply.status(400).send({ error: 'note required' })
const { rows: existing } = await pool.query('SELECT id, template_id FROM tasks WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Task not found' })
const task = existing[0]
await logEvent(task.id, 'comment', { note, userName: req.user.name })
let addedToTemplate = false
if (add_to_template === true && task.template_id) {
const stamp = new Date().toISOString().slice(0, 10)
await pool.query(
`UPDATE task_templates
SET template_notes = CASE WHEN template_notes = '' THEN $1 ELSE template_notes || E'\n' || $1 END
WHERE id = $2`,
[`[${stamp} ${req.user.name}] ${note}`, task.template_id]
)
addedToTemplate = true
}
return { ok: true, added_to_template: addedToTemplate }
})
// POST /api/tasks/:id/newbook-block — set the room out of order in NewBook
app.post('/api/tasks/:id/newbook-block', { preHandler: requireCap('update') }, async (req, reply) => {
return toggleNewbookBlock(req, reply, true)
})
// POST /api/tasks/:id/newbook-unblock — release the room in NewBook
app.post('/api/tasks/:id/newbook-unblock', { preHandler: requireCap('update') }, async (req, reply) => {
return toggleNewbookBlock(req, reply, false)
})
async function toggleNewbookBlock(req, reply, block) {
const { rows } = await pool.query(
`SELECT t.*, l.newbook_site_id, l.source FROM tasks t JOIN locations l ON l.id = t.location_id WHERE t.id = $1`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Task not found' })
const task = rows[0]
if (task.source !== 'newbook' || !task.newbook_site_id) {
return reply.status(400).send({ error: 'Task location is not a NewBook room' })
}
const config = await getConfig()
const status = block ? (config.newbook_block_status || 'Maintenance') : (config.newbook_unblock_status || 'Dirty')
try {
await updateSiteStatus(task.newbook_site_id, status)
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
await pool.query('UPDATE tasks SET newbook_blocked = $1, updated_at = NOW() WHERE id = $2', [block, task.id])
await logEvent(task.id, block ? 'newbook_block' : 'newbook_unblock', {
note: `Room ${block ? 'blocked' : 'released'} in NewBook (status: ${status})`,
userName: req.user.name,
})
return { ok: true, newbook_blocked: block }
}
// GET /api/occupancy — today's in-house NewBook site ids (for the unoccupied filter UI)
app.get('/api/occupancy', { preHandler: requireCap('view') }, async (req, reply) => {
try {
const occupied = await fetchOccupiedSiteIds()
return { date: new Date().toISOString().slice(0, 10), occupied_site_ids: [...occupied] }
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
})
}

View file

@ -0,0 +1,95 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { PRIORITIES } from '../lib/task-core.js'
import { spawnDueTemplates } from '../lib/scheduler.js'
const INTERVAL_UNITS = ['days', 'weeks', 'months']
export async function templateRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/templates — recurring task templates
app.get('/api/templates', { preHandler: requireCap('view') }, async () => {
const { rows } = await pool.query(
`SELECT tp.*, l.name AS location_name, a.name AS asset_name, ct.name AS contractor_name
FROM task_templates tp
JOIN locations l ON l.id = tp.location_id
LEFT JOIN assets a ON a.id = tp.asset_id
LEFT JOIN contractors ct ON ct.id = tp.contractor_id
ORDER BY tp.active DESC, tp.next_due`
)
return rows
})
app.post('/api/templates', { preHandler: requireCap('manage_templates') }, async (req, reply) => {
const b = req.body || {}
if (!b.title || !b.location_id || !b.next_due) {
return reply.status(400).send({ error: 'title, location_id and next_due required' })
}
if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'Invalid priority' })
if (b.interval_unit && !INTERVAL_UNITS.includes(b.interval_unit)) return reply.status(400).send({ error: 'Invalid interval_unit' })
const interval = parseInt(b.interval_value) || 1
if (interval < 1) return reply.status(400).send({ error: 'interval_value must be at least 1' })
const { rows } = await pool.query(
`INSERT INTO task_templates
(title, description, location_id, asset_id, priority, unusable, assigned_type, assigned_to,
assigned_to_name, contractor_id, interval_value, interval_unit, next_due, template_notes, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
[
b.title, b.description || null, b.location_id, b.asset_id || null,
b.priority || 'medium', b.unusable === true,
b.assigned_type === 'contractor' ? 'contractor' : 'staff',
b.assigned_type === 'contractor' ? null : (b.assigned_to || null),
b.assigned_type === 'contractor' ? null : (b.assigned_to_name || null),
b.assigned_type === 'contractor' ? (b.contractor_id || null) : null,
interval, b.interval_unit || 'months', b.next_due,
b.template_notes || '', req.user.email,
]
)
return rows[0]
})
app.patch('/api/templates/:id', { preHandler: requireCap('manage_templates') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM task_templates WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Template not found' })
const t = existing[0]
const b = req.body || {}
if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'Invalid priority' })
if (b.interval_unit && !INTERVAL_UNITS.includes(b.interval_unit)) return reply.status(400).send({ error: 'Invalid interval_unit' })
const newType = b.assigned_type || t.assigned_type
const { rows } = await pool.query(
`UPDATE task_templates SET
title = $1, description = $2, location_id = $3, asset_id = $4, priority = $5, unusable = $6,
assigned_type = $7, assigned_to = $8, assigned_to_name = $9, contractor_id = $10,
interval_value = $11, interval_unit = $12, next_due = $13, template_notes = $14, active = $15
WHERE id = $16 RETURNING *`,
[
b.title ?? t.title,
b.description !== undefined ? b.description : t.description,
b.location_id ?? t.location_id,
b.asset_id !== undefined ? b.asset_id : t.asset_id,
b.priority ?? t.priority,
b.unusable ?? t.unusable,
newType,
newType === 'contractor' ? null : (b.assigned_to !== undefined ? b.assigned_to : t.assigned_to),
newType === 'contractor' ? null : (b.assigned_to_name !== undefined ? b.assigned_to_name : t.assigned_to_name),
newType === 'contractor' ? (b.contractor_id !== undefined ? b.contractor_id : t.contractor_id) : null,
b.interval_value ? parseInt(b.interval_value) : t.interval_value,
b.interval_unit ?? t.interval_unit,
b.next_due ?? t.next_due,
b.template_notes !== undefined ? b.template_notes : t.template_notes,
b.active ?? t.active,
req.params.id,
]
)
return rows[0]
})
// POST /api/templates/run-due — manually trigger the scheduler sweep
app.post('/api/templates/run-due', { preHandler: requireCap('manage_templates') }, async (req) => {
const spawned = await spawnDueTemplates(req.log)
return { ok: true, spawned }
})
}