feat: add room-planner app — 3-day HK room view with NewBook integration

NewBook-connected daily housekeeping planner. Replaces the hotelhubmodule-housekeeping-dailylist
WordPress plugin. LXC 120 · 10.10.10.120:3080 · slug: room-planner.

- 3-day booking window (yesterday/today/tomorrow) fetched live from NewBook
- Task completion ticks back to NewBook; room status patches NewBook directly
- 23px border sliver CSS system for adjacent-day booking status
- 3-state filter cycling (off→inclusive→exclusive) for categories and flow types
- Stat filters for outstanding tasks and clean/dirty status
- Rolling 48h activity log with checkout/checkin/status/tasks events
- newbook_pings event bus for future NewBook poller integration
- Room modal with permission-gated guest/rate/notes, task checkboxes, status buttons
- Placeholder sections for future linen-count and routine-tasks modules
- Settings page: task type colours, twin/extra-bed detection, category exclusions
- Mobile-first layout (sidebar desktop, compact top bar mobile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-03 13:07:00 +00:00
commit 1e658b6a48
39 changed files with 3765 additions and 0 deletions

7
backend/Dockerfile Normal file
View file

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

16
backend/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "hnf-room-planner-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": "^4.28.1",
"jose": "^5.9.6",
"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 || 'room-planner'
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 all non-settings caps until re-login
caps = ['view', 'guest_details', 'rate_details', 'view_all_notes', 'complete_tasks', 'update_status']
}
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}` })
}
}
}

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

@ -0,0 +1,70 @@
import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function initDb() {
await pool.query(`
-- Instance-level config: task display, twin detection, note visibility, etc.
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Rolling activity log. Pruned to 48h on each write.
CREATE TABLE IF NOT EXISTS activity_log (
id SERIAL PRIMARY KEY,
room_id TEXT NOT NULL,
event_type TEXT NOT NULL,
event_data JSONB,
user_name TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
service_date DATE NOT NULL,
booking_ref TEXT
);
CREATE INDEX IF NOT EXISTS activity_log_date_idx ON activity_log (service_date, occurred_at DESC);
-- Lightweight NewBook change signals from the poller. No booking data stored.
CREATE TABLE IF NOT EXISTS newbook_pings (
id SERIAL PRIMARY KEY,
booking_ids TEXT[] NOT NULL,
event_types TEXT[] NOT NULL,
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '5 minutes'
);
CREATE INDEX IF NOT EXISTS newbook_pings_expires_idx ON newbook_pings (expires_at);
`)
await seedDefaultConfig()
}
async function seedDefaultConfig() {
const defaults = {
task_display: {},
twin_detection: {
enabled: true,
custom_field_ids: [],
keywords: ['twin', 'two single', '2 single', 'single beds', 'sofabed', 'sofa bed'],
exclude_keywords: ['twin room', 'twin suite'],
},
extra_bed_detection: {
enabled: true,
keywords: ['extra bed', 'extra cot', 'rollaway', 'roll away', 'fold out'],
},
excluded_categories: [],
hide_excluded_categories: false,
visible_note_types: [],
checkout_notification_timeout: 30,
default_checkout_time: process.env.DEFAULT_CHECKOUT_TIME || '11:00',
}
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)]
)
}
}

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

@ -0,0 +1,35 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import { initDb } from './db.js'
import { roomRoutes } from './routes/rooms.js'
import { taskRoutes } from './routes/tasks.js'
import { statusRoutes } from './routes/status.js'
import { configRoutes } from './routes/config.js'
import { activityRoutes } from './routes/activity.js'
import { eventsRoutes } from './routes/events.js'
const app = Fastify({ logger: true, trustProxy: true })
await app.register(cookie)
await app.register(cors, {
origin: process.env.CORS_ORIGIN || false,
credentials: true,
})
app.get('/health', async () => ({ status: 'healthy' }))
await app.register(roomRoutes)
await app.register(taskRoutes)
await app.register(statusRoutes)
await app.register(configRoutes)
await app.register(activityRoutes)
await app.register(eventsRoutes)
try {
await initDb()
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
}

View file

@ -0,0 +1,124 @@
// Shared booking-flow classification logic (server-side).
// The same logic is duplicated in frontend/src/lib/booking-flow.ts for client use.
export const STATUS_COLORS = {
arrived: '#3b82f6', // blue
confirmed: '#10b981', // green
unconfirmed: '#f59e0b', // amber
departed: '#a855f7', // purple
cancelled: '#94a3b8', // slate
blocked: '#6b7280', // grey
}
// Extract YYYY-MM-DD from a NewBook datetime string
export function toDateStr(dt) {
if (!dt) return null
return String(dt).slice(0, 10)
}
// Extract HH:MM from a NewBook datetime string
export function toTimeStr(dt) {
if (!dt) return null
const t = String(dt).slice(11, 16)
return t || null
}
// Find all bookings for a given site on a specific date.
// A booking occupies a site on viewDate if: arrival <= viewDate < departure
// (departure date is the check-out day, so the room is vacated on that morning)
export function bookingsForSiteOnDate(bookings, siteId, viewDate) {
return bookings.filter(b => {
if (String(b.site_id) !== String(siteId)) return false
const arrival = toDateStr(b.booking_arrival)
const departure = toDateStr(b.booking_departure)
if (!arrival || !departure) return false
return arrival <= viewDate && departure > viewDate
})
}
// Find the departing booking for a site on viewDate (departure_date === viewDate)
export function departingBookingForSite(bookings, siteId, viewDate) {
return bookings.find(b =>
String(b.site_id) === String(siteId) &&
toDateStr(b.booking_departure) === viewDate
) ?? null
}
// Classify a site's booking state for a given view date.
// Returns a rich descriptor used to build the room card.
export function classifyRoom(site, allBookings, viewDate, yesterday, tomorrow) {
const siteId = String(site.site_id)
// Primary: booking that occupies the room on viewDate (arrival <= viewDate < departure)
const todayBookings = bookingsForSiteOnDate(allBookings, siteId, viewDate)
const departing = departingBookingForSite(allBookings, siteId, viewDate)
// Adjacent day primary occupants (for border slivers)
const prevBookings = bookingsForSiteOnDate(allBookings, siteId, yesterday)
const nextBookings = bookingsForSiteOnDate(allBookings, siteId, tomorrow)
const prevBooking = prevBookings[0] ?? null
const nextBooking = nextBookings[0] ?? null
// Determine flow type
let flowType = 'vacant'
let primaryBooking = todayBookings[0] ?? null
if (primaryBooking) {
const status = (primaryBooking.booking_status || '').toLowerCase()
if (status === 'blocked') {
flowType = 'blocked'
} else {
const arrivalDate = toDateStr(primaryBooking.booking_arrival)
const departureDate = toDateStr(primaryBooking.booking_departure)
const arrivingToday = arrivalDate === viewDate
const departingToday = departureDate === viewDate
if (arrivingToday && departing && String(departing.booking_id) !== String(primaryBooking.booking_id)) {
// A different booking departs same day this one arrives
flowType = 'back-to-back'
} else if (arrivingToday) {
flowType = 'arrive'
} else if (departingToday) {
// Booking departs today but arrival <= viewDate so they were here — shouldn't normally hit
flowType = 'depart'
} else {
flowType = 'stopover'
}
}
} else if (departing) {
// Room had a departing guest but no incoming booking occupies it today
primaryBooking = departing
flowType = 'depart'
}
const arrivalDate = primaryBooking ? toDateStr(primaryBooking.booking_arrival) : null
const departureDate = primaryBooking ? toDateStr(primaryBooking.booking_departure) : null
return {
site_id: siteId,
site_name: site.site_name,
site_status: site.site_status || 'unknown',
category_id: String(site.site_category_id || ''),
category_name: site.site_category_name || '',
category_order: site.site_category_order ?? 0,
site_order: site.site_order ?? 0,
flow_type: flowType,
booking: primaryBooking,
// Span flags — does this booking continue across the day boundary?
spans_previous: !!primaryBooking && !!arrivalDate && arrivalDate < viewDate,
spans_next: !!primaryBooking && !!departureDate && departureDate > viewDate,
// Adjacent day context for border slivers
previous_booking: prevBooking,
next_booking: nextBooking,
previous_status: prevBooking ? (prevBooking.booking_status || '').toLowerCase() : null,
next_status: nextBooking ? (nextBooking.booking_status || '').toLowerCase() : null,
// Departure time info for the wider-border badge
departing_booking: departing,
departing_time: departing ? toTimeStr(departing.booking_departure) : null,
}
}

125
backend/src/lib/newbook.js Normal file
View file

@ -0,0 +1,125 @@
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)}`)
}
const json = await res.json()
return json
} catch (err) {
clearTimeout(timer)
throw err
}
}
// Fetch all sites (rooms) with current status.
// Each site object includes site_id, site_name, site_status, and typically
// site_category_id, site_category_name, site_order from the NewBook response.
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, unconfirmed, departed, blocked.
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 ?? []
}
// Fetch tasks for a date range. show_uncomplete='true' (string) pulls rollover tasks from before fromDate.
export async function fetchTasks(fromDate, toDate) {
const res = await callApi('tasks_list', {
period_from: `${fromDate} 00:00:00`,
period_to: `${toDate} 23:59:59`,
show_uncomplete: 'true',
})
return res?.data ?? []
}
// Fetch available task types (for settings configuration).
// Note: endpoint is tasks_types_list (not task_types_list).
export async function fetchTaskTypes() {
const res = await callApi('tasks_types_list', {})
return res?.data ?? []
}
// Mark a task as complete in NewBook.
// completed_on must be 'YYYY-MM-DD HH:MM:SS'. Response may include updated site_status.
export async function completeTask(taskId) {
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
const res = await callApi('tasks_update', {
task_id: taskId,
completed_on: now,
})
return res
}
// Remove task completion in NewBook.
export async function uncompleteTask(taskId) {
const res = await callApi('tasks_update', {
task_id: taskId,
completed_on: null,
})
return res
}
// Update room status in NewBook. NewBook expects 'status' parameter (not 'site_status').
export async function updateSiteStatus(siteId, status) {
const res = await callApi('sites_update', {
site_id: siteId,
status,
})
return res
}
// Fetch bookings changed since a timestamp — used by the NewBook poller.
export async function fetchChangedSince(sinceTimestamp) {
const res = await callApi('bookings_list', {
changed_since: sinceTimestamp,
list_type: 'all',
})
return res?.data ?? []
}

View file

@ -0,0 +1,49 @@
import { requireAuth } from '../auth.js'
import { pool } from '../db.js'
export async function activityRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/activity?date=YYYY-MM-DD&limit=50
app.get('/api/activity', async (req, reply) => {
const date = req.query.date || new Date().toISOString().slice(0, 10)
const limit = Math.min(parseInt(req.query.limit || '50'), 200)
const { rows } = await pool.query(
`SELECT id, room_id, event_type, event_data, user_name, occurred_at, booking_ref
FROM activity_log
WHERE service_date = $1
ORDER BY occurred_at DESC
LIMIT $2`,
[date, limit]
)
return rows
})
// POST /api/activity — log an event from the frontend
// Body: { room_id, event_type, event_data?, service_date?, booking_ref? }
app.post('/api/activity', async (req, reply) => {
const { room_id, event_type, event_data, service_date, booking_ref } = req.body || {}
const ALLOWED_TYPES = ['checkout', 'checkin', 'status_clean', 'status_dirty', 'tasks_complete']
if (!room_id) return reply.status(400).send({ error: 'room_id required' })
if (!ALLOWED_TYPES.includes(event_type)) {
return reply.status(400).send({ error: `event_type must be one of: ${ALLOWED_TYPES.join(', ')}` })
}
await pool.query(
`INSERT INTO activity_log (room_id, event_type, event_data, user_name, service_date, booking_ref)
VALUES ($1, $2, $3, $4, $5, $6)`,
[room_id, event_type, event_data ? JSON.stringify(event_data) : null,
req.user.name, service_date || new Date().toISOString().slice(0, 10), booking_ref || null]
)
// Prune on each write
await pool.query(
`DELETE FROM activity_log WHERE occurred_at < NOW() - INTERVAL '48 hours'`
).catch(() => {})
return { ok: true }
})
}

View file

@ -0,0 +1,42 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { fetchTaskTypes } from '../lib/newbook.js'
export async function configRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/config — returns all config keys as a flat object
app.get('/api/config', async (req, reply) => {
const { rows } = await pool.query('SELECT key, value FROM config ORDER BY key')
const config = Object.fromEntries(rows.map(r => [r.key, r.value]))
return config
})
// 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 }
})
// GET /api/config/task-types — fetch available task types from NewBook for settings UI
app.get('/api/config/task-types', { preHandler: requireCap('settings') }, async (req, reply) => {
try {
const types = await fetchTaskTypes()
return types.map(t => ({
id: String(t.task_type_id),
name: t.task_type_name || t.task_description || String(t.task_type_id),
}))
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
})
}

View file

@ -0,0 +1,54 @@
import { requireAuth } from '../auth.js'
import { pool } from '../db.js'
// Polling interval for checking new pings (ms)
const POLL_INTERVAL = 30_000
export async function eventsRoutes(app) {
// GET /api/events?since=<ISO timestamp>
// Returns pings detected after `since`, then the client re-fetches rooms for affected IDs.
// Non-SSE: simple JSON poll endpoint so clients can choose their own cadence.
app.get('/api/events', { preHandler: requireAuth }, async (req, reply) => {
const since = req.query.since || new Date(Date.now() - POLL_INTERVAL).toISOString()
// Prune expired pings
await pool.query(`DELETE FROM newbook_pings WHERE expires_at < NOW()`).catch(() => {})
const { rows } = await pool.query(
`SELECT id, booking_ids, event_types, detected_at
FROM newbook_pings
WHERE detected_at > $1
ORDER BY detected_at ASC`,
[since]
)
return {
pings: rows,
server_time: new Date().toISOString(),
}
})
// POST /api/events/ping — used by the newbook-poller service to register a change signal
// The poller is a separate service; this endpoint receives its signals.
app.post('/api/events/ping', async (req, reply) => {
// Simple shared-secret auth for the poller (not user-facing)
const secret = req.headers['x-poller-secret']
if (!secret || secret !== process.env.POLLER_SECRET) {
// If no POLLER_SECRET configured, allow from internal network only
const ip = req.headers['x-real-ip'] || req.ip
const isInternal = ip?.startsWith('10.10.10.') || ip?.startsWith('127.')
if (!isInternal) return reply.status(401).send({ error: 'Unauthorized' })
}
const { booking_ids, event_types } = req.body || {}
if (!booking_ids?.length) return reply.status(400).send({ error: 'booking_ids required' })
await pool.query(
`INSERT INTO newbook_pings (booking_ids, event_types)
VALUES ($1, $2)`,
[booking_ids, event_types || ['changed']]
)
return { ok: true }
})
}

155
backend/src/routes/rooms.js Normal file
View file

@ -0,0 +1,155 @@
import { requireAuth, hasCap } from '../auth.js'
import { pool } from '../db.js'
import { fetchSites, fetchBookings, fetchTasks } from '../lib/newbook.js'
import { classifyRoom, toDateStr } from '../lib/booking-flow.js'
function dateOffset(dateStr, days) {
const d = new Date(dateStr + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
function filterBookingData(booking, canSeeGuest, canSeeRate, canSeeAllNotes, visibleNoteTypes) {
if (!booking) return null
const out = {
booking_id: booking.booking_id,
booking_reference_id: booking.booking_reference_id,
booking_status: booking.booking_status,
booking_arrival: booking.booking_arrival,
booking_departure: booking.booking_departure,
booking_eta: booking.booking_eta,
booking_locked: booking.booking_locked,
pax: booking.pax,
site_id: booking.site_id,
custom_fields: booking.custom_fields || [],
}
if (canSeeGuest) {
const guests = booking.guests || []
out.guest_name = guests[0]?.guest_name || booking.account_for_name || null
}
if (canSeeRate) {
out.rate_plan_name = booking.rate_plan_name || null
}
if (canSeeAllNotes) {
out.notes = booking.notes || []
} else if (visibleNoteTypes?.length) {
out.notes = (booking.notes || []).filter(n => visibleNoteTypes.includes(String(n.note_type_id)))
} else {
out.notes = []
}
return out
}
export async function roomRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/rooms?date=YYYY-MM-DD
app.get('/api/rooms', async (req, reply) => {
if (!hasCap(req, 'view')) return reply.status(403).send({ error: 'Missing capability: view' })
const viewDate = req.query.date || new Date().toISOString().slice(0, 10)
const yesterday = dateOffset(viewDate, -1)
const tomorrow = dateOffset(viewDate, +1)
const canSeeGuest = hasCap(req, 'guest_details')
const canSeeRate = hasCap(req, 'rate_details')
const canSeeAllNotes = hasCap(req, 'view_all_notes')
// Load config for note type visibility
const cfgRow = await pool.query(`SELECT value FROM config WHERE key = 'visible_note_types'`)
const visibleNoteTypes = cfgRow.rows[0]?.value || []
// Load exclusion config
const excRow = await pool.query(
`SELECT key, value FROM config WHERE key IN ('excluded_categories','hide_excluded_categories')`
)
const cfgMap = Object.fromEntries(excRow.rows.map(r => [r.key, r.value]))
const excludedCategories = cfgMap.excluded_categories || []
const hideExcluded = cfgMap.hide_excluded_categories ?? false
// Fetch from NewBook in parallel
let sites, bookings, tasks
try {
;[sites, bookings, tasks] = await Promise.all([
fetchSites(),
fetchBookings(yesterday, tomorrow),
fetchTasks(yesterday, tomorrow),
])
} catch (err) {
return reply.status(502).send({ error: `NewBook fetch failed: ${err.message}` })
}
// Derive category map from sites — NewBook includes site_category_id/name/order per site
const categoryMap = {}
for (const site of sites) {
const catId = String(site.site_category_id || '')
if (catId && !categoryMap[catId]) {
categoryMap[catId] = {
id: catId,
name: site.site_category_name || catId,
order: site.site_category_order ?? 999,
}
}
}
// Classify each site
const rooms = []
for (const site of sites) {
const catId = String(site.site_category_id || '')
const isExcluded = excludedCategories.includes(catId)
if (isExcluded && hideExcluded) continue
const classified = classifyRoom(site, bookings, viewDate, yesterday, tomorrow)
// Filter booking data per capabilities
if (classified.booking) {
classified.booking = filterBookingData(
classified.booking, canSeeGuest, canSeeRate, canSeeAllNotes, visibleNoteTypes
)
}
if (classified.departing_booking && classified.departing_booking !== classified.booking) {
classified.departing_booking = filterBookingData(
classified.departing_booking, canSeeGuest, canSeeRate, canSeeAllNotes, visibleNoteTypes
)
}
classified.previous_booking = classified.previous_booking
? filterBookingData(classified.previous_booking, false, false, false, [])
: null
classified.next_booking = classified.next_booking
? filterBookingData(classified.next_booking, false, false, false, [])
: null
classified.filter_excluded = isExcluded
// Attach tasks for this site across the 3-day window, keyed by date
const siteId = String(site.site_id)
classified.tasks = tasks
.filter(t => String(t.booking_site_id || t.site_id) === siteId)
.map(t => ({
task_id: t.task_id,
task_description: t.task_description,
task_type_id: String(t.task_type_id),
task_when_date: t.task_when_date ? toDateStr(t.task_when_date) : null,
task_period_from: t.task_period_from ? toDateStr(t.task_period_from) : null,
task_period_to: t.task_period_to ? toDateStr(t.task_period_to) : null,
site_id: siteId,
booking_id: t.booking_id,
completed_on: t.task_completed_on || null,
}))
rooms.push(classified)
}
return {
view_date: viewDate,
yesterday,
tomorrow,
rooms,
categories: Object.values(categoryMap).sort((a, b) => a.order - b.order),
}
})
}

View file

@ -0,0 +1,41 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { updateSiteStatus } from '../lib/newbook.js'
const VALID_STATUSES = ['Clean', 'Dirty', 'Inspected']
export async function statusRoutes(app) {
app.addHook('preHandler', requireAuth)
// POST /api/status
// Body: { room_id, status: 'Clean'|'Dirty'|'Inspected', service_date, booking_ref }
app.post('/api/status', { preHandler: requireCap('update_status') }, async (req, reply) => {
const { room_id, status, service_date, booking_ref } = req.body || {}
if (!room_id) return reply.status(400).send({ error: 'room_id required' })
if (!VALID_STATUSES.includes(status)) {
return reply.status(400).send({ error: `status must be one of: ${VALID_STATUSES.join(', ')}` })
}
try {
await updateSiteStatus(room_id, status)
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
// Log to activity
const eventType = status === 'Clean' ? 'status_clean' : 'status_dirty'
await pool.query(
`INSERT INTO activity_log (room_id, event_type, event_data, user_name, service_date, booking_ref)
VALUES ($1, $2, $3, $4, $5, $6)`,
[room_id, eventType, JSON.stringify({ status }), req.user.name,
service_date || new Date().toISOString().slice(0, 10), booking_ref || null]
).catch(() => {})
await pool.query(
`DELETE FROM activity_log WHERE occurred_at < NOW() - INTERVAL '48 hours'`
).catch(() => {})
return { ok: true }
})
}

View file

@ -0,0 +1,58 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { completeTask, uncompleteTask } from '../lib/newbook.js'
export async function taskRoutes(app) {
app.addHook('preHandler', requireAuth)
// POST /api/tasks/complete
// Body: { task_id, room_id, service_date, booking_ref }
app.post('/api/tasks/complete', { preHandler: requireCap('complete_tasks') }, async (req, reply) => {
const { task_id, room_id, service_date, booking_ref } = req.body || {}
if (!task_id) return reply.status(400).send({ error: 'task_id required' })
let nbResult
try {
nbResult = await completeTask(task_id)
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
// Log tasks_complete event in activity log if site_status came back clean
// (NewBook marks clean when last task is ticked — site_status in response indicates this)
const siteStatus = nbResult?.site_status || nbResult?.data?.site_status
if (room_id && siteStatus === 'Clean') {
await pool.query(
`INSERT INTO activity_log (room_id, event_type, event_data, user_name, service_date, booking_ref)
VALUES ($1, 'tasks_complete', $2, $3, $4, $5)`,
[room_id, JSON.stringify({ site_status: 'Clean' }), req.user.name,
service_date || new Date().toISOString().slice(0, 10), booking_ref || null]
).catch(() => {}) // non-critical
await pruneActivityLog()
}
return { ok: true, site_status: siteStatus || null }
})
// POST /api/tasks/uncomplete
// Body: { task_id }
app.post('/api/tasks/uncomplete', { preHandler: requireCap('complete_tasks') }, async (req, reply) => {
const { task_id } = req.body || {}
if (!task_id) return reply.status(400).send({ error: 'task_id required' })
try {
await uncompleteTask(task_id)
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
return { ok: true }
})
}
async function pruneActivityLog() {
await pool.query(
`DELETE FROM activity_log WHERE occurred_at < NOW() - INTERVAL '48 hours'`
).catch(() => {})
}