// Booking sheet integration for WORLDPLAY // Manages bed assignments on a Google Sheets booking sheet // Sheet structure (tab "WorldPlay"): // Row: "Occupancy Commons Hub" (venue header) // Row: '' | '' | room # | bed # | bed type | 07/06/2026 | 08/06/2026 | ... // Row: '' | '' | 1 | 1 | single | | | ... // (empty rows = section separator) // Row: "Herrnhof Villa" (venue header) // Row: Appt name | Appt # | room # | bed # | bed type | 07/06/2026 | ... const fs = require('fs'); let sheetsClient = null; // Accommodation criteria mapping — maps accommodation_type to sheet matching rules const ACCOMMODATION_CRITERIA = { 'ch-shared': { venue: 'Commons Hub', bedTypes: ['Bunk up', 'Bunk down', 'Single'], }, 'ch-double': { venue: 'Commons Hub', bedTypes: ['Double'], }, 'hh-living': { venue: 'Herrnhof Villa', bedTypes: ['Living room', 'Sofa bed', 'Daybed', 'Couch'], }, 'hh-triple': { venue: 'Herrnhof Villa', bedTypes: ['Triple'], }, 'hh-twin': { venue: 'Herrnhof Villa', bedTypes: ['Twin', 'Double (separate)'], }, 'hh-single': { venue: 'Herrnhof Villa', bedTypes: ['Single'], }, 'hh-couple': { venue: 'Herrnhof Villa', bedTypes: ['Double (shared)', 'Double', 'Couple'], }, }; // Date columns on the sheet use DD/MM/YYYY format // Map our ISO date IDs to the sheet header strings const DAY_SHEET_HEADERS = [ '07/06/2026', '08/06/2026', '09/06/2026', '10/06/2026', '11/06/2026', '12/06/2026', '13/06/2026', ]; const DAY_ID_TO_SHEET_HEADER = { '2026-06-07': '07/06/2026', '2026-06-08': '08/06/2026', '2026-06-09': '09/06/2026', '2026-06-10': '10/06/2026', '2026-06-11': '11/06/2026', '2026-06-12': '12/06/2026', '2026-06-13': '13/06/2026', }; /** Convert 0-based column index to spreadsheet letter (supports A-Z, AA-AZ) */ function colIdxToLetter(col) { if (col < 26) return String.fromCharCode(65 + col); return String.fromCharCode(64 + Math.floor(col / 26)) + String.fromCharCode(65 + (col % 26)); } function getCredentials() { const filePath = process.env.GOOGLE_SERVICE_ACCOUNT_FILE; if (filePath) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (err) { console.error('[Booking Sheet] Failed to read credentials file:', err.message); return null; } } const raw = process.env.GOOGLE_SERVICE_ACCOUNT; if (!raw) return null; try { return JSON.parse(raw.trim()); } catch { console.error('[Booking Sheet] Failed to parse service account JSON'); return null; } } async function getSheetsClient() { if (sheetsClient) return sheetsClient; const creds = getCredentials(); if (!creds) return null; const { google } = require('googleapis'); const auth = new google.auth.GoogleAuth({ credentials: creds, scopes: ['https://www.googleapis.com/auth/spreadsheets'], }); sheetsClient = google.sheets({ version: 'v4', auth }); return sheetsClient; } /** * Read and parse the booking sheet. * Returns array of bed objects: * { venue, room, bedType, rowIndex, dayColumns: { '07/06/2026': colIndex, ... }, * occupancy: { '07/06/2026': 'Guest Name' | null, ... } } */ async function parseBookingSheet() { const sheets = await getSheetsClient(); if (!sheets) { console.log('[Booking Sheet] No credentials configured — skipping'); return null; } const sheetId = process.env.BOOKING_SHEET_ID; if (!sheetId) { console.log('[Booking Sheet] No BOOKING_SHEET_ID configured — skipping'); return null; } const sheetName = process.env.BOOKING_SHEET_TAB || 'WorldPlay'; const response = await sheets.spreadsheets.values.get({ spreadsheetId: sheetId, range: `${sheetName}!A:L`, }); const rows = response.data.values || []; const beds = []; let currentVenue = null; let dayColIndexes = {}; let currentRoom = null; // Room numbers carry down for beds in same room let bedTypeCol = -1; // Column index for bed type (varies by section) let roomCol = -1; // Column index for room # for (let i = 0; i < rows.length; i++) { const row = rows[i]; if (!row || row.length === 0 || row.every(cell => !cell || !cell.toString().trim())) { currentVenue = null; dayColIndexes = {}; currentRoom = null; bedTypeCol = -1; roomCol = -1; continue; } const firstCell = (row[0] || '').toString().trim(); // Check if this is a venue header if (firstCell.startsWith('Occupancy Commons Hub') || firstCell === 'Commons Hub') { currentVenue = 'Commons Hub'; dayColIndexes = {}; currentRoom = null; continue; } if (firstCell === 'Herrnhof Villa') { currentVenue = 'Herrnhof Villa'; dayColIndexes = {}; currentRoom = null; continue; } // Check if this is the column header row (look for date-formatted headers) if (currentVenue && !Object.keys(dayColIndexes).length) { // Find date columns and structural columns let foundDates = false; for (let c = 0; c < row.length; c++) { const header = (row[c] || '').toString().trim(); if (DAY_SHEET_HEADERS.includes(header)) { dayColIndexes[header] = c; foundDates = true; } if (header.toLowerCase() === 'room #') roomCol = c; if (header.toLowerCase() === 'bed type') bedTypeCol = c; } if (foundDates) continue; } // If we have a venue and day columns, this is a bed row if (currentVenue && Object.keys(dayColIndexes).length > 0 && roomCol >= 0 && bedTypeCol >= 0) { const roomCell = (row[roomCol] || '').toString().trim(); if (roomCell) currentRoom = roomCell; const bedType = (row[bedTypeCol] || '').toString().trim(); if (!bedType || !currentRoom) continue; const occupancy = {}; for (const [day, colIdx] of Object.entries(dayColIndexes)) { const cellValue = (row[colIdx] || '').toString().trim(); occupancy[day] = cellValue || null; } beds.push({ venue: currentVenue, room: currentRoom, bedType, rowIndex: i, dayColumns: { ...dayColIndexes }, occupancy, }); } } return beds; } /** * Find an available bed matching the accommodation criteria. * A bed is "available" if ALL selected day columns are empty. * @param {object[]} beds - parsed bed objects * @param {string} accommodationType - e.g. 'ch-shared' * @param {string[]} dayHeaders - e.g. ['07/06/2026', '09/06/2026'] */ function findAvailableBed(beds, accommodationType, dayHeaders) { const criteria = ACCOMMODATION_CRITERIA[accommodationType]; if (!criteria) { console.error(`[Booking Sheet] Unknown accommodation type: ${accommodationType}`); return null; } for (const bed of beds) { if (bed.venue !== criteria.venue) continue; const bedTypeLower = bed.bedType.toLowerCase(); const matchesBedType = criteria.bedTypes.some(bt => bedTypeLower.includes(bt.toLowerCase())); if (!matchesBedType) continue; // Check ALL selected days are empty for this bed const allEmpty = dayHeaders.every(day => !bed.occupancy[day]); if (allEmpty) { return bed; } } return null; } /** * Assign a guest to a bed on the booking sheet. * Writes guest name into each selected day column for the matched bed row. * * @param {string} guestName - Full name of the guest * @param {string} accommodationType - e.g. 'ch-shared', 'hh-single' * @param {string|string[]} selectedDays - 'full-week' or array of date strings like ['2026-06-07', ...] * @returns {object} Result with success status, venue, room, bedType */ async function assignBooking(guestName, accommodationType, selectedDays) { if (!accommodationType) { return { success: false, reason: 'Missing accommodation type' }; } // Convert selectedDays to sheet header date strings let dayHeaders; if (selectedDays === 'full-week' || !selectedDays) { dayHeaders = [...DAY_SHEET_HEADERS]; // all 7 days } else { dayHeaders = selectedDays.map(id => DAY_ID_TO_SHEET_HEADER[id]).filter(Boolean); if (dayHeaders.length === 0) { return { success: false, reason: 'No valid days selected' }; } } try { const beds = await parseBookingSheet(); if (!beds) { return { success: false, reason: 'Booking sheet not configured' }; } const bed = findAvailableBed(beds, accommodationType, dayHeaders); if (!bed) { console.warn(`[Booking Sheet] No available bed for ${accommodationType} on days: ${dayHeaders.join(', ')}`); return { success: false, reason: 'No available bed matching criteria' }; } const sheets = await getSheetsClient(); const sheetId = process.env.BOOKING_SHEET_ID; const sheetName = process.env.BOOKING_SHEET_TAB || 'WorldPlay'; const rowNum = bed.rowIndex + 1; // Sheets is 1-indexed // Build batch update data for each selected day column const data = []; for (const dayHeader of dayHeaders) { const colIdx = bed.dayColumns[dayHeader]; if (colIdx === undefined) continue; const colLetter = colIdxToLetter(colIdx); data.push({ range: `${sheetName}!${colLetter}${rowNum}`, values: [[guestName]], }); } if (data.length === 0) { return { success: false, reason: 'No matching day columns found on sheet' }; } await sheets.spreadsheets.values.batchUpdate({ spreadsheetId: sheetId, resource: { valueInputOption: 'RAW', data, }, }); console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for ${dayHeaders.length} days`); return { success: true, venue: bed.venue, room: bed.room, bedType: bed.bedType, }; } catch (error) { console.error('[Booking Sheet] Assignment failed:', error); return { success: false, reason: error.message }; } } module.exports = { assignBooking, parseBookingSheet, findAvailableBed, ACCOMMODATION_CRITERIA };