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
package/README.md CHANGED
@@ -27,10 +27,15 @@ Designed for salons, clinics, hotels, restaurants, event venues, and any busines
27
27
  - **Guest Bookings** — Account-less reservations with inline contact details (name + email/phone); `allowGuestBooking` plugin option and per-service `inherit`/`enabled`/`disabled` override; guests receive a `cancellationToken` via the `afterBookingCreate` hook for cancel-link delivery
28
28
  - **Idempotency** — Optional `idempotencyKey` prevents duplicate submissions
29
29
  - **Collection Overrides** — Customize any generated collection (add fields like a `join`, tweak admin options, attach your own hooks) via `collectionOverrides` without forking — the plugin's hooks and access are merged, not clobbered (supersedes the deprecated `extraReservationFields`)
30
+ - **Services ↔ Resources Join** — Services show a read-only `resources` field (a `join` over `Resources.services`) listing which resources currently perform them; assignment still happens on the Resource
31
+ - **Active Enforcement** — `active: false` on a Service or Resource — including one referenced by a multi-resource `items[]` entry — blocks new bookings against it, blocks rescheduling or re-pointing an existing booking onto it, and excludes it from availability; non-scheduling edits such as confirm and cancel always remain allowed, and `enforceActive: false` opts out entirely
32
+ - **Concurrency-Safe Booking** — A transactional `bookingLock` on Resources serializes simultaneous bookers for the same slot; automatic retry recovers lost capacity on MongoDB, and a surviving conflict maps to a clean HTTP 409 rather than a raw 500 — see [Concurrent booking: database adapter support](#concurrent-booking-database-adapter-support)
33
+ - **Slot Holds** — Opt-in short-lived claims on a slot while a customer completes checkout, so it can't be booked out from under them; convert to a real booking or release, both idempotent-safe
34
+ - **Delete Guard** — Deleting a Service or Resource still referenced by a reservation (or, for a Resource, a schedule) fails with an actionable message instead of an inconsistent database or a silent dangling reference; `active: false` is the supported way to retire one
30
35
  - **Cancellation Policy** — Configurable minimum notice period enforcement
31
- - **Plugin Hooks API** — Seven lifecycle hooks (`beforeBookingCreate`, `afterBookingCreate`, `beforeBookingConfirm`, `afterBookingConfirm`, `beforeBookingCancel`, `afterBookingCancel`, `afterStatusChange`) for integrating email, Stripe, and external systems
36
+ - **Plugin Hooks API** — Seven lifecycle hooks (`beforeBookingCreate`, `afterBookingCreate`, `beforeBookingConfirm`, `afterBookingConfirm`, `beforeBookingCancel`, `afterBookingCancel`, `afterStatusChange`) for integrating email, Stripe, and external systems — all fire inside the write's own database transaction, never after
32
37
  - **Availability Service** — Pure functions and DB helpers for slot generation (15-min step) and conflict checking with guest-count-aware filtering
33
- - **Public REST API** — Six pre-built endpoints for availability, slot listing, resource availability, booking (incl. guest bookings), cancellation, and customer search — with ownership enforcement and input validation
38
+ - **Public REST API** — Seven pre-built endpoints for availability, slot listing, resource availability, booking (incl. guest bookings), cancellation, and customer search, plus two more for slot holds when enabled — with ownership enforcement and input validation
34
39
  - **Calendar View** — Month/week/day/lanes/pending calendar replacing the default reservations list view, with per-resource availability shading and click-a-free-slot-to-book; plus an availability-aware slot picker on the reservation form
35
40
  - **Dashboard Widget** — Server component showing today's booking stats
36
41
  - **Availability Overview** — Weekly grid of resource availability vs. booked slots
@@ -51,7 +56,7 @@ pnpm add payload-reserve
51
56
  npm install payload-reserve
52
57
  ```
53
58
 
54
- **Peer dependencies:** `payload ^3.79.0`, `@payloadcms/ui ^3.79.0`, `@payloadcms/translations ^3.79.0`
59
+ **Peer dependencies:** `payload ^3.86.0`, `@payloadcms/ui ^3.86.0`, `@payloadcms/translations ^3.86.0`
55
60
 
56
61
  ---
57
62
 
@@ -69,6 +74,8 @@ export default buildConfig({
69
74
  })
70
75
  ```
71
76
 
77
+ > **Using SQLite?** It requires one extra adapter setting to avoid silent double-booking under concurrent load — see [Concurrent booking: database adapter support](#concurrent-booking-database-adapter-support).
78
+
72
79
  ---
73
80
 
74
81
  ## Resource Owner Multi-Tenancy
@@ -291,7 +298,10 @@ Customize any generated collection without forking the plugin via `collectionOve
291
298
  payloadReserve({
292
299
  collectionOverrides: {
293
300
  services: {
294
- // append a join field back to Resources (the inverse of Resources.services)
301
+ // Generic illustration of appending a field via collectionOverrides not
302
+ // the way to get a services↔resources view: Services already ships that
303
+ // built in as a read-only `resources` join (see below). Don't name your
304
+ // own appended field `resources` on `services`, or it collides with it.
295
305
  fields: ({ defaultFields }) => [
296
306
  ...defaultFields,
297
307
  { name: 'referencedResources', type: 'join', collection: 'resources', on: 'services' },
@@ -307,10 +317,40 @@ payloadReserve({
307
317
 
308
318
  The plugin's load-bearing behavior is protected: supplied `hooks` are **merged** (the plugin's conflict-detection/status hooks always run, and run first — an override can add hooks but not remove them), `access` **composes** per operation (rules you omit keep the plugin's owner-mode/default behavior), and `slug` is ignored (use the `slugs` option). The `customers` override applies only when the standalone Customers collection is generated (ignored when `userCollection` is set — your auth collection is yours to edit directly). `collectionOverrides` supersedes the deprecated `extraReservationFields`.
309
319
 
320
+ > **Note:** a `collectionOverrides.resources` override that removes, renames, or nests the `services` field inside a *named* group or tab causes the Services `resources` join (below) to be silently skipped rather than erroring — the app still boots, the field simply doesn't appear.
321
+
310
322
  ### Optional `Resource.services`
311
323
 
312
324
  The `services` relationship on Resources is now optional. This lets a freshly provisioned staff Resource exist before services are assigned, avoiding validation errors during auto-provisioning.
313
325
 
326
+ ### Services `resources` Join
327
+
328
+ Services expose a read-only `resources` field — a Payload `join` over `Resources.services` — listing which resources currently perform that service. `Resources.services` remains the single source of truth and the only editable side: assign or remove services from the **Resource**, and the reverse list on the Service updates automatically. There is no way to add or remove a link from the Service side.
329
+
330
+ Reading (or viewing in the admin) a Service returns the join in Payload's standard join-field shape:
331
+
332
+ ```json
333
+ {
334
+ "resources": {
335
+ "docs": [{ "id": "...", "name": "...", "resourceType": "staff", "active": true }],
336
+ "hasNextPage": false,
337
+ "totalDocs": 3
338
+ }
339
+ }
340
+ ```
341
+
342
+ - **`defaultLimit: 100`** — raised from Payload's default of 10, so a service performed by more than 10 resources doesn't silently show a truncated list.
343
+ - **`allowCreate: false`** — the join can only ever create a *brand-new* Resource from the drawer, never link an existing one, and the Resource's `services` field is `hasMany` while the join's create-drawer pre-fill is scalar — so the affordance is disabled. Assign resources on the Resource itself.
344
+ - **`defaultColumns: ['name', 'resourceType', 'active']`** — the `active` column lets an admin see at a glance that a linked resource is disabled.
345
+
346
+ If a `collectionOverrides.resources` override removes, renames, or nests `services` inside a *named* group/tab, the join is gated off entirely (see the note above) rather than crashing the app at init.
347
+
348
+ #### `joins: false` on hot paths
349
+
350
+ Payload join fields resolve via a DB-level aggregation (`$lookup`) and ignore `depth`, so every internal `findByID` the plugin makes on Services now passes `joins: false` to avoid that extra query cost. If your own code reads Services on a hot path, consider doing the same — `payload.findByID({ collection: 'services', id, joins: false })`.
351
+
352
+ One path still pays for the join: when a Reservation is read at `depth >= 1`, Payload populates its `service` relationship through the internal dataloader, which offers no `joins: false` seam from plugin code. If that shows up in your profiles, read Reservations with `depth: 0` and fetch the service yourself.
353
+
314
354
  ---
315
355
 
316
356
  ## External Busy (Calendar Sync & Other Sources)
@@ -369,6 +409,179 @@ payloadReserve({
369
409
 
370
410
  ---
371
411
 
412
+ ## Access Control & Booking Correctness
413
+
414
+ ### Endpoints enforce collection access control
415
+
416
+ Four endpoints now gate the request through Payload's normal access-control pipeline (`overrideAccess: false` + `req`) instead of reading privileged. The governing rule is **gate the request with one explicit access-checked call, then keep the derived reads privileged** — an endpoint authorizes *what you asked for*, and once you are past that gate it still assembles a complete answer. So this is deliberately a per-path change, not a blanket flip:
417
+
418
+ | Endpoint | What delegates to access control | What stays privileged | Why |
419
+ |---|---|---|---|
420
+ | `/api/reservation-customer-search` | the customer query itself | — | The whole endpoint is the read; nothing is derived from it. |
421
+ | `/api/reserve/resource-availability` | a `findByID` probe of the requested resource (404 on denial) | every read that builds the grid: the resource itself, its schedules, its busy reservations, and — only when `slotHolds` is enabled — its unexpired slot holds. So **four** reads for a plain resource with `slotHolds` on, three with it off. Each `requiredResources` pool its services name adds three more (the pool's own resource read, its busy reservations, its holds), or two with `slotHolds` off. Two are Resources reads, and there is no separate services read | The probe decides *whether you may see this resource*. The grid must then show every conflicting booking, including other tenants' and other owners' — a busy slot you can't see is a double-booking. |
422
+ | `/api/reserve/cancel` | the update, **only** on the privileged-non-owner path | the reservation read, and the update on the owner and guest-token paths | For a guest the cancellation token *is* the authorization. For an owner, `resourceOwnerMode`'s `update: adminOnly` would otherwise block a customer cancelling their own booking. Ownership and token matching are checked in the endpoint before either path is taken. |
423
+ | `/api/reserve/effective-timezone` | the tenant-document read (falls back to the global zone on denial) | — | Prevents a forged tenant cookie resolving a zone you have no membership in. |
424
+
425
+ - **Plain installs** (no `resourceOwnerMode`, no `multiTenant`, and no custom `access` overrides) are unaffected: Payload's own built-in default is `read: ({ req: { user } }) => Boolean(user)`, so any authenticated user still passes.
426
+ - **A `userCollection` with a restrictive `read` rule now narrows customer search accordingly.** If the existing auth collection you pointed `userCollection` at defines its own `access.read` — for example, one that scopes a user to their own record or to a department — `/api/reservation-customer-search` now respects it, because the endpoint no longer out-permissions the collection it reads from. If staff stop seeing customers they used to see, the fix is in that collection's own `access.read`, not in the plugin.
427
+
428
+ #### `/api/reserve/book`: the tenant probe is the gate, not `overrideAccess`
429
+
430
+ `POST /api/reserve/book` does **not** follow the single "gate with one access-checked call" pattern the table above uses. Its write stays **privileged for every caller** (`overrideAccess: true` — the Local API default this endpoint has always used), and a separate access-checked probe is what authorizes the tenant. Both halves of that are deliberate:
431
+
432
+ 1. **`overrideAccess: false` cannot gate this write, so it is not used to pretend otherwise.** Payload's `create` access check (`executeAccess`) only tests the *truthiness* of what an access function returns and — unlike read/update/delete — never applies a returned `Where`, because there is no existing document to filter. Multi-tenant scopes access precisely by returning a `Where`, so its tenant-scoped `create` access passes for *any* authenticated member of *any* tenant no matter how this flag is set. Delegating would therefore add no isolation while actively breaking real callers: `resourceOwnerMode` makes reservation `create` **admin-only** (`makeReservationOwnerAccess`), so both an ordinary customer booking for themselves *and* a non-admin staff or resource-owner account booking a walk-in would get a flat `403` from an endpoint that served them in every earlier release.
433
+ 2. **`callerMayUseTenant` (which tenant they may write into) is the gate.** For every authenticated caller, an access-checked (`overrideAccess: false`) probe read (`src/utilities/tenantTimezone.ts`) checks any explicit `tenant` in the request body against the tenants collection, and a denial returns a clean `403` before `payload.create` is ever called. This is what **closes** the formerly-open hole where an authenticated tenant-A caller could `POST` an explicit `tenant: <tenant-B-id>` and land the reservation there. A `hasMany` tenant field is supported: every id in the array is probed.
434
+
435
+ Who may book for whom is enforced separately and independently of either of the above: an authenticated non-privileged caller is forced onto their own customer id, and an anonymous caller may not name an existing customer at all (the guest flow covers them).
436
+
437
+ **Precondition worth knowing:** the probe is a genuine membership check only when the caller authenticates against the *same* collection multi-tenant wraps as its admin/tenant-owning collection — true whenever `userCollection` points at that collection. In **standalone mode** (no `userCollection`), customers authenticate against the plugin's own `slugs.customers`, a collection multi-tenant never wraps, so the probe passes for any tenant id supplied by a logged-in customer. The plugin warns about this at boot whenever standalone mode + multi-tenant is detected; the fix is `userCollection` pointing at multi-tenant's admin auth collection, or your own tenant check in front of the endpoint.
438
+
439
+ **Consequence to know about, by design, not an oversight:** because the write is privileged, a consumer's own `access.reservations.create` — and any field-level `create` access added via `collectionOverrides.reservations` — is **not** applied to bookings made through this endpoint. That is unchanged from every release before tenant scoping existed, and nothing about it is exploitable (who may book for whom is enforced in the endpoint itself, and the tenant is probed). But if you add create access via `collectionOverrides` expecting it to run for every booking, it will not run for this one — put the rule in a `beforeChange` hook (which does run) or in the `hooks.beforeBookingCreate` plugin hook instead.
440
+
441
+ The reservations collection's own REST API (`POST /api/reservations`) is unaffected — it goes through Payload's access control as normal.
442
+
443
+ ### `resourceOwnerMode`: the availability grid now matches the Resources collection
444
+
445
+ Under `resourceOwnerMode`, a staff user (a role listed in `staffRoles` but **not** in `adminRoles`) can now only pull `/api/reserve/resource-availability` for their **own** resource — the same restriction the Resources collection already applies to ordinary reads. Add the role to `adminRoles` to restore the wider (all-resources) view for that user.
446
+
447
+ ### Multi-resource conflict detection now covers the top-level `resource`
448
+
449
+ A booking's top-level `resource` field is now conflict-checked even when the request also supplies an explicit `items[]` that never names it. Previously, a caller could put resource A in the top-level fields and only *other* resources in `items[]`, and A itself was never checked for overlaps — even though every other booking's conflict check already treats A as occupied by this one. Overlaps against the top-level resource that were previously accepted are now rejected.
450
+
451
+ If you call `resolveReservationItems` directly (it's exported from the package root), note that its returned array can now contain one more entry than before — one synthesized from the top-level `resource`/`startTime`/`endTime` and flagged `fromParent: true` — unless an `items[]` entry for that same resource already, demonstrably, covers the same window.
452
+
453
+ ### Calendar clicks and slot windows resolve in the business timezone
454
+
455
+ The admin Calendar's grid instants, click targets, day-key sequences, and month/week header labels are now computed in the plugin's business timezone (`timezone`, or the selected tenant's zone under `multiTenant`) rather than the browser's local timezone. Previously, viewing the calendar from a timezone different than the business's could make clicking a displayed time slot book a different wall-clock hour than the one shown.
456
+
457
+ ### Plugin order matters under `multiTenant`
458
+
459
+ `payloadReserve()` must be listed **before** `multiTenantPlugin()` in the `plugins` array, and this plugin's collection slugs must appear in the multi-tenant plugin's own `collections` option:
460
+
461
+ ```typescript
462
+ plugins: [
463
+ payloadReserve({ /* ... */ }),
464
+ multiTenantPlugin({
465
+ collections: {
466
+ // `customers` only exists in standalone mode (no `userCollection`).
467
+ // Leaving it out means /api/reservation-customer-search keeps
468
+ // returning every tenant's customers — there is no tenant field for
469
+ // it to filter on.
470
+ customers: {},
471
+ reservations: {},
472
+ resources: {},
473
+ schedules: {},
474
+ services: {},
475
+ },
476
+ // ...
477
+ }),
478
+ ]
479
+ ```
480
+
481
+ At `payloadReserve`'s own plugin-time no tenant field exists on any collection yet (multi-tenant hasn't run), and multi-tenant only ever scopes the collections named in its own `collections` option — it never discovers them. So if you enable multi-tenant elsewhere in your config but forget to list payload-reserve's collections there, those documents silently stay readable across every tenant. The plugin now detects this at boot and logs a warning naming the unscoped slugs:
482
+
483
+ ```
484
+ payload-reserve: these collections are NOT tenant-scoped: reservations, resources, schedules, services, customers. This config looks like it enables multi-tenancy (another collection carries a "tenant" field, or an auth collection carries a "tenants" membership array) — detection is a heuristic, so disregard this if you are not using multi-tenancy. Otherwise add these slugs to the multi-tenant plugin's "collections" option, or their documents stay readable across tenants.
485
+ ```
486
+
487
+ Detection is a **heuristic** — the multi-tenant plugin exposes nothing at init to check directly — so the warning arms on either of two independent signals: some collection in your config carries a top-level tenant field, or an auth collection carries multi-tenant's `tenants` membership array. Each covers the other's blind spot (scoping *only* these collections and forgetting them all leaves nothing else carrying the field; `tenantsArrayField.includeDefaultField: false` removes the array). Neither is exact, so the warning can fire on a config that merely looks tenant-shaped — it is only ever a warning and never blocks boot.
488
+
489
+ Two more diagnostics fire under the same "multi-tenant detected" condition. In **standalone mode**, `/api/reserve/book`'s tenant-membership probe (above) is a real membership check only when the caller authenticates against the same collection multi-tenant wraps — standalone customers never do, so the plugin warns that the probe cannot actually verify a logged-in customer's membership there; point `userCollection` at multi-tenant's own admin auth collection to close it. In **`userCollection` mode**, if your auth collection carries *neither* a flat tenant field *nor* multi-tenant's `tenants` membership array, it is genuinely unscoped and the plugin warns naming it — the remedy depends on which collection multi-tenant treats as its own admin/tenant-owning collection: if it's this one, it needs the `tenants` array (which multi-tenant adds automatically unless disabled), not a listing in `collections`, which multi-tenant itself rejects for that collection; only a genuinely separate auth collection belongs in `collections`.
490
+
491
+ ### Hook timing: every plugin hook fires before commit, never after
492
+
493
+ `afterBookingCreate`, `afterBookingConfirm`, `afterBookingCancel`, and `afterStatusChange` all fire **inside** the write's database transaction, not after it commits. Payload runs collection `afterChange` hooks and then commits in the same operation: verified directly against the installed package, `node_modules/payload/dist/collections/operations/create.js` invokes the collection's `afterChange` hooks at line 291 and calls `commitTransaction(req)` at line 324 — the only things that run in between are Payload's own `afterOperation` hook (which this plugin does not use) and `unlinkTempFiles`. The update path (where a status-change hook typically fires) is structurally identical.
494
+
495
+ Payload exposes **no** post-commit hook point — `afterOperation` is pre-commit too — so this is a documented constraint, not something a future version can quietly fix. In practice the failure window is narrow: the only way a plugin hook fires for a write that never actually lands is the commit itself failing *after* every hook already ran successfully — a database partition, not a routine validation failure (those throw earlier, from a `beforeChange` hook, before any `afterChange` hook or the commit is ever reached). **If your integration must not act on an uncommitted booking** (e.g. charging a card from `afterBookingConfirm`), make the side effect idempotent and reconcile afterward — upsert by reservation id, or verify the booking still exists before treating a webhook as authoritative — rather than relying on hook-vs-commit ordering to protect you.
496
+
497
+ ### Concurrent booking: database adapter support
498
+
499
+ Two simultaneous bookers for the same slot are only kept from double-booking each other because a `beforeChange` hook (`acquireBookingLock`) writes each claimed resource's hidden `bookingLock` field inside the booking's own transaction — the database, not the plugin, is what forces the losers to wait or abort. **Before this existed, nothing prevented a double-booking at all:** measured on MongoDB, 10 simultaneous creates for one `quantity: 1` slot produced 10 confirmed reservations, and 8 simultaneous creates against a `quantity: 3` resource produced 8.
500
+
501
+ Because the lock only works *inside* a transaction, **a database that gives Payload no transaction gives no protection, silently.** A standalone (non-replica-set) MongoDB skips transactions entirely; SQLite needs `transactionOptions` set on the adapter (see below) or it never gets one either. The plugin's boot diagnostic (`supportsTransactions`) probes this at startup and warns if it fails:
502
+
503
+ ```
504
+ payload-reserve: this database does not support transactions, so concurrent bookings for the same slot can double-book. MongoDB needs a replica set (even single-node) for transaction support. Postgres supports transactions by default. SQLite requires transactionOptions to be set on the adapter, or it silently runs without them.
505
+ ```
506
+
507
+ **Retry is required on MongoDB and does nothing on Postgres.** `retryOnWriteConflict` re-runs the whole write on a fresh transaction when the failure is a recognized transient conflict (structured signals only — MongoDB's `errorLabels`/code, Postgres's SQLSTATE, SQLite's `code` prefix — never message text). MongoDB's loser aborts immediately rather than waiting, so without retry a `quantity: 3` resource recovers only 1 of 3 under a burst. Postgres's loser *blocks* until the winner commits and then proceeds through the normal conflict check on the merits, so retry never fires there at all — measured 3 of 3 recovered with no retry needed. `/api/reserve/book`, `/api/reserve/cancel`, and `/api/reserve/hold` all wrap their write in `retryOnWriteConflict`; a conflict that survives every attempt maps to a clean HTTP **409**, not a raw 500. Measured directly (burst of 8 concurrent creates against a `quantity: 3` resource; `dev/concurrency.int.spec.ts`, `dev/holds.int.spec.ts`):
508
+
509
+ | Adapter | Serializes? | Loser behavior | Capacity with no retry (of 3) | Retry recovers full capacity? |
510
+ |---|---|---|---|---|
511
+ | MongoDB (replica set) | Yes | Aborts (`WriteConflict`, code 112, `errorLabels: ['TransientTransactionError']`) | 1 of 3 | Yes at moderate contention — recovers the other 2 (3 of 3 for a burst of 8). **Not a guarantee under extreme contention:** the retry budget is finite (5 attempts), and a measured 40-way burst against a `quantity: 5` resource granted only 3, with the other 37 receiving clean `409`s. Capacity is never *exceeded*; it can still be *under-filled* when the burst is large relative to the budget. |
512
+ | Postgres 18.4 | Yes | Blocks, then proceeds; rejected bookings surface as a clean `ValidationError` (400) | 3 of 3 | Not needed — retry never fires |
513
+ | SQLite (`@payloadcms/db-sqlite`, `transactionOptions` set — **required**) | Yes, single-winner correctness | Aborts immediately at `beginTransaction`, before the transaction body ever runs (`LibsqlError`, `code: 'SQLITE_LOCKED_SHAREDCACHE'`, `rawCode: 262`) | 1 of 3 | **No** — capped at 1 of 3, even with retry attempted and a large retry budget |
514
+
515
+ **`transactionOptions` must be set on `sqliteAdapter(...)`, or SQLite silently double-books.** `@payloadcms/db-sqlite` wires up a real `beginTransaction` only when the adapter is given a truthy `transactionOptions`; without it, `beginTransaction` is a hard-coded no-op that always resolves `null`, so the plugin's booking lock never runs inside a transaction and serializes nothing. Measured with the adapter's bare default config (`sqliteAdapter({ client, push: true })`, no `transactionOptions`): every concurrency-sensitive assertion failed with complete, silent over-booking — 10 of 10 concurrent bookings for one slot persisted (expected 1), a `quantity: 3` resource accepted all 8 of a burst of 8, and 8 of 8 concurrent slot-hold attempts were all granted the same slot (expected 1) — no error, no rejected request, just extra rows. Postgres enables transactions by default and only needs `transactionOptions: false` to turn them *off*; SQLite has the inverse default. The plugin's boot diagnostic (`supportsTransactions`) detects a misconfigured adapter either way and warns at startup.
516
+
517
+ **With `transactionOptions` set** (the dev harness now ships `transactionOptions: {}` in its `SQLITE=1` branch, and this is required for production use too), SQLite correctly serializes for **single-winner** scenarios: exactly one of 10 concurrent bookings for one slot survives, a `quantity: 3` resource never exceeds capacity under a burst, overlapping-time bursts still yield exactly one booking, and — critically for holds — exactly one of 8 concurrent slot-hold attempts is granted. Every single-winner assertion in `dev/holds.int.spec.ts` passes under this configuration; only its `quantity: 3` capacity-recovery case does not, for the reason immediately below, which is the same reason the booking equivalent does not.
518
+
519
+ **Retry-based capacity recovery does not work for SQLite, and this is a limitation the plugin cannot fix.** `src/utilities/retryOnWriteConflict.ts` now recognizes the `SQLITE_BUSY`/`SQLITE_LOCKED*` code family (structured `code`-prefix match, same as the brief's constraint requires) — but in practice this detection never fires for SQLite's actual failure shape. Confirmed by direct inspection: the original `LibsqlError` (with `code: 'SQLITE_LOCKED_SHAREDCACHE'`) is caught **inside** `@payloadcms/drizzle`'s own `beginTransaction.js` (a Payload-core dependency, not this plugin) and re-thrown as a bare `new Error('Error: cannot begin transaction: ...')` — a plain object with a message string and nothing else (`Object.keys(error)` is empty; no `code`, no `cause`, `name` is the generic `'Error'`). By the time the error reaches this plugin's retry logic, the structured signal is already gone. Raising the retry budget from the default (5 attempts) to 30 makes no difference — confirmed each of the losing attempts fails exactly once, meaning retry is never even entered, since `isTransientWriteConflict` correctly returns `false` for an error with no matchable field. The only way to detect this failure would be matching on the error's message text, which this project's structured-signal-only constraint explicitly forbids. **Net effect: on SQLite, a `quantity: 1` resource is fully safe under concurrency (no double-booking, ever); a `quantity: N > 1` resource under a simultaneous burst will safely reject the excess rather than over-book, but will not recover to its full legitimate capacity the way Mongo and Postgres do** — some legitimately bookable slots may be lost under contention. This is a `@payloadcms/db-sqlite`/`@payloadcms/drizzle` limitation external to this plugin, not something addressed here.
520
+
521
+ **The same gap means `POST /api/reserve/book` returns a raw HTTP 500 under SQLite contention, not the clean `409` this plugin's retry/409 mapping (above) gives Mongo and Postgres.** `createBooking.ts` maps a surviving conflict to `409 { retryable: true }` by checking `isTransientWriteConflict` on whatever `retryOnWriteConflict` ultimately throws — the same check that cannot recognize SQLite's stripped `beginTransaction` error above. Measured directly: 6 simultaneous `POST /api/reserve/book` calls for the same slot correctly booked exactly one (no over-booking), but 5 of the 6 losers came back as `500`, not `409`. **Practically, this means a public booking widget backed by SQLite will show its generic error-handling UI, not a "someone just booked this, try again" message, whenever two customers click "Book" for the same slot at close to the same time** — the request still fails safely (nothing is double-booked), but the caller gets an opaque server error instead of an actionable one. This is the same external, unfixable-without-message-matching limitation described above, reaching a second call site.
522
+
523
+ #### Running the test suite against every adapter
524
+
525
+ The integration suite defaults to an in-memory MongoDB replica set. Run it against Postgres or SQLite with an env var — no code changes needed:
526
+
527
+ ```bash
528
+ # Postgres — point PG_URL at a real, empty database
529
+ PG_URL="postgres://user:password@localhost:5432/reserve_test" CI=true pnpm test:int
530
+
531
+ # SQLite — in-memory by default
532
+ SQLITE=1 CI=true pnpm test:int
533
+ ```
534
+
535
+ Both are opt-in dev/CI conveniences, not something a consumer's app needs to configure — they exist so this plugin's own suite can be verified against every adapter it claims to support. **Neither run is expected to be fully green by construction, and this is known, not a regression to chase.** Last measured (38 files, 539 tests):
536
+
537
+ | Run | Result | The gaps |
538
+ |---|---|---|
539
+ | MongoDB (default) | 38 files, 539 passed, 0 skipped | none |
540
+ | `PG_URL=…` | 38 files, 538 passed, 1 skipped | the `bufferFor error trace` skip below |
541
+ | `SQLITE=1` | 35 of 38 files, 535 passed, 3 failed, 1 skipped | the same `bufferFor` skip, plus **3 failures that are all one accepted upstream limitation**: `dev/concurrency.int.spec.ts`'s `quantity: 3` capacity recovery, `dev/holds.int.spec.ts`'s `quantity: 3` capacity recovery, and `dev/bookingRetry.int.spec.ts`'s no-500 assertion. All three trace to `@payloadcms/drizzle` discarding the driver's structured error code at `beginTransaction` (see the section above) — never "fix" any of them by matching on message text. |
542
+
543
+ The `bufferFor error trace` skip manufactures a dangling reference via `context.skipReservationHooks` to exercise a code path that the delete guard below makes structurally unreachable on a schema-enforced SQL database, so the scenario is MongoDB-only by construction.
544
+
545
+ ### Slot holds (opt-in)
546
+
547
+ Short-lived claims on a slot, taken while a customer completes an external step (typically payment) before the booking itself exists. Enable with the `slotHolds` plugin option:
548
+
549
+ ```typescript
550
+ payloadReserve({
551
+ slotHolds: {
552
+ enabled: true,
553
+ ttlMinutes: 10, // default; how long an unconverted hold blocks its slot
554
+ },
555
+ })
556
+ ```
557
+
558
+ Disabled by default — when absent, no `reservation-holds` collection is created, neither endpoint below is registered, and availability behaviour is byte-identical to a plugin build without this feature at all. A hold occupies its resource exactly like a blocking reservation (folded into the same availability/conflict calculations); it carries no status, buffer, or `items[]` — one resource, one window, one clock. Expired holds are never trusted and are swept opportunistically, so no background job is required.
559
+
560
+ | Method | Path | Body | Response |
561
+ |---|---|---|---|
562
+ | POST | `/api/reserve/hold` | `{ resource, service, startTime, endTime?, guestCount? }` | `201 { token, expiresAt }`; `409 { error: 'slot_taken' \| 'service_inactive' }` when the slot genuinely isn't available; `409 { error, retryable: true }` when lock contention outlived the retry budget; `404 { error: 'service_not_found' \| 'resource_not_found' }`; `400` for a missing or unparseable field |
563
+ | POST | `/api/reserve/hold/release` | `{ token }` | `200 { released: 0 \| 1 }` — always `200`, even for an already-released or expired token (idempotent) |
564
+
565
+ Pass the token straight through to `POST /api/reserve/book` as `holdToken` to convert a hold into a real booking — the hold is excluded from that request's own conflict check (so it doesn't block the very booking it was protecting) and the hold row is deleted on success, best-effort, so a delete failure never fails the booking itself.
566
+
567
+ **The hold token is a bearer secret, and the collection is closed to the REST API entirely** — `create`, `read`, `update` and `delete` all return `false`, so `GET /api/reservation-holds` is denied for every caller including admins. Anyone who can read a live token can release someone else's hold or book their slot with it, and `admin: { hidden: true }` hides only the nav link, not the route. The plugin's own reads and writes reach the collection through the Local API privileged, so nothing internal depends on those rules. **Under `multiTenant`, list the holds slug in the multi-tenant plugin's own `collections` option** alongside the scheduling collections — the boot diagnostic warns if you don't.
568
+
569
+ **Held slots are excluded from availability, not just from bookings.** `/api/reserve/availability`, `/api/reserve/slots` and `/api/reserve/resource-availability` all treat an unexpired hold as busy, so every customer-facing path agrees with the write path — a customer is never shown a slot the booking endpoint will then refuse with a `409`. That covers the reservation form's slot picker (`AvailabilityTimeField`, which fetches `/reserve/slots`) and the admin **Calendar** view (which fetches `/reserve/resource-availability`). Nothing in that path changes when `slotHolds` is off.
570
+
571
+ **Known limitation — the admin Availability grid does not show holds.** `AvailabilityOverview` (the weekly grid at `/reservation-availability`) does not use `/reserve/resource-availability`; it queries the `resources`, `schedules` and `reservations` REST endpoints directly and computes the grid in the browser, so an unexpired hold is invisible there and its slot reads as free. This is **display-only and admin-only**: it cannot cause a wrong write, because every write still goes through `checkAvailability`, which does count holds — an admin who books over a held slot from that grid is refused exactly as any other caller would be. Cross-check the Calendar view, or `/reserve/availability`, when holds matter.
572
+
573
+ ### Deleting a referenced Service or Resource is blocked
574
+
575
+ Deleting a Service or Resource that a reservation still references (or, for a Resource, that a Schedule still references) now fails with an actionable `400`:
576
+
577
+ ```
578
+ Cannot delete this resource: 2 reservations and 1 schedule still reference it. Uncheck "active" to retire it instead — that stops new bookings while keeping existing ones intact.
579
+ ```
580
+
581
+ **This is a real behavioural change on MongoDB**, where the same delete previously succeeded silently, leaving the referencing reservation or schedule pointing at a document that no longer exists. On Postgres/SQLite the delete was never silent — it always failed — but with a raw, un-actionable database error (`23502`/`SQLITE_CONSTRAINT_NOTNULL`) rather than this message, because `service`/`resource` are required fields (`NOT NULL` in SQL) while the underlying adapter emits `ON DELETE SET NULL` for the same relationship, a self-contradictory schema only application code can resolve cleanly. The guard checks `items[]` references too, not just the top-level `service`/`resource` field, and covers multi-resource bookings the same way. Set `active: false` instead of deleting to retire a Service or Resource while keeping its booking (or schedule) history intact — this is what the error message points you at.
582
+
583
+ ---
584
+
372
585
  ## Internationalization
373
586
 
374
587
  Every admin string the plugin renders — field labels, descriptions, select options, calendar/dashboard components, and validation errors — is translatable. The plugin ships **12 languages**: English, French (`fr`), German (`de`), Spanish (`es`), Russian (`ru`), Polish (`pl`), Turkish (`tr`), Arabic (`ar`), Simplified Chinese (`zh`), Indonesian (`id`), Persian/Farsi (`fa`), and Hindi (`hi`). All but Hindi ship in Payload core and appear in the admin language switcher automatically.
@@ -0,0 +1,18 @@
1
+ import type { CollectionConfig } from 'payload';
2
+ import type { ResolvedReservationPluginConfig } from '../types.js';
3
+ /**
4
+ * Short-lived claims on a slot, taken while a customer completes an external
5
+ * step (typically payment) before the booking itself exists.
6
+ *
7
+ * A hold occupies its resource exactly as a blocking reservation does — see
8
+ * `checkAvailability`, which folds unexpired holds into the same `Occupancy[]`
9
+ * reduction as reservations and external busy intervals. Holds do not have
10
+ * statuses, buffers, or items: they are one resource, one window, one clock.
11
+ *
12
+ * Rows are never trusted past `expiresAt`. Expiry is enforced by filtering at
13
+ * read time rather than by a TTL index, because TTL indexes are MongoDB-only
14
+ * and this collection has to behave identically on Postgres and SQLite. Expired
15
+ * rows are swept opportunistically when a new hold is taken on the same
16
+ * resource, so no background job is required.
17
+ */
18
+ export declare function createHoldsCollection(config: ResolvedReservationPluginConfig): CollectionConfig;
@@ -0,0 +1,113 @@
1
+ import { validateHoldSlot } from '../hooks/holds/validateHoldSlot.js';
2
+ /**
3
+ * Short-lived claims on a slot, taken while a customer completes an external
4
+ * step (typically payment) before the booking itself exists.
5
+ *
6
+ * A hold occupies its resource exactly as a blocking reservation does — see
7
+ * `checkAvailability`, which folds unexpired holds into the same `Occupancy[]`
8
+ * reduction as reservations and external busy intervals. Holds do not have
9
+ * statuses, buffers, or items: they are one resource, one window, one clock.
10
+ *
11
+ * Rows are never trusted past `expiresAt`. Expiry is enforced by filtering at
12
+ * read time rather than by a TTL index, because TTL indexes are MongoDB-only
13
+ * and this collection has to behave identically on Postgres and SQLite. Expired
14
+ * rows are swept opportunistically when a new hold is taken on the same
15
+ * resource, so no background job is required.
16
+ */ export function createHoldsCollection(config) {
17
+ return {
18
+ slug: config.slugs.holds,
19
+ access: {
20
+ // Every operation is closed, `read` included. `admin.hidden` only hides
21
+ // the nav link — Payload still mounts `GET /api/<slug>` — and `token`
22
+ // below is a BEARER SECRET with no field-level read rule of its own, so
23
+ // any reader can release someone else's hold or book their slot with it.
24
+ // There is no legitimate REST reader: the plugin's own endpoints, hooks
25
+ // and services all reach this collection through the Local API with
26
+ // `overrideAccess` at its default (`true`), which bypasses these rules —
27
+ // `checkAvailability`'s find, `takeHold`'s create and expiry sweep,
28
+ // `releaseHold`'s delete, and `createBooking`'s hold consumption.
29
+ create: ()=>false,
30
+ delete: ()=>false,
31
+ read: ()=>false,
32
+ update: ()=>false
33
+ },
34
+ admin: {
35
+ defaultColumns: [
36
+ 'resource',
37
+ 'startTime',
38
+ 'expiresAt'
39
+ ],
40
+ description: 'Short-lived slot claims taken during checkout. Rows past their expiry are ignored and swept automatically.',
41
+ group: config.adminGroup,
42
+ hidden: true,
43
+ useAsTitle: 'token'
44
+ },
45
+ fields: [
46
+ {
47
+ name: 'resource',
48
+ type: 'relationship',
49
+ index: true,
50
+ relationTo: config.slugs.resources,
51
+ required: true
52
+ },
53
+ {
54
+ name: 'service',
55
+ type: 'relationship',
56
+ relationTo: config.slugs.services,
57
+ required: true
58
+ },
59
+ {
60
+ name: 'startTime',
61
+ type: 'date',
62
+ index: true,
63
+ required: true
64
+ },
65
+ {
66
+ name: 'endTime',
67
+ type: 'date',
68
+ required: true
69
+ },
70
+ {
71
+ name: 'guestCount',
72
+ type: 'number',
73
+ defaultValue: 1,
74
+ min: 1
75
+ },
76
+ {
77
+ // Read-time expiry filter depends on this being indexed — every
78
+ // availability check adds a range predicate over it.
79
+ name: 'expiresAt',
80
+ type: 'date',
81
+ index: true,
82
+ required: true
83
+ },
84
+ {
85
+ // The bearer secret. Whoever holds this may convert the hold into a
86
+ // booking or release it; it is generated server-side and never accepted
87
+ // from a request body.
88
+ name: 'token',
89
+ type: 'text',
90
+ index: true,
91
+ required: true,
92
+ unique: true
93
+ },
94
+ {
95
+ name: 'customer',
96
+ type: 'relationship',
97
+ relationTo: config.slugs.customers
98
+ }
99
+ ],
100
+ hooks: {
101
+ beforeChange: [
102
+ validateHoldSlot(config)
103
+ ]
104
+ },
105
+ labels: {
106
+ plural: 'Slot Holds',
107
+ singular: 'Slot Hold'
108
+ },
109
+ timestamps: true
110
+ };
111
+ }
112
+
113
+ //# sourceMappingURL=Holds.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/collections/Holds.ts"],"sourcesContent":["import type { CollectionConfig, CollectionSlug } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { validateHoldSlot } from '../hooks/holds/validateHoldSlot.js'\n\n/**\n * Short-lived claims on a slot, taken while a customer completes an external\n * step (typically payment) before the booking itself exists.\n *\n * A hold occupies its resource exactly as a blocking reservation does — see\n * `checkAvailability`, which folds unexpired holds into the same `Occupancy[]`\n * reduction as reservations and external busy intervals. Holds do not have\n * statuses, buffers, or items: they are one resource, one window, one clock.\n *\n * Rows are never trusted past `expiresAt`. Expiry is enforced by filtering at\n * read time rather than by a TTL index, because TTL indexes are MongoDB-only\n * and this collection has to behave identically on Postgres and SQLite. Expired\n * rows are swept opportunistically when a new hold is taken on the same\n * resource, so no background job is required.\n */\nexport function createHoldsCollection(config: ResolvedReservationPluginConfig): CollectionConfig {\n return {\n slug: config.slugs.holds,\n access: {\n // Every operation is closed, `read` included. `admin.hidden` only hides\n // the nav link — Payload still mounts `GET /api/<slug>` — and `token`\n // below is a BEARER SECRET with no field-level read rule of its own, so\n // any reader can release someone else's hold or book their slot with it.\n // There is no legitimate REST reader: the plugin's own endpoints, hooks\n // and services all reach this collection through the Local API with\n // `overrideAccess` at its default (`true`), which bypasses these rules —\n // `checkAvailability`'s find, `takeHold`'s create and expiry sweep,\n // `releaseHold`'s delete, and `createBooking`'s hold consumption.\n create: () => false,\n delete: () => false,\n read: () => false,\n update: () => false,\n },\n admin: {\n defaultColumns: ['resource', 'startTime', 'expiresAt'],\n description:\n 'Short-lived slot claims taken during checkout. Rows past their expiry are ignored and swept automatically.',\n group: config.adminGroup,\n hidden: true,\n useAsTitle: 'token',\n },\n fields: [\n {\n name: 'resource',\n type: 'relationship',\n index: true,\n relationTo: config.slugs.resources as unknown as CollectionSlug,\n required: true,\n },\n {\n name: 'service',\n type: 'relationship',\n relationTo: config.slugs.services as unknown as CollectionSlug,\n required: true,\n },\n {\n name: 'startTime',\n type: 'date',\n index: true,\n required: true,\n },\n {\n name: 'endTime',\n type: 'date',\n required: true,\n },\n {\n name: 'guestCount',\n type: 'number',\n defaultValue: 1,\n min: 1,\n },\n {\n // Read-time expiry filter depends on this being indexed — every\n // availability check adds a range predicate over it.\n name: 'expiresAt',\n type: 'date',\n index: true,\n required: true,\n },\n {\n // The bearer secret. Whoever holds this may convert the hold into a\n // booking or release it; it is generated server-side and never accepted\n // from a request body.\n name: 'token',\n type: 'text',\n index: true,\n required: true,\n unique: true,\n },\n {\n name: 'customer',\n type: 'relationship',\n relationTo: config.slugs.customers as unknown as CollectionSlug,\n },\n ],\n hooks: {\n beforeChange: [validateHoldSlot(config)],\n },\n labels: {\n plural: 'Slot Holds',\n singular: 'Slot Hold',\n },\n timestamps: true,\n }\n}\n"],"names":["validateHoldSlot","createHoldsCollection","config","slug","slugs","holds","access","create","delete","read","update","admin","defaultColumns","description","group","adminGroup","hidden","useAsTitle","fields","name","type","index","relationTo","resources","required","services","defaultValue","min","unique","customers","hooks","beforeChange","labels","plural","singular","timestamps"],"mappings":"AAIA,SAASA,gBAAgB,QAAQ,qCAAoC;AAErE;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,sBAAsBC,MAAuC;IAC3E,OAAO;QACLC,MAAMD,OAAOE,KAAK,CAACC,KAAK;QACxBC,QAAQ;YACN,wEAAwE;YACxE,sEAAsE;YACtE,wEAAwE;YACxE,yEAAyE;YACzE,wEAAwE;YACxE,oEAAoE;YACpE,yEAAyE;YACzE,oEAAoE;YACpE,kEAAkE;YAClEC,QAAQ,IAAM;YACdC,QAAQ,IAAM;YACdC,MAAM,IAAM;YACZC,QAAQ,IAAM;QAChB;QACAC,OAAO;YACLC,gBAAgB;gBAAC;gBAAY;gBAAa;aAAY;YACtDC,aACE;YACFC,OAAOZ,OAAOa,UAAU;YACxBC,QAAQ;YACRC,YAAY;QACd;QACAC,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;gBACNC,OAAO;gBACPC,YAAYpB,OAAOE,KAAK,CAACmB,SAAS;gBAClCC,UAAU;YACZ;YACA;gBACEL,MAAM;gBACNC,MAAM;gBACNE,YAAYpB,OAAOE,KAAK,CAACqB,QAAQ;gBACjCD,UAAU;YACZ;YACA;gBACEL,MAAM;gBACNC,MAAM;gBACNC,OAAO;gBACPG,UAAU;YACZ;YACA;gBACEL,MAAM;gBACNC,MAAM;gBACNI,UAAU;YACZ;YACA;gBACEL,MAAM;gBACNC,MAAM;gBACNM,cAAc;gBACdC,KAAK;YACP;YACA;gBACE,gEAAgE;gBAChE,qDAAqD;gBACrDR,MAAM;gBACNC,MAAM;gBACNC,OAAO;gBACPG,UAAU;YACZ;YACA;gBACE,oEAAoE;gBACpE,wEAAwE;gBACxE,uBAAuB;gBACvBL,MAAM;gBACNC,MAAM;gBACNC,OAAO;gBACPG,UAAU;gBACVI,QAAQ;YACV;YACA;gBACET,MAAM;gBACNC,MAAM;gBACNE,YAAYpB,OAAOE,KAAK,CAACyB,SAAS;YACpC;SACD;QACDC,OAAO;YACLC,cAAc;gBAAC/B,iBAAiBE;aAAQ;QAC1C;QACA8B,QAAQ;YACNC,QAAQ;YACRC,UAAU;QACZ;QACAC,YAAY;IACd;AACF"}
@@ -1,8 +1,10 @@
1
+ import { acquireBookingLock } from '../hooks/reservations/acquireBookingLock.js';
1
2
  import { calculateEndTime } from '../hooks/reservations/calculateEndTime.js';
2
3
  import { checkIdempotency } from '../hooks/reservations/checkIdempotency.js';
3
4
  import { enforceCustomerOwnership } from '../hooks/reservations/enforceCustomerOwnership.js';
4
5
  import { expandRequiredResources } from '../hooks/reservations/expandRequiredResources.js';
5
6
  import { onStatusChange } from '../hooks/reservations/onStatusChange.js';
7
+ import { validateActive } from '../hooks/reservations/validateActive.js';
6
8
  import { validateCancellation } from '../hooks/reservations/validateCancellation.js';
7
9
  import { validateConflicts } from '../hooks/reservations/validateConflicts.js';
8
10
  import { validateGuestBooking } from '../hooks/reservations/validateGuestBooking.js';
@@ -272,7 +274,13 @@ export function createReservationsCollection(config) {
272
274
  checkIdempotency(config),
273
275
  validateGuestBooking(config),
274
276
  expandRequiredResources(config),
277
+ // After expandRequiredResources so auto-injected required pools are
278
+ // checked too; before validateConflicts so rejection is cheap.
279
+ validateActive(config),
275
280
  calculateEndTime(config),
281
+ // Must precede validateConflicts: it manufactures the write contention
282
+ // that makes the conflict check meaningful under concurrency.
283
+ acquireBookingLock(config),
276
284
  validateConflicts(config),
277
285
  // validateCancellation runs BEFORE validateStatusTransition so a cancel
278
286
  // rejected by the notice period never fires the beforeBookingCancel
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/collections/Reservations.ts"],"sourcesContent":["import type {\n CollectionAfterChangeHook,\n CollectionBeforeChangeHook,\n CollectionConfig,\n CollectionSlug,\n} from 'payload'\n\nimport type { PluginT } from '../translations/index.js'\nimport type { ReservationPluginHooks, ResolvedReservationPluginConfig } from '../types.js'\n\nimport { calculateEndTime } from '../hooks/reservations/calculateEndTime.js'\nimport { checkIdempotency } from '../hooks/reservations/checkIdempotency.js'\nimport { enforceCustomerOwnership } from '../hooks/reservations/enforceCustomerOwnership.js'\nimport { expandRequiredResources } from '../hooks/reservations/expandRequiredResources.js'\nimport { onStatusChange } from '../hooks/reservations/onStatusChange.js'\nimport { validateCancellation } from '../hooks/reservations/validateCancellation.js'\nimport { validateConflicts } from '../hooks/reservations/validateConflicts.js'\nimport { validateGuestBooking } from '../hooks/reservations/validateGuestBooking.js'\nimport { validateStatusTransition } from '../hooks/reservations/validateStatusTransition.js'\nimport { statusToI18nKey } from '../utilities/i18nUtils.js'\nimport { composeAccess, makeReservationOwnerAccess } from '../utilities/ownerAccess.js'\n\nfunction createPluginHooksBeforeCreate(\n hooks: ReservationPluginHooks,\n): CollectionBeforeChangeHook {\n return async ({ context, data, operation, req }) => {\n if (context?.skipReservationHooks) {return data}\n\n if (operation === 'create' && hooks.beforeBookingCreate) {\n let mutatedData = data\n for (const hook of hooks.beforeBookingCreate) {\n const result = await hook({ data: mutatedData, req })\n if (result) {mutatedData = result}\n }\n return mutatedData\n }\n\n return data\n }\n}\n\nfunction createPluginHooksAfterCreate(\n hooks: ReservationPluginHooks,\n): CollectionAfterChangeHook {\n return async ({ context, doc, operation, req }) => {\n if (context?.skipReservationHooks) {return doc}\n if (operation === 'create' && hooks.afterBookingCreate) {\n const docRecord = doc as Record<string, unknown>\n for (const hook of hooks.afterBookingCreate) {\n await hook({ doc: docRecord, req })\n }\n }\n return doc\n }\n}\n\nexport function createReservationsCollection(\n config: ResolvedReservationPluginConfig,\n): CollectionConfig {\n const { statusMachine } = config\n const rom = config.resourceOwnerMode\n const access =\n composeAccess(rom ? makeReservationOwnerAccess(rom) : {}, config.access.reservations)\n\n return {\n slug: config.slugs.reservations,\n access,\n admin: {\n components: {\n views: {\n list: {\n Component: 'payload-reserve/client#CalendarView',\n },\n },\n },\n group: config.adminGroup,\n listSearchableFields: ['status'],\n useAsTitle: 'startTime',\n },\n fields: [\n {\n name: 'service',\n type: 'relationship',\n label: ({ t }) => (t as PluginT)('reservation:fieldService'),\n relationTo: config.slugs.services as unknown as CollectionSlug,\n required: true,\n },\n {\n name: 'resource',\n type: 'relationship',\n label: ({ t }) => (t as PluginT)('reservation:fieldResource'),\n relationTo: config.slugs.resources as unknown as CollectionSlug,\n required: true,\n },\n {\n name: 'customer',\n type: 'relationship',\n admin: {\n allowCreate: true,\n allowEdit: true,\n components: {\n Field: 'payload-reserve/client#CustomerField',\n },\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldCustomer'),\n relationTo: config.slugs.customers as unknown as CollectionSlug,\n required: false,\n },\n {\n name: 'guest',\n type: 'group',\n admin: {\n description: ({ t }) => (t as PluginT)('reservation:fieldGuestDesc'),\n },\n fields: [\n {\n name: 'name',\n type: 'text',\n label: ({ t }) => (t as PluginT)('reservation:fieldGuestName'),\n maxLength: 200,\n },\n {\n name: 'email',\n type: 'email',\n label: ({ t }) => (t as PluginT)('reservation:fieldGuestEmail'),\n },\n {\n name: 'phone',\n type: 'text',\n label: ({ t }) => (t as PluginT)('reservation:fieldGuestPhone'),\n maxLength: 50,\n },\n ],\n label: ({ t }) => (t as PluginT)('reservation:fieldGuest'),\n },\n {\n name: 'cancellationToken',\n type: 'text',\n access: {\n // Server-generated secret: never settable via the API (the\n // validateGuestBooking hook stamps it), readable only by staff/admin.\n create: () => false,\n read: ({ req }) =>\n Boolean(req.user) && req.user!.collection !== config.slugs.customers,\n update: () => false,\n },\n admin: {\n hidden: true,\n },\n index: true,\n },\n {\n name: 'startTime',\n type: 'date',\n admin: {\n components: {\n Field: 'payload-reserve/client#AvailabilityTimeField',\n },\n date: {\n pickerAppearance: 'dayAndTime',\n },\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldStartTime'),\n required: true,\n },\n {\n name: 'endTime',\n type: 'date',\n admin: {\n // Editable: flexible-duration services require a user-supplied endTime\n // (calculateEndTime overwrites it for fixed/full-day on save).\n date: {\n pickerAppearance: 'dayAndTime',\n },\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldEndTime'),\n },\n {\n name: 'status',\n type: 'select',\n defaultValue: statusMachine.defaultStatus,\n label: ({ t }) => (t as PluginT)('reservation:fieldStatus'),\n options: statusMachine.statuses.map((s) => ({\n label: ({ t }) => {\n const key = statusToI18nKey(s)\n const translated = (t as PluginT)(key)\n return translated !== key ? translated : s.charAt(0).toUpperCase() + s.slice(1)\n },\n value: s,\n })),\n },\n {\n name: 'cancellationReason',\n type: 'textarea',\n admin: {\n condition: (_, siblingData) => siblingData?.status === statusMachine.cancelStatus,\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldCancellationReason'),\n },\n {\n name: 'guestCount',\n type: 'number',\n defaultValue: 1,\n label: ({ t }) => (t as PluginT)('reservation:fieldGuestCount'),\n min: 1,\n },\n {\n name: 'notes',\n type: 'textarea',\n label: ({ t }) => (t as PluginT)('reservation:fieldNotes'),\n },\n {\n name: 'items',\n type: 'array',\n admin: {\n description: ({ t }) => (t as PluginT)('reservation:fieldItemsDesc'),\n },\n fields: [\n {\n name: 'resource',\n type: 'relationship',\n label: ({ t }) => (t as PluginT)('reservation:fieldResource'),\n relationTo: config.slugs.resources as unknown as CollectionSlug,\n required: true,\n },\n {\n name: 'service',\n type: 'relationship',\n label: ({ t }) => (t as PluginT)('reservation:fieldService'),\n relationTo: config.slugs.services as unknown as CollectionSlug,\n },\n {\n name: 'startTime',\n type: 'date',\n admin: { date: { pickerAppearance: 'dayAndTime' } },\n label: ({ t }) => (t as PluginT)('reservation:fieldStartTime'),\n },\n {\n name: 'endTime',\n type: 'date',\n admin: { date: { pickerAppearance: 'dayAndTime' }, readOnly: false },\n label: ({ t }) => (t as PluginT)('reservation:fieldEndTime'),\n },\n {\n name: 'guestCount',\n type: 'number',\n label: ({ t }) => (t as PluginT)('reservation:fieldGuestCount'),\n min: 1,\n },\n ],\n label: ({ t }) => (t as PluginT)('reservation:fieldItems'),\n },\n {\n name: 'idempotencyKey',\n type: 'text',\n admin: { position: 'sidebar', readOnly: true },\n index: true,\n unique: true,\n },\n ...config.extraReservationFields,\n ],\n hooks: {\n afterChange: [\n createPluginHooksAfterCreate(config.hooks),\n onStatusChange(config),\n ],\n beforeChange: [\n createPluginHooksBeforeCreate(config.hooks),\n enforceCustomerOwnership(config),\n checkIdempotency(config),\n validateGuestBooking(config),\n expandRequiredResources(config),\n calculateEndTime(config),\n validateConflicts(config),\n // validateCancellation runs BEFORE validateStatusTransition so a cancel\n // rejected by the notice period never fires the beforeBookingCancel\n // plugin hooks (e.g. refund initiation) for an update that won't land.\n validateCancellation(config),\n validateStatusTransition(config),\n ],\n },\n labels: {\n plural: ({ t }) => (t as PluginT)('reservation:collectionReservations'),\n singular: ({ t }) => (t as PluginT)('reservation:collectionReservations'),\n },\n }\n}\n"],"names":["calculateEndTime","checkIdempotency","enforceCustomerOwnership","expandRequiredResources","onStatusChange","validateCancellation","validateConflicts","validateGuestBooking","validateStatusTransition","statusToI18nKey","composeAccess","makeReservationOwnerAccess","createPluginHooksBeforeCreate","hooks","context","data","operation","req","skipReservationHooks","beforeBookingCreate","mutatedData","hook","result","createPluginHooksAfterCreate","doc","afterBookingCreate","docRecord","createReservationsCollection","config","statusMachine","rom","resourceOwnerMode","access","reservations","slug","slugs","admin","components","views","list","Component","group","adminGroup","listSearchableFields","useAsTitle","fields","name","type","label","t","relationTo","services","required","resources","allowCreate","allowEdit","Field","customers","description","maxLength","create","read","Boolean","user","collection","update","hidden","index","date","pickerAppearance","defaultValue","defaultStatus","options","statuses","map","s","key","translated","charAt","toUpperCase","slice","value","condition","_","siblingData","status","cancelStatus","min","readOnly","position","unique","extraReservationFields","afterChange","beforeChange","labels","plural","singular"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/collections/Reservations.ts"],"sourcesContent":["import type {\n CollectionAfterChangeHook,\n CollectionBeforeChangeHook,\n CollectionConfig,\n CollectionSlug,\n} from 'payload'\n\nimport type { PluginT } from '../translations/index.js'\nimport type { ReservationPluginHooks, ResolvedReservationPluginConfig } from '../types.js'\n\nimport { acquireBookingLock } from '../hooks/reservations/acquireBookingLock.js'\nimport { calculateEndTime } from '../hooks/reservations/calculateEndTime.js'\nimport { checkIdempotency } from '../hooks/reservations/checkIdempotency.js'\nimport { enforceCustomerOwnership } from '../hooks/reservations/enforceCustomerOwnership.js'\nimport { expandRequiredResources } from '../hooks/reservations/expandRequiredResources.js'\nimport { onStatusChange } from '../hooks/reservations/onStatusChange.js'\nimport { validateActive } from '../hooks/reservations/validateActive.js'\nimport { validateCancellation } from '../hooks/reservations/validateCancellation.js'\nimport { validateConflicts } from '../hooks/reservations/validateConflicts.js'\nimport { validateGuestBooking } from '../hooks/reservations/validateGuestBooking.js'\nimport { validateStatusTransition } from '../hooks/reservations/validateStatusTransition.js'\nimport { statusToI18nKey } from '../utilities/i18nUtils.js'\nimport { composeAccess, makeReservationOwnerAccess } from '../utilities/ownerAccess.js'\n\nfunction createPluginHooksBeforeCreate(\n hooks: ReservationPluginHooks,\n): CollectionBeforeChangeHook {\n return async ({ context, data, operation, req }) => {\n if (context?.skipReservationHooks) {return data}\n\n if (operation === 'create' && hooks.beforeBookingCreate) {\n let mutatedData = data\n for (const hook of hooks.beforeBookingCreate) {\n const result = await hook({ data: mutatedData, req })\n if (result) {mutatedData = result}\n }\n return mutatedData\n }\n\n return data\n }\n}\n\nfunction createPluginHooksAfterCreate(\n hooks: ReservationPluginHooks,\n): CollectionAfterChangeHook {\n return async ({ context, doc, operation, req }) => {\n if (context?.skipReservationHooks) {return doc}\n if (operation === 'create' && hooks.afterBookingCreate) {\n const docRecord = doc as Record<string, unknown>\n for (const hook of hooks.afterBookingCreate) {\n await hook({ doc: docRecord, req })\n }\n }\n return doc\n }\n}\n\nexport function createReservationsCollection(\n config: ResolvedReservationPluginConfig,\n): CollectionConfig {\n const { statusMachine } = config\n const rom = config.resourceOwnerMode\n const access =\n composeAccess(rom ? makeReservationOwnerAccess(rom) : {}, config.access.reservations)\n\n return {\n slug: config.slugs.reservations,\n access,\n admin: {\n components: {\n views: {\n list: {\n Component: 'payload-reserve/client#CalendarView',\n },\n },\n },\n group: config.adminGroup,\n listSearchableFields: ['status'],\n useAsTitle: 'startTime',\n },\n fields: [\n {\n name: 'service',\n type: 'relationship',\n label: ({ t }) => (t as PluginT)('reservation:fieldService'),\n relationTo: config.slugs.services as unknown as CollectionSlug,\n required: true,\n },\n {\n name: 'resource',\n type: 'relationship',\n label: ({ t }) => (t as PluginT)('reservation:fieldResource'),\n relationTo: config.slugs.resources as unknown as CollectionSlug,\n required: true,\n },\n {\n name: 'customer',\n type: 'relationship',\n admin: {\n allowCreate: true,\n allowEdit: true,\n components: {\n Field: 'payload-reserve/client#CustomerField',\n },\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldCustomer'),\n relationTo: config.slugs.customers as unknown as CollectionSlug,\n required: false,\n },\n {\n name: 'guest',\n type: 'group',\n admin: {\n description: ({ t }) => (t as PluginT)('reservation:fieldGuestDesc'),\n },\n fields: [\n {\n name: 'name',\n type: 'text',\n label: ({ t }) => (t as PluginT)('reservation:fieldGuestName'),\n maxLength: 200,\n },\n {\n name: 'email',\n type: 'email',\n label: ({ t }) => (t as PluginT)('reservation:fieldGuestEmail'),\n },\n {\n name: 'phone',\n type: 'text',\n label: ({ t }) => (t as PluginT)('reservation:fieldGuestPhone'),\n maxLength: 50,\n },\n ],\n label: ({ t }) => (t as PluginT)('reservation:fieldGuest'),\n },\n {\n name: 'cancellationToken',\n type: 'text',\n access: {\n // Server-generated secret: never settable via the API (the\n // validateGuestBooking hook stamps it), readable only by staff/admin.\n create: () => false,\n read: ({ req }) =>\n Boolean(req.user) && req.user!.collection !== config.slugs.customers,\n update: () => false,\n },\n admin: {\n hidden: true,\n },\n index: true,\n },\n {\n name: 'startTime',\n type: 'date',\n admin: {\n components: {\n Field: 'payload-reserve/client#AvailabilityTimeField',\n },\n date: {\n pickerAppearance: 'dayAndTime',\n },\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldStartTime'),\n required: true,\n },\n {\n name: 'endTime',\n type: 'date',\n admin: {\n // Editable: flexible-duration services require a user-supplied endTime\n // (calculateEndTime overwrites it for fixed/full-day on save).\n date: {\n pickerAppearance: 'dayAndTime',\n },\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldEndTime'),\n },\n {\n name: 'status',\n type: 'select',\n defaultValue: statusMachine.defaultStatus,\n label: ({ t }) => (t as PluginT)('reservation:fieldStatus'),\n options: statusMachine.statuses.map((s) => ({\n label: ({ t }) => {\n const key = statusToI18nKey(s)\n const translated = (t as PluginT)(key)\n return translated !== key ? translated : s.charAt(0).toUpperCase() + s.slice(1)\n },\n value: s,\n })),\n },\n {\n name: 'cancellationReason',\n type: 'textarea',\n admin: {\n condition: (_, siblingData) => siblingData?.status === statusMachine.cancelStatus,\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldCancellationReason'),\n },\n {\n name: 'guestCount',\n type: 'number',\n defaultValue: 1,\n label: ({ t }) => (t as PluginT)('reservation:fieldGuestCount'),\n min: 1,\n },\n {\n name: 'notes',\n type: 'textarea',\n label: ({ t }) => (t as PluginT)('reservation:fieldNotes'),\n },\n {\n name: 'items',\n type: 'array',\n admin: {\n description: ({ t }) => (t as PluginT)('reservation:fieldItemsDesc'),\n },\n fields: [\n {\n name: 'resource',\n type: 'relationship',\n label: ({ t }) => (t as PluginT)('reservation:fieldResource'),\n relationTo: config.slugs.resources as unknown as CollectionSlug,\n required: true,\n },\n {\n name: 'service',\n type: 'relationship',\n label: ({ t }) => (t as PluginT)('reservation:fieldService'),\n relationTo: config.slugs.services as unknown as CollectionSlug,\n },\n {\n name: 'startTime',\n type: 'date',\n admin: { date: { pickerAppearance: 'dayAndTime' } },\n label: ({ t }) => (t as PluginT)('reservation:fieldStartTime'),\n },\n {\n name: 'endTime',\n type: 'date',\n admin: { date: { pickerAppearance: 'dayAndTime' }, readOnly: false },\n label: ({ t }) => (t as PluginT)('reservation:fieldEndTime'),\n },\n {\n name: 'guestCount',\n type: 'number',\n label: ({ t }) => (t as PluginT)('reservation:fieldGuestCount'),\n min: 1,\n },\n ],\n label: ({ t }) => (t as PluginT)('reservation:fieldItems'),\n },\n {\n name: 'idempotencyKey',\n type: 'text',\n admin: { position: 'sidebar', readOnly: true },\n index: true,\n unique: true,\n },\n ...config.extraReservationFields,\n ],\n hooks: {\n afterChange: [\n createPluginHooksAfterCreate(config.hooks),\n onStatusChange(config),\n ],\n beforeChange: [\n createPluginHooksBeforeCreate(config.hooks),\n enforceCustomerOwnership(config),\n checkIdempotency(config),\n validateGuestBooking(config),\n expandRequiredResources(config),\n // After expandRequiredResources so auto-injected required pools are\n // checked too; before validateConflicts so rejection is cheap.\n validateActive(config),\n calculateEndTime(config),\n // Must precede validateConflicts: it manufactures the write contention\n // that makes the conflict check meaningful under concurrency.\n acquireBookingLock(config),\n validateConflicts(config),\n // validateCancellation runs BEFORE validateStatusTransition so a cancel\n // rejected by the notice period never fires the beforeBookingCancel\n // plugin hooks (e.g. refund initiation) for an update that won't land.\n validateCancellation(config),\n validateStatusTransition(config),\n ],\n },\n labels: {\n plural: ({ t }) => (t as PluginT)('reservation:collectionReservations'),\n singular: ({ t }) => (t as PluginT)('reservation:collectionReservations'),\n },\n }\n}\n"],"names":["acquireBookingLock","calculateEndTime","checkIdempotency","enforceCustomerOwnership","expandRequiredResources","onStatusChange","validateActive","validateCancellation","validateConflicts","validateGuestBooking","validateStatusTransition","statusToI18nKey","composeAccess","makeReservationOwnerAccess","createPluginHooksBeforeCreate","hooks","context","data","operation","req","skipReservationHooks","beforeBookingCreate","mutatedData","hook","result","createPluginHooksAfterCreate","doc","afterBookingCreate","docRecord","createReservationsCollection","config","statusMachine","rom","resourceOwnerMode","access","reservations","slug","slugs","admin","components","views","list","Component","group","adminGroup","listSearchableFields","useAsTitle","fields","name","type","label","t","relationTo","services","required","resources","allowCreate","allowEdit","Field","customers","description","maxLength","create","read","Boolean","user","collection","update","hidden","index","date","pickerAppearance","defaultValue","defaultStatus","options","statuses","map","s","key","translated","charAt","toUpperCase","slice","value","condition","_","siblingData","status","cancelStatus","min","readOnly","position","unique","extraReservationFields","afterChange","beforeChange","labels","plural","singular"],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import { preventDeleteWhenReferenced } from '../hooks/shared/preventDeleteWhenReferenced.js';
1
2
  import { composeAccess, makeResourceOwnerAccess } from '../utilities/ownerAccess.js';
2
3
  import { buildSelectOptions } from '../utilities/selectOptions.js';
3
4
  /**
@@ -143,8 +144,41 @@ export function createResourcesCollection(config) {
143
144
  },
144
145
  ...ownerFieldDef ? [
145
146
  ownerFieldDef
146
- ] : []
147
+ ] : [],
148
+ {
149
+ // Serialization point for concurrent bookings. Every booking write
150
+ // touches this field on each resource it claims, inside the booking's
151
+ // transaction — which turns "two transactions insert different
152
+ // reservation documents" (which no database serializes) into "two
153
+ // transactions write the SAME document" (which every database does).
154
+ // The value is never read; only the write matters.
155
+ name: 'bookingLock',
156
+ type: 'text',
157
+ admin: {
158
+ hidden: true
159
+ }
160
+ }
147
161
  ],
162
+ hooks: {
163
+ beforeDelete: [
164
+ preventDeleteWhenReferenced({
165
+ config,
166
+ // Schedules.resource is also required: true — the exact same
167
+ // NOT NULL / ON DELETE SET NULL contradiction, one relationship
168
+ // over. A resource with schedules but zero reservations must be
169
+ // blocked too.
170
+ extraChecks: [
171
+ {
172
+ collection: config.slugs.schedules,
173
+ field: 'resource',
174
+ label: 'schedule'
175
+ }
176
+ ],
177
+ field: 'resource',
178
+ label: 'resource'
179
+ })
180
+ ]
181
+ },
148
182
  labels: {
149
183
  plural: ({ t })=>t('reservation:collectionResources'),
150
184
  singular: ({ t })=>t('reservation:collectionResources')