valley-commons/api/booking-sheet.js

275 lines
8.3 KiB
JavaScript

// Booking sheet integration
// Manages bed assignments on a Google Sheets booking sheet
// Ported from CCG's lib/booking-sheet.ts, adapted for week-based columns
const fs = require('fs');
let sheetsClient = null;
// Accommodation criteria mapping — maps accommodation_type to sheet matching rules
const ACCOMMODATION_CRITERIA = {
'ch-multi': {
venue: 'Commons Hub',
bedTypes: ['Bunk up', 'Bunk down', 'Single'],
},
'ch-double': {
venue: 'Commons Hub',
bedTypes: ['Double'],
},
'hh-single': {
venue: 'Herrnhof Villa',
bedTypes: ['Single'],
},
'hh-double-separate': {
venue: 'Herrnhof Villa',
bedTypes: ['Double (separate)', 'Twin'],
},
'hh-double-shared': {
venue: 'Herrnhof Villa',
bedTypes: ['Double (shared)', 'Double'],
},
'hh-triple': {
venue: 'Herrnhof Villa',
bedTypes: ['Triple'],
},
'hh-daybed': {
venue: 'Herrnhof Villa',
bedTypes: ['Daybed', 'Extra bed', 'Sofa bed'],
},
};
// Week column headers expected in the sheet
const WEEK_COLUMNS = ['Week 1', 'Week 2', 'Week 3', 'Week 4'];
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.
* Expected structure per venue section:
* Row: "Commons Hub" or "Herrnhof Villa" (venue header)
* Row: Room | Bed Type | Week 1 | Week 2 | Week 3 | Week 4 (column headers)
* Row: 5 | Bunk up | | | | (bed rows)
* ...
* Empty row (section separator)
*
* Returns array of bed objects:
* { venue, room, bedType, rowIndex, weekColumns: { 'Week 1': colIndex, ... }, occupancy: { 'Week 1': '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 || process.env.GOOGLE_SHEET_ID;
const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet';
const response = await sheets.spreadsheets.values.get({
spreadsheetId: sheetId,
range: `${sheetName}!A:G`,
});
const rows = response.data.values || [];
const beds = [];
let currentVenue = null;
let weekColIndexes = {};
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row || row.length === 0 || row.every(cell => !cell || !cell.toString().trim())) {
// Empty row — reset venue context
currentVenue = null;
weekColIndexes = {};
continue;
}
const firstCell = (row[0] || '').toString().trim();
// Check if this is a venue header
if (firstCell === 'Commons Hub' || firstCell === 'Herrnhof Villa') {
currentVenue = firstCell;
weekColIndexes = {};
continue;
}
// Check if this is the column header row (contains "Room" and week columns)
if (firstCell.toLowerCase() === 'room' && currentVenue) {
for (let c = 0; c < row.length; c++) {
const header = (row[c] || '').toString().trim();
if (WEEK_COLUMNS.includes(header)) {
weekColIndexes[header] = c;
}
}
continue;
}
// If we have a venue and week columns, this is a bed row
if (currentVenue && Object.keys(weekColIndexes).length > 0 && firstCell) {
const room = firstCell;
const bedType = (row[1] || '').toString().trim();
if (!bedType) continue;
const occupancy = {};
for (const [week, colIdx] of Object.entries(weekColIndexes)) {
const cellValue = (row[colIdx] || '').toString().trim();
occupancy[week] = cellValue || null;
}
beds.push({
venue: currentVenue,
room,
bedType,
rowIndex: i,
weekColumns: { ...weekColIndexes },
occupancy,
});
}
}
return beds;
}
/**
* Find an available bed matching the accommodation criteria for the given weeks.
* A bed is "available" only if ALL requested week columns are empty.
*/
function findAvailableBed(beds, accommodationType, selectedWeeks) {
const criteria = ACCOMMODATION_CRITERIA[accommodationType];
if (!criteria) {
console.error(`[Booking Sheet] Unknown accommodation type: ${accommodationType}`);
return null;
}
// Map week values (week1, week2, etc.) to column headers (Week 1, Week 2, etc.)
const weekHeaders = selectedWeeks.map(w => {
const num = w.replace('week', '');
return `Week ${num}`;
});
for (const bed of beds) {
// Match venue
if (bed.venue !== criteria.venue) continue;
// Match bed type (case-insensitive partial match)
const bedTypeLower = bed.bedType.toLowerCase();
const matchesBedType = criteria.bedTypes.some(bt => bedTypeLower.includes(bt.toLowerCase()));
if (!matchesBedType) continue;
// Check all requested weeks are empty
const allWeeksAvailable = weekHeaders.every(wh => !bed.occupancy[wh]);
if (!allWeeksAvailable) continue;
return bed;
}
return null;
}
/**
* Assign a guest to a bed on the booking sheet.
* Writes guest name to the selected week columns for the matched bed row.
*
* @param {string} guestName - Full name of the guest
* @param {string} accommodationType - e.g. 'ch-multi', 'hh-single'
* @param {string[]} selectedWeeks - e.g. ['week1', 'week2', 'week3']
* @returns {object} Result with success status, venue, room, bedType
*/
async function assignBooking(guestName, accommodationType, selectedWeeks) {
if (!accommodationType || !selectedWeeks || selectedWeeks.length === 0) {
return { success: false, reason: 'Missing accommodation type or weeks' };
}
try {
const beds = await parseBookingSheet();
if (!beds) {
return { success: false, reason: 'Booking sheet not configured' };
}
const bed = findAvailableBed(beds, accommodationType, selectedWeeks);
if (!bed) {
console.warn(`[Booking Sheet] No available bed for ${accommodationType}, weeks: ${selectedWeeks.join(', ')}`);
return { success: false, reason: 'No available bed matching criteria' };
}
// Write guest name to the selected week columns
const sheets = await getSheetsClient();
const sheetId = process.env.BOOKING_SHEET_ID || process.env.GOOGLE_SHEET_ID;
const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet';
// Convert week values to column headers
const weekHeaders = selectedWeeks.map(w => `Week ${w.replace('week', '')}`);
// Build batch update data
const data = weekHeaders
.filter(wh => bed.weekColumns[wh] !== undefined)
.map(wh => {
const col = bed.weekColumns[wh];
const colLetter = String.fromCharCode(65 + col); // A=0, B=1, ...
const rowNum = bed.rowIndex + 1; // Sheets is 1-indexed
return {
range: `${sheetName}!${colLetter}${rowNum}`,
values: [[guestName]],
};
});
if (data.length > 0) {
await sheets.spreadsheets.values.batchUpdate({
spreadsheetId: sheetId,
resource: {
valueInputOption: 'USER_ENTERED',
data,
},
});
}
console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for weeks: ${selectedWeeks.join(', ')}`);
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 };