payload-reserve 2.4.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +217 -4
  2. package/dist/collections/Holds.d.ts +18 -0
  3. package/dist/collections/Holds.js +113 -0
  4. package/dist/collections/Holds.js.map +1 -0
  5. package/dist/collections/Reservations.js +8 -0
  6. package/dist/collections/Reservations.js.map +1 -1
  7. package/dist/collections/Resources.js +35 -1
  8. package/dist/collections/Resources.js.map +1 -1
  9. package/dist/collections/Services.js +41 -0
  10. package/dist/collections/Services.js.map +1 -1
  11. package/dist/components/CalendarView/LaneTimelineView.js +4 -5
  12. package/dist/components/CalendarView/LaneTimelineView.js.map +1 -1
  13. package/dist/components/CalendarView/index.js +55 -71
  14. package/dist/components/CalendarView/index.js.map +1 -1
  15. package/dist/components/DashboardWidget/DashboardWidgetServer.js +11 -74
  16. package/dist/components/DashboardWidget/DashboardWidgetServer.js.map +1 -1
  17. package/dist/components/DashboardWidget/fetchDashboardStats.d.ts +42 -0
  18. package/dist/components/DashboardWidget/fetchDashboardStats.js +113 -0
  19. package/dist/components/DashboardWidget/fetchDashboardStats.js.map +1 -0
  20. package/dist/defaults.d.ts +2 -0
  21. package/dist/defaults.js +11 -0
  22. package/dist/defaults.js.map +1 -1
  23. package/dist/endpoints/cancelBooking.js +43 -14
  24. package/dist/endpoints/cancelBooking.js.map +1 -1
  25. package/dist/endpoints/checkAvailability.js +12 -1
  26. package/dist/endpoints/checkAvailability.js.map +1 -1
  27. package/dist/endpoints/createBooking.js +108 -7
  28. package/dist/endpoints/createBooking.js.map +1 -1
  29. package/dist/endpoints/customerSearch.js +6 -0
  30. package/dist/endpoints/customerSearch.js.map +1 -1
  31. package/dist/endpoints/effectiveTimezone.js +1 -0
  32. package/dist/endpoints/effectiveTimezone.js.map +1 -1
  33. package/dist/endpoints/getSlots.js +12 -1
  34. package/dist/endpoints/getSlots.js.map +1 -1
  35. package/dist/endpoints/holdSlot.d.ts +12 -0
  36. package/dist/endpoints/holdSlot.js +126 -0
  37. package/dist/endpoints/holdSlot.js.map +1 -0
  38. package/dist/endpoints/releaseSlot.d.ts +8 -0
  39. package/dist/endpoints/releaseSlot.js +34 -0
  40. package/dist/endpoints/releaseSlot.js.map +1 -0
  41. package/dist/endpoints/resourceAvailability.d.ts +2 -0
  42. package/dist/endpoints/resourceAvailability.js +79 -3
  43. package/dist/endpoints/resourceAvailability.js.map +1 -1
  44. package/dist/hooks/holds/validateHoldSlot.d.ts +14 -0
  45. package/dist/hooks/holds/validateHoldSlot.js +90 -0
  46. package/dist/hooks/holds/validateHoldSlot.js.map +1 -0
  47. package/dist/hooks/reservations/acquireBookingLock.d.ts +30 -0
  48. package/dist/hooks/reservations/acquireBookingLock.js +71 -0
  49. package/dist/hooks/reservations/acquireBookingLock.js.map +1 -0
  50. package/dist/hooks/reservations/calculateEndTime.js +13 -1
  51. package/dist/hooks/reservations/calculateEndTime.js.map +1 -1
  52. package/dist/hooks/reservations/expandRequiredResources.js +3 -0
  53. package/dist/hooks/reservations/expandRequiredResources.js.map +1 -1
  54. package/dist/hooks/reservations/validateActive.d.ts +11 -0
  55. package/dist/hooks/reservations/validateActive.js +103 -0
  56. package/dist/hooks/reservations/validateActive.js.map +1 -0
  57. package/dist/hooks/reservations/validateConflicts.js +7 -1
  58. package/dist/hooks/reservations/validateConflicts.js.map +1 -1
  59. package/dist/hooks/reservations/validateGuestBooking.js +3 -0
  60. package/dist/hooks/reservations/validateGuestBooking.js.map +1 -1
  61. package/dist/hooks/shared/preventDeleteWhenReferenced.d.ts +42 -0
  62. package/dist/hooks/shared/preventDeleteWhenReferenced.js +87 -0
  63. package/dist/hooks/shared/preventDeleteWhenReferenced.js.map +1 -0
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/plugin.js +179 -3
  67. package/dist/plugin.js.map +1 -1
  68. package/dist/services/AvailabilityService.d.ts +46 -5
  69. package/dist/services/AvailabilityService.js +168 -12
  70. package/dist/services/AvailabilityService.js.map +1 -1
  71. package/dist/services/HoldService.d.ts +53 -0
  72. package/dist/services/HoldService.js +169 -0
  73. package/dist/services/HoldService.js.map +1 -0
  74. package/dist/services/index.d.ts +1 -0
  75. package/dist/services/index.js.map +1 -1
  76. package/dist/translations/ar.json +5 -1
  77. package/dist/translations/de.json +5 -1
  78. package/dist/translations/en.json +5 -1
  79. package/dist/translations/es.json +5 -1
  80. package/dist/translations/fa.json +5 -1
  81. package/dist/translations/fr.json +5 -1
  82. package/dist/translations/hi.json +5 -1
  83. package/dist/translations/id.json +5 -1
  84. package/dist/translations/index.js +36 -12
  85. package/dist/translations/index.js.map +1 -1
  86. package/dist/translations/pl.json +5 -1
  87. package/dist/translations/ru.json +5 -1
  88. package/dist/translations/tr.json +5 -1
  89. package/dist/translations/zh.json +5 -1
  90. package/dist/types.d.ts +29 -0
  91. package/dist/types.js.map +1 -1
  92. package/dist/utilities/calendarGrid.d.ts +44 -0
  93. package/dist/utilities/calendarGrid.js +67 -0
  94. package/dist/utilities/calendarGrid.js.map +1 -0
  95. package/dist/utilities/reservationChanges.d.ts +6 -1
  96. package/dist/utilities/reservationChanges.js +1 -1
  97. package/dist/utilities/reservationChanges.js.map +1 -1
  98. package/dist/utilities/resolveReservationItems.d.ts +13 -2
  99. package/dist/utilities/resolveReservationItems.js +87 -2
  100. package/dist/utilities/resolveReservationItems.js.map +1 -1
  101. package/dist/utilities/retryOnWriteConflict.d.ts +66 -0
  102. package/dist/utilities/retryOnWriteConflict.js +135 -0
  103. package/dist/utilities/retryOnWriteConflict.js.map +1 -0
  104. package/dist/utilities/tenantTimezone.d.ts +77 -1
  105. package/dist/utilities/tenantTimezone.js +163 -2
  106. package/dist/utilities/tenantTimezone.js.map +1 -1
  107. package/dist/utilities/transactionSupport.d.ts +13 -0
  108. package/dist/utilities/transactionSupport.js +30 -0
  109. package/dist/utilities/transactionSupport.js.map +1 -0
  110. package/package.json +13 -13
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { CollectionConfig, Field, PayloadRequest } from 'payload'\n\n// --- Duration & Capacity models ---\n\nexport type DurationType = 'fixed' | 'flexible' | 'full-day'\n\nexport type CapacityMode = 'per-guest' | 'per-reservation'\n\n// --- Configurable status machine ---\n\nexport type StatusMachineConfig = {\n blockingStatuses: string[]\n /** Status treated as \"cancelled\" — fires beforeBookingCancel/afterBookingCancel, the cancellation notice period, and the cancellationReason field. */\n cancelStatus: string\n /** Status treated as \"confirmed\" — fires beforeBookingConfirm/afterBookingConfirm. */\n confirmStatus: string\n defaultStatus: string\n statuses: string[]\n terminalStatuses: string[]\n transitions: Record<string, string[]>\n}\n\nexport const DEFAULT_STATUS_MACHINE: StatusMachineConfig = {\n blockingStatuses: ['pending', 'confirmed'],\n cancelStatus: 'cancelled',\n confirmStatus: 'confirmed',\n defaultStatus: 'pending',\n statuses: ['pending', 'confirmed', 'completed', 'cancelled', 'no-show'],\n terminalStatuses: ['completed', 'cancelled', 'no-show'],\n transitions: {\n cancelled: [],\n completed: [],\n confirmed: ['completed', 'cancelled', 'no-show'],\n 'no-show': [],\n pending: ['confirmed', 'cancelled'],\n },\n}\n\n// --- Reservation item (for multi-resource bookings, Phase 3) ---\n\nexport type ReservationItemConfig = {\n endTime?: string\n guestCount?: number\n resource: string\n service?: string\n startTime?: string\n}\n\n// --- Plugin hooks for external integrations ---\n\nexport type ReservationPluginHooks = {\n afterBookingCancel?: Array<\n (args: { doc: Record<string, unknown>; req: PayloadRequest }) => Promise<void> | void\n >\n afterBookingConfirm?: Array<\n (args: { doc: Record<string, unknown>; req: PayloadRequest }) => Promise<void> | void\n >\n afterBookingCreate?: Array<\n (args: { doc: Record<string, unknown>; req: PayloadRequest }) => Promise<void> | void\n >\n afterStatusChange?: Array<\n (args: {\n doc: Record<string, unknown>\n newStatus: string\n previousStatus: string\n req: PayloadRequest\n }) => Promise<void> | void\n >\n beforeBookingCancel?: Array<\n (args: {\n doc: Record<string, unknown>\n reason?: string\n req: PayloadRequest\n }) => Promise<void> | void\n >\n beforeBookingConfirm?: Array<\n (args: {\n doc: Record<string, unknown>\n newStatus: string\n req: PayloadRequest\n }) => Promise<void> | void\n >\n beforeBookingCreate?: Array<\n (args: {\n data: Record<string, unknown>\n req: PayloadRequest\n }) => Promise<Record<string, unknown>> | Record<string, unknown>\n >\n}\n\n// --- Resource owner mode ---\n\nexport type ResourceOwnerModeConfig = {\n /** Roles that can see all records (default: check req.user.collection === adminCollection) */\n adminRoles?: string[]\n /** Whether Services also get an owner field (default: false — Services are platform-managed) */\n ownedServices?: boolean\n /**\n * Collection the owner field relates to (where owners/staff live). Defaults to\n * `staffProvisioning.userCollection` when set, otherwise `slugs.customers`. Set\n * this when owners live in a different collection than your customers (e.g.\n * separate `users` and `customers` collections).\n */\n ownerCollection?: string\n /** Field name for the owner relationship on Resources (default: 'owner') */\n ownerField?: string\n /** User field holding the role for admin detection (default: staffProvisioning.roleField or 'role') */\n roleField?: string\n}\n\nexport type ResolvedResourceOwnerModeConfig = {\n adminRoles: string[]\n ownedServices: boolean\n ownerCollection?: string\n ownerField: string\n roleField: string\n}\n\n// --- Staff provisioning ---\n\nexport type StaffProvisioningConfig = {\n /** Stamp tenant / custom fields onto the provisioned Resource before create. */\n beforeCreate?: (args: {\n data: Record<string, unknown>\n req: PayloadRequest\n user: Record<string, unknown>\n }) => Promise<Record<string, unknown>> | Record<string, unknown>\n /** User field copied into Resource `name` (default 'name', falls back to email). */\n nameFrom?: string\n /** resourceType to stamp (default 'staff'). Must be a valid resourceType. */\n resourceType?: string\n /** Field on the user holding the role (default 'role'). */\n roleField?: string\n /** Role value(s) marking a user as staff. Required, non-empty. */\n staffRoles: string[]\n /** Auth collection holding staff users. Defaults to top-level `userCollection`. */\n userCollection?: string\n}\n\nexport type ResolvedStaffProvisioningConfig = {\n beforeCreate?: StaffProvisioningConfig['beforeCreate']\n nameFrom: string\n resourceType: string\n roleField: string\n staffRoles: string[]\n userCollection: string\n}\n\n// --- Plugin configuration ---\n\n/**\n * Per-collection override applied to a generated collection. `fields` is a\n * function receiving the plugin's default fields so you can append/reorder/\n * replace them; supplied `hooks` are merged with (not replacing) the plugin's;\n * `access` composes per operation; `slug` is ignored (use the `slugs` option).\n */\nexport type CollectionOverride = {\n fields?: (args: { defaultFields: Field[] }) => Field[]\n} & Omit<Partial<CollectionConfig>, 'fields' | 'slug'>\n\n/** One external busy interval returned by `getExternalBusy` (ISO date strings). */\nexport type ExternalBusyInterval = { end: string; label?: string; start: string }\n\n/**\n * App-supplied resolver mapping an external source (e.g. Google/Outlook sync\n * tables) to busy intervals for one resource over [start, end). Wired into\n * checkAvailability (bookings overlapping an interval are unavailable — the\n * interval blocks the WHOLE resource, all units) and into the\n * resource-availability endpoint (returned as `external` for distinct\n * rendering). The plugin calls it inside try/catch and treats an error as []\n * (fail-open): a calendar/sync failure must never block a real booking.\n * Called once per candidate window during slot computation (N calls per\n * request) — keep it cheap: read a local sync table or a per-request cache,\n * never a remote API directly.\n */\nexport type GetExternalBusy = (args: {\n end: Date\n req: PayloadRequest\n resourceId: number | string\n start: Date\n}) => Promise<ExternalBusyInterval[]>\n\nexport type ReservationPluginConfig = {\n /** Override access control per collection */\n access?: {\n customers?: CollectionConfig['access']\n reservations?: CollectionConfig['access']\n resources?: CollectionConfig['access']\n schedules?: CollectionConfig['access']\n services?: CollectionConfig['access']\n }\n /** Admin group name for all reservation collections */\n adminGroup?: string\n /** Allow bookings without a customer account by default (per-service override available) */\n allowGuestBooking?: boolean\n /** Hours of notice required before cancellation */\n cancellationNoticePeriod?: number\n /** Per-collection overrides applied to the generated collections (issue #4) */\n collectionOverrides?: {\n customers?: CollectionOverride\n reservations?: CollectionOverride\n resources?: CollectionOverride\n schedules?: CollectionOverride\n services?: CollectionOverride\n }\n /** Emit info-level `reserve_debug` traces for slot generation and conflict detection (default false) */\n debug?: boolean\n /** Default buffer time in minutes between reservations */\n defaultBufferTime?: number\n /** Disable the plugin entirely */\n disabled?: boolean\n /** @deprecated Use `collectionOverrides.reservations.fields` instead. Extra fields appended to Reservations. */\n extraReservationFields?: Field[]\n /** Resolve external busy intervals (calendar sync etc.) folded into availability + calendar display. */\n getExternalBusy?: GetExternalBusy\n /** Plugin hooks for external integrations */\n hooks?: ReservationPluginHooks\n /** Configurable leave/exception type vocabulary (default: vacation/sick/personal/closure/other) */\n leaveTypes?: string[]\n /** Tenant scoping for the custom admin views (calendar, availability, dashboard). Applied only when the scoped collection has the tenant field AND the tenant cookie is set. */\n multiTenant?: {\n /** Cookie written by the tenant-selector (default 'payload-tenant'). */\n cookieName?: string\n /** Tenant field name on scoped collections (default 'tenant'). */\n tenantField?: string\n /**\n * Field on the tenant document holding its IANA timezone (default 'timezone').\n * When set and the selected tenant has a valid value, the admin views resolve\n * day-boundaries in that tenant's zone instead of the global `timezone`.\n */\n timezoneField?: string\n }\n /** Enable resource-owner multi-tenancy (opt-in) */\n resourceOwnerMode?: ResourceOwnerModeConfig\n /** Configurable resourceType vocabulary (default: staff/equipment/room) */\n resourceTypes?: string[]\n /** Override collection slugs */\n slugs?: {\n customers?: string\n media?: string\n reservations?: string\n resources?: string\n schedules?: string\n services?: string\n }\n /** Auto-provision a Resource from staff-role users (opt-in; requires resourceOwnerMode) */\n staffProvisioning?: StaffProvisioningConfig\n /** Configurable status machine (defaults to current behavior) */\n statusMachine?: Partial<StatusMachineConfig>\n /** IANA business timezone governing schedules and day boundaries (default 'UTC') */\n timezone?: string\n /** Which existing auth collection to extend with customer fields */\n userCollection?: string\n}\n\nexport type ResolvedReservationPluginConfig = {\n access: {\n customers?: CollectionConfig['access']\n reservations?: CollectionConfig['access']\n resources?: CollectionConfig['access']\n schedules?: CollectionConfig['access']\n services?: CollectionConfig['access']\n }\n adminGroup: string\n allowGuestBooking: boolean\n cancellationNoticePeriod: number\n collectionOverrides: {\n customers?: CollectionOverride\n reservations?: CollectionOverride\n resources?: CollectionOverride\n schedules?: CollectionOverride\n services?: CollectionOverride\n }\n debug: boolean\n defaultBufferTime: number\n disabled: boolean\n extraReservationFields: Field[]\n getExternalBusy: GetExternalBusy | undefined\n /** Whether the media collection (`slugs.media`) exists — set by the plugin; gates the image upload fields. */\n hasMediaCollection: boolean\n hooks: ReservationPluginHooks\n leaveTypes: string[]\n localized: boolean\n multiTenant: {\n cookieName: string\n tenantField: string\n timezoneField: string\n }\n resourceOwnerMode: ResolvedResourceOwnerModeConfig | undefined\n resourceTypes: string[]\n slugs: {\n customers: string\n media: string\n reservations: string\n resources: string\n schedules: string\n services: string\n }\n staffProvisioning: ResolvedStaffProvisioningConfig | undefined\n statusMachine: StatusMachineConfig\n timezone: string\n userCollection: string | undefined\n}\n\nexport type ReservationStatus = 'cancelled' | 'completed' | 'confirmed' | 'no-show' | 'pending'\n\nexport type DayOfWeek = 'fri' | 'mon' | 'sat' | 'sun' | 'thu' | 'tue' | 'wed'\n\nexport type ScheduleType = 'manual' | 'recurring'\n\n/** @deprecated Use DEFAULT_STATUS_MACHINE.transitions instead */\nexport const VALID_STATUS_TRANSITIONS: Record<ReservationStatus, ReservationStatus[]> =\n DEFAULT_STATUS_MACHINE.transitions as Record<ReservationStatus, ReservationStatus[]>\n"],"names":["DEFAULT_STATUS_MACHINE","blockingStatuses","cancelStatus","confirmStatus","defaultStatus","statuses","terminalStatuses","transitions","cancelled","completed","confirmed","pending","VALID_STATUS_TRANSITIONS"],"mappings":"AAsBA,OAAO,MAAMA,yBAA8C;IACzDC,kBAAkB;QAAC;QAAW;KAAY;IAC1CC,cAAc;IACdC,eAAe;IACfC,eAAe;IACfC,UAAU;QAAC;QAAW;QAAa;QAAa;QAAa;KAAU;IACvEC,kBAAkB;QAAC;QAAa;QAAa;KAAU;IACvDC,aAAa;QACXC,WAAW,EAAE;QACbC,WAAW,EAAE;QACbC,WAAW;YAAC;YAAa;YAAa;SAAU;QAChD,WAAW,EAAE;QACbC,SAAS;YAAC;YAAa;SAAY;IACrC;AACF,EAAC;AAkRD,+DAA+D,GAC/D,OAAO,MAAMC,2BACXZ,uBAAuBO,WAAW,CAAkD"}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { CollectionConfig, Field, PayloadRequest } from 'payload'\n\n// --- Duration & Capacity models ---\n\nexport type DurationType = 'fixed' | 'flexible' | 'full-day'\n\nexport type CapacityMode = 'per-guest' | 'per-reservation'\n\n// --- Configurable status machine ---\n\nexport type StatusMachineConfig = {\n blockingStatuses: string[]\n /** Status treated as \"cancelled\" — fires beforeBookingCancel/afterBookingCancel, the cancellation notice period, and the cancellationReason field. */\n cancelStatus: string\n /** Status treated as \"confirmed\" — fires beforeBookingConfirm/afterBookingConfirm. */\n confirmStatus: string\n defaultStatus: string\n statuses: string[]\n terminalStatuses: string[]\n transitions: Record<string, string[]>\n}\n\nexport const DEFAULT_STATUS_MACHINE: StatusMachineConfig = {\n blockingStatuses: ['pending', 'confirmed'],\n cancelStatus: 'cancelled',\n confirmStatus: 'confirmed',\n defaultStatus: 'pending',\n statuses: ['pending', 'confirmed', 'completed', 'cancelled', 'no-show'],\n terminalStatuses: ['completed', 'cancelled', 'no-show'],\n transitions: {\n cancelled: [],\n completed: [],\n confirmed: ['completed', 'cancelled', 'no-show'],\n 'no-show': [],\n pending: ['confirmed', 'cancelled'],\n },\n}\n\n// --- Reservation item (for multi-resource bookings, Phase 3) ---\n\nexport type ReservationItemConfig = {\n endTime?: string\n guestCount?: number\n resource: string\n service?: string\n startTime?: string\n}\n\n// --- Plugin hooks for external integrations ---\n\nexport type ReservationPluginHooks = {\n afterBookingCancel?: Array<\n (args: { doc: Record<string, unknown>; req: PayloadRequest }) => Promise<void> | void\n >\n afterBookingConfirm?: Array<\n (args: { doc: Record<string, unknown>; req: PayloadRequest }) => Promise<void> | void\n >\n afterBookingCreate?: Array<\n (args: { doc: Record<string, unknown>; req: PayloadRequest }) => Promise<void> | void\n >\n afterStatusChange?: Array<\n (args: {\n doc: Record<string, unknown>\n newStatus: string\n previousStatus: string\n req: PayloadRequest\n }) => Promise<void> | void\n >\n beforeBookingCancel?: Array<\n (args: {\n doc: Record<string, unknown>\n reason?: string\n req: PayloadRequest\n }) => Promise<void> | void\n >\n beforeBookingConfirm?: Array<\n (args: {\n doc: Record<string, unknown>\n newStatus: string\n req: PayloadRequest\n }) => Promise<void> | void\n >\n beforeBookingCreate?: Array<\n (args: {\n data: Record<string, unknown>\n req: PayloadRequest\n }) => Promise<Record<string, unknown>> | Record<string, unknown>\n >\n}\n\n// --- Resource owner mode ---\n\nexport type ResourceOwnerModeConfig = {\n /** Roles that can see all records (default: check req.user.collection === adminCollection) */\n adminRoles?: string[]\n /** Whether Services also get an owner field (default: false — Services are platform-managed) */\n ownedServices?: boolean\n /**\n * Collection the owner field relates to (where owners/staff live). Defaults to\n * `staffProvisioning.userCollection` when set, otherwise `slugs.customers`. Set\n * this when owners live in a different collection than your customers (e.g.\n * separate `users` and `customers` collections).\n */\n ownerCollection?: string\n /** Field name for the owner relationship on Resources (default: 'owner') */\n ownerField?: string\n /** User field holding the role for admin detection (default: staffProvisioning.roleField or 'role') */\n roleField?: string\n}\n\nexport type ResolvedResourceOwnerModeConfig = {\n adminRoles: string[]\n ownedServices: boolean\n ownerCollection?: string\n ownerField: string\n roleField: string\n}\n\n// --- Staff provisioning ---\n\nexport type StaffProvisioningConfig = {\n /** Stamp tenant / custom fields onto the provisioned Resource before create. */\n beforeCreate?: (args: {\n data: Record<string, unknown>\n req: PayloadRequest\n user: Record<string, unknown>\n }) => Promise<Record<string, unknown>> | Record<string, unknown>\n /** User field copied into Resource `name` (default 'name', falls back to email). */\n nameFrom?: string\n /** resourceType to stamp (default 'staff'). Must be a valid resourceType. */\n resourceType?: string\n /** Field on the user holding the role (default 'role'). */\n roleField?: string\n /** Role value(s) marking a user as staff. Required, non-empty. */\n staffRoles: string[]\n /** Auth collection holding staff users. Defaults to top-level `userCollection`. */\n userCollection?: string\n}\n\nexport type ResolvedStaffProvisioningConfig = {\n beforeCreate?: StaffProvisioningConfig['beforeCreate']\n nameFrom: string\n resourceType: string\n roleField: string\n staffRoles: string[]\n userCollection: string\n}\n\n// --- Plugin configuration ---\n\n/**\n * Per-collection override applied to a generated collection. `fields` is a\n * function receiving the plugin's default fields so you can append/reorder/\n * replace them; supplied `hooks` are merged with (not replacing) the plugin's;\n * `access` composes per operation; `slug` is ignored (use the `slugs` option).\n */\nexport type CollectionOverride = {\n fields?: (args: { defaultFields: Field[] }) => Field[]\n} & Omit<Partial<CollectionConfig>, 'fields' | 'slug'>\n\n/** One external busy interval returned by `getExternalBusy` (ISO date strings). */\nexport type ExternalBusyInterval = { end: string; label?: string; start: string }\n\n/**\n * App-supplied resolver mapping an external source (e.g. Google/Outlook sync\n * tables) to busy intervals for one resource over [start, end). Wired into\n * checkAvailability (bookings overlapping an interval are unavailable — the\n * interval blocks the WHOLE resource, all units) and into the\n * resource-availability endpoint (returned as `external` for distinct\n * rendering). The plugin calls it inside try/catch and treats an error as []\n * (fail-open): a calendar/sync failure must never block a real booking.\n * Called once per candidate window during slot computation (N calls per\n * request) — keep it cheap: read a local sync table or a per-request cache,\n * never a remote API directly.\n */\nexport type GetExternalBusy = (args: {\n end: Date\n req: PayloadRequest\n resourceId: number | string\n start: Date\n}) => Promise<ExternalBusyInterval[]>\n\nexport type ReservationPluginConfig = {\n /** Override access control per collection */\n access?: {\n customers?: CollectionConfig['access']\n reservations?: CollectionConfig['access']\n resources?: CollectionConfig['access']\n schedules?: CollectionConfig['access']\n services?: CollectionConfig['access']\n }\n /** Admin group name for all reservation collections */\n adminGroup?: string\n /** Allow bookings without a customer account by default (per-service override available) */\n allowGuestBooking?: boolean\n /** Hours of notice required before cancellation */\n cancellationNoticePeriod?: number\n /** Per-collection overrides applied to the generated collections (issue #4) */\n collectionOverrides?: {\n customers?: CollectionOverride\n reservations?: CollectionOverride\n resources?: CollectionOverride\n schedules?: CollectionOverride\n services?: CollectionOverride\n }\n /** Emit info-level `reserve_debug` traces for slot generation and conflict detection (default false) */\n debug?: boolean\n /** Default buffer time in minutes between reservations */\n defaultBufferTime?: number\n /** Disable the plugin entirely */\n disabled?: boolean\n /**\n * Reject bookings against an inactive service or resource, and omit them from\n * availability. Default `true`. Set `false` to restore the previous behaviour\n * where `active` was advisory and only filtered in the admin UI.\n */\n enforceActive?: boolean\n /** @deprecated Use `collectionOverrides.reservations.fields` instead. Extra fields appended to Reservations. */\n extraReservationFields?: Field[]\n /** Resolve external busy intervals (calendar sync etc.) folded into availability + calendar display. */\n getExternalBusy?: GetExternalBusy\n /** Plugin hooks for external integrations */\n hooks?: ReservationPluginHooks\n /** Configurable leave/exception type vocabulary (default: vacation/sick/personal/closure/other) */\n leaveTypes?: string[]\n /** Tenant scoping for the custom admin views (calendar, availability, dashboard). Applied only when the scoped collection has the tenant field AND the tenant cookie is set. */\n multiTenant?: {\n /** Cookie written by the tenant-selector (default 'payload-tenant'). */\n cookieName?: string\n /** Tenant field name on scoped collections (default 'tenant'). */\n tenantField?: string\n /**\n * Field on the tenant document holding its IANA timezone (default 'timezone').\n * When set and the selected tenant has a valid value, the admin views resolve\n * day-boundaries in that tenant's zone instead of the global `timezone`.\n */\n timezoneField?: string\n }\n /** Enable resource-owner multi-tenancy (opt-in) */\n resourceOwnerMode?: ResourceOwnerModeConfig\n /** Configurable resourceType vocabulary (default: staff/equipment/room) */\n resourceTypes?: string[]\n /**\n * Short-lived slot claims taken while a customer completes checkout.\n *\n * Opt-in: when absent, no holds collection is created and availability\n * behaviour is byte-identical to before. Enabling it makes unexpired holds\n * occupy their resource in every availability check.\n */\n slotHolds?: {\n enabled?: boolean\n /** Minutes a hold survives before it stops occupying its slot. Default 10. */\n ttlMinutes?: number\n }\n /** Override collection slugs */\n slugs?: {\n customers?: string\n /** Slug for the slot-holds collection (only created when `slotHolds.enabled`). */\n holds?: string\n media?: string\n reservations?: string\n resources?: string\n schedules?: string\n services?: string\n }\n /** Auto-provision a Resource from staff-role users (opt-in; requires resourceOwnerMode) */\n staffProvisioning?: StaffProvisioningConfig\n /** Configurable status machine (defaults to current behavior) */\n statusMachine?: Partial<StatusMachineConfig>\n /** IANA business timezone governing schedules and day boundaries (default 'UTC') */\n timezone?: string\n /** Which existing auth collection to extend with customer fields */\n userCollection?: string\n}\n\nexport type ResolvedReservationPluginConfig = {\n access: {\n customers?: CollectionConfig['access']\n reservations?: CollectionConfig['access']\n resources?: CollectionConfig['access']\n schedules?: CollectionConfig['access']\n services?: CollectionConfig['access']\n }\n adminGroup: string\n allowGuestBooking: boolean\n cancellationNoticePeriod: number\n collectionOverrides: {\n customers?: CollectionOverride\n reservations?: CollectionOverride\n resources?: CollectionOverride\n schedules?: CollectionOverride\n services?: CollectionOverride\n }\n debug: boolean\n defaultBufferTime: number\n disabled: boolean\n enforceActive: boolean\n extraReservationFields: Field[]\n getExternalBusy: GetExternalBusy | undefined\n /** Whether the media collection (`slugs.media`) exists — set by the plugin; gates the image upload fields. */\n hasMediaCollection: boolean\n /** Set by the plugin once Resources is built: does it still expose a\n * top-level `services` relationship for the Services join to target? */\n hasResourceServicesField: boolean\n hooks: ReservationPluginHooks\n leaveTypes: string[]\n localized: boolean\n multiTenant: {\n cookieName: string\n tenantField: string\n timezoneField: string\n }\n resourceOwnerMode: ResolvedResourceOwnerModeConfig | undefined\n resourceTypes: string[]\n slotHolds: {\n enabled: boolean\n ttlMinutes: number\n }\n slugs: {\n customers: string\n holds: string\n media: string\n reservations: string\n resources: string\n schedules: string\n services: string\n }\n staffProvisioning: ResolvedStaffProvisioningConfig | undefined\n statusMachine: StatusMachineConfig\n timezone: string\n userCollection: string | undefined\n}\n\nexport type ReservationStatus = 'cancelled' | 'completed' | 'confirmed' | 'no-show' | 'pending'\n\nexport type DayOfWeek = 'fri' | 'mon' | 'sat' | 'sun' | 'thu' | 'tue' | 'wed'\n\nexport type ScheduleType = 'manual' | 'recurring'\n\n/** @deprecated Use DEFAULT_STATUS_MACHINE.transitions instead */\nexport const VALID_STATUS_TRANSITIONS: Record<ReservationStatus, ReservationStatus[]> =\n DEFAULT_STATUS_MACHINE.transitions as Record<ReservationStatus, ReservationStatus[]>\n"],"names":["DEFAULT_STATUS_MACHINE","blockingStatuses","cancelStatus","confirmStatus","defaultStatus","statuses","terminalStatuses","transitions","cancelled","completed","confirmed","pending","VALID_STATUS_TRANSITIONS"],"mappings":"AAsBA,OAAO,MAAMA,yBAA8C;IACzDC,kBAAkB;QAAC;QAAW;KAAY;IAC1CC,cAAc;IACdC,eAAe;IACfC,eAAe;IACfC,UAAU;QAAC;QAAW;QAAa;QAAa;QAAa;KAAU;IACvEC,kBAAkB;QAAC;QAAa;QAAa;KAAU;IACvDC,aAAa;QACXC,WAAW,EAAE;QACbC,WAAW,EAAE;QACbC,WAAW;YAAC;YAAa;YAAa;SAAU;QAChD,WAAW,EAAE;QACbC,SAAS;YAAC;YAAa;SAAY;IACrC;AACF,EAAC;AA+SD,+DAA+D,GAC/D,OAAO,MAAMC,2BACXZ,uBAAuBO,WAAW,CAAkD"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Weekday index for a calendar date, 0 = Sunday. TZ-independent pure calendar
3
+ * math — the UTC instant is a carrier for the date only, never a real time.
4
+ */
5
+ export declare function weekdayIndexOfDayKey(dayKey: string): number;
6
+ /** The Sunday on or before `dayKey`. */
7
+ export declare function startOfWeekDayKey(dayKey: string): string;
8
+ /** The Sunday on or before the 1st of `dayKey`'s month — the month grid origin. */
9
+ export declare function monthGridStartDayKey(dayKey: string): string;
10
+ /** `count` consecutive day keys starting at `startDayKey`. */
11
+ export declare function dayKeySequence(startDayKey: string, count: number): string[];
12
+ /**
13
+ * The instant at `hour`:00 on `dayKey` in the BUSINESS timezone.
14
+ *
15
+ * This is the whole point of the module: calendar rows are labelled with
16
+ * getHourInTimezone(date, businessTZ), so the instant a row maps to must be
17
+ * built in that same zone. Using Date#setHours here builds it in the VIEWER's
18
+ * zone, which is how clicking "10:00" came to book a different hour.
19
+ */
20
+ export declare function instantAtHour(dayKey: string, hour: number, timeZone: string): Date;
21
+ /**
22
+ * Like `instantAtHour`, but tolerates the exclusive end hour a grid window can
23
+ * carry: hour 24 is not a wall-clock time (`combineDayKeyAndTime` rejects
24
+ * "24:00"), it means midnight starting the FOLLOWING day, so carry it onto the
25
+ * next day key.
26
+ *
27
+ * The carry is deliberately a day-key increment rather than `+24h` on the day's
28
+ * start: on a DST-transition day the grid spans 23 or 25 real hours, and only
29
+ * re-resolving midnight in the business zone lands on the right instant.
30
+ */
31
+ export declare function gridInstant(dayKey: string, hour: number, timeZone: string): Date;
32
+ /**
33
+ * A Date safe to hand to Intl for rendering `dayKey`'s calendar date.
34
+ *
35
+ * Noon is deliberate: far enough from both midnights that formatting it back
36
+ * with the SAME `timeZone` it was built with can never render the adjacent
37
+ * date, DST included — which is how every caller in this codebase uses it
38
+ * (construct and format in the one business zone). The margin is only ±12h
39
+ * from that zone's own offset, not unconditional: formatting in a *different*
40
+ * zone whose offset differs from `timeZone`'s by more than ~12h (e.g. noon
41
+ * built in Pacific/Auckland, UTC+12/+13, formatted in a UTC-11 zone) can still
42
+ * land on the adjacent day.
43
+ */
44
+ export declare function displayDateForDayKey(dayKey: string, timeZone: string): Date;
@@ -0,0 +1,67 @@
1
+ import { addDaysToDayKey, combineDayKeyAndTime } from './timezoneUtils.js';
2
+ const DAY_KEY_RE = /^\d{4}-\d{2}-\d{2}$/;
3
+ function assertDayKey(dayKey) {
4
+ if (!DAY_KEY_RE.test(dayKey)) {
5
+ throw new Error(`Invalid day key "${dayKey}" — expected YYYY-MM-DD`);
6
+ }
7
+ }
8
+ /**
9
+ * Weekday index for a calendar date, 0 = Sunday. TZ-independent pure calendar
10
+ * math — the UTC instant is a carrier for the date only, never a real time.
11
+ */ export function weekdayIndexOfDayKey(dayKey) {
12
+ assertDayKey(dayKey);
13
+ return new Date(`${dayKey}T00:00:00Z`).getUTCDay();
14
+ }
15
+ /** The Sunday on or before `dayKey`. */ export function startOfWeekDayKey(dayKey) {
16
+ return addDaysToDayKey(dayKey, -weekdayIndexOfDayKey(dayKey));
17
+ }
18
+ /** The Sunday on or before the 1st of `dayKey`'s month — the month grid origin. */ export function monthGridStartDayKey(dayKey) {
19
+ assertDayKey(dayKey);
20
+ return startOfWeekDayKey(`${dayKey.slice(0, 8)}01`);
21
+ }
22
+ /** `count` consecutive day keys starting at `startDayKey`. */ export function dayKeySequence(startDayKey, count) {
23
+ assertDayKey(startDayKey);
24
+ const keys = [];
25
+ for(let i = 0; i < count; i++){
26
+ keys.push(addDaysToDayKey(startDayKey, i));
27
+ }
28
+ return keys;
29
+ }
30
+ /**
31
+ * The instant at `hour`:00 on `dayKey` in the BUSINESS timezone.
32
+ *
33
+ * This is the whole point of the module: calendar rows are labelled with
34
+ * getHourInTimezone(date, businessTZ), so the instant a row maps to must be
35
+ * built in that same zone. Using Date#setHours here builds it in the VIEWER's
36
+ * zone, which is how clicking "10:00" came to book a different hour.
37
+ */ export function instantAtHour(dayKey, hour, timeZone) {
38
+ return combineDayKeyAndTime(dayKey, `${String(hour).padStart(2, '0')}:00`, timeZone);
39
+ }
40
+ /**
41
+ * Like `instantAtHour`, but tolerates the exclusive end hour a grid window can
42
+ * carry: hour 24 is not a wall-clock time (`combineDayKeyAndTime` rejects
43
+ * "24:00"), it means midnight starting the FOLLOWING day, so carry it onto the
44
+ * next day key.
45
+ *
46
+ * The carry is deliberately a day-key increment rather than `+24h` on the day's
47
+ * start: on a DST-transition day the grid spans 23 or 25 real hours, and only
48
+ * re-resolving midnight in the business zone lands on the right instant.
49
+ */ export function gridInstant(dayKey, hour, timeZone) {
50
+ return instantAtHour(addDaysToDayKey(dayKey, Math.floor(hour / 24)), hour % 24, timeZone);
51
+ }
52
+ /**
53
+ * A Date safe to hand to Intl for rendering `dayKey`'s calendar date.
54
+ *
55
+ * Noon is deliberate: far enough from both midnights that formatting it back
56
+ * with the SAME `timeZone` it was built with can never render the adjacent
57
+ * date, DST included — which is how every caller in this codebase uses it
58
+ * (construct and format in the one business zone). The margin is only ±12h
59
+ * from that zone's own offset, not unconditional: formatting in a *different*
60
+ * zone whose offset differs from `timeZone`'s by more than ~12h (e.g. noon
61
+ * built in Pacific/Auckland, UTC+12/+13, formatted in a UTC-11 zone) can still
62
+ * land on the adjacent day.
63
+ */ export function displayDateForDayKey(dayKey, timeZone) {
64
+ return combineDayKeyAndTime(dayKey, '12:00', timeZone);
65
+ }
66
+
67
+ //# sourceMappingURL=calendarGrid.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utilities/calendarGrid.ts"],"sourcesContent":["import { addDaysToDayKey, combineDayKeyAndTime } from './timezoneUtils.js'\n\nconst DAY_KEY_RE = /^\\d{4}-\\d{2}-\\d{2}$/\n\nfunction assertDayKey(dayKey: string): void {\n if (!DAY_KEY_RE.test(dayKey)) {\n throw new Error(`Invalid day key \"${dayKey}\" — expected YYYY-MM-DD`)\n }\n}\n\n/**\n * Weekday index for a calendar date, 0 = Sunday. TZ-independent pure calendar\n * math — the UTC instant is a carrier for the date only, never a real time.\n */\nexport function weekdayIndexOfDayKey(dayKey: string): number {\n assertDayKey(dayKey)\n return new Date(`${dayKey}T00:00:00Z`).getUTCDay()\n}\n\n/** The Sunday on or before `dayKey`. */\nexport function startOfWeekDayKey(dayKey: string): string {\n return addDaysToDayKey(dayKey, -weekdayIndexOfDayKey(dayKey))\n}\n\n/** The Sunday on or before the 1st of `dayKey`'s month — the month grid origin. */\nexport function monthGridStartDayKey(dayKey: string): string {\n assertDayKey(dayKey)\n return startOfWeekDayKey(`${dayKey.slice(0, 8)}01`)\n}\n\n/** `count` consecutive day keys starting at `startDayKey`. */\nexport function dayKeySequence(startDayKey: string, count: number): string[] {\n assertDayKey(startDayKey)\n const keys: string[] = []\n for (let i = 0; i < count; i++) {\n keys.push(addDaysToDayKey(startDayKey, i))\n }\n return keys\n}\n\n/**\n * The instant at `hour`:00 on `dayKey` in the BUSINESS timezone.\n *\n * This is the whole point of the module: calendar rows are labelled with\n * getHourInTimezone(date, businessTZ), so the instant a row maps to must be\n * built in that same zone. Using Date#setHours here builds it in the VIEWER's\n * zone, which is how clicking \"10:00\" came to book a different hour.\n */\nexport function instantAtHour(dayKey: string, hour: number, timeZone: string): Date {\n return combineDayKeyAndTime(dayKey, `${String(hour).padStart(2, '0')}:00`, timeZone)\n}\n\n/**\n * Like `instantAtHour`, but tolerates the exclusive end hour a grid window can\n * carry: hour 24 is not a wall-clock time (`combineDayKeyAndTime` rejects\n * \"24:00\"), it means midnight starting the FOLLOWING day, so carry it onto the\n * next day key.\n *\n * The carry is deliberately a day-key increment rather than `+24h` on the day's\n * start: on a DST-transition day the grid spans 23 or 25 real hours, and only\n * re-resolving midnight in the business zone lands on the right instant.\n */\nexport function gridInstant(dayKey: string, hour: number, timeZone: string): Date {\n return instantAtHour(addDaysToDayKey(dayKey, Math.floor(hour / 24)), hour % 24, timeZone)\n}\n\n/**\n * A Date safe to hand to Intl for rendering `dayKey`'s calendar date.\n *\n * Noon is deliberate: far enough from both midnights that formatting it back\n * with the SAME `timeZone` it was built with can never render the adjacent\n * date, DST included — which is how every caller in this codebase uses it\n * (construct and format in the one business zone). The margin is only ±12h\n * from that zone's own offset, not unconditional: formatting in a *different*\n * zone whose offset differs from `timeZone`'s by more than ~12h (e.g. noon\n * built in Pacific/Auckland, UTC+12/+13, formatted in a UTC-11 zone) can still\n * land on the adjacent day.\n */\nexport function displayDateForDayKey(dayKey: string, timeZone: string): Date {\n return combineDayKeyAndTime(dayKey, '12:00', timeZone)\n}\n"],"names":["addDaysToDayKey","combineDayKeyAndTime","DAY_KEY_RE","assertDayKey","dayKey","test","Error","weekdayIndexOfDayKey","Date","getUTCDay","startOfWeekDayKey","monthGridStartDayKey","slice","dayKeySequence","startDayKey","count","keys","i","push","instantAtHour","hour","timeZone","String","padStart","gridInstant","Math","floor","displayDateForDayKey"],"mappings":"AAAA,SAASA,eAAe,EAAEC,oBAAoB,QAAQ,qBAAoB;AAE1E,MAAMC,aAAa;AAEnB,SAASC,aAAaC,MAAc;IAClC,IAAI,CAACF,WAAWG,IAAI,CAACD,SAAS;QAC5B,MAAM,IAAIE,MAAM,CAAC,iBAAiB,EAAEF,OAAO,uBAAuB,CAAC;IACrE;AACF;AAEA;;;CAGC,GACD,OAAO,SAASG,qBAAqBH,MAAc;IACjDD,aAAaC;IACb,OAAO,IAAII,KAAK,GAAGJ,OAAO,UAAU,CAAC,EAAEK,SAAS;AAClD;AAEA,sCAAsC,GACtC,OAAO,SAASC,kBAAkBN,MAAc;IAC9C,OAAOJ,gBAAgBI,QAAQ,CAACG,qBAAqBH;AACvD;AAEA,iFAAiF,GACjF,OAAO,SAASO,qBAAqBP,MAAc;IACjDD,aAAaC;IACb,OAAOM,kBAAkB,GAAGN,OAAOQ,KAAK,CAAC,GAAG,GAAG,EAAE,CAAC;AACpD;AAEA,4DAA4D,GAC5D,OAAO,SAASC,eAAeC,WAAmB,EAAEC,KAAa;IAC/DZ,aAAaW;IACb,MAAME,OAAiB,EAAE;IACzB,IAAK,IAAIC,IAAI,GAAGA,IAAIF,OAAOE,IAAK;QAC9BD,KAAKE,IAAI,CAAClB,gBAAgBc,aAAaG;IACzC;IACA,OAAOD;AACT;AAEA;;;;;;;CAOC,GACD,OAAO,SAASG,cAAcf,MAAc,EAAEgB,IAAY,EAAEC,QAAgB;IAC1E,OAAOpB,qBAAqBG,QAAQ,GAAGkB,OAAOF,MAAMG,QAAQ,CAAC,GAAG,KAAK,GAAG,CAAC,EAAEF;AAC7E;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASG,YAAYpB,MAAc,EAAEgB,IAAY,EAAEC,QAAgB;IACxE,OAAOF,cAAcnB,gBAAgBI,QAAQqB,KAAKC,KAAK,CAACN,OAAO,MAAMA,OAAO,IAAIC;AAClF;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASM,qBAAqBvB,MAAc,EAAEiB,QAAgB;IACnE,OAAOpB,qBAAqBG,QAAQ,SAASiB;AAC/C"}
@@ -11,7 +11,12 @@ export declare function mergeReservationData(data: Record<string, unknown>, orig
11
11
  * field, and a notes-only edit must not trigger re-validation.
12
12
  */
13
13
  export declare function schedulingFieldsChanged({ blockingStatuses, data, originalDoc, }: {
14
- blockingStatuses: string[];
14
+ /**
15
+ * Omit to compare scheduling VALUES only, skipping the status clause below.
16
+ * `validateActive` needs that: a confirm or cancel must stay possible on a
17
+ * booking whose service or resource was deactivated after it was made.
18
+ */
19
+ blockingStatuses?: string[];
15
20
  data: Record<string, unknown>;
16
21
  originalDoc: Record<string, unknown> | undefined;
17
22
  }): boolean;
@@ -76,7 +76,7 @@ function itemsEqual(a, b) {
76
76
  return true;
77
77
  }
78
78
  }
79
- if ('status' in data && typeof data.status === 'string') {
79
+ if (blockingStatuses && 'status' in data && typeof data.status === 'string') {
80
80
  const prevStatus = originalDoc.status;
81
81
  if (data.status !== prevStatus && blockingStatuses.includes(data.status) && (prevStatus === undefined || !blockingStatuses.includes(prevStatus))) {
82
82
  return true;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utilities/reservationChanges.ts"],"sourcesContent":["import { extractId } from './resolveReservationItems.js'\n\n/**\n * Fields whose change means a reservation's slot occupancy may differ,\n * requiring conflict re-validation and endTime recomputation on update.\n */\nconst SCHEDULING_FIELDS = [\n 'endTime',\n 'guestCount',\n 'items',\n 'resource',\n 'service',\n 'startTime',\n] as const\n\n/**\n * Read-only merged view of an update: the original document overlaid with the\n * incoming partial patch. Used for validation only — never assign the result\n * back into `data`, or unchanged fields get written back to the database.\n */\nexport function mergeReservationData(\n data: Record<string, unknown>,\n originalDoc: Record<string, unknown> | undefined,\n): Record<string, unknown> {\n return { ...(originalDoc ?? {}), ...(data ?? {}) }\n}\n\nfunction normalizeDate(value: unknown): null | number {\n if (value === null || value === undefined || value === '') {\n return null\n }\n const time = new Date(value as string).getTime()\n return Number.isNaN(time) ? null : time\n}\n\nfunction normalizeRelationship(value: unknown): null | string {\n const id = extractId(value)\n return id === undefined ? null : String(id)\n}\n\nfunction itemsEqual(a: unknown, b: unknown): boolean {\n const listA = Array.isArray(a) ? (a as Array<Record<string, unknown>>) : []\n const listB = Array.isArray(b) ? (b as Array<Record<string, unknown>>) : []\n if (listA.length !== listB.length) {\n return false\n }\n return listA.every((itemA, i) => {\n const itemB = listB[i]\n return (\n normalizeRelationship(itemA.resource) === normalizeRelationship(itemB.resource) &&\n normalizeRelationship(itemA.service) === normalizeRelationship(itemB.service) &&\n normalizeDate(itemA.startTime) === normalizeDate(itemB.startTime) &&\n normalizeDate(itemA.endTime) === normalizeDate(itemB.endTime) &&\n ((itemA.guestCount ?? null) as null | number) ===\n ((itemB.guestCount ?? null) as null | number)\n )\n })\n}\n\n/**\n * True when the update patch changes any scheduling-relevant field, or moves\n * status from a non-blocking value into a blocking one (re-occupying a slot).\n * Key presence alone is not a change — full-document admin saves include every\n * field, and a notes-only edit must not trigger re-validation.\n */\nexport function schedulingFieldsChanged({\n blockingStatuses,\n data,\n originalDoc,\n}: {\n blockingStatuses: string[]\n data: Record<string, unknown>\n originalDoc: Record<string, unknown> | undefined\n}): boolean {\n if (!originalDoc) {\n return true\n }\n\n for (const field of SCHEDULING_FIELDS) {\n if (!(field in data)) {\n continue\n }\n const next = data[field]\n const prev = originalDoc[field]\n let changed: boolean\n switch (field) {\n case 'endTime':\n case 'startTime':\n changed = normalizeDate(next) !== normalizeDate(prev)\n break\n case 'guestCount':\n changed = ((next ?? null) as null | number) !== ((prev ?? null) as null | number)\n break\n case 'items':\n changed = !itemsEqual(next, prev)\n break\n default:\n changed = normalizeRelationship(next) !== normalizeRelationship(prev)\n }\n if (changed) {\n return true\n }\n }\n\n if ('status' in data && typeof data.status === 'string') {\n const prevStatus = originalDoc.status as string | undefined\n if (\n data.status !== prevStatus &&\n blockingStatuses.includes(data.status) &&\n (prevStatus === undefined || !blockingStatuses.includes(prevStatus))\n ) {\n return true\n }\n }\n\n return false\n}\n"],"names":["extractId","SCHEDULING_FIELDS","mergeReservationData","data","originalDoc","normalizeDate","value","undefined","time","Date","getTime","Number","isNaN","normalizeRelationship","id","String","itemsEqual","a","b","listA","Array","isArray","listB","length","every","itemA","i","itemB","resource","service","startTime","endTime","guestCount","schedulingFieldsChanged","blockingStatuses","field","next","prev","changed","status","prevStatus","includes"],"mappings":"AAAA,SAASA,SAAS,QAAQ,+BAA8B;AAExD;;;CAGC,GACD,MAAMC,oBAAoB;IACxB;IACA;IACA;IACA;IACA;IACA;CACD;AAED;;;;CAIC,GACD,OAAO,SAASC,qBACdC,IAA6B,EAC7BC,WAAgD;IAEhD,OAAO;QAAE,GAAIA,eAAe,CAAC,CAAC;QAAG,GAAID,QAAQ,CAAC,CAAC;IAAE;AACnD;AAEA,SAASE,cAAcC,KAAc;IACnC,IAAIA,UAAU,QAAQA,UAAUC,aAAaD,UAAU,IAAI;QACzD,OAAO;IACT;IACA,MAAME,OAAO,IAAIC,KAAKH,OAAiBI,OAAO;IAC9C,OAAOC,OAAOC,KAAK,CAACJ,QAAQ,OAAOA;AACrC;AAEA,SAASK,sBAAsBP,KAAc;IAC3C,MAAMQ,KAAKd,UAAUM;IACrB,OAAOQ,OAAOP,YAAY,OAAOQ,OAAOD;AAC1C;AAEA,SAASE,WAAWC,CAAU,EAAEC,CAAU;IACxC,MAAMC,QAAQC,MAAMC,OAAO,CAACJ,KAAMA,IAAuC,EAAE;IAC3E,MAAMK,QAAQF,MAAMC,OAAO,CAACH,KAAMA,IAAuC,EAAE;IAC3E,IAAIC,MAAMI,MAAM,KAAKD,MAAMC,MAAM,EAAE;QACjC,OAAO;IACT;IACA,OAAOJ,MAAMK,KAAK,CAAC,CAACC,OAAOC;QACzB,MAAMC,QAAQL,KAAK,CAACI,EAAE;QACtB,OACEb,sBAAsBY,MAAMG,QAAQ,MAAMf,sBAAsBc,MAAMC,QAAQ,KAC9Ef,sBAAsBY,MAAMI,OAAO,MAAMhB,sBAAsBc,MAAME,OAAO,KAC5ExB,cAAcoB,MAAMK,SAAS,MAAMzB,cAAcsB,MAAMG,SAAS,KAChEzB,cAAcoB,MAAMM,OAAO,MAAM1B,cAAcsB,MAAMI,OAAO,KAC5D,AAAEN,CAAAA,MAAMO,UAAU,IAAI,IAAG,MACrBL,CAAAA,MAAMK,UAAU,IAAI,IAAG;IAE/B;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASC,wBAAwB,EACtCC,gBAAgB,EAChB/B,IAAI,EACJC,WAAW,EAKZ;IACC,IAAI,CAACA,aAAa;QAChB,OAAO;IACT;IAEA,KAAK,MAAM+B,SAASlC,kBAAmB;QACrC,IAAI,CAAEkC,CAAAA,SAAShC,IAAG,GAAI;YACpB;QACF;QACA,MAAMiC,OAAOjC,IAAI,CAACgC,MAAM;QACxB,MAAME,OAAOjC,WAAW,CAAC+B,MAAM;QAC/B,IAAIG;QACJ,OAAQH;YACN,KAAK;YACL,KAAK;gBACHG,UAAUjC,cAAc+B,UAAU/B,cAAcgC;gBAChD;YACF,KAAK;gBACHC,UAAU,AAAEF,CAAAA,QAAQ,IAAG,MAA2BC,CAAAA,QAAQ,IAAG;gBAC7D;YACF,KAAK;gBACHC,UAAU,CAACtB,WAAWoB,MAAMC;gBAC5B;YACF;gBACEC,UAAUzB,sBAAsBuB,UAAUvB,sBAAsBwB;QACpE;QACA,IAAIC,SAAS;YACX,OAAO;QACT;IACF;IAEA,IAAI,YAAYnC,QAAQ,OAAOA,KAAKoC,MAAM,KAAK,UAAU;QACvD,MAAMC,aAAapC,YAAYmC,MAAM;QACrC,IACEpC,KAAKoC,MAAM,KAAKC,cAChBN,iBAAiBO,QAAQ,CAACtC,KAAKoC,MAAM,KACpCC,CAAAA,eAAejC,aAAa,CAAC2B,iBAAiBO,QAAQ,CAACD,WAAU,GAClE;YACA,OAAO;QACT;IACF;IAEA,OAAO;AACT"}
1
+ {"version":3,"sources":["../../src/utilities/reservationChanges.ts"],"sourcesContent":["import { extractId } from './resolveReservationItems.js'\n\n/**\n * Fields whose change means a reservation's slot occupancy may differ,\n * requiring conflict re-validation and endTime recomputation on update.\n */\nconst SCHEDULING_FIELDS = [\n 'endTime',\n 'guestCount',\n 'items',\n 'resource',\n 'service',\n 'startTime',\n] as const\n\n/**\n * Read-only merged view of an update: the original document overlaid with the\n * incoming partial patch. Used for validation only — never assign the result\n * back into `data`, or unchanged fields get written back to the database.\n */\nexport function mergeReservationData(\n data: Record<string, unknown>,\n originalDoc: Record<string, unknown> | undefined,\n): Record<string, unknown> {\n return { ...(originalDoc ?? {}), ...(data ?? {}) }\n}\n\nfunction normalizeDate(value: unknown): null | number {\n if (value === null || value === undefined || value === '') {\n return null\n }\n const time = new Date(value as string).getTime()\n return Number.isNaN(time) ? null : time\n}\n\nfunction normalizeRelationship(value: unknown): null | string {\n const id = extractId(value)\n return id === undefined ? null : String(id)\n}\n\nfunction itemsEqual(a: unknown, b: unknown): boolean {\n const listA = Array.isArray(a) ? (a as Array<Record<string, unknown>>) : []\n const listB = Array.isArray(b) ? (b as Array<Record<string, unknown>>) : []\n if (listA.length !== listB.length) {\n return false\n }\n return listA.every((itemA, i) => {\n const itemB = listB[i]\n return (\n normalizeRelationship(itemA.resource) === normalizeRelationship(itemB.resource) &&\n normalizeRelationship(itemA.service) === normalizeRelationship(itemB.service) &&\n normalizeDate(itemA.startTime) === normalizeDate(itemB.startTime) &&\n normalizeDate(itemA.endTime) === normalizeDate(itemB.endTime) &&\n ((itemA.guestCount ?? null) as null | number) ===\n ((itemB.guestCount ?? null) as null | number)\n )\n })\n}\n\n/**\n * True when the update patch changes any scheduling-relevant field, or moves\n * status from a non-blocking value into a blocking one (re-occupying a slot).\n * Key presence alone is not a change — full-document admin saves include every\n * field, and a notes-only edit must not trigger re-validation.\n */\nexport function schedulingFieldsChanged({\n blockingStatuses,\n data,\n originalDoc,\n}: {\n /**\n * Omit to compare scheduling VALUES only, skipping the status clause below.\n * `validateActive` needs that: a confirm or cancel must stay possible on a\n * booking whose service or resource was deactivated after it was made.\n */\n blockingStatuses?: string[]\n data: Record<string, unknown>\n originalDoc: Record<string, unknown> | undefined\n}): boolean {\n if (!originalDoc) {\n return true\n }\n\n for (const field of SCHEDULING_FIELDS) {\n if (!(field in data)) {\n continue\n }\n const next = data[field]\n const prev = originalDoc[field]\n let changed: boolean\n switch (field) {\n case 'endTime':\n case 'startTime':\n changed = normalizeDate(next) !== normalizeDate(prev)\n break\n case 'guestCount':\n changed = ((next ?? null) as null | number) !== ((prev ?? null) as null | number)\n break\n case 'items':\n changed = !itemsEqual(next, prev)\n break\n default:\n changed = normalizeRelationship(next) !== normalizeRelationship(prev)\n }\n if (changed) {\n return true\n }\n }\n\n if (blockingStatuses && 'status' in data && typeof data.status === 'string') {\n const prevStatus = originalDoc.status as string | undefined\n if (\n data.status !== prevStatus &&\n blockingStatuses.includes(data.status) &&\n (prevStatus === undefined || !blockingStatuses.includes(prevStatus))\n ) {\n return true\n }\n }\n\n return false\n}\n"],"names":["extractId","SCHEDULING_FIELDS","mergeReservationData","data","originalDoc","normalizeDate","value","undefined","time","Date","getTime","Number","isNaN","normalizeRelationship","id","String","itemsEqual","a","b","listA","Array","isArray","listB","length","every","itemA","i","itemB","resource","service","startTime","endTime","guestCount","schedulingFieldsChanged","blockingStatuses","field","next","prev","changed","status","prevStatus","includes"],"mappings":"AAAA,SAASA,SAAS,QAAQ,+BAA8B;AAExD;;;CAGC,GACD,MAAMC,oBAAoB;IACxB;IACA;IACA;IACA;IACA;IACA;CACD;AAED;;;;CAIC,GACD,OAAO,SAASC,qBACdC,IAA6B,EAC7BC,WAAgD;IAEhD,OAAO;QAAE,GAAIA,eAAe,CAAC,CAAC;QAAG,GAAID,QAAQ,CAAC,CAAC;IAAE;AACnD;AAEA,SAASE,cAAcC,KAAc;IACnC,IAAIA,UAAU,QAAQA,UAAUC,aAAaD,UAAU,IAAI;QACzD,OAAO;IACT;IACA,MAAME,OAAO,IAAIC,KAAKH,OAAiBI,OAAO;IAC9C,OAAOC,OAAOC,KAAK,CAACJ,QAAQ,OAAOA;AACrC;AAEA,SAASK,sBAAsBP,KAAc;IAC3C,MAAMQ,KAAKd,UAAUM;IACrB,OAAOQ,OAAOP,YAAY,OAAOQ,OAAOD;AAC1C;AAEA,SAASE,WAAWC,CAAU,EAAEC,CAAU;IACxC,MAAMC,QAAQC,MAAMC,OAAO,CAACJ,KAAMA,IAAuC,EAAE;IAC3E,MAAMK,QAAQF,MAAMC,OAAO,CAACH,KAAMA,IAAuC,EAAE;IAC3E,IAAIC,MAAMI,MAAM,KAAKD,MAAMC,MAAM,EAAE;QACjC,OAAO;IACT;IACA,OAAOJ,MAAMK,KAAK,CAAC,CAACC,OAAOC;QACzB,MAAMC,QAAQL,KAAK,CAACI,EAAE;QACtB,OACEb,sBAAsBY,MAAMG,QAAQ,MAAMf,sBAAsBc,MAAMC,QAAQ,KAC9Ef,sBAAsBY,MAAMI,OAAO,MAAMhB,sBAAsBc,MAAME,OAAO,KAC5ExB,cAAcoB,MAAMK,SAAS,MAAMzB,cAAcsB,MAAMG,SAAS,KAChEzB,cAAcoB,MAAMM,OAAO,MAAM1B,cAAcsB,MAAMI,OAAO,KAC5D,AAAEN,CAAAA,MAAMO,UAAU,IAAI,IAAG,MACrBL,CAAAA,MAAMK,UAAU,IAAI,IAAG;IAE/B;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASC,wBAAwB,EACtCC,gBAAgB,EAChB/B,IAAI,EACJC,WAAW,EAUZ;IACC,IAAI,CAACA,aAAa;QAChB,OAAO;IACT;IAEA,KAAK,MAAM+B,SAASlC,kBAAmB;QACrC,IAAI,CAAEkC,CAAAA,SAAShC,IAAG,GAAI;YACpB;QACF;QACA,MAAMiC,OAAOjC,IAAI,CAACgC,MAAM;QACxB,MAAME,OAAOjC,WAAW,CAAC+B,MAAM;QAC/B,IAAIG;QACJ,OAAQH;YACN,KAAK;YACL,KAAK;gBACHG,UAAUjC,cAAc+B,UAAU/B,cAAcgC;gBAChD;YACF,KAAK;gBACHC,UAAU,AAAEF,CAAAA,QAAQ,IAAG,MAA2BC,CAAAA,QAAQ,IAAG;gBAC7D;YACF,KAAK;gBACHC,UAAU,CAACtB,WAAWoB,MAAMC;gBAC5B;YACF;gBACEC,UAAUzB,sBAAsBuB,UAAUvB,sBAAsBwB;QACpE;QACA,IAAIC,SAAS;YACX,OAAO;QACT;IACF;IAEA,IAAIJ,oBAAoB,YAAY/B,QAAQ,OAAOA,KAAKoC,MAAM,KAAK,UAAU;QAC3E,MAAMC,aAAapC,YAAYmC,MAAM;QACrC,IACEpC,KAAKoC,MAAM,KAAKC,cAChBN,iBAAiBO,QAAQ,CAACtC,KAAKoC,MAAM,KACpCC,CAAAA,eAAejC,aAAa,CAAC2B,iBAAiBO,QAAQ,CAACD,WAAU,GAClE;YACA,OAAO;QACT;IACF;IAEA,OAAO;AACT"}
@@ -1,5 +1,7 @@
1
1
  export type ResolvedItem = {
2
2
  endTime: string;
3
+ /** True when this item was synthesised from the top-level resource/startTime. */
4
+ fromParent?: boolean;
3
5
  guestCount: number;
4
6
  resource: number | string;
5
7
  service?: number | string;
@@ -8,13 +10,22 @@ export type ResolvedItem = {
8
10
  /**
9
11
  * Normalize reservation data into a list of resource-level items.
10
12
  *
11
- * - If items[] is populated -> return items (filling defaults from parent).
13
+ * - If items[] is populated -> return items (filling defaults from parent), then
14
+ * append a synthesised item for the top-level resource/startTime/endTime UNLESS
15
+ * an items[] entry can already be shown to cover that same window (see B1 in
16
+ * the synthesis step below) — this is what makes the top-level `resource`
17
+ * conflict-checked even when items[] never names it.
12
18
  * Items missing startTime or resource throw a ValidationError.
13
19
  * Duplicate (resource, startTime) pairs throw a ValidationError.
20
+ * An inverted top-level (parent) window throws a ValidationError too — UNLESS
21
+ * `options.lenient` is set, in which case parent synthesis is silently
22
+ * skipped instead (see the lenient-mode note below).
14
23
  * - If items[] is empty/absent -> return single item from top-level fields
15
24
  *
16
25
  * Every downstream function (conflict check, endTime calc, availability)
17
26
  * works with ResolvedItem[], never with raw reservation data.
18
27
  */
19
- export declare function resolveReservationItems(data: Record<string, unknown>): ResolvedItem[];
28
+ export declare function resolveReservationItems(data: Record<string, unknown>, options?: {
29
+ lenient?: boolean;
30
+ }): ResolvedItem[];
20
31
  export declare function extractId(value: unknown): number | string | undefined;
@@ -1,15 +1,24 @@
1
1
  import { ValidationError } from 'payload';
2
+ import { doRangesOverlap } from './slotUtils.js';
2
3
  /**
3
4
  * Normalize reservation data into a list of resource-level items.
4
5
  *
5
- * - If items[] is populated -> return items (filling defaults from parent).
6
+ * - If items[] is populated -> return items (filling defaults from parent), then
7
+ * append a synthesised item for the top-level resource/startTime/endTime UNLESS
8
+ * an items[] entry can already be shown to cover that same window (see B1 in
9
+ * the synthesis step below) — this is what makes the top-level `resource`
10
+ * conflict-checked even when items[] never names it.
6
11
  * Items missing startTime or resource throw a ValidationError.
7
12
  * Duplicate (resource, startTime) pairs throw a ValidationError.
13
+ * An inverted top-level (parent) window throws a ValidationError too — UNLESS
14
+ * `options.lenient` is set, in which case parent synthesis is silently
15
+ * skipped instead (see the lenient-mode note below).
8
16
  * - If items[] is empty/absent -> return single item from top-level fields
9
17
  *
10
18
  * Every downstream function (conflict check, endTime calc, availability)
11
19
  * works with ResolvedItem[], never with raw reservation data.
12
- */ export function resolveReservationItems(data) {
20
+ */ export function resolveReservationItems(data, options) {
21
+ const lenient = options?.lenient ?? false;
13
22
  const items = data.items;
14
23
  if (items && items.length > 0) {
15
24
  const resolved = [];
@@ -58,6 +67,82 @@ import { ValidationError } from 'payload';
58
67
  startTime
59
68
  });
60
69
  }
70
+ // The stored row occupies its top-level `resource` for every OTHER booking's
71
+ // conflict check (buildCoarseOverlapQuery matches top level OR items[]), so
72
+ // it must occupy it for its own check too. Sharing a resource id with an
73
+ // items[] entry is NOT sufficient to skip synthesis — an items[] entry for
74
+ // the same resource at an unrelated time is a genuinely separate occupancy,
75
+ // and treating "same resource anywhere" as "already covered" reopens the
76
+ // exact double-booking class this function exists to close (a caller can
77
+ // list resource A at one time in items[] while the top-level fields book A
78
+ // at a completely different, uncovered time). Synthesis is skipped only
79
+ // when an items[] entry for the SAME resource can be shown to cover the
80
+ // SAME window:
81
+ // (a) an exact startTime match — needs no endTime, so it still works when
82
+ // this function runs before endTime is computed (validateActive and
83
+ // calculateEndTime both call it before calculateEndTime has run); or
84
+ // (b) both endTimes are known and the windows overlap — covers
85
+ // calculateEndTime's multi-resource branch, which can overwrite the
86
+ // top-level startTime/endTime to SPAN every item, so by the time this
87
+ // function runs again (e.g. from validateConflicts) the parent's
88
+ // window no longer starts at the same instant as any one item even
89
+ // though that item's own window is fully contained in it.
90
+ // When neither can be shown, synthesize: a redundant-but-harmless extra
91
+ // check is far cheaper than a silently missed one.
92
+ const parentResource = extractId(data.resource);
93
+ const parentStart = data.startTime;
94
+ const parentEnd = data.endTime;
95
+ // An inverted parent window can never overlap anything, so the coverage test
96
+ // below would always say "not covered" and synthesise a phantom item that
97
+ // conflicts with nothing. Reject it at the source instead — it is malformed
98
+ // input, not a case to paper over.
99
+ //
100
+ // Lenient mode (reservationOccupancies only — see AvailabilityService.ts)
101
+ // resolves ALREADY-STORED documents, which can carry an inverted window
102
+ // from a context.skipReservationHooks write or data predating this check.
103
+ // A read must never crash over one malformed row, and — critically — it
104
+ // must not lose the real items[] occupancies already resolved above by
105
+ // throwing out of this function entirely. So lenient mode just skips
106
+ // parent synthesis here instead of throwing: precisely the pre-check
107
+ // behavior minus the (harmless but pointless) phantom item. The write
108
+ // path never passes `lenient`, so it keeps the hard rejection.
109
+ if (parentStart && parentEnd && new Date(parentEnd) <= new Date(parentStart)) {
110
+ if (lenient) {
111
+ return resolved;
112
+ }
113
+ throw new ValidationError({
114
+ errors: [
115
+ {
116
+ message: 'endTime must be after startTime',
117
+ path: 'endTime'
118
+ }
119
+ ]
120
+ });
121
+ }
122
+ if (parentResource !== undefined && parentResource !== '' && parentStart) {
123
+ const parentAlreadyItemized = resolved.some((item)=>{
124
+ // String-compare ids: a raw id (string for Mongo, number for Postgres)
125
+ // and a populated relationship's extracted `.id` should match even if
126
+ // one side came through as a different primitive type than the other.
127
+ if (String(item.resource) !== String(parentResource)) {
128
+ return false;
129
+ }
130
+ if (item.startTime === parentStart) {
131
+ return true;
132
+ }
133
+ return Boolean(parentEnd && item.endTime && doRangesOverlap(new Date(parentStart), new Date(parentEnd), new Date(item.startTime), new Date(item.endTime)));
134
+ });
135
+ if (!parentAlreadyItemized) {
136
+ resolved.push({
137
+ endTime: parentEnd,
138
+ fromParent: true,
139
+ guestCount: data.guestCount ?? 1,
140
+ resource: parentResource,
141
+ service: extractId(data.service),
142
+ startTime: parentStart
143
+ });
144
+ }
145
+ }
61
146
  return resolved;
62
147
  }
63
148
  // Single-resource fallback (current behavior)
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utilities/resolveReservationItems.ts"],"sourcesContent":["import { ValidationError } from 'payload'\n\nexport type ResolvedItem = {\n endTime: string\n guestCount: number\n resource: number | string\n service?: number | string\n startTime: string\n}\n\n/**\n * Normalize reservation data into a list of resource-level items.\n *\n * - If items[] is populated -> return items (filling defaults from parent).\n * Items missing startTime or resource throw a ValidationError.\n * Duplicate (resource, startTime) pairs throw a ValidationError.\n * - If items[] is empty/absent -> return single item from top-level fields\n *\n * Every downstream function (conflict check, endTime calc, availability)\n * works with ResolvedItem[], never with raw reservation data.\n */\nexport function resolveReservationItems(data: Record<string, unknown>): ResolvedItem[] {\n const items = data.items as Array<Record<string, unknown>> | undefined\n\n if (items && items.length > 0) {\n const resolved: ResolvedItem[] = []\n const seen = new Set<string>()\n\n for (let i = 0; i < items.length; i++) {\n const item = items[i]\n const resource = extractId(item.resource) ?? extractId(data.resource)\n const startTime = (item.startTime as string) ?? (data.startTime as string)\n\n if (resource === undefined || resource === '') {\n throw new ValidationError({\n errors: [\n {\n message: `Item ${i} is missing a resource`,\n path: `items.${i}.resource`,\n },\n ],\n })\n }\n\n if (!startTime) {\n throw new ValidationError({\n errors: [\n {\n message: `Item ${i} is missing a startTime`,\n path: `items.${i}.startTime`,\n },\n ],\n })\n }\n\n const key = `${resource}::${startTime}`\n if (seen.has(key)) {\n throw new ValidationError({\n errors: [\n {\n message: `Duplicate booking: item ${i} has the same resource and startTime as a previous item`,\n path: `items.${i}.startTime`,\n },\n ],\n })\n }\n seen.add(key)\n\n resolved.push({\n endTime: (item.endTime as string) ?? (data.endTime as string),\n guestCount: (item.guestCount as number) ?? (data.guestCount as number) ?? 1,\n resource,\n service: extractId(item.service) ?? extractId(data.service),\n startTime,\n })\n }\n\n return resolved\n }\n\n // Single-resource fallback (current behavior)\n if (!data.resource || !data.startTime) {\n return []\n }\n\n const resource = extractId(data.resource)\n if (resource === undefined || resource === '') {\n return []\n }\n\n return [\n {\n endTime: data.endTime as string,\n guestCount: (data.guestCount as number) ?? 1,\n resource,\n service: extractId(data.service),\n startTime: data.startTime as string,\n },\n ]\n}\n\n// Exported for unit testing. Returns the underlying id value from a relationship\n// field which Payload represents as either the raw id (string for Mongo, number\n// for Postgres) or a populated document `{ id, ... }`.\n//\n// Note: `0` is a valid numeric Postgres id but rare; we still return it rather\n// than treat it as missing.\nexport function extractId(value: unknown): number | string | undefined {\n if (typeof value === 'string' && value) {\n return value\n }\n if (typeof value === 'number') {\n return value\n }\n if (value && typeof value === 'object' && 'id' in value) {\n return (value as { id: number | string }).id\n }\n return undefined\n}\n"],"names":["ValidationError","resolveReservationItems","data","items","length","resolved","seen","Set","i","item","resource","extractId","startTime","undefined","errors","message","path","key","has","add","push","endTime","guestCount","service","value","id"],"mappings":"AAAA,SAASA,eAAe,QAAQ,UAAS;AAUzC;;;;;;;;;;CAUC,GACD,OAAO,SAASC,wBAAwBC,IAA6B;IACnE,MAAMC,QAAQD,KAAKC,KAAK;IAExB,IAAIA,SAASA,MAAMC,MAAM,GAAG,GAAG;QAC7B,MAAMC,WAA2B,EAAE;QACnC,MAAMC,OAAO,IAAIC;QAEjB,IAAK,IAAIC,IAAI,GAAGA,IAAIL,MAAMC,MAAM,EAAEI,IAAK;YACrC,MAAMC,OAAON,KAAK,CAACK,EAAE;YACrB,MAAME,WAAWC,UAAUF,KAAKC,QAAQ,KAAKC,UAAUT,KAAKQ,QAAQ;YACpE,MAAME,YAAY,AAACH,KAAKG,SAAS,IAAgBV,KAAKU,SAAS;YAE/D,IAAIF,aAAaG,aAAaH,aAAa,IAAI;gBAC7C,MAAM,IAAIV,gBAAgB;oBACxBc,QAAQ;wBACN;4BACEC,SAAS,CAAC,KAAK,EAAEP,EAAE,sBAAsB,CAAC;4BAC1CQ,MAAM,CAAC,MAAM,EAAER,EAAE,SAAS,CAAC;wBAC7B;qBACD;gBACH;YACF;YAEA,IAAI,CAACI,WAAW;gBACd,MAAM,IAAIZ,gBAAgB;oBACxBc,QAAQ;wBACN;4BACEC,SAAS,CAAC,KAAK,EAAEP,EAAE,uBAAuB,CAAC;4BAC3CQ,MAAM,CAAC,MAAM,EAAER,EAAE,UAAU,CAAC;wBAC9B;qBACD;gBACH;YACF;YAEA,MAAMS,MAAM,GAAGP,SAAS,EAAE,EAAEE,WAAW;YACvC,IAAIN,KAAKY,GAAG,CAACD,MAAM;gBACjB,MAAM,IAAIjB,gBAAgB;oBACxBc,QAAQ;wBACN;4BACEC,SAAS,CAAC,wBAAwB,EAAEP,EAAE,uDAAuD,CAAC;4BAC9FQ,MAAM,CAAC,MAAM,EAAER,EAAE,UAAU,CAAC;wBAC9B;qBACD;gBACH;YACF;YACAF,KAAKa,GAAG,CAACF;YAETZ,SAASe,IAAI,CAAC;gBACZC,SAAS,AAACZ,KAAKY,OAAO,IAAgBnB,KAAKmB,OAAO;gBAClDC,YAAY,AAACb,KAAKa,UAAU,IAAgBpB,KAAKoB,UAAU,IAAe;gBAC1EZ;gBACAa,SAASZ,UAAUF,KAAKc,OAAO,KAAKZ,UAAUT,KAAKqB,OAAO;gBAC1DX;YACF;QACF;QAEA,OAAOP;IACT;IAEA,8CAA8C;IAC9C,IAAI,CAACH,KAAKQ,QAAQ,IAAI,CAACR,KAAKU,SAAS,EAAE;QACrC,OAAO,EAAE;IACX;IAEA,MAAMF,WAAWC,UAAUT,KAAKQ,QAAQ;IACxC,IAAIA,aAAaG,aAAaH,aAAa,IAAI;QAC7C,OAAO,EAAE;IACX;IAEA,OAAO;QACL;YACEW,SAASnB,KAAKmB,OAAO;YACrBC,YAAY,AAACpB,KAAKoB,UAAU,IAAe;YAC3CZ;YACAa,SAASZ,UAAUT,KAAKqB,OAAO;YAC/BX,WAAWV,KAAKU,SAAS;QAC3B;KACD;AACH;AAEA,iFAAiF;AACjF,gFAAgF;AAChF,uDAAuD;AACvD,EAAE;AACF,+EAA+E;AAC/E,4BAA4B;AAC5B,OAAO,SAASD,UAAUa,KAAc;IACtC,IAAI,OAAOA,UAAU,YAAYA,OAAO;QACtC,OAAOA;IACT;IACA,IAAI,OAAOA,UAAU,UAAU;QAC7B,OAAOA;IACT;IACA,IAAIA,SAAS,OAAOA,UAAU,YAAY,QAAQA,OAAO;QACvD,OAAO,AAACA,MAAkCC,EAAE;IAC9C;IACA,OAAOZ;AACT"}
1
+ {"version":3,"sources":["../../src/utilities/resolveReservationItems.ts"],"sourcesContent":["import { ValidationError } from 'payload'\n\nimport { doRangesOverlap } from './slotUtils.js'\n\nexport type ResolvedItem = {\n endTime: string\n /** True when this item was synthesised from the top-level resource/startTime. */\n fromParent?: boolean\n guestCount: number\n resource: number | string\n service?: number | string\n startTime: string\n}\n\n/**\n * Normalize reservation data into a list of resource-level items.\n *\n * - If items[] is populated -> return items (filling defaults from parent), then\n * append a synthesised item for the top-level resource/startTime/endTime UNLESS\n * an items[] entry can already be shown to cover that same window (see B1 in\n * the synthesis step below) — this is what makes the top-level `resource`\n * conflict-checked even when items[] never names it.\n * Items missing startTime or resource throw a ValidationError.\n * Duplicate (resource, startTime) pairs throw a ValidationError.\n * An inverted top-level (parent) window throws a ValidationError too — UNLESS\n * `options.lenient` is set, in which case parent synthesis is silently\n * skipped instead (see the lenient-mode note below).\n * - If items[] is empty/absent -> return single item from top-level fields\n *\n * Every downstream function (conflict check, endTime calc, availability)\n * works with ResolvedItem[], never with raw reservation data.\n */\nexport function resolveReservationItems(\n data: Record<string, unknown>,\n options?: { lenient?: boolean },\n): ResolvedItem[] {\n const lenient = options?.lenient ?? false\n const items = data.items as Array<Record<string, unknown>> | undefined\n\n if (items && items.length > 0) {\n const resolved: ResolvedItem[] = []\n const seen = new Set<string>()\n\n for (let i = 0; i < items.length; i++) {\n const item = items[i]\n const resource = extractId(item.resource) ?? extractId(data.resource)\n const startTime = (item.startTime as string) ?? (data.startTime as string)\n\n if (resource === undefined || resource === '') {\n throw new ValidationError({\n errors: [\n {\n message: `Item ${i} is missing a resource`,\n path: `items.${i}.resource`,\n },\n ],\n })\n }\n\n if (!startTime) {\n throw new ValidationError({\n errors: [\n {\n message: `Item ${i} is missing a startTime`,\n path: `items.${i}.startTime`,\n },\n ],\n })\n }\n\n const key = `${resource}::${startTime}`\n if (seen.has(key)) {\n throw new ValidationError({\n errors: [\n {\n message: `Duplicate booking: item ${i} has the same resource and startTime as a previous item`,\n path: `items.${i}.startTime`,\n },\n ],\n })\n }\n seen.add(key)\n\n resolved.push({\n endTime: (item.endTime as string) ?? (data.endTime as string),\n guestCount: (item.guestCount as number) ?? (data.guestCount as number) ?? 1,\n resource,\n service: extractId(item.service) ?? extractId(data.service),\n startTime,\n })\n }\n\n // The stored row occupies its top-level `resource` for every OTHER booking's\n // conflict check (buildCoarseOverlapQuery matches top level OR items[]), so\n // it must occupy it for its own check too. Sharing a resource id with an\n // items[] entry is NOT sufficient to skip synthesis — an items[] entry for\n // the same resource at an unrelated time is a genuinely separate occupancy,\n // and treating \"same resource anywhere\" as \"already covered\" reopens the\n // exact double-booking class this function exists to close (a caller can\n // list resource A at one time in items[] while the top-level fields book A\n // at a completely different, uncovered time). Synthesis is skipped only\n // when an items[] entry for the SAME resource can be shown to cover the\n // SAME window:\n // (a) an exact startTime match — needs no endTime, so it still works when\n // this function runs before endTime is computed (validateActive and\n // calculateEndTime both call it before calculateEndTime has run); or\n // (b) both endTimes are known and the windows overlap — covers\n // calculateEndTime's multi-resource branch, which can overwrite the\n // top-level startTime/endTime to SPAN every item, so by the time this\n // function runs again (e.g. from validateConflicts) the parent's\n // window no longer starts at the same instant as any one item even\n // though that item's own window is fully contained in it.\n // When neither can be shown, synthesize: a redundant-but-harmless extra\n // check is far cheaper than a silently missed one.\n const parentResource = extractId(data.resource)\n const parentStart = data.startTime as string\n const parentEnd = data.endTime as string | undefined\n\n // An inverted parent window can never overlap anything, so the coverage test\n // below would always say \"not covered\" and synthesise a phantom item that\n // conflicts with nothing. Reject it at the source instead — it is malformed\n // input, not a case to paper over.\n //\n // Lenient mode (reservationOccupancies only — see AvailabilityService.ts)\n // resolves ALREADY-STORED documents, which can carry an inverted window\n // from a context.skipReservationHooks write or data predating this check.\n // A read must never crash over one malformed row, and — critically — it\n // must not lose the real items[] occupancies already resolved above by\n // throwing out of this function entirely. So lenient mode just skips\n // parent synthesis here instead of throwing: precisely the pre-check\n // behavior minus the (harmless but pointless) phantom item. The write\n // path never passes `lenient`, so it keeps the hard rejection.\n if (parentStart && parentEnd && new Date(parentEnd) <= new Date(parentStart)) {\n if (lenient) {\n return resolved\n }\n throw new ValidationError({\n errors: [{ message: 'endTime must be after startTime', path: 'endTime' }],\n })\n }\n\n if (parentResource !== undefined && parentResource !== '' && parentStart) {\n const parentAlreadyItemized = resolved.some((item) => {\n // String-compare ids: a raw id (string for Mongo, number for Postgres)\n // and a populated relationship's extracted `.id` should match even if\n // one side came through as a different primitive type than the other.\n if (String(item.resource) !== String(parentResource)) {\n return false\n }\n if (item.startTime === parentStart) {\n return true\n }\n return Boolean(\n parentEnd &&\n item.endTime &&\n doRangesOverlap(\n new Date(parentStart),\n new Date(parentEnd),\n new Date(item.startTime),\n new Date(item.endTime),\n ),\n )\n })\n\n if (!parentAlreadyItemized) {\n resolved.push({\n endTime: parentEnd as string,\n fromParent: true,\n guestCount: (data.guestCount as number) ?? 1,\n resource: parentResource,\n service: extractId(data.service),\n startTime: parentStart,\n })\n }\n }\n\n return resolved\n }\n\n // Single-resource fallback (current behavior)\n if (!data.resource || !data.startTime) {\n return []\n }\n\n const resource = extractId(data.resource)\n if (resource === undefined || resource === '') {\n return []\n }\n\n return [\n {\n endTime: data.endTime as string,\n guestCount: (data.guestCount as number) ?? 1,\n resource,\n service: extractId(data.service),\n startTime: data.startTime as string,\n },\n ]\n}\n\n// Exported for unit testing. Returns the underlying id value from a relationship\n// field which Payload represents as either the raw id (string for Mongo, number\n// for Postgres) or a populated document `{ id, ... }`.\n//\n// Note: `0` is a valid numeric Postgres id but rare; we still return it rather\n// than treat it as missing.\nexport function extractId(value: unknown): number | string | undefined {\n if (typeof value === 'string' && value) {\n return value\n }\n if (typeof value === 'number') {\n return value\n }\n if (value && typeof value === 'object' && 'id' in value) {\n return (value as { id: number | string }).id\n }\n return undefined\n}\n"],"names":["ValidationError","doRangesOverlap","resolveReservationItems","data","options","lenient","items","length","resolved","seen","Set","i","item","resource","extractId","startTime","undefined","errors","message","path","key","has","add","push","endTime","guestCount","service","parentResource","parentStart","parentEnd","Date","parentAlreadyItemized","some","String","Boolean","fromParent","value","id"],"mappings":"AAAA,SAASA,eAAe,QAAQ,UAAS;AAEzC,SAASC,eAAe,QAAQ,iBAAgB;AAYhD;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASC,wBACdC,IAA6B,EAC7BC,OAA+B;IAE/B,MAAMC,UAAUD,SAASC,WAAW;IACpC,MAAMC,QAAQH,KAAKG,KAAK;IAExB,IAAIA,SAASA,MAAMC,MAAM,GAAG,GAAG;QAC7B,MAAMC,WAA2B,EAAE;QACnC,MAAMC,OAAO,IAAIC;QAEjB,IAAK,IAAIC,IAAI,GAAGA,IAAIL,MAAMC,MAAM,EAAEI,IAAK;YACrC,MAAMC,OAAON,KAAK,CAACK,EAAE;YACrB,MAAME,WAAWC,UAAUF,KAAKC,QAAQ,KAAKC,UAAUX,KAAKU,QAAQ;YACpE,MAAME,YAAY,AAACH,KAAKG,SAAS,IAAgBZ,KAAKY,SAAS;YAE/D,IAAIF,aAAaG,aAAaH,aAAa,IAAI;gBAC7C,MAAM,IAAIb,gBAAgB;oBACxBiB,QAAQ;wBACN;4BACEC,SAAS,CAAC,KAAK,EAAEP,EAAE,sBAAsB,CAAC;4BAC1CQ,MAAM,CAAC,MAAM,EAAER,EAAE,SAAS,CAAC;wBAC7B;qBACD;gBACH;YACF;YAEA,IAAI,CAACI,WAAW;gBACd,MAAM,IAAIf,gBAAgB;oBACxBiB,QAAQ;wBACN;4BACEC,SAAS,CAAC,KAAK,EAAEP,EAAE,uBAAuB,CAAC;4BAC3CQ,MAAM,CAAC,MAAM,EAAER,EAAE,UAAU,CAAC;wBAC9B;qBACD;gBACH;YACF;YAEA,MAAMS,MAAM,GAAGP,SAAS,EAAE,EAAEE,WAAW;YACvC,IAAIN,KAAKY,GAAG,CAACD,MAAM;gBACjB,MAAM,IAAIpB,gBAAgB;oBACxBiB,QAAQ;wBACN;4BACEC,SAAS,CAAC,wBAAwB,EAAEP,EAAE,uDAAuD,CAAC;4BAC9FQ,MAAM,CAAC,MAAM,EAAER,EAAE,UAAU,CAAC;wBAC9B;qBACD;gBACH;YACF;YACAF,KAAKa,GAAG,CAACF;YAETZ,SAASe,IAAI,CAAC;gBACZC,SAAS,AAACZ,KAAKY,OAAO,IAAgBrB,KAAKqB,OAAO;gBAClDC,YAAY,AAACb,KAAKa,UAAU,IAAgBtB,KAAKsB,UAAU,IAAe;gBAC1EZ;gBACAa,SAASZ,UAAUF,KAAKc,OAAO,KAAKZ,UAAUX,KAAKuB,OAAO;gBAC1DX;YACF;QACF;QAEA,6EAA6E;QAC7E,4EAA4E;QAC5E,yEAAyE;QACzE,2EAA2E;QAC3E,4EAA4E;QAC5E,yEAAyE;QACzE,yEAAyE;QACzE,2EAA2E;QAC3E,wEAAwE;QACxE,wEAAwE;QACxE,eAAe;QACf,2EAA2E;QAC3E,yEAAyE;QACzE,0EAA0E;QAC1E,gEAAgE;QAChE,yEAAyE;QACzE,2EAA2E;QAC3E,sEAAsE;QACtE,wEAAwE;QACxE,+DAA+D;QAC/D,wEAAwE;QACxE,mDAAmD;QACnD,MAAMY,iBAAiBb,UAAUX,KAAKU,QAAQ;QAC9C,MAAMe,cAAczB,KAAKY,SAAS;QAClC,MAAMc,YAAY1B,KAAKqB,OAAO;QAE9B,6EAA6E;QAC7E,0EAA0E;QAC1E,4EAA4E;QAC5E,mCAAmC;QACnC,EAAE;QACF,0EAA0E;QAC1E,wEAAwE;QACxE,0EAA0E;QAC1E,wEAAwE;QACxE,uEAAuE;QACvE,qEAAqE;QACrE,qEAAqE;QACrE,sEAAsE;QACtE,+DAA+D;QAC/D,IAAII,eAAeC,aAAa,IAAIC,KAAKD,cAAc,IAAIC,KAAKF,cAAc;YAC5E,IAAIvB,SAAS;gBACX,OAAOG;YACT;YACA,MAAM,IAAIR,gBAAgB;gBACxBiB,QAAQ;oBAAC;wBAAEC,SAAS;wBAAmCC,MAAM;oBAAU;iBAAE;YAC3E;QACF;QAEA,IAAIQ,mBAAmBX,aAAaW,mBAAmB,MAAMC,aAAa;YACxE,MAAMG,wBAAwBvB,SAASwB,IAAI,CAAC,CAACpB;gBAC3C,uEAAuE;gBACvE,sEAAsE;gBACtE,sEAAsE;gBACtE,IAAIqB,OAAOrB,KAAKC,QAAQ,MAAMoB,OAAON,iBAAiB;oBACpD,OAAO;gBACT;gBACA,IAAIf,KAAKG,SAAS,KAAKa,aAAa;oBAClC,OAAO;gBACT;gBACA,OAAOM,QACLL,aACEjB,KAAKY,OAAO,IACZvB,gBACE,IAAI6B,KAAKF,cACT,IAAIE,KAAKD,YACT,IAAIC,KAAKlB,KAAKG,SAAS,GACvB,IAAIe,KAAKlB,KAAKY,OAAO;YAG7B;YAEA,IAAI,CAACO,uBAAuB;gBAC1BvB,SAASe,IAAI,CAAC;oBACZC,SAASK;oBACTM,YAAY;oBACZV,YAAY,AAACtB,KAAKsB,UAAU,IAAe;oBAC3CZ,UAAUc;oBACVD,SAASZ,UAAUX,KAAKuB,OAAO;oBAC/BX,WAAWa;gBACb;YACF;QACF;QAEA,OAAOpB;IACT;IAEA,8CAA8C;IAC9C,IAAI,CAACL,KAAKU,QAAQ,IAAI,CAACV,KAAKY,SAAS,EAAE;QACrC,OAAO,EAAE;IACX;IAEA,MAAMF,WAAWC,UAAUX,KAAKU,QAAQ;IACxC,IAAIA,aAAaG,aAAaH,aAAa,IAAI;QAC7C,OAAO,EAAE;IACX;IAEA,OAAO;QACL;YACEW,SAASrB,KAAKqB,OAAO;YACrBC,YAAY,AAACtB,KAAKsB,UAAU,IAAe;YAC3CZ;YACAa,SAASZ,UAAUX,KAAKuB,OAAO;YAC/BX,WAAWZ,KAAKY,SAAS;QAC3B;KACD;AACH;AAEA,iFAAiF;AACjF,gFAAgF;AAChF,uDAAuD;AACvD,EAAE;AACF,+EAA+E;AAC/E,4BAA4B;AAC5B,OAAO,SAASD,UAAUsB,KAAc;IACtC,IAAI,OAAOA,UAAU,YAAYA,OAAO;QACtC,OAAOA;IACT;IACA,IAAI,OAAOA,UAAU,UAAU;QAC7B,OAAOA;IACT;IACA,IAAIA,SAAS,OAAOA,UAAU,YAAY,QAAQA,OAAO;QACvD,OAAO,AAACA,MAAkCC,EAAE;IAC9C;IACA,OAAOrB;AACT"}
@@ -0,0 +1,66 @@
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
+ */
16
+ /**
17
+ * Whether an error means "the database refused this transaction because
18
+ * another one touched the same rows; running it again may succeed."
19
+ *
20
+ * Detection is by the driver's own structured signals, never by message text.
21
+ * MongoDB tags exactly this class with the `TransientTransactionError` label,
22
+ * which is the retry contract its drivers document; Postgres uses SQLSTATE;
23
+ * SQLite/libsql use a `code` string in the `SQLITE_BUSY`/`SQLITE_LOCKED*`
24
+ * family.
25
+ */
26
+ export declare function isTransientWriteConflict(error: unknown): boolean;
27
+ /**
28
+ * Run `operation`, retrying only transient write conflicts.
29
+ *
30
+ * Every other error — a genuine booking conflict, a validation failure, an
31
+ * access denial — propagates on the first attempt. Retrying those would turn a
32
+ * clean rejection into a slow one.
33
+ *
34
+ * Backoff is exponential with full jitter, so a burst of losers does not
35
+ * synchronise into a second stampede on the same document.
36
+ *
37
+ * Pass `req` whenever the operation writes through the Payload Local API. Every
38
+ * attempt re-invokes a closure over the SAME `req`, and Payload's
39
+ * `initTransaction` JOINS an existing `req.transactionID` rather than opening a
40
+ * fresh transaction — so a transaction id left behind by a failed attempt would
41
+ * make the next attempt re-enter a transaction the database already discarded.
42
+ * Payload's own operations normally clean up after themselves (`killTransaction`
43
+ * deletes the id from their catch), with one gap they cannot close: when
44
+ * `beginTransaction` ITSELF rejects, `initTransaction` has already stored the
45
+ * rejected promise on the req and `killTransaction`'s guard skips promises, so
46
+ * the req stays poisoned — every later `initTransaction` short-circuits on
47
+ * `instanceof Promise` and re-throws the first error without touching the
48
+ * database. Clearing a leftover here closes it. See dev/retryTransaction.spec.ts.
49
+ *
50
+ * Do NOT attribute SQLite's "raising the retry budget changes nothing"
51
+ * measurement to this. That has a different, independent cause: `@payloadcms/
52
+ * drizzle` rethrows the driver's error as a bare `Error`, so
53
+ * `isTransientWriteConflict` returns FALSE and this loop throws on attempt 1 —
54
+ * attempts 2..N never happen, and poisoning never gets a chance to matter. See
55
+ * README's "Concurrent booking: database adapter support". The shape this
56
+ * clearing actually protects is any caller that swallows a
57
+ * begin-transaction failure and then keeps using the same req (see the expiry
58
+ * sweep in HoldService.takeHold).
59
+ */
60
+ export declare function retryOnWriteConflict<T>(operation: () => Promise<T>, { attempts, baseDelayMs, req, }?: {
61
+ attempts?: number;
62
+ baseDelayMs?: number;
63
+ req?: {
64
+ transactionID?: unknown;
65
+ };
66
+ }): Promise<T>;