payload-reserve 2.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +218 -4
  2. package/dist/collections/Holds.d.ts +18 -0
  3. package/dist/collections/Holds.js +113 -0
  4. package/dist/collections/Holds.js.map +1 -0
  5. package/dist/collections/Reservations.js +8 -0
  6. package/dist/collections/Reservations.js.map +1 -1
  7. package/dist/collections/Resources.js +35 -1
  8. package/dist/collections/Resources.js.map +1 -1
  9. package/dist/collections/Services.js +41 -0
  10. package/dist/collections/Services.js.map +1 -1
  11. package/dist/components/CalendarView/LaneTimelineView.js +4 -5
  12. package/dist/components/CalendarView/LaneTimelineView.js.map +1 -1
  13. package/dist/components/CalendarView/index.js +55 -71
  14. package/dist/components/CalendarView/index.js.map +1 -1
  15. package/dist/components/DashboardWidget/DashboardWidgetServer.js +11 -74
  16. package/dist/components/DashboardWidget/DashboardWidgetServer.js.map +1 -1
  17. package/dist/components/DashboardWidget/fetchDashboardStats.d.ts +42 -0
  18. package/dist/components/DashboardWidget/fetchDashboardStats.js +113 -0
  19. package/dist/components/DashboardWidget/fetchDashboardStats.js.map +1 -0
  20. package/dist/defaults.d.ts +2 -0
  21. package/dist/defaults.js +12 -0
  22. package/dist/defaults.js.map +1 -1
  23. package/dist/endpoints/cancelBooking.js +43 -14
  24. package/dist/endpoints/cancelBooking.js.map +1 -1
  25. package/dist/endpoints/checkAvailability.js +27 -1
  26. package/dist/endpoints/checkAvailability.js.map +1 -1
  27. package/dist/endpoints/createBooking.js +108 -7
  28. package/dist/endpoints/createBooking.js.map +1 -1
  29. package/dist/endpoints/customerSearch.js +6 -0
  30. package/dist/endpoints/customerSearch.js.map +1 -1
  31. package/dist/endpoints/effectiveTimezone.js +1 -0
  32. package/dist/endpoints/effectiveTimezone.js.map +1 -1
  33. package/dist/endpoints/getSlots.js +27 -1
  34. package/dist/endpoints/getSlots.js.map +1 -1
  35. package/dist/endpoints/holdSlot.d.ts +12 -0
  36. package/dist/endpoints/holdSlot.js +126 -0
  37. package/dist/endpoints/holdSlot.js.map +1 -0
  38. package/dist/endpoints/releaseSlot.d.ts +8 -0
  39. package/dist/endpoints/releaseSlot.js +34 -0
  40. package/dist/endpoints/releaseSlot.js.map +1 -0
  41. package/dist/endpoints/resourceAvailability.d.ts +4 -0
  42. package/dist/endpoints/resourceAvailability.js +105 -4
  43. package/dist/endpoints/resourceAvailability.js.map +1 -1
  44. package/dist/hooks/holds/validateHoldSlot.d.ts +14 -0
  45. package/dist/hooks/holds/validateHoldSlot.js +90 -0
  46. package/dist/hooks/holds/validateHoldSlot.js.map +1 -0
  47. package/dist/hooks/reservations/acquireBookingLock.d.ts +30 -0
  48. package/dist/hooks/reservations/acquireBookingLock.js +71 -0
  49. package/dist/hooks/reservations/acquireBookingLock.js.map +1 -0
  50. package/dist/hooks/reservations/calculateEndTime.js +13 -1
  51. package/dist/hooks/reservations/calculateEndTime.js.map +1 -1
  52. package/dist/hooks/reservations/expandRequiredResources.js +3 -0
  53. package/dist/hooks/reservations/expandRequiredResources.js.map +1 -1
  54. package/dist/hooks/reservations/validateActive.d.ts +11 -0
  55. package/dist/hooks/reservations/validateActive.js +103 -0
  56. package/dist/hooks/reservations/validateActive.js.map +1 -0
  57. package/dist/hooks/reservations/validateConflicts.js +22 -1
  58. package/dist/hooks/reservations/validateConflicts.js.map +1 -1
  59. package/dist/hooks/reservations/validateGuestBooking.js +3 -0
  60. package/dist/hooks/reservations/validateGuestBooking.js.map +1 -1
  61. package/dist/hooks/shared/preventDeleteWhenReferenced.d.ts +42 -0
  62. package/dist/hooks/shared/preventDeleteWhenReferenced.js +87 -0
  63. package/dist/hooks/shared/preventDeleteWhenReferenced.js.map +1 -0
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/plugin.js +179 -3
  67. package/dist/plugin.js.map +1 -1
  68. package/dist/services/AvailabilityService.d.ts +51 -5
  69. package/dist/services/AvailabilityService.js +315 -38
  70. package/dist/services/AvailabilityService.js.map +1 -1
  71. package/dist/services/HoldService.d.ts +53 -0
  72. package/dist/services/HoldService.js +169 -0
  73. package/dist/services/HoldService.js.map +1 -0
  74. package/dist/services/index.d.ts +1 -0
  75. package/dist/services/index.js.map +1 -1
  76. package/dist/translations/ar.json +5 -1
  77. package/dist/translations/de.json +5 -1
  78. package/dist/translations/en.json +5 -1
  79. package/dist/translations/es.json +5 -1
  80. package/dist/translations/fa.json +5 -1
  81. package/dist/translations/fr.json +5 -1
  82. package/dist/translations/hi.json +5 -1
  83. package/dist/translations/id.json +5 -1
  84. package/dist/translations/pl.json +5 -1
  85. package/dist/translations/ru.json +5 -1
  86. package/dist/translations/tr.json +5 -1
  87. package/dist/translations/zh.json +5 -1
  88. package/dist/types.d.ts +32 -0
  89. package/dist/types.js.map +1 -1
  90. package/dist/utilities/calendarGrid.d.ts +44 -0
  91. package/dist/utilities/calendarGrid.js +67 -0
  92. package/dist/utilities/calendarGrid.js.map +1 -0
  93. package/dist/utilities/reservationChanges.d.ts +6 -1
  94. package/dist/utilities/reservationChanges.js +1 -1
  95. package/dist/utilities/reservationChanges.js.map +1 -1
  96. package/dist/utilities/reserveDebug.d.ts +26 -0
  97. package/dist/utilities/reserveDebug.js +35 -0
  98. package/dist/utilities/reserveDebug.js.map +1 -0
  99. package/dist/utilities/resolveReservationItems.d.ts +13 -2
  100. package/dist/utilities/resolveReservationItems.js +87 -2
  101. package/dist/utilities/resolveReservationItems.js.map +1 -1
  102. package/dist/utilities/retryOnWriteConflict.d.ts +66 -0
  103. package/dist/utilities/retryOnWriteConflict.js +135 -0
  104. package/dist/utilities/retryOnWriteConflict.js.map +1 -0
  105. package/dist/utilities/tenantTimezone.d.ts +77 -1
  106. package/dist/utilities/tenantTimezone.js +163 -2
  107. package/dist/utilities/tenantTimezone.js.map +1 -1
  108. package/dist/utilities/transactionSupport.d.ts +13 -0
  109. package/dist/utilities/transactionSupport.js +30 -0
  110. package/dist/utilities/transactionSupport.js.map +1 -0
  111. package/package.json +13 -13
@@ -1,3 +1,4 @@
1
+ import { NOOP_RESERVE_DEBUG } from '../utilities/reserveDebug.js';
1
2
  import { resolveReservationItems } from '../utilities/resolveReservationItems.js';
2
3
  import { isExceptionDate, resolveScheduleForDate } from '../utilities/scheduleUtils.js';
3
4
  import { addMinutes, computeBlockedWindow, doRangesOverlap, intersectIntervals } from '../utilities/slotUtils.js';
@@ -148,12 +149,23 @@ export function buildOverlapQuery(params) {
148
149
  }
149
150
  return occupancies;
150
151
  }
151
- /** Occupancy a single fetched reservation imposes on `resourceId`. */ export async function reservationOccupancies(params) {
152
+ /**
153
+ * Occupancy a single fetched reservation imposes on `resourceId`.
154
+ *
155
+ * Resolves `reservation` LENIENTLY: this is a stored document, which — unlike
156
+ * a write in progress — can carry an inverted top-level window from a
157
+ * `context.skipReservationHooks` write or data predating that check. Lenient
158
+ * mode skips parent synthesis instead of throwing, so a malformed top-level
159
+ * window never discards the real `items[]` occupancies also present on the
160
+ * same row (see the lenient-mode note in resolveReservationItems.ts).
161
+ */ export async function reservationOccupancies(params) {
152
162
  const { bufferFor, capacityMode, reservation, resourceId } = params;
153
163
  return itemsToOccupancies({
154
164
  bufferFor,
155
165
  capacityMode,
156
- items: resolveReservationItems(reservation),
166
+ items: resolveReservationItems(reservation, {
167
+ lenient: true
168
+ }),
157
169
  resourceId
158
170
  });
159
171
  }
@@ -180,7 +192,8 @@ export function validateTransition(fromStatus, toStatus, statusMachine) {
180
192
  }
181
193
  // --- DB functions (use Payload Local API only) ---
182
194
  export async function checkAvailability(params) {
183
- const { blockingStatuses, bufferAfter, bufferBefore, endTime, excludeReservationId, getExternalBusy, guestCount, payload, req, reservationSlug, resourceId, resourceSlug, servicesSlug, siblingItems, startTime } = params;
195
+ const { blockingStatuses, bufferAfter, bufferBefore, debug, endTime, excludeHoldToken, excludeReservationId, getExternalBusy, guestCount, holdsSlug, payload, req, reservationSlug, resourceId, resourceSlug, servicesSlug, siblingItems, startTime } = params;
196
+ const trace = debug ?? NOOP_RESERVE_DEBUG;
184
197
  // Fetch resource for quantity and capacity mode
185
198
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
186
199
  const resource = await payload.findByID({
@@ -194,19 +207,29 @@ export async function checkAvailability(params) {
194
207
  // Candidate window expanded by its own buffers
195
208
  const { effectiveEnd: candidateEnd, effectiveStart: candidateStart } = computeBlockedWindow(startTime, endTime, bufferBefore, bufferAfter);
196
209
  // Coarse superset fetch — precise per-item overlap is computed in memory below
210
+ const coarseWhere = buildCoarseOverlapQuery({
211
+ blockingStatuses,
212
+ candidateEnd,
213
+ candidateStart,
214
+ excludeReservationId,
215
+ resourceId
216
+ });
197
217
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
198
218
  const { docs } = await payload.find({
199
219
  collection: reservationSlug,
200
220
  depth: 0,
201
221
  limit: 0,
202
222
  req,
203
- where: buildCoarseOverlapQuery({
204
- blockingStatuses,
205
- candidateEnd,
206
- candidateStart,
207
- excludeReservationId,
208
- resourceId
209
- })
223
+ where: coarseWhere
224
+ });
225
+ trace.dbg('check', {
226
+ blockingReservations: docs.length,
227
+ candidateEnd: candidateEnd.toISOString(),
228
+ candidateStart: candidateStart.toISOString(),
229
+ capacityMode,
230
+ coarseWhere,
231
+ quantity,
232
+ resourceId
210
233
  });
211
234
  // Per-call cache: fetch each distinct service's buffers at most once
212
235
  const bufferCache = new Map();
@@ -227,6 +250,9 @@ export async function checkAvailability(params) {
227
250
  id: serviceId,
228
251
  collection: servicesSlug,
229
252
  depth: 0,
253
+ // Skip the resources join — internal logic never reads it, and without this
254
+ // every service read becomes an aggregation with a $lookup.
255
+ joins: false,
230
256
  req
231
257
  });
232
258
  if (service) {
@@ -235,19 +261,41 @@ export async function checkAvailability(params) {
235
261
  before: service.bufferTimeBefore ?? 0
236
262
  };
237
263
  }
238
- } catch {
239
- // service missing — no buffers
264
+ } catch (err) {
265
+ trace.dbg('error', {
266
+ err,
267
+ serviceId,
268
+ where: 'bufferFor'
269
+ });
240
270
  }
241
271
  }
242
272
  bufferCache.set(key, result);
243
273
  return result;
244
274
  };
245
- const fetchedOccupancies = (await Promise.all(docs.map((doc)=>reservationOccupancies({
275
+ // reservationOccupancies resolves each doc LENIENTLY (see its own doc
276
+ // comment) — an inverted top-level window on a stored row skips parent
277
+ // synthesis rather than throwing, so this never needs a try/catch: every
278
+ // real items[] occupancy on the row still comes back.
279
+ //
280
+ // SEQUENTIAL, not Promise.all, and that is load-bearing. Each iteration can
281
+ // reach `bufferFor` -> `payload.findByID` on the shared `req`, and this whole
282
+ // function runs inside the booking's own transaction on the write path. A
283
+ // MongoDB ClientSession cannot carry concurrent operations inside a
284
+ // transaction: when two do collide, the loser's `count`/`findByID` calls
285
+ // `killTransaction` from its own catch, which rolls back and clears the
286
+ // transaction the enclosing create/delete owns, and the survivor then reports
287
+ // `NoSuchTransaction` ("transaction number N does not match any in-progress
288
+ // transactions"). Buffers are per-service and cached, so the loop is at most
289
+ // one read per DISTINCT neighbouring service either way.
290
+ const fetchedOccupancies = [];
291
+ for (const doc of docs){
292
+ fetchedOccupancies.push(...await reservationOccupancies({
246
293
  bufferFor,
247
294
  capacityMode,
248
295
  reservation: doc,
249
296
  resourceId
250
- })))).flat();
297
+ }));
298
+ }
251
299
  // Sibling items from the same booking (review A5) — expanded with the same
252
300
  // per-service buffers and capacity mode.
253
301
  const siblingOccupancies = siblingItems ? await itemsToOccupancies({
@@ -273,19 +321,102 @@ export async function checkAvailability(params) {
273
321
  blockedStart: new Date(iv.start),
274
322
  units: quantity
275
323
  })).filter((o)=>!isNaN(o.blockedStart.getTime()) && !isNaN(o.blockedEnd.getTime()));
276
- } catch {
324
+ } catch (err) {
325
+ trace.dbg('error', {
326
+ err,
327
+ resourceId,
328
+ where: 'getExternalBusy'
329
+ });
277
330
  externalOccupancies = [];
278
331
  }
279
332
  }
333
+ // Unexpired slot holds occupy the resource exactly as a blocking reservation
334
+ // does. Expiry is a read-time predicate rather than a TTL index so the
335
+ // behaviour is identical on Mongo, Postgres and SQLite. `excludeHoldToken`
336
+ // is the bearer converting their OWN hold into a booking — without it the
337
+ // hold would block the very booking it was taken to protect.
338
+ const holdOccupancies = [];
339
+ if (holdsSlug) {
340
+ const holdWhere = {
341
+ and: [
342
+ {
343
+ resource: {
344
+ equals: resourceId
345
+ }
346
+ },
347
+ {
348
+ expiresAt: {
349
+ greater_than: new Date().toISOString()
350
+ }
351
+ },
352
+ {
353
+ startTime: {
354
+ less_than: candidateEnd.toISOString()
355
+ }
356
+ },
357
+ {
358
+ endTime: {
359
+ greater_than: candidateStart.toISOString()
360
+ }
361
+ },
362
+ ...excludeHoldToken ? [
363
+ {
364
+ token: {
365
+ not_equals: excludeHoldToken
366
+ }
367
+ }
368
+ ] : []
369
+ ]
370
+ };
371
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
372
+ const { docs: holdDocs } = await payload.find({
373
+ collection: holdsSlug,
374
+ depth: 0,
375
+ limit: 0,
376
+ req,
377
+ where: holdWhere
378
+ });
379
+ // Sequential for the same reason the reservation loop above is: `bufferFor`
380
+ // reads through the shared `req`, and concurrent operations on one MongoDB
381
+ // session inside a transaction abort it.
382
+ for (const hold of holdDocs){
383
+ // Expand by the held service's own buffers, so a hold reserves the same
384
+ // real-world window the resulting booking will.
385
+ const { after, before } = await bufferFor(hold.service);
386
+ const { effectiveEnd, effectiveStart } = computeBlockedWindow(new Date(hold.startTime), new Date(hold.endTime), before, after);
387
+ holdOccupancies.push({
388
+ blockedEnd: effectiveEnd,
389
+ blockedStart: effectiveStart,
390
+ units: capacityMode === 'per-guest' ? hold.guestCount ?? 1 : 1
391
+ });
392
+ }
393
+ trace.dbg('check_holds', {
394
+ activeHolds: holdOccupancies.length,
395
+ resourceId
396
+ });
397
+ }
280
398
  const occupancies = [
281
399
  ...fetchedOccupancies,
282
400
  ...siblingOccupancies,
283
- ...externalOccupancies
401
+ ...externalOccupancies,
402
+ ...holdOccupancies
284
403
  ];
285
404
  // Sum the units of every occupancy whose buffered window overlaps the candidate
286
405
  const currentUnits = occupancies.reduce((sum, occ)=>doRangesOverlap(candidateStart, candidateEnd, occ.blockedStart, occ.blockedEnd) ? sum + occ.units : sum, 0);
287
406
  const candidateUnits = capacityMode === 'per-guest' ? guestCount : 1;
288
407
  const available = currentUnits + candidateUnits <= quantity;
408
+ trace.dbg('check_result', {
409
+ available,
410
+ candidateUnits,
411
+ currentUnits,
412
+ occupancySources: {
413
+ external: externalOccupancies.length,
414
+ fetched: fetchedOccupancies.length,
415
+ sibling: siblingOccupancies.length
416
+ },
417
+ quantity,
418
+ resourceId
419
+ });
289
420
  return {
290
421
  available,
291
422
  currentCount: currentUnits,
@@ -294,14 +425,29 @@ export async function checkAvailability(params) {
294
425
  };
295
426
  }
296
427
  export async function getAvailableSlots(params) {
297
- const { blockingStatuses, date, getExternalBusy, guestCount, payload, req, reservationSlug, resourceId, resourceIds, resourceSlug, scheduleSlug, serviceId, serviceSlug, timeZone } = params;
428
+ const { blockingStatuses, date, debug, enforceActive, getExternalBusy, guestCount, holdsSlug, payload, req, reservationSlug, resourceId, resourceIds, resourceSlug, scheduleSlug, serviceId, serviceSlug, timeZone } = params;
298
429
  const tz = timeZone ?? 'UTC';
299
430
  // Resolve the set of resources to intersect (single-resource callers still work)
300
431
  const ids = resourceIds && resourceIds.length > 0 ? resourceIds : resourceId !== undefined ? [
301
432
  resourceId
302
433
  ] : [];
434
+ const trace = (debug ?? NOOP_RESERVE_DEBUG).child({
435
+ resourceIds: ids,
436
+ serviceId
437
+ });
438
+ trace.dbg('input', {
439
+ date: date instanceof Date ? date.toISOString() : date,
440
+ guestCount: guestCount ?? 1,
441
+ timeZone: tz
442
+ });
303
443
  if (ids.length === 0) {
304
- return [];
444
+ trace.dbg('empty', {
445
+ reason: 'no_resource_ids'
446
+ });
447
+ return {
448
+ reason: 'no_resource_ids',
449
+ slots: []
450
+ };
305
451
  }
306
452
  // 1. Service for duration + buffer times (from the primary service)
307
453
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -309,45 +455,119 @@ export async function getAvailableSlots(params) {
309
455
  id: serviceId,
310
456
  collection: serviceSlug,
311
457
  depth: 0,
458
+ // Skip the resources join — internal logic never reads it, and without this
459
+ // every service read becomes an aggregation with a $lookup.
460
+ joins: false,
312
461
  req
313
462
  });
463
+ if (enforceActive !== false && service?.active === false) {
464
+ trace.dbg('empty', {
465
+ reason: 'service_inactive',
466
+ serviceId
467
+ });
468
+ return {
469
+ reason: 'service_inactive',
470
+ slots: []
471
+ };
472
+ }
314
473
  const duration = service.duration ?? 60;
315
474
  const bufferBefore = service.bufferTimeBefore ?? 0;
316
475
  const bufferAfter = service.bufferTimeAfter ?? 0;
317
476
  const durationType = service.durationType ?? 'fixed';
477
+ trace.dbg('service', {
478
+ bufferAfter,
479
+ bufferBefore,
480
+ duration,
481
+ durationType
482
+ });
318
483
  // 2. Per resource: fetch schedules and resolve to windows. A resource with >=1
319
484
  // schedule is "schedule-bearing" and constrains time; a resource with zero
320
485
  // schedules is capacity-only and contributes no time windows.
321
486
  const scheduleBearingWindowLists = [];
322
487
  for (const rid of ids){
488
+ // This read exists solely to feed the active check below — skip it
489
+ // entirely when enforceActive is off so an opted-out consumer doesn't pay
490
+ // for a findByID whose result is never read.
491
+ if (enforceActive !== false) {
492
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
493
+ const resourceDoc = await payload.findByID({
494
+ id: rid,
495
+ collection: resourceSlug,
496
+ depth: 0,
497
+ // Skip joins — internal logic never reads them, and without this every
498
+ // resource read becomes an aggregation with a $lookup.
499
+ joins: false,
500
+ req
501
+ }).catch(()=>null);
502
+ if (resourceDoc?.active === false) {
503
+ trace.dbg('empty', {
504
+ reason: 'resource_inactive',
505
+ resourceId: rid
506
+ });
507
+ return {
508
+ reason: 'resource_inactive',
509
+ slots: []
510
+ };
511
+ }
512
+ }
513
+ const scheduleWhere = {
514
+ and: [
515
+ {
516
+ resource: {
517
+ equals: rid
518
+ }
519
+ },
520
+ {
521
+ active: {
522
+ equals: true
523
+ }
524
+ }
525
+ ]
526
+ };
323
527
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
528
  const { docs: schedules } = await payload.find({
325
529
  collection: scheduleSlug,
326
530
  depth: 0,
327
531
  limit: 100,
328
532
  req,
329
- where: {
330
- and: [
331
- {
332
- resource: {
333
- equals: rid
334
- }
335
- },
336
- {
337
- active: {
338
- equals: true
339
- }
340
- }
341
- ]
342
- }
533
+ where: scheduleWhere
534
+ });
535
+ trace.dbg('schedules', {
536
+ activeFlags: schedules.map((s)=>s.active),
537
+ resourceId: rid,
538
+ rowsFound: schedules.length,
539
+ scheduleWhere
343
540
  });
344
541
  if (!schedules || schedules.length === 0) {
542
+ trace.dbg('skip', {
543
+ reason: 'no_active_schedules',
544
+ resourceId: rid,
545
+ rowsFound: 0
546
+ });
345
547
  continue;
346
548
  }
347
549
  // A11: an exception on ANY of the resource's schedules makes the whole
348
550
  // resource unavailable that day — not just the schedule it's recorded on.
349
- const exceptedToday = schedules.some((s)=>isExceptionDate(date, s.exceptions ?? [], tz));
350
- if (exceptedToday) {
551
+ let matched;
552
+ for (const s of schedules){
553
+ const exs = s.exceptions ?? [];
554
+ if (isExceptionDate(date, exs, tz)) {
555
+ matched = {
556
+ exception: exs.find((e)=>isExceptionDate(date, [
557
+ e
558
+ ], tz)),
559
+ scheduleId: s.id
560
+ };
561
+ break;
562
+ }
563
+ }
564
+ if (matched) {
565
+ trace.dbg('skip', {
566
+ matchedException: matched.exception,
567
+ reason: 'date_excepted',
568
+ resourceId: rid,
569
+ scheduleId: matched.scheduleId
570
+ });
351
571
  scheduleBearingWindowLists.push([]);
352
572
  continue;
353
573
  }
@@ -355,11 +575,26 @@ export async function getAvailableSlots(params) {
355
575
  for (const schedule of schedules){
356
576
  windows.push(...resolveScheduleForDate(schedule, date, tz));
357
577
  }
578
+ trace.dbg('windows', {
579
+ resourceId: rid,
580
+ windowCount: windows.length,
581
+ windows: windows.map((w)=>({
582
+ end: w.end.toISOString(),
583
+ start: w.start.toISOString()
584
+ })),
585
+ windowsEmpty: windows.length === 0
586
+ });
358
587
  scheduleBearingWindowLists.push(windows);
359
588
  }
360
589
  // No resource constrains time → no basis for generating slots
361
590
  if (scheduleBearingWindowLists.length === 0) {
362
- return [];
591
+ trace.dbg('empty', {
592
+ reason: 'no_windows'
593
+ });
594
+ return {
595
+ reason: 'no_windows',
596
+ slots: []
597
+ };
363
598
  }
364
599
  // 3. Intersect all schedule-bearing window lists
365
600
  let timeRanges = scheduleBearingWindowLists[0];
@@ -367,7 +602,17 @@ export async function getAvailableSlots(params) {
367
602
  timeRanges = intersectIntervals(timeRanges, scheduleBearingWindowLists[i]);
368
603
  }
369
604
  if (timeRanges.length === 0) {
370
- return [];
605
+ trace.dbg('empty', {
606
+ perResourceWindows: scheduleBearingWindowLists.map((list)=>list.map((w)=>({
607
+ end: w.end.toISOString(),
608
+ start: w.start.toISOString()
609
+ }))),
610
+ reason: 'empty_intersection'
611
+ });
612
+ return {
613
+ reason: 'empty_intersection',
614
+ slots: []
615
+ };
371
616
  }
372
617
  // 4. Candidate slot sizing
373
618
  // NOTE: epoch-trick sizing is only meaningful for fixed/flexible durations.
@@ -381,6 +626,11 @@ export async function getAvailableSlots(params) {
381
626
  });
382
627
  const slotDuration = Math.round(slotEndOffset.getTime() / 60_000);
383
628
  const effectiveDuration = durationType === 'fixed' ? duration : slotDuration;
629
+ trace.dbg('sizing', {
630
+ effectiveDuration,
631
+ slotDuration,
632
+ stepSize: Math.min(effectiveDuration, 15)
633
+ });
384
634
  // Helper: a window is available only if EVERY required resource is free
385
635
  const allAvailable = async (start, end, bBefore, bAfter)=>{
386
636
  for (const rid of ids){
@@ -388,9 +638,11 @@ export async function getAvailableSlots(params) {
388
638
  blockingStatuses,
389
639
  bufferAfter: bAfter,
390
640
  bufferBefore: bBefore,
641
+ debug: trace,
391
642
  endTime: end,
392
643
  getExternalBusy,
393
644
  guestCount: guestCount ?? 1,
645
+ holdsSlug,
394
646
  payload,
395
647
  req,
396
648
  reservationSlug,
@@ -408,7 +660,9 @@ export async function getAvailableSlots(params) {
408
660
  const availableSlots = [];
409
661
  // Full-day: offer each range as a single slot if all resources are free
410
662
  if (durationType === 'full-day') {
663
+ let candidatesGenerated = 0;
411
664
  for (const range of timeRanges){
665
+ candidatesGenerated++;
412
666
  if (await allAvailable(range.start, range.end, 0, 0)) {
413
667
  availableSlots.push({
414
668
  end: range.end,
@@ -416,9 +670,21 @@ export async function getAvailableSlots(params) {
416
670
  });
417
671
  }
418
672
  }
419
- return availableSlots;
673
+ trace.dbg('result', {
674
+ candidatesAvailable: availableSlots.length,
675
+ candidatesGenerated,
676
+ durationType: 'full-day',
677
+ returnedCount: availableSlots.length
678
+ });
679
+ return {
680
+ ...availableSlots.length === 0 ? {
681
+ reason: 'all_slots_taken'
682
+ } : {},
683
+ slots: availableSlots
684
+ };
420
685
  }
421
686
  const stepSize = Math.min(effectiveDuration, 15);
687
+ let candidatesGenerated = 0;
422
688
  for (const range of timeRanges){
423
689
  let candidateStart = new Date(range.start);
424
690
  while(true){
@@ -426,6 +692,7 @@ export async function getAvailableSlots(params) {
426
692
  if (candidateEnd > range.end) {
427
693
  break;
428
694
  }
695
+ candidatesGenerated++;
429
696
  if (await allAvailable(candidateStart, candidateEnd, bufferBefore, bufferAfter)) {
430
697
  availableSlots.push({
431
698
  end: candidateEnd,
@@ -435,7 +702,17 @@ export async function getAvailableSlots(params) {
435
702
  candidateStart = addMinutes(candidateStart, stepSize);
436
703
  }
437
704
  }
438
- return availableSlots;
705
+ trace.dbg('result', {
706
+ candidatesAvailable: availableSlots.length,
707
+ candidatesGenerated,
708
+ returnedCount: availableSlots.length
709
+ });
710
+ return {
711
+ ...availableSlots.length === 0 ? {
712
+ reason: candidatesGenerated === 0 ? 'window_too_short' : 'all_slots_taken'
713
+ } : {},
714
+ slots: availableSlots
715
+ };
439
716
  }
440
717
 
441
718
  //# sourceMappingURL=AvailabilityService.js.map