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.
- package/README.md +55 -3
- package/dist/collections/Reservations.js +19 -7
- package/dist/collections/Reservations.js.map +1 -1
- package/dist/collections/Resources.js +11 -8
- package/dist/collections/Resources.js.map +1 -1
- package/dist/collections/Schedules.js +12 -6
- package/dist/collections/Schedules.js.map +1 -1
- package/dist/collections/Services.js +19 -10
- package/dist/collections/Services.js.map +1 -1
- package/dist/components/AvailabilityOverview/index.js +76 -18
- package/dist/components/AvailabilityOverview/index.js.map +1 -1
- package/dist/components/CalendarView/CalendarView.module.css +9 -0
- package/dist/components/CalendarView/LaneTimelineView.d.ts +4 -1
- package/dist/components/CalendarView/LaneTimelineView.js +17 -12
- package/dist/components/CalendarView/LaneTimelineView.js.map +1 -1
- package/dist/components/CalendarView/index.js +166 -44
- package/dist/components/CalendarView/index.js.map +1 -1
- package/dist/components/CustomerField/index.js +8 -3
- package/dist/components/CustomerField/index.js.map +1 -1
- package/dist/components/DashboardWidget/DashboardWidgetServer.js +91 -18
- package/dist/components/DashboardWidget/DashboardWidgetServer.js.map +1 -1
- package/dist/defaults.js +44 -9
- package/dist/defaults.js.map +1 -1
- package/dist/endpoints/cancelBooking.js +1 -1
- package/dist/endpoints/cancelBooking.js.map +1 -1
- package/dist/endpoints/checkAvailability.js +56 -7
- package/dist/endpoints/checkAvailability.js.map +1 -1
- package/dist/endpoints/createBooking.js +19 -10
- package/dist/endpoints/createBooking.js.map +1 -1
- package/dist/endpoints/customerSearch.js +5 -2
- package/dist/endpoints/customerSearch.js.map +1 -1
- package/dist/endpoints/effectiveTimezone.d.ts +13 -0
- package/dist/endpoints/effectiveTimezone.js +41 -0
- package/dist/endpoints/effectiveTimezone.js.map +1 -0
- package/dist/endpoints/getSlots.js +56 -7
- package/dist/endpoints/getSlots.js.map +1 -1
- package/dist/endpoints/resourceAvailability.d.ts +4 -1
- package/dist/endpoints/resourceAvailability.js +102 -26
- package/dist/endpoints/resourceAvailability.js.map +1 -1
- package/dist/hooks/reservations/calculateEndTime.js +48 -20
- package/dist/hooks/reservations/calculateEndTime.js.map +1 -1
- package/dist/hooks/reservations/enforceCustomerOwnership.d.ts +11 -0
- package/dist/hooks/reservations/enforceCustomerOwnership.js +30 -0
- package/dist/hooks/reservations/enforceCustomerOwnership.js.map +1 -0
- package/dist/hooks/reservations/onStatusChange.js +10 -4
- package/dist/hooks/reservations/onStatusChange.js.map +1 -1
- package/dist/hooks/reservations/validateCancellation.js +3 -2
- package/dist/hooks/reservations/validateCancellation.js.map +1 -1
- package/dist/hooks/reservations/validateConflicts.js +23 -4
- package/dist/hooks/reservations/validateConflicts.js.map +1 -1
- package/dist/hooks/reservations/validateGuestBooking.js +3 -4
- package/dist/hooks/reservations/validateGuestBooking.js.map +1 -1
- package/dist/hooks/reservations/validateStatusTransition.js +2 -2
- package/dist/hooks/reservations/validateStatusTransition.js.map +1 -1
- package/dist/hooks/users/provisionStaffResource.js +5 -8
- package/dist/hooks/users/provisionStaffResource.js.map +1 -1
- package/dist/plugin.js +83 -14
- package/dist/plugin.js.map +1 -1
- package/dist/services/AvailabilityService.d.ts +54 -2
- package/dist/services/AvailabilityService.js +180 -46
- package/dist/services/AvailabilityService.js.map +1 -1
- package/dist/translations/ar.json +1 -0
- package/dist/translations/de.json +1 -0
- package/dist/translations/en.json +1 -0
- package/dist/translations/es.json +1 -0
- package/dist/translations/fa.json +1 -0
- package/dist/translations/fr.json +1 -0
- package/dist/translations/hi.json +1 -0
- package/dist/translations/id.json +1 -0
- package/dist/translations/pl.json +1 -0
- package/dist/translations/ru.json +1 -0
- package/dist/translations/tr.json +1 -0
- package/dist/translations/zh.json +1 -0
- package/dist/types.d.ts +46 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/dist/utilities/collectionOverrides.d.ts +14 -0
- package/dist/utilities/collectionOverrides.js +47 -0
- package/dist/utilities/collectionOverrides.js.map +1 -0
- package/dist/utilities/ownerAccess.d.ts +6 -0
- package/dist/utilities/ownerAccess.js +25 -12
- package/dist/utilities/ownerAccess.js.map +1 -1
- package/dist/utilities/reservationChanges.d.ts +17 -0
- package/dist/utilities/reservationChanges.js +88 -0
- package/dist/utilities/reservationChanges.js.map +1 -0
- package/dist/utilities/scheduleUtils.d.ts +14 -8
- package/dist/utilities/scheduleUtils.js +26 -19
- package/dist/utilities/scheduleUtils.js.map +1 -1
- package/dist/utilities/tenantTimezone.d.ts +41 -0
- package/dist/utilities/tenantTimezone.js +77 -0
- package/dist/utilities/tenantTimezone.js.map +1 -0
- package/dist/utilities/timezoneUtils.d.ts +44 -0
- package/dist/utilities/timezoneUtils.js +146 -0
- package/dist/utilities/timezoneUtils.js.map +1 -0
- 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
|
-
//
|
|
33
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 = {
|
package/dist/plugin.js.map
CHANGED
|
@@ -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":"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"}
|
|
@@ -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 {
|
|
2
|
-
import {
|
|
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 =
|
|
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
|
-
//
|
|
108
|
-
const { effectiveEnd, effectiveStart } = computeBlockedWindow(startTime, endTime, bufferBefore, bufferAfter);
|
|
109
|
-
//
|
|
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 {
|
|
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
|
|
148
|
-
currentCount:
|
|
149
|
-
reason:
|
|
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) {
|