bookingjs
Version:
A framework-agnostic JavaScript library for managing rink bookings, availability, and scheduling
1,452 lines (1,349 loc) • 92.8 kB
JavaScript
'use strict';
var uuid = require('uuid');
/**
* Base error class for BookingJS
*/
class BookingJSError extends Error {
constructor(message, code) {
super(message);
this.name = this.constructor.name;
this.code = code;
Error.captureStackTrace(this, this.constructor);
}
}
/**
* Error thrown when a booking conflicts with an existing reservation
*/
class BookingConflictError extends BookingJSError {
constructor(message = 'Booking conflicts with existing reservation', conflictingBooking = null) {
super(message, 'BOOKING_CONFLICT');
this.conflictingBooking = conflictingBooking;
}
}
/**
* Error thrown when requested time slot is not available
*/
class InvalidTimeSlotError extends BookingJSError {
constructor(message = 'Requested time slot is not available') {
super(message, 'INVALID_TIME_SLOT');
}
}
/**
* Error thrown when member lacks required permissions
*/
class MemberPermissionError extends BookingJSError {
constructor(message = 'Member lacks required permissions', memberId = null) {
super(message, 'MEMBER_PERMISSION');
this.memberId = memberId;
}
}
/**
* Error thrown for data validation failures
*/
class ValidationError extends BookingJSError {
constructor(message = 'Data validation failed', field = null, value = null) {
super(message, 'VALIDATION_ERROR');
this.field = field;
this.value = value;
}
}
/**
* Error thrown when booking type is not allowed on green/rink
*/
class BookingTypeNotAllowedError extends BookingJSError {
constructor(message = 'Booking type is not allowed on this green/rink', bookingTypeId = null, location = null) {
super(message, 'BOOKING_TYPE_NOT_ALLOWED');
this.bookingTypeId = bookingTypeId;
this.location = location;
}
}
/**
* Validates booking data
* @param {Object} bookingData - Booking data to validate
* @throws {ValidationError} When validation fails
*/
function validateBooking(bookingData) {
if (!bookingData) {
throw new ValidationError('Booking data is required');
}
if (!bookingData.memberId) {
throw new ValidationError('Member ID is required', 'memberId', bookingData.memberId);
}
if (!bookingData.rinkId) {
throw new ValidationError('Rink ID is required', 'rinkId', bookingData.rinkId);
}
if (!bookingData.bookingTypeId) {
throw new ValidationError('Booking type ID is required', 'bookingTypeId', bookingData.bookingTypeId);
}
if (!bookingData.date) {
throw new ValidationError('Date is required', 'date', bookingData.date);
}
if (!bookingData.start || !(bookingData.start instanceof Date)) {
throw new ValidationError('Valid start time is required', 'start', bookingData.start);
}
if (!bookingData.end || !(bookingData.end instanceof Date)) {
throw new ValidationError('Valid end time is required', 'end', bookingData.end);
}
if (bookingData.start >= bookingData.end) {
throw new ValidationError('End time must be after start time', 'time', {
start: bookingData.start,
end: bookingData.end
});
}
// Validate status if provided
const validStatuses = ['pending', 'confirmed', 'cancelled', 'completed'];
if (bookingData.status && !validStatuses.includes(bookingData.status)) {
throw new ValidationError('Invalid booking status', 'status', bookingData.status);
}
}
/**
* Validates time slot data
* @param {Object} timeSlot - Time slot to validate
* @throws {InvalidTimeSlotError} When validation fails
*/
function validateTimeSlot(timeSlot) {
if (!timeSlot) {
throw new InvalidTimeSlotError('Time slot is required');
}
if (!timeSlot.start || !(timeSlot.start instanceof Date)) {
throw new InvalidTimeSlotError('Valid start time is required');
}
if (!timeSlot.end || !(timeSlot.end instanceof Date)) {
throw new InvalidTimeSlotError('Valid end time is required');
}
if (timeSlot.start >= timeSlot.end) {
throw new InvalidTimeSlotError('End time must be after start time');
}
}
/**
* Validates member permissions (placeholder for future implementation)
* @param {string} memberId - Member ID to validate
* @param {string} action - Action being performed
* @throws {MemberPermissionError} When validation fails
*/
function validateMember(memberId, action = 'access') {
if (!memberId || typeof memberId !== 'string') {
throw new MemberPermissionError('Valid member ID is required', memberId);
}
// Placeholder for future permission validation logic
// This would integrate with the container app's permission system
}
/**
* Validates color hex format
* @param {string} color - Color hex string to validate
* @param {string} fieldName - Name of the field being validated
* @throws {ValidationError} When validation fails
*/
function validateColor(color, fieldName = 'color') {
const colorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
if (!color || typeof color !== 'string' || !colorRegex.test(color)) {
throw new ValidationError(`Valid hex color required for ${fieldName}`, fieldName, color);
}
}
/**
* Calculate duration between two dates in minutes
* @param {Date} start - Start time
* @param {Date} end - End time
* @returns {number} Duration in minutes
*/
function calculateDuration(start, end) {
if (!start || !end || !(start instanceof Date) || !(end instanceof Date)) {
return 0;
}
return Math.round((end - start) / (1000 * 60));
}
/**
* Check if a time slot is available (not overlapping with existing bookings)
* @param {Date} start - Proposed start time
* @param {Date} end - Proposed end time
* @param {Array} existingBookings - Array of existing bookings to check against
* @param {string} excludeBookingId - Booking ID to exclude from check (for updates)
* @returns {boolean} True if time slot is available
*/
function isTimeSlotAvailable(start, end, existingBookings = [], excludeBookingId = null) {
if (!start || !end || !(start instanceof Date) || !(end instanceof Date)) {
return false;
}
if (start >= end) {
return false;
}
for (const booking of existingBookings) {
// Skip cancelled bookings and excluded booking
if (booking.status === 'cancelled' || booking._id === excludeBookingId) {
continue;
}
const bookingStart = booking.start;
const bookingEnd = booking.end;
// Check for time overlap
if (start < bookingEnd && end > bookingStart) {
return false;
}
}
return true;
}
/**
* Format date and time for display
* @param {Date} date - Date to format
* @param {Object} options - Formatting options
* @returns {string} Formatted date string
*/
function formatDateTime(date, options = {}) {
if (!date || !(date instanceof Date)) {
return '';
}
const defaults = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
};
const formatOptions = {
...defaults,
...options
};
return date.toLocaleString('en-GB', formatOptions);
}
/**
* Check if two time periods overlap
* @param {Date} start1 - Start of first period
* @param {Date} end1 - End of first period
* @param {Date} start2 - Start of second period
* @param {Date} end2 - End of second period
* @returns {boolean} True if periods overlap
*/
function timePeriodsOverlap(start1, end1, start2, end2) {
return start1 < end2 && end1 > start2;
}
/**
* Get the next available time slot after a given time
* @param {Date} afterTime - Time to search after
* @param {number} duration - Duration in minutes
* @param {Array} existingBookings - Existing bookings to avoid
* @param {Object} workingHours - Working hours constraints
* @returns {Object|null} Next available slot or null
*/
function getNextAvailableSlot(afterTime, duration, existingBookings = [], workingHours = null) {
if (!afterTime || !duration || duration <= 0) {
return null;
}
const proposedStart = new Date(afterTime);
const proposedEnd = new Date(proposedStart.getTime() + duration * 60 * 1000);
// Apply working hours if provided
if (workingHours) {
const startHour = proposedStart.getHours();
const endHour = proposedEnd.getHours();
if (startHour < workingHours.start || endHour > workingHours.end) {
// Adjust to next available working hour
const nextDay = new Date(proposedStart);
nextDay.setDate(nextDay.getDate() + 1);
nextDay.setHours(workingHours.start, 0, 0, 0);
return getNextAvailableSlot(nextDay, duration, existingBookings, workingHours);
}
}
if (isTimeSlotAvailable(proposedStart, proposedEnd, existingBookings)) {
return {
start: proposedStart,
end: proposedEnd
};
}
// Find next gap in bookings
const futureBookings = existingBookings.filter(booking => booking.start > afterTime && booking.status !== 'cancelled').sort((a, b) => a.start - b.start);
if (futureBookings.length === 0) {
return {
start: proposedStart,
end: proposedEnd
};
}
// Try after each booking
for (const booking of futureBookings) {
const nextSlot = getNextAvailableSlot(booking.end, duration, existingBookings, workingHours);
if (nextSlot) {
return nextSlot;
}
}
return null;
}
/**
* Represents a bookable rink within a green
*/
class Rink {
/**
* Create a new Rink
* @param {Object} data - Rink configuration
* @param {string} [data._id] - Unique identifier (auto-generated if not provided)
* @param {number} data.number - Rink number (1-12)
* @param {Array<string>} [data.allowedBookingTypes] - Array of BookingType IDs that can be booked
*/
constructor(data) {
if (!data || typeof data !== 'object') {
throw new ValidationError('Rink data is required');
}
if (typeof data.number !== 'number' || data.number < 1 || data.number > 12) {
throw new ValidationError('Rink number must be between 1 and 12', 'number', data.number);
}
this._id = data._id || uuid.v4();
this.number = data.number;
this.allowedBookingTypes = Array.isArray(data.allowedBookingTypes) ? [...data.allowedBookingTypes] : [];
}
/**
* Update rink details
* @param {Object} updates - Properties to update
* @returns {Rink} This instance for chaining
*/
update(updates) {
if (!updates || typeof updates !== 'object') {
throw new ValidationError('Updates object is required');
}
const allowedFields = ['number', 'allowedBookingTypes'];
const updateKeys = Object.keys(updates);
for (const key of updateKeys) {
if (!allowedFields.includes(key)) {
throw new ValidationError(`Field '${key}' cannot be updated`, key, updates[key]);
}
}
// Validate number update
if (updates.number !== undefined) {
if (typeof updates.number !== 'number' || updates.number < 1 || updates.number > 12) {
throw new ValidationError('Rink number must be between 1 and 12', 'number', updates.number);
}
this.number = updates.number;
}
// Validate allowedBookingTypes update
if (updates.allowedBookingTypes !== undefined) {
if (!Array.isArray(updates.allowedBookingTypes)) {
throw new ValidationError('Allowed booking types must be an array', 'allowedBookingTypes', updates.allowedBookingTypes);
}
this.allowedBookingTypes = [...updates.allowedBookingTypes];
}
return this;
}
/**
* Check availability for specific time
* @param {Date} date - Date to check
* @param {Date} start - Start time to check
* @param {Date} end - End time to check
* @param {Array} existingBookings - Existing bookings to check against
* @param {string} excludeBookingId - Booking ID to exclude from check
* @returns {boolean} True if available
*/
isAvailable(date, start, end, existingBookings = [], excludeBookingId = null) {
if (!date || !start || !end) {
return false;
}
// Filter bookings for this rink and date
const rinkBookings = existingBookings.filter(booking => booking.rinkId === this._id && booking.date.toDateString() === date.toDateString() && booking.status !== 'cancelled');
return isTimeSlotAvailable(start, end, rinkBookings, excludeBookingId);
}
/**
* Add a booking type to allowed types
* @param {string} bookingTypeId - Booking type ID to add
* @returns {Rink} This instance for chaining
*/
addAllowedBookingType(bookingTypeId) {
if (!bookingTypeId || typeof bookingTypeId !== 'string') {
throw new ValidationError('Valid booking type ID is required', 'bookingTypeId', bookingTypeId);
}
if (!this.allowedBookingTypes.includes(bookingTypeId)) {
this.allowedBookingTypes.push(bookingTypeId);
}
return this;
}
/**
* Remove a booking type from allowed types
* @param {string} bookingTypeId - Booking type ID to remove
* @returns {Rink} This instance for chaining
*/
removeAllowedBookingType(bookingTypeId) {
if (!bookingTypeId || typeof bookingTypeId !== 'string') {
throw new ValidationError('Valid booking type ID is required', 'bookingTypeId', bookingTypeId);
}
const index = this.allowedBookingTypes.indexOf(bookingTypeId);
if (index > -1) {
this.allowedBookingTypes.splice(index, 1);
}
return this;
}
/**
* Check if booking type is allowed on this rink
* @param {string} bookingTypeId - Booking type ID to check
* @returns {boolean} True if booking type is allowed
*/
isBookingTypeAllowed(bookingTypeId) {
if (!bookingTypeId || typeof bookingTypeId !== 'string') {
return false;
}
// If no restrictions, allow all booking types
if (this.allowedBookingTypes.length === 0) {
return true;
}
return this.allowedBookingTypes.includes(bookingTypeId);
}
/**
* Validate if a booking can be made on this rink
* @param {string} bookingTypeId - Booking type ID
* @param {Date} date - Booking date
* @param {Date} start - Start time
* @param {Date} end - End time
* @param {Array} existingBookings - Existing bookings
* @param {string} excludeBookingId - Booking ID to exclude
* @throws {BookingTypeNotAllowedError} When booking type is not allowed
* @throws {ValidationError} When time slot is not available
*/
validateBooking(bookingTypeId, date, start, end, existingBookings = [], excludeBookingId = null) {
// Check if booking type is allowed
if (!this.isBookingTypeAllowed(bookingTypeId)) {
throw new BookingTypeNotAllowedError(`Booking type ${bookingTypeId} is not allowed on rink ${this.number}`, bookingTypeId, `rink-${this._id}`);
}
// Check availability
if (!this.isAvailable(date, start, end, existingBookings, excludeBookingId)) {
throw new ValidationError(`Rink ${this.number} is not available for the requested time slot`, 'availability', {
date,
start,
end
});
}
}
/**
* Get bookings for this rink within a date range
* @param {Array} allBookings - All bookings to filter
* @param {Date} startDate - Start of date range
* @param {Date} endDate - End of date range
* @returns {Array} Filtered bookings for this rink
*/
getBookings(allBookings, startDate = null, endDate = null) {
let rinkBookings = allBookings.filter(booking => booking.rinkId === this._id);
if (startDate && endDate) {
rinkBookings = rinkBookings.filter(booking => {
const bookingDate = new Date(booking.date);
return bookingDate >= startDate && bookingDate <= endDate;
});
}
return rinkBookings.sort((a, b) => a.start - b.start);
}
/**
* Get utilization statistics for this rink
* @param {Array} allBookings - All bookings to analyze
* @param {Date} startDate - Start of analysis period
* @param {Date} endDate - End of analysis period
* @returns {Object} Utilization statistics
*/
getUtilizationStats(allBookings, startDate, endDate) {
const rinkBookings = this.getBookings(allBookings, startDate, endDate).filter(booking => booking.status === 'confirmed' || booking.status === 'completed');
const totalMinutes = rinkBookings.reduce((sum, booking) => {
return sum + Math.round((booking.end - booking.start) / (1000 * 60));
}, 0);
const totalDays = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24));
const totalAvailableMinutes = totalDays * 12 * 60; // Assuming 12 hours per day
return {
rinkId: this._id,
rinkNumber: this.number,
totalBookings: rinkBookings.length,
totalBookedMinutes: totalMinutes,
totalAvailableMinutes,
utilizationPercentage: totalMinutes / totalAvailableMinutes * 100,
averageBookingDuration: rinkBookings.length > 0 ? totalMinutes / rinkBookings.length : 0
};
}
/**
* Convert to JSON string for persistence
* @returns {string} JSON string representation
*/
toJSON() {
const data = {
_id: this._id,
number: this.number,
allowedBookingTypes: [...this.allowedBookingTypes]
};
return JSON.stringify(data);
}
/**
* Create Rink instance from JSON string
* @param {string} jsonString - JSON string data
* @returns {Rink} New Rink instance
*/
static fromJSON(jsonString) {
if (!jsonString || typeof jsonString !== 'string') {
throw new ValidationError('Valid JSON string is required');
}
let jsonData;
try {
jsonData = JSON.parse(jsonString);
} catch (error) {
throw new ValidationError('Invalid JSON string format');
}
return new Rink({
_id: jsonData._id,
number: jsonData.number,
allowedBookingTypes: jsonData.allowedBookingTypes || []
});
}
}
/**
* Represents a bowling green containing multiple rinks
*/
class Green {
/**
* Create a new Green
* @param {Object} data - Green configuration
* @param {string} [data._id] - Unique identifier (auto-generated if not provided)
* @param {string} data.name - Name of the green
* @param {Array<Rink>} [data.rinks] - Array of Rink instances (max 12)
* @param {string} [data.location] - Physical location/address
* @param {Array<string>} [data.facilities] - Array of available facilities
* @param {Array<string>} [data.allowedBookingTypes] - Array of BookingType IDs
*/
constructor(data) {
if (!data || typeof data !== 'object') {
throw new ValidationError('Green data is required');
}
if (!data.name || typeof data.name !== 'string') {
throw new ValidationError('Name is required', 'name', data.name);
}
this._id = data._id || uuid.v4();
this.name = data.name.trim();
this.location = data.location ? data.location.trim() : '';
this.facilities = Array.isArray(data.facilities) ? [...data.facilities] : [];
this.allowedBookingTypes = Array.isArray(data.allowedBookingTypes) ? [...data.allowedBookingTypes] : [];
// Initialize rinks
this.rinks = [];
if (Array.isArray(data.rinks)) {
for (const rinkData of data.rinks) {
if (rinkData instanceof Rink) {
this.addRink(rinkData);
} else {
this.addRink(new Rink(rinkData));
}
}
}
}
/**
* Add a rink to the green (max 12)
* @param {Rink|Object} rink - Rink instance or rink data
* @returns {Green} This instance for chaining
*/
addRink(rink) {
if (this.rinks.length >= 12) {
throw new ValidationError('Maximum of 12 rinks allowed per green', 'rinks', this.rinks.length);
}
let rinkInstance;
if (rink instanceof Rink) {
rinkInstance = rink;
} else if (rink && typeof rink === 'object') {
rinkInstance = new Rink(rink);
} else {
throw new ValidationError('Valid rink data or Rink instance is required', 'rink', rink);
}
// Check for duplicate rink numbers
const existingRink = this.rinks.find(r => r.number === rinkInstance.number);
if (existingRink) {
throw new ValidationError(`Rink number ${rinkInstance.number} already exists`, 'rinkNumber', rinkInstance.number);
}
this.rinks.push(rinkInstance);
return this;
}
/**
* Remove a rink from the green
* @param {string} rinkId - Rink ID to remove
* @returns {Green} This instance for chaining
*/
removeRink(rinkId) {
if (!rinkId || typeof rinkId !== 'string') {
throw new ValidationError('Valid rink ID is required', 'rinkId', rinkId);
}
const index = this.rinks.findIndex(rink => rink._id === rinkId);
if (index === -1) {
throw new ValidationError('Rink not found', 'rinkId', rinkId);
}
this.rinks.splice(index, 1);
return this;
}
/**
* Get a specific rink by ID
* @param {string} rinkId - Rink ID to find
* @returns {Rink|null} Rink instance or null if not found
*/
getRink(rinkId) {
if (!rinkId || typeof rinkId !== 'string') {
return null;
}
return this.rinks.find(rink => rink._id === rinkId) || null;
}
/**
* Get a rink by number
* @param {number} rinkNumber - Rink number to find
* @returns {Rink|null} Rink instance or null if not found
*/
getRinkByNumber(rinkNumber) {
if (typeof rinkNumber !== 'number') {
return null;
}
return this.rinks.find(rink => rink.number === rinkNumber) || null;
}
/**
* Get available rinks for time period
* @param {Date} date - Date to check
* @param {Date} start - Start time
* @param {Date} end - End time
* @param {Array} existingBookings - Existing bookings to check against
* @param {string} bookingTypeId - Booking type ID to check restrictions
* @param {string} excludeBookingId - Booking ID to exclude from check
* @returns {Array<Rink>} Available rinks
*/
getAvailableRinks(date, start, end, existingBookings = [], bookingTypeId = null, excludeBookingId = null) {
if (!date || !start || !end) {
return [];
}
return this.rinks.filter(rink => {
// Check booking type restrictions
if (bookingTypeId && !this.isBookingTypeAllowed(bookingTypeId)) {
return false;
}
if (bookingTypeId && !rink.isBookingTypeAllowed(bookingTypeId)) {
return false;
}
// Check availability
return rink.isAvailable(date, start, end, existingBookings, excludeBookingId);
}).sort((a, b) => a.number - b.number);
}
/**
* Add a booking type to allowed types
* @param {string} bookingTypeId - Booking type ID to add
* @returns {Green} This instance for chaining
*/
addAllowedBookingType(bookingTypeId) {
if (!bookingTypeId || typeof bookingTypeId !== 'string') {
throw new ValidationError('Valid booking type ID is required', 'bookingTypeId', bookingTypeId);
}
if (!this.allowedBookingTypes.includes(bookingTypeId)) {
this.allowedBookingTypes.push(bookingTypeId);
}
return this;
}
/**
* Remove a booking type from allowed types
* @param {string} bookingTypeId - Booking type ID to remove
* @returns {Green} This instance for chaining
*/
removeAllowedBookingType(bookingTypeId) {
if (!bookingTypeId || typeof bookingTypeId !== 'string') {
throw new ValidationError('Valid booking type ID is required', 'bookingTypeId', bookingTypeId);
}
const index = this.allowedBookingTypes.indexOf(bookingTypeId);
if (index > -1) {
this.allowedBookingTypes.splice(index, 1);
}
return this;
}
/**
* Check if booking type is allowed on this green
* @param {string} bookingTypeId - Booking type ID to check
* @returns {boolean} True if booking type is allowed
*/
isBookingTypeAllowed(bookingTypeId) {
if (!bookingTypeId || typeof bookingTypeId !== 'string') {
return false;
}
// If no restrictions, allow all booking types
if (this.allowedBookingTypes.length === 0) {
return true;
}
return this.allowedBookingTypes.includes(bookingTypeId);
}
/**
* Add a facility to the green
* @param {string} facility - Facility name to add
* @returns {Green} This instance for chaining
*/
addFacility(facility) {
if (!facility || typeof facility !== 'string') {
throw new ValidationError('Valid facility name is required', 'facility', facility);
}
const facilityName = facility.trim();
if (!this.facilities.includes(facilityName)) {
this.facilities.push(facilityName);
}
return this;
}
/**
* Remove a facility from the green
* @param {string} facility - Facility name to remove
* @returns {Green} This instance for chaining
*/
removeFacility(facility) {
if (!facility || typeof facility !== 'string') {
throw new ValidationError('Valid facility name is required', 'facility', facility);
}
const index = this.facilities.indexOf(facility.trim());
if (index > -1) {
this.facilities.splice(index, 1);
}
return this;
}
/**
* Update green details
* @param {Object} updates - Properties to update
* @returns {Green} This instance for chaining
*/
update(updates) {
if (!updates || typeof updates !== 'object') {
throw new ValidationError('Updates object is required');
}
const allowedFields = ['name', 'location', 'facilities', 'allowedBookingTypes'];
const updateKeys = Object.keys(updates);
for (const key of updateKeys) {
if (!allowedFields.includes(key)) {
throw new ValidationError(`Field '${key}' cannot be updated`, key, updates[key]);
}
}
// Apply updates
if (updates.name !== undefined) {
if (!updates.name || typeof updates.name !== 'string') {
throw new ValidationError('Name must be a non-empty string', 'name', updates.name);
}
this.name = updates.name.trim();
}
if (updates.location !== undefined) {
this.location = updates.location ? updates.location.trim() : '';
}
if (updates.facilities !== undefined) {
if (!Array.isArray(updates.facilities)) {
throw new ValidationError('Facilities must be an array', 'facilities', updates.facilities);
}
this.facilities = [...updates.facilities];
}
if (updates.allowedBookingTypes !== undefined) {
if (!Array.isArray(updates.allowedBookingTypes)) {
throw new ValidationError('Allowed booking types must be an array', 'allowedBookingTypes', updates.allowedBookingTypes);
}
this.allowedBookingTypes = [...updates.allowedBookingTypes];
}
return this;
}
/**
* Get bookings for this green within a date range
* @param {Array} allBookings - All bookings to filter
* @param {Date} startDate - Start of date range
* @param {Date} endDate - End of date range
* @returns {Array} Filtered bookings for this green
*/
getBookings(allBookings, startDate = null, endDate = null) {
const rinkIds = this.rinks.map(rink => rink._id);
let greenBookings = allBookings.filter(booking => rinkIds.includes(booking.rinkId));
if (startDate && endDate) {
greenBookings = greenBookings.filter(booking => {
const bookingDate = new Date(booking.date);
return bookingDate >= startDate && bookingDate <= endDate;
});
}
return greenBookings.sort((a, b) => a.start - b.start);
}
/**
* Get utilization statistics for this green
* @param {Array} allBookings - All bookings to analyze
* @param {Date} startDate - Start of analysis period
* @param {Date} endDate - End of analysis period
* @returns {Object} Utilization statistics
*/
getUtilizationStats(allBookings, startDate, endDate) {
const greenBookings = this.getBookings(allBookings, startDate, endDate).filter(booking => booking.status === 'confirmed' || booking.status === 'completed');
const totalMinutes = greenBookings.reduce((sum, booking) => {
return sum + Math.round((booking.end - booking.start) / (1000 * 60));
}, 0);
const totalDays = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24));
const totalAvailableMinutes = totalDays * this.rinks.length * 12 * 60; // Assuming 12 hours per day per rink
const rinkStats = this.rinks.map(rink => rink.getUtilizationStats(allBookings, startDate, endDate));
return {
greenId: this._id,
greenName: this.name,
totalRinks: this.rinks.length,
totalBookings: greenBookings.length,
totalBookedMinutes: totalMinutes,
totalAvailableMinutes,
utilizationPercentage: totalMinutes / totalAvailableMinutes * 100,
averageBookingDuration: greenBookings.length > 0 ? totalMinutes / greenBookings.length : 0,
rinkStats
};
}
/**
* Validate if a booking can be made on this green
* @param {string} rinkId - Rink ID
* @param {string} bookingTypeId - Booking type ID
* @param {Date} date - Booking date
* @param {Date} start - Start time
* @param {Date} end - End time
* @param {Array} existingBookings - Existing bookings
* @param {string} excludeBookingId - Booking ID to exclude
* @throws {ValidationError} When rink is not found
* @throws {BookingTypeNotAllowedError} When booking type is not allowed
*/
validateBooking(rinkId, bookingTypeId, date, start, end, existingBookings = [], excludeBookingId = null) {
// Check if rink exists
const rink = this.getRink(rinkId);
if (!rink) {
throw new ValidationError(`Rink ${rinkId} not found in green ${this.name}`, 'rinkId', rinkId);
}
// Check if booking type is allowed on green
if (!this.isBookingTypeAllowed(bookingTypeId)) {
throw new BookingTypeNotAllowedError(`Booking type ${bookingTypeId} is not allowed on green ${this.name}`, bookingTypeId, `green-${this._id}`);
}
// Delegate to rink validation
rink.validateBooking(bookingTypeId, date, start, end, existingBookings, excludeBookingId);
}
/**
* Convert to JSON string for persistence
* @returns {string} JSON string representation
*/
toJSON() {
const data = {
_id: this._id,
name: this.name,
location: this.location,
facilities: [...this.facilities],
allowedBookingTypes: [...this.allowedBookingTypes],
rinks: this.rinks.map(rink => JSON.parse(rink.toJSON()))
};
return JSON.stringify(data);
}
/**
* Create Green instance from JSON string
* @param {string} jsonString - JSON string data
* @returns {Green} New Green instance
*/
static fromJSON(jsonString) {
if (!jsonString || typeof jsonString !== 'string') {
throw new ValidationError('Valid JSON string is required');
}
let jsonData;
try {
jsonData = JSON.parse(jsonString);
} catch (error) {
throw new ValidationError('Invalid JSON string format');
}
return new Green({
_id: jsonData._id,
name: jsonData.name,
location: jsonData.location,
facilities: jsonData.facilities || [],
allowedBookingTypes: jsonData.allowedBookingTypes || [],
rinks: jsonData.rinks || []
});
}
}
/**
* Represents a booking reservation
*/
class Booking {
/**
* Create a new Booking
* @param {Object} data - Booking configuration
* @param {string} [data._id] - Unique identifier (auto-generated if not provided)
* @param {string} data.memberId - Member GUID making the booking
* @param {string} data.rinkId - Reference to the booked rink
* @param {string} data.bookingTypeId - Reference to the booking type
* @param {Date} data.date - Booking date
* @param {Date} data.start - Booking start time
* @param {Date} data.end - Booking end time
* @param {string} [data.status='pending'] - Booking status
* @param {Array<string>} [data.participants] - Array of member IDs
* @param {string} [data.notes] - Additional booking notes
* @param {Date} [data.createdDate] - Timestamp of booking creation
* @param {Date} [data.updatedDate] - Timestamp of last update
*/
constructor(data) {
if (!data || typeof data !== 'object') {
throw new ValidationError('Booking data is required');
}
// Validate required booking data
validateBooking(data);
this._id = data._id || uuid.v4();
this.memberId = data.memberId;
this.rinkId = data.rinkId;
this.bookingTypeId = data.bookingTypeId;
this.date = new Date(data.date);
this.start = new Date(data.start);
this.end = new Date(data.end);
this.status = data.status || 'pending';
this.participants = Array.isArray(data.participants) ? [...data.participants] : [];
this.notes = data.notes || '';
this.createdDate = data.createdDate ? new Date(data.createdDate) : new Date();
this.updatedDate = data.updatedDate ? new Date(data.updatedDate) : new Date();
// Ensure member is in participants list
if (!this.participants.includes(this.memberId)) {
this.participants.unshift(this.memberId);
}
// Validate date consistency
const bookingDay = this.date.toDateString();
const startDay = this.start.toDateString();
const endDay = this.end.toDateString();
if (bookingDay !== startDay || bookingDay !== endDay) {
throw new ValidationError('Start and end times must be on the same date as the booking date', 'dateConsistency', {
date: this.date,
start: this.start,
end: this.end
});
}
}
/**
* Update booking status
* @param {string} newStatus - New status to set
* @param {string} memberId - Member performing the update
* @returns {Booking} This instance for chaining
*/
updateStatus(newStatus, memberId = null) {
const validStatuses = ['pending', 'confirmed', 'cancelled', 'completed'];
if (!validStatuses.includes(newStatus)) {
throw new ValidationError('Invalid booking status', 'status', newStatus);
}
// Validate status transitions
const validTransitions = {
pending: ['confirmed', 'cancelled'],
confirmed: ['cancelled', 'completed'],
cancelled: [],
// Cannot transition from cancelled
completed: [] // Cannot transition from completed
};
if (!validTransitions[this.status].includes(newStatus)) {
throw new ValidationError(`Invalid status transition from ${this.status} to ${newStatus}`, 'statusTransition', {
from: this.status,
to: newStatus
});
}
this.status = newStatus;
this.updatedDate = new Date();
return this;
}
/**
* Add participant to booking
* @param {string} memberId - Member ID to add
* @returns {Booking} This instance for chaining
*/
addParticipant(memberId) {
if (!memberId || typeof memberId !== 'string') {
throw new ValidationError('Valid member ID is required', 'memberId', memberId);
}
if (!this.participants.includes(memberId)) {
this.participants.push(memberId);
this.updatedDate = new Date();
}
return this;
}
/**
* Remove participant from booking
* @param {string} memberId - Member ID to remove
* @returns {Booking} This instance for chaining
*/
removeParticipant(memberId) {
if (!memberId || typeof memberId !== 'string') {
throw new ValidationError('Valid member ID is required', 'memberId', memberId);
}
// Cannot remove the booking owner
if (memberId === this.memberId) {
throw new ValidationError('Cannot remove booking owner from participants', 'memberId', memberId);
}
const index = this.participants.indexOf(memberId);
if (index > -1) {
this.participants.splice(index, 1);
this.updatedDate = new Date();
}
return this;
}
/**
* Get booking duration in minutes
* @returns {number} Duration in minutes
*/
getDuration() {
return calculateDuration(this.start, this.end);
}
/**
* Check if booking is currently active
* @returns {boolean} True if booking is currently active
*/
isActive() {
if (this.status !== 'confirmed') {
return false;
}
const now = new Date();
return now >= this.start && now <= this.end;
}
/**
* Check if booking is in the past
* @returns {boolean} True if booking is in the past
*/
isPast() {
const now = new Date();
return this.end < now;
}
/**
* Check if booking is in the future
* @returns {boolean} True if booking is in the future
*/
isFuture() {
const now = new Date();
return this.start > now;
}
/**
* Check if booking can be modified
* @returns {boolean} True if booking can be modified
*/
canModify() {
return this.status === 'pending' || this.status === 'confirmed';
}
/**
* Check if booking can be cancelled
* @returns {boolean} True if booking can be cancelled
*/
canCancel() {
return (this.status === 'pending' || this.status === 'confirmed') && !this.isPast();
}
/**
* Update booking details
* @param {Object} updates - Properties to update
* @param {string} memberId - Member performing the update
* @returns {Booking} This instance for chaining
*/
update(updates, memberId = null) {
if (!updates || typeof updates !== 'object') {
throw new ValidationError('Updates object is required');
}
if (!this.canModify()) {
throw new ValidationError(`Cannot modify booking with status: ${this.status}`, 'status', this.status);
}
const allowedFields = ['date', 'start', 'end', 'bookingTypeId', 'participants', 'notes'];
const updateKeys = Object.keys(updates);
for (const key of updateKeys) {
if (!allowedFields.includes(key)) {
throw new ValidationError(`Field '${key}' cannot be updated`, key, updates[key]);
}
}
// Validate time updates
const newDate = updates.date ? new Date(updates.date) : this.date;
const newStart = updates.start ? new Date(updates.start) : this.start;
const newEnd = updates.end ? new Date(updates.end) : this.end;
if (newStart >= newEnd) {
throw new ValidationError('End time must be after start time', 'time', {
start: newStart,
end: newEnd
});
}
// Apply updates
if (updates.date !== undefined) {
this.date = newDate;
}
if (updates.start !== undefined) {
this.start = newStart;
}
if (updates.end !== undefined) {
this.end = newEnd;
}
if (updates.bookingTypeId !== undefined) {
this.bookingTypeId = updates.bookingTypeId;
}
if (updates.participants !== undefined) {
if (!Array.isArray(updates.participants)) {
throw new ValidationError('Participants must be an array', 'participants', updates.participants);
}
this.participants = [...updates.participants];
// Ensure booking owner is in participants
if (!this.participants.includes(this.memberId)) {
this.participants.unshift(this.memberId);
}
}
if (updates.notes !== undefined) {
this.notes = updates.notes || '';
}
this.updatedDate = new Date();
return this;
}
/**
* Get booking type reference (requires BookingType instances to be provided)
* @param {Array<BookingType>} bookingTypes - Available booking types
* @returns {BookingType|null} BookingType instance or null if not found
*/
getBookingType(bookingTypes) {
if (!Array.isArray(bookingTypes)) {
return null;
}
return bookingTypes.find(type => type._id === this.bookingTypeId) || null;
}
/**
* Check if booking overlaps with another booking
* @param {Booking} otherBooking - Other booking to check
* @returns {boolean} True if bookings overlap
*/
overlapsWith(otherBooking) {
if (!otherBooking || !(otherBooking instanceof Booking)) {
return false;
}
// Different rinks don't overlap
if (this.rinkId !== otherBooking.rinkId) {
return false;
}
// Different dates don't overlap
if (this.date.toDateString() !== otherBooking.date.toDateString()) {
return false;
}
// Check time overlap
return this.start < otherBooking.end && this.end > otherBooking.start;
}
/**
* Convert to JSON string for persistence
* @returns {string} JSON string representation
*/
toJSON() {
const data = {
_id: this._id,
memberId: this.memberId,
rinkId: this.rinkId,
bookingTypeId: this.bookingTypeId,
date: this.date.toISOString().split('T')[0],
// Date only
start: this.start.toISOString(),
end: this.end.toISOString(),
status: this.status,
participants: [...this.participants],
notes: this.notes,
createdDate: this.createdDate.toISOString(),
updatedDate: this.updatedDate.toISOString()
};
return JSON.stringify(data);
}
/**
* Create Booking instance from JSON string
* @param {string} jsonString - JSON string data
* @returns {Booking} New Booking instance
*/
static fromJSON(jsonString) {
if (!jsonString || typeof jsonString !== 'string') {
throw new ValidationError('Valid JSON string is required');
}
let jsonData;
try {
jsonData = JSON.parse(jsonString);
} catch (error) {
throw new ValidationError('Invalid JSON string format');
}
return new Booking({
_id: jsonData._id,
memberId: jsonData.memberId,
rinkId: jsonData.rinkId,
bookingTypeId: jsonData.bookingTypeId,
date: new Date(jsonData.date),
start: new Date(jsonData.start),
end: new Date(jsonData.end),
status: jsonData.status,
participants: jsonData.participants || [],
notes: jsonData.notes || '',
createdDate: jsonData.createdDate ? new Date(jsonData.createdDate) : new Date(),
updatedDate: jsonData.updatedDate ? new Date(jsonData.updatedDate) : new Date()
});
}
}
/**
* Represents a type of booking with visual styling and restrictions
*/
class BookingType {
/**
* Create a new BookingType
* @param {Object} data - BookingType configuration
* @param {string} [data._id] - Unique identifier (auto-generated if not provided)
* @param {string} data.name - Display name for the booking type
* @param {string} data.backgroundColor - Background color (hex color)
* @param {string} data.foregroundColor - Text/foreground color (hex color)
* @param {string} [data.description] - Optional description
* @param {number} [data.priority=1] - Numeric priority for conflicts (higher = more important)
*/
constructor(data) {
if (!data || typeof data !== 'object') {
throw new ValidationError('BookingType data is required');
}
if (!data.name || typeof data.name !== 'string') {
throw new ValidationError('Name is required', 'name', data.name);
}
if (!data.backgroundColor) {
throw new ValidationError('Background color is required', 'backgroundColor', data.backgroundColor);
}
if (!data.foregroundColor) {
throw new ValidationError('Foreground color is required', 'foregroundColor', data.foregroundColor);
}
// Validate colors
validateColor(data.backgroundColor, 'backgroundColor');
validateColor(data.foregroundColor, 'foregroundColor');
this._id = data._id || uuid.v4();
this.name = data.name.trim();
this.backgroundColor = data.backgroundColor;
this.foregroundColor = data.foregroundColor;
this.description = data.description ? data.description.trim() : '';
this.priority = typeof data.priority === 'number' ? data.priority : 1;
// Validate priority range
if (this.priority < 1 || this.priority > 100) {
throw new ValidationError('Priority must be between 1 and 100', 'priority', this.priority);
}
}
/**
* Update booking type properties
* @param {Object} updates - Properties to update
* @returns {BookingType} This instance for chaining
*/
update(updates) {
if (!updates || typeof updates !== 'object') {
throw new ValidationError('Updates object is required');
}
// Validate updates without applying them first
const allowedFields = ['name', 'backgroundColor', 'foregroundColor', 'description', 'priority'];
const updateKeys = Object.keys(updates);
for (const key of updateKeys) {
if (!allowedFields.includes(key)) {
throw new ValidationError(`Field '${key}' cannot be updated`, key, updates[key]);
}
}
// Validate specific field types
if (updates.name !== undefined) {
if (!updates.name || typeof updates.name !== 'string') {
throw new ValidationError('Name must be a non-empty string', 'name', updates.name);
}
}
if (updates.backgroundColor !== undefined) {
validateColor(updates.backgroundColor, 'backgroundColor');
}
if (updates.foregroundColor !== undefined) {
validateColor(updates.foregroundColor, 'foregroundColor');
}
if (updates.priority !== undefined) {
if (typeof updates.priority !== 'number' || updates.priority < 1 || updates.priority > 100) {
throw new ValidationError('Priority must be a number between 1 and 100', 'priority', updates.priority);
}
}
// Apply updates
if (updates.name !== undefined) {
this.name = updates.name.trim();
}
if (updates.backgroundColor !== undefined) {
this.backgroundColor = updates.backgroundColor;
}
if (updates.foregroundColor !== undefined) {
this.foregroundColor = updates.foregroundColor;
}
if (updates.description !== undefined) {
this.description = updates.description ? updates.description.trim() : '';
}
if (updates.priority !== undefined) {
this.priority = updates.priority;
}
return this;
}
/**
* Convert to JSON string for persistence
* @returns {string} JSON string representation
*/
toJSON() {
const data = {
_id: this._id,
name: this.name,
backgroundColor: this.backgroundColor,
foregroundColor: this.foregroundColor,
description: this.description,
priority: this.priority
};
return JSON.stringify(data);
}
/**
* Create BookingType instance from JSON string
* @param {string} jsonString - JSON string data
* @returns {BookingType} New BookingType instance
*/
static fromJSON(jsonString) {
if (!jsonString || typeof jsonString !== 'string') {
throw new ValidationError('Valid JSON string is required');
}
let jsonData;
try {
jsonData = JSON.parse(jsonString);
} catch (error) {
throw new ValidationError('Invalid JSON string format');
}
return new BookingType(jsonData);
}
/**
* Get default booking types
* @returns {Array<BookingType>} Array of default booking types
*/
static getDefaults() {
return [new BookingType({
_id: 'regular',
name: 'Regular Booking',
backgroundColor: '#4CAF50',
foregroundColor: '#FFFFFF',
description: 'Standard green booking',
priority: 1
}), new BookingType({
_id: 'maintenance',
name: 'Maintenance',
backgroundColor: '#FF9800',
foregroundColor: '#000000',
description: 'Green maintenance and repairs',
priority: 10
}), new BookingType({
_id: 'tournament',
name: 'Tournament',
backgroundColor: '#9C27B0',
foregroundColor: '#FFFFFF',
description: 'Tournament or competition booking',
priority: 5
})];
}
}
/**
* Represents an audit log entry for tracking system changes
*/
class AuditLogEntry {
/**
* Create a new AuditLogEntry
* @param {Object} data - AuditLogEntry configuration
* @param {string} [data._id] - Unique identifier (auto-generated if not provided)
* @param {string} data.action - Type of action performed
* @param {string} data.entityType - Type of entity affected
* @param {string} data.entityId - ID of the affected entity
* @param {string} data.memberId - ID of member who performed the action
* @param {Date} [data.timestamp] - When the action occurred (defaults to now)
* @param {Object} [data.oldValues] - Previous values (for updates)
* @param {Object} [data.newValues] - New values (for creates/updates)
* @param {Object} [data.metadata] - Additional context information
*/
constructor(data) {
if (!data || typeof data !== 'object') {
throw new ValidationError('AuditLogEntry data is required');
}
// Validate required fields
if (!data.action || typeof data.action !== 'string') {
throw new ValidationError('Action is required', 'action', data.action);
}
if (!data.entityType || typeof data.entityType !== 'string') {
throw new ValidationError('Entity type is required', 'entityType', data.entityType);
}
if (!data.entityId || typeof data.entityId !== 'string') {
throw new ValidationError('Entity ID is required', 'entityId', data.entityId);
}
if (!data.memberId || typeof data.memberId !== 'string') {
throw new ValidationError('Member ID is required', 'memberId', data.memberId);
}
// Validate action types
const validActions = ['create', 'update', 'delete', 'cancel'];
if (!validActions.includes(data.action)) {
throw new ValidationError(`Invalid action. Must be one of: ${validActions.join(', ')}`, 'action', data.action);
}
// Validate entity types
const validEntityTypes = ['booking', 'green', 'rink', 'bookingType', 'timeSlot', 'system'];
if (!validEntityTypes.includes(data.entityType)) {
throw new ValidationError(`Invalid entity type. Must be one of: ${validEntityTypes.join(', ')}`, 'entityType', data.entityType);
}
this._id = data._id || uuid.v4();
this.action = data.action;
this.entityType = data.entityType;
this.entityId = data.entityId;
this.memberId = data.memberId;
this.timestamp = data.timestamp ? new Date(data.timestamp) : new Date();
this.oldValues = data.oldValues || null;
this.newValues = data.newValues || null;
this.metadata