Initial commit: twin-optimiser sub-app (housekeeping category)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
a695bd843f
28 changed files with 1913 additions and 0 deletions
7
backend/Dockerfile
Normal file
7
backend/Dockerfile
Normal 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
16
backend/package.json
Normal 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
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 || '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
54
backend/src/db.js
Normal 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
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 { 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
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
|
||||
}
|
||||
220
backend/src/lib/newbook.js
Normal file
220
backend/src/lib/newbook.js
Normal 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
|
||||
}
|
||||
25
backend/src/routes/grid.js
Normal file
25
backend/src/routes/grid.js
Normal 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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
53
backend/src/routes/settings.js
Normal file
53
backend/src/routes/settings.js
Normal 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()
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue