payload-reserve 1.6.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +55 -3
  2. package/dist/collections/Reservations.js +19 -7
  3. package/dist/collections/Reservations.js.map +1 -1
  4. package/dist/collections/Resources.js +11 -8
  5. package/dist/collections/Resources.js.map +1 -1
  6. package/dist/collections/Schedules.js +12 -6
  7. package/dist/collections/Schedules.js.map +1 -1
  8. package/dist/collections/Services.js +19 -10
  9. package/dist/collections/Services.js.map +1 -1
  10. package/dist/components/AvailabilityOverview/index.js +76 -18
  11. package/dist/components/AvailabilityOverview/index.js.map +1 -1
  12. package/dist/components/CalendarView/CalendarView.module.css +9 -0
  13. package/dist/components/CalendarView/LaneTimelineView.d.ts +4 -1
  14. package/dist/components/CalendarView/LaneTimelineView.js +17 -12
  15. package/dist/components/CalendarView/LaneTimelineView.js.map +1 -1
  16. package/dist/components/CalendarView/index.js +166 -44
  17. package/dist/components/CalendarView/index.js.map +1 -1
  18. package/dist/components/CustomerField/index.js +8 -3
  19. package/dist/components/CustomerField/index.js.map +1 -1
  20. package/dist/components/DashboardWidget/DashboardWidgetServer.js +91 -18
  21. package/dist/components/DashboardWidget/DashboardWidgetServer.js.map +1 -1
  22. package/dist/defaults.js +44 -9
  23. package/dist/defaults.js.map +1 -1
  24. package/dist/endpoints/cancelBooking.js +1 -1
  25. package/dist/endpoints/cancelBooking.js.map +1 -1
  26. package/dist/endpoints/checkAvailability.js +56 -7
  27. package/dist/endpoints/checkAvailability.js.map +1 -1
  28. package/dist/endpoints/createBooking.js +19 -10
  29. package/dist/endpoints/createBooking.js.map +1 -1
  30. package/dist/endpoints/customerSearch.js +5 -2
  31. package/dist/endpoints/customerSearch.js.map +1 -1
  32. package/dist/endpoints/effectiveTimezone.d.ts +13 -0
  33. package/dist/endpoints/effectiveTimezone.js +41 -0
  34. package/dist/endpoints/effectiveTimezone.js.map +1 -0
  35. package/dist/endpoints/getSlots.js +56 -7
  36. package/dist/endpoints/getSlots.js.map +1 -1
  37. package/dist/endpoints/resourceAvailability.d.ts +4 -1
  38. package/dist/endpoints/resourceAvailability.js +102 -26
  39. package/dist/endpoints/resourceAvailability.js.map +1 -1
  40. package/dist/hooks/reservations/calculateEndTime.js +48 -20
  41. package/dist/hooks/reservations/calculateEndTime.js.map +1 -1
  42. package/dist/hooks/reservations/enforceCustomerOwnership.d.ts +11 -0
  43. package/dist/hooks/reservations/enforceCustomerOwnership.js +30 -0
  44. package/dist/hooks/reservations/enforceCustomerOwnership.js.map +1 -0
  45. package/dist/hooks/reservations/onStatusChange.js +10 -4
  46. package/dist/hooks/reservations/onStatusChange.js.map +1 -1
  47. package/dist/hooks/reservations/validateCancellation.js +3 -2
  48. package/dist/hooks/reservations/validateCancellation.js.map +1 -1
  49. package/dist/hooks/reservations/validateConflicts.js +23 -4
  50. package/dist/hooks/reservations/validateConflicts.js.map +1 -1
  51. package/dist/hooks/reservations/validateGuestBooking.js +3 -4
  52. package/dist/hooks/reservations/validateGuestBooking.js.map +1 -1
  53. package/dist/hooks/reservations/validateStatusTransition.js +2 -2
  54. package/dist/hooks/reservations/validateStatusTransition.js.map +1 -1
  55. package/dist/hooks/users/provisionStaffResource.js +5 -8
  56. package/dist/hooks/users/provisionStaffResource.js.map +1 -1
  57. package/dist/plugin.js +83 -14
  58. package/dist/plugin.js.map +1 -1
  59. package/dist/services/AvailabilityService.d.ts +54 -2
  60. package/dist/services/AvailabilityService.js +180 -46
  61. package/dist/services/AvailabilityService.js.map +1 -1
  62. package/dist/translations/ar.json +1 -0
  63. package/dist/translations/de.json +1 -0
  64. package/dist/translations/en.json +1 -0
  65. package/dist/translations/es.json +1 -0
  66. package/dist/translations/fa.json +1 -0
  67. package/dist/translations/fr.json +1 -0
  68. package/dist/translations/hi.json +1 -0
  69. package/dist/translations/id.json +1 -0
  70. package/dist/translations/pl.json +1 -0
  71. package/dist/translations/ru.json +1 -0
  72. package/dist/translations/tr.json +1 -0
  73. package/dist/translations/zh.json +1 -0
  74. package/dist/types.d.ts +46 -1
  75. package/dist/types.js +2 -0
  76. package/dist/types.js.map +1 -1
  77. package/dist/utilities/collectionOverrides.d.ts +14 -0
  78. package/dist/utilities/collectionOverrides.js +47 -0
  79. package/dist/utilities/collectionOverrides.js.map +1 -0
  80. package/dist/utilities/ownerAccess.d.ts +6 -0
  81. package/dist/utilities/ownerAccess.js +25 -12
  82. package/dist/utilities/ownerAccess.js.map +1 -1
  83. package/dist/utilities/reservationChanges.d.ts +17 -0
  84. package/dist/utilities/reservationChanges.js +88 -0
  85. package/dist/utilities/reservationChanges.js.map +1 -0
  86. package/dist/utilities/scheduleUtils.d.ts +14 -8
  87. package/dist/utilities/scheduleUtils.js +26 -19
  88. package/dist/utilities/scheduleUtils.js.map +1 -1
  89. package/dist/utilities/tenantTimezone.d.ts +41 -0
  90. package/dist/utilities/tenantTimezone.js +77 -0
  91. package/dist/utilities/tenantTimezone.js.map +1 -0
  92. package/dist/utilities/timezoneUtils.d.ts +44 -0
  93. package/dist/utilities/timezoneUtils.js +146 -0
  94. package/dist/utilities/timezoneUtils.js.map +1 -0
  95. package/package.json +1 -1
package/dist/plugin.js CHANGED
@@ -9,10 +9,41 @@ import { createCancelBookingEndpoint } from './endpoints/cancelBooking.js';
9
9
  import { createCheckAvailabilityEndpoint } from './endpoints/checkAvailability.js';
10
10
  import { createBookingEndpoint } from './endpoints/createBooking.js';
11
11
  import { createCustomerSearchEndpoint } from './endpoints/customerSearch.js';
12
+ import { createEffectiveTimezoneEndpoint } from './endpoints/effectiveTimezone.js';
12
13
  import { createGetSlotsEndpoint } from './endpoints/getSlots.js';
13
14
  import { createResourceAvailabilityEndpoint } from './endpoints/resourceAvailability.js';
14
15
  import { provisionStaffResource } from './hooks/users/provisionStaffResource.js';
15
16
  import { translations } from './translations/index.js';
17
+ import { applyCollectionOverride } from './utilities/collectionOverrides.js';
18
+ /**
19
+ * All named field paths reachable from a field list, descending through
20
+ * presentational containers (tabs, rows, collapsibles, unnamed groups) that
21
+ * don't create their own data nesting — so dedup catches a field declared
22
+ * inside one of them. Named groups/arrays DO nest data, so we don't recurse
23
+ * into them (a `name` inside a named group is a different path).
24
+ */ function collectFieldNames(fields) {
25
+ const names = new Set();
26
+ const walk = (list)=>{
27
+ for (const field of list){
28
+ if ('name' in field && field.name) {
29
+ names.add(field.name);
30
+ } else if ('tabs' in field && Array.isArray(field.tabs)) {
31
+ for (const tab of field.tabs){
32
+ if ('name' in tab && tab.name) {
33
+ names.add(tab.name);
34
+ } else if (Array.isArray(tab.fields)) {
35
+ walk(tab.fields);
36
+ }
37
+ }
38
+ } else if ('fields' in field && Array.isArray(field.fields)) {
39
+ // row / collapsible / unnamed group
40
+ walk(field.fields);
41
+ }
42
+ }
43
+ };
44
+ walk(fields);
45
+ return names;
46
+ }
16
47
  export const payloadReserve = (pluginOptions = {})=>(config)=>{
17
48
  const resolved = resolveConfig(pluginOptions);
18
49
  // Detect localization from the Payload config
@@ -22,24 +53,29 @@ export const payloadReserve = (pluginOptions = {})=>(config)=>{
22
53
  if (!config.collections) {
23
54
  config.collections = [];
24
55
  }
25
- if (resolved.disabled) {
26
- return config;
27
- }
28
56
  if (resolved.userCollection) {
29
57
  // Extend the existing auth collection with customer fields
30
58
  const targetCollection = config.collections.find((col)=>col.slug === resolved.userCollection);
31
- if (targetCollection) {
32
- // Collect existing field names for deduplication check
33
- const existingFieldNames = new Set(targetCollection.fields.map((field)=>'name' in field ? field.name : undefined).filter(Boolean));
59
+ if (!targetCollection) {
60
+ // Fail loudly rather than silently skipping field injection and pointing
61
+ // the customers slug at a collection that doesn't exist (review C2).
62
+ throw new Error(`payload-reserve: userCollection "${resolved.userCollection}" was not found in config.collections. ` + `Define it before payloadReserve() runs, or correct the slug.`);
63
+ }
64
+ {
65
+ // Collect existing field names — descend into presentational containers
66
+ // (tabs/rows/collapsibles/groups) so a field nested there isn't
67
+ // re-injected at the top level (review C4).
68
+ const existingFieldNames = collectFieldNames(targetCollection.fields);
34
69
  // Fields to inject if not already present. `name` is added so that
35
70
  // admin.useAsTitle: 'name' works out of the box on the extended user
36
71
  // collection (matches the v1.0.0 behaviour documented in README/SKILL).
72
+ // It is NOT required — an existing users collection may have rows
73
+ // without a name, and forcing required would fail their next update (C4).
37
74
  const fieldsToAdd = [
38
75
  {
39
76
  name: 'name',
40
77
  type: 'text',
41
- maxLength: 200,
42
- required: true
78
+ maxLength: 200
43
79
  },
44
80
  {
45
81
  name: 'phone',
@@ -67,17 +103,49 @@ export const payloadReserve = (pluginOptions = {})=>(config)=>{
67
103
  // Point the customers slug at the user collection so other parts of the
68
104
  // plugin (endpoints, hooks) reference the correct collection
69
105
  resolved.slugs.customers = resolved.userCollection;
70
- // Push only the 4 domain collections (no standalone Customers)
71
- config.collections.push(createServicesCollection(resolved), createResourcesCollection(resolved), createSchedulesCollection(resolved), createReservationsCollection(resolved));
72
- } else {
73
- // Default behaviour: push all 5 collections including standalone Customers
74
- config.collections.push(createServicesCollection(resolved), createResourcesCollection(resolved), createSchedulesCollection(resolved), createReservationsCollection(resolved), createCustomersCollection(resolved));
106
+ }
107
+ // The slugs this plugin is about to register (Customers only in standalone mode)
108
+ const slugsToRegister = [
109
+ resolved.slugs.services,
110
+ resolved.slugs.resources,
111
+ resolved.slugs.schedules,
112
+ resolved.slugs.reservations,
113
+ ...resolved.userCollection ? [] : [
114
+ resolved.slugs.customers
115
+ ]
116
+ ];
117
+ // C11: fail with a clear, actionable error on slug collision instead of
118
+ // Payload's generic DuplicateCollection throw.
119
+ for (const slug of slugsToRegister){
120
+ if (config.collections.some((col)=>col.slug === slug)) {
121
+ throw new Error(`payload-reserve: a collection with slug "${slug}" already exists. ` + `Override the plugin's slug via the \`slugs\` option.`);
122
+ }
123
+ }
124
+ // Image upload fields are added only when the media collection actually
125
+ // exists, so installs without one don't hit an opaque init error (C8).
126
+ resolved.hasMediaCollection = config.collections.some((col)=>col.slug === resolved.slugs.media);
127
+ const ov = resolved.collectionOverrides;
128
+ config.collections.push(applyCollectionOverride(createServicesCollection(resolved), ov.services), applyCollectionOverride(createResourcesCollection(resolved), ov.resources), applyCollectionOverride(createSchedulesCollection(resolved), ov.schedules), applyCollectionOverride(createReservationsCollection(resolved), ov.reservations), // The customers override applies only in standalone mode; in userCollection
129
+ // mode the host owns that collection and can edit it directly.
130
+ ...resolved.userCollection ? [] : [
131
+ applyCollectionOverride(createCustomersCollection(resolved), ov.customers)
132
+ ]);
133
+ // C3: collections are registered (above) even when disabled so the DB schema
134
+ // stays stable; behavior (hooks, endpoints, admin, provisioning) is inert.
135
+ if (resolved.disabled) {
136
+ for (const slug of slugsToRegister){
137
+ const col = config.collections.find((c)=>c.slug === slug);
138
+ if (col) {
139
+ delete col.hooks;
140
+ }
141
+ }
142
+ return config;
75
143
  }
76
144
  // Register custom endpoints
77
145
  if (!config.endpoints) {
78
146
  config.endpoints = [];
79
147
  }
80
- config.endpoints.push(createCancelBookingEndpoint(resolved), createCheckAvailabilityEndpoint(resolved), createBookingEndpoint(resolved), createCustomerSearchEndpoint(resolved), createGetSlotsEndpoint(resolved), createResourceAvailabilityEndpoint(resolved));
148
+ config.endpoints.push(createCancelBookingEndpoint(resolved), createCheckAvailabilityEndpoint(resolved), createBookingEndpoint(resolved), createCustomerSearchEndpoint(resolved), createEffectiveTimezoneEndpoint(resolved), createGetSlotsEndpoint(resolved), createResourceAvailabilityEndpoint(resolved));
81
149
  // Wire staff auto-provisioning onto the staff user collection
82
150
  if (resolved.staffProvisioning) {
83
151
  const staffUserSlug = resolved.staffProvisioning.userCollection;
@@ -109,6 +177,7 @@ export const payloadReserve = (pluginOptions = {})=>(config)=>{
109
177
  };
110
178
  config.admin.custom.reservationStatusMachine = resolved.statusMachine;
111
179
  config.admin.custom.reservationTenant = resolved.multiTenant;
180
+ config.admin.custom.reservationTimezone = resolved.timezone;
112
181
  // Add dashboard widget
113
182
  if (!config.admin.dashboard) {
114
183
  config.admin.dashboard = {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { CollectionSlug, Config, Field } from 'payload'\n\nimport { deepMergeSimple } from 'payload/shared'\n\nimport type { ReservationPluginConfig } from './types.js'\n\nimport { createCustomersCollection } from './collections/Customers.js'\nimport { createReservationsCollection } from './collections/Reservations.js'\nimport { createResourcesCollection } from './collections/Resources.js'\nimport { createSchedulesCollection } from './collections/Schedules.js'\nimport { createServicesCollection } from './collections/Services.js'\nimport { resolveConfig } from './defaults.js'\nimport { createCancelBookingEndpoint } from './endpoints/cancelBooking.js'\nimport { createCheckAvailabilityEndpoint } from './endpoints/checkAvailability.js'\nimport { createBookingEndpoint } from './endpoints/createBooking.js'\nimport { createCustomerSearchEndpoint } from './endpoints/customerSearch.js'\nimport { createGetSlotsEndpoint } from './endpoints/getSlots.js'\nimport { createResourceAvailabilityEndpoint } from './endpoints/resourceAvailability.js'\nimport { provisionStaffResource } from './hooks/users/provisionStaffResource.js'\nimport { type PluginT, translations } from './translations/index.js'\n\nexport const payloadReserve =\n (pluginOptions: ReservationPluginConfig = {}) =>\n (config: Config): Config => {\n const resolved = resolveConfig(pluginOptions)\n\n // Detect localization from the Payload config\n if (config.localization) {\n resolved.localized = true\n }\n\n if (!config.collections) {\n config.collections = []\n }\n\n if (resolved.disabled) {\n return config\n }\n\n if (resolved.userCollection) {\n // Extend the existing auth collection with customer fields\n const targetCollection = config.collections.find(\n (col) => col.slug === resolved.userCollection,\n )\n\n if (targetCollection) {\n // Collect existing field names for deduplication check\n const existingFieldNames = new Set(\n targetCollection.fields\n .map((field) => ('name' in field ? field.name : undefined))\n .filter(Boolean),\n )\n\n // Fields to inject if not already present. `name` is added so that\n // admin.useAsTitle: 'name' works out of the box on the extended user\n // collection (matches the v1.0.0 behaviour documented in README/SKILL).\n const fieldsToAdd: Field[] = [\n {\n name: 'name',\n type: 'text',\n maxLength: 200,\n required: true,\n },\n {\n name: 'phone',\n type: 'text',\n maxLength: 50,\n },\n {\n name: 'notes',\n type: 'textarea',\n },\n {\n name: 'bookings',\n type: 'join',\n collection: resolved.slugs.reservations as unknown as CollectionSlug,\n on: 'customer',\n },\n ]\n\n for (const field of fieldsToAdd) {\n const fieldName = 'name' in field ? field.name : undefined\n if (fieldName && !existingFieldNames.has(fieldName)) {\n targetCollection.fields.push(field)\n }\n }\n }\n\n // Point the customers slug at the user collection so other parts of the\n // plugin (endpoints, hooks) reference the correct collection\n resolved.slugs.customers = resolved.userCollection\n\n // Push only the 4 domain collections (no standalone Customers)\n config.collections.push(\n createServicesCollection(resolved),\n createResourcesCollection(resolved),\n createSchedulesCollection(resolved),\n createReservationsCollection(resolved),\n )\n } else {\n // Default behaviour: push all 5 collections including standalone Customers\n config.collections.push(\n createServicesCollection(resolved),\n createResourcesCollection(resolved),\n createSchedulesCollection(resolved),\n createReservationsCollection(resolved),\n createCustomersCollection(resolved),\n )\n }\n\n // Register custom endpoints\n if (!config.endpoints) {config.endpoints = []}\n config.endpoints.push(\n createCancelBookingEndpoint(resolved),\n createCheckAvailabilityEndpoint(resolved),\n createBookingEndpoint(resolved),\n createCustomerSearchEndpoint(resolved),\n createGetSlotsEndpoint(resolved),\n createResourceAvailabilityEndpoint(resolved),\n )\n\n // Wire staff auto-provisioning onto the staff user collection\n if (resolved.staffProvisioning) {\n const staffUserSlug = resolved.staffProvisioning.userCollection\n const staffCollection = config.collections.find((col) => col.slug === staffUserSlug)\n if (!staffCollection) {\n throw new Error(\n `staffProvisioning.userCollection \"${staffUserSlug}\" was not found in config.collections`,\n )\n }\n staffCollection.hooks = {\n ...staffCollection.hooks,\n afterChange: [\n ...(staffCollection.hooks?.afterChange ?? []),\n provisionStaffResource(resolved),\n ],\n }\n }\n\n // Set up admin configuration\n if (!config.admin) {config.admin = {}}\n if (!config.admin.components) {config.admin.components = {}}\n\n // Store slugs and status machine in admin custom for component access\n if (!config.admin.custom) {config.admin.custom = {}}\n config.admin.custom.reservationSlugs = {\n ...resolved.slugs,\n }\n config.admin.custom.reservationStatusMachine = resolved.statusMachine\n config.admin.custom.reservationTenant = resolved.multiTenant\n\n // Add dashboard widget\n if (!config.admin.dashboard) {\n config.admin.dashboard = { widgets: [] }\n }\n if (!config.admin.dashboard.widgets) {\n config.admin.dashboard.widgets = []\n }\n config.admin.dashboard.widgets.push({\n slug: 'reservation-todays-reservations',\n Component: 'payload-reserve/rsc#DashboardWidgetServer',\n label: ({ t }) => (t as PluginT)('reservation:dashboardTitle'),\n maxWidth: 'large',\n minWidth: 'medium',\n })\n\n // Add availability overview as custom admin view\n if (!config.admin.components.views) {\n config.admin.components.views = {}\n }\n ;(config.admin.components.views as Record<string, unknown>)['reservation-availability'] = {\n Component: 'payload-reserve/client#AvailabilityOverview',\n path: '/reservation-availability',\n }\n\n // Merge plugin translations (user translations take precedence)\n config.i18n = {\n ...(config.i18n ?? {}),\n translations: deepMergeSimple(\n translations,\n (config.i18n?.translations as Record<string, Record<string, unknown>>) ?? {},\n ),\n }\n\n return config\n }\n"],"names":["deepMergeSimple","createCustomersCollection","createReservationsCollection","createResourcesCollection","createSchedulesCollection","createServicesCollection","resolveConfig","createCancelBookingEndpoint","createCheckAvailabilityEndpoint","createBookingEndpoint","createCustomerSearchEndpoint","createGetSlotsEndpoint","createResourceAvailabilityEndpoint","provisionStaffResource","translations","payloadReserve","pluginOptions","config","resolved","localization","localized","collections","disabled","userCollection","targetCollection","find","col","slug","existingFieldNames","Set","fields","map","field","name","undefined","filter","Boolean","fieldsToAdd","type","maxLength","required","collection","slugs","reservations","on","fieldName","has","push","customers","endpoints","staffProvisioning","staffUserSlug","staffCollection","Error","hooks","afterChange","admin","components","custom","reservationSlugs","reservationStatusMachine","statusMachine","reservationTenant","multiTenant","dashboard","widgets","Component","label","t","maxWidth","minWidth","views","path","i18n"],"mappings":"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"}
1
+ {"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { CollectionSlug, Config, Field } from 'payload'\n\nimport { deepMergeSimple } from 'payload/shared'\n\nimport type { ReservationPluginConfig } from './types.js'\n\nimport { createCustomersCollection } from './collections/Customers.js'\nimport { createReservationsCollection } from './collections/Reservations.js'\nimport { createResourcesCollection } from './collections/Resources.js'\nimport { createSchedulesCollection } from './collections/Schedules.js'\nimport { createServicesCollection } from './collections/Services.js'\nimport { resolveConfig } from './defaults.js'\nimport { createCancelBookingEndpoint } from './endpoints/cancelBooking.js'\nimport { createCheckAvailabilityEndpoint } from './endpoints/checkAvailability.js'\nimport { createBookingEndpoint } from './endpoints/createBooking.js'\nimport { createCustomerSearchEndpoint } from './endpoints/customerSearch.js'\nimport { createEffectiveTimezoneEndpoint } from './endpoints/effectiveTimezone.js'\nimport { createGetSlotsEndpoint } from './endpoints/getSlots.js'\nimport { createResourceAvailabilityEndpoint } from './endpoints/resourceAvailability.js'\nimport { provisionStaffResource } from './hooks/users/provisionStaffResource.js'\nimport { type PluginT, translations } from './translations/index.js'\nimport { applyCollectionOverride } from './utilities/collectionOverrides.js'\n\n/**\n * All named field paths reachable from a field list, descending through\n * presentational containers (tabs, rows, collapsibles, unnamed groups) that\n * don't create their own data nesting — so dedup catches a field declared\n * inside one of them. Named groups/arrays DO nest data, so we don't recurse\n * into them (a `name` inside a named group is a different path).\n */\nfunction collectFieldNames(fields: Field[]): Set<string> {\n const names = new Set<string>()\n const walk = (list: Field[]): void => {\n for (const field of list) {\n if ('name' in field && field.name) {\n names.add(field.name)\n } else if ('tabs' in field && Array.isArray(field.tabs)) {\n for (const tab of field.tabs) {\n if ('name' in tab && tab.name) {\n names.add(tab.name)\n } else if (Array.isArray(tab.fields)) {\n walk(tab.fields)\n }\n }\n } else if ('fields' in field && Array.isArray(field.fields)) {\n // row / collapsible / unnamed group\n walk(field.fields)\n }\n }\n }\n walk(fields)\n return names\n}\n\nexport const payloadReserve =\n (pluginOptions: ReservationPluginConfig = {}) =>\n (config: Config): Config => {\n const resolved = resolveConfig(pluginOptions)\n\n // Detect localization from the Payload config\n if (config.localization) {\n resolved.localized = true\n }\n\n if (!config.collections) {\n config.collections = []\n }\n\n if (resolved.userCollection) {\n // Extend the existing auth collection with customer fields\n const targetCollection = config.collections.find(\n (col) => col.slug === resolved.userCollection,\n )\n\n if (!targetCollection) {\n // Fail loudly rather than silently skipping field injection and pointing\n // the customers slug at a collection that doesn't exist (review C2).\n throw new Error(\n `payload-reserve: userCollection \"${resolved.userCollection}\" was not found in config.collections. ` +\n `Define it before payloadReserve() runs, or correct the slug.`,\n )\n }\n\n {\n // Collect existing field names — descend into presentational containers\n // (tabs/rows/collapsibles/groups) so a field nested there isn't\n // re-injected at the top level (review C4).\n const existingFieldNames = collectFieldNames(targetCollection.fields)\n\n // Fields to inject if not already present. `name` is added so that\n // admin.useAsTitle: 'name' works out of the box on the extended user\n // collection (matches the v1.0.0 behaviour documented in README/SKILL).\n // It is NOT required — an existing users collection may have rows\n // without a name, and forcing required would fail their next update (C4).\n const fieldsToAdd: Field[] = [\n {\n name: 'name',\n type: 'text',\n maxLength: 200,\n },\n {\n name: 'phone',\n type: 'text',\n maxLength: 50,\n },\n {\n name: 'notes',\n type: 'textarea',\n },\n {\n name: 'bookings',\n type: 'join',\n collection: resolved.slugs.reservations as unknown as CollectionSlug,\n on: 'customer',\n },\n ]\n\n for (const field of fieldsToAdd) {\n const fieldName = 'name' in field ? field.name : undefined\n if (fieldName && !existingFieldNames.has(fieldName)) {\n targetCollection.fields.push(field)\n }\n }\n }\n\n // Point the customers slug at the user collection so other parts of the\n // plugin (endpoints, hooks) reference the correct collection\n resolved.slugs.customers = resolved.userCollection\n }\n\n // The slugs this plugin is about to register (Customers only in standalone mode)\n const slugsToRegister = [\n resolved.slugs.services,\n resolved.slugs.resources,\n resolved.slugs.schedules,\n resolved.slugs.reservations,\n ...(resolved.userCollection ? [] : [resolved.slugs.customers]),\n ]\n\n // C11: fail with a clear, actionable error on slug collision instead of\n // Payload's generic DuplicateCollection throw.\n for (const slug of slugsToRegister) {\n if (config.collections.some((col) => col.slug === slug)) {\n throw new Error(\n `payload-reserve: a collection with slug \"${slug}\" already exists. ` +\n `Override the plugin's slug via the \\`slugs\\` option.`,\n )\n }\n }\n\n // Image upload fields are added only when the media collection actually\n // exists, so installs without one don't hit an opaque init error (C8).\n resolved.hasMediaCollection = config.collections.some(\n (col) => col.slug === resolved.slugs.media,\n )\n\n const ov = resolved.collectionOverrides\n config.collections.push(\n applyCollectionOverride(createServicesCollection(resolved), ov.services),\n applyCollectionOverride(createResourcesCollection(resolved), ov.resources),\n applyCollectionOverride(createSchedulesCollection(resolved), ov.schedules),\n applyCollectionOverride(createReservationsCollection(resolved), ov.reservations),\n // The customers override applies only in standalone mode; in userCollection\n // mode the host owns that collection and can edit it directly.\n ...(resolved.userCollection\n ? []\n : [applyCollectionOverride(createCustomersCollection(resolved), ov.customers)]),\n )\n\n // C3: collections are registered (above) even when disabled so the DB schema\n // stays stable; behavior (hooks, endpoints, admin, provisioning) is inert.\n if (resolved.disabled) {\n for (const slug of slugsToRegister) {\n const col = config.collections.find((c) => c.slug === slug)\n if (col) {\n delete col.hooks\n }\n }\n return config\n }\n\n // Register custom endpoints\n if (!config.endpoints) {config.endpoints = []}\n config.endpoints.push(\n createCancelBookingEndpoint(resolved),\n createCheckAvailabilityEndpoint(resolved),\n createBookingEndpoint(resolved),\n createCustomerSearchEndpoint(resolved),\n createEffectiveTimezoneEndpoint(resolved),\n createGetSlotsEndpoint(resolved),\n createResourceAvailabilityEndpoint(resolved),\n )\n\n // Wire staff auto-provisioning onto the staff user collection\n if (resolved.staffProvisioning) {\n const staffUserSlug = resolved.staffProvisioning.userCollection\n const staffCollection = config.collections.find((col) => col.slug === staffUserSlug)\n if (!staffCollection) {\n throw new Error(\n `staffProvisioning.userCollection \"${staffUserSlug}\" was not found in config.collections`,\n )\n }\n staffCollection.hooks = {\n ...staffCollection.hooks,\n afterChange: [\n ...(staffCollection.hooks?.afterChange ?? []),\n provisionStaffResource(resolved),\n ],\n }\n }\n\n // Set up admin configuration\n if (!config.admin) {config.admin = {}}\n if (!config.admin.components) {config.admin.components = {}}\n\n // Store slugs and status machine in admin custom for component access\n if (!config.admin.custom) {config.admin.custom = {}}\n config.admin.custom.reservationSlugs = {\n ...resolved.slugs,\n }\n config.admin.custom.reservationStatusMachine = resolved.statusMachine\n config.admin.custom.reservationTenant = resolved.multiTenant\n config.admin.custom.reservationTimezone = resolved.timezone\n\n // Add dashboard widget\n if (!config.admin.dashboard) {\n config.admin.dashboard = { widgets: [] }\n }\n if (!config.admin.dashboard.widgets) {\n config.admin.dashboard.widgets = []\n }\n config.admin.dashboard.widgets.push({\n slug: 'reservation-todays-reservations',\n Component: 'payload-reserve/rsc#DashboardWidgetServer',\n label: ({ t }) => (t as PluginT)('reservation:dashboardTitle'),\n maxWidth: 'large',\n minWidth: 'medium',\n })\n\n // Add availability overview as custom admin view\n if (!config.admin.components.views) {\n config.admin.components.views = {}\n }\n ;(config.admin.components.views as Record<string, unknown>)['reservation-availability'] = {\n Component: 'payload-reserve/client#AvailabilityOverview',\n path: '/reservation-availability',\n }\n\n // Merge plugin translations (user translations take precedence)\n config.i18n = {\n ...(config.i18n ?? {}),\n translations: deepMergeSimple(\n translations,\n (config.i18n?.translations as Record<string, Record<string, unknown>>) ?? {},\n ),\n }\n\n return config\n }\n"],"names":["deepMergeSimple","createCustomersCollection","createReservationsCollection","createResourcesCollection","createSchedulesCollection","createServicesCollection","resolveConfig","createCancelBookingEndpoint","createCheckAvailabilityEndpoint","createBookingEndpoint","createCustomerSearchEndpoint","createEffectiveTimezoneEndpoint","createGetSlotsEndpoint","createResourceAvailabilityEndpoint","provisionStaffResource","translations","applyCollectionOverride","collectFieldNames","fields","names","Set","walk","list","field","name","add","Array","isArray","tabs","tab","payloadReserve","pluginOptions","config","resolved","localization","localized","collections","userCollection","targetCollection","find","col","slug","Error","existingFieldNames","fieldsToAdd","type","maxLength","collection","slugs","reservations","on","fieldName","undefined","has","push","customers","slugsToRegister","services","resources","schedules","some","hasMediaCollection","media","ov","collectionOverrides","disabled","c","hooks","endpoints","staffProvisioning","staffUserSlug","staffCollection","afterChange","admin","components","custom","reservationSlugs","reservationStatusMachine","statusMachine","reservationTenant","multiTenant","reservationTimezone","timezone","dashboard","widgets","Component","label","t","maxWidth","minWidth","views","path","i18n"],"mappings":"AAEA,SAASA,eAAe,QAAQ,iBAAgB;AAIhD,SAASC,yBAAyB,QAAQ,6BAA4B;AACtE,SAASC,4BAA4B,QAAQ,gCAA+B;AAC5E,SAASC,yBAAyB,QAAQ,6BAA4B;AACtE,SAASC,yBAAyB,QAAQ,6BAA4B;AACtE,SAASC,wBAAwB,QAAQ,4BAA2B;AACpE,SAASC,aAAa,QAAQ,gBAAe;AAC7C,SAASC,2BAA2B,QAAQ,+BAA8B;AAC1E,SAASC,+BAA+B,QAAQ,mCAAkC;AAClF,SAASC,qBAAqB,QAAQ,+BAA8B;AACpE,SAASC,4BAA4B,QAAQ,gCAA+B;AAC5E,SAASC,+BAA+B,QAAQ,mCAAkC;AAClF,SAASC,sBAAsB,QAAQ,0BAAyB;AAChE,SAASC,kCAAkC,QAAQ,sCAAqC;AACxF,SAASC,sBAAsB,QAAQ,0CAAyC;AAChF,SAAuBC,YAAY,QAAQ,0BAAyB;AACpE,SAASC,uBAAuB,QAAQ,qCAAoC;AAE5E;;;;;;CAMC,GACD,SAASC,kBAAkBC,MAAe;IACxC,MAAMC,QAAQ,IAAIC;IAClB,MAAMC,OAAO,CAACC;QACZ,KAAK,MAAMC,SAASD,KAAM;YACxB,IAAI,UAAUC,SAASA,MAAMC,IAAI,EAAE;gBACjCL,MAAMM,GAAG,CAACF,MAAMC,IAAI;YACtB,OAAO,IAAI,UAAUD,SAASG,MAAMC,OAAO,CAACJ,MAAMK,IAAI,GAAG;gBACvD,KAAK,MAAMC,OAAON,MAAMK,IAAI,CAAE;oBAC5B,IAAI,UAAUC,OAAOA,IAAIL,IAAI,EAAE;wBAC7BL,MAAMM,GAAG,CAACI,IAAIL,IAAI;oBACpB,OAAO,IAAIE,MAAMC,OAAO,CAACE,IAAIX,MAAM,GAAG;wBACpCG,KAAKQ,IAAIX,MAAM;oBACjB;gBACF;YACF,OAAO,IAAI,YAAYK,SAASG,MAAMC,OAAO,CAACJ,MAAML,MAAM,GAAG;gBAC3D,oCAAoC;gBACpCG,KAAKE,MAAML,MAAM;YACnB;QACF;IACF;IACAG,KAAKH;IACL,OAAOC;AACT;AAEA,OAAO,MAAMW,iBACX,CAACC,gBAAyC,CAAC,CAAC,GAC5C,CAACC;QACC,MAAMC,WAAW3B,cAAcyB;QAE/B,8CAA8C;QAC9C,IAAIC,OAAOE,YAAY,EAAE;YACvBD,SAASE,SAAS,GAAG;QACvB;QAEA,IAAI,CAACH,OAAOI,WAAW,EAAE;YACvBJ,OAAOI,WAAW,GAAG,EAAE;QACzB;QAEA,IAAIH,SAASI,cAAc,EAAE;YAC3B,2DAA2D;YAC3D,MAAMC,mBAAmBN,OAAOI,WAAW,CAACG,IAAI,CAC9C,CAACC,MAAQA,IAAIC,IAAI,KAAKR,SAASI,cAAc;YAG/C,IAAI,CAACC,kBAAkB;gBACrB,yEAAyE;gBACzE,qEAAqE;gBACrE,MAAM,IAAII,MACR,CAAC,iCAAiC,EAAET,SAASI,cAAc,CAAC,uCAAuC,CAAC,GAClG,CAAC,4DAA4D,CAAC;YAEpE;YAEA;gBACE,wEAAwE;gBACxE,gEAAgE;gBAChE,4CAA4C;gBAC5C,MAAMM,qBAAqB1B,kBAAkBqB,iBAAiBpB,MAAM;gBAEpE,mEAAmE;gBACnE,qEAAqE;gBACrE,wEAAwE;gBACxE,kEAAkE;gBAClE,0EAA0E;gBAC1E,MAAM0B,cAAuB;oBAC3B;wBACEpB,MAAM;wBACNqB,MAAM;wBACNC,WAAW;oBACb;oBACA;wBACEtB,MAAM;wBACNqB,MAAM;wBACNC,WAAW;oBACb;oBACA;wBACEtB,MAAM;wBACNqB,MAAM;oBACR;oBACA;wBACErB,MAAM;wBACNqB,MAAM;wBACNE,YAAYd,SAASe,KAAK,CAACC,YAAY;wBACvCC,IAAI;oBACN;iBACD;gBAED,KAAK,MAAM3B,SAASqB,YAAa;oBAC/B,MAAMO,YAAY,UAAU5B,QAAQA,MAAMC,IAAI,GAAG4B;oBACjD,IAAID,aAAa,CAACR,mBAAmBU,GAAG,CAACF,YAAY;wBACnDb,iBAAiBpB,MAAM,CAACoC,IAAI,CAAC/B;oBAC/B;gBACF;YACF;YAEA,wEAAwE;YACxE,6DAA6D;YAC7DU,SAASe,KAAK,CAACO,SAAS,GAAGtB,SAASI,cAAc;QACpD;QAEA,iFAAiF;QACjF,MAAMmB,kBAAkB;YACtBvB,SAASe,KAAK,CAACS,QAAQ;YACvBxB,SAASe,KAAK,CAACU,SAAS;YACxBzB,SAASe,KAAK,CAACW,SAAS;YACxB1B,SAASe,KAAK,CAACC,YAAY;eACvBhB,SAASI,cAAc,GAAG,EAAE,GAAG;gBAACJ,SAASe,KAAK,CAACO,SAAS;aAAC;SAC9D;QAED,wEAAwE;QACxE,+CAA+C;QAC/C,KAAK,MAAMd,QAAQe,gBAAiB;YAClC,IAAIxB,OAAOI,WAAW,CAACwB,IAAI,CAAC,CAACpB,MAAQA,IAAIC,IAAI,KAAKA,OAAO;gBACvD,MAAM,IAAIC,MACR,CAAC,yCAAyC,EAAED,KAAK,kBAAkB,CAAC,GAClE,CAAC,oDAAoD,CAAC;YAE5D;QACF;QAEA,wEAAwE;QACxE,uEAAuE;QACvER,SAAS4B,kBAAkB,GAAG7B,OAAOI,WAAW,CAACwB,IAAI,CACnD,CAACpB,MAAQA,IAAIC,IAAI,KAAKR,SAASe,KAAK,CAACc,KAAK;QAG5C,MAAMC,KAAK9B,SAAS+B,mBAAmB;QACvChC,OAAOI,WAAW,CAACkB,IAAI,CACrBtC,wBAAwBX,yBAAyB4B,WAAW8B,GAAGN,QAAQ,GACvEzC,wBAAwBb,0BAA0B8B,WAAW8B,GAAGL,SAAS,GACzE1C,wBAAwBZ,0BAA0B6B,WAAW8B,GAAGJ,SAAS,GACzE3C,wBAAwBd,6BAA6B+B,WAAW8B,GAAGd,YAAY,GAC/E,4EAA4E;QAC5E,+DAA+D;WAC3DhB,SAASI,cAAc,GACvB,EAAE,GACF;YAACrB,wBAAwBf,0BAA0BgC,WAAW8B,GAAGR,SAAS;SAAE;QAGlF,6EAA6E;QAC7E,2EAA2E;QAC3E,IAAItB,SAASgC,QAAQ,EAAE;YACrB,KAAK,MAAMxB,QAAQe,gBAAiB;gBAClC,MAAMhB,MAAMR,OAAOI,WAAW,CAACG,IAAI,CAAC,CAAC2B,IAAMA,EAAEzB,IAAI,KAAKA;gBACtD,IAAID,KAAK;oBACP,OAAOA,IAAI2B,KAAK;gBAClB;YACF;YACA,OAAOnC;QACT;QAEA,4BAA4B;QAC5B,IAAI,CAACA,OAAOoC,SAAS,EAAE;YAACpC,OAAOoC,SAAS,GAAG,EAAE;QAAA;QAC7CpC,OAAOoC,SAAS,CAACd,IAAI,CACnB/C,4BAA4B0B,WAC5BzB,gCAAgCyB,WAChCxB,sBAAsBwB,WACtBvB,6BAA6BuB,WAC7BtB,gCAAgCsB,WAChCrB,uBAAuBqB,WACvBpB,mCAAmCoB;QAGrC,8DAA8D;QAC9D,IAAIA,SAASoC,iBAAiB,EAAE;YAC9B,MAAMC,gBAAgBrC,SAASoC,iBAAiB,CAAChC,cAAc;YAC/D,MAAMkC,kBAAkBvC,OAAOI,WAAW,CAACG,IAAI,CAAC,CAACC,MAAQA,IAAIC,IAAI,KAAK6B;YACtE,IAAI,CAACC,iBAAiB;gBACpB,MAAM,IAAI7B,MACR,CAAC,kCAAkC,EAAE4B,cAAc,qCAAqC,CAAC;YAE7F;YACAC,gBAAgBJ,KAAK,GAAG;gBACtB,GAAGI,gBAAgBJ,KAAK;gBACxBK,aAAa;uBACPD,gBAAgBJ,KAAK,EAAEK,eAAe,EAAE;oBAC5C1D,uBAAuBmB;iBACxB;YACH;QACF;QAEA,6BAA6B;QAC7B,IAAI,CAACD,OAAOyC,KAAK,EAAE;YAACzC,OAAOyC,KAAK,GAAG,CAAC;QAAC;QACrC,IAAI,CAACzC,OAAOyC,KAAK,CAACC,UAAU,EAAE;YAAC1C,OAAOyC,KAAK,CAACC,UAAU,GAAG,CAAC;QAAC;QAE3D,sEAAsE;QACtE,IAAI,CAAC1C,OAAOyC,KAAK,CAACE,MAAM,EAAE;YAAC3C,OAAOyC,KAAK,CAACE,MAAM,GAAG,CAAC;QAAC;QACnD3C,OAAOyC,KAAK,CAACE,MAAM,CAACC,gBAAgB,GAAG;YACrC,GAAG3C,SAASe,KAAK;QACnB;QACAhB,OAAOyC,KAAK,CAACE,MAAM,CAACE,wBAAwB,GAAG5C,SAAS6C,aAAa;QACrE9C,OAAOyC,KAAK,CAACE,MAAM,CAACI,iBAAiB,GAAG9C,SAAS+C,WAAW;QAC5DhD,OAAOyC,KAAK,CAACE,MAAM,CAACM,mBAAmB,GAAGhD,SAASiD,QAAQ;QAE3D,uBAAuB;QACvB,IAAI,CAAClD,OAAOyC,KAAK,CAACU,SAAS,EAAE;YAC3BnD,OAAOyC,KAAK,CAACU,SAAS,GAAG;gBAAEC,SAAS,EAAE;YAAC;QACzC;QACA,IAAI,CAACpD,OAAOyC,KAAK,CAACU,SAAS,CAACC,OAAO,EAAE;YACnCpD,OAAOyC,KAAK,CAACU,SAAS,CAACC,OAAO,GAAG,EAAE;QACrC;QACApD,OAAOyC,KAAK,CAACU,SAAS,CAACC,OAAO,CAAC9B,IAAI,CAAC;YAClCb,MAAM;YACN4C,WAAW;YACXC,OAAO,CAAC,EAAEC,CAAC,EAAE,GAAK,AAACA,EAAc;YACjCC,UAAU;YACVC,UAAU;QACZ;QAEA,iDAAiD;QACjD,IAAI,CAACzD,OAAOyC,KAAK,CAACC,UAAU,CAACgB,KAAK,EAAE;YAClC1D,OAAOyC,KAAK,CAACC,UAAU,CAACgB,KAAK,GAAG,CAAC;QACnC;;QACE1D,OAAOyC,KAAK,CAACC,UAAU,CAACgB,KAAK,AAA4B,CAAC,2BAA2B,GAAG;YACxFL,WAAW;YACXM,MAAM;QACR;QAEA,gEAAgE;QAChE3D,OAAO4D,IAAI,GAAG;YACZ,GAAI5D,OAAO4D,IAAI,IAAI,CAAC,CAAC;YACrB7E,cAAcf,gBACZe,cACA,AAACiB,OAAO4D,IAAI,EAAE7E,gBAA4D,CAAC;QAE/E;QAEA,OAAOiB;IACT,EAAC"}
@@ -1,10 +1,18 @@
1
1
  import type { Payload, PayloadRequest, Where } from 'payload';
2
- import type { DurationType, StatusMachineConfig } from '../types.js';
2
+ import type { CapacityMode, DurationType, StatusMachineConfig } from '../types.js';
3
+ import type { ResolvedItem } from '../utilities/resolveReservationItems.js';
4
+ /** A window during which a resource is occupied, expanded by buffer times. */
5
+ export type Occupancy = {
6
+ blockedEnd: Date;
7
+ blockedStart: Date;
8
+ units: number;
9
+ };
3
10
  export declare function computeEndTime(params: {
4
11
  durationType: DurationType;
5
12
  endTime?: Date;
6
13
  serviceDuration: number;
7
14
  startTime: Date;
15
+ timeZone?: string;
8
16
  }): {
9
17
  durationMinutes: number;
10
18
  endTime: Date;
@@ -16,6 +24,46 @@ export declare function buildOverlapQuery(params: {
16
24
  excludeReservationId?: number | string;
17
25
  resourceId: number | string;
18
26
  }): Where;
27
+ /**
28
+ * Coarse superset query: blocking reservations whose top-level (span) window
29
+ * comes within COARSE_MARGIN_MS of the candidate window and reference the
30
+ * resource at top level or in items[]. The precise per-item overlap is computed
31
+ * in memory afterwards — the top-level span is a superset of every item's
32
+ * window, so this never misses a real conflict (margin covers neighbor buffers).
33
+ */
34
+ export declare function buildCoarseOverlapQuery(params: {
35
+ blockingStatuses: string[];
36
+ candidateEnd: Date;
37
+ candidateStart: Date;
38
+ excludeReservationId?: number | string;
39
+ resourceId: number | string;
40
+ }): Where;
41
+ /**
42
+ * The occupancy windows a set of resolved items imposes on `resourceId`. Each
43
+ * matching item's [startTime, endTime) is expanded by that item's own service
44
+ * buffers (so neighbor buffers are enforced — review A3), and only the items
45
+ * that actually reference `resourceId` count (so a multi-resource booking blocks
46
+ * each resource only for its own item's window — review A4).
47
+ */
48
+ export declare function itemsToOccupancies(params: {
49
+ bufferFor: (serviceId: number | string | undefined) => Promise<{
50
+ after: number;
51
+ before: number;
52
+ }>;
53
+ capacityMode: CapacityMode;
54
+ items: ResolvedItem[];
55
+ resourceId: number | string;
56
+ }): Promise<Occupancy[]>;
57
+ /** Occupancy a single fetched reservation imposes on `resourceId`. */
58
+ export declare function reservationOccupancies(params: {
59
+ bufferFor: (serviceId: number | string | undefined) => Promise<{
60
+ after: number;
61
+ before: number;
62
+ }>;
63
+ capacityMode: CapacityMode;
64
+ reservation: Record<string, unknown>;
65
+ resourceId: number | string;
66
+ }): Promise<Occupancy[]>;
19
67
  export declare function isBlockingStatus(status: string, statusMachine: StatusMachineConfig): boolean;
20
68
  export declare function validateTransition(fromStatus: string, toStatus: string, statusMachine: StatusMachineConfig): {
21
69
  reason?: string;
@@ -33,6 +81,9 @@ export declare function checkAvailability(params: {
33
81
  reservationSlug: string;
34
82
  resourceId: number | string;
35
83
  resourceSlug: string;
84
+ servicesSlug: string;
85
+ /** Other items from the same booking — counted as occupancy (review A5). */
86
+ siblingItems?: ResolvedItem[];
36
87
  startTime: Date;
37
88
  }): Promise<{
38
89
  available: boolean;
@@ -42,7 +93,7 @@ export declare function checkAvailability(params: {
42
93
  }>;
43
94
  export declare function getAvailableSlots(params: {
44
95
  blockingStatuses: string[];
45
- date: Date;
96
+ date: Date | string;
46
97
  guestCount?: number;
47
98
  payload: Payload;
48
99
  req: PayloadRequest;
@@ -53,6 +104,7 @@ export declare function getAvailableSlots(params: {
53
104
  scheduleSlug: string;
54
105
  serviceId: number | string;
55
106
  serviceSlug: string;
107
+ timeZone?: string;
56
108
  }): Promise<Array<{
57
109
  end: Date;
58
110
  start: Date;
@@ -1,11 +1,14 @@
1
- import { resolveScheduleForDate } from '../utilities/scheduleUtils.js';
2
- import { addMinutes, computeBlockedWindow, intersectIntervals } from '../utilities/slotUtils.js';
1
+ import { resolveReservationItems } from '../utilities/resolveReservationItems.js';
2
+ import { isExceptionDate, resolveScheduleForDate } from '../utilities/scheduleUtils.js';
3
+ import { addMinutes, computeBlockedWindow, doRangesOverlap, intersectIntervals } from '../utilities/slotUtils.js';
4
+ import { endOfDayInTimezone } from '../utilities/timezoneUtils.js';
5
+ /** Coarse pre-filter widen: covers any realistic neighbor buffer (buffers are
6
+ * minutes). The precise per-item overlap check runs in memory afterwards. */ const COARSE_MARGIN_MS = 24 * 60 * 60 * 1000;
3
7
  // --- Pure functions (no DB) ---
4
8
  export function computeEndTime(params) {
5
9
  const { durationType, serviceDuration, startTime } = params;
6
10
  if (durationType === 'full-day') {
7
- const end = new Date(startTime);
8
- end.setHours(23, 59, 59, 999);
11
+ const end = endOfDayInTimezone(startTime, params.timeZone ?? 'UTC');
9
12
  const durationMinutes = Math.round((end.getTime() - startTime.getTime()) / 60_000);
10
13
  return {
11
14
  durationMinutes,
@@ -70,6 +73,90 @@ export function buildOverlapQuery(params) {
70
73
  and: conditions
71
74
  };
72
75
  }
76
+ /**
77
+ * Coarse superset query: blocking reservations whose top-level (span) window
78
+ * comes within COARSE_MARGIN_MS of the candidate window and reference the
79
+ * resource at top level or in items[]. The precise per-item overlap is computed
80
+ * in memory afterwards — the top-level span is a superset of every item's
81
+ * window, so this never misses a real conflict (margin covers neighbor buffers).
82
+ */ export function buildCoarseOverlapQuery(params) {
83
+ const { blockingStatuses, candidateEnd, candidateStart, excludeReservationId, resourceId } = params;
84
+ const windowStart = new Date(candidateStart.getTime() - COARSE_MARGIN_MS);
85
+ const windowEnd = new Date(candidateEnd.getTime() + COARSE_MARGIN_MS);
86
+ const conditions = [
87
+ {
88
+ status: {
89
+ in: blockingStatuses
90
+ }
91
+ },
92
+ {
93
+ startTime: {
94
+ less_than: windowEnd.toISOString()
95
+ }
96
+ },
97
+ {
98
+ endTime: {
99
+ greater_than: windowStart.toISOString()
100
+ }
101
+ },
102
+ {
103
+ or: [
104
+ {
105
+ resource: {
106
+ equals: resourceId
107
+ }
108
+ },
109
+ {
110
+ 'items.resource': {
111
+ equals: resourceId
112
+ }
113
+ }
114
+ ]
115
+ }
116
+ ];
117
+ if (excludeReservationId !== undefined) {
118
+ conditions.push({
119
+ id: {
120
+ not_equals: excludeReservationId
121
+ }
122
+ });
123
+ }
124
+ return {
125
+ and: conditions
126
+ };
127
+ }
128
+ /**
129
+ * The occupancy windows a set of resolved items imposes on `resourceId`. Each
130
+ * matching item's [startTime, endTime) is expanded by that item's own service
131
+ * buffers (so neighbor buffers are enforced — review A3), and only the items
132
+ * that actually reference `resourceId` count (so a multi-resource booking blocks
133
+ * each resource only for its own item's window — review A4).
134
+ */ export async function itemsToOccupancies(params) {
135
+ const { bufferFor, capacityMode, items, resourceId } = params;
136
+ const occupancies = [];
137
+ for (const item of items){
138
+ if (String(item.resource) !== String(resourceId) || !item.endTime) {
139
+ continue;
140
+ }
141
+ const { after, before } = await bufferFor(item.service);
142
+ const { effectiveEnd, effectiveStart } = computeBlockedWindow(new Date(item.startTime), new Date(item.endTime), before, after);
143
+ occupancies.push({
144
+ blockedEnd: effectiveEnd,
145
+ blockedStart: effectiveStart,
146
+ units: capacityMode === 'per-guest' ? item.guestCount : 1
147
+ });
148
+ }
149
+ return occupancies;
150
+ }
151
+ /** Occupancy a single fetched reservation imposes on `resourceId`. */ export async function reservationOccupancies(params) {
152
+ const { bufferFor, capacityMode, reservation, resourceId } = params;
153
+ return itemsToOccupancies({
154
+ bufferFor,
155
+ capacityMode,
156
+ items: resolveReservationItems(reservation),
157
+ resourceId
158
+ });
159
+ }
73
160
  export function isBlockingStatus(status, statusMachine) {
74
161
  return statusMachine.blockingStatuses.includes(status);
75
162
  }
@@ -93,7 +180,7 @@ export function validateTransition(fromStatus, toStatus, statusMachine) {
93
180
  }
94
181
  // --- DB functions (use Payload Local API only) ---
95
182
  export async function checkAvailability(params) {
96
- const { blockingStatuses, bufferAfter, bufferBefore, endTime, excludeReservationId, guestCount, payload, req, reservationSlug, resourceId, resourceSlug, startTime } = params;
183
+ const { blockingStatuses, bufferAfter, bufferBefore, endTime, excludeReservationId, guestCount, payload, req, reservationSlug, resourceId, resourceSlug, servicesSlug, siblingItems, startTime } = params;
97
184
  // Fetch resource for quantity and capacity mode
98
185
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
186
  const resource = await payload.findByID({
@@ -104,54 +191,89 @@ export async function checkAvailability(params) {
104
191
  });
105
192
  const quantity = resource.quantity ?? 1;
106
193
  const capacityMode = resource.capacityMode ?? 'per-reservation';
107
- // Compute effective window with buffers
108
- const { effectiveEnd, effectiveStart } = computeBlockedWindow(startTime, endTime, bufferBefore, bufferAfter);
109
- // Build overlap query
110
- const where = buildOverlapQuery({
111
- blockingStatuses,
112
- effectiveEnd,
113
- effectiveStart,
114
- excludeReservationId,
115
- resourceId
116
- });
117
- if (capacityMode === 'per-guest') {
118
- // Must fetch docs to sum guestCount
119
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
120
- const { docs } = await payload.find({
121
- collection: reservationSlug,
122
- depth: 0,
123
- limit: 0,
124
- req,
125
- select: {
126
- guestCount: true
127
- },
128
- where
129
- });
130
- const currentGuests = docs.reduce((sum, doc)=>sum + (doc.guestCount ?? 1), 0);
131
- return {
132
- available: currentGuests + guestCount <= quantity,
133
- currentCount: currentGuests,
134
- reason: currentGuests + guestCount > quantity ? 'Guest capacity exceeded' : undefined,
135
- totalCapacity: quantity
136
- };
137
- }
138
- // per-reservation mode: count is sufficient
139
- // TODO: batch queries — linear per-item cost acceptable for 2-5 items
194
+ // Candidate window expanded by its own buffers
195
+ const { effectiveEnd: candidateEnd, effectiveStart: candidateStart } = computeBlockedWindow(startTime, endTime, bufferBefore, bufferAfter);
196
+ // Coarse superset fetch — precise per-item overlap is computed in memory below
140
197
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
141
- const { totalDocs } = await payload.count({
198
+ const { docs } = await payload.find({
142
199
  collection: reservationSlug,
200
+ depth: 0,
201
+ limit: 0,
143
202
  req,
144
- where
203
+ where: buildCoarseOverlapQuery({
204
+ blockingStatuses,
205
+ candidateEnd,
206
+ candidateStart,
207
+ excludeReservationId,
208
+ resourceId
209
+ })
145
210
  });
211
+ // Per-call cache: fetch each distinct service's buffers at most once
212
+ const bufferCache = new Map();
213
+ const bufferFor = async (serviceId)=>{
214
+ const key = serviceId === undefined ? '' : String(serviceId);
215
+ const cached = bufferCache.get(key);
216
+ if (cached) {
217
+ return cached;
218
+ }
219
+ let result = {
220
+ after: 0,
221
+ before: 0
222
+ };
223
+ if (serviceId !== undefined) {
224
+ try {
225
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
226
+ const service = await payload.findByID({
227
+ id: serviceId,
228
+ collection: servicesSlug,
229
+ depth: 0,
230
+ req
231
+ });
232
+ if (service) {
233
+ result = {
234
+ after: service.bufferTimeAfter ?? 0,
235
+ before: service.bufferTimeBefore ?? 0
236
+ };
237
+ }
238
+ } catch {
239
+ // service missing — no buffers
240
+ }
241
+ }
242
+ bufferCache.set(key, result);
243
+ return result;
244
+ };
245
+ const fetchedOccupancies = (await Promise.all(docs.map((doc)=>reservationOccupancies({
246
+ bufferFor,
247
+ capacityMode,
248
+ reservation: doc,
249
+ resourceId
250
+ })))).flat();
251
+ // Sibling items from the same booking (review A5) — expanded with the same
252
+ // per-service buffers and capacity mode.
253
+ const siblingOccupancies = siblingItems ? await itemsToOccupancies({
254
+ bufferFor,
255
+ capacityMode,
256
+ items: siblingItems,
257
+ resourceId
258
+ }) : [];
259
+ const occupancies = [
260
+ ...fetchedOccupancies,
261
+ ...siblingOccupancies
262
+ ];
263
+ // Sum the units of every occupancy whose buffered window overlaps the candidate
264
+ const currentUnits = occupancies.reduce((sum, occ)=>doRangesOverlap(candidateStart, candidateEnd, occ.blockedStart, occ.blockedEnd) ? sum + occ.units : sum, 0);
265
+ const candidateUnits = capacityMode === 'per-guest' ? guestCount : 1;
266
+ const available = currentUnits + candidateUnits <= quantity;
146
267
  return {
147
- available: totalDocs + 1 <= quantity,
148
- currentCount: totalDocs,
149
- reason: totalDocs + 1 > quantity ? 'All units are booked for this time' : undefined,
268
+ available,
269
+ currentCount: currentUnits,
270
+ reason: available ? undefined : capacityMode === 'per-guest' ? 'Guest capacity exceeded' : 'All units are booked for this time',
150
271
  totalCapacity: quantity
151
272
  };
152
273
  }
153
274
  export async function getAvailableSlots(params) {
154
- const { blockingStatuses, date, guestCount, payload, req, reservationSlug, resourceId, resourceIds, resourceSlug, scheduleSlug, serviceId, serviceSlug } = params;
275
+ const { blockingStatuses, date, guestCount, payload, req, reservationSlug, resourceId, resourceIds, resourceSlug, scheduleSlug, serviceId, serviceSlug, timeZone } = params;
276
+ const tz = timeZone ?? 'UTC';
155
277
  // Resolve the set of resources to intersect (single-resource callers still work)
156
278
  const ids = resourceIds && resourceIds.length > 0 ? resourceIds : resourceId !== undefined ? [
157
279
  resourceId
@@ -200,9 +322,16 @@ export async function getAvailableSlots(params) {
200
322
  if (!schedules || schedules.length === 0) {
201
323
  continue;
202
324
  }
325
+ // A11: an exception on ANY of the resource's schedules makes the whole
326
+ // resource unavailable that day — not just the schedule it's recorded on.
327
+ const exceptedToday = schedules.some((s)=>isExceptionDate(date, s.exceptions ?? [], tz));
328
+ if (exceptedToday) {
329
+ scheduleBearingWindowLists.push([]);
330
+ continue;
331
+ }
203
332
  const windows = [];
204
333
  for (const schedule of schedules){
205
- windows.push(...resolveScheduleForDate(schedule, date));
334
+ windows.push(...resolveScheduleForDate(schedule, date, tz));
206
335
  }
207
336
  scheduleBearingWindowLists.push(windows);
208
337
  }
@@ -219,10 +348,14 @@ export async function getAvailableSlots(params) {
219
348
  return [];
220
349
  }
221
350
  // 4. Candidate slot sizing
351
+ // NOTE: epoch-trick sizing is only meaningful for fixed/flexible durations.
352
+ // full-day services return early via the range-as-slot branch below and never
353
+ // consume slotDuration — keep it that way if reordering this function.
222
354
  const { endTime: slotEndOffset } = computeEndTime({
223
355
  durationType,
224
356
  serviceDuration: duration,
225
- startTime: new Date(0)
357
+ startTime: new Date(0),
358
+ timeZone: tz
226
359
  });
227
360
  const slotDuration = Math.round(slotEndOffset.getTime() / 60_000);
228
361
  const effectiveDuration = durationType === 'fixed' ? duration : slotDuration;
@@ -240,6 +373,7 @@ export async function getAvailableSlots(params) {
240
373
  reservationSlug,
241
374
  resourceId: rid,
242
375
  resourceSlug,
376
+ servicesSlug: serviceSlug,
243
377
  startTime: start
244
378
  });
245
379
  if (!result.available) {