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.
- package/README.md +218 -4
- package/dist/collections/Holds.d.ts +18 -0
- package/dist/collections/Holds.js +113 -0
- package/dist/collections/Holds.js.map +1 -0
- package/dist/collections/Reservations.js +8 -0
- package/dist/collections/Reservations.js.map +1 -1
- package/dist/collections/Resources.js +35 -1
- package/dist/collections/Resources.js.map +1 -1
- package/dist/collections/Services.js +41 -0
- package/dist/collections/Services.js.map +1 -1
- package/dist/components/CalendarView/LaneTimelineView.js +4 -5
- package/dist/components/CalendarView/LaneTimelineView.js.map +1 -1
- package/dist/components/CalendarView/index.js +55 -71
- package/dist/components/CalendarView/index.js.map +1 -1
- package/dist/components/DashboardWidget/DashboardWidgetServer.js +11 -74
- package/dist/components/DashboardWidget/DashboardWidgetServer.js.map +1 -1
- package/dist/components/DashboardWidget/fetchDashboardStats.d.ts +42 -0
- package/dist/components/DashboardWidget/fetchDashboardStats.js +113 -0
- package/dist/components/DashboardWidget/fetchDashboardStats.js.map +1 -0
- package/dist/defaults.d.ts +2 -0
- package/dist/defaults.js +12 -0
- package/dist/defaults.js.map +1 -1
- package/dist/endpoints/cancelBooking.js +43 -14
- package/dist/endpoints/cancelBooking.js.map +1 -1
- package/dist/endpoints/checkAvailability.js +27 -1
- package/dist/endpoints/checkAvailability.js.map +1 -1
- package/dist/endpoints/createBooking.js +108 -7
- package/dist/endpoints/createBooking.js.map +1 -1
- package/dist/endpoints/customerSearch.js +6 -0
- package/dist/endpoints/customerSearch.js.map +1 -1
- package/dist/endpoints/effectiveTimezone.js +1 -0
- package/dist/endpoints/effectiveTimezone.js.map +1 -1
- package/dist/endpoints/getSlots.js +27 -1
- package/dist/endpoints/getSlots.js.map +1 -1
- package/dist/endpoints/holdSlot.d.ts +12 -0
- package/dist/endpoints/holdSlot.js +126 -0
- package/dist/endpoints/holdSlot.js.map +1 -0
- package/dist/endpoints/releaseSlot.d.ts +8 -0
- package/dist/endpoints/releaseSlot.js +34 -0
- package/dist/endpoints/releaseSlot.js.map +1 -0
- package/dist/endpoints/resourceAvailability.d.ts +4 -0
- package/dist/endpoints/resourceAvailability.js +105 -4
- package/dist/endpoints/resourceAvailability.js.map +1 -1
- package/dist/hooks/holds/validateHoldSlot.d.ts +14 -0
- package/dist/hooks/holds/validateHoldSlot.js +90 -0
- package/dist/hooks/holds/validateHoldSlot.js.map +1 -0
- package/dist/hooks/reservations/acquireBookingLock.d.ts +30 -0
- package/dist/hooks/reservations/acquireBookingLock.js +71 -0
- package/dist/hooks/reservations/acquireBookingLock.js.map +1 -0
- package/dist/hooks/reservations/calculateEndTime.js +13 -1
- package/dist/hooks/reservations/calculateEndTime.js.map +1 -1
- package/dist/hooks/reservations/expandRequiredResources.js +3 -0
- package/dist/hooks/reservations/expandRequiredResources.js.map +1 -1
- package/dist/hooks/reservations/validateActive.d.ts +11 -0
- package/dist/hooks/reservations/validateActive.js +103 -0
- package/dist/hooks/reservations/validateActive.js.map +1 -0
- package/dist/hooks/reservations/validateConflicts.js +22 -1
- package/dist/hooks/reservations/validateConflicts.js.map +1 -1
- package/dist/hooks/reservations/validateGuestBooking.js +3 -0
- package/dist/hooks/reservations/validateGuestBooking.js.map +1 -1
- package/dist/hooks/shared/preventDeleteWhenReferenced.d.ts +42 -0
- package/dist/hooks/shared/preventDeleteWhenReferenced.js +87 -0
- package/dist/hooks/shared/preventDeleteWhenReferenced.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js.map +1 -1
- package/dist/plugin.js +179 -3
- package/dist/plugin.js.map +1 -1
- package/dist/services/AvailabilityService.d.ts +51 -5
- package/dist/services/AvailabilityService.js +315 -38
- package/dist/services/AvailabilityService.js.map +1 -1
- package/dist/services/HoldService.d.ts +53 -0
- package/dist/services/HoldService.js +169 -0
- package/dist/services/HoldService.js.map +1 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/translations/ar.json +5 -1
- package/dist/translations/de.json +5 -1
- package/dist/translations/en.json +5 -1
- package/dist/translations/es.json +5 -1
- package/dist/translations/fa.json +5 -1
- package/dist/translations/fr.json +5 -1
- package/dist/translations/hi.json +5 -1
- package/dist/translations/id.json +5 -1
- package/dist/translations/pl.json +5 -1
- package/dist/translations/ru.json +5 -1
- package/dist/translations/tr.json +5 -1
- package/dist/translations/zh.json +5 -1
- package/dist/types.d.ts +32 -0
- package/dist/types.js.map +1 -1
- package/dist/utilities/calendarGrid.d.ts +44 -0
- package/dist/utilities/calendarGrid.js +67 -0
- package/dist/utilities/calendarGrid.js.map +1 -0
- package/dist/utilities/reservationChanges.d.ts +6 -1
- package/dist/utilities/reservationChanges.js +1 -1
- package/dist/utilities/reservationChanges.js.map +1 -1
- package/dist/utilities/reserveDebug.d.ts +26 -0
- package/dist/utilities/reserveDebug.js +35 -0
- package/dist/utilities/reserveDebug.js.map +1 -0
- package/dist/utilities/resolveReservationItems.d.ts +13 -2
- package/dist/utilities/resolveReservationItems.js +87 -2
- package/dist/utilities/resolveReservationItems.js.map +1 -1
- package/dist/utilities/retryOnWriteConflict.d.ts +66 -0
- package/dist/utilities/retryOnWriteConflict.js +135 -0
- package/dist/utilities/retryOnWriteConflict.js.map +1 -0
- package/dist/utilities/tenantTimezone.d.ts +77 -1
- package/dist/utilities/tenantTimezone.js +163 -2
- package/dist/utilities/tenantTimezone.js.map +1 -1
- package/dist/utilities/transactionSupport.d.ts +13 -0
- package/dist/utilities/transactionSupport.js +30 -0
- package/dist/utilities/transactionSupport.js.map +1 -0
- package/package.json +13 -13
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createReserveDebug, NOOP_RESERVE_DEBUG } from '../utilities/reserveDebug.js';
|
|
1
2
|
import { resolveScheduleForDate } from '../utilities/scheduleUtils.js';
|
|
2
3
|
import { readCookie } from '../utilities/tenantFilter.js';
|
|
3
4
|
import { getEffectiveTenantTimezone } from '../utilities/tenantTimezone.js';
|
|
@@ -5,7 +6,7 @@ import { addDaysToDayKey, combineDayKeyAndTime, getDayKeyInTimezone } from '../u
|
|
|
5
6
|
import { isPrivilegedUser } from '../utilities/userRoles.js';
|
|
6
7
|
const MAX_RANGE_MS = 90 * 86_400_000;
|
|
7
8
|
/** Busy intervals (with capacity units) for one resource over [start, end). */ async function busyFor(args) {
|
|
8
|
-
const { blockingStatuses, capacityMode, end, payload, reservationSlug, resourceId, start } = args;
|
|
9
|
+
const { blockingStatuses, capacityMode, end, holdsSlug, payload, reservationSlug, resourceId, start } = args;
|
|
9
10
|
const where = {
|
|
10
11
|
and: [
|
|
11
12
|
{
|
|
@@ -48,14 +49,62 @@ const MAX_RANGE_MS = 90 * 86_400_000;
|
|
|
48
49
|
limit: 0,
|
|
49
50
|
where
|
|
50
51
|
});
|
|
51
|
-
|
|
52
|
+
const busy = docs.filter((r)=>r.startTime && r.endTime).map((r)=>({
|
|
52
53
|
end: new Date(r.endTime).toISOString(),
|
|
53
54
|
start: new Date(r.startTime).toISOString(),
|
|
54
55
|
units: capacityMode === 'per-guest' ? r.guestCount ?? 1 : 1
|
|
55
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;
|
|
56
104
|
}
|
|
57
105
|
export async function buildResourceAvailability(params) {
|
|
58
|
-
const { blockingStatuses, 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;
|
|
107
|
+
const trace = debug ?? NOOP_RESERVE_DEBUG;
|
|
59
108
|
// depth 1 so `services` are populated (their `requiredResources` come back as ids)
|
|
60
109
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
110
|
const resource = await payload.findByID({
|
|
@@ -143,6 +192,7 @@ export async function buildResourceAvailability(params) {
|
|
|
143
192
|
blockingStatuses,
|
|
144
193
|
capacityMode,
|
|
145
194
|
end,
|
|
195
|
+
holdsSlug,
|
|
146
196
|
payload,
|
|
147
197
|
reservationSlug,
|
|
148
198
|
resourceId,
|
|
@@ -178,6 +228,7 @@ export async function buildResourceAvailability(params) {
|
|
|
178
228
|
blockingStatuses,
|
|
179
229
|
capacityMode: poolCapacityMode,
|
|
180
230
|
end,
|
|
231
|
+
holdsSlug,
|
|
181
232
|
payload,
|
|
182
233
|
reservationSlug,
|
|
183
234
|
resourceId: poolId,
|
|
@@ -197,7 +248,12 @@ export async function buildResourceAvailability(params) {
|
|
|
197
248
|
resourceId,
|
|
198
249
|
start
|
|
199
250
|
})).filter((iv)=>!isNaN(Date.parse(iv.start)) && !isNaN(Date.parse(iv.end)));
|
|
200
|
-
} catch
|
|
251
|
+
} catch (err) {
|
|
252
|
+
trace.dbg('error', {
|
|
253
|
+
err,
|
|
254
|
+
resourceId,
|
|
255
|
+
where: 'getExternalBusy'
|
|
256
|
+
});
|
|
201
257
|
external = [];
|
|
202
258
|
}
|
|
203
259
|
}
|
|
@@ -230,6 +286,7 @@ export function createResourceAvailabilityEndpoint(config) {
|
|
|
230
286
|
status: 403
|
|
231
287
|
});
|
|
232
288
|
}
|
|
289
|
+
const dbg = createReserveDebug(req.payload.logger, config.debug);
|
|
233
290
|
const url = new URL(req.url);
|
|
234
291
|
const resource = url.searchParams.get('resource');
|
|
235
292
|
const start = url.searchParams.get('start');
|
|
@@ -265,6 +322,31 @@ export function createResourceAvailabilityEndpoint(config) {
|
|
|
265
322
|
status: 400
|
|
266
323
|
});
|
|
267
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
|
+
}
|
|
268
350
|
// In multiTenant mode, resolve day-boundaries in the SELECTED tenant's zone
|
|
269
351
|
// (tenant timezone → global → UTC). Degrades to the global zone for plain
|
|
270
352
|
// installs: no tenant relationship on reservations / no tenant cookie ⇒ no
|
|
@@ -273,15 +355,25 @@ export function createResourceAvailabilityEndpoint(config) {
|
|
|
273
355
|
const timeZone = await getEffectiveTenantTimezone({
|
|
274
356
|
globalTimezone: config.timezone,
|
|
275
357
|
payload: req.payload,
|
|
358
|
+
req,
|
|
276
359
|
scopedCollection: reservationsCollection,
|
|
277
360
|
tenantField: config.multiTenant.tenantField,
|
|
278
361
|
tenantId: readCookie(req.headers?.get('cookie'), config.multiTenant.cookieName),
|
|
279
362
|
timezoneField: config.multiTenant.timezoneField
|
|
280
363
|
});
|
|
364
|
+
dbg.dbg('request', {
|
|
365
|
+
end,
|
|
366
|
+
endpoint: 'resource-availability',
|
|
367
|
+
resource,
|
|
368
|
+
start,
|
|
369
|
+
timeZone
|
|
370
|
+
});
|
|
281
371
|
const result = await buildResourceAvailability({
|
|
282
372
|
blockingStatuses: config.statusMachine.blockingStatuses,
|
|
373
|
+
debug: dbg,
|
|
283
374
|
end: endDate,
|
|
284
375
|
getExternalBusy: config.getExternalBusy,
|
|
376
|
+
holdsSlug: config.slotHolds.enabled ? config.slugs.holds : undefined,
|
|
285
377
|
payload: req.payload,
|
|
286
378
|
req,
|
|
287
379
|
reservationSlug: config.slugs.reservations,
|
|
@@ -292,12 +384,21 @@ export function createResourceAvailabilityEndpoint(config) {
|
|
|
292
384
|
timeZone
|
|
293
385
|
});
|
|
294
386
|
if (!result) {
|
|
387
|
+
dbg.dbg('response', {
|
|
388
|
+
endpoint: 'resource-availability',
|
|
389
|
+
reason: 'resource_not_found'
|
|
390
|
+
});
|
|
295
391
|
return Response.json({
|
|
296
392
|
error: 'Resource not found'
|
|
297
393
|
}, {
|
|
298
394
|
status: 404
|
|
299
395
|
});
|
|
300
396
|
}
|
|
397
|
+
dbg.dbg('response', {
|
|
398
|
+
dayCount: result.days.length,
|
|
399
|
+
endpoint: 'resource-availability',
|
|
400
|
+
externalCount: result.external.length
|
|
401
|
+
});
|
|
301
402
|
return Response.json(result);
|
|
302
403
|
},
|
|
303
404
|
method: 'get',
|
|
@@ -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 { 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 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 end,\n getExternalBusy,\n payload,\n req,\n reservationSlug,\n resourceId,\n resourceSlug,\n scheduleSlug,\n start,\n timeZone,\n } = params\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 {\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\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 const result = await buildResourceAvailability({\n blockingStatuses: config.statusMachine.blockingStatuses,\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 return Response.json({ error: 'Resource not found' }, { status: 404 })\n }\n\n return Response.json(result)\n },\n method: 'get',\n path: '/reserve/resource-availability',\n }\n}\n"],"names":["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","getExternalBusy","req","resourceSlug","scheduleSlug","timeZone","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","createResourceAvailabilityEndpoint","config","handler","user","Response","json","error","url","URL","searchParams","get","startDate","reservationsCollection","collections","c","slug","slugs","reservations","globalTimezone","timezone","scopedCollection","tenantField","multiTenant","tenantId","headers","cookieName","timezoneField","result","statusMachine","resources","method","path"],"mappings":"AAQA,SAASA,sBAAsB,QAAQ,gCAA+B;AACtE,SAASC,UAAU,QAAQ,+BAA8B;AACzD,SAASC,0BAA0B,QAAQ,iCAAgC;AAC3E,SACEC,eAAe,EACfC,oBAAoB,EACpBC,mBAAmB,QACd,gCAA+B;AACtC,SAASC,gBAAgB,QAAQ,4BAA2B;AAE5D,MAAMC,eAAe,KAAK;AAuB1B,6EAA6E,GAC7E,eAAeC,QAAQC,IAQtB;IACC,MAAM,EAAEC,gBAAgB,EAAEC,YAAY,EAAEC,GAAG,EAAEC,OAAO,EAAEC,eAAe,EAAEC,UAAU,EAAEC,KAAK,EAAE,GAAGP;IAC7F,MAAMQ,QAAe;QACnBC,KAAK;YACH;gBAAEC,QAAQ;oBAAEC,IAAIV;gBAAiB;YAAE;YACnC;gBAAEW,WAAW;oBAAEC,WAAWV,IAAIW,WAAW;gBAAG;YAAE;YAC9C;gBAAEC,SAAS;oBAAEC,cAAcT,MAAMO,WAAW;gBAAG;YAAE;YACjD;gBAAEG,IAAI;oBAAC;wBAAEC,UAAU;4BAAEC,QAAQb;wBAAW;oBAAE;oBAAG;wBAAE,kBAAkB;4BAAEa,QAAQb;wBAAW;oBAAE;iBAAE;YAAC;SAC5F;IACH;IACA,+EAA+E;IAC/E,6EAA6E;IAC7E,8DAA8D;IAC9D,MAAM,EAAEc,IAAI,EAAE,GAAG,MAAM,AAAChB,QAAQiB,IAAI,CAAS;QAC3CC,YAAYjB;QACZkB,OAAO;QACPC,OAAO;QACPhB;IACF;IACA,OAAO,AAACY,KACLK,MAAM,CAAC,CAACC,IAAMA,EAAEd,SAAS,IAAIc,EAAEX,OAAO,EACtCY,GAAG,CAAC,CAACD,IAAO,CAAA;YACXvB,KAAK,IAAIyB,KAAKF,EAAEX,OAAO,EAAYD,WAAW;YAC9CP,OAAO,IAAIqB,KAAKF,EAAEd,SAAS,EAAYE,WAAW;YAClDe,OAAO3B,iBAAiB,cAAe,AAACwB,EAAEI,UAAU,IAAe,IAAK;QAC1E,CAAA;AACJ;AAEA,OAAO,eAAeC,0BAA0BC,MAY/C;IACC,MAAM,EACJ/B,gBAAgB,EAChBE,GAAG,EACH8B,eAAe,EACf7B,OAAO,EACP8B,GAAG,EACH7B,eAAe,EACfC,UAAU,EACV6B,YAAY,EACZC,YAAY,EACZ7B,KAAK,EACL8B,QAAQ,EACT,GAAGL;IAEJ,mFAAmF;IACnF,8DAA8D;IAC9D,MAAMd,WAAW,MAAM,AAACd,QAAQkC,QAAQ,CAAS;QAC/CC,IAAIjC;QACJgB,YAAYa;QACZZ,OAAO;IACT,GAAGiB,KAAK,CAAC,IAAM;IACf,IAAI,CAACtB,UAAU;QACb,OAAO;IACT;IACA,MAAMuB,WAAW,AAACvB,UAAUuB,YAAuB;IACnD,MAAMvC,eACJ,AAACgB,UAAUhB,gBAAoD;IAEjE,8DAA8D;IAC9D,MAAM,EAAEkB,MAAMsB,SAAS,EAAE,GAAG,MAAM,AAACtC,QAAQiB,IAAI,CAAS;QACtDC,YAAYc;QACZb,OAAO;QACPC,OAAO;QACPhB,OAAO;YAAEC,KAAK;gBAAC;oBAAEkC,QAAQ;wBAAExB,QAAQ;oBAAK;gBAAE;gBAAG;oBAAED,UAAU;wBAAEC,QAAQb;oBAAW;gBAAE;aAAE;QAAC;IACrF;IASA,MAAMsC,OAA0B,EAAE;IAClC,MAAMC,WAAWjD,oBAAoBW,OAAO8B;IAC5C,MAAMS,UAAUlD,oBAAoB,IAAIgC,KAAKzB,IAAI4C,OAAO,KAAK,IAAIV;IACjE,IAAK,IAAIW,OAAOH,UAAUG,QAAQF,SAASE,OAAOtD,gBAAgBsD,MAAM,GAAI;QAC1E,MAAMC,eAAgD,EAAE;QACxD,MAAMC,UAAsC,EAAE;QAE9C,uEAAuE;QACvE,4EAA4E;QAC5E,wEAAwE;QACxE,IAAIC;QACJ,KAAK,MAAMC,SAASV,UAA6C;YAC/D,MAAMW,aAAa,AAACD,MAAMC,UAAU,IAAmC,EAAE;YACzE,KAAK,MAAMC,OAAOD,WAAY;gBAC5B,MAAME,WAAW3D,oBAAoB,IAAIgC,KAAK0B,IAAIN,IAAI,GAAGX;gBACzD,MAAMmB,SAASF,IAAIG,OAAO,GAAG7D,oBAAoB,IAAIgC,KAAK0B,IAAIG,OAAO,GAAGpB,YAAYkB;gBACpF,IAAIP,QAAQO,YAAYP,QAAQQ,QAAQ;oBACtCL,eAAeG;oBACf;gBACF;YACF;YACA,IAAIH,cAAc;gBAChB;YACF;QACF;QAEA,IAAIA,cAAc;YAChB,MAAMO,WAAW/D,qBAAqBqD,MAAM,SAASX;YACrD,MAAMsB,SAAS,IAAI/B,KAAKjC,qBAAqBqD,MAAM,SAASX,UAAUU,OAAO,KAAK;YAClFG,QAAQU,IAAI,CAAC;gBACXC,MAAMV,aAAaU,IAAI;gBACvB1D,KAAKwD,OAAO7C,WAAW;gBACvBgD,QAAQX,aAAaW,MAAM;gBAC3BvD,OAAOmD,SAAS5C,WAAW;YAC7B;QACF,OAAO;YACL,KAAK,MAAMsC,SAASV,UAA6C;gBAC/D,gFAAgF;gBAChF,MAAMqB,SAASxE,uBACb6D,OACAJ,MACAX;gBAEF,KAAK,MAAMX,KAAKqC,OAAQ;oBACtBd,aAAaW,IAAI,CAAC;wBAAEzD,KAAKuB,EAAEvB,GAAG,CAACW,WAAW;wBAAIP,OAAOmB,EAAEnB,KAAK,CAACO,WAAW;oBAAG;gBAC7E;YACF;QACF;QAEA8B,KAAKgB,IAAI,CAAC;YAAEZ;YAAMC;YAAcC;QAAQ;IAC1C;IAEA,MAAMc,OAAO,MAAMjE,QAAQ;QACzBE;QACAC;QACAC;QACAC;QACAC;QACAC;QACAC;IACF;IAEA,8EAA8E;IAC9E,4EAA4E;IAC5E,wEAAwE;IACxE,MAAM0D,UAAU,IAAIC;IACpB,KAAK,MAAMC,OAAO,AAACjD,UAAUkD,YAA+C,EAAE,CAAE;QAC9E,MAAMC,OAAO,AAAC,CAAA,OAAOF,QAAQ,WAAYA,IAAIG,iBAAiB,GAAiB,EAAE,AAAD,KAAM,EAAE;QACxF,KAAK,MAAMC,MAAMF,KAAM;YACrB,MAAM9B,KACJ,OAAOgC,OAAO,YAAYA,OAAO,OAC7B,AAACA,GAAgChC,EAAE,GAClCgC;YACP,IAAIhC,MAAM,QAAQiC,OAAOjC,QAAQiC,OAAOlE,aAAa;gBACnD2D,QAAQQ,GAAG,CAACD,OAAOjC;YACrB;QACF;IACF;IAEA,MAAMmC,gBAAuD,EAAE;IAC/D,KAAK,MAAMC,UAAUV,QAAS;QAC5B,8DAA8D;QAC9D,MAAMW,OAAO,MAAM,AAACxE,QAAQkC,QAAQ,CAAS;YAC3CC,IAAIoC;YACJrD,YAAYa;YACZZ,OAAO;QACT,GAAGiB,KAAK,CAAC,IAAM;QACf,IAAI,CAACoC,MAAM;YACT;QACF;QACA,MAAMC,mBACJ,AAACD,KAAK1E,YAAY,IAAwC;QAC5DwE,cAAcd,IAAI,CAAC;YACjBI,MAAM,MAAMjE,QAAQ;gBAClBE;gBACAC,cAAc2E;gBACd1E;gBACAC;gBACAC;gBACAC,YAAYqE;gBACZpE;YACF;YACAkC,UAAU,AAACmC,KAAKnC,QAAQ,IAAe;QACzC;IACF;IAEA,yEAAyE;IACzE,yDAAyD;IACzD,IAAIqC,WAAmC,EAAE;IACzC,IAAI7C,mBAAmBC,KAAK;QAC1B,IAAI;YACF4C,WAAW,AAAC,CAAA,MAAM7C,gBAAgB;gBAAE9B;gBAAK+B;gBAAK5B;gBAAYC;YAAM,EAAC,EAAGkB,MAAM,CACxE,CAACsD,KAAO,CAACC,MAAMpD,KAAKqD,KAAK,CAACF,GAAGxE,KAAK,MAAM,CAACyE,MAAMpD,KAAKqD,KAAK,CAACF,GAAG5E,GAAG;QAEpE,EAAE,OAAM;YACN2E,WAAW,EAAE;QACf;IACF;IAEA,OAAO;QAAEd;QAAM9D;QAAc0C;QAAMkC;QAAUrC;QAAUiC;QAAerC;IAAS;AACjF;AAEA,OAAO,SAAS6C,mCACdC,MAAuC;IAEvC,OAAO;QACLC,SAAS,OAAOlD;YACd,wEAAwE;YACxE,gCAAgC;YAChC,IAAI,CAACA,IAAImD,IAAI,EAAE;gBACb,OAAOC,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAe,GAAG;oBAAE9E,QAAQ;gBAAI;YAChE;YACA,IAAI,CAACb,iBAAiBqC,IAAImD,IAAI,EAAEF,SAAS;gBACvC,OAAOG,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAY,GAAG;oBAAE9E,QAAQ;gBAAI;YAC7D;YAEA,MAAM+E,MAAM,IAAIC,IAAIxD,IAAIuD,GAAG;YAC3B,MAAMvE,WAAWuE,IAAIE,YAAY,CAACC,GAAG,CAAC;YACtC,MAAMrF,QAAQkF,IAAIE,YAAY,CAACC,GAAG,CAAC;YACnC,MAAMzF,MAAMsF,IAAIE,YAAY,CAACC,GAAG,CAAC;YAEjC,IAAI,CAAC1E,YAAY,CAACX,SAAS,CAACJ,KAAK;gBAC/B,OAAOmF,SAASC,IAAI,CAClB;oBAAEC,OAAO;gBAAsD,GAC/D;oBAAE9E,QAAQ;gBAAI;YAElB;YAEA,MAAMmF,YAAY,IAAIjE,KAAKrB;YAC3B,MAAMkD,UAAU,IAAI7B,KAAKzB;YACzB,IAAI6E,MAAMa,UAAU9C,OAAO,OAAOiC,MAAMvB,QAAQV,OAAO,KAAK;gBAC1D,OAAOuC,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAyB,GAAG;oBAAE9E,QAAQ;gBAAI;YAC1E;YACA,IAAI+C,WAAWoC,WAAW;gBACxB,OAAOP,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAA0B,GAAG;oBAAE9E,QAAQ;gBAAI;YAC3E;YACA,oEAAoE;YACpE,IAAI+C,QAAQV,OAAO,KAAK8C,UAAU9C,OAAO,KAAKjD,cAAc;gBAC1D,OAAOwF,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAqC,GAAG;oBAAE9E,QAAQ;gBAAI;YACtF;YAEA,4EAA4E;YAC5E,0EAA0E;YAC1E,2EAA2E;YAC3E,kCAAkC;YAClC,MAAMoF,yBAAyB5D,IAAI9B,OAAO,CAAC+E,MAAM,CAACY,WAAW,EAAE1E,KAC7D,CAAC2E,IAAMA,EAAEC,IAAI,KAAKd,OAAOe,KAAK,CAACC,YAAY;YAE7C,MAAM9D,WAAW,MAAM5C,2BAA2B;gBAChD2G,gBAAgBjB,OAAOkB,QAAQ;gBAC/BjG,SAAS8B,IAAI9B,OAAO;gBACpBkG,kBAAkBR;gBAClBS,aAAapB,OAAOqB,WAAW,CAACD,WAAW;gBAC3CE,UAAUjH,WAAW0C,IAAIwE,OAAO,EAAEd,IAAI,WAAWT,OAAOqB,WAAW,CAACG,UAAU;gBAC9EC,eAAezB,OAAOqB,WAAW,CAACI,aAAa;YACjD;YAEA,MAAMC,SAAS,MAAM9E,0BAA0B;gBAC7C9B,kBAAkBkF,OAAO2B,aAAa,CAAC7G,gBAAgB;gBACvDE,KAAKsD;gBACLxB,iBAAiBkD,OAAOlD,eAAe;gBACvC7B,SAAS8B,IAAI9B,OAAO;gBACpB8B;gBACA7B,iBAAiB8E,OAAOe,KAAK,CAACC,YAAY;gBAC1C7F,YAAYY;gBACZiB,cAAcgD,OAAOe,KAAK,CAACa,SAAS;gBACpC3E,cAAc+C,OAAOe,KAAK,CAACxD,SAAS;gBACpCnC,OAAOsF;gBACPxD;YACF;YAEA,IAAI,CAACwE,QAAQ;gBACX,OAAOvB,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAqB,GAAG;oBAAE9E,QAAQ;gBAAI;YACtE;YAEA,OAAO4E,SAASC,IAAI,CAACsB;QACvB;QACAG,QAAQ;QACRC,MAAM;IACR;AACF"}
|
|
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":"AAQA,SACEA,kBAAkB,EAClBC,kBAAkB,QAEb,+BAA8B;AACrC,SAASC,sBAAsB,QAAQ,gCAA+B;AACtE,SAASC,UAAU,QAAQ,+BAA8B;AACzD,SAASC,0BAA0B,QAAQ,iCAAgC;AAC3E,SACEC,eAAe,EACfC,oBAAoB,EACpBC,mBAAmB,QACd,gCAA+B;AACtC,SAASC,gBAAgB,QAAQ,4BAA2B;AAE5D,MAAMC,eAAe,KAAK;AAuB1B,6EAA6E,GAC7E,eAAeC,QAAQC,IAUtB;IACC,MAAM,EACJC,gBAAgB,EAChBC,YAAY,EACZC,GAAG,EACHC,SAAS,EACTC,OAAO,EACPC,eAAe,EACfC,UAAU,EACVC,KAAK,EACN,GAAGR;IACJ,MAAMS,QAAe;QACnBC,KAAK;YACH;gBAAEC,QAAQ;oBAAEC,IAAIX;gBAAiB;YAAE;YACnC;gBAAEY,WAAW;oBAAEC,WAAWX,IAAIY,WAAW;gBAAG;YAAE;YAC9C;gBAAEC,SAAS;oBAAEC,cAAcT,MAAMO,WAAW;gBAAG;YAAE;YACjD;gBAAEG,IAAI;oBAAC;wBAAEC,UAAU;4BAAEC,QAAQb;wBAAW;oBAAE;oBAAG;wBAAE,kBAAkB;4BAAEa,QAAQb;wBAAW;oBAAE;iBAAE;YAAC;SAC5F;IACH;IACA,+EAA+E;IAC/E,6EAA6E;IAC7E,8DAA8D;IAC9D,MAAM,EAAEc,IAAI,EAAE,GAAG,MAAM,AAAChB,QAAQiB,IAAI,CAAS;QAC3CC,YAAYjB;QACZkB,OAAO;QACPC,OAAO;QACPhB;IACF;IACA,MAAMiB,OAAa,AAACL,KACjBM,MAAM,CAAC,CAACC,IAAMA,EAAEf,SAAS,IAAIe,EAAEZ,OAAO,EACtCa,GAAG,CAAC,CAACD,IAAO,CAAA;YACXzB,KAAK,IAAI2B,KAAKF,EAAEZ,OAAO,EAAYD,WAAW;YAC9CP,OAAO,IAAIsB,KAAKF,EAAEf,SAAS,EAAYE,WAAW;YAClDgB,OAAO7B,iBAAiB,cAAe,AAAC0B,EAAEI,UAAU,IAAe,IAAK;QAC1E,CAAA;IAEF,4EAA4E;IAC5E,+EAA+E;IAC/E,6EAA6E;IAC7E,gCAAgC;IAChC,IAAI5B,WAAW;QACb,8DAA8D;QAC9D,MAAM,EAAEiB,MAAMY,QAAQ,EAAE,GAAG,MAAM,AAAC5B,QAAQiB,IAAI,CAAS;YACrDC,YAAYnB;YACZoB,OAAO;YACPC,OAAO;YACPhB,OAAO;gBACLC,KAAK;oBACH;wBAAES,UAAU;4BAAEC,QAAQb;wBAAW;oBAAE;oBACnC;wBAAE2B,WAAW;4BAAEjB,cAAc,IAAIa,OAAOf,WAAW;wBAAG;oBAAE;oBACxD;wBAAEF,WAAW;4BAAEC,WAAWX,IAAIY,WAAW;wBAAG;oBAAE;oBAC9C;wBAAEC,SAAS;4BAAEC,cAAcT,MAAMO,WAAW;wBAAG;oBAAE;iBAClD;YACH;QACF;QACA,KAAK,MAAMoB,QAAQF,SAA4C;YAC7D,IAAI,CAACE,KAAKtB,SAAS,IAAI,CAACsB,KAAKnB,OAAO,EAAE;gBACpC;YACF;YACAU,KAAKU,IAAI,CAAC;gBACRjC,KAAK,IAAI2B,KAAKK,KAAKnB,OAAO,EAAYD,WAAW;gBACjDP,OAAO,IAAIsB,KAAKK,KAAKtB,SAAS,EAAYE,WAAW;gBACrDgB,OAAO7B,iBAAiB,cAAe,AAACiC,KAAKH,UAAU,IAAe,IAAK;YAC7E;QACF;IACF;IAEA,OAAON;AACT;AAEA,OAAO,eAAeW,0BAA0BC,MAe/C;IACC,MAAM,EACJrC,gBAAgB,EAChBsC,KAAK,EACLpC,GAAG,EACHqC,eAAe,EACfpC,SAAS,EACTC,OAAO,EACPoC,GAAG,EACHnC,eAAe,EACfC,UAAU,EACVmC,YAAY,EACZC,YAAY,EACZnC,KAAK,EACLoC,QAAQ,EACT,GAAGN;IACJ,MAAMO,QAAQN,SAASjD;IAEvB,mFAAmF;IACnF,8DAA8D;IAC9D,MAAM6B,WAAW,MAAM,AAACd,QAAQyC,QAAQ,CAAS;QAC/CC,IAAIxC;QACJgB,YAAYmB;QACZlB,OAAO;IACT,GAAGwB,KAAK,CAAC,IAAM;IACf,IAAI,CAAC7B,UAAU;QACb,OAAO;IACT;IACA,MAAM8B,WAAW,AAAC9B,UAAU8B,YAAuB;IACnD,MAAM/C,eACJ,AAACiB,UAAUjB,gBAAoD;IAEjE,8DAA8D;IAC9D,MAAM,EAAEmB,MAAM6B,SAAS,EAAE,GAAG,MAAM,AAAC7C,QAAQiB,IAAI,CAAS;QACtDC,YAAYoB;QACZnB,OAAO;QACPC,OAAO;QACPhB,OAAO;YAAEC,KAAK;gBAAC;oBAAEyC,QAAQ;wBAAE/B,QAAQ;oBAAK;gBAAE;gBAAG;oBAAED,UAAU;wBAAEC,QAAQb;oBAAW;gBAAE;aAAE;QAAC;IACrF;IASA,MAAM6C,OAA0B,EAAE;IAClC,MAAMC,WAAWzD,oBAAoBY,OAAOoC;IAC5C,MAAMU,UAAU1D,oBAAoB,IAAIkC,KAAK3B,IAAIoD,OAAO,KAAK,IAAIX;IACjE,IAAK,IAAIY,OAAOH,UAAUG,QAAQF,SAASE,OAAO9D,gBAAgB8D,MAAM,GAAI;QAC1E,MAAMC,eAAgD,EAAE;QACxD,MAAMC,UAAsC,EAAE;QAE9C,uEAAuE;QACvE,4EAA4E;QAC5E,wEAAwE;QACxE,IAAIC;QACJ,KAAK,MAAMC,SAASV,UAA6C;YAC/D,MAAMW,aAAa,AAACD,MAAMC,UAAU,IAAmC,EAAE;YACzE,KAAK,MAAMC,OAAOD,WAAY;gBAC5B,MAAME,WAAWnE,oBAAoB,IAAIkC,KAAKgC,IAAIN,IAAI,GAAGZ;gBACzD,MAAMoB,SAASF,IAAIG,OAAO,GAAGrE,oBAAoB,IAAIkC,KAAKgC,IAAIG,OAAO,GAAGrB,YAAYmB;gBACpF,IAAIP,QAAQO,YAAYP,QAAQQ,QAAQ;oBACtCL,eAAeG;oBACf;gBACF;YACF;YACA,IAAIH,cAAc;gBAChB;YACF;QACF;QAEA,IAAIA,cAAc;YAChB,MAAMO,WAAWvE,qBAAqB6D,MAAM,SAASZ;YACrD,MAAMuB,SAAS,IAAIrC,KAAKnC,qBAAqB6D,MAAM,SAASZ,UAAUW,OAAO,KAAK;YAClFG,QAAQtB,IAAI,CAAC;gBACXgC,MAAMT,aAAaS,IAAI;gBACvBjE,KAAKgE,OAAOpD,WAAW;gBACvBsD,QAAQV,aAAaU,MAAM;gBAC3B7D,OAAO0D,SAASnD,WAAW;YAC7B;QACF,OAAO;YACL,KAAK,MAAM6C,SAASV,UAA6C;gBAC/D,gFAAgF;gBAChF,MAAMoB,SAAS/E,uBACbqE,OACAJ,MACAZ;gBAEF,KAAK,MAAMhB,KAAK0C,OAAQ;oBACtBb,aAAarB,IAAI,CAAC;wBAAEjC,KAAKyB,EAAEzB,GAAG,CAACY,WAAW;wBAAIP,OAAOoB,EAAEpB,KAAK,CAACO,WAAW;oBAAG;gBAC7E;YACF;QACF;QAEAqC,KAAKhB,IAAI,CAAC;YAAEoB;YAAMC;YAAcC;QAAQ;IAC1C;IAEA,MAAMhC,OAAO,MAAM3B,QAAQ;QACzBE;QACAC;QACAC;QACAC;QACAC;QACAC;QACAC;QACAC;IACF;IAEA,8EAA8E;IAC9E,4EAA4E;IAC5E,wEAAwE;IACxE,MAAM+D,UAAU,IAAIC;IACpB,KAAK,MAAMC,OAAO,AAACtD,UAAUuD,YAA+C,EAAE,CAAE;QAC9E,MAAMC,OAAO,AAAC,CAAA,OAAOF,QAAQ,WAAYA,IAAIG,iBAAiB,GAAiB,EAAE,AAAD,KAAM,EAAE;QACxF,KAAK,MAAMC,MAAMF,KAAM;YACrB,MAAM5B,KACJ,OAAO8B,OAAO,YAAYA,OAAO,OAC7B,AAACA,GAAgC9B,EAAE,GAClC8B;YACP,IAAI9B,MAAM,QAAQ+B,OAAO/B,QAAQ+B,OAAOvE,aAAa;gBACnDgE,QAAQQ,GAAG,CAACD,OAAO/B;YACrB;QACF;IACF;IAEA,MAAMiC,gBAAuD,EAAE;IAC/D,KAAK,MAAMC,UAAUV,QAAS;QAC5B,8DAA8D;QAC9D,MAAMW,OAAO,MAAM,AAAC7E,QAAQyC,QAAQ,CAAS;YAC3CC,IAAIkC;YACJ1D,YAAYmB;YACZlB,OAAO;QACT,GAAGwB,KAAK,CAAC,IAAM;QACf,IAAI,CAACkC,MAAM;YACT;QACF;QACA,MAAMC,mBACJ,AAACD,KAAKhF,YAAY,IAAwC;QAC5D8E,cAAc5C,IAAI,CAAC;YACjBV,MAAM,MAAM3B,QAAQ;gBAClBE;gBACAC,cAAciF;gBACdhF;gBACAC;gBACAC;gBACAC;gBACAC,YAAY0E;gBACZzE;YACF;YACAyC,UAAU,AAACiC,KAAKjC,QAAQ,IAAe;QACzC;IACF;IAEA,yEAAyE;IACzE,yDAAyD;IACzD,IAAImC,WAAmC,EAAE;IACzC,IAAI5C,mBAAmBC,KAAK;QAC1B,IAAI;YACF2C,WAAW,AAAC,CAAA,MAAM5C,gBAAgB;gBAAErC;gBAAKsC;gBAAKlC;gBAAYC;YAAM,EAAC,EAAGmB,MAAM,CACxE,CAAC0D,KAAO,CAACC,MAAMxD,KAAKyD,KAAK,CAACF,GAAG7E,KAAK,MAAM,CAAC8E,MAAMxD,KAAKyD,KAAK,CAACF,GAAGlF,GAAG;QAEpE,EAAE,OAAOqF,KAAK;YACZ3C,MAAM4C,GAAG,CAAC,SAAS;gBAAED;gBAAKjF;gBAAYE,OAAO;YAAkB;YAC/D2E,WAAW,EAAE;QACf;IACF;IAEA,OAAO;QAAE1D;QAAMxB;QAAckD;QAAMgC;QAAUnC;QAAU+B;QAAepC;IAAS;AACjF;AAEA,OAAO,SAAS8C,mCACdC,MAAuC;IAEvC,OAAO;QACLC,SAAS,OAAOnD;YACd,wEAAwE;YACxE,gCAAgC;YAChC,IAAI,CAACA,IAAIoD,IAAI,EAAE;gBACb,OAAOC,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAe,GAAG;oBAAErF,QAAQ;gBAAI;YAChE;YACA,IAAI,CAACd,iBAAiB4C,IAAIoD,IAAI,EAAEF,SAAS;gBACvC,OAAOG,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAY,GAAG;oBAAErF,QAAQ;gBAAI;YAC7D;YACA,MAAM8E,MAAMpG,mBAAmBoD,IAAIpC,OAAO,CAAC4F,MAAM,EAAEN,OAAOpD,KAAK;YAE/D,MAAM2D,MAAM,IAAIC,IAAI1D,IAAIyD,GAAG;YAC3B,MAAM/E,WAAW+E,IAAIE,YAAY,CAACC,GAAG,CAAC;YACtC,MAAM7F,QAAQ0F,IAAIE,YAAY,CAACC,GAAG,CAAC;YACnC,MAAMlG,MAAM+F,IAAIE,YAAY,CAACC,GAAG,CAAC;YAEjC,IAAI,CAAClF,YAAY,CAACX,SAAS,CAACL,KAAK;gBAC/B,OAAO2F,SAASC,IAAI,CAClB;oBAAEC,OAAO;gBAAsD,GAC/D;oBAAErF,QAAQ;gBAAI;YAElB;YAEA,MAAM2F,YAAY,IAAIxE,KAAKtB;YAC3B,MAAMyD,UAAU,IAAInC,KAAK3B;YACzB,IAAImF,MAAMgB,UAAU/C,OAAO,OAAO+B,MAAMrB,QAAQV,OAAO,KAAK;gBAC1D,OAAOuC,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAyB,GAAG;oBAAErF,QAAQ;gBAAI;YAC1E;YACA,IAAIsD,WAAWqC,WAAW;gBACxB,OAAOR,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAA0B,GAAG;oBAAErF,QAAQ;gBAAI;YAC3E;YACA,oEAAoE;YACpE,IAAIsD,QAAQV,OAAO,KAAK+C,UAAU/C,OAAO,KAAKzD,cAAc;gBAC1D,OAAOgG,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAqC,GAAG;oBAAErF,QAAQ;gBAAI;YACtF;YAEA,uEAAuE;YACvE,yEAAyE;YACzE,yEAAyE;YACzE,wEAAwE;YACxE,wEAAwE;YACxE,uEAAuE;YACvE,8DAA8D;YAC9D,MAAM4F,YAAY,MAAM,AAAC9D,IAAIpC,OAAO,CAACyC,QAAQ,CAAS;gBACpDC,IAAI5B;gBACJI,YAAYoE,OAAOa,KAAK,CAACC,SAAS;gBAClCjF,OAAO;gBACPkF,gBAAgB;gBAChBjE;YACF,GAAGO,KAAK,CAAC,IAAM;YAEf,IAAI,CAACuD,WAAW;gBACdd,IAAIA,GAAG,CAAC,YAAY;oBAAEkB,UAAU;oBAAyBtC,QAAQ;gBAAqB;gBACtF,OAAOyB,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAqB,GAAG;oBAAErF,QAAQ;gBAAI;YACtE;YAEA,4EAA4E;YAC5E,0EAA0E;YAC1E,2EAA2E;YAC3E,kCAAkC;YAClC,MAAMiG,yBAAyBnE,IAAIpC,OAAO,CAACsF,MAAM,CAACkB,WAAW,EAAEvF,KAC7D,CAACwF,IAAMA,EAAEC,IAAI,KAAKpB,OAAOa,KAAK,CAACQ,YAAY;YAE7C,MAAMpE,WAAW,MAAMnD,2BAA2B;gBAChDwH,gBAAgBtB,OAAOuB,QAAQ;gBAC/B7G,SAASoC,IAAIpC,OAAO;gBACpBoC;gBACA0E,kBAAkBP;gBAClBQ,aAAazB,OAAO0B,WAAW,CAACD,WAAW;gBAC3CE,UAAU9H,WAAWiD,IAAI8E,OAAO,EAAElB,IAAI,WAAWV,OAAO0B,WAAW,CAACG,UAAU;gBAC9EC,eAAe9B,OAAO0B,WAAW,CAACI,aAAa;YACjD;YAEAhC,IAAIA,GAAG,CAAC,WAAW;gBAAEtF;gBAAKwG,UAAU;gBAAyBxF;gBAAUX;gBAAOoC;YAAS;YAEvF,MAAM8E,SAAS,MAAMrF,0BAA0B;gBAC7CpC,kBAAkB0F,OAAOgC,aAAa,CAAC1H,gBAAgB;gBACvDsC,OAAOkD;gBACPtF,KAAK8D;gBACLzB,iBAAiBmD,OAAOnD,eAAe;gBACvCpC,WAAWuF,OAAOiC,SAAS,CAACC,OAAO,GAAGlC,OAAOa,KAAK,CAACsB,KAAK,GAAGC;gBAC3D1H,SAASoC,IAAIpC,OAAO;gBACpBoC;gBACAnC,iBAAiBqF,OAAOa,KAAK,CAACQ,YAAY;gBAC1CzG,YAAYY;gBACZuB,cAAciD,OAAOa,KAAK,CAACC,SAAS;gBACpC9D,cAAcgD,OAAOa,KAAK,CAACtD,SAAS;gBACpC1C,OAAO8F;gBACP1D;YACF;YAEA,IAAI,CAAC8E,QAAQ;gBACXjC,IAAIA,GAAG,CAAC,YAAY;oBAAEkB,UAAU;oBAAyBtC,QAAQ;gBAAqB;gBACtF,OAAOyB,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAqB,GAAG;oBAAErF,QAAQ;gBAAI;YACtE;YAEA8E,IAAIA,GAAG,CAAC,YAAY;gBAClBuC,UAAUN,OAAOtE,IAAI,CAAC6E,MAAM;gBAC5BtB,UAAU;gBACVuB,eAAeR,OAAOtC,QAAQ,CAAC6C,MAAM;YACvC;YACA,OAAOnC,SAASC,IAAI,CAAC2B;QACvB;QACAS,QAAQ;QACRC,MAAM;IACR;AACF"}
|
|
@@ -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
|
-
|
|
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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"}
|