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 }