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
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Retry a write that lost a transaction-level lock race.
3
+ *
4
+ * `acquireBookingLock` makes concurrent bookings for one resource contend on a
5
+ * single document so the database serializes them. On MongoDB the loser does
6
+ * not wait — it aborts immediately with a WriteConflict. That is correct for a
7
+ * `quantity: 1` resource (only one booking should win anyway) but wrong for a
8
+ * `quantity: 3` one, where two more bookings legitimately fit. Measured
9
+ * without retry: 8 simultaneous bookings against a `quantity: 3` resource
10
+ * persisted only 1.
11
+ *
12
+ * Retrying re-runs the whole operation on a fresh transaction. The retry takes
13
+ * the lock cleanly, re-reads availability — now seeing the winner's committed
14
+ * booking — and is admitted or rejected by `validateConflicts` on the merits.
15
+ */ /** MongoDB WriteConflict. */ const MONGO_WRITE_CONFLICT = 112;
16
+ /** Postgres serialization_failure and deadlock_detected. */ const POSTGRES_SERIALIZATION_FAILURES = new Set([
17
+ '40P01',
18
+ '40001'
19
+ ]);
20
+ /**
21
+ * SQLite/libsql busy-or-locked family. Only reachable when the adapter is
22
+ * given a truthy `transactionOptions` — otherwise `beginTransaction` is a
23
+ * no-op and nothing ever contends. `SQLITE_BUSY` and plain `SQLITE_LOCKED`
24
+ * are the same family as the qualified variant measured below; prefix match
25
+ * (not an exact-set lookup like the Postgres codes above) because
26
+ * SQLite/libsql qualify the base code with a reason suffix (`_SHAREDCACHE`,
27
+ * `_SNAPSHOT`, ...) — the prefix is still the driver's own structured
28
+ * signal, never message text.
29
+ *
30
+ * KNOWN GAP, confirmed by direct inspection, not assumption: for a loser
31
+ * that aborts at `beginTransaction` itself (SQLite's actual failure mode —
32
+ * a second write transaction cannot even open while one is held, rather
33
+ * than queuing for it), this check can never fire in practice. The real
34
+ * driver error — a `LibsqlError` with `code: 'SQLITE_LOCKED_SHAREDCACHE'` —
35
+ * is caught *inside* `@payloadcms/drizzle`'s own `beginTransaction.js` (a
36
+ * Payload-core dependency, not this plugin) and re-thrown as a bare
37
+ * `new Error('Error: cannot begin transaction: ...')`: no `code`, no
38
+ * `cause`, generic `name`. By the time it reaches this function the
39
+ * structured signal is already gone, so retry never engages for that path
40
+ * — measured: burst of 8 against a `quantity: 3` resource with retry
41
+ * recovers only 1 of 3, identical to no retry at all, and raising the
42
+ * retry budget from 5 to 30 attempts makes no difference. This match is
43
+ * kept because it is still correct for any SQLite/libsql error shape that
44
+ * *does* preserve `code` (a mid-transaction conflict would be a different
45
+ * code path), and matching on the wrapped error's message text to plug this
46
+ * specific gap is exactly what this project's structured-signal-only
47
+ * constraint forbids.
48
+ */ const SQLITE_BUSY_OR_LOCKED_PREFIXES = [
49
+ 'SQLITE_BUSY',
50
+ 'SQLITE_LOCKED'
51
+ ];
52
+ /**
53
+ * Whether an error means "the database refused this transaction because
54
+ * another one touched the same rows; running it again may succeed."
55
+ *
56
+ * Detection is by the driver's own structured signals, never by message text.
57
+ * MongoDB tags exactly this class with the `TransientTransactionError` label,
58
+ * which is the retry contract its drivers document; Postgres uses SQLSTATE;
59
+ * SQLite/libsql use a `code` string in the `SQLITE_BUSY`/`SQLITE_LOCKED*`
60
+ * family.
61
+ */ export function isTransientWriteConflict(error) {
62
+ if (!error || typeof error !== 'object') {
63
+ return false;
64
+ }
65
+ const { code, errorLabels } = error;
66
+ if (Array.isArray(errorLabels) && errorLabels.includes('TransientTransactionError')) {
67
+ return true;
68
+ }
69
+ if (code === MONGO_WRITE_CONFLICT) {
70
+ return true;
71
+ }
72
+ if (typeof code !== 'string') {
73
+ return false;
74
+ }
75
+ return POSTGRES_SERIALIZATION_FAILURES.has(code) || SQLITE_BUSY_OR_LOCKED_PREFIXES.some((prefix)=>code.startsWith(prefix));
76
+ }
77
+ /**
78
+ * Run `operation`, retrying only transient write conflicts.
79
+ *
80
+ * Every other error — a genuine booking conflict, a validation failure, an
81
+ * access denial — propagates on the first attempt. Retrying those would turn a
82
+ * clean rejection into a slow one.
83
+ *
84
+ * Backoff is exponential with full jitter, so a burst of losers does not
85
+ * synchronise into a second stampede on the same document.
86
+ *
87
+ * Pass `req` whenever the operation writes through the Payload Local API. Every
88
+ * attempt re-invokes a closure over the SAME `req`, and Payload's
89
+ * `initTransaction` JOINS an existing `req.transactionID` rather than opening a
90
+ * fresh transaction — so a transaction id left behind by a failed attempt would
91
+ * make the next attempt re-enter a transaction the database already discarded.
92
+ * Payload's own operations normally clean up after themselves (`killTransaction`
93
+ * deletes the id from their catch), with one gap they cannot close: when
94
+ * `beginTransaction` ITSELF rejects, `initTransaction` has already stored the
95
+ * rejected promise on the req and `killTransaction`'s guard skips promises, so
96
+ * the req stays poisoned — every later `initTransaction` short-circuits on
97
+ * `instanceof Promise` and re-throws the first error without touching the
98
+ * database. Clearing a leftover here closes it. See dev/retryTransaction.spec.ts.
99
+ *
100
+ * Do NOT attribute SQLite's "raising the retry budget changes nothing"
101
+ * measurement to this. That has a different, independent cause: `@payloadcms/
102
+ * drizzle` rethrows the driver's error as a bare `Error`, so
103
+ * `isTransientWriteConflict` returns FALSE and this loop throws on attempt 1 —
104
+ * attempts 2..N never happen, and poisoning never gets a chance to matter. See
105
+ * README's "Concurrent booking: database adapter support". The shape this
106
+ * clearing actually protects is any caller that swallows a
107
+ * begin-transaction failure and then keeps using the same req (see the expiry
108
+ * sweep in HoldService.takeHold).
109
+ */ export async function retryOnWriteConflict(operation, { attempts = 5, baseDelayMs = 10, req } = {}) {
110
+ let lastError;
111
+ // Only leftovers from a failed attempt are ours to clear. A req that arrives
112
+ // already inside a transaction belongs to an enclosing caller: dropping that
113
+ // id would silently detach the retried write from their unit of work.
114
+ const callerOwnsTransaction = req ? req.transactionID !== undefined : false;
115
+ for(let attempt = 0; attempt < attempts; attempt++){
116
+ if (attempt > 0 && req && !callerOwnsTransaction && req.transactionID !== undefined) {
117
+ delete req.transactionID;
118
+ }
119
+ try {
120
+ return await operation();
121
+ } catch (error) {
122
+ if (!isTransientWriteConflict(error)) {
123
+ throw error;
124
+ }
125
+ lastError = error;
126
+ if (attempt < attempts - 1) {
127
+ const ceiling = baseDelayMs * 2 ** attempt;
128
+ await new Promise((resolve)=>setTimeout(resolve, Math.random() * ceiling));
129
+ }
130
+ }
131
+ }
132
+ throw lastError;
133
+ }
134
+
135
+ //# sourceMappingURL=retryOnWriteConflict.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utilities/retryOnWriteConflict.ts"],"sourcesContent":["/**\n * Retry a write that lost a transaction-level lock race.\n *\n * `acquireBookingLock` makes concurrent bookings for one resource contend on a\n * single document so the database serializes them. On MongoDB the loser does\n * not wait — it aborts immediately with a WriteConflict. That is correct for a\n * `quantity: 1` resource (only one booking should win anyway) but wrong for a\n * `quantity: 3` one, where two more bookings legitimately fit. Measured\n * without retry: 8 simultaneous bookings against a `quantity: 3` resource\n * persisted only 1.\n *\n * Retrying re-runs the whole operation on a fresh transaction. The retry takes\n * the lock cleanly, re-reads availability — now seeing the winner's committed\n * booking — and is admitted or rejected by `validateConflicts` on the merits.\n */\n\n/** MongoDB WriteConflict. */\nconst MONGO_WRITE_CONFLICT = 112\n\n/** Postgres serialization_failure and deadlock_detected. */\nconst POSTGRES_SERIALIZATION_FAILURES = new Set(['40P01', '40001'])\n\n/**\n * SQLite/libsql busy-or-locked family. Only reachable when the adapter is\n * given a truthy `transactionOptions` — otherwise `beginTransaction` is a\n * no-op and nothing ever contends. `SQLITE_BUSY` and plain `SQLITE_LOCKED`\n * are the same family as the qualified variant measured below; prefix match\n * (not an exact-set lookup like the Postgres codes above) because\n * SQLite/libsql qualify the base code with a reason suffix (`_SHAREDCACHE`,\n * `_SNAPSHOT`, ...) — the prefix is still the driver's own structured\n * signal, never message text.\n *\n * KNOWN GAP, confirmed by direct inspection, not assumption: for a loser\n * that aborts at `beginTransaction` itself (SQLite's actual failure mode —\n * a second write transaction cannot even open while one is held, rather\n * than queuing for it), this check can never fire in practice. The real\n * driver error — a `LibsqlError` with `code: 'SQLITE_LOCKED_SHAREDCACHE'` —\n * is caught *inside* `@payloadcms/drizzle`'s own `beginTransaction.js` (a\n * Payload-core dependency, not this plugin) and re-thrown as a bare\n * `new Error('Error: cannot begin transaction: ...')`: no `code`, no\n * `cause`, generic `name`. By the time it reaches this function the\n * structured signal is already gone, so retry never engages for that path\n * — measured: burst of 8 against a `quantity: 3` resource with retry\n * recovers only 1 of 3, identical to no retry at all, and raising the\n * retry budget from 5 to 30 attempts makes no difference. This match is\n * kept because it is still correct for any SQLite/libsql error shape that\n * *does* preserve `code` (a mid-transaction conflict would be a different\n * code path), and matching on the wrapped error's message text to plug this\n * specific gap is exactly what this project's structured-signal-only\n * constraint forbids.\n */\nconst SQLITE_BUSY_OR_LOCKED_PREFIXES = ['SQLITE_BUSY', 'SQLITE_LOCKED']\n\n/**\n * Whether an error means \"the database refused this transaction because\n * another one touched the same rows; running it again may succeed.\"\n *\n * Detection is by the driver's own structured signals, never by message text.\n * MongoDB tags exactly this class with the `TransientTransactionError` label,\n * which is the retry contract its drivers document; Postgres uses SQLSTATE;\n * SQLite/libsql use a `code` string in the `SQLITE_BUSY`/`SQLITE_LOCKED*`\n * family.\n */\nexport function isTransientWriteConflict(error: unknown): boolean {\n if (!error || typeof error !== 'object') {\n return false\n }\n\n const { code, errorLabels } = error as { code?: unknown; errorLabels?: unknown }\n\n if (Array.isArray(errorLabels) && errorLabels.includes('TransientTransactionError')) {\n return true\n }\n\n if (code === MONGO_WRITE_CONFLICT) {\n return true\n }\n\n if (typeof code !== 'string') {\n return false\n }\n\n return (\n POSTGRES_SERIALIZATION_FAILURES.has(code) ||\n SQLITE_BUSY_OR_LOCKED_PREFIXES.some((prefix) => code.startsWith(prefix))\n )\n}\n\n/**\n * Run `operation`, retrying only transient write conflicts.\n *\n * Every other error — a genuine booking conflict, a validation failure, an\n * access denial — propagates on the first attempt. Retrying those would turn a\n * clean rejection into a slow one.\n *\n * Backoff is exponential with full jitter, so a burst of losers does not\n * synchronise into a second stampede on the same document.\n *\n * Pass `req` whenever the operation writes through the Payload Local API. Every\n * attempt re-invokes a closure over the SAME `req`, and Payload's\n * `initTransaction` JOINS an existing `req.transactionID` rather than opening a\n * fresh transaction — so a transaction id left behind by a failed attempt would\n * make the next attempt re-enter a transaction the database already discarded.\n * Payload's own operations normally clean up after themselves (`killTransaction`\n * deletes the id from their catch), with one gap they cannot close: when\n * `beginTransaction` ITSELF rejects, `initTransaction` has already stored the\n * rejected promise on the req and `killTransaction`'s guard skips promises, so\n * the req stays poisoned — every later `initTransaction` short-circuits on\n * `instanceof Promise` and re-throws the first error without touching the\n * database. Clearing a leftover here closes it. See dev/retryTransaction.spec.ts.\n *\n * Do NOT attribute SQLite's \"raising the retry budget changes nothing\"\n * measurement to this. That has a different, independent cause: `@payloadcms/\n * drizzle` rethrows the driver's error as a bare `Error`, so\n * `isTransientWriteConflict` returns FALSE and this loop throws on attempt 1 —\n * attempts 2..N never happen, and poisoning never gets a chance to matter. See\n * README's \"Concurrent booking: database adapter support\". The shape this\n * clearing actually protects is any caller that swallows a\n * begin-transaction failure and then keeps using the same req (see the expiry\n * sweep in HoldService.takeHold).\n */\nexport async function retryOnWriteConflict<T>(\n operation: () => Promise<T>,\n {\n attempts = 5,\n baseDelayMs = 10,\n req,\n }: { attempts?: number; baseDelayMs?: number; req?: { transactionID?: unknown } } = {},\n): Promise<T> {\n let lastError: unknown\n\n // Only leftovers from a failed attempt are ours to clear. A req that arrives\n // already inside a transaction belongs to an enclosing caller: dropping that\n // id would silently detach the retried write from their unit of work.\n const callerOwnsTransaction = req ? req.transactionID !== undefined : false\n\n for (let attempt = 0; attempt < attempts; attempt++) {\n if (attempt > 0 && req && !callerOwnsTransaction && req.transactionID !== undefined) {\n delete req.transactionID\n }\n\n try {\n return await operation()\n } catch (error) {\n if (!isTransientWriteConflict(error)) {\n throw error\n }\n\n lastError = error\n\n if (attempt < attempts - 1) {\n const ceiling = baseDelayMs * 2 ** attempt\n await new Promise((resolve) => setTimeout(resolve, Math.random() * ceiling))\n }\n }\n }\n\n throw lastError\n}\n"],"names":["MONGO_WRITE_CONFLICT","POSTGRES_SERIALIZATION_FAILURES","Set","SQLITE_BUSY_OR_LOCKED_PREFIXES","isTransientWriteConflict","error","code","errorLabels","Array","isArray","includes","has","some","prefix","startsWith","retryOnWriteConflict","operation","attempts","baseDelayMs","req","lastError","callerOwnsTransaction","transactionID","undefined","attempt","ceiling","Promise","resolve","setTimeout","Math","random"],"mappings":"AAAA;;;;;;;;;;;;;;CAcC,GAED,2BAA2B,GAC3B,MAAMA,uBAAuB;AAE7B,0DAA0D,GAC1D,MAAMC,kCAAkC,IAAIC,IAAI;IAAC;IAAS;CAAQ;AAElE;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,MAAMC,iCAAiC;IAAC;IAAe;CAAgB;AAEvE;;;;;;;;;CASC,GACD,OAAO,SAASC,yBAAyBC,KAAc;IACrD,IAAI,CAACA,SAAS,OAAOA,UAAU,UAAU;QACvC,OAAO;IACT;IAEA,MAAM,EAAEC,IAAI,EAAEC,WAAW,EAAE,GAAGF;IAE9B,IAAIG,MAAMC,OAAO,CAACF,gBAAgBA,YAAYG,QAAQ,CAAC,8BAA8B;QACnF,OAAO;IACT;IAEA,IAAIJ,SAASN,sBAAsB;QACjC,OAAO;IACT;IAEA,IAAI,OAAOM,SAAS,UAAU;QAC5B,OAAO;IACT;IAEA,OACEL,gCAAgCU,GAAG,CAACL,SACpCH,+BAA+BS,IAAI,CAAC,CAACC,SAAWP,KAAKQ,UAAU,CAACD;AAEpE;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgCC,GACD,OAAO,eAAeE,qBACpBC,SAA2B,EAC3B,EACEC,WAAW,CAAC,EACZC,cAAc,EAAE,EAChBC,GAAG,EAC4E,GAAG,CAAC,CAAC;IAEtF,IAAIC;IAEJ,6EAA6E;IAC7E,6EAA6E;IAC7E,sEAAsE;IACtE,MAAMC,wBAAwBF,MAAMA,IAAIG,aAAa,KAAKC,YAAY;IAEtE,IAAK,IAAIC,UAAU,GAAGA,UAAUP,UAAUO,UAAW;QACnD,IAAIA,UAAU,KAAKL,OAAO,CAACE,yBAAyBF,IAAIG,aAAa,KAAKC,WAAW;YACnF,OAAOJ,IAAIG,aAAa;QAC1B;QAEA,IAAI;YACF,OAAO,MAAMN;QACf,EAAE,OAAOX,OAAO;YACd,IAAI,CAACD,yBAAyBC,QAAQ;gBACpC,MAAMA;YACR;YAEAe,YAAYf;YAEZ,IAAImB,UAAUP,WAAW,GAAG;gBAC1B,MAAMQ,UAAUP,cAAc,KAAKM;gBACnC,MAAM,IAAIE,QAAQ,CAACC,UAAYC,WAAWD,SAASE,KAAKC,MAAM,KAAKL;YACrE;QACF;IACF;IAEA,MAAML;AACR"}
@@ -1,4 +1,5 @@
1
- import type { Payload } from 'payload';
1
+ import type { Payload, PayloadRequest } from 'payload';
2
+ import type { ResolvedReservationPluginConfig } from '../types.js';
2
3
  type CollectionLike = {
3
4
  fields?: unknown[];
4
5
  } | null | undefined;
@@ -33,9 +34,84 @@ export declare function tenantCollectionSlug(collection: CollectionLike, tenantF
33
34
  export declare function getEffectiveTenantTimezone(args: {
34
35
  globalTimezone: string;
35
36
  payload: Payload;
37
+ req?: PayloadRequest;
36
38
  scopedCollection: CollectionLike;
37
39
  tenantField: string;
38
40
  tenantId: null | string;
39
41
  timezoneField: string;
40
42
  }): Promise<string>;
43
+ /**
44
+ * Normalizes a relationship-shaped value down to its id: a raw string/number
45
+ * id, or an object carrying one — `{ id }`, the shape both a populated
46
+ * relationship and a client sending `{ "tenant": { "id": "..." } }` produce.
47
+ * Returns null for anything else (including an object with no usable `id`),
48
+ * so callers can fail closed on a shape they don't recognize instead of
49
+ * silently skipping whatever check they were about to run.
50
+ */
51
+ export declare function normalizeRelationshipId(value: unknown): null | number | string;
52
+ /**
53
+ * Every tenant id a tenant-field value names, as a list.
54
+ *
55
+ * Exists because multi-tenant supports a `hasMany` tenant field, so the value
56
+ * can legitimately be an ARRAY of relationship-shaped entries.
57
+ * {@link normalizeRelationshipId} returns null for an array, and the caller
58
+ * fails closed on null — which turned a supported multi-tenant configuration
59
+ * into a blanket 403 on every authenticated booking that carried an explicit
60
+ * tenant (a total booking outage for that install, with no diagnostic).
61
+ *
62
+ * - `[]` -> `[]`: an empty array writes no tenant, so there is nothing to
63
+ * authorize; the caller permits it, exactly as it does for an absent value.
64
+ * - a one-or-many array -> one id per entry, all of which the caller must be
65
+ * authorized for (any single unrecognized entry poisons the whole value).
66
+ * - a scalar / `{ id }` -> a single-element list.
67
+ * - anything else -> null, so the caller can still fail closed, loudly.
68
+ */
69
+ export declare function normalizeRelationshipIds(value: unknown): Array<number | string> | null;
70
+ /**
71
+ * Whether an authenticated caller may write the tenant value present in
72
+ * `data[config.multiTenant.tenantField]` onto a new document in
73
+ * `config.slugs.reservations`.
74
+ *
75
+ * Exists because delegating `overrideAccess` is NOT sufficient on its own:
76
+ * multi-tenant's tenant-field `validate` only checks presence, and Payload's
77
+ * `create` operation (`executeAccess`) only checks the TRUTHINESS of a
78
+ * collection access result — unlike read/update/delete, which apply a
79
+ * returned `Where` via `combineQueries` against a real document. So MT's own
80
+ * tenant-scoped `create` access can't reject an explicit foreign tenant
81
+ * either; an authenticated caller can write one straight through regardless
82
+ * of `overrideAccess`. This closes that gap the same way
83
+ * {@link getEffectiveTenantTimezone} closes it for a client-supplied tenant
84
+ * *cookie*: an access-checked (`overrideAccess: false`) probe read against
85
+ * the tenants collection for the *submitted* tenant id — a lookup that finds
86
+ * nothing means the caller isn't a member (or a multi-tenant super-admin).
87
+ *
88
+ * PRECONDITION — this is a real membership check only when the caller
89
+ * authenticates against the SAME collection multi-tenant wraps as its
90
+ * admin/tenant-owning collection. `withTenantAccess` only applies its
91
+ * membership constraint when `req.user.collection === adminUsersSlug`;
92
+ * otherwise it falls back to the tenants collection's own (here: default,
93
+ * unrestricted) access function, which only requires `Boolean(req.user)`. In
94
+ * this plugin's STANDALONE mode (`userCollection` unset), a customer
95
+ * authenticates against `slugs.customers` — a collection multi-tenant never
96
+ * wraps — so this probe passes for ANY tenant id for a logged-in customer.
97
+ * `src/plugin.ts`'s boot diagnostic warns about that configuration. It is
98
+ * also a no-op if the host set multi-tenant's own
99
+ * `useTenantsCollectionAccess: false`, which is not detectable from here.
100
+ *
101
+ * Returns true — nothing to check — when the reservations collection isn't
102
+ * tenant-scoped at all, or `data` doesn't carry an explicit tenant value (MT's
103
+ * own access-checked `defaultValue` applies in that case; see
104
+ * `getEffectiveTenantTimezone`'s doc comment for why that path is already
105
+ * safe). Returns false — fail closed — for an unrecognized value shape (logged,
106
+ * since a shape this plugin cannot read refuses every such booking), a probe
107
+ * that finds no matching tenant, or any error while probing.
108
+ *
109
+ * A `hasMany` tenant field is supported: the value may be an array, and EVERY
110
+ * id in it is probed.
111
+ */
112
+ export declare function callerMayUseTenant(args: {
113
+ config: Pick<ResolvedReservationPluginConfig, 'multiTenant' | 'slugs'>;
114
+ data: Record<string, unknown>;
115
+ req: PayloadRequest;
116
+ }): Promise<boolean>;
41
117
  export {};
@@ -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.4.0",
3
+ "version": "3.0.1",
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",