payload-reserve 2.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +218 -4
  2. package/dist/collections/Holds.d.ts +18 -0
  3. package/dist/collections/Holds.js +113 -0
  4. package/dist/collections/Holds.js.map +1 -0
  5. package/dist/collections/Reservations.js +8 -0
  6. package/dist/collections/Reservations.js.map +1 -1
  7. package/dist/collections/Resources.js +35 -1
  8. package/dist/collections/Resources.js.map +1 -1
  9. package/dist/collections/Services.js +41 -0
  10. package/dist/collections/Services.js.map +1 -1
  11. package/dist/components/CalendarView/LaneTimelineView.js +4 -5
  12. package/dist/components/CalendarView/LaneTimelineView.js.map +1 -1
  13. package/dist/components/CalendarView/index.js +55 -71
  14. package/dist/components/CalendarView/index.js.map +1 -1
  15. package/dist/components/DashboardWidget/DashboardWidgetServer.js +11 -74
  16. package/dist/components/DashboardWidget/DashboardWidgetServer.js.map +1 -1
  17. package/dist/components/DashboardWidget/fetchDashboardStats.d.ts +42 -0
  18. package/dist/components/DashboardWidget/fetchDashboardStats.js +113 -0
  19. package/dist/components/DashboardWidget/fetchDashboardStats.js.map +1 -0
  20. package/dist/defaults.d.ts +2 -0
  21. package/dist/defaults.js +12 -0
  22. package/dist/defaults.js.map +1 -1
  23. package/dist/endpoints/cancelBooking.js +43 -14
  24. package/dist/endpoints/cancelBooking.js.map +1 -1
  25. package/dist/endpoints/checkAvailability.js +27 -1
  26. package/dist/endpoints/checkAvailability.js.map +1 -1
  27. package/dist/endpoints/createBooking.js +108 -7
  28. package/dist/endpoints/createBooking.js.map +1 -1
  29. package/dist/endpoints/customerSearch.js +6 -0
  30. package/dist/endpoints/customerSearch.js.map +1 -1
  31. package/dist/endpoints/effectiveTimezone.js +1 -0
  32. package/dist/endpoints/effectiveTimezone.js.map +1 -1
  33. package/dist/endpoints/getSlots.js +27 -1
  34. package/dist/endpoints/getSlots.js.map +1 -1
  35. package/dist/endpoints/holdSlot.d.ts +12 -0
  36. package/dist/endpoints/holdSlot.js +126 -0
  37. package/dist/endpoints/holdSlot.js.map +1 -0
  38. package/dist/endpoints/releaseSlot.d.ts +8 -0
  39. package/dist/endpoints/releaseSlot.js +34 -0
  40. package/dist/endpoints/releaseSlot.js.map +1 -0
  41. package/dist/endpoints/resourceAvailability.d.ts +4 -0
  42. package/dist/endpoints/resourceAvailability.js +105 -4
  43. package/dist/endpoints/resourceAvailability.js.map +1 -1
  44. package/dist/hooks/holds/validateHoldSlot.d.ts +14 -0
  45. package/dist/hooks/holds/validateHoldSlot.js +90 -0
  46. package/dist/hooks/holds/validateHoldSlot.js.map +1 -0
  47. package/dist/hooks/reservations/acquireBookingLock.d.ts +30 -0
  48. package/dist/hooks/reservations/acquireBookingLock.js +71 -0
  49. package/dist/hooks/reservations/acquireBookingLock.js.map +1 -0
  50. package/dist/hooks/reservations/calculateEndTime.js +13 -1
  51. package/dist/hooks/reservations/calculateEndTime.js.map +1 -1
  52. package/dist/hooks/reservations/expandRequiredResources.js +3 -0
  53. package/dist/hooks/reservations/expandRequiredResources.js.map +1 -1
  54. package/dist/hooks/reservations/validateActive.d.ts +11 -0
  55. package/dist/hooks/reservations/validateActive.js +103 -0
  56. package/dist/hooks/reservations/validateActive.js.map +1 -0
  57. package/dist/hooks/reservations/validateConflicts.js +22 -1
  58. package/dist/hooks/reservations/validateConflicts.js.map +1 -1
  59. package/dist/hooks/reservations/validateGuestBooking.js +3 -0
  60. package/dist/hooks/reservations/validateGuestBooking.js.map +1 -1
  61. package/dist/hooks/shared/preventDeleteWhenReferenced.d.ts +42 -0
  62. package/dist/hooks/shared/preventDeleteWhenReferenced.js +87 -0
  63. package/dist/hooks/shared/preventDeleteWhenReferenced.js.map +1 -0
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/plugin.js +179 -3
  67. package/dist/plugin.js.map +1 -1
  68. package/dist/services/AvailabilityService.d.ts +51 -5
  69. package/dist/services/AvailabilityService.js +315 -38
  70. package/dist/services/AvailabilityService.js.map +1 -1
  71. package/dist/services/HoldService.d.ts +53 -0
  72. package/dist/services/HoldService.js +169 -0
  73. package/dist/services/HoldService.js.map +1 -0
  74. package/dist/services/index.d.ts +1 -0
  75. package/dist/services/index.js.map +1 -1
  76. package/dist/translations/ar.json +5 -1
  77. package/dist/translations/de.json +5 -1
  78. package/dist/translations/en.json +5 -1
  79. package/dist/translations/es.json +5 -1
  80. package/dist/translations/fa.json +5 -1
  81. package/dist/translations/fr.json +5 -1
  82. package/dist/translations/hi.json +5 -1
  83. package/dist/translations/id.json +5 -1
  84. package/dist/translations/pl.json +5 -1
  85. package/dist/translations/ru.json +5 -1
  86. package/dist/translations/tr.json +5 -1
  87. package/dist/translations/zh.json +5 -1
  88. package/dist/types.d.ts +32 -0
  89. package/dist/types.js.map +1 -1
  90. package/dist/utilities/calendarGrid.d.ts +44 -0
  91. package/dist/utilities/calendarGrid.js +67 -0
  92. package/dist/utilities/calendarGrid.js.map +1 -0
  93. package/dist/utilities/reservationChanges.d.ts +6 -1
  94. package/dist/utilities/reservationChanges.js +1 -1
  95. package/dist/utilities/reservationChanges.js.map +1 -1
  96. package/dist/utilities/reserveDebug.d.ts +26 -0
  97. package/dist/utilities/reserveDebug.js +35 -0
  98. package/dist/utilities/reserveDebug.js.map +1 -0
  99. package/dist/utilities/resolveReservationItems.d.ts +13 -2
  100. package/dist/utilities/resolveReservationItems.js +87 -2
  101. package/dist/utilities/resolveReservationItems.js.map +1 -1
  102. package/dist/utilities/retryOnWriteConflict.d.ts +66 -0
  103. package/dist/utilities/retryOnWriteConflict.js +135 -0
  104. package/dist/utilities/retryOnWriteConflict.js.map +1 -0
  105. package/dist/utilities/tenantTimezone.d.ts +77 -1
  106. package/dist/utilities/tenantTimezone.js +163 -2
  107. package/dist/utilities/tenantTimezone.js.map +1 -1
  108. package/dist/utilities/transactionSupport.d.ts +13 -0
  109. package/dist/utilities/transactionSupport.js +30 -0
  110. package/dist/utilities/transactionSupport.js.map +1 -0
  111. package/package.json +13 -13
@@ -1,3 +1,4 @@
1
+ import { collectionHasTenantField } from './tenantFilter.js';
1
2
  import { isValidTimezone } from './timezoneUtils.js';
2
3
  /**
3
4
  * Pure precedence resolver for a tenant's effective timezone:
@@ -43,7 +44,7 @@ import { isValidTimezone } from './timezoneUtils.js';
43
44
  * selected, when the scoped collection lacks a tenant relationship, or when the
44
45
  * lookup fails. Never throws.
45
46
  */ export async function getEffectiveTenantTimezone(args) {
46
- const { globalTimezone, payload, scopedCollection, tenantField, tenantId, timezoneField } = args;
47
+ const { globalTimezone, payload, req, scopedCollection, tenantField, tenantId, timezoneField } = args;
47
48
  if (!tenantId) {
48
49
  return resolveTenantTimezone({
49
50
  globalTimezone
@@ -57,11 +58,19 @@ import { isValidTimezone } from './timezoneUtils.js';
57
58
  }
58
59
  let tenantTimezone = null;
59
60
  try {
61
+ // The tenant id comes from a cookie, so the read must be access-checked —
62
+ // multi-tenant constrains its tenants collection by `id`, which turns this
63
+ // into a membership check. Without a req we cannot check, so stay privileged
64
+ // and let the caller's own gate apply.
60
65
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
66
  const tenant = await payload.findByID({
62
67
  id: tenantId,
63
68
  collection: tenantSlug,
64
- depth: 0
69
+ depth: 0,
70
+ ...req ? {
71
+ overrideAccess: false,
72
+ req
73
+ } : {}
65
74
  });
66
75
  const raw = tenant?.[timezoneField];
67
76
  tenantTimezone = typeof raw === 'string' ? raw : null;
@@ -73,5 +82,157 @@ import { isValidTimezone } from './timezoneUtils.js';
73
82
  tenantTimezone
74
83
  });
75
84
  }
85
+ /**
86
+ * Normalizes a relationship-shaped value down to its id: a raw string/number
87
+ * id, or an object carrying one — `{ id }`, the shape both a populated
88
+ * relationship and a client sending `{ "tenant": { "id": "..." } }` produce.
89
+ * Returns null for anything else (including an object with no usable `id`),
90
+ * so callers can fail closed on a shape they don't recognize instead of
91
+ * silently skipping whatever check they were about to run.
92
+ */ export function normalizeRelationshipId(value) {
93
+ if (typeof value === 'string' || typeof value === 'number') {
94
+ return value;
95
+ }
96
+ if (value && typeof value === 'object' && 'id' in value) {
97
+ const id = value.id;
98
+ if (typeof id === 'string' || typeof id === 'number') {
99
+ return id;
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+ /**
105
+ * Every tenant id a tenant-field value names, as a list.
106
+ *
107
+ * Exists because multi-tenant supports a `hasMany` tenant field, so the value
108
+ * can legitimately be an ARRAY of relationship-shaped entries.
109
+ * {@link normalizeRelationshipId} returns null for an array, and the caller
110
+ * fails closed on null — which turned a supported multi-tenant configuration
111
+ * into a blanket 403 on every authenticated booking that carried an explicit
112
+ * tenant (a total booking outage for that install, with no diagnostic).
113
+ *
114
+ * - `[]` -> `[]`: an empty array writes no tenant, so there is nothing to
115
+ * authorize; the caller permits it, exactly as it does for an absent value.
116
+ * - a one-or-many array -> one id per entry, all of which the caller must be
117
+ * authorized for (any single unrecognized entry poisons the whole value).
118
+ * - a scalar / `{ id }` -> a single-element list.
119
+ * - anything else -> null, so the caller can still fail closed, loudly.
120
+ */ export function normalizeRelationshipIds(value) {
121
+ if (Array.isArray(value)) {
122
+ const ids = [];
123
+ for (const entry of value){
124
+ const id = normalizeRelationshipId(entry);
125
+ if (id === null) {
126
+ return null;
127
+ }
128
+ ids.push(id);
129
+ }
130
+ return ids;
131
+ }
132
+ const single = normalizeRelationshipId(value);
133
+ return single === null ? null : [
134
+ single
135
+ ];
136
+ }
137
+ /**
138
+ * Whether an authenticated caller may write the tenant value present in
139
+ * `data[config.multiTenant.tenantField]` onto a new document in
140
+ * `config.slugs.reservations`.
141
+ *
142
+ * Exists because delegating `overrideAccess` is NOT sufficient on its own:
143
+ * multi-tenant's tenant-field `validate` only checks presence, and Payload's
144
+ * `create` operation (`executeAccess`) only checks the TRUTHINESS of a
145
+ * collection access result — unlike read/update/delete, which apply a
146
+ * returned `Where` via `combineQueries` against a real document. So MT's own
147
+ * tenant-scoped `create` access can't reject an explicit foreign tenant
148
+ * either; an authenticated caller can write one straight through regardless
149
+ * of `overrideAccess`. This closes that gap the same way
150
+ * {@link getEffectiveTenantTimezone} closes it for a client-supplied tenant
151
+ * *cookie*: an access-checked (`overrideAccess: false`) probe read against
152
+ * the tenants collection for the *submitted* tenant id — a lookup that finds
153
+ * nothing means the caller isn't a member (or a multi-tenant super-admin).
154
+ *
155
+ * PRECONDITION — this is a real membership check only when the caller
156
+ * authenticates against the SAME collection multi-tenant wraps as its
157
+ * admin/tenant-owning collection. `withTenantAccess` only applies its
158
+ * membership constraint when `req.user.collection === adminUsersSlug`;
159
+ * otherwise it falls back to the tenants collection's own (here: default,
160
+ * unrestricted) access function, which only requires `Boolean(req.user)`. In
161
+ * this plugin's STANDALONE mode (`userCollection` unset), a customer
162
+ * authenticates against `slugs.customers` — a collection multi-tenant never
163
+ * wraps — so this probe passes for ANY tenant id for a logged-in customer.
164
+ * `src/plugin.ts`'s boot diagnostic warns about that configuration. It is
165
+ * also a no-op if the host set multi-tenant's own
166
+ * `useTenantsCollectionAccess: false`, which is not detectable from here.
167
+ *
168
+ * Returns true — nothing to check — when the reservations collection isn't
169
+ * tenant-scoped at all, or `data` doesn't carry an explicit tenant value (MT's
170
+ * own access-checked `defaultValue` applies in that case; see
171
+ * `getEffectiveTenantTimezone`'s doc comment for why that path is already
172
+ * safe). Returns false — fail closed — for an unrecognized value shape (logged,
173
+ * since a shape this plugin cannot read refuses every such booking), a probe
174
+ * that finds no matching tenant, or any error while probing.
175
+ *
176
+ * A `hasMany` tenant field is supported: the value may be an array, and EVERY
177
+ * id in it is probed.
178
+ */ export async function callerMayUseTenant(args) {
179
+ const { config, data, req } = args;
180
+ const tenantField = config.multiTenant.tenantField;
181
+ const rawTenant = data[tenantField];
182
+ if (rawTenant === undefined || rawTenant === null) {
183
+ return true;
184
+ }
185
+ const reservationsCollection = req.payload.config.collections?.find((c)=>c.slug === config.slugs.reservations);
186
+ if (!collectionHasTenantField(reservationsCollection, tenantField)) {
187
+ return true;
188
+ }
189
+ const tenantSlug = tenantCollectionSlug(reservationsCollection, tenantField);
190
+ if (!tenantSlug) {
191
+ return true;
192
+ }
193
+ // A `hasMany` tenant field is a supported multi-tenant configuration, so the
194
+ // value may be an array — every id in it must be authorized.
195
+ const tenantIds = normalizeRelationshipIds(rawTenant);
196
+ if (tenantIds === null) {
197
+ // Still fail closed, but say so: an unrecognized shape refuses the booking,
198
+ // and silently refusing every booking is indistinguishable from an outage.
199
+ // The value itself is caller-supplied and is deliberately NOT logged.
200
+ req.payload.logger.warn({
201
+ msg: `payload-reserve: refusing a booking because its "${tenantField}" value has an unrecognized shape. Expected an id, a { id } object, or an array of either (a "hasMany" tenant field). If your tenant field uses a shape this plugin does not recognize, every authenticated booking carrying an explicit tenant will be refused with a 403.`,
202
+ tenantValueShape: Array.isArray(rawTenant) ? 'array' : typeof rawTenant
203
+ });
204
+ return false;
205
+ }
206
+ for (const tenantId of tenantIds){
207
+ try {
208
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
+ const tenant = await req.payload.findByID({
210
+ id: tenantId,
211
+ collection: tenantSlug,
212
+ depth: 0,
213
+ overrideAccess: false,
214
+ req
215
+ });
216
+ if (!tenant) {
217
+ return false;
218
+ }
219
+ } catch (err) {
220
+ // Forbidden/NotFound is exactly the "not a member" outcome this probe
221
+ // exists to detect — expected, not worth logging. Anything else (a DB
222
+ // outage, a genuine bug) still fails closed but is worth surfacing.
223
+ const status = err?.status;
224
+ if (status !== 403 && status !== 404) {
225
+ req.payload.logger.warn({
226
+ err,
227
+ msg: 'payload-reserve: tenant membership probe failed'
228
+ });
229
+ }
230
+ return false;
231
+ }
232
+ }
233
+ // Includes the empty-array case: no tenant is being written, so there is
234
+ // nothing to authorize.
235
+ return true;
236
+ }
76
237
 
77
238
  //# sourceMappingURL=tenantTimezone.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utilities/tenantTimezone.ts"],"sourcesContent":["import type { Payload } from 'payload'\n\nimport { isValidTimezone } from './timezoneUtils.js'\n\ntype CollectionLike = { fields?: unknown[] } | null | undefined\n\ntype FieldLike = { name?: string; relationTo?: string | string[]; type?: string }\n\n/**\n * Pure precedence resolver for a tenant's effective timezone:\n *\n * selected tenant's zone → global default → 'UTC'\n *\n * An absent or invalid zone at any step is skipped, never thrown — so a tenant\n * with a bad/empty timezone value transparently falls back to the global default.\n */\nexport function resolveTenantTimezone(args: {\n globalTimezone: string\n tenantTimezone?: null | string\n}): string {\n const { globalTimezone, tenantTimezone } = args\n if (isValidTimezone(tenantTimezone)) {\n return tenantTimezone\n }\n if (isValidTimezone(globalTimezone)) {\n return globalTimezone\n }\n return 'UTC'\n}\n\n/**\n * The tenant collection's slug, read off the scoped collection's tenant\n * relationship field (`relationTo`). Keeps the plugin tenant-agnostic — it never\n * hardcodes a tenants slug. Returns null for an absent, polymorphic, or\n * non-relationship tenant field.\n */\nexport function tenantCollectionSlug(collection: CollectionLike, tenantField: string): null | string {\n const fields = collection?.fields\n if (!Array.isArray(fields)) {\n return null\n }\n for (const f of fields) {\n if (typeof f === 'object' && f !== null && (f as FieldLike).name === tenantField) {\n const rel = (f as FieldLike).relationTo\n return typeof rel === 'string' ? rel : null\n }\n }\n return null\n}\n\n/**\n * Resolve the effective timezone for the selected tenant in multiTenant mode.\n *\n * Loads the tenant document (slug derived from the scoped collection's tenant\n * relationship) and reads `timezoneField`, then applies {@link resolveTenantTimezone}\n * precedence. Returns the global default — without a DB read — when no tenant is\n * selected, when the scoped collection lacks a tenant relationship, or when the\n * lookup fails. Never throws.\n */\nexport async function getEffectiveTenantTimezone(args: {\n globalTimezone: string\n payload: Payload\n scopedCollection: CollectionLike\n tenantField: string\n tenantId: null | string\n timezoneField: string\n}): Promise<string> {\n const { globalTimezone, payload, scopedCollection, tenantField, tenantId, timezoneField } = args\n if (!tenantId) {\n return resolveTenantTimezone({ globalTimezone })\n }\n const tenantSlug = tenantCollectionSlug(scopedCollection, tenantField)\n if (!tenantSlug) {\n return resolveTenantTimezone({ globalTimezone })\n }\n let tenantTimezone: null | string = null\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const tenant = await (payload.findByID as any)({\n id: tenantId,\n collection: tenantSlug,\n depth: 0,\n })\n const raw = (tenant as null | Record<string, unknown>)?.[timezoneField]\n tenantTimezone = typeof raw === 'string' ? raw : null\n } catch {\n tenantTimezone = null\n }\n return resolveTenantTimezone({ globalTimezone, tenantTimezone })\n}\n"],"names":["isValidTimezone","resolveTenantTimezone","args","globalTimezone","tenantTimezone","tenantCollectionSlug","collection","tenantField","fields","Array","isArray","f","name","rel","relationTo","getEffectiveTenantTimezone","payload","scopedCollection","tenantId","timezoneField","tenantSlug","tenant","findByID","id","depth","raw"],"mappings":"AAEA,SAASA,eAAe,QAAQ,qBAAoB;AAMpD;;;;;;;CAOC,GACD,OAAO,SAASC,sBAAsBC,IAGrC;IACC,MAAM,EAAEC,cAAc,EAAEC,cAAc,EAAE,GAAGF;IAC3C,IAAIF,gBAAgBI,iBAAiB;QACnC,OAAOA;IACT;IACA,IAAIJ,gBAAgBG,iBAAiB;QACnC,OAAOA;IACT;IACA,OAAO;AACT;AAEA;;;;;CAKC,GACD,OAAO,SAASE,qBAAqBC,UAA0B,EAAEC,WAAmB;IAClF,MAAMC,SAASF,YAAYE;IAC3B,IAAI,CAACC,MAAMC,OAAO,CAACF,SAAS;QAC1B,OAAO;IACT;IACA,KAAK,MAAMG,KAAKH,OAAQ;QACtB,IAAI,OAAOG,MAAM,YAAYA,MAAM,QAAQ,AAACA,EAAgBC,IAAI,KAAKL,aAAa;YAChF,MAAMM,MAAM,AAACF,EAAgBG,UAAU;YACvC,OAAO,OAAOD,QAAQ,WAAWA,MAAM;QACzC;IACF;IACA,OAAO;AACT;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeE,2BAA2Bb,IAOhD;IACC,MAAM,EAAEC,cAAc,EAAEa,OAAO,EAAEC,gBAAgB,EAAEV,WAAW,EAAEW,QAAQ,EAAEC,aAAa,EAAE,GAAGjB;IAC5F,IAAI,CAACgB,UAAU;QACb,OAAOjB,sBAAsB;YAAEE;QAAe;IAChD;IACA,MAAMiB,aAAaf,qBAAqBY,kBAAkBV;IAC1D,IAAI,CAACa,YAAY;QACf,OAAOnB,sBAAsB;YAAEE;QAAe;IAChD;IACA,IAAIC,iBAAgC;IACpC,IAAI;QACF,8DAA8D;QAC9D,MAAMiB,SAAS,MAAM,AAACL,QAAQM,QAAQ,CAAS;YAC7CC,IAAIL;YACJZ,YAAYc;YACZI,OAAO;QACT;QACA,MAAMC,MAAOJ,QAA2C,CAACF,cAAc;QACvEf,iBAAiB,OAAOqB,QAAQ,WAAWA,MAAM;IACnD,EAAE,OAAM;QACNrB,iBAAiB;IACnB;IACA,OAAOH,sBAAsB;QAAEE;QAAgBC;IAAe;AAChE"}
1
+ {"version":3,"sources":["../../src/utilities/tenantTimezone.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { collectionHasTenantField } from './tenantFilter.js'\nimport { isValidTimezone } from './timezoneUtils.js'\n\ntype CollectionLike = { fields?: unknown[] } | null | undefined\n\ntype FieldLike = { name?: string; relationTo?: string | string[]; type?: string }\n\n/**\n * Pure precedence resolver for a tenant's effective timezone:\n *\n * selected tenant's zone → global default → 'UTC'\n *\n * An absent or invalid zone at any step is skipped, never thrown — so a tenant\n * with a bad/empty timezone value transparently falls back to the global default.\n */\nexport function resolveTenantTimezone(args: {\n globalTimezone: string\n tenantTimezone?: null | string\n}): string {\n const { globalTimezone, tenantTimezone } = args\n if (isValidTimezone(tenantTimezone)) {\n return tenantTimezone\n }\n if (isValidTimezone(globalTimezone)) {\n return globalTimezone\n }\n return 'UTC'\n}\n\n/**\n * The tenant collection's slug, read off the scoped collection's tenant\n * relationship field (`relationTo`). Keeps the plugin tenant-agnostic — it never\n * hardcodes a tenants slug. Returns null for an absent, polymorphic, or\n * non-relationship tenant field.\n */\nexport function tenantCollectionSlug(collection: CollectionLike, tenantField: string): null | string {\n const fields = collection?.fields\n if (!Array.isArray(fields)) {\n return null\n }\n for (const f of fields) {\n if (typeof f === 'object' && f !== null && (f as FieldLike).name === tenantField) {\n const rel = (f as FieldLike).relationTo\n return typeof rel === 'string' ? rel : null\n }\n }\n return null\n}\n\n/**\n * Resolve the effective timezone for the selected tenant in multiTenant mode.\n *\n * Loads the tenant document (slug derived from the scoped collection's tenant\n * relationship) and reads `timezoneField`, then applies {@link resolveTenantTimezone}\n * precedence. Returns the global default — without a DB read — when no tenant is\n * selected, when the scoped collection lacks a tenant relationship, or when the\n * lookup fails. Never throws.\n */\nexport async function getEffectiveTenantTimezone(args: {\n globalTimezone: string\n payload: Payload\n req?: PayloadRequest\n scopedCollection: CollectionLike\n tenantField: string\n tenantId: null | string\n timezoneField: string\n}): Promise<string> {\n const { globalTimezone, payload, req, scopedCollection, tenantField, tenantId, timezoneField } =\n args\n if (!tenantId) {\n return resolveTenantTimezone({ globalTimezone })\n }\n const tenantSlug = tenantCollectionSlug(scopedCollection, tenantField)\n if (!tenantSlug) {\n return resolveTenantTimezone({ globalTimezone })\n }\n let tenantTimezone: null | string = null\n try {\n // The tenant id comes from a cookie, so the read must be access-checked —\n // multi-tenant constrains its tenants collection by `id`, which turns this\n // into a membership check. Without a req we cannot check, so stay privileged\n // and let the caller's own gate apply.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const tenant = await (payload.findByID as any)({\n id: tenantId,\n collection: tenantSlug,\n depth: 0,\n ...(req ? { overrideAccess: false, req } : {}),\n })\n const raw = (tenant as null | Record<string, unknown>)?.[timezoneField]\n tenantTimezone = typeof raw === 'string' ? raw : null\n } catch {\n tenantTimezone = null\n }\n return resolveTenantTimezone({ globalTimezone, tenantTimezone })\n}\n\n/**\n * Normalizes a relationship-shaped value down to its id: a raw string/number\n * id, or an object carrying one — `{ id }`, the shape both a populated\n * relationship and a client sending `{ \"tenant\": { \"id\": \"...\" } }` produce.\n * Returns null for anything else (including an object with no usable `id`),\n * so callers can fail closed on a shape they don't recognize instead of\n * silently skipping whatever check they were about to run.\n */\nexport function normalizeRelationshipId(value: unknown): null | number | string {\n if (typeof value === 'string' || typeof value === 'number') {\n return value\n }\n if (value && typeof value === 'object' && 'id' in value) {\n const id = (value as { id?: unknown }).id\n if (typeof id === 'string' || typeof id === 'number') {\n return id\n }\n }\n return null\n}\n\n/**\n * Every tenant id a tenant-field value names, as a list.\n *\n * Exists because multi-tenant supports a `hasMany` tenant field, so the value\n * can legitimately be an ARRAY of relationship-shaped entries.\n * {@link normalizeRelationshipId} returns null for an array, and the caller\n * fails closed on null — which turned a supported multi-tenant configuration\n * into a blanket 403 on every authenticated booking that carried an explicit\n * tenant (a total booking outage for that install, with no diagnostic).\n *\n * - `[]` -> `[]`: an empty array writes no tenant, so there is nothing to\n * authorize; the caller permits it, exactly as it does for an absent value.\n * - a one-or-many array -> one id per entry, all of which the caller must be\n * authorized for (any single unrecognized entry poisons the whole value).\n * - a scalar / `{ id }` -> a single-element list.\n * - anything else -> null, so the caller can still fail closed, loudly.\n */\nexport function normalizeRelationshipIds(value: unknown): Array<number | string> | null {\n if (Array.isArray(value)) {\n const ids: Array<number | string> = []\n for (const entry of value) {\n const id = normalizeRelationshipId(entry)\n if (id === null) {\n return null\n }\n ids.push(id)\n }\n return ids\n }\n const single = normalizeRelationshipId(value)\n return single === null ? null : [single]\n}\n\n/**\n * Whether an authenticated caller may write the tenant value present in\n * `data[config.multiTenant.tenantField]` onto a new document in\n * `config.slugs.reservations`.\n *\n * Exists because delegating `overrideAccess` is NOT sufficient on its own:\n * multi-tenant's tenant-field `validate` only checks presence, and Payload's\n * `create` operation (`executeAccess`) only checks the TRUTHINESS of a\n * collection access result — unlike read/update/delete, which apply a\n * returned `Where` via `combineQueries` against a real document. So MT's own\n * tenant-scoped `create` access can't reject an explicit foreign tenant\n * either; an authenticated caller can write one straight through regardless\n * of `overrideAccess`. This closes that gap the same way\n * {@link getEffectiveTenantTimezone} closes it for a client-supplied tenant\n * *cookie*: an access-checked (`overrideAccess: false`) probe read against\n * the tenants collection for the *submitted* tenant id — a lookup that finds\n * nothing means the caller isn't a member (or a multi-tenant super-admin).\n *\n * PRECONDITION — this is a real membership check only when the caller\n * authenticates against the SAME collection multi-tenant wraps as its\n * admin/tenant-owning collection. `withTenantAccess` only applies its\n * membership constraint when `req.user.collection === adminUsersSlug`;\n * otherwise it falls back to the tenants collection's own (here: default,\n * unrestricted) access function, which only requires `Boolean(req.user)`. In\n * this plugin's STANDALONE mode (`userCollection` unset), a customer\n * authenticates against `slugs.customers` — a collection multi-tenant never\n * wraps — so this probe passes for ANY tenant id for a logged-in customer.\n * `src/plugin.ts`'s boot diagnostic warns about that configuration. It is\n * also a no-op if the host set multi-tenant's own\n * `useTenantsCollectionAccess: false`, which is not detectable from here.\n *\n * Returns true — nothing to check — when the reservations collection isn't\n * tenant-scoped at all, or `data` doesn't carry an explicit tenant value (MT's\n * own access-checked `defaultValue` applies in that case; see\n * `getEffectiveTenantTimezone`'s doc comment for why that path is already\n * safe). Returns false — fail closed — for an unrecognized value shape (logged,\n * since a shape this plugin cannot read refuses every such booking), a probe\n * that finds no matching tenant, or any error while probing.\n *\n * A `hasMany` tenant field is supported: the value may be an array, and EVERY\n * id in it is probed.\n */\nexport async function callerMayUseTenant(args: {\n config: Pick<ResolvedReservationPluginConfig, 'multiTenant' | 'slugs'>\n data: Record<string, unknown>\n req: PayloadRequest\n}): Promise<boolean> {\n const { config, data, req } = args\n const tenantField = config.multiTenant.tenantField\n const rawTenant = data[tenantField]\n if (rawTenant === undefined || rawTenant === null) {\n return true\n }\n\n const reservationsCollection = req.payload.config.collections?.find(\n (c) => c.slug === config.slugs.reservations,\n ) as CollectionLike\n if (!collectionHasTenantField(reservationsCollection, tenantField)) {\n return true\n }\n const tenantSlug = tenantCollectionSlug(reservationsCollection, tenantField)\n if (!tenantSlug) {\n return true\n }\n\n // A `hasMany` tenant field is a supported multi-tenant configuration, so the\n // value may be an array — every id in it must be authorized.\n const tenantIds = normalizeRelationshipIds(rawTenant)\n if (tenantIds === null) {\n // Still fail closed, but say so: an unrecognized shape refuses the booking,\n // and silently refusing every booking is indistinguishable from an outage.\n // The value itself is caller-supplied and is deliberately NOT logged.\n req.payload.logger.warn({\n msg: `payload-reserve: refusing a booking because its \"${tenantField}\" value has an unrecognized shape. Expected an id, a { id } object, or an array of either (a \"hasMany\" tenant field). If your tenant field uses a shape this plugin does not recognize, every authenticated booking carrying an explicit tenant will be refused with a 403.`,\n tenantValueShape: Array.isArray(rawTenant) ? 'array' : typeof rawTenant,\n })\n return false\n }\n\n for (const tenantId of tenantIds) {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const tenant = await (req.payload.findByID as any)({\n id: tenantId,\n collection: tenantSlug,\n depth: 0,\n overrideAccess: false,\n req,\n })\n if (!tenant) {\n return false\n }\n } catch (err) {\n // Forbidden/NotFound is exactly the \"not a member\" outcome this probe\n // exists to detect — expected, not worth logging. Anything else (a DB\n // outage, a genuine bug) still fails closed but is worth surfacing.\n const status = (err as { status?: number } | undefined)?.status\n if (status !== 403 && status !== 404) {\n req.payload.logger.warn({ err, msg: 'payload-reserve: tenant membership probe failed' })\n }\n return false\n }\n }\n\n // Includes the empty-array case: no tenant is being written, so there is\n // nothing to authorize.\n return true\n}\n"],"names":["collectionHasTenantField","isValidTimezone","resolveTenantTimezone","args","globalTimezone","tenantTimezone","tenantCollectionSlug","collection","tenantField","fields","Array","isArray","f","name","rel","relationTo","getEffectiveTenantTimezone","payload","req","scopedCollection","tenantId","timezoneField","tenantSlug","tenant","findByID","id","depth","overrideAccess","raw","normalizeRelationshipId","value","normalizeRelationshipIds","ids","entry","push","single","callerMayUseTenant","config","data","multiTenant","rawTenant","undefined","reservationsCollection","collections","find","c","slug","slugs","reservations","tenantIds","logger","warn","msg","tenantValueShape","err","status"],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ import type { Payload } from 'payload';
2
+ /**
3
+ * Whether this database will actually give the plugin a transaction.
4
+ *
5
+ * It matters because `acquireBookingLock` is only meaningful inside one. Without
6
+ * a transaction the lock write still happens but serializes nothing, so
7
+ * concurrent bookings silently double-book exactly as they did before the lock
8
+ * existed — a failure that is open rather than closed, and invisible.
9
+ *
10
+ * Payload's mongoose adapter returns null from `beginTransaction` when the
11
+ * connection is not a replica set. Probing costs one no-op transaction at boot.
12
+ */
13
+ export declare function supportsTransactions(payload: Payload): Promise<boolean>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Whether this database will actually give the plugin a transaction.
3
+ *
4
+ * It matters because `acquireBookingLock` is only meaningful inside one. Without
5
+ * a transaction the lock write still happens but serializes nothing, so
6
+ * concurrent bookings silently double-book exactly as they did before the lock
7
+ * existed — a failure that is open rather than closed, and invisible.
8
+ *
9
+ * Payload's mongoose adapter returns null from `beginTransaction` when the
10
+ * connection is not a replica set. Probing costs one no-op transaction at boot.
11
+ */ export async function supportsTransactions(payload) {
12
+ const db = payload.db;
13
+ if (typeof db?.beginTransaction !== 'function') {
14
+ return false;
15
+ }
16
+ try {
17
+ const id = await db.beginTransaction();
18
+ if (id === null || id === undefined) {
19
+ return false;
20
+ }
21
+ await db.rollbackTransaction?.(id);
22
+ return true;
23
+ } catch {
24
+ // An adapter that throws here cannot give us a transaction, which is the
25
+ // only thing the caller needs to know.
26
+ return false;
27
+ }
28
+ }
29
+
30
+ //# sourceMappingURL=transactionSupport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utilities/transactionSupport.ts"],"sourcesContent":["import type { Payload } from 'payload'\n\n/**\n * Whether this database will actually give the plugin a transaction.\n *\n * It matters because `acquireBookingLock` is only meaningful inside one. Without\n * a transaction the lock write still happens but serializes nothing, so\n * concurrent bookings silently double-book exactly as they did before the lock\n * existed — a failure that is open rather than closed, and invisible.\n *\n * Payload's mongoose adapter returns null from `beginTransaction` when the\n * connection is not a replica set. Probing costs one no-op transaction at boot.\n */\nexport async function supportsTransactions(payload: Payload): Promise<boolean> {\n const db = payload.db as {\n beginTransaction?: (options?: unknown) => Promise<null | number | string>\n rollbackTransaction?: (id: unknown) => Promise<void>\n }\n\n if (typeof db?.beginTransaction !== 'function') {\n return false\n }\n\n try {\n const id = await db.beginTransaction()\n if (id === null || id === undefined) {\n return false\n }\n await db.rollbackTransaction?.(id)\n return true\n } catch {\n // An adapter that throws here cannot give us a transaction, which is the\n // only thing the caller needs to know.\n return false\n }\n}\n"],"names":["supportsTransactions","payload","db","beginTransaction","id","undefined","rollbackTransaction"],"mappings":"AAEA;;;;;;;;;;CAUC,GACD,OAAO,eAAeA,qBAAqBC,OAAgB;IACzD,MAAMC,KAAKD,QAAQC,EAAE;IAKrB,IAAI,OAAOA,IAAIC,qBAAqB,YAAY;QAC9C,OAAO;IACT;IAEA,IAAI;QACF,MAAMC,KAAK,MAAMF,GAAGC,gBAAgB;QACpC,IAAIC,OAAO,QAAQA,OAAOC,WAAW;YACnC,OAAO;QACT;QACA,MAAMH,GAAGI,mBAAmB,GAAGF;QAC/B,OAAO;IACT,EAAE,OAAM;QACN,yEAAyE;QACzE,uCAAuC;QACvC,OAAO;IACT;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payload-reserve",
3
- "version": "2.3.0",
3
+ "version": "3.0.0",
4
4
  "description": "A Payload CMS 3.x plugin for reservation and booking management with conflict detection, status workflows, and calendar UI",
5
5
  "keywords": [
6
6
  "payload",
@@ -49,15 +49,15 @@
49
49
  "@changesets/cli": "^2.30.0",
50
50
  "@elghaied/payload-plugin-sms": "^0.3.0",
51
51
  "@eslint/eslintrc": "^3.2.0",
52
- "@payloadcms/db-mongodb": "3.79.0",
53
- "@payloadcms/db-postgres": "3.79.0",
54
- "@payloadcms/db-sqlite": "3.79.0",
52
+ "@payloadcms/db-mongodb": "3.86.0",
53
+ "@payloadcms/db-postgres": "3.86.0",
54
+ "@payloadcms/db-sqlite": "3.86.0",
55
55
  "@payloadcms/eslint-config": "3.9.0",
56
- "@payloadcms/next": "3.79.0",
57
- "@payloadcms/plugin-multi-tenant": "3.79.0",
58
- "@payloadcms/richtext-lexical": "3.79.0",
59
- "@payloadcms/translations": "3.79.0",
60
- "@payloadcms/ui": "3.79.0",
56
+ "@payloadcms/next": "3.86.0",
57
+ "@payloadcms/plugin-multi-tenant": "3.86.0",
58
+ "@payloadcms/richtext-lexical": "3.86.0",
59
+ "@payloadcms/translations": "3.86.0",
60
+ "@payloadcms/ui": "3.86.0",
61
61
  "@playwright/test": "1.56.1",
62
62
  "@swc-node/register": "1.10.9",
63
63
  "@swc/cli": "0.6.0",
@@ -72,7 +72,7 @@
72
72
  "mongodb-memory-server": "10.1.4",
73
73
  "next": "15.4.11",
74
74
  "open": "^10.1.0",
75
- "payload": "3.79.0",
75
+ "payload": "3.86.0",
76
76
  "prettier": "^3.4.2",
77
77
  "qs-esm": "7.0.2",
78
78
  "react": "19.2.1",
@@ -85,9 +85,9 @@
85
85
  "vitest": "4.0.18"
86
86
  },
87
87
  "peerDependencies": {
88
- "@payloadcms/translations": "^3.79.0",
89
- "@payloadcms/ui": "^3.79.0",
90
- "payload": "^3.79.0"
88
+ "@payloadcms/translations": "^3.86.0",
89
+ "@payloadcms/ui": "^3.86.0",
90
+ "payload": "^3.86.0"
91
91
  },
92
92
  "engines": {
93
93
  "node": "^18.20.2 || >=20.9.0",