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:
jtricerolph 2026-07-02 09:24:14 +00:00
commit 4abae5eda1
28 changed files with 2475 additions and 0 deletions

35
backend/src/auth.js Normal file
View 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
View 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
View 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
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
}

355
backend/src/lib/newbook.js Normal file
View 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
}

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

View 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()
})
}