317 lines
9.9 KiB
JavaScript
317 lines
9.9 KiB
JavaScript
// 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 };
|