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:
commit
1e658b6a48
39 changed files with 3765 additions and 0 deletions
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 || '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
70
backend/src/db.js
Normal 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
35
backend/src/index.js
Normal 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
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
|
||||
}
|
||||
124
backend/src/lib/booking-flow.js
Normal file
124
backend/src/lib/booking-flow.js
Normal 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
125
backend/src/lib/newbook.js
Normal 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 ?? []
|
||||
}
|
||||
49
backend/src/routes/activity.js
Normal file
49
backend/src/routes/activity.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
42
backend/src/routes/config.js
Normal file
42
backend/src/routes/config.js
Normal 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}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
54
backend/src/routes/events.js
Normal file
54
backend/src/routes/events.js
Normal 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
155
backend/src/routes/rooms.js
Normal 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),
|
||||
}
|
||||
})
|
||||
}
|
||||
41
backend/src/routes/status.js
Normal file
41
backend/src/routes/status.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
58
backend/src/routes/tasks.js
Normal file
58
backend/src/routes/tasks.js
Normal 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(() => {})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue