room-planner/backend/src/routes/rooms.js
jtricerolph 5f3e52d8a6 Debug: expose full raw booking in debug-room; fix guest name extraction
- debug-room now returns full raw NewBook booking objects so all
  field names are visible for diagnosis
- guest_name extraction also tries guests[0].firstname+lastname
  (original PHP plugin pattern), not just guests[0].guest_name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 13:24:24 +00:00

260 lines
9.9 KiB
JavaScript

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'
// Fetch the newbook.rooms config from the settings service (different DB).
// Returns the parsed value object, or null if unavailable (settings not deployed, not synced yet, etc.).
async function fetchSettingsRoomsConfig() {
const url = process.env.SETTINGS_URL
const secret = process.env.SETTINGS_SECRET
if (!url || !secret) return null
try {
const res = await fetch(`${url}/settings/api/internal/global-config/newbook.rooms`, {
headers: { Authorization: `Bearer ${secret}` },
})
if (!res.ok) return null
const body = await res.json()
return body.value ?? null
} catch {
return null
}
}
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 === true || booking.booking_locked === 1 || booking.booking_locked === '1',
pax: booking.pax,
site_id: booking.site_id,
custom_fields: booking.custom_fields || [],
}
if (canSeeGuest) {
const guests = booking.guests || []
const g0 = guests[0]
const fromGuests = g0
? (g0.guest_name || [g0.firstname, g0.lastname].filter(Boolean).join(' ') || null)
: null
out.guest_name = fromGuests || booking.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/debug-room?room=101&date=YYYY-MM-DD
// Returns raw NewBook data for a single room — use to diagnose bracket/booking issues.
app.get('/api/rooms/debug-room', async (req, reply) => {
if (!hasCap(req, 'settings')) return reply.status(403).send({ error: 'Forbidden' })
const viewDate = req.query.date || new Date().toISOString().slice(0, 10)
const roomName = req.query.room
const yesterday = dateOffset(viewDate, -1)
const tomorrow = dateOffset(viewDate, +1)
const [sites, bookings] = await Promise.all([
fetchSites(),
fetchBookings(yesterday, tomorrow),
])
if (!roomName) {
const sample = bookings[0] || {}
return {
hint: 'Pass ?room=<site_name> to inspect a specific room',
view_date: viewDate,
total_sites: sites.length,
total_bookings: bookings.length,
booking_field_names: Object.keys(sample),
site_field_names: sites.length ? Object.keys(sites[0]) : [],
site_list: sites.map(s => ({ id: s.site_id, name: s.site_name })),
}
}
const site = sites.find(s => s.site_name === roomName)
if (!site) return { error: `Room "${roomName}" not found`, available: sites.map(s => s.site_name) }
const siteId = String(site.site_id)
const idFields = bookings.length
? Object.keys(bookings[0]).filter(k =>
k.toLowerCase().includes('site') || k.toLowerCase().includes('room') ||
k.toLowerCase().includes('unit') || k.toLowerCase().includes('location')
)
: []
const matchFn = b =>
idFields.some(f => String(b[f] || '') === siteId) ||
String(b.site_id || '') === siteId ||
String(b.booking_site_id || '') === siteId ||
(b.site_name && b.site_name === roomName)
const siteBookings = bookings.filter(matchFn)
const classified = classifyRoom(site, bookings, viewDate, yesterday, tomorrow)
return {
view_date: viewDate, yesterday, tomorrow,
site: { id: siteId, name: site.site_name, fields: Object.keys(site) },
booking_id_fields: idFields,
total_bookings_fetched: bookings.length,
matching_bookings: siteBookings,
classified: {
flow_type: classified.flow_type,
spans_previous: classified.spans_previous,
spans_next: classified.spans_next,
previous_status: classified.previous_status,
next_status: classified.next_status,
booking_id: classified.booking?.booking_id,
booking_arrival: classified.booking?.booking_arrival,
booking_departure: classified.booking?.booking_departure,
next_booking_id: classified.next_booking?.booking_id,
next_booking_arrival: classified.next_booking?.booking_arrival,
},
}
})
// 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 local config + settings category order in parallel
const [cfgRow, excRow, settingsRooms] = await Promise.all([
pool.query(`SELECT value FROM config WHERE key = 'visible_note_types'`),
pool.query(`SELECT key, value FROM config WHERE key IN ('excluded_categories','hide_excluded_categories')`),
fetchSettingsRoomsConfig(),
])
const visibleNoteTypes = cfgRow.rows[0]?.value || []
const cfgMap = Object.fromEntries(excRow.rows.map(r => [r.key, r.value]))
const excludedCategories = cfgMap.excluded_categories || []
const hideExcluded = cfgMap.hide_excluded_categories ?? false
// Build category sort-order map from settings (admin-configured) — keyed by category id
const settingsCatOrder = {}
for (const cat of settingsRooms?.categories ?? []) {
settingsCatOrder[String(cat.id)] = cat.sort_order ?? 999
}
// Fetch from NewBook in parallel
let sites, bookings, tasks
try {
// list_type 'staying' includes bookings with arrival <= period_to,
// so fetching yesterday→tomorrow captures all adjacent-day brackets.
;[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}` })
}
const categoryMap = {}
for (const site of sites) {
const catId = String(
site.site_category_id ?? site.category_id ?? site.category?.id ?? ''
)
if (catId && !categoryMap[catId]) {
const nbOrder = site.site_category_order ?? site.category_order ?? site.category?.order ?? 999
categoryMap[catId] = {
id: catId,
name: site.site_category_name ?? site.category_name ?? site.category?.name ?? catId,
order: settingsCatOrder[catId] ?? nbOrder,
}
}
}
// Classify each site
const rooms = []
for (const site of sites) {
const catId = String(site.site_category_id ?? site.category_id ?? site.category?.id ?? '')
const isExcluded = excludedCategories.includes(catId)
if (isExcluded && hideExcluded) continue
const classified = classifyRoom(site, bookings, viewDate, yesterday, tomorrow)
// Override category_order with settings value if available
const catOrderOverride = settingsCatOrder[catId]
if (catOrderOverride !== undefined) classified.category_order = catOrderOverride
// 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),
}
})
}