| … | … | @@ -0,0 +1,752 @@ |
| 1 | # hyper-booking v1 — Scheduling Engine Spec |
| 2 | |
| 3 | The core of the booking system. Handles provider availability, |
| 4 | slot calculation, booking lifecycle, and calendar sync. |
| 5 | |
| 6 | --- |
| 7 | |
| 8 | ## Overview |
| 9 | |
| 10 | ``` |
| 11 | Provider sets availability |
| 12 | → Engine calculates open slots |
| 13 | → Client picks a slot |
| 14 | → Engine creates booking |
| 15 | → Scheduler fires reminders |
| 16 | → Session completes or cancels |
| 17 | ``` |
| 18 | |
| 19 | --- |
| 20 | |
| 21 | ## 1. Provider Availability |
| 22 | |
| 23 | ### Weekly Schedule |
| 24 | |
| 25 | Recurring weekly time blocks. Each day has zero or more availability windows. |
| 26 | |
| 27 | ```javascript |
| 28 | { |
| 29 | providerId: 'prov_xyz', |
| 30 | timezone: 'America/New_York', |
| 31 | weekly: { |
| 32 | monday: [{ start: '09:00', end: '12:00' }, { start: '14:00', end: '17:00' }], |
| 33 | tuesday: [{ start: '09:00', end: '17:00' }], |
| 34 | wednesday: [{ start: '09:00', end: '12:00' }], |
| 35 | thursday: [{ start: '10:00', end: '18:00' }], |
| 36 | friday: [{ start: '09:00', end: '15:00' }], |
| 37 | saturday: [], |
| 38 | sunday: [] |
| 39 | } |
| 40 | } |
| 41 | ``` |
| 42 | |
| 43 | - Times are in provider's local timezone |
| 44 | - Empty array = day off |
| 45 | - Multiple windows per day supported (lunch break, split schedule) |
| 46 | |
| 47 | ### Date Overrides |
| 48 | |
| 49 | Specific dates with custom hours or marked unavailable. |
| 50 | |
| 51 | ```javascript |
| 52 | { |
| 53 | overrides: [ |
| 54 | // Day off |
| 55 | { date: '2026-04-15', available: false, reason: 'Vacation' }, |
| 56 | |
| 57 | // Custom hours (replaces weekly for that date) |
| 58 | { date: '2026-04-20', available: true, |
| 59 | windows: [{ start: '08:00', end: '11:00' }] }, |
| 60 | |
| 61 | // Extra day (normally off) |
| 62 | { date: '2026-04-19', available: true, |
| 63 | windows: [{ start: '10:00', end: '14:00' }] } |
| 64 | ] |
| 65 | } |
| 66 | ``` |
| 67 | |
| 68 | ### Booking Rules |
| 69 | |
| 70 | ```javascript |
| 71 | { |
| 72 | bufferMinutes: 15, // gap between sessions |
| 73 | advanceBooking: { |
| 74 | minHours: 24, // can't book less than 24h ahead |
| 75 | maxDays: 60 // can't book more than 60 days ahead |
| 76 | }, |
| 77 | cancellation: { |
| 78 | windowHours: 24, // must cancel 24h+ before session |
| 79 | refundPolicy: 'full' // full | partial | none |
| 80 | }, |
| 81 | reschedule: { |
| 82 | windowHours: 24, // must reschedule 24h+ before |
| 83 | maxPerBooking: 2 // max reschedules per booking |
| 84 | }, |
| 85 | noShow: { |
| 86 | penalty: 'forfeit' // forfeit (lose session from bundle) | none |
| 87 | } |
| 88 | } |
| 89 | ``` |
| 90 | |
| 91 | ### Storage |
| 92 | |
| 93 | ``` |
| 94 | availability:{providerId} → weekly schedule + rules |
| 95 | availability-overrides:{providerId} → date overrides array |
| 96 | ``` |
| 97 | |
| 98 | --- |
| 99 | |
| 100 | ## 2. Slot Calculation |
| 101 | |
| 102 | The algorithm that turns availability + existing bookings into bookable slots. |
| 103 | |
| 104 | ### Input |
| 105 | |
| 106 | ```javascript |
| 107 | calculateSlots({ |
| 108 | providerId, |
| 109 | date, // specific date to check |
| 110 | duration, // session duration in minutes (from package) |
| 111 | timezone // client's timezone (for display) |
| 112 | }) |
| 113 | ``` |
| 114 | |
| 115 | ### Algorithm |
| 116 | |
| 117 | ```javascript |
| 118 | function calculateSlots (providerId, date, duration, clientTimezone) { |
| 119 | // 1. Get provider availability config |
| 120 | const avail = await db.get(`availability:${providerId}`) |
| 121 | const overrides = await db.get(`availability-overrides:${providerId}`) |
| 122 | |
| 123 | // 2. Determine windows for this date |
| 124 | const dayOfWeek = getDayOfWeek(date, avail.timezone) |
| 125 | let windows |
| 126 | |
| 127 | const override = overrides?.find(o => o.date === date) |
| 128 | if (override) { |
| 129 | if (!override.available) return [] // day off |
| 130 | windows = override.windows |
| 131 | } else { |
| 132 | windows = avail.weekly[dayOfWeek] |
| 133 | if (!windows || windows.length === 0) return [] // regular day off |
| 134 | } |
| 135 | |
| 136 | // 3. Check advance booking limits |
| 137 | const now = Date.now() |
| 138 | const dateStart = toTimestamp(date, '00:00', avail.timezone) |
| 139 | const hoursUntil = (dateStart - now) / 3600000 |
| 140 | |
| 141 | if (hoursUntil < avail.advanceBooking.minHours) return [] |
| 142 | if (hoursUntil / 24 > avail.advanceBooking.maxDays) return [] |
| 143 | |
| 144 | // 4. Generate all possible slots from windows |
| 145 | const allSlots = [] |
| 146 | for (const window of windows) { |
| 147 | const windowStart = toTimestamp(date, window.start, avail.timezone) |
| 148 | const windowEnd = toTimestamp(date, window.end, avail.timezone) |
| 149 | |
| 150 | let slotStart = windowStart |
| 151 | while (slotStart + duration * 60000 <= windowEnd) { |
| 152 | allSlots.push({ |
| 153 | start: slotStart, |
| 154 | end: slotStart + duration * 60000 |
| 155 | }) |
| 156 | slotStart += duration * 60000 + avail.bufferMinutes * 60000 |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // 5. Remove slots that conflict with existing bookings |
| 161 | const existingBookings = await getBookingsForDate(providerId, date) |
| 162 | const buffer = avail.bufferMinutes * 60000 |
| 163 | |
| 164 | const availableSlots = allSlots.filter(slot => { |
| 165 | return !existingBookings.some(booking => { |
| 166 | const bookingStart = booking.startTime - buffer |
| 167 | const bookingEnd = booking.endTime + buffer |
| 168 | return slot.start < bookingEnd && slot.end > bookingStart |
| 169 | }) |
| 170 | }) |
| 171 | |
| 172 | // 6. Convert to client timezone for display |
| 173 | return availableSlots.map(slot => ({ |
| 174 | start: slot.start, |
| 175 | end: slot.end, |
| 176 | startDisplay: formatInTimezone(slot.start, clientTimezone), |
| 177 | endDisplay: formatInTimezone(slot.end, clientTimezone), |
| 178 | startISO: new Date(slot.start).toISOString(), |
| 179 | endISO: new Date(slot.end).toISOString() |
| 180 | })) |
| 181 | } |
| 182 | ``` |
| 183 | |
| 184 | ### Slot Grid (Week View) |
| 185 | |
| 186 | For the calendar UI, calculate slots for a full week at once: |
| 187 | |
| 188 | ```javascript |
| 189 | async function getWeekSlots (providerId, weekStart, duration, clientTimezone) { |
| 190 | const days = {} |
| 191 | for (let i = 0; i < 7; i++) { |
| 192 | const date = addDays(weekStart, i) |
| 193 | const dateStr = formatDate(date) |
| 194 | days[dateStr] = await calculateSlots(providerId, dateStr, duration, clientTimezone) |
| 195 | } |
| 196 | return days |
| 197 | } |
| 198 | ``` |
| 199 | |
| 200 | ### API |
| 201 | |
| 202 | ``` |
| 203 | GET /api/providers/:slug/slots?date=2026-04-10&duration=60&tz=America/Chicago |
| 204 | → { slots: [{ start, end, startDisplay, endDisplay }] } |
| 205 | |
| 206 | GET /api/providers/:slug/slots?weekOf=2026-04-07&duration=60&tz=America/Chicago |
| 207 | → { days: { '2026-04-07': [...], '2026-04-08': [...], ... } } |
| 208 | ``` |
| 209 | |
| 210 | --- |
| 211 | |
| 212 | ## 3. Booking Lifecycle |
| 213 | |
| 214 | ### States |
| 215 | |
| 216 | ``` |
| 217 | pending → confirmed → completed |
| 218 | → no-show |
| 219 | → cancelled |
| 220 | → rescheduled → confirmed → ... |
| 221 | ``` |
| 222 | |
| 223 | ### State Transitions |
| 224 | |
| 225 | | From | To | Trigger | Side Effects | |
| 226 | |------|----|---------|-------------| |
| 227 | | — | pending | Client submits booking | Hold slot | |
| 228 | | pending | confirmed | Payment succeeds (or free session) | Notify both, schedule reminders, send .ics | |
| 229 | | confirmed | completed | Provider marks complete (or auto after end time) | Release slot, update purchase session count | |
| 230 | | confirmed | cancelled | Client or provider cancels | Release slot, process refund, cancel reminders | |
| 231 | | confirmed | rescheduled | Client picks new time | Release old slot, hold new slot, update reminders | |
| 232 | | confirmed | no-show | Provider marks no-show | Apply penalty per rules, update purchase session count | |
| 233 | | pending | cancelled | Payment fails or timeout (15 min) | Release slot | |
| 234 | |
| 235 | ### Create Booking |
| 236 | |
| 237 | ```javascript |
| 238 | async function createBooking (clientId, providerId, packageId, purchaseId, slotStart, slotEnd) { |
| 239 | // 1. Verify slot is still available (race condition check) |
| 240 | const conflicts = await getConflictingBookings(providerId, slotStart, slotEnd) |
| 241 | if (conflicts.length > 0) { |
| 242 | throw new ConflictError('Slot no longer available') |
| 243 | } |
| 244 | |
| 245 | // 2. Verify purchase has remaining sessions |
| 246 | const purchase = await db.get(`purchase:${purchaseId}`) |
| 247 | if (purchase.sessionsUsed >= purchase.sessionsTotal) { |
| 248 | throw new ExhaustedError('No remaining sessions in package') |
| 249 | } |
| 250 | |
| 251 | // 3. Create booking |
| 252 | const booking = { |
| 253 | id: generateId('bk'), |
| 254 | providerId, |
| 255 | clientId, |
| 256 | packageId, |
| 257 | purchaseId, |
| 258 | startTime: slotStart, |
| 259 | endTime: slotEnd, |
| 260 | status: 'pending', |
| 261 | rescheduleCount: 0, |
| 262 | createdAt: Date.now() |
| 263 | } |
| 264 | |
| 265 | // 4. Atomic write: booking + index entries |
| 266 | const batch = db.batch() |
| 267 | batch.put(`booking:${booking.id}`, booking) |
| 268 | batch.put(`bookings-by-provider:${providerId}:${formatDate(slotStart)}:${booking.id}`, booking.id) |
| 269 | batch.put(`bookings-by-client:${clientId}:${booking.id}`, booking.id) |
| 270 | await batch.flush() |
| 271 | |
| 272 | return booking |
| 273 | } |
| 274 | ``` |
| 275 | |
| 276 | ### Confirm Booking |
| 277 | |
| 278 | ```javascript |
| 279 | async function confirmBooking (bookingId) { |
| 280 | const booking = await db.get(`booking:${bookingId}`) |
| 281 | booking.status = 'confirmed' |
| 282 | booking.confirmedAt = Date.now() |
| 283 | await db.put(`booking:${bookingId}`, booking) |
| 284 | |
| 285 | // Increment sessions used on purchase |
| 286 | const purchase = await db.get(`purchase:${booking.purchaseId}`) |
| 287 | purchase.sessionsUsed++ |
| 288 | await db.put(`purchase:${purchase.id}`, purchase) |
| 289 | |
| 290 | // Schedule reminders |
| 291 | const notifier = getNotificationManager() |
| 292 | await notifier.scheduleReminder(booking, 'reminder-24h', 24 * 3600000) |
| 293 | await notifier.scheduleReminder(booking, 'reminder-1h', 1 * 3600000) |
| 294 | |
| 295 | // Send confirmation notification |
| 296 | await notifier.notify(booking.clientId, { |
| 297 | type: 'booking-confirmed', |
| 298 | bookingId: booking.id, |
| 299 | providerName: booking.providerName, |
| 300 | dateTime: new Date(booking.startTime).toISOString(), |
| 301 | icsAttachment: generateICS(booking) |
| 302 | }) |
| 303 | |
| 304 | return booking |
| 305 | } |
| 306 | ``` |
| 307 | |
| 308 | ### Cancel Booking |
| 309 | |
| 310 | ```javascript |
| 311 | async function cancelBooking (bookingId, cancelledBy, reason) { |
| 312 | const booking = await db.get(`booking:${bookingId}`) |
| 313 | const avail = await db.get(`availability:${booking.providerId}`) |
| 314 | |
| 315 | // Check cancellation window |
| 316 | const hoursUntil = (booking.startTime - Date.now()) / 3600000 |
| 317 | const withinWindow = hoursUntil >= avail.cancellation.windowHours |
| 318 | |
| 319 | booking.status = 'cancelled' |
| 320 | booking.cancelledAt = Date.now() |
| 321 | booking.cancelledBy = cancelledBy // 'client' | 'provider' |
| 322 | booking.cancelReason = reason |
| 323 | |
| 324 | await db.put(`booking:${bookingId}`, booking) |
| 325 | |
| 326 | // Cancel scheduled reminders |
| 327 | const notifier = getNotificationManager() |
| 328 | await notifier.scheduler.cancel(bookingId) |
| 329 | |
| 330 | // Process refund based on policy |
| 331 | if (cancelledBy === 'client' && withinWindow) { |
| 332 | if (avail.cancellation.refundPolicy === 'full') { |
| 333 | await hyperPay.refund({ paymentId: booking.paymentId }) |
| 334 | } |
| 335 | } |
| 336 | // Provider cancellation always refunds |
| 337 | if (cancelledBy === 'provider') { |
| 338 | await hyperPay.refund({ paymentId: booking.paymentId }) |
| 339 | } |
| 340 | |
| 341 | // Decrement sessions used (return session to bundle) |
| 342 | const purchase = await db.get(`purchase:${booking.purchaseId}`) |
| 343 | purchase.sessionsUsed-- |
| 344 | await db.put(`purchase:${purchase.id}`, purchase) |
| 345 | |
| 346 | // Notify |
| 347 | await notifier.notify(booking.clientId, { |
| 348 | type: 'cancelled', |
| 349 | bookingId: booking.id, |
| 350 | cancelledBy, |
| 351 | refunded: withinWindow || cancelledBy === 'provider' |
| 352 | }) |
| 353 | |
| 354 | return booking |
| 355 | } |
| 356 | ``` |
| 357 | |
| 358 | ### Reschedule Booking |
| 359 | |
| 360 | ```javascript |
| 361 | async function rescheduleBooking (bookingId, newStart, newEnd) { |
| 362 | const booking = await db.get(`booking:${bookingId}`) |
| 363 | const avail = await db.get(`availability:${booking.providerId}`) |
| 364 | |
| 365 | // Check reschedule window |
| 366 | const hoursUntil = (booking.startTime - Date.now()) / 3600000 |
| 367 | if (hoursUntil < avail.reschedule.windowHours) { |
| 368 | throw new PolicyError('Too late to reschedule') |
| 369 | } |
| 370 | |
| 371 | // Check max reschedules |
| 372 | if (booking.rescheduleCount >= avail.reschedule.maxPerBooking) { |
| 373 | throw new PolicyError('Maximum reschedules reached') |
| 374 | } |
| 375 | |
| 376 | // Verify new slot is available |
| 377 | const conflicts = await getConflictingBookings(booking.providerId, newStart, newEnd) |
| 378 | if (conflicts.length > 0) { |
| 379 | throw new ConflictError('New slot not available') |
| 380 | } |
| 381 | |
| 382 | // Update booking |
| 383 | const oldStart = booking.startTime |
| 384 | booking.startTime = newStart |
| 385 | booking.endTime = newEnd |
| 386 | booking.rescheduleCount++ |
| 387 | booking.rescheduledAt = Date.now() |
| 388 | booking.previousTimes = booking.previousTimes || [] |
| 389 | booking.previousTimes.push({ start: oldStart, rescheduledAt: booking.rescheduledAt }) |
| 390 | |
| 391 | // Update indexes |
| 392 | const batch = db.batch() |
| 393 | batch.put(`booking:${bookingId}`, booking) |
| 394 | // Remove old date index, add new |
| 395 | batch.del(`bookings-by-provider:${booking.providerId}:${formatDate(oldStart)}:${bookingId}`) |
| 396 | batch.put(`bookings-by-provider:${booking.providerId}:${formatDate(newStart)}:${bookingId}`, bookingId) |
| 397 | await batch.flush() |
| 398 | |
| 399 | // Reschedule reminders |
| 400 | const notifier = getNotificationManager() |
| 401 | await notifier.scheduler.cancel(bookingId) |
| 402 | await notifier.scheduleReminder(booking, 'reminder-24h', 24 * 3600000) |
| 403 | await notifier.scheduleReminder(booking, 'reminder-1h', 1 * 3600000) |
| 404 | |
| 405 | // Notify with updated .ics |
| 406 | await notifier.notify(booking.clientId, { |
| 407 | type: 'rescheduled', |
| 408 | bookingId: booking.id, |
| 409 | oldDateTime: new Date(oldStart).toISOString(), |
| 410 | newDateTime: new Date(newStart).toISOString(), |
| 411 | icsAttachment: generateICS(booking) |
| 412 | }) |
| 413 | |
| 414 | return booking |
| 415 | } |
| 416 | ``` |
| 417 | |
| 418 | --- |
| 419 | |
| 420 | ## 4. Timezone Handling |
| 421 | |
| 422 | ### Principles |
| 423 | |
| 424 | 1. **Store everything in UTC** (millisecond timestamps) |
| 425 | 2. **Provider sets home timezone** (used for availability definition) |
| 426 | 3. **Client timezone auto-detected** (browser `Intl.DateTimeFormat().resolvedOptions().timeZone`) |
| 427 | 4. **Convert at display time only** — never store local times |
| 428 | |
| 429 | ### Conversion |
| 430 | |
| 431 | ```javascript |
| 432 | // Provider defines availability in their local time |
| 433 | // Convert to UTC for storage and comparison |
| 434 | function toUTC (date, time, timezone) { |
| 435 | // e.g., '2026-04-10', '09:00', 'America/New_York' |
| 436 | // → 1744286400000 (UTC timestamp) |
| 437 | const dt = new Date(`${date}T${time}:00`) |
| 438 | // Use Intl to get UTC offset for that timezone on that date |
| 439 | const formatter = new Intl.DateTimeFormat('en-US', { |
| 440 | timeZone: timezone, |
| 441 | timeZoneName: 'shortOffset' |
| 442 | }) |
| 443 | // ... offset calculation |
| 444 | return dt.getTime() - offsetMs |
| 445 | } |
| 446 | |
| 447 | // Display slot in client's timezone |
| 448 | function formatInTimezone (utcTimestamp, timezone) { |
| 449 | return new Intl.DateTimeFormat('en-US', { |
| 450 | timeZone: timezone, |
| 451 | weekday: 'short', |
| 452 | month: 'short', |
| 453 | day: 'numeric', |
| 454 | hour: 'numeric', |
| 455 | minute: '2-digit', |
| 456 | hour12: true |
| 457 | }).format(new Date(utcTimestamp)) |
| 458 | } |
| 459 | ``` |
| 460 | |
| 461 | ### DST Handling |
| 462 | |
| 463 | - Availability windows defined as clock time ("9am") not UTC offset |
| 464 | - Recalculate UTC equivalent each day (handles DST transitions) |
| 465 | - A provider who works "9am-5pm" always works 9am-5pm local, regardless of DST |
| 466 | |
| 467 | --- |
| 468 | |
| 469 | ## 5. Calendar Sync |
| 470 | |
| 471 | ### iCal Export (v1) |
| 472 | |
| 473 | Provider's schedule as an iCal feed. Clients subscribe from Google Calendar, Apple Calendar, etc. |
| 474 | |
| 475 | ``` |
| 476 | GET /api/providers/:id/calendar.ics |
| 477 | → VCALENDAR with all confirmed bookings as VEVENT entries |
| 478 | ``` |
| 479 | |
| 480 | ```javascript |
| 481 | function generateProviderCalendar (providerId) { |
| 482 | const bookings = await getUpcomingBookings(providerId) |
| 483 | |
| 484 | const events = bookings.map(b => [ |
| 485 | 'BEGIN:VEVENT', |
| 486 | `UID:${b.id}@hyper-booking`, |
| 487 | `DTSTART:${formatICSDate(b.startTime)}`, |
| 488 | `DTEND:${formatICSDate(b.endTime)}`, |
| 489 | `SUMMARY:${b.clientName} — ${b.packageName}`, |
| 490 | `DESCRIPTION:Package: ${b.packageName}\\nClient: ${b.clientName}`, |
| 491 | `STATUS:${b.status === 'confirmed' ? 'CONFIRMED' : 'CANCELLED'}`, |
| 492 | 'END:VEVENT' |
| 493 | ].join('\r\n')) |
| 494 | |
| 495 | return [ |
| 496 | 'BEGIN:VCALENDAR', |
| 497 | 'VERSION:2.0', |
| 498 | 'PRODID:-//hyper-booking//EN', |
| 499 | 'METHOD:PUBLISH', |
| 500 | `X-WR-CALNAME:${providerName} Bookings`, |
| 501 | ...events, |
| 502 | 'END:VCALENDAR' |
| 503 | ].join('\r\n') |
| 504 | } |
| 505 | ``` |
| 506 | |
| 507 | ### iCal Import (v1) |
| 508 | |
| 509 | Provider imports external calendar to block busy times. |
| 510 | |
| 511 | ``` |
| 512 | POST /api/providers/:id/calendar/import |
| 513 | { url: 'https://calendar.google.com/calendar/ical/.../basic.ics' } |
| 514 | ``` |
| 515 | |
| 516 | - Server fetches iCal URL periodically (every 15 min) |
| 517 | - Extracts busy times as "external blocks" |
| 518 | - Slot calculation excludes external blocks |
| 519 | - Stored as date overrides with source: 'external' |
| 520 | |
| 521 | ### Google Calendar API (v2) |
| 522 | |
| 523 | Two-way sync — bookings appear in Google Calendar, Google Calendar events block slots. |
| 524 | Requires OAuth2. Deferred to v2. |
| 525 | |
| 526 | --- |
| 527 | |
| 528 | ## 6. Recurring Bookings |
| 529 | |
| 530 | For subscriptions and bundles, auto-book the same slot weekly. |
| 531 | |
| 532 | ```javascript |
| 533 | async function createRecurringBooking (clientId, booking, recurrence) { |
| 534 | // recurrence: { type: 'weekly', count: 6 } or { type: 'weekly', until: '2026-06-01' } |
| 535 | |
| 536 | const bookings = [] |
| 537 | let currentStart = booking.startTime |
| 538 | let currentEnd = booking.endTime |
| 539 | const weekMs = 7 * 24 * 3600000 |
| 540 | |
| 541 | const count = recurrence.count || 52 // max 1 year |
| 542 | for (let i = 0; i < count; i++) { |
| 543 | // Check if slot is available |
| 544 | const conflicts = await getConflictingBookings(booking.providerId, currentStart, currentEnd) |
| 545 | if (conflicts.length === 0) { |
| 546 | const bk = await createBooking( |
| 547 | clientId, booking.providerId, booking.packageId, |
| 548 | booking.purchaseId, currentStart, currentEnd |
| 549 | ) |
| 550 | bookings.push(bk) |
| 551 | } |
| 552 | // else skip that week (provider busy) |
| 553 | |
| 554 | currentStart += weekMs |
| 555 | currentEnd += weekMs |
| 556 | |
| 557 | if (recurrence.until && currentStart > new Date(recurrence.until).getTime()) break |
| 558 | } |
| 559 | |
| 560 | return bookings |
| 561 | } |
| 562 | ``` |
| 563 | |
| 564 | --- |
| 565 | |
| 566 | ## 7. Group Sessions |
| 567 | |
| 568 | Multiple clients booking the same time slot. |
| 569 | |
| 570 | ### Group Slot |
| 571 | |
| 572 | ```javascript |
| 573 | { |
| 574 | id: 'grp_abc', |
| 575 | providerId: 'prov_xyz', |
| 576 | packageId: 'pkg_abc', |
| 577 | startTime: 1744286400000, |
| 578 | endTime: 1744290000000, |
| 579 | capacity: 12, |
| 580 | enrolled: ['cli_1', 'cli_2', 'cli_3'], |
| 581 | waitlist: [], |
| 582 | status: 'open' // open | full | completed | cancelled |
| 583 | } |
| 584 | ``` |
| 585 | |
| 586 | ### Booking a Group Session |
| 587 | |
| 588 | ```javascript |
| 589 | async function bookGroupSession (groupId, clientId, purchaseId) { |
| 590 | const group = await db.get(`group:${groupId}`) |
| 591 | |
| 592 | if (group.enrolled.length >= group.capacity) { |
| 593 | // Add to waitlist |
| 594 | group.waitlist.push(clientId) |
| 595 | await db.put(`group:${groupId}`, group) |
| 596 | return { status: 'waitlisted', position: group.waitlist.length } |
| 597 | } |
| 598 | |
| 599 | // Enroll |
| 600 | group.enrolled.push(clientId) |
| 601 | if (group.enrolled.length >= group.capacity) { |
| 602 | group.status = 'full' |
| 603 | } |
| 604 | await db.put(`group:${groupId}`, group) |
| 605 | |
| 606 | // Create individual booking record (for client portal) |
| 607 | const booking = await createBooking( |
| 608 | clientId, group.providerId, group.packageId, |
| 609 | purchaseId, group.startTime, group.endTime |
| 610 | ) |
| 611 | booking.groupId = groupId |
| 612 | await db.put(`booking:${booking.id}`, booking) |
| 613 | |
| 614 | return { status: 'enrolled', booking } |
| 615 | } |
| 616 | ``` |
| 617 | |
| 618 | ### Waitlist Promotion |
| 619 | |
| 620 | When someone cancels a group session: |
| 621 | |
| 622 | ```javascript |
| 623 | async function promoteFromWaitlist (groupId) { |
| 624 | const group = await db.get(`group:${groupId}`) |
| 625 | if (group.waitlist.length === 0) return null |
| 626 | |
| 627 | const nextClientId = group.waitlist.shift() |
| 628 | group.enrolled.push(nextClientId) |
| 629 | group.status = 'open' |
| 630 | await db.put(`group:${groupId}`, group) |
| 631 | |
| 632 | // Notify waitlisted client |
| 633 | await notifier.notify(nextClientId, { |
| 634 | type: 'waitlist-promoted', |
| 635 | groupId, |
| 636 | message: 'A spot opened up! You have been enrolled.' |
| 637 | }) |
| 638 | |
| 639 | return nextClientId |
| 640 | } |
| 641 | ``` |
| 642 | |
| 643 | --- |
| 644 | |
| 645 | ## 8. Conflict Detection |
| 646 | |
| 647 | Prevent double-booking with buffer time. |
| 648 | |
| 649 | ```javascript |
| 650 | async function getConflictingBookings (providerId, start, end) { |
| 651 | const avail = await db.get(`availability:${providerId}`) |
| 652 | const buffer = avail.bufferMinutes * 60000 |
| 653 | |
| 654 | const date = formatDate(start) |
| 655 | const bookings = await getBookingsForDate(providerId, date) |
| 656 | |
| 657 | return bookings.filter(b => { |
| 658 | if (b.status === 'cancelled') return false |
| 659 | const bStart = b.startTime - buffer |
| 660 | const bEnd = b.endTime + buffer |
| 661 | return start < bEnd && end > bStart |
| 662 | }) |
| 663 | } |
| 664 | ``` |
| 665 | |
| 666 | ### Race Condition Handling |
| 667 | |
| 668 | Two clients could try to book the same slot simultaneously. |
| 669 | |
| 670 | **Strategy: Optimistic locking with Hyperbee CAS (compare-and-swap)** |
| 671 | |
| 672 | ```javascript |
| 673 | async function atomicBookSlot (providerId, start, end, bookingData) { |
| 674 | // Use Hyperbee batch with conflict check |
| 675 | const slotKey = `slot-lock:${providerId}:${start}` |
| 676 | |
| 677 | const existing = await db.get(slotKey) |
| 678 | if (existing) { |
| 679 | throw new ConflictError('Slot already booked') |
| 680 | } |
| 681 | |
| 682 | // Atomic batch: write lock + booking |
| 683 | const batch = db.batch() |
| 684 | batch.put(slotKey, { bookingId: bookingData.id, lockedAt: Date.now() }) |
| 685 | batch.put(`booking:${bookingData.id}`, bookingData) |
| 686 | await batch.flush() |
| 687 | |
| 688 | return bookingData |
| 689 | } |
| 690 | ``` |
| 691 | |
| 692 | --- |
| 693 | |
| 694 | ## 9. Auto-Complete |
| 695 | |
| 696 | Sessions auto-complete after end time if provider doesn't manually mark. |
| 697 | |
| 698 | ```javascript |
| 699 | // Run periodically (every 15 min) or on provider dashboard load |
| 700 | async function autoCompleteExpiredBookings (providerId) { |
| 701 | const now = Date.now() |
| 702 | const today = formatDate(now) |
| 703 | const yesterday = formatDate(now - 86400000) |
| 704 | |
| 705 | for (const date of [yesterday, today]) { |
| 706 | const bookings = await getBookingsForDate(providerId, date) |
| 707 | for (const booking of bookings) { |
| 708 | if (booking.status === 'confirmed' && booking.endTime < now) { |
| 709 | booking.status = 'completed' |
| 710 | booking.completedAt = now |
| 711 | booking.completedBy = 'auto' |
| 712 | await db.put(`booking:${booking.id}`, booking) |
| 713 | } |
| 714 | } |
| 715 | } |
| 716 | } |
| 717 | ``` |
| 718 | |
| 719 | --- |
| 720 | |
| 721 | ## 10. API Endpoints |
| 722 | |
| 723 | ``` |
| 724 | # Availability (provider) |
| 725 | GET /api/availability Get own availability config |
| 726 | PUT /api/availability Update weekly schedule + rules |
| 727 | PUT /api/availability/overrides Add/update date overrides |
| 728 | POST /api/availability/import-calendar Import external iCal URL |
| 729 | |
| 730 | # Slots (public) |
| 731 | GET /api/providers/:slug/slots Available slots for date/week + duration |
| 732 | |
| 733 | # Bookings (client) |
| 734 | POST /api/bookings Create booking (after checkout) |
| 735 | GET /api/bookings/:id Get booking details |
| 736 | PUT /api/bookings/:id/reschedule Reschedule (new start/end) |
| 737 | PUT /api/bookings/:id/cancel Cancel booking |
| 738 | |
| 739 | # Bookings (provider) |
| 740 | GET /api/dashboard/bookings All bookings for date range |
| 741 | PUT /api/bookings/:id/complete Mark session complete |
| 742 | PUT /api/bookings/:id/no-show Mark no-show |
| 743 | PUT /api/bookings/:id/notes Add provider notes |
| 744 | |
| 745 | # Calendar |
| 746 | GET /api/providers/:id/calendar.ics iCal feed (subscribe) |
| 747 | |
| 748 | # Groups (provider) |
| 749 | POST /api/groups Create group session |
| 750 | GET /api/groups/:id Group details + attendee list |
| 751 | GET /api/groups/:id/waitlist Waitlist |
| 752 | ``` |