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:
commit
6ca395097e
47 changed files with 6727 additions and 0 deletions
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal 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
19
backend/package.json
Normal 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
57
backend/src/auth.js
Normal 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
195
backend/src/db.js
Normal 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
54
backend/src/index.js
Normal 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
80
backend/src/ip-check.js
Normal 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
79
backend/src/lib/mailer.js
Normal 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.' : ''}`
|
||||
)
|
||||
}
|
||||
71
backend/src/lib/newbook.js
Normal file
71
backend/src/lib/newbook.js
Normal 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 })
|
||||
}
|
||||
67
backend/src/lib/scheduler.js
Normal file
67
backend/src/lib/scheduler.js
Normal 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)
|
||||
}
|
||||
80
backend/src/lib/task-core.js
Normal file
80
backend/src/lib/task-core.js
Normal 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
|
||||
}
|
||||
75
backend/src/routes/assets.js
Normal file
75
backend/src/routes/assets.js
Normal 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]
|
||||
})
|
||||
}
|
||||
26
backend/src/routes/config.js
Normal file
26
backend/src/routes/config.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
126
backend/src/routes/contractors.js
Normal file
126
backend/src/routes/contractors.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
102
backend/src/routes/history.js
Normal file
102
backend/src/routes/history.js
Normal 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')
|
||||
})
|
||||
}
|
||||
123
backend/src/routes/locations.js
Normal file
123
backend/src/routes/locations.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
70
backend/src/routes/photos.js
Normal file
70
backend/src/routes/photos.js
Normal 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
315
backend/src/routes/tasks.js
Normal 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}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
95
backend/src/routes/templates.js
Normal file
95
backend/src/routes/templates.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue