payload-reserve 2.4.0 → 3.0.1

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 (110) hide show
  1. package/README.md +217 -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 +11 -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 +12 -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 +12 -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 +2 -0
  42. package/dist/endpoints/resourceAvailability.js +79 -3
  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 +7 -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 +46 -5
  69. package/dist/services/AvailabilityService.js +168 -12
  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/index.js +36 -12
  85. package/dist/translations/index.js.map +1 -1
  86. package/dist/translations/pl.json +5 -1
  87. package/dist/translations/ru.json +5 -1
  88. package/dist/translations/tr.json +5 -1
  89. package/dist/translations/zh.json +5 -1
  90. package/dist/types.d.ts +29 -0
  91. package/dist/types.js.map +1 -1
  92. package/dist/utilities/calendarGrid.d.ts +44 -0
  93. package/dist/utilities/calendarGrid.js +67 -0
  94. package/dist/utilities/calendarGrid.js.map +1 -0
  95. package/dist/utilities/reservationChanges.d.ts +6 -1
  96. package/dist/utilities/reservationChanges.js +1 -1
  97. package/dist/utilities/reservationChanges.js.map +1 -1
  98. package/dist/utilities/resolveReservationItems.d.ts +13 -2
  99. package/dist/utilities/resolveReservationItems.js +87 -2
  100. package/dist/utilities/resolveReservationItems.js.map +1 -1
  101. package/dist/utilities/retryOnWriteConflict.d.ts +66 -0
  102. package/dist/utilities/retryOnWriteConflict.js +135 -0
  103. package/dist/utilities/retryOnWriteConflict.js.map +1 -0
  104. package/dist/utilities/tenantTimezone.d.ts +77 -1
  105. package/dist/utilities/tenantTimezone.js +163 -2
  106. package/dist/utilities/tenantTimezone.js.map +1 -1
  107. package/dist/utilities/transactionSupport.d.ts +13 -0
  108. package/dist/utilities/transactionSupport.js +30 -0
  109. package/dist/utilities/transactionSupport.js.map +1 -0
  110. package/package.json +13 -13
@@ -6,7 +6,7 @@ import { addDaysToDayKey, combineDayKeyAndTime, getDayKeyInTimezone } from '../u
6
6
  import { isPrivilegedUser } from '../utilities/userRoles.js';
7
7
  const MAX_RANGE_MS = 90 * 86_400_000;
8
8
  /** Busy intervals (with capacity units) for one resource over [start, end). */ async function busyFor(args) {
9
- const { blockingStatuses, capacityMode, end, payload, reservationSlug, resourceId, start } = args;
9
+ const { blockingStatuses, capacityMode, end, holdsSlug, payload, reservationSlug, resourceId, start } = args;
10
10
  const where = {
11
11
  and: [
12
12
  {
@@ -49,14 +49,61 @@ const MAX_RANGE_MS = 90 * 86_400_000;
49
49
  limit: 0,
50
50
  where
51
51
  });
52
- return docs.filter((r)=>r.startTime && r.endTime).map((r)=>({
52
+ const busy = docs.filter((r)=>r.startTime && r.endTime).map((r)=>({
53
53
  end: new Date(r.endTime).toISOString(),
54
54
  start: new Date(r.startTime).toISOString(),
55
55
  units: capacityMode === 'per-guest' ? r.guestCount ?? 1 : 1
56
56
  }));
57
+ // An unexpired hold occupies the resource exactly as a blocking reservation
58
+ // does — the write path counts it — so the grid must show it busy. Expiry is a
59
+ // read-time predicate here for the same reason it is in `checkAvailability`:
60
+ // TTL indexes are MongoDB-only.
61
+ if (holdsSlug) {
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
+ const { docs: holdDocs } = await payload.find({
64
+ collection: holdsSlug,
65
+ depth: 0,
66
+ limit: 0,
67
+ where: {
68
+ and: [
69
+ {
70
+ resource: {
71
+ equals: resourceId
72
+ }
73
+ },
74
+ {
75
+ expiresAt: {
76
+ greater_than: new Date().toISOString()
77
+ }
78
+ },
79
+ {
80
+ startTime: {
81
+ less_than: end.toISOString()
82
+ }
83
+ },
84
+ {
85
+ endTime: {
86
+ greater_than: start.toISOString()
87
+ }
88
+ }
89
+ ]
90
+ }
91
+ });
92
+ for (const hold of holdDocs){
93
+ if (!hold.startTime || !hold.endTime) {
94
+ continue;
95
+ }
96
+ busy.push({
97
+ end: new Date(hold.endTime).toISOString(),
98
+ start: new Date(hold.startTime).toISOString(),
99
+ units: capacityMode === 'per-guest' ? hold.guestCount ?? 1 : 1
100
+ });
101
+ }
102
+ }
103
+ return busy;
57
104
  }
58
105
  export async function buildResourceAvailability(params) {
59
- const { blockingStatuses, debug, end, getExternalBusy, payload, req, reservationSlug, resourceId, resourceSlug, scheduleSlug, start, timeZone } = params;
106
+ const { blockingStatuses, debug, end, getExternalBusy, holdsSlug, payload, req, reservationSlug, resourceId, resourceSlug, scheduleSlug, start, timeZone } = params;
60
107
  const trace = debug ?? NOOP_RESERVE_DEBUG;
61
108
  // depth 1 so `services` are populated (their `requiredResources` come back as ids)
62
109
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -145,6 +192,7 @@ export async function buildResourceAvailability(params) {
145
192
  blockingStatuses,
146
193
  capacityMode,
147
194
  end,
195
+ holdsSlug,
148
196
  payload,
149
197
  reservationSlug,
150
198
  resourceId,
@@ -180,6 +228,7 @@ export async function buildResourceAvailability(params) {
180
228
  blockingStatuses,
181
229
  capacityMode: poolCapacityMode,
182
230
  end,
231
+ holdsSlug,
183
232
  payload,
184
233
  reservationSlug,
185
234
  resourceId: poolId,
@@ -273,6 +322,31 @@ export function createResourceAvailabilityEndpoint(config) {
273
322
  status: 400
274
323
  });
275
324
  }
325
+ // Authorization boundary. Delegating to collection access here is what
326
+ // makes owner-mode ownership AND multi-tenant isolation apply — the four
327
+ // reads inside buildResourceAvailability deliberately stay privileged so
328
+ // the busy grid stays complete (see the spec, A4). depth:0 is required:
329
+ // at depth 1 an access-denied `services` relationship populates as null
330
+ // and the requiredResources loop throws on `typeof null === 'object'`.
331
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
332
+ const permitted = await req.payload.findByID({
333
+ id: resource,
334
+ collection: config.slugs.resources,
335
+ depth: 0,
336
+ overrideAccess: false,
337
+ req
338
+ }).catch(()=>null);
339
+ if (!permitted) {
340
+ dbg.dbg('response', {
341
+ endpoint: 'resource-availability',
342
+ reason: 'resource_not_found'
343
+ });
344
+ return Response.json({
345
+ error: 'Resource not found'
346
+ }, {
347
+ status: 404
348
+ });
349
+ }
276
350
  // In multiTenant mode, resolve day-boundaries in the SELECTED tenant's zone
277
351
  // (tenant timezone → global → UTC). Degrades to the global zone for plain
278
352
  // installs: no tenant relationship on reservations / no tenant cookie ⇒ no
@@ -281,6 +355,7 @@ export function createResourceAvailabilityEndpoint(config) {
281
355
  const timeZone = await getEffectiveTenantTimezone({
282
356
  globalTimezone: config.timezone,
283
357
  payload: req.payload,
358
+ req,
284
359
  scopedCollection: reservationsCollection,
285
360
  tenantField: config.multiTenant.tenantField,
286
361
  tenantId: readCookie(req.headers?.get('cookie'), config.multiTenant.cookieName),
@@ -298,6 +373,7 @@ export function createResourceAvailabilityEndpoint(config) {
298
373
  debug: dbg,
299
374
  end: endDate,
300
375
  getExternalBusy: config.getExternalBusy,
376
+ holdsSlug: config.slotHolds.enabled ? config.slugs.holds : undefined,
301
377
  payload: req.payload,
302
378
  req,
303
379
  reservationSlug: config.slugs.reservations,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/resourceAvailability.ts"],"sourcesContent":["import type { Endpoint, Payload, PayloadRequest, Where } from 'payload'\n\nimport type {\n ExternalBusyInterval,\n GetExternalBusy,\n ResolvedReservationPluginConfig,\n} from '../types.js'\n\nimport {\n createReserveDebug,\n NOOP_RESERVE_DEBUG,\n type ReserveDebug,\n} from '../utilities/reserveDebug.js'\nimport { resolveScheduleForDate } from '../utilities/scheduleUtils.js'\nimport { readCookie } from '../utilities/tenantFilter.js'\nimport { getEffectiveTenantTimezone } from '../utilities/tenantTimezone.js'\nimport {\n addDaysToDayKey,\n combineDayKeyAndTime,\n getDayKeyInTimezone,\n} from '../utilities/timezoneUtils.js'\nimport { isPrivilegedUser } from '../utilities/userRoles.js'\n\nconst MAX_RANGE_MS = 90 * 86_400_000\n\ntype DayAvailability = {\n date: string\n shiftWindows: Array<{ end: string; start: string }>\n timeOff: Array<{ end: string; reason?: string; start: string; type?: string }>\n}\n\ntype Busy = Array<{ end: string; start: string; units: number }>\n\nexport type ResourceAvailability = {\n busy: Busy\n capacityMode: 'per-guest' | 'per-reservation'\n days: DayAvailability[]\n /** External busy intervals (calendar sync etc.) from getExternalBusy — display-only here; enforcement lives in checkAvailability. */\n external: ExternalBusyInterval[]\n quantity: number\n /** Capacity of resources this resource's services also require (e.g. a chair pool). */\n requiredPools: Array<{ busy: Busy; quantity: number }>\n /** IANA zone the day windows were resolved in (selected tenant's zone, else global). */\n timeZone: string\n}\n\n/** Busy intervals (with capacity units) for one resource over [start, end). */\nasync function busyFor(args: {\n blockingStatuses: string[]\n capacityMode: 'per-guest' | 'per-reservation'\n end: Date\n payload: Payload\n reservationSlug: string\n resourceId: number | string\n start: Date\n}): Promise<Busy> {\n const { blockingStatuses, capacityMode, end, payload, reservationSlug, resourceId, start } = args\n const where: Where = {\n and: [\n { status: { in: blockingStatuses } },\n { startTime: { less_than: end.toISOString() } },\n { endTime: { greater_than: start.toISOString() } },\n { or: [{ resource: { equals: resourceId } }, { 'items.resource': { equals: resourceId } }] },\n ],\n }\n // limit:0 = all matching — bounded by the endpoint's 90-day range cap, so this\n // can't run away, and the grid no longer silently drops busy intervals (D9).\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const { docs } = await (payload.find as any)({\n collection: reservationSlug,\n depth: 0,\n limit: 0,\n where,\n })\n return (docs as Array<Record<string, unknown>>)\n .filter((r) => r.startTime && r.endTime)\n .map((r) => ({\n end: new Date(r.endTime as string).toISOString(),\n start: new Date(r.startTime as string).toISOString(),\n units: capacityMode === 'per-guest' ? ((r.guestCount as number) ?? 1) : 1,\n }))\n}\n\nexport async function buildResourceAvailability(params: {\n blockingStatuses: string[]\n debug?: ReserveDebug\n end: Date\n getExternalBusy?: GetExternalBusy\n payload: Payload\n req?: PayloadRequest\n reservationSlug: string\n resourceId: number | string\n resourceSlug: string\n scheduleSlug: string\n start: Date\n timeZone: string\n}): Promise<null | ResourceAvailability> {\n const {\n blockingStatuses,\n debug,\n end,\n getExternalBusy,\n payload,\n req,\n reservationSlug,\n resourceId,\n resourceSlug,\n scheduleSlug,\n start,\n timeZone,\n } = params\n const trace = debug ?? NOOP_RESERVE_DEBUG\n\n // depth 1 so `services` are populated (their `requiredResources` come back as ids)\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const resource = await (payload.findByID as any)({\n id: resourceId,\n collection: resourceSlug,\n depth: 1,\n }).catch(() => null)\n if (!resource) {\n return null\n }\n const quantity = (resource?.quantity as number) ?? 1\n const capacityMode =\n (resource?.capacityMode as 'per-guest' | 'per-reservation') ?? 'per-reservation'\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const { docs: schedules } = await (payload.find as any)({\n collection: scheduleSlug,\n depth: 0,\n limit: 100,\n where: { and: [{ active: { equals: true } }, { resource: { equals: resourceId } }] },\n })\n\n type RawException = {\n date: string\n endDate?: string\n reason?: string\n type?: string\n }\n\n const days: DayAvailability[] = []\n const startKey = getDayKeyInTimezone(start, timeZone)\n const lastKey = getDayKeyInTimezone(new Date(end.getTime() - 1), timeZone)\n for (let date = startKey; date <= lastKey; date = addDaysToDayKey(date, 1)) {\n const shiftWindows: DayAvailability['shiftWindows'] = []\n const timeOff: DayAvailability['timeOff'] = []\n\n // A11: an exception on ANY of the resource's schedules makes the whole\n // resource unavailable that day. Find the first matching exception (if any)\n // — it suppresses all shift windows and marks the full day as time-off.\n let dayException: RawException | undefined\n for (const sched of schedules as Array<Record<string, unknown>>) {\n const exceptions = (sched.exceptions as RawException[] | undefined) ?? []\n for (const exc of exceptions) {\n const excStart = getDayKeyInTimezone(new Date(exc.date), timeZone)\n const excEnd = exc.endDate ? getDayKeyInTimezone(new Date(exc.endDate), timeZone) : excStart\n if (date >= excStart && date <= excEnd) {\n dayException = exc\n break\n }\n }\n if (dayException) {\n break\n }\n }\n\n if (dayException) {\n const dayStart = combineDayKeyAndTime(date, '00:00', timeZone)\n const dayEnd = new Date(combineDayKeyAndTime(date, '23:59', timeZone).getTime() + 59_999)\n timeOff.push({\n type: dayException.type,\n end: dayEnd.toISOString(),\n reason: dayException.reason,\n start: dayStart.toISOString(),\n })\n } else {\n for (const sched of schedules as Array<Record<string, unknown>>) {\n // resolveScheduleForDate accepts a Schedule-shaped object; cast through unknown\n const ranges = resolveScheduleForDate(\n sched as unknown as Parameters<typeof resolveScheduleForDate>[0],\n date,\n timeZone,\n )\n for (const r of ranges) {\n shiftWindows.push({ end: r.end.toISOString(), start: r.start.toISOString() })\n }\n }\n }\n\n days.push({ date, shiftWindows, timeOff })\n }\n\n const busy = await busyFor({\n blockingStatuses,\n capacityMode,\n end,\n payload,\n reservationSlug,\n resourceId,\n start,\n })\n\n // Resources this resource's services ALSO require (e.g. a shared chair pool).\n // A slot isn't truly bookable if any of these is at capacity, even when the\n // resource itself is free — so the calendar reflects real availability.\n const poolIds = new Set<string>()\n for (const svc of (resource?.services as Array<Record<string, unknown>>) ?? []) {\n const reqs = (typeof svc === 'object' ? (svc.requiredResources as unknown[]) : []) ?? []\n for (const rr of reqs) {\n const id: number | string | undefined =\n typeof rr === 'object' && rr !== null\n ? (rr as { id?: number | string }).id\n : (rr as number | string)\n if (id != null && String(id) !== String(resourceId)) {\n poolIds.add(String(id))\n }\n }\n }\n\n const requiredPools: ResourceAvailability['requiredPools'] = []\n for (const poolId of poolIds) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const pool = await (payload.findByID as any)({\n id: poolId,\n collection: resourceSlug,\n depth: 0,\n }).catch(() => null)\n if (!pool) {\n continue\n }\n const poolCapacityMode =\n (pool.capacityMode as 'per-guest' | 'per-reservation') ?? 'per-reservation'\n requiredPools.push({\n busy: await busyFor({\n blockingStatuses,\n capacityMode: poolCapacityMode,\n end,\n payload,\n reservationSlug,\n resourceId: poolId,\n start,\n }),\n quantity: (pool.quantity as number) ?? 1,\n })\n }\n\n // External busy — display only (enforcement lives in checkAvailability).\n // Fail-open: a resolver error must never break the grid.\n let external: ExternalBusyInterval[] = []\n if (getExternalBusy && req) {\n try {\n external = (await getExternalBusy({ end, req, resourceId, start })).filter(\n (iv) => !isNaN(Date.parse(iv.start)) && !isNaN(Date.parse(iv.end)),\n )\n } catch (err) {\n trace.dbg('error', { err, resourceId, where: 'getExternalBusy' })\n external = []\n }\n }\n\n return { busy, capacityMode, days, external, quantity, requiredPools, timeZone }\n}\n\nexport function createResourceAvailabilityEndpoint(\n config: ResolvedReservationPluginConfig,\n): Endpoint {\n return {\n handler: async (req) => {\n // The grid data (every reservation's busy window) is staff/admin-only —\n // same gate as customer search.\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n if (!isPrivilegedUser(req.user, config)) {\n return Response.json({ error: 'Forbidden' }, { status: 403 })\n }\n const dbg = createReserveDebug(req.payload.logger, config.debug)\n\n const url = new URL(req.url!)\n const resource = url.searchParams.get('resource')\n const start = url.searchParams.get('start')\n const end = url.searchParams.get('end')\n\n if (!resource || !start || !end) {\n return Response.json(\n { error: 'Missing required query params: resource, start, end' },\n { status: 400 },\n )\n }\n\n const startDate = new Date(start)\n const endDate = new Date(end)\n if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {\n return Response.json({ error: 'Invalid start/end date' }, { status: 400 })\n }\n if (endDate <= startDate) {\n return Response.json({ error: 'end must be after start' }, { status: 400 })\n }\n // Unbounded ranges turn the per-day resolution loop into a CPU sink\n if (endDate.getTime() - startDate.getTime() > MAX_RANGE_MS) {\n return Response.json({ error: 'Date range too large (max 90 days)' }, { status: 400 })\n }\n\n // In multiTenant mode, resolve day-boundaries in the SELECTED tenant's zone\n // (tenant timezone → global → UTC). Degrades to the global zone for plain\n // installs: no tenant relationship on reservations / no tenant cookie ⇒ no\n // DB read, same output as before.\n const reservationsCollection = req.payload.config.collections?.find(\n (c) => c.slug === config.slugs.reservations,\n )\n const timeZone = await getEffectiveTenantTimezone({\n globalTimezone: config.timezone,\n payload: req.payload,\n scopedCollection: reservationsCollection as { fields?: unknown[] } | undefined,\n tenantField: config.multiTenant.tenantField,\n tenantId: readCookie(req.headers?.get('cookie'), config.multiTenant.cookieName),\n timezoneField: config.multiTenant.timezoneField,\n })\n\n dbg.dbg('request', { end, endpoint: 'resource-availability', resource, start, timeZone })\n\n const result = await buildResourceAvailability({\n blockingStatuses: config.statusMachine.blockingStatuses,\n debug: dbg,\n end: endDate,\n getExternalBusy: config.getExternalBusy,\n payload: req.payload,\n req,\n reservationSlug: config.slugs.reservations,\n resourceId: resource,\n resourceSlug: config.slugs.resources,\n scheduleSlug: config.slugs.schedules,\n start: startDate,\n timeZone,\n })\n\n if (!result) {\n dbg.dbg('response', { endpoint: 'resource-availability', reason: 'resource_not_found' })\n return Response.json({ error: 'Resource not found' }, { status: 404 })\n }\n\n dbg.dbg('response', {\n dayCount: result.days.length,\n endpoint: 'resource-availability',\n externalCount: result.external.length,\n })\n return Response.json(result)\n },\n method: 'get',\n path: '/reserve/resource-availability',\n }\n}\n"],"names":["createReserveDebug","NOOP_RESERVE_DEBUG","resolveScheduleForDate","readCookie","getEffectiveTenantTimezone","addDaysToDayKey","combineDayKeyAndTime","getDayKeyInTimezone","isPrivilegedUser","MAX_RANGE_MS","busyFor","args","blockingStatuses","capacityMode","end","payload","reservationSlug","resourceId","start","where","and","status","in","startTime","less_than","toISOString","endTime","greater_than","or","resource","equals","docs","find","collection","depth","limit","filter","r","map","Date","units","guestCount","buildResourceAvailability","params","debug","getExternalBusy","req","resourceSlug","scheduleSlug","timeZone","trace","findByID","id","catch","quantity","schedules","active","days","startKey","lastKey","getTime","date","shiftWindows","timeOff","dayException","sched","exceptions","exc","excStart","excEnd","endDate","dayStart","dayEnd","push","type","reason","ranges","busy","poolIds","Set","svc","services","reqs","requiredResources","rr","String","add","requiredPools","poolId","pool","poolCapacityMode","external","iv","isNaN","parse","err","dbg","createResourceAvailabilityEndpoint","config","handler","user","Response","json","error","logger","url","URL","searchParams","get","startDate","reservationsCollection","collections","c","slug","slugs","reservations","globalTimezone","timezone","scopedCollection","tenantField","multiTenant","tenantId","headers","cookieName","timezoneField","endpoint","result","statusMachine","resources","dayCount","length","externalCount","method","path"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/endpoints/resourceAvailability.ts"],"sourcesContent":["import type { Endpoint, Payload, PayloadRequest, Where } from 'payload'\n\nimport type {\n ExternalBusyInterval,\n GetExternalBusy,\n ResolvedReservationPluginConfig,\n} from '../types.js'\n\nimport {\n createReserveDebug,\n NOOP_RESERVE_DEBUG,\n type ReserveDebug,\n} from '../utilities/reserveDebug.js'\nimport { resolveScheduleForDate } from '../utilities/scheduleUtils.js'\nimport { readCookie } from '../utilities/tenantFilter.js'\nimport { getEffectiveTenantTimezone } from '../utilities/tenantTimezone.js'\nimport {\n addDaysToDayKey,\n combineDayKeyAndTime,\n getDayKeyInTimezone,\n} from '../utilities/timezoneUtils.js'\nimport { isPrivilegedUser } from '../utilities/userRoles.js'\n\nconst MAX_RANGE_MS = 90 * 86_400_000\n\ntype DayAvailability = {\n date: string\n shiftWindows: Array<{ end: string; start: string }>\n timeOff: Array<{ end: string; reason?: string; start: string; type?: string }>\n}\n\ntype Busy = Array<{ end: string; start: string; units: number }>\n\nexport type ResourceAvailability = {\n busy: Busy\n capacityMode: 'per-guest' | 'per-reservation'\n days: DayAvailability[]\n /** External busy intervals (calendar sync etc.) from getExternalBusy — display-only here; enforcement lives in checkAvailability. */\n external: ExternalBusyInterval[]\n quantity: number\n /** Capacity of resources this resource's services also require (e.g. a chair pool). */\n requiredPools: Array<{ busy: Busy; quantity: number }>\n /** IANA zone the day windows were resolved in (selected tenant's zone, else global). */\n timeZone: string\n}\n\n/** Busy intervals (with capacity units) for one resource over [start, end). */\nasync function busyFor(args: {\n blockingStatuses: string[]\n capacityMode: 'per-guest' | 'per-reservation'\n end: Date\n /** Slot-holds slug; omit to ignore holds (i.e. when `slotHolds` is off). */\n holdsSlug?: string\n payload: Payload\n reservationSlug: string\n resourceId: number | string\n start: Date\n}): Promise<Busy> {\n const {\n blockingStatuses,\n capacityMode,\n end,\n holdsSlug,\n payload,\n reservationSlug,\n resourceId,\n start,\n } = args\n const where: Where = {\n and: [\n { status: { in: blockingStatuses } },\n { startTime: { less_than: end.toISOString() } },\n { endTime: { greater_than: start.toISOString() } },\n { or: [{ resource: { equals: resourceId } }, { 'items.resource': { equals: resourceId } }] },\n ],\n }\n // limit:0 = all matching — bounded by the endpoint's 90-day range cap, so this\n // can't run away, and the grid no longer silently drops busy intervals (D9).\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const { docs } = await (payload.find as any)({\n collection: reservationSlug,\n depth: 0,\n limit: 0,\n where,\n })\n const busy: Busy = (docs as Array<Record<string, unknown>>)\n .filter((r) => r.startTime && r.endTime)\n .map((r) => ({\n end: new Date(r.endTime as string).toISOString(),\n start: new Date(r.startTime as string).toISOString(),\n units: capacityMode === 'per-guest' ? ((r.guestCount as number) ?? 1) : 1,\n }))\n\n // An unexpired hold occupies the resource exactly as a blocking reservation\n // does — the write path counts it — so the grid must show it busy. Expiry is a\n // read-time predicate here for the same reason it is in `checkAvailability`:\n // TTL indexes are MongoDB-only.\n if (holdsSlug) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const { docs: holdDocs } = await (payload.find as any)({\n collection: holdsSlug,\n depth: 0,\n limit: 0,\n where: {\n and: [\n { resource: { equals: resourceId } },\n { expiresAt: { greater_than: new Date().toISOString() } },\n { startTime: { less_than: end.toISOString() } },\n { endTime: { greater_than: start.toISOString() } },\n ],\n } as Where,\n })\n for (const hold of holdDocs as Array<Record<string, unknown>>) {\n if (!hold.startTime || !hold.endTime) {\n continue\n }\n busy.push({\n end: new Date(hold.endTime as string).toISOString(),\n start: new Date(hold.startTime as string).toISOString(),\n units: capacityMode === 'per-guest' ? ((hold.guestCount as number) ?? 1) : 1,\n })\n }\n }\n\n return busy\n}\n\nexport async function buildResourceAvailability(params: {\n blockingStatuses: string[]\n debug?: ReserveDebug\n end: Date\n getExternalBusy?: GetExternalBusy\n /** Slot-holds slug; omit to ignore holds (i.e. when `slotHolds` is off). */\n holdsSlug?: string\n payload: Payload\n req?: PayloadRequest\n reservationSlug: string\n resourceId: number | string\n resourceSlug: string\n scheduleSlug: string\n start: Date\n timeZone: string\n}): Promise<null | ResourceAvailability> {\n const {\n blockingStatuses,\n debug,\n end,\n getExternalBusy,\n holdsSlug,\n payload,\n req,\n reservationSlug,\n resourceId,\n resourceSlug,\n scheduleSlug,\n start,\n timeZone,\n } = params\n const trace = debug ?? NOOP_RESERVE_DEBUG\n\n // depth 1 so `services` are populated (their `requiredResources` come back as ids)\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const resource = await (payload.findByID as any)({\n id: resourceId,\n collection: resourceSlug,\n depth: 1,\n }).catch(() => null)\n if (!resource) {\n return null\n }\n const quantity = (resource?.quantity as number) ?? 1\n const capacityMode =\n (resource?.capacityMode as 'per-guest' | 'per-reservation') ?? 'per-reservation'\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const { docs: schedules } = await (payload.find as any)({\n collection: scheduleSlug,\n depth: 0,\n limit: 100,\n where: { and: [{ active: { equals: true } }, { resource: { equals: resourceId } }] },\n })\n\n type RawException = {\n date: string\n endDate?: string\n reason?: string\n type?: string\n }\n\n const days: DayAvailability[] = []\n const startKey = getDayKeyInTimezone(start, timeZone)\n const lastKey = getDayKeyInTimezone(new Date(end.getTime() - 1), timeZone)\n for (let date = startKey; date <= lastKey; date = addDaysToDayKey(date, 1)) {\n const shiftWindows: DayAvailability['shiftWindows'] = []\n const timeOff: DayAvailability['timeOff'] = []\n\n // A11: an exception on ANY of the resource's schedules makes the whole\n // resource unavailable that day. Find the first matching exception (if any)\n // — it suppresses all shift windows and marks the full day as time-off.\n let dayException: RawException | undefined\n for (const sched of schedules as Array<Record<string, unknown>>) {\n const exceptions = (sched.exceptions as RawException[] | undefined) ?? []\n for (const exc of exceptions) {\n const excStart = getDayKeyInTimezone(new Date(exc.date), timeZone)\n const excEnd = exc.endDate ? getDayKeyInTimezone(new Date(exc.endDate), timeZone) : excStart\n if (date >= excStart && date <= excEnd) {\n dayException = exc\n break\n }\n }\n if (dayException) {\n break\n }\n }\n\n if (dayException) {\n const dayStart = combineDayKeyAndTime(date, '00:00', timeZone)\n const dayEnd = new Date(combineDayKeyAndTime(date, '23:59', timeZone).getTime() + 59_999)\n timeOff.push({\n type: dayException.type,\n end: dayEnd.toISOString(),\n reason: dayException.reason,\n start: dayStart.toISOString(),\n })\n } else {\n for (const sched of schedules as Array<Record<string, unknown>>) {\n // resolveScheduleForDate accepts a Schedule-shaped object; cast through unknown\n const ranges = resolveScheduleForDate(\n sched as unknown as Parameters<typeof resolveScheduleForDate>[0],\n date,\n timeZone,\n )\n for (const r of ranges) {\n shiftWindows.push({ end: r.end.toISOString(), start: r.start.toISOString() })\n }\n }\n }\n\n days.push({ date, shiftWindows, timeOff })\n }\n\n const busy = await busyFor({\n blockingStatuses,\n capacityMode,\n end,\n holdsSlug,\n payload,\n reservationSlug,\n resourceId,\n start,\n })\n\n // Resources this resource's services ALSO require (e.g. a shared chair pool).\n // A slot isn't truly bookable if any of these is at capacity, even when the\n // resource itself is free — so the calendar reflects real availability.\n const poolIds = new Set<string>()\n for (const svc of (resource?.services as Array<Record<string, unknown>>) ?? []) {\n const reqs = (typeof svc === 'object' ? (svc.requiredResources as unknown[]) : []) ?? []\n for (const rr of reqs) {\n const id: number | string | undefined =\n typeof rr === 'object' && rr !== null\n ? (rr as { id?: number | string }).id\n : (rr as number | string)\n if (id != null && String(id) !== String(resourceId)) {\n poolIds.add(String(id))\n }\n }\n }\n\n const requiredPools: ResourceAvailability['requiredPools'] = []\n for (const poolId of poolIds) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const pool = await (payload.findByID as any)({\n id: poolId,\n collection: resourceSlug,\n depth: 0,\n }).catch(() => null)\n if (!pool) {\n continue\n }\n const poolCapacityMode =\n (pool.capacityMode as 'per-guest' | 'per-reservation') ?? 'per-reservation'\n requiredPools.push({\n busy: await busyFor({\n blockingStatuses,\n capacityMode: poolCapacityMode,\n end,\n holdsSlug,\n payload,\n reservationSlug,\n resourceId: poolId,\n start,\n }),\n quantity: (pool.quantity as number) ?? 1,\n })\n }\n\n // External busy — display only (enforcement lives in checkAvailability).\n // Fail-open: a resolver error must never break the grid.\n let external: ExternalBusyInterval[] = []\n if (getExternalBusy && req) {\n try {\n external = (await getExternalBusy({ end, req, resourceId, start })).filter(\n (iv) => !isNaN(Date.parse(iv.start)) && !isNaN(Date.parse(iv.end)),\n )\n } catch (err) {\n trace.dbg('error', { err, resourceId, where: 'getExternalBusy' })\n external = []\n }\n }\n\n return { busy, capacityMode, days, external, quantity, requiredPools, timeZone }\n}\n\nexport function createResourceAvailabilityEndpoint(\n config: ResolvedReservationPluginConfig,\n): Endpoint {\n return {\n handler: async (req) => {\n // The grid data (every reservation's busy window) is staff/admin-only —\n // same gate as customer search.\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n if (!isPrivilegedUser(req.user, config)) {\n return Response.json({ error: 'Forbidden' }, { status: 403 })\n }\n const dbg = createReserveDebug(req.payload.logger, config.debug)\n\n const url = new URL(req.url!)\n const resource = url.searchParams.get('resource')\n const start = url.searchParams.get('start')\n const end = url.searchParams.get('end')\n\n if (!resource || !start || !end) {\n return Response.json(\n { error: 'Missing required query params: resource, start, end' },\n { status: 400 },\n )\n }\n\n const startDate = new Date(start)\n const endDate = new Date(end)\n if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {\n return Response.json({ error: 'Invalid start/end date' }, { status: 400 })\n }\n if (endDate <= startDate) {\n return Response.json({ error: 'end must be after start' }, { status: 400 })\n }\n // Unbounded ranges turn the per-day resolution loop into a CPU sink\n if (endDate.getTime() - startDate.getTime() > MAX_RANGE_MS) {\n return Response.json({ error: 'Date range too large (max 90 days)' }, { status: 400 })\n }\n\n // Authorization boundary. Delegating to collection access here is what\n // makes owner-mode ownership AND multi-tenant isolation apply — the four\n // reads inside buildResourceAvailability deliberately stay privileged so\n // the busy grid stays complete (see the spec, A4). depth:0 is required:\n // at depth 1 an access-denied `services` relationship populates as null\n // and the requiredResources loop throws on `typeof null === 'object'`.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const permitted = await (req.payload.findByID as any)({\n id: resource,\n collection: config.slugs.resources,\n depth: 0,\n overrideAccess: false,\n req,\n }).catch(() => null)\n\n if (!permitted) {\n dbg.dbg('response', { endpoint: 'resource-availability', reason: 'resource_not_found' })\n return Response.json({ error: 'Resource not found' }, { status: 404 })\n }\n\n // In multiTenant mode, resolve day-boundaries in the SELECTED tenant's zone\n // (tenant timezone → global → UTC). Degrades to the global zone for plain\n // installs: no tenant relationship on reservations / no tenant cookie ⇒ no\n // DB read, same output as before.\n const reservationsCollection = req.payload.config.collections?.find(\n (c) => c.slug === config.slugs.reservations,\n )\n const timeZone = await getEffectiveTenantTimezone({\n globalTimezone: config.timezone,\n payload: req.payload,\n req,\n scopedCollection: reservationsCollection as { fields?: unknown[] } | undefined,\n tenantField: config.multiTenant.tenantField,\n tenantId: readCookie(req.headers?.get('cookie'), config.multiTenant.cookieName),\n timezoneField: config.multiTenant.timezoneField,\n })\n\n dbg.dbg('request', { end, endpoint: 'resource-availability', resource, start, timeZone })\n\n const result = await buildResourceAvailability({\n blockingStatuses: config.statusMachine.blockingStatuses,\n debug: dbg,\n end: endDate,\n getExternalBusy: config.getExternalBusy,\n holdsSlug: config.slotHolds.enabled ? config.slugs.holds : undefined,\n payload: req.payload,\n req,\n reservationSlug: config.slugs.reservations,\n resourceId: resource,\n resourceSlug: config.slugs.resources,\n scheduleSlug: config.slugs.schedules,\n start: startDate,\n timeZone,\n })\n\n if (!result) {\n dbg.dbg('response', { endpoint: 'resource-availability', reason: 'resource_not_found' })\n return Response.json({ error: 'Resource not found' }, { status: 404 })\n }\n\n dbg.dbg('response', {\n dayCount: result.days.length,\n endpoint: 'resource-availability',\n externalCount: result.external.length,\n })\n return Response.json(result)\n },\n method: 'get',\n path: '/reserve/resource-availability',\n }\n}\n"],"names":["createReserveDebug","NOOP_RESERVE_DEBUG","resolveScheduleForDate","readCookie","getEffectiveTenantTimezone","addDaysToDayKey","combineDayKeyAndTime","getDayKeyInTimezone","isPrivilegedUser","MAX_RANGE_MS","busyFor","args","blockingStatuses","capacityMode","end","holdsSlug","payload","reservationSlug","resourceId","start","where","and","status","in","startTime","less_than","toISOString","endTime","greater_than","or","resource","equals","docs","find","collection","depth","limit","busy","filter","r","map","Date","units","guestCount","holdDocs","expiresAt","hold","push","buildResourceAvailability","params","debug","getExternalBusy","req","resourceSlug","scheduleSlug","timeZone","trace","findByID","id","catch","quantity","schedules","active","days","startKey","lastKey","getTime","date","shiftWindows","timeOff","dayException","sched","exceptions","exc","excStart","excEnd","endDate","dayStart","dayEnd","type","reason","ranges","poolIds","Set","svc","services","reqs","requiredResources","rr","String","add","requiredPools","poolId","pool","poolCapacityMode","external","iv","isNaN","parse","err","dbg","createResourceAvailabilityEndpoint","config","handler","user","Response","json","error","logger","url","URL","searchParams","get","startDate","permitted","slugs","resources","overrideAccess","endpoint","reservationsCollection","collections","c","slug","reservations","globalTimezone","timezone","scopedCollection","tenantField","multiTenant","tenantId","headers","cookieName","timezoneField","result","statusMachine","slotHolds","enabled","holds","undefined","dayCount","length","externalCount","method","path"],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ import type { CollectionBeforeChangeHook } from 'payload';
2
+ import type { ResolvedReservationPluginConfig } from '../../types.js';
3
+ /**
4
+ * Serialize and validate a slot hold, inside the hold's own transaction.
5
+ *
6
+ * This deliberately lives in a `beforeChange` hook rather than in a service
7
+ * function that calls the pieces in sequence. The lock only serializes anything
8
+ * if the lock write, the availability read, and the insert all share one
9
+ * transaction — and Payload opens that transaction around `create`, so a hook
10
+ * is inside it while an orchestrating caller is not. An earlier version of this
11
+ * ran the same three steps from a service function and granted 3 of 8
12
+ * simultaneous holds for one slot; moved here, it grants exactly 1.
13
+ */
14
+ export declare const validateHoldSlot: (config: ResolvedReservationPluginConfig) => CollectionBeforeChangeHook;
@@ -0,0 +1,90 @@
1
+ import { ValidationError } from 'payload';
2
+ import { checkAvailability } from '../../services/AvailabilityService.js';
3
+ import { extractId } from '../../utilities/resolveReservationItems.js';
4
+ /**
5
+ * Serialize and validate a slot hold, inside the hold's own transaction.
6
+ *
7
+ * This deliberately lives in a `beforeChange` hook rather than in a service
8
+ * function that calls the pieces in sequence. The lock only serializes anything
9
+ * if the lock write, the availability read, and the insert all share one
10
+ * transaction — and Payload opens that transaction around `create`, so a hook
11
+ * is inside it while an orchestrating caller is not. An earlier version of this
12
+ * ran the same three steps from a service function and granted 3 of 8
13
+ * simultaneous holds for one slot; moved here, it grants exactly 1.
14
+ */ export const validateHoldSlot = (config)=>async ({ context, data, operation, req })=>{
15
+ if (context?.skipReservationHooks || operation !== 'create') {
16
+ return data;
17
+ }
18
+ const resourceId = extractId(data.resource);
19
+ const serviceId = extractId(data.service);
20
+ if (resourceId === undefined || !data.startTime || !data.endTime) {
21
+ return data;
22
+ }
23
+ // Claim the resource first. Two simultaneous holds — or a hold racing a
24
+ // booking — now collide on this one document and the database serializes
25
+ // them. See acquireBookingLock for the full rationale.
26
+ await req.payload.db.updateOne({
27
+ id: resourceId,
28
+ collection: config.slugs.resources,
29
+ data: {
30
+ bookingLock: `hold:${String(data.startTime)}`
31
+ },
32
+ req,
33
+ returning: false
34
+ });
35
+ let bufferBefore = config.defaultBufferTime;
36
+ let bufferAfter = config.defaultBufferTime;
37
+ if (serviceId !== undefined) {
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ const service = await req.payload.findByID({
40
+ id: serviceId,
41
+ collection: config.slugs.services,
42
+ depth: 0,
43
+ joins: false,
44
+ req
45
+ });
46
+ if (service) {
47
+ if (config.enforceActive && service.active === false) {
48
+ throw new ValidationError({
49
+ errors: [
50
+ {
51
+ message: 'Service is not active',
52
+ path: 'service'
53
+ }
54
+ ]
55
+ });
56
+ }
57
+ bufferBefore = service.bufferTimeBefore ?? config.defaultBufferTime;
58
+ bufferAfter = service.bufferTimeAfter ?? config.defaultBufferTime;
59
+ }
60
+ }
61
+ const availability = await checkAvailability({
62
+ blockingStatuses: config.statusMachine.blockingStatuses,
63
+ bufferAfter,
64
+ bufferBefore,
65
+ endTime: new Date(data.endTime),
66
+ getExternalBusy: config.getExternalBusy,
67
+ guestCount: data.guestCount ?? 1,
68
+ holdsSlug: config.slugs.holds,
69
+ payload: req.payload,
70
+ req,
71
+ reservationSlug: config.slugs.reservations,
72
+ resourceId,
73
+ resourceSlug: config.slugs.resources,
74
+ servicesSlug: config.slugs.services,
75
+ startTime: new Date(data.startTime)
76
+ });
77
+ if (!availability.available) {
78
+ throw new ValidationError({
79
+ errors: [
80
+ {
81
+ message: availability.reason ?? 'Slot is not available',
82
+ path: 'startTime'
83
+ }
84
+ ]
85
+ });
86
+ }
87
+ return data;
88
+ };
89
+
90
+ //# sourceMappingURL=validateHoldSlot.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/hooks/holds/validateHoldSlot.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { checkAvailability } from '../../services/AvailabilityService.js'\nimport { extractId } from '../../utilities/resolveReservationItems.js'\n\n/**\n * Serialize and validate a slot hold, inside the hold's own transaction.\n *\n * This deliberately lives in a `beforeChange` hook rather than in a service\n * function that calls the pieces in sequence. The lock only serializes anything\n * if the lock write, the availability read, and the insert all share one\n * transaction — and Payload opens that transaction around `create`, so a hook\n * is inside it while an orchestrating caller is not. An earlier version of this\n * ran the same three steps from a service function and granted 3 of 8\n * simultaneous holds for one slot; moved here, it grants exactly 1.\n */\nexport const validateHoldSlot =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, operation, req }) => {\n if (context?.skipReservationHooks || operation !== 'create') {\n return data\n }\n\n const resourceId = extractId(data.resource)\n const serviceId = extractId(data.service)\n\n if (resourceId === undefined || !data.startTime || !data.endTime) {\n return data\n }\n\n // Claim the resource first. Two simultaneous holds — or a hold racing a\n // booking — now collide on this one document and the database serializes\n // them. See acquireBookingLock for the full rationale.\n await req.payload.db.updateOne({\n id: resourceId,\n collection: config.slugs.resources,\n data: { bookingLock: `hold:${String(data.startTime)}` },\n req,\n returning: false,\n })\n\n let bufferBefore = config.defaultBufferTime\n let bufferAfter = config.defaultBufferTime\n\n if (serviceId !== undefined) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const service = await (req.payload.findByID as any)({\n id: serviceId,\n collection: config.slugs.services,\n depth: 0,\n joins: false,\n req,\n })\n\n if (service) {\n if (config.enforceActive && service.active === false) {\n throw new ValidationError({\n errors: [{ message: 'Service is not active', path: 'service' }],\n })\n }\n bufferBefore = (service.bufferTimeBefore as number) ?? config.defaultBufferTime\n bufferAfter = (service.bufferTimeAfter as number) ?? config.defaultBufferTime\n }\n }\n\n const availability = await checkAvailability({\n blockingStatuses: config.statusMachine.blockingStatuses,\n bufferAfter,\n bufferBefore,\n endTime: new Date(data.endTime as string),\n getExternalBusy: config.getExternalBusy,\n guestCount: (data.guestCount as number) ?? 1,\n holdsSlug: config.slugs.holds,\n payload: req.payload,\n req,\n reservationSlug: config.slugs.reservations,\n resourceId,\n resourceSlug: config.slugs.resources,\n servicesSlug: config.slugs.services,\n startTime: new Date(data.startTime as string),\n })\n\n if (!availability.available) {\n throw new ValidationError({\n errors: [\n { message: availability.reason ?? 'Slot is not available', path: 'startTime' },\n ],\n })\n }\n\n return data\n }\n"],"names":["ValidationError","checkAvailability","extractId","validateHoldSlot","config","context","data","operation","req","skipReservationHooks","resourceId","resource","serviceId","service","undefined","startTime","endTime","payload","db","updateOne","id","collection","slugs","resources","bookingLock","String","returning","bufferBefore","defaultBufferTime","bufferAfter","findByID","services","depth","joins","enforceActive","active","errors","message","path","bufferTimeBefore","bufferTimeAfter","availability","blockingStatuses","statusMachine","Date","getExternalBusy","guestCount","holdsSlug","holds","reservationSlug","reservations","resourceSlug","servicesSlug","available","reason"],"mappings":"AAEA,SAASA,eAAe,QAAQ,UAAS;AAIzC,SAASC,iBAAiB,QAAQ,wCAAuC;AACzE,SAASC,SAAS,QAAQ,6CAA4C;AAEtE;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,mBACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,GAAG,EAAE;QACtC,IAAIH,SAASI,wBAAwBF,cAAc,UAAU;YAC3D,OAAOD;QACT;QAEA,MAAMI,aAAaR,UAAUI,KAAKK,QAAQ;QAC1C,MAAMC,YAAYV,UAAUI,KAAKO,OAAO;QAExC,IAAIH,eAAeI,aAAa,CAACR,KAAKS,SAAS,IAAI,CAACT,KAAKU,OAAO,EAAE;YAChE,OAAOV;QACT;QAEA,wEAAwE;QACxE,yEAAyE;QACzE,uDAAuD;QACvD,MAAME,IAAIS,OAAO,CAACC,EAAE,CAACC,SAAS,CAAC;YAC7BC,IAAIV;YACJW,YAAYjB,OAAOkB,KAAK,CAACC,SAAS;YAClCjB,MAAM;gBAAEkB,aAAa,CAAC,KAAK,EAAEC,OAAOnB,KAAKS,SAAS,GAAG;YAAC;YACtDP;YACAkB,WAAW;QACb;QAEA,IAAIC,eAAevB,OAAOwB,iBAAiB;QAC3C,IAAIC,cAAczB,OAAOwB,iBAAiB;QAE1C,IAAIhB,cAAcE,WAAW;YAC3B,8DAA8D;YAC9D,MAAMD,UAAU,MAAM,AAACL,IAAIS,OAAO,CAACa,QAAQ,CAAS;gBAClDV,IAAIR;gBACJS,YAAYjB,OAAOkB,KAAK,CAACS,QAAQ;gBACjCC,OAAO;gBACPC,OAAO;gBACPzB;YACF;YAEA,IAAIK,SAAS;gBACX,IAAIT,OAAO8B,aAAa,IAAIrB,QAAQsB,MAAM,KAAK,OAAO;oBACpD,MAAM,IAAInC,gBAAgB;wBACxBoC,QAAQ;4BAAC;gCAAEC,SAAS;gCAAyBC,MAAM;4BAAU;yBAAE;oBACjE;gBACF;gBACAX,eAAe,AAACd,QAAQ0B,gBAAgB,IAAenC,OAAOwB,iBAAiB;gBAC/EC,cAAc,AAAChB,QAAQ2B,eAAe,IAAepC,OAAOwB,iBAAiB;YAC/E;QACF;QAEA,MAAMa,eAAe,MAAMxC,kBAAkB;YAC3CyC,kBAAkBtC,OAAOuC,aAAa,CAACD,gBAAgB;YACvDb;YACAF;YACAX,SAAS,IAAI4B,KAAKtC,KAAKU,OAAO;YAC9B6B,iBAAiBzC,OAAOyC,eAAe;YACvCC,YAAY,AAACxC,KAAKwC,UAAU,IAAe;YAC3CC,WAAW3C,OAAOkB,KAAK,CAAC0B,KAAK;YAC7B/B,SAAST,IAAIS,OAAO;YACpBT;YACAyC,iBAAiB7C,OAAOkB,KAAK,CAAC4B,YAAY;YAC1CxC;YACAyC,cAAc/C,OAAOkB,KAAK,CAACC,SAAS;YACpC6B,cAAchD,OAAOkB,KAAK,CAACS,QAAQ;YACnChB,WAAW,IAAI6B,KAAKtC,KAAKS,SAAS;QACpC;QAEA,IAAI,CAAC0B,aAAaY,SAAS,EAAE;YAC3B,MAAM,IAAIrD,gBAAgB;gBACxBoC,QAAQ;oBACN;wBAAEC,SAASI,aAAaa,MAAM,IAAI;wBAAyBhB,MAAM;oBAAY;iBAC9E;YACH;QACF;QAEA,OAAOhC;IACT,EAAC"}
@@ -0,0 +1,30 @@
1
+ import type { CollectionBeforeChangeHook } from 'payload';
2
+ import type { ResolvedReservationPluginConfig } from '../../types.js';
3
+ /**
4
+ * Serialize concurrent bookings that claim the same resource.
5
+ *
6
+ * `validateConflicts` is a read-then-write: it queries for overlapping
7
+ * reservations, then Payload inserts. Payload runs `create` inside a
8
+ * transaction, but transactional isolation does not help here — snapshot
9
+ * isolation lets N transactions each read "no conflict" and then insert N
10
+ * DIFFERENT documents. A database raises a write conflict only when two
11
+ * transactions touch the SAME document, and reservation rows are distinct.
12
+ * Measured: 10 simultaneous bookings for one `quantity: 1` slot produced 10
13
+ * confirmed reservations.
14
+ *
15
+ * This hook manufactures the missing contention. Before the conflict check
16
+ * reads, it writes a throwaway value to each claimed resource's `bookingLock`
17
+ * field on the booking's own transaction. Two bookings for one resource now
18
+ * collide on that single document, and the database serializes them:
19
+ *
20
+ * - MongoDB fails the loser fast with a WriteConflict; its transaction aborts
21
+ * and nothing is inserted.
22
+ * - Postgres/SQLite block the loser until the winner commits, then let it
23
+ * proceed — where `validateConflicts` now SEES the committed booking and
24
+ * rejects it through the normal path.
25
+ *
26
+ * Either way the outcome is correct. The write goes through the database
27
+ * adapter rather than the Local API deliberately: this must not fire Resources
28
+ * hooks, run its access control, or touch its version history.
29
+ */
30
+ export declare const acquireBookingLock: (config: ResolvedReservationPluginConfig) => CollectionBeforeChangeHook;
@@ -0,0 +1,71 @@
1
+ import { mergeReservationData, schedulingFieldsChanged } from '../../utilities/reservationChanges.js';
2
+ import { resolveReservationItems } from '../../utilities/resolveReservationItems.js';
3
+ /**
4
+ * Serialize concurrent bookings that claim the same resource.
5
+ *
6
+ * `validateConflicts` is a read-then-write: it queries for overlapping
7
+ * reservations, then Payload inserts. Payload runs `create` inside a
8
+ * transaction, but transactional isolation does not help here — snapshot
9
+ * isolation lets N transactions each read "no conflict" and then insert N
10
+ * DIFFERENT documents. A database raises a write conflict only when two
11
+ * transactions touch the SAME document, and reservation rows are distinct.
12
+ * Measured: 10 simultaneous bookings for one `quantity: 1` slot produced 10
13
+ * confirmed reservations.
14
+ *
15
+ * This hook manufactures the missing contention. Before the conflict check
16
+ * reads, it writes a throwaway value to each claimed resource's `bookingLock`
17
+ * field on the booking's own transaction. Two bookings for one resource now
18
+ * collide on that single document, and the database serializes them:
19
+ *
20
+ * - MongoDB fails the loser fast with a WriteConflict; its transaction aborts
21
+ * and nothing is inserted.
22
+ * - Postgres/SQLite block the loser until the winner commits, then let it
23
+ * proceed — where `validateConflicts` now SEES the committed booking and
24
+ * rejects it through the normal path.
25
+ *
26
+ * Either way the outcome is correct. The write goes through the database
27
+ * adapter rather than the Local API deliberately: this must not fire Resources
28
+ * hooks, run its access control, or touch its version history.
29
+ */ export const acquireBookingLock = (config)=>async ({ context, data, operation, originalDoc, req })=>{
30
+ if (context?.skipReservationHooks) {
31
+ return data;
32
+ }
33
+ // Mirror validateConflicts' guard exactly. A benign edit claims no resource
34
+ // time, so it needs no lock — and taking one would resolve items on a row
35
+ // whose items[] may hold a duplicate (resource, startTime) pair that
36
+ // resolveReservationItems rejects, breaking edits that must stay possible.
37
+ const isUpdate = operation === 'update';
38
+ if (isUpdate && !schedulingFieldsChanged({
39
+ blockingStatuses: config.statusMachine.blockingStatuses,
40
+ data: data,
41
+ originalDoc: originalDoc
42
+ })) {
43
+ return data;
44
+ }
45
+ const source = isUpdate ? mergeReservationData(data, originalDoc) : data;
46
+ const items = resolveReservationItems(source);
47
+ if (items.length === 0) {
48
+ return data;
49
+ }
50
+ // Sorted so two bookings claiming the same pair of resources always take
51
+ // them in the same order — otherwise A(r1,r2) and B(r2,r1) could deadlock
52
+ // on a blocking database.
53
+ const resourceIds = [
54
+ ...new Set(items.map((item)=>String(item.resource)))
55
+ ].sort();
56
+ const token = `${Date.now()}:${resourceIds.length}`;
57
+ for (const id of resourceIds){
58
+ await req.payload.db.updateOne({
59
+ id,
60
+ collection: config.slugs.resources,
61
+ data: {
62
+ bookingLock: token
63
+ },
64
+ req,
65
+ returning: false
66
+ });
67
+ }
68
+ return data;
69
+ };
70
+
71
+ //# sourceMappingURL=acquireBookingLock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/hooks/reservations/acquireBookingLock.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport {\n mergeReservationData,\n schedulingFieldsChanged,\n} from '../../utilities/reservationChanges.js'\nimport { resolveReservationItems } from '../../utilities/resolveReservationItems.js'\n\n/**\n * Serialize concurrent bookings that claim the same resource.\n *\n * `validateConflicts` is a read-then-write: it queries for overlapping\n * reservations, then Payload inserts. Payload runs `create` inside a\n * transaction, but transactional isolation does not help here — snapshot\n * isolation lets N transactions each read \"no conflict\" and then insert N\n * DIFFERENT documents. A database raises a write conflict only when two\n * transactions touch the SAME document, and reservation rows are distinct.\n * Measured: 10 simultaneous bookings for one `quantity: 1` slot produced 10\n * confirmed reservations.\n *\n * This hook manufactures the missing contention. Before the conflict check\n * reads, it writes a throwaway value to each claimed resource's `bookingLock`\n * field on the booking's own transaction. Two bookings for one resource now\n * collide on that single document, and the database serializes them:\n *\n * - MongoDB fails the loser fast with a WriteConflict; its transaction aborts\n * and nothing is inserted.\n * - Postgres/SQLite block the loser until the winner commits, then let it\n * proceed — where `validateConflicts` now SEES the committed booking and\n * rejects it through the normal path.\n *\n * Either way the outcome is correct. The write goes through the database\n * adapter rather than the Local API deliberately: this must not fire Resources\n * hooks, run its access control, or touch its version history.\n */\nexport const acquireBookingLock =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, operation, originalDoc, req }) => {\n if (context?.skipReservationHooks) {\n return data\n }\n\n // Mirror validateConflicts' guard exactly. A benign edit claims no resource\n // time, so it needs no lock — and taking one would resolve items on a row\n // whose items[] may hold a duplicate (resource, startTime) pair that\n // resolveReservationItems rejects, breaking edits that must stay possible.\n const isUpdate = operation === 'update'\n if (\n isUpdate &&\n !schedulingFieldsChanged({\n blockingStatuses: config.statusMachine.blockingStatuses,\n data: data as Record<string, unknown>,\n originalDoc: originalDoc as Record<string, unknown> | undefined,\n })\n ) {\n return data\n }\n\n const source =\n isUpdate\n ? mergeReservationData(\n data as Record<string, unknown>,\n originalDoc as Record<string, unknown> | undefined,\n )\n : (data as Record<string, unknown>)\n\n const items = resolveReservationItems(source)\n if (items.length === 0) {\n return data\n }\n\n // Sorted so two bookings claiming the same pair of resources always take\n // them in the same order — otherwise A(r1,r2) and B(r2,r1) could deadlock\n // on a blocking database.\n const resourceIds = [...new Set(items.map((item) => String(item.resource)))].sort()\n\n const token = `${Date.now()}:${resourceIds.length}`\n\n for (const id of resourceIds) {\n await req.payload.db.updateOne({\n id,\n collection: config.slugs.resources,\n data: { bookingLock: token },\n req,\n returning: false,\n })\n }\n\n return data\n }\n"],"names":["mergeReservationData","schedulingFieldsChanged","resolveReservationItems","acquireBookingLock","config","context","data","operation","originalDoc","req","skipReservationHooks","isUpdate","blockingStatuses","statusMachine","source","items","length","resourceIds","Set","map","item","String","resource","sort","token","Date","now","id","payload","db","updateOne","collection","slugs","resources","bookingLock","returning"],"mappings":"AAIA,SACEA,oBAAoB,EACpBC,uBAAuB,QAClB,wCAAuC;AAC9C,SAASC,uBAAuB,QAAQ,6CAA4C;AAEpF;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,MAAMC,qBACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,WAAW,EAAEC,GAAG,EAAE;QACnD,IAAIJ,SAASK,sBAAsB;YACjC,OAAOJ;QACT;QAEA,4EAA4E;QAC5E,0EAA0E;QAC1E,qEAAqE;QACrE,2EAA2E;QAC3E,MAAMK,WAAWJ,cAAc;QAC/B,IACEI,YACA,CAACV,wBAAwB;YACvBW,kBAAkBR,OAAOS,aAAa,CAACD,gBAAgB;YACvDN,MAAMA;YACNE,aAAaA;QACf,IACA;YACA,OAAOF;QACT;QAEA,MAAMQ,SACJH,WACIX,qBACEM,MACAE,eAEDF;QAEP,MAAMS,QAAQb,wBAAwBY;QACtC,IAAIC,MAAMC,MAAM,KAAK,GAAG;YACtB,OAAOV;QACT;QAEA,yEAAyE;QACzE,0EAA0E;QAC1E,0BAA0B;QAC1B,MAAMW,cAAc;eAAI,IAAIC,IAAIH,MAAMI,GAAG,CAAC,CAACC,OAASC,OAAOD,KAAKE,QAAQ;SAAI,CAACC,IAAI;QAEjF,MAAMC,QAAQ,GAAGC,KAAKC,GAAG,GAAG,CAAC,EAAET,YAAYD,MAAM,EAAE;QAEnD,KAAK,MAAMW,MAAMV,YAAa;YAC5B,MAAMR,IAAImB,OAAO,CAACC,EAAE,CAACC,SAAS,CAAC;gBAC7BH;gBACAI,YAAY3B,OAAO4B,KAAK,CAACC,SAAS;gBAClC3B,MAAM;oBAAE4B,aAAaV;gBAAM;gBAC3Bf;gBACA0B,WAAW;YACb;QACF;QAEA,OAAO7B;IACT,EAAC"}
@@ -22,13 +22,21 @@ export const calculateEndTime = (config)=>async ({ context, data, operation, ori
22
22
  return data;
23
23
  }
24
24
  const items = resolveReservationItems(merged);
25
- if (items.length <= 1) {
25
+ // Branch on REAL items only. A synthesised parent item (B1) must not flip a
26
+ // single-resource booking onto the multi-resource path — that would change
27
+ // which code computes endTime, which this fix deliberately does not touch.
28
+ const realItemCount = items.filter((i)=>!i.fromParent).length;
29
+ if (realItemCount <= 1) {
26
30
  // Single-resource: compute top-level endTime
27
31
  const serviceId = extractId(merged.service);
28
32
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
33
  const service = await req.payload.findByID({
30
34
  id: serviceId,
31
35
  collection: config.slugs.services,
36
+ depth: 0,
37
+ // Skip the resources join — internal logic never reads it, and without this
38
+ // every service read becomes an aggregation with a $lookup.
39
+ joins: false,
32
40
  req
33
41
  });
34
42
  if (!service?.duration && service?.durationType !== 'full-day') {
@@ -94,6 +102,10 @@ export const calculateEndTime = (config)=>async ({ context, data, operation, ori
94
102
  const service = await req.payload.findByID({
95
103
  id: itemServiceId,
96
104
  collection: config.slugs.services,
105
+ depth: 0,
106
+ // Skip the resources join — internal logic never reads it, and without this
107
+ // every service read becomes an aggregation with a $lookup.
108
+ joins: false,
97
109
  req
98
110
  });
99
111
  if (!service?.duration && service?.durationType !== 'full-day') {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/reservations/calculateEndTime.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { DurationType, ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { computeEndTime } from '../../services/AvailabilityService.js'\nimport {\n mergeReservationData,\n schedulingFieldsChanged,\n} from '../../utilities/reservationChanges.js'\nimport { extractId, resolveReservationItems } from '../../utilities/resolveReservationItems.js'\n\nexport const calculateEndTime =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, operation, originalDoc, req }) => {\n if (context?.skipReservationHooks) {\n return data\n }\n\n const isUpdate = operation === 'update'\n\n // Skip when an update touches no scheduling-relevant field — a notes or\n // status edit must not recompute (or invalidate) the stored times.\n if (\n isUpdate &&\n !schedulingFieldsChanged({\n blockingStatuses: config.statusMachine.blockingStatuses,\n data: data as Record<string, unknown>,\n originalDoc: originalDoc as Record<string, unknown> | undefined,\n })\n ) {\n return data\n }\n\n // On update `data` is a partial patch — compute from the merged document.\n const merged = isUpdate\n ? mergeReservationData(\n data as Record<string, unknown>,\n originalDoc as Record<string, unknown> | undefined,\n )\n : (data as Record<string, unknown>)\n\n if (!merged?.startTime || !merged?.service) {\n return data\n }\n\n const items = resolveReservationItems(merged)\n\n if (items.length <= 1) {\n // Single-resource: compute top-level endTime\n const serviceId = extractId(merged.service)\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const service = await (req.payload.findByID as any)({\n id: serviceId,\n collection: config.slugs.services,\n req,\n })\n\n if (!service?.duration && service?.durationType !== 'full-day') {\n return data\n }\n\n const durationType = ((service.durationType as string) ?? 'fixed') as DurationType\n const startDate = new Date(merged.startTime as string)\n\n if (durationType === 'flexible') {\n if (!merged.endTime) {\n throw new ValidationError({\n errors: [\n { message: 'endTime is required for flexible duration services', path: 'endTime' },\n ],\n })\n }\n // An inverted window would be invisible to overlap queries — reject it\n // (computeEndTime performs no validation for flexible durations).\n if (new Date(merged.endTime as string) <= startDate) {\n throw new ValidationError({\n errors: [{ message: 'endTime must be after startTime', path: 'endTime' }],\n })\n }\n } else {\n const result = computeEndTime({\n durationType,\n serviceDuration: (service.duration as number) ?? 0,\n startTime: startDate,\n timeZone: config.timezone,\n })\n data.endTime = result.endTime.toISOString()\n }\n } else {\n // Multi-resource: recompute only when the patch carries items[]. In\n // practice Payload backfills items from originalDoc on API updates, so\n // this guard mainly protects direct programmatic invocation; the\n // schedulingFieldsChanged gate above is the real skip for benign edits.\n // Rewriting items from a partial patch is A4 territory and out of scope.\n if (isUpdate && !data.items) {\n return data\n }\n\n // Compute endTime per item, then set a top-level endTime that spans all\n // items so conflict detection (which queries top-level startTime/endTime)\n // can see this reservation.\n let earliestStart: Date | undefined\n let latestEnd: Date | undefined\n for (const item of data.items as Array<Record<string, unknown>>) {\n if (!item.startTime) {\n continue\n }\n\n const itemServiceId = extractId(item.service) ?? extractId(merged.service)\n\n if (!itemServiceId) {\n continue\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const service = await (req.payload.findByID as any)({\n id: itemServiceId,\n collection: config.slugs.services,\n req,\n })\n\n if (!service?.duration && service?.durationType !== 'full-day') {\n continue\n }\n\n const durationType = ((service.durationType as string) ?? 'fixed') as DurationType\n\n if (durationType === 'flexible' && !item.endTime) {\n continue\n }\n\n if (durationType !== 'flexible') {\n const result = computeEndTime({\n durationType,\n serviceDuration: (service.duration as number) ?? 0,\n startTime: new Date(item.startTime as string),\n timeZone: config.timezone,\n })\n item.endTime = result.endTime.toISOString()\n }\n\n const start = new Date(item.startTime as string)\n if (!earliestStart || start < earliestStart) {\n earliestStart = start\n }\n\n if (item.endTime) {\n const end = new Date(item.endTime as string)\n if (!latestEnd || end > latestEnd) {\n latestEnd = end\n }\n }\n }\n\n if (earliestStart) {\n data.startTime = earliestStart.toISOString()\n }\n\n if (latestEnd) {\n data.endTime = latestEnd.toISOString()\n }\n }\n\n return data\n }\n"],"names":["ValidationError","computeEndTime","mergeReservationData","schedulingFieldsChanged","extractId","resolveReservationItems","calculateEndTime","config","context","data","operation","originalDoc","req","skipReservationHooks","isUpdate","blockingStatuses","statusMachine","merged","startTime","service","items","length","serviceId","payload","findByID","id","collection","slugs","services","duration","durationType","startDate","Date","endTime","errors","message","path","result","serviceDuration","timeZone","timezone","toISOString","earliestStart","latestEnd","item","itemServiceId","start","end"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../src/hooks/reservations/calculateEndTime.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { DurationType, ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { computeEndTime } from '../../services/AvailabilityService.js'\nimport {\n mergeReservationData,\n schedulingFieldsChanged,\n} from '../../utilities/reservationChanges.js'\nimport { extractId, resolveReservationItems } from '../../utilities/resolveReservationItems.js'\n\nexport const calculateEndTime =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, operation, originalDoc, req }) => {\n if (context?.skipReservationHooks) {\n return data\n }\n\n const isUpdate = operation === 'update'\n\n // Skip when an update touches no scheduling-relevant field — a notes or\n // status edit must not recompute (or invalidate) the stored times.\n if (\n isUpdate &&\n !schedulingFieldsChanged({\n blockingStatuses: config.statusMachine.blockingStatuses,\n data: data as Record<string, unknown>,\n originalDoc: originalDoc as Record<string, unknown> | undefined,\n })\n ) {\n return data\n }\n\n // On update `data` is a partial patch — compute from the merged document.\n const merged = isUpdate\n ? mergeReservationData(\n data as Record<string, unknown>,\n originalDoc as Record<string, unknown> | undefined,\n )\n : (data as Record<string, unknown>)\n\n if (!merged?.startTime || !merged?.service) {\n return data\n }\n\n const items = resolveReservationItems(merged)\n\n // Branch on REAL items only. A synthesised parent item (B1) must not flip a\n // single-resource booking onto the multi-resource path — that would change\n // which code computes endTime, which this fix deliberately does not touch.\n const realItemCount = items.filter((i) => !i.fromParent).length\n\n if (realItemCount <= 1) {\n // Single-resource: compute top-level endTime\n const serviceId = extractId(merged.service)\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const service = await (req.payload.findByID as any)({\n id: serviceId,\n collection: config.slugs.services,\n depth: 0,\n // Skip the resources join — internal logic never reads it, and without this\n // every service read becomes an aggregation with a $lookup.\n joins: false,\n req,\n })\n\n if (!service?.duration && service?.durationType !== 'full-day') {\n return data\n }\n\n const durationType = ((service.durationType as string) ?? 'fixed') as DurationType\n const startDate = new Date(merged.startTime as string)\n\n if (durationType === 'flexible') {\n if (!merged.endTime) {\n throw new ValidationError({\n errors: [\n { message: 'endTime is required for flexible duration services', path: 'endTime' },\n ],\n })\n }\n // An inverted window would be invisible to overlap queries — reject it\n // (computeEndTime performs no validation for flexible durations).\n if (new Date(merged.endTime as string) <= startDate) {\n throw new ValidationError({\n errors: [{ message: 'endTime must be after startTime', path: 'endTime' }],\n })\n }\n } else {\n const result = computeEndTime({\n durationType,\n serviceDuration: (service.duration as number) ?? 0,\n startTime: startDate,\n timeZone: config.timezone,\n })\n data.endTime = result.endTime.toISOString()\n }\n } else {\n // Multi-resource: recompute only when the patch carries items[]. In\n // practice Payload backfills items from originalDoc on API updates, so\n // this guard mainly protects direct programmatic invocation; the\n // schedulingFieldsChanged gate above is the real skip for benign edits.\n // Rewriting items from a partial patch is A4 territory and out of scope.\n if (isUpdate && !data.items) {\n return data\n }\n\n // Compute endTime per item, then set a top-level endTime that spans all\n // items so conflict detection (which queries top-level startTime/endTime)\n // can see this reservation.\n let earliestStart: Date | undefined\n let latestEnd: Date | undefined\n for (const item of data.items as Array<Record<string, unknown>>) {\n if (!item.startTime) {\n continue\n }\n\n const itemServiceId = extractId(item.service) ?? extractId(merged.service)\n\n if (!itemServiceId) {\n continue\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const service = await (req.payload.findByID as any)({\n id: itemServiceId,\n collection: config.slugs.services,\n depth: 0,\n // Skip the resources join — internal logic never reads it, and without this\n // every service read becomes an aggregation with a $lookup.\n joins: false,\n req,\n })\n\n if (!service?.duration && service?.durationType !== 'full-day') {\n continue\n }\n\n const durationType = ((service.durationType as string) ?? 'fixed') as DurationType\n\n if (durationType === 'flexible' && !item.endTime) {\n continue\n }\n\n if (durationType !== 'flexible') {\n const result = computeEndTime({\n durationType,\n serviceDuration: (service.duration as number) ?? 0,\n startTime: new Date(item.startTime as string),\n timeZone: config.timezone,\n })\n item.endTime = result.endTime.toISOString()\n }\n\n const start = new Date(item.startTime as string)\n if (!earliestStart || start < earliestStart) {\n earliestStart = start\n }\n\n if (item.endTime) {\n const end = new Date(item.endTime as string)\n if (!latestEnd || end > latestEnd) {\n latestEnd = end\n }\n }\n }\n\n if (earliestStart) {\n data.startTime = earliestStart.toISOString()\n }\n\n if (latestEnd) {\n data.endTime = latestEnd.toISOString()\n }\n }\n\n return data\n }\n"],"names":["ValidationError","computeEndTime","mergeReservationData","schedulingFieldsChanged","extractId","resolveReservationItems","calculateEndTime","config","context","data","operation","originalDoc","req","skipReservationHooks","isUpdate","blockingStatuses","statusMachine","merged","startTime","service","items","realItemCount","filter","i","fromParent","length","serviceId","payload","findByID","id","collection","slugs","services","depth","joins","duration","durationType","startDate","Date","endTime","errors","message","path","result","serviceDuration","timeZone","timezone","toISOString","earliestStart","latestEnd","item","itemServiceId","start","end"],"mappings":"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"}
@@ -22,6 +22,9 @@ import { extractId } from '../../utilities/resolveRequiredResources.js';
22
22
  id: serviceId,
23
23
  collection: config.slugs.services,
24
24
  depth: 0,
25
+ // Skip the resources join — internal logic never reads it, and without this
26
+ // every service read becomes an aggregation with a $lookup.
27
+ joins: false,
25
28
  req
26
29
  });
27
30
  required = (service?.requiredResources ?? []).map((r)=>extractId(r)).filter((r)=>r !== undefined);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/reservations/expandRequiredResources.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { extractId } from '../../utilities/resolveRequiredResources.js'\n\n/**\n * Expand a service's `requiredResources` into the reservation's `items[]` so the\n * booking actually occupies every required resource pool. Runs before\n * calculateEndTime and validateConflicts so the appended items get endTimes and\n * are conflict-checked.\n */\nexport const expandRequiredResources =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, operation, req }) => {\n if (context?.skipReservationHooks) {return data}\n if (operation !== 'create') {return data}\n\n const serviceId = extractId(data?.service)\n if (!serviceId || !data?.startTime) {return data}\n\n let required: Array<number | string> = []\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const service = await (req.payload.findByID as any)({\n id: serviceId,\n collection: config.slugs.services,\n depth: 0,\n req,\n })\n required = ((service?.requiredResources as unknown[]) ?? [])\n .map((r) => extractId(r))\n .filter((r): r is number | string => r !== undefined)\n } catch {\n return data\n }\n\n if (required.length === 0) {return data}\n\n const existingItems = Array.isArray(data.items)\n ? [...(data.items as Array<Record<string, unknown>>)]\n : []\n const present = new Set<string>()\n if (existingItems.length > 0) {\n for (const it of existingItems) {\n const r = extractId(it.resource)\n if (r !== undefined) {present.add(String(r))}\n }\n } else {\n const primary = extractId(data.resource)\n if (primary !== undefined) {present.add(String(primary))}\n }\n\n const additions = required.filter((r) => !present.has(String(r)))\n if (additions.length === 0) {return data}\n\n const items: Array<Record<string, unknown>> = [...existingItems]\n if (existingItems.length === 0) {\n const primary = extractId(data.resource)\n if (primary !== undefined) {\n items.push({\n endTime: data.endTime,\n resource: primary,\n service: serviceId,\n startTime: data.startTime,\n })\n }\n }\n\n for (const r of additions) {\n items.push({\n endTime: data.endTime,\n resource: r,\n service: serviceId,\n startTime: data.startTime,\n })\n }\n\n data.items = items\n return data\n }\n"],"names":["extractId","expandRequiredResources","config","context","data","operation","req","skipReservationHooks","serviceId","service","startTime","required","payload","findByID","id","collection","slugs","services","depth","requiredResources","map","r","filter","undefined","length","existingItems","Array","isArray","items","present","Set","it","resource","add","String","primary","additions","has","push","endTime"],"mappings":"AAIA,SAASA,SAAS,QAAQ,8CAA6C;AAEvE;;;;;CAKC,GACD,OAAO,MAAMC,0BACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,GAAG,EAAE;QACtC,IAAIH,SAASI,sBAAsB;YAAC,OAAOH;QAAI;QAC/C,IAAIC,cAAc,UAAU;YAAC,OAAOD;QAAI;QAExC,MAAMI,YAAYR,UAAUI,MAAMK;QAClC,IAAI,CAACD,aAAa,CAACJ,MAAMM,WAAW;YAAC,OAAON;QAAI;QAEhD,IAAIO,WAAmC,EAAE;QACzC,IAAI;YACF,8DAA8D;YAC9D,MAAMF,UAAU,MAAM,AAACH,IAAIM,OAAO,CAACC,QAAQ,CAAS;gBAClDC,IAAIN;gBACJO,YAAYb,OAAOc,KAAK,CAACC,QAAQ;gBACjCC,OAAO;gBACPZ;YACF;YACAK,WAAW,AAAC,CAAA,AAACF,SAASU,qBAAmC,EAAE,AAAD,EACvDC,GAAG,CAAC,CAACC,IAAMrB,UAAUqB,IACrBC,MAAM,CAAC,CAACD,IAA4BA,MAAME;QAC/C,EAAE,OAAM;YACN,OAAOnB;QACT;QAEA,IAAIO,SAASa,MAAM,KAAK,GAAG;YAAC,OAAOpB;QAAI;QAEvC,MAAMqB,gBAAgBC,MAAMC,OAAO,CAACvB,KAAKwB,KAAK,IAC1C;eAAKxB,KAAKwB,KAAK;SAAoC,GACnD,EAAE;QACN,MAAMC,UAAU,IAAIC;QACpB,IAAIL,cAAcD,MAAM,GAAG,GAAG;YAC5B,KAAK,MAAMO,MAAMN,cAAe;gBAC9B,MAAMJ,IAAIrB,UAAU+B,GAAGC,QAAQ;gBAC/B,IAAIX,MAAME,WAAW;oBAACM,QAAQI,GAAG,CAACC,OAAOb;gBAAG;YAC9C;QACF,OAAO;YACL,MAAMc,UAAUnC,UAAUI,KAAK4B,QAAQ;YACvC,IAAIG,YAAYZ,WAAW;gBAACM,QAAQI,GAAG,CAACC,OAAOC;YAAS;QAC1D;QAEA,MAAMC,YAAYzB,SAASW,MAAM,CAAC,CAACD,IAAM,CAACQ,QAAQQ,GAAG,CAACH,OAAOb;QAC7D,IAAIe,UAAUZ,MAAM,KAAK,GAAG;YAAC,OAAOpB;QAAI;QAExC,MAAMwB,QAAwC;eAAIH;SAAc;QAChE,IAAIA,cAAcD,MAAM,KAAK,GAAG;YAC9B,MAAMW,UAAUnC,UAAUI,KAAK4B,QAAQ;YACvC,IAAIG,YAAYZ,WAAW;gBACzBK,MAAMU,IAAI,CAAC;oBACTC,SAASnC,KAAKmC,OAAO;oBACrBP,UAAUG;oBACV1B,SAASD;oBACTE,WAAWN,KAAKM,SAAS;gBAC3B;YACF;QACF;QAEA,KAAK,MAAMW,KAAKe,UAAW;YACzBR,MAAMU,IAAI,CAAC;gBACTC,SAASnC,KAAKmC,OAAO;gBACrBP,UAAUX;gBACVZ,SAASD;gBACTE,WAAWN,KAAKM,SAAS;YAC3B;QACF;QAEAN,KAAKwB,KAAK,GAAGA;QACb,OAAOxB;IACT,EAAC"}
1
+ {"version":3,"sources":["../../../src/hooks/reservations/expandRequiredResources.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { extractId } from '../../utilities/resolveRequiredResources.js'\n\n/**\n * Expand a service's `requiredResources` into the reservation's `items[]` so the\n * booking actually occupies every required resource pool. Runs before\n * calculateEndTime and validateConflicts so the appended items get endTimes and\n * are conflict-checked.\n */\nexport const expandRequiredResources =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, operation, req }) => {\n if (context?.skipReservationHooks) {return data}\n if (operation !== 'create') {return data}\n\n const serviceId = extractId(data?.service)\n if (!serviceId || !data?.startTime) {return data}\n\n let required: Array<number | string> = []\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const service = await (req.payload.findByID as any)({\n id: serviceId,\n collection: config.slugs.services,\n depth: 0,\n // Skip the resources join — internal logic never reads it, and without this\n // every service read becomes an aggregation with a $lookup.\n joins: false,\n req,\n })\n required = ((service?.requiredResources as unknown[]) ?? [])\n .map((r) => extractId(r))\n .filter((r): r is number | string => r !== undefined)\n } catch {\n return data\n }\n\n if (required.length === 0) {return data}\n\n const existingItems = Array.isArray(data.items)\n ? [...(data.items as Array<Record<string, unknown>>)]\n : []\n const present = new Set<string>()\n if (existingItems.length > 0) {\n for (const it of existingItems) {\n const r = extractId(it.resource)\n if (r !== undefined) {present.add(String(r))}\n }\n } else {\n const primary = extractId(data.resource)\n if (primary !== undefined) {present.add(String(primary))}\n }\n\n const additions = required.filter((r) => !present.has(String(r)))\n if (additions.length === 0) {return data}\n\n const items: Array<Record<string, unknown>> = [...existingItems]\n if (existingItems.length === 0) {\n const primary = extractId(data.resource)\n if (primary !== undefined) {\n items.push({\n endTime: data.endTime,\n resource: primary,\n service: serviceId,\n startTime: data.startTime,\n })\n }\n }\n\n for (const r of additions) {\n items.push({\n endTime: data.endTime,\n resource: r,\n service: serviceId,\n startTime: data.startTime,\n })\n }\n\n data.items = items\n return data\n }\n"],"names":["extractId","expandRequiredResources","config","context","data","operation","req","skipReservationHooks","serviceId","service","startTime","required","payload","findByID","id","collection","slugs","services","depth","joins","requiredResources","map","r","filter","undefined","length","existingItems","Array","isArray","items","present","Set","it","resource","add","String","primary","additions","has","push","endTime"],"mappings":"AAIA,SAASA,SAAS,QAAQ,8CAA6C;AAEvE;;;;;CAKC,GACD,OAAO,MAAMC,0BACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,GAAG,EAAE;QACtC,IAAIH,SAASI,sBAAsB;YAAC,OAAOH;QAAI;QAC/C,IAAIC,cAAc,UAAU;YAAC,OAAOD;QAAI;QAExC,MAAMI,YAAYR,UAAUI,MAAMK;QAClC,IAAI,CAACD,aAAa,CAACJ,MAAMM,WAAW;YAAC,OAAON;QAAI;QAEhD,IAAIO,WAAmC,EAAE;QACzC,IAAI;YACF,8DAA8D;YAC9D,MAAMF,UAAU,MAAM,AAACH,IAAIM,OAAO,CAACC,QAAQ,CAAS;gBAClDC,IAAIN;gBACJO,YAAYb,OAAOc,KAAK,CAACC,QAAQ;gBACjCC,OAAO;gBACP,4EAA4E;gBAC5E,4DAA4D;gBAC5DC,OAAO;gBACPb;YACF;YACAK,WAAW,AAAC,CAAA,AAACF,SAASW,qBAAmC,EAAE,AAAD,EACvDC,GAAG,CAAC,CAACC,IAAMtB,UAAUsB,IACrBC,MAAM,CAAC,CAACD,IAA4BA,MAAME;QAC/C,EAAE,OAAM;YACN,OAAOpB;QACT;QAEA,IAAIO,SAASc,MAAM,KAAK,GAAG;YAAC,OAAOrB;QAAI;QAEvC,MAAMsB,gBAAgBC,MAAMC,OAAO,CAACxB,KAAKyB,KAAK,IAC1C;eAAKzB,KAAKyB,KAAK;SAAoC,GACnD,EAAE;QACN,MAAMC,UAAU,IAAIC;QACpB,IAAIL,cAAcD,MAAM,GAAG,GAAG;YAC5B,KAAK,MAAMO,MAAMN,cAAe;gBAC9B,MAAMJ,IAAItB,UAAUgC,GAAGC,QAAQ;gBAC/B,IAAIX,MAAME,WAAW;oBAACM,QAAQI,GAAG,CAACC,OAAOb;gBAAG;YAC9C;QACF,OAAO;YACL,MAAMc,UAAUpC,UAAUI,KAAK6B,QAAQ;YACvC,IAAIG,YAAYZ,WAAW;gBAACM,QAAQI,GAAG,CAACC,OAAOC;YAAS;QAC1D;QAEA,MAAMC,YAAY1B,SAASY,MAAM,CAAC,CAACD,IAAM,CAACQ,QAAQQ,GAAG,CAACH,OAAOb;QAC7D,IAAIe,UAAUZ,MAAM,KAAK,GAAG;YAAC,OAAOrB;QAAI;QAExC,MAAMyB,QAAwC;eAAIH;SAAc;QAChE,IAAIA,cAAcD,MAAM,KAAK,GAAG;YAC9B,MAAMW,UAAUpC,UAAUI,KAAK6B,QAAQ;YACvC,IAAIG,YAAYZ,WAAW;gBACzBK,MAAMU,IAAI,CAAC;oBACTC,SAASpC,KAAKoC,OAAO;oBACrBP,UAAUG;oBACV3B,SAASD;oBACTE,WAAWN,KAAKM,SAAS;gBAC3B;YACF;QACF;QAEA,KAAK,MAAMY,KAAKe,UAAW;YACzBR,MAAMU,IAAI,CAAC;gBACTC,SAASpC,KAAKoC,OAAO;gBACrBP,UAAUX;gBACVb,SAASD;gBACTE,WAAWN,KAAKM,SAAS;YAC3B;QACF;QAEAN,KAAKyB,KAAK,GAAGA;QACb,OAAOzB;IACT,EAAC"}