UNPKG

bookingjs

Version:

A framework-agnostic JavaScript library for managing rink bookings, availability, and scheduling

1,452 lines (1,349 loc) 92.8 kB
'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