Initial commit: HK Planner app
Housekeeping workload and hours planning app — port of the WP hotel housekeeping hours calculator plugin. 7-day occupancy planner with Newbook PMS integration, staff rota, time requirements, and pickup tracking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
4abae5eda1
28 changed files with 2475 additions and 0 deletions
35
backend/src/auth.js
Normal file
35
backend/src/auth.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { jwtVerify } from 'jose'
|
||||
import { isOnsite } from './ip-check.js'
|
||||
|
||||
const APP_SLUG = process.env.APP_SLUG || 'hk-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' })
|
||||
}
|
||||
}
|
||||
|
||||
request.user = {
|
||||
email: payload.sub,
|
||||
name: payload.name,
|
||||
is_admin: payload.is_admin ?? false,
|
||||
}
|
||||
}
|
||||
26
backend/src/db.js
Normal file
26
backend/src/db.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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 hk_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL DEFAULT 'null'::jsonb
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
export async function getConfig(key, defaultVal = null) {
|
||||
const { rows } = await pool.query('SELECT value FROM hk_config WHERE key = $1', [key])
|
||||
return rows.length ? rows[0].value : defaultVal
|
||||
}
|
||||
|
||||
export async function setConfig(key, value) {
|
||||
await pool.query(
|
||||
`INSERT INTO hk_config (key, value) VALUES ($1, $2::jsonb)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[key, JSON.stringify(value)]
|
||||
)
|
||||
}
|
||||
24
backend/src/index.js
Normal file
24
backend/src/index.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import Fastify from 'fastify'
|
||||
import cookie from '@fastify/cookie'
|
||||
import cors from '@fastify/cors'
|
||||
import { initDb } from './db.js'
|
||||
import { bookingRoutes } from './routes/bookings.js'
|
||||
import { configRoutes } from './routes/config.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(bookingRoutes)
|
||||
await app.register(configRoutes)
|
||||
|
||||
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
|
||||
}
|
||||
355
backend/src/lib/newbook.js
Normal file
355
backend/src/lib/newbook.js
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
const API_BASE = 'https://api.newbook.cloud/rest/'
|
||||
|
||||
// In-process bookings cache: key → { data, expiry }
|
||||
const bookingsCache = new Map()
|
||||
|
||||
// ── Date helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
export function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function offsetDate(dateStr, days) {
|
||||
const [y, m, d] = dateStr.split('-').map(Number)
|
||||
const dt = new Date(y, m - 1, d)
|
||||
dt.setDate(dt.getDate() + days)
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function dateToMs(dateStr) {
|
||||
const [y, m, d] = dateStr.split('-').map(Number)
|
||||
return new Date(y, m - 1, d).getTime()
|
||||
}
|
||||
|
||||
// ── API client ─────────────────────────────────────────────────────────────
|
||||
|
||||
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}`)
|
||||
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 body = { ...data, region: creds.region, api_key: creds.apiKey }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBookingsRange(startDate, endDate) {
|
||||
return callApi('bookings_list', {
|
||||
period_from: startDate + ' 00:00:00',
|
||||
period_to: endDate + ' 23:59:59',
|
||||
list_type: 'staying',
|
||||
data_offset: 0,
|
||||
data_limit: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchBookingsDelta(startDate, endDate) {
|
||||
return callApi('bookings_list', {
|
||||
period_from: startDate + ' 00:00:00',
|
||||
period_to: endDate + ' 23:59:59',
|
||||
list_type: 'all',
|
||||
data_offset: 0,
|
||||
data_limit: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchSitesList() {
|
||||
return callApi('sites_list', {})
|
||||
}
|
||||
|
||||
export async function testConnection() {
|
||||
try {
|
||||
const resp = await fetchSitesList()
|
||||
if (resp.data) {
|
||||
return { ok: true, message: `Connected. Found ${resp.data.length} site(s).` }
|
||||
}
|
||||
return { ok: false, error: resp.error || 'No data returned' }
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Booking data algorithm (port of PHP get_bookings_data) ─────────────────
|
||||
|
||||
const ACTIVE_STATUSES = ['Confirmed', 'Unconfirmed', 'Arrived', 'Departed']
|
||||
const CANCELLED_STATUSES = ['Cancelled', 'No Show']
|
||||
|
||||
export async function getBookingsData(weekStart, lastViewed, forceRefresh, getConfig) {
|
||||
const cacheKey = `bookings_${weekStart}_${lastViewed}`
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cached = bookingsCache.get(cacheKey)
|
||||
if (cached && Date.now() < cached.expiry) return cached.data
|
||||
}
|
||||
|
||||
const today = todayStr()
|
||||
const todayMs = dateToMs(today)
|
||||
const fetchFrom = offsetDate(weekStart, -8)
|
||||
const endDate = offsetDate(weekStart, 6)
|
||||
const lastViewedMs = dateToMs(lastViewed)
|
||||
|
||||
const [bookingsResp, deltaResp, sitesResp] = await Promise.all([
|
||||
fetchBookingsRange(fetchFrom, endDate),
|
||||
fetchBookingsDelta(lastViewed, endDate),
|
||||
fetchSitesList(),
|
||||
])
|
||||
|
||||
if (!bookingsResp.data) throw new Error(bookingsResp.error || 'No booking data returned')
|
||||
|
||||
const sites = sitesResp.data || []
|
||||
|
||||
// Build category_map and site → catKey index
|
||||
const categoryMap = {} // catKey → {name, total_rooms}
|
||||
const siteToKey = {} // siteId → catKey
|
||||
|
||||
for (const site of sites) {
|
||||
const siteId = site.site_id || ''
|
||||
const catName = (site.category_name || 'Unknown').trim()
|
||||
const catKey = catName.toLowerCase()
|
||||
if (siteId) siteToKey[siteId] = catKey
|
||||
if (!categoryMap[catKey]) categoryMap[catKey] = { name: catName, total_rooms: 0 }
|
||||
categoryMap[catKey].total_rooms++
|
||||
}
|
||||
|
||||
// Build 7-day date window
|
||||
const dates = []
|
||||
for (let i = 0; i < 7; i++) dates.push(offsetDate(weekStart, i))
|
||||
|
||||
// day_data[date][catKey] = {departs, stays, arrivals, rooms:{}}
|
||||
const dayData = {}
|
||||
for (const d of dates) dayData[d] = {}
|
||||
|
||||
// delta_data[date][catKey] = {new:0, cancelled:0}
|
||||
const deltaData = {}
|
||||
for (const d of dates) deltaData[d] = {}
|
||||
|
||||
// prior_arrivals[catKey][dow] = [{arrival_date, placed_date}]
|
||||
const priorArrivals = {}
|
||||
|
||||
// prior_occ[catKey][date] = {rooms:{siteId:true}}
|
||||
const priorOcc = {}
|
||||
|
||||
// ── Process main bookings ──────────────────────────────────────────────
|
||||
|
||||
for (const booking of bookingsResp.data) {
|
||||
const siteId = booking.site_id || ''
|
||||
if (!siteId) continue
|
||||
|
||||
let catName
|
||||
if (booking.category_name) {
|
||||
catName = booking.category_name.trim()
|
||||
} else if (siteToKey[siteId]) {
|
||||
catName = categoryMap[siteToKey[siteId]].name
|
||||
} else {
|
||||
catName = 'Unknown'
|
||||
}
|
||||
const catKey = catName.toLowerCase()
|
||||
if (!categoryMap[catKey]) categoryMap[catKey] = { name: catName, total_rooms: 0 }
|
||||
|
||||
const arrivalDate = (booking.booking_arrival || '').slice(0, 10)
|
||||
const departureDate = (booking.booking_departure || '').slice(0, 10)
|
||||
if (!arrivalDate || !departureDate) continue
|
||||
|
||||
const arrivalMs = dateToMs(arrivalDate)
|
||||
const departureMs = dateToMs(departureDate)
|
||||
|
||||
// This-week occupancy
|
||||
for (const date of dates) {
|
||||
const isArriving = arrivalDate === date
|
||||
const isDeparting = departureDate === date
|
||||
const isStaying = arrivalDate < date && departureDate > date
|
||||
|
||||
if (!isArriving && !isDeparting && !isStaying) continue
|
||||
|
||||
if (!dayData[date][catKey]) {
|
||||
dayData[date][catKey] = { departs: 0, stays: 0, arrivals: 0, rooms: {} }
|
||||
}
|
||||
if (isDeparting) dayData[date][catKey].departs++
|
||||
if (isStaying) dayData[date][catKey].stays++
|
||||
if (isArriving) dayData[date][catKey].arrivals++
|
||||
dayData[date][catKey].rooms[siteId] = true
|
||||
}
|
||||
|
||||
// Skip future arrivals for prior-week data
|
||||
if (arrivalMs >= todayMs) continue
|
||||
|
||||
// Prior-week occupancy (7 days before each display date)
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const priorDate = offsetDate(today, -(7 - i))
|
||||
const priorDateMs = dateToMs(priorDate)
|
||||
if (arrivalMs <= priorDateMs && departureMs > priorDateMs) {
|
||||
if (!priorOcc[catKey]) priorOcc[catKey] = {}
|
||||
if (!priorOcc[catKey][priorDate]) priorOcc[catKey][priorDate] = { rooms: {} }
|
||||
priorOcc[catKey][priorDate].rooms[siteId] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Prior-week arrival pickup hints (keyed by weekday)
|
||||
const placedDate = (booking.booking_placed || '').slice(0, 10)
|
||||
if (!placedDate) continue
|
||||
|
||||
const dow = new Date(arrivalDate + 'T00:00:00').getDay() // 0=Sun…6=Sat
|
||||
if (!priorArrivals[catKey]) priorArrivals[catKey] = {}
|
||||
if (!priorArrivals[catKey][dow]) priorArrivals[catKey][dow] = []
|
||||
priorArrivals[catKey][dow].push({ arrival_date: arrivalDate, placed_date: placedDate })
|
||||
}
|
||||
|
||||
// ── Process delta bookings ─────────────────────────────────────────────
|
||||
|
||||
const deltaBookings = deltaResp.data || []
|
||||
|
||||
for (const booking of deltaBookings) {
|
||||
const siteId = booking.site_id || ''
|
||||
if (!siteId) continue
|
||||
|
||||
const catName = (booking.category_name || 'Unknown').trim()
|
||||
const catKey = catName.toLowerCase()
|
||||
|
||||
const arrivalDate = (booking.booking_arrival || '').slice(0, 10)
|
||||
const departureDate = (booking.booking_departure || '').slice(0, 10)
|
||||
if (!arrivalDate || !departureDate) continue
|
||||
|
||||
const status = (booking.booking_status || '').trim()
|
||||
const isActive = ACTIVE_STATUSES.includes(status)
|
||||
const isCancelled = CANCELLED_STATUSES.includes(status)
|
||||
|
||||
const placedDateStr = (booking.booking_placed || '').slice(0, 10)
|
||||
const cancelledDateStr = (booking.booking_cancelled || '').slice(0, 10)
|
||||
const placedMs = placedDateStr ? dateToMs(placedDateStr) : 0
|
||||
const cancelledMs = cancelledDateStr ? dateToMs(cancelledDateStr) : 0
|
||||
|
||||
const isNewSince = isActive && placedMs && placedMs >= lastViewedMs
|
||||
const isCancelledSince = isCancelled && cancelledMs && cancelledMs >= lastViewedMs
|
||||
|
||||
if (!isNewSince && !isCancelledSince) continue
|
||||
|
||||
for (const date of dates) {
|
||||
const isArriving = arrivalDate === date
|
||||
const isStaying = arrivalDate < date && departureDate > date
|
||||
if (!isArriving && !isStaying) continue
|
||||
|
||||
if (!deltaData[date][catKey]) deltaData[date][catKey] = { new: 0, cancelled: 0 }
|
||||
if (isNewSince) deltaData[date][catKey].new++
|
||||
else if (isCancelledSince) deltaData[date][catKey].cancelled++
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply saved category order ─────────────────────────────────────────
|
||||
|
||||
const savedOrder = (await getConfig('category_order', [])) || []
|
||||
const excluded = (await getConfig('excluded_categories', [])) || []
|
||||
|
||||
const orderedKeys = []
|
||||
for (const key of savedOrder) {
|
||||
if (categoryMap[key]) orderedKeys.push(key)
|
||||
}
|
||||
for (const key of Object.keys(categoryMap)) {
|
||||
if (!orderedKeys.includes(key)) orderedKeys.push(key)
|
||||
}
|
||||
|
||||
// ── Build output ───────────────────────────────────────────────────────
|
||||
|
||||
const categoriesOut = []
|
||||
|
||||
for (const catKey of orderedKeys) {
|
||||
if (!categoryMap[catKey]) continue
|
||||
if (excluded.includes(catKey)) continue
|
||||
|
||||
const catDays = {}
|
||||
|
||||
for (let di = 0; di < dates.length; di++) {
|
||||
const date = dates[di]
|
||||
const dateMs = dateToMs(date)
|
||||
const leadDays = Math.round((dateMs - todayMs) / 86400000)
|
||||
const dow = new Date(date + 'T00:00:00').getDay()
|
||||
|
||||
// Pickup hint — how many last-week same-weekday arrivals were booked within leadDays
|
||||
let hintCount = 0
|
||||
if (priorArrivals[catKey]?.[dow]) {
|
||||
for (const pa of priorArrivals[catKey][dow]) {
|
||||
const arrMs = dateToMs(pa.arrival_date)
|
||||
const plMs = dateToMs(pa.placed_date)
|
||||
const daysBefore = Math.round((arrMs - plMs) / 86400000)
|
||||
if (daysBefore <= leadDays) hintCount++
|
||||
}
|
||||
}
|
||||
|
||||
const priorDate = offsetDate(date, -7)
|
||||
const priorOccCount = priorOcc[catKey]?.[priorDate]
|
||||
? Object.keys(priorOcc[catKey][priorDate].rooms).length
|
||||
: 0
|
||||
const totalRooms = categoryMap[catKey].total_rooms
|
||||
const priorVacCount = Math.max(0, totalRooms - priorOccCount)
|
||||
|
||||
const delta = deltaData[date]?.[catKey] || {}
|
||||
const dd = dayData[date]?.[catKey]
|
||||
|
||||
catDays[date] = dd ? {
|
||||
total_servicing: Object.keys(dd.rooms).length,
|
||||
departs: dd.departs,
|
||||
stays: dd.stays,
|
||||
arrivals: dd.arrivals,
|
||||
pickup_hint: hintCount,
|
||||
pickup_lead: leadDays,
|
||||
prior_occ: priorOccCount,
|
||||
prior_vac: priorVacCount,
|
||||
delta_new: delta.new || 0,
|
||||
delta_cancelled: delta.cancelled || 0,
|
||||
} : {
|
||||
total_servicing: 0, departs: 0, stays: 0, arrivals: 0,
|
||||
pickup_hint: hintCount, pickup_lead: leadDays,
|
||||
prior_occ: priorOccCount, prior_vac: priorVacCount,
|
||||
delta_new: delta.new || 0, delta_cancelled: delta.cancelled || 0,
|
||||
}
|
||||
}
|
||||
|
||||
categoriesOut.push({
|
||||
id: catKey,
|
||||
name: categoryMap[catKey].name,
|
||||
total_rooms: categoryMap[catKey].total_rooms,
|
||||
days: catDays,
|
||||
})
|
||||
}
|
||||
|
||||
const result = { dates, categories: categoriesOut, last_viewed: lastViewed }
|
||||
bookingsCache.set(cacheKey, { data: result, expiry: Date.now() + 5 * 60 * 1000 })
|
||||
return result
|
||||
}
|
||||
26
backend/src/routes/bookings.js
Normal file
26
backend/src/routes/bookings.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { requireAuth } from '../auth.js'
|
||||
import { getConfig } from '../db.js'
|
||||
import { getBookingsData, todayStr, offsetDate } from '../lib/newbook.js'
|
||||
|
||||
export async function bookingRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
app.get('/api/bookings', async (req, reply) => {
|
||||
const today = todayStr()
|
||||
|
||||
let weekStart = req.query.week_start || today
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(weekStart)) weekStart = today
|
||||
|
||||
let lastViewed = req.query.last_viewed || offsetDate(today, -1)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(lastViewed)) lastViewed = offsetDate(today, -1)
|
||||
|
||||
const force = req.query.force_refresh === '1'
|
||||
|
||||
try {
|
||||
return await getBookingsData(weekStart, lastViewed, force, getConfig)
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
return reply.status(500).send({ error: err.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
188
backend/src/routes/config.js
Normal file
188
backend/src/routes/config.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { requireAuth } from '../auth.js'
|
||||
import { getConfig, setConfig } from '../db.js'
|
||||
import { fetchSitesList, testConnection } from '../lib/newbook.js'
|
||||
|
||||
export async function configRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// ── GET /api/config — all settings at once ──────────────────────────────
|
||||
|
||||
app.get('/api/config', async (req) => {
|
||||
const [timeReqs, staffData, pickupData, generalTasks, lastReviewed, toleranceMins] =
|
||||
await Promise.all([
|
||||
getConfig('time_requirements', {}),
|
||||
getConfig('staff_data', []),
|
||||
getConfig('pickup_data', {}),
|
||||
getConfig('general_tasks', []),
|
||||
getConfig('last_reviewed', null),
|
||||
getConfig('tolerance_minutes', 30),
|
||||
])
|
||||
|
||||
const today = new Date()
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const yestStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`
|
||||
|
||||
return {
|
||||
time_requirements: timeReqs || {},
|
||||
staff_data: staffData || [],
|
||||
pickup_data: pickupData || {},
|
||||
general_tasks: generalTasks || [],
|
||||
last_reviewed: lastReviewed || yestStr,
|
||||
tolerance_minutes: toleranceMins != null ? toleranceMins : 30,
|
||||
}
|
||||
})
|
||||
|
||||
// ── PUT /api/config/time-requirements ────────────────────────────────────
|
||||
|
||||
app.put('/api/config/time-requirements', async (req, reply) => {
|
||||
const { cat, action, value } = req.body || {}
|
||||
if (!cat || !['depart', 'stay', 'arrive'].includes(action)) {
|
||||
return reply.status(400).send({ error: 'Invalid data' })
|
||||
}
|
||||
|
||||
const stored = (await getConfig('time_requirements', {})) || {}
|
||||
if (!stored[cat]) stored[cat] = { depart: 0, stay: 0, arrive: 0 }
|
||||
stored[cat][action] = Math.max(0, parseInt(value, 10) || 0)
|
||||
await setConfig('time_requirements', stored)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── PUT /api/config/staff ─────────────────────────────────────────────────
|
||||
|
||||
app.put('/api/config/staff', async (req, reply) => {
|
||||
const { staff_data } = req.body || {}
|
||||
if (!Array.isArray(staff_data)) return reply.status(400).send({ error: 'Invalid data' })
|
||||
|
||||
const clean = staff_data
|
||||
.filter(m => m && typeof m.name === 'string' && m.name.trim())
|
||||
.map(m => ({
|
||||
name: m.name.trim(),
|
||||
hours: Object.fromEntries(
|
||||
Object.entries(m.hours || {}).map(([k, v]) => [k, Math.max(0, parseFloat(v) || 0)])
|
||||
),
|
||||
}))
|
||||
|
||||
await setConfig('staff_data', clean)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── PUT /api/config/pickup ────────────────────────────────────────────────
|
||||
|
||||
app.put('/api/config/pickup', async (req, reply) => {
|
||||
const { pickup_data } = req.body || {}
|
||||
if (!pickup_data || typeof pickup_data !== 'object') {
|
||||
return reply.status(400).send({ error: 'Invalid data' })
|
||||
}
|
||||
|
||||
const clean = {}
|
||||
for (const [catId, dates] of Object.entries(pickup_data)) {
|
||||
if (typeof dates !== 'object') continue
|
||||
clean[catId] = {}
|
||||
for (const [date, vals] of Object.entries(dates)) {
|
||||
clean[catId][date] = {
|
||||
count: Math.max(0, parseInt(vals?.count, 10) || 0),
|
||||
total: Math.max(0, parseInt(vals?.total, 10) || 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await setConfig('pickup_data', clean)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── PUT /api/config/general-tasks ────────────────────────────────────────
|
||||
|
||||
app.put('/api/config/general-tasks', async (req, reply) => {
|
||||
const { general_tasks } = req.body || {}
|
||||
if (!Array.isArray(general_tasks)) return reply.status(400).send({ error: 'Invalid data' })
|
||||
|
||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const clean = general_tasks
|
||||
.filter(t => t && typeof t.name === 'string' && t.name.trim())
|
||||
.map(t => ({
|
||||
name: t.name.trim(),
|
||||
hours: Object.fromEntries(DAYS.map(day => [day, Math.max(0, parseInt(t.hours?.[day], 10) || 0)])),
|
||||
}))
|
||||
|
||||
await setConfig('general_tasks', clean)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── PUT /api/config/last-reviewed ────────────────────────────────────────
|
||||
|
||||
app.put('/api/config/last-reviewed', async (req, reply) => {
|
||||
const { date } = req.body || {}
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return reply.status(400).send({ error: 'Invalid date' })
|
||||
}
|
||||
await setConfig('last_reviewed', date)
|
||||
return { ok: true, date }
|
||||
})
|
||||
|
||||
// ── GET /api/categories — admin: fetch from Newbook + saved order ─────────
|
||||
|
||||
app.get('/api/categories', async (req, reply) => {
|
||||
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
|
||||
|
||||
let sites
|
||||
try {
|
||||
const resp = await fetchSitesList()
|
||||
if (!resp.data) throw new Error(resp.error || 'No data')
|
||||
sites = resp.data
|
||||
} catch (err) {
|
||||
return reply.status(502).send({ error: err.message })
|
||||
}
|
||||
|
||||
const cats = {}
|
||||
for (const site of sites) {
|
||||
const catName = (site.category_name || 'Unknown').trim()
|
||||
const catKey = catName.toLowerCase()
|
||||
if (!cats[catKey]) cats[catKey] = { id: catKey, name: catName, room_count: 0 }
|
||||
cats[catKey].room_count++
|
||||
}
|
||||
|
||||
const savedOrder = (await getConfig('category_order', [])) || []
|
||||
const excluded = (await getConfig('excluded_categories', [])) || []
|
||||
|
||||
const ordered = []
|
||||
for (const key of savedOrder) {
|
||||
if (cats[key]) {
|
||||
ordered.push({ ...cats[key], excluded: excluded.includes(key) })
|
||||
delete cats[key]
|
||||
}
|
||||
}
|
||||
for (const [key, cat] of Object.entries(cats)) {
|
||||
ordered.push({ ...cat, excluded: excluded.includes(key) })
|
||||
}
|
||||
|
||||
return { categories: ordered }
|
||||
})
|
||||
|
||||
// ── PUT /api/categories — admin: save order + exclusions ─────────────────
|
||||
|
||||
app.put('/api/categories', async (req, reply) => {
|
||||
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
|
||||
|
||||
const { order, excluded } = req.body || {}
|
||||
if (!Array.isArray(order)) return reply.status(400).send({ error: 'Invalid data' })
|
||||
|
||||
const cleanOrder = order.filter(k => typeof k === 'string')
|
||||
const cleanExcl = Array.isArray(excluded) ? excluded.filter(k => typeof k === 'string') : []
|
||||
|
||||
await Promise.all([
|
||||
setConfig('category_order', cleanOrder),
|
||||
setConfig('excluded_categories', cleanExcl),
|
||||
])
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── POST /api/newbook/test — admin ────────────────────────────────────────
|
||||
|
||||
app.post('/api/newbook/test', async (req, reply) => {
|
||||
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
|
||||
return testConnection()
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue