Initial commit: twin-optimiser sub-app (housekeeping category)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 20:14:01 +00:00
commit a695bd843f
28 changed files with 1913 additions and 0 deletions

7
backend/Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM node:22-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-hk-twin-optimiser-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"
}
}

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 || 'twin-optimiser'
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,
}
}

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

@ -0,0 +1,54 @@
import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const DEFAULTS = {
custom_field_names: 'Bed Type',
custom_field_values: 'twin, 2 x single',
notes_search_terms: '',
excluded_terms: '',
normal_color: '#9e9e9e',
twin_color: '#26b823',
potential_twin_color: '#ffc670',
}
export async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS twin_settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL DEFAULT 'null'::jsonb
)
`)
}
export async function getSetting(key) {
const { rows } = await pool.query('SELECT value FROM twin_settings WHERE key = $1', [key])
return rows.length ? rows[0].value : null
}
export async function getAllSettings() {
const { rows } = await pool.query('SELECT key, value FROM twin_settings')
const stored = Object.fromEntries(rows.map(r => [r.key, r.value]))
return { ...DEFAULTS, ...stored }
}
export async function saveSettings(settings) {
const client = await pool.connect()
try {
await client.query('BEGIN')
for (const [key, value] of Object.entries(settings)) {
await client.query(
`INSERT INTO twin_settings (key, value) VALUES ($1, $2::jsonb)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
[key, JSON.stringify(value)]
)
}
await client.query('COMMIT')
} catch (e) {
await client.query('ROLLBACK')
throw e
} finally {
client.release()
}
}

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 { gridRoutes } from './routes/grid.js'
import { settingsRoutes } from './routes/settings.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(gridRoutes)
await app.register(settingsRoutes)
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
}

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

@ -0,0 +1,220 @@
const API_BASE = 'https://api.newbook.cloud/rest/'
const gridCache = new Map()
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')}`
}
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 fetchBookings(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 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 }
}
}
// ── Twin detection ─────────────────────────────────────────────────────────────
function classifyBooking(booking, settings) {
const fieldNames = (settings.custom_field_names || '').split(',').map(s => s.trim()).filter(Boolean)
const fieldValues = (settings.custom_field_values || '').split(',').map(s => s.trim()).filter(Boolean)
// Primary: configured custom fields
if (fieldNames.length && fieldValues.length) {
const customFields = booking.booking_custom_fields || []
for (const fieldName of fieldNames) {
const field = customFields.find(f => f.name === fieldName)
if (!field?.value) continue
const valueLower = field.value.toLowerCase()
for (const searchValue of fieldValues) {
if (valueLower.includes(searchValue.toLowerCase())) {
return { type: 'twin', field_name: fieldName, field_value: field.value, matched_term: searchValue }
}
}
}
}
// Legacy: "Bed Type" label field
const legacyFields = [...(booking.custom_fields || []), ...(booking.booking_custom_fields || [])]
const bedTypeField = legacyFields.find(f => f.label === 'Bed Type' || f.name === 'Bed Type')
if (bedTypeField?.value) {
const v = bedTypeField.value.toLowerCase()
if (v.includes('twin')) {
return { type: 'twin', field_name: 'Bed Type (Legacy)', field_value: bedTypeField.value, matched_term: 'twin' }
}
if (/2\s*x?\s*single/i.test(v)) {
return { type: 'twin', field_name: 'Bed Type (Legacy)', field_value: bedTypeField.value, matched_term: '2 x single' }
}
}
// Potential: notes search
const noteTerms = (settings.notes_search_terms || '').split(',').map(s => s.trim()).filter(Boolean)
const excludeTerms = (settings.excluded_terms || '').split(',').map(s => s.trim()).filter(Boolean)
if (noteTerms.length) {
const notes = booking.notes || []
for (const note of notes) {
let content = note.content || ''
for (const excl of excludeTerms) content = content.split(excl).join('')
const contentLower = content.toLowerCase()
for (const term of noteTerms) {
if (contentLower.includes(term.toLowerCase())) {
return { type: 'potential_twin', note_content: note.content, matched_term: term }
}
}
}
}
return { type: 'normal' }
}
function isEarlyCheckin(booking) {
for (const timeStr of [booking.booking_arrival, booking.booking_eta]) {
if (timeStr && timeStr.length > 10) {
const [h, m] = timeStr.slice(11, 16).split(':').map(Number)
if (!isNaN(h) && (h * 60 + (m || 0)) < 15 * 60) return true
}
}
return false
}
// ── Grid builder ───────────────────────────────────────────────────────────────
export async function fetchGridData(startDate, days, settings, forceRefresh = false) {
const endDate = offsetDate(startDate, days - 1)
const cacheKey = `${startDate}_${days}`
if (!forceRefresh) {
const hit = gridCache.get(cacheKey)
if (hit && Date.now() < hit.expiry) return hit.data
}
const resp = await fetchBookings(startDate, endDate)
if (!resp?.data) throw new Error(resp?.error || 'No booking data returned')
const dates = []
for (let i = 0; i < days; i++) dates.push(offsetDate(startDate, i))
const grid = {}
const rooms = []
for (const booking of resp.data) {
const siteId = booking.site_id || ''
const siteName = booking.site_name || ''
if (!siteId || !siteName) continue
if (!grid[siteId]) {
grid[siteId] = {
site_name: siteName,
category: (booking.category_name || 'Uncategorized').trim(),
cells: {},
}
rooms.push(siteId)
}
const checkin = (booking.booking_arrival || '').slice(0, 10)
const checkout = (booking.booking_departure || '').slice(0, 10)
if (!checkin || !checkout) continue
const detection = classifyBooking(booking, settings)
const early = isEarlyCheckin(booking)
const locked = String(booking.booking_locked) === '1'
for (const date of dates) {
if (date >= checkin && date < checkout && !grid[siteId].cells[date]) {
grid[siteId].cells[date] = {
booking_id: booking.booking_id,
booking_ref: booking.booking_reference_id,
checkin,
checkout,
detection,
is_early_checkin: early,
is_locked: locked,
}
}
}
}
// Sort by category then room name
rooms.sort((a, b) => {
const ac = grid[a].category, bc = grid[b].category
if (ac !== bc) return ac.localeCompare(bc)
return grid[a].site_name.localeCompare(grid[b].site_name)
})
const result = { dates, rooms, grid }
gridCache.set(cacheKey, { data: result, expiry: Date.now() + 5 * 60 * 1000 })
return result
}

View file

@ -0,0 +1,25 @@
import { requireAuth } from '../auth.js'
import { getAllSettings } from '../db.js'
import { fetchGridData, todayStr } from '../lib/newbook.js'
export async function gridRoutes(app) {
app.addHook('preHandler', requireAuth)
app.get('/api/grid', async (req, reply) => {
let startDate = req.query.start_date || todayStr()
if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate)) startDate = todayStr()
let days = parseInt(req.query.days, 10) || 14
if (days < 1 || days > 30) days = 14
const force = req.query.force === '1'
try {
const settings = await getAllSettings()
return await fetchGridData(startDate, days, settings, force)
} catch (err) {
app.log.error(err)
return reply.status(500).send({ error: err.message })
}
})
}

View file

@ -0,0 +1,53 @@
import { requireAuth } from '../auth.js'
import { getAllSettings, saveSettings } from '../db.js'
import { testConnection } from '../lib/newbook.js'
const ALLOWED_KEYS = [
'custom_field_names',
'custom_field_values',
'notes_search_terms',
'excluded_terms',
'normal_color',
'twin_color',
'potential_twin_color',
]
function isValidHex(s) {
return /^#[0-9a-fA-F]{6}$/.test(s)
}
export async function settingsRoutes(app) {
app.addHook('preHandler', requireAuth)
app.get('/api/settings', async () => {
return getAllSettings()
})
app.post('/api/settings', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
const body = req.body || {}
const update = {}
for (const key of ALLOWED_KEYS) {
if (!(key in body)) continue
const val = body[key]
if (key.endsWith('_color')) {
if (!isValidHex(val)) return reply.status(400).send({ error: `Invalid hex color for ${key}` })
} else {
if (typeof val !== 'string') return reply.status(400).send({ error: `${key} must be a string` })
}
update[key] = val
}
if (!Object.keys(update).length) return reply.status(400).send({ error: 'Nothing to update' })
await saveSettings(update)
return getAllSettings()
})
app.post('/api/newbook/test', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
return testConnection()
})
}