payload-reserve 1.5.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +40 -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 +70 -26
  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 +154 -53
  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 +97 -21
  21. package/dist/components/DashboardWidget/DashboardWidgetServer.js.map +1 -1
  22. package/dist/defaults.js +46 -8
  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/getSlots.js +56 -7
  33. package/dist/endpoints/getSlots.js.map +1 -1
  34. package/dist/endpoints/resourceAvailability.d.ts +2 -1
  35. package/dist/endpoints/resourceAvailability.js +85 -25
  36. package/dist/endpoints/resourceAvailability.js.map +1 -1
  37. package/dist/hooks/reservations/calculateEndTime.js +48 -20
  38. package/dist/hooks/reservations/calculateEndTime.js.map +1 -1
  39. package/dist/hooks/reservations/enforceCustomerOwnership.d.ts +11 -0
  40. package/dist/hooks/reservations/enforceCustomerOwnership.js +30 -0
  41. package/dist/hooks/reservations/enforceCustomerOwnership.js.map +1 -0
  42. package/dist/hooks/reservations/onStatusChange.js +10 -4
  43. package/dist/hooks/reservations/onStatusChange.js.map +1 -1
  44. package/dist/hooks/reservations/validateCancellation.js +3 -2
  45. package/dist/hooks/reservations/validateCancellation.js.map +1 -1
  46. package/dist/hooks/reservations/validateConflicts.js +23 -4
  47. package/dist/hooks/reservations/validateConflicts.js.map +1 -1
  48. package/dist/hooks/reservations/validateGuestBooking.js +3 -4
  49. package/dist/hooks/reservations/validateGuestBooking.js.map +1 -1
  50. package/dist/hooks/reservations/validateStatusTransition.js +2 -2
  51. package/dist/hooks/reservations/validateStatusTransition.js.map +1 -1
  52. package/dist/hooks/users/provisionStaffResource.js +5 -8
  53. package/dist/hooks/users/provisionStaffResource.js.map +1 -1
  54. package/dist/plugin.js +82 -13
  55. package/dist/plugin.js.map +1 -1
  56. package/dist/services/AvailabilityService.d.ts +54 -2
  57. package/dist/services/AvailabilityService.js +180 -46
  58. package/dist/services/AvailabilityService.js.map +1 -1
  59. package/dist/translations/ar.json +1 -0
  60. package/dist/translations/de.json +1 -0
  61. package/dist/translations/en.json +1 -0
  62. package/dist/translations/es.json +1 -0
  63. package/dist/translations/fa.json +1 -0
  64. package/dist/translations/fr.json +1 -0
  65. package/dist/translations/hi.json +1 -0
  66. package/dist/translations/id.json +1 -0
  67. package/dist/translations/pl.json +1 -0
  68. package/dist/translations/ru.json +1 -0
  69. package/dist/translations/tr.json +1 -0
  70. package/dist/translations/zh.json +1 -0
  71. package/dist/types.d.ts +50 -1
  72. package/dist/types.js +2 -0
  73. package/dist/types.js.map +1 -1
  74. package/dist/utilities/collectionOverrides.d.ts +14 -0
  75. package/dist/utilities/collectionOverrides.js +47 -0
  76. package/dist/utilities/collectionOverrides.js.map +1 -0
  77. package/dist/utilities/ownerAccess.d.ts +6 -0
  78. package/dist/utilities/ownerAccess.js +25 -12
  79. package/dist/utilities/ownerAccess.js.map +1 -1
  80. package/dist/utilities/reservationChanges.d.ts +17 -0
  81. package/dist/utilities/reservationChanges.js +88 -0
  82. package/dist/utilities/reservationChanges.js.map +1 -0
  83. package/dist/utilities/scheduleUtils.d.ts +14 -8
  84. package/dist/utilities/scheduleUtils.js +26 -19
  85. package/dist/utilities/scheduleUtils.js.map +1 -1
  86. package/dist/utilities/tenantFilter.d.ts +25 -0
  87. package/dist/utilities/tenantFilter.js +56 -0
  88. package/dist/utilities/tenantFilter.js.map +1 -0
  89. package/dist/utilities/timezoneUtils.d.ts +39 -0
  90. package/dist/utilities/timezoneUtils.js +134 -0
  91. package/dist/utilities/timezoneUtils.js.map +1 -0
  92. package/dist/utilities/useTenantFilter.d.ts +6 -0
  93. package/dist/utilities/useTenantFilter.js +28 -0
  94. package/dist/utilities/useTenantFilter.js.map +1 -0
  95. package/package.json +2 -1
@@ -0,0 +1,11 @@
1
+ import type { CollectionBeforeChangeHook } from 'payload';
2
+ import type { ResolvedReservationPluginConfig } from '../../types.js';
3
+ /**
4
+ * Prevents a non-privileged authenticated user from creating a reservation on
5
+ * behalf of another customer (mass assignment). The `/api/reserve/book` endpoint
6
+ * already enforces this, but a logged-in customer could reach the same write
7
+ * through Payload's default collection REST API — this guard closes that route
8
+ * (review B3 parallel path). Staff/admin may still book for anyone (walk-ins);
9
+ * guest bookings (no `customer`) are untouched.
10
+ */
11
+ export declare const enforceCustomerOwnership: (config: ResolvedReservationPluginConfig) => CollectionBeforeChangeHook;
@@ -0,0 +1,30 @@
1
+ import { extractId } from '../../utilities/resolveReservationItems.js';
2
+ import { isPrivilegedUser } from '../../utilities/userRoles.js';
3
+ /**
4
+ * Prevents a non-privileged authenticated user from creating a reservation on
5
+ * behalf of another customer (mass assignment). The `/api/reserve/book` endpoint
6
+ * already enforces this, but a logged-in customer could reach the same write
7
+ * through Payload's default collection REST API — this guard closes that route
8
+ * (review B3 parallel path). Staff/admin may still book for anyone (walk-ins);
9
+ * guest bookings (no `customer`) are untouched.
10
+ */ export const enforceCustomerOwnership = (config)=>({ context, data, operation, req })=>{
11
+ if (context?.skipReservationHooks) {
12
+ return data;
13
+ }
14
+ if (operation !== 'create' || !req.user) {
15
+ return data;
16
+ }
17
+ if (isPrivilegedUser(req.user, config)) {
18
+ return data;
19
+ }
20
+ const customer = data?.customer;
21
+ if (customer != null && customer !== '') {
22
+ const customerId = extractId(customer);
23
+ if (String(customerId) !== String(req.user.id)) {
24
+ data.customer = req.user.id;
25
+ }
26
+ }
27
+ return data;
28
+ };
29
+
30
+ //# sourceMappingURL=enforceCustomerOwnership.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/hooks/reservations/enforceCustomerOwnership.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { extractId } from '../../utilities/resolveReservationItems.js'\nimport { isPrivilegedUser } from '../../utilities/userRoles.js'\n\n/**\n * Prevents a non-privileged authenticated user from creating a reservation on\n * behalf of another customer (mass assignment). The `/api/reserve/book` endpoint\n * already enforces this, but a logged-in customer could reach the same write\n * through Payload's default collection REST API — this guard closes that route\n * (review B3 parallel path). Staff/admin may still book for anyone (walk-ins);\n * guest bookings (no `customer`) are untouched.\n */\nexport const enforceCustomerOwnership =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n ({ context, data, operation, req }) => {\n if (context?.skipReservationHooks) {\n return data\n }\n if (operation !== 'create' || !req.user) {\n return data\n }\n if (isPrivilegedUser(req.user, config)) {\n return data\n }\n\n const customer = data?.customer\n if (customer != null && customer !== '') {\n const customerId = extractId(customer)\n if (String(customerId) !== String(req.user.id)) {\n data.customer = req.user.id\n }\n }\n\n return data\n }\n"],"names":["extractId","isPrivilegedUser","enforceCustomerOwnership","config","context","data","operation","req","skipReservationHooks","user","customer","customerId","String","id"],"mappings":"AAIA,SAASA,SAAS,QAAQ,6CAA4C;AACtE,SAASC,gBAAgB,QAAQ,+BAA8B;AAE/D;;;;;;;CAOC,GACD,OAAO,MAAMC,2BACX,CAACC,SACD,CAAC,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,GAAG,EAAE;QAChC,IAAIH,SAASI,sBAAsB;YACjC,OAAOH;QACT;QACA,IAAIC,cAAc,YAAY,CAACC,IAAIE,IAAI,EAAE;YACvC,OAAOJ;QACT;QACA,IAAIJ,iBAAiBM,IAAIE,IAAI,EAAEN,SAAS;YACtC,OAAOE;QACT;QAEA,MAAMK,WAAWL,MAAMK;QACvB,IAAIA,YAAY,QAAQA,aAAa,IAAI;YACvC,MAAMC,aAAaX,UAAUU;YAC7B,IAAIE,OAAOD,gBAAgBC,OAAOL,IAAIE,IAAI,CAACI,EAAE,GAAG;gBAC9CR,KAAKK,QAAQ,GAAGH,IAAIE,IAAI,CAACI,EAAE;YAC7B;QACF;QAEA,OAAOR;IACT,EAAC"}
@@ -1,8 +1,14 @@
1
- export const onStatusChange = (config)=>async ({ context, doc, previousDoc, req })=>{
1
+ export const onStatusChange = (config)=>async ({ context, doc, operation, previousDoc, req })=>{
2
2
  if (context?.skipReservationHooks) {
3
3
  return doc;
4
4
  }
5
- if (!previousDoc || previousDoc.status === doc.status) {
5
+ // On create Payload passes previousDoc: {} (not undefined) — there is no
6
+ // previous status, so status-change hooks must not fire (afterBookingCreate
7
+ // covers creation).
8
+ if (operation !== 'update') {
9
+ return doc;
10
+ }
11
+ if (!previousDoc?.status || previousDoc.status === doc.status) {
6
12
  return doc;
7
13
  }
8
14
  const prev = previousDoc.status;
@@ -24,7 +30,7 @@ export const onStatusChange = (config)=>async ({ context, doc, previousDoc, req
24
30
  }
25
31
  }
26
32
  }
27
- if (next === 'confirmed' && config.hooks?.afterBookingConfirm) {
33
+ if (next === config.statusMachine.confirmStatus && config.hooks?.afterBookingConfirm) {
28
34
  for (const hook of config.hooks.afterBookingConfirm){
29
35
  try {
30
36
  await hook({
@@ -39,7 +45,7 @@ export const onStatusChange = (config)=>async ({ context, doc, previousDoc, req
39
45
  }
40
46
  }
41
47
  }
42
- if (next === 'cancelled' && config.hooks?.afterBookingCancel) {
48
+ if (next === config.statusMachine.cancelStatus && config.hooks?.afterBookingCancel) {
43
49
  for (const hook of config.hooks.afterBookingCancel){
44
50
  try {
45
51
  await hook({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/reservations/onStatusChange.ts"],"sourcesContent":["import type { CollectionAfterChangeHook } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nexport const onStatusChange =\n (config: ResolvedReservationPluginConfig): CollectionAfterChangeHook =>\n async ({ context, doc, previousDoc, req }) => {\n if (context?.skipReservationHooks) {return doc}\n if (!previousDoc || previousDoc.status === doc.status) {return doc}\n\n const prev = previousDoc.status as string\n const next = doc.status as string\n\n if (config.hooks?.afterStatusChange) {\n for (const hook of config.hooks.afterStatusChange) {\n try {\n await hook({ doc: doc as Record<string, unknown>, newStatus: next, previousStatus: prev, req })\n } catch (err) {\n req.payload.logger.error({ err, msg: `afterStatusChange hook failed for reservation ${doc.id}` })\n }\n }\n }\n\n if (next === 'confirmed' && config.hooks?.afterBookingConfirm) {\n for (const hook of config.hooks.afterBookingConfirm) {\n try {\n await hook({ doc: doc as Record<string, unknown>, req })\n } catch (err) {\n req.payload.logger.error({ err, msg: `afterBookingConfirm hook failed for reservation ${doc.id}` })\n }\n }\n }\n if (next === 'cancelled' && config.hooks?.afterBookingCancel) {\n for (const hook of config.hooks.afterBookingCancel) {\n try {\n await hook({ doc: doc as Record<string, unknown>, req })\n } catch (err) {\n req.payload.logger.error({ err, msg: `afterBookingCancel hook failed for reservation ${doc.id}` })\n }\n }\n }\n\n return doc\n }\n"],"names":["onStatusChange","config","context","doc","previousDoc","req","skipReservationHooks","status","prev","next","hooks","afterStatusChange","hook","newStatus","previousStatus","err","payload","logger","error","msg","id","afterBookingConfirm","afterBookingCancel"],"mappings":"AAIA,OAAO,MAAMA,iBACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,WAAW,EAAEC,GAAG,EAAE;QACvC,IAAIH,SAASI,sBAAsB;YAAC,OAAOH;QAAG;QAC9C,IAAI,CAACC,eAAeA,YAAYG,MAAM,KAAKJ,IAAII,MAAM,EAAE;YAAC,OAAOJ;QAAG;QAElE,MAAMK,OAAOJ,YAAYG,MAAM;QAC/B,MAAME,OAAON,IAAII,MAAM;QAEvB,IAAIN,OAAOS,KAAK,EAAEC,mBAAmB;YACnC,KAAK,MAAMC,QAAQX,OAAOS,KAAK,CAACC,iBAAiB,CAAE;gBACjD,IAAI;oBACF,MAAMC,KAAK;wBAAET,KAAKA;wBAAgCU,WAAWJ;wBAAMK,gBAAgBN;wBAAMH;oBAAI;gBAC/F,EAAE,OAAOU,KAAK;oBACZV,IAAIW,OAAO,CAACC,MAAM,CAACC,KAAK,CAAC;wBAAEH;wBAAKI,KAAK,CAAC,8CAA8C,EAAEhB,IAAIiB,EAAE,EAAE;oBAAC;gBACjG;YACF;QACF;QAEA,IAAIX,SAAS,eAAeR,OAAOS,KAAK,EAAEW,qBAAqB;YAC7D,KAAK,MAAMT,QAAQX,OAAOS,KAAK,CAACW,mBAAmB,CAAE;gBACnD,IAAI;oBACF,MAAMT,KAAK;wBAAET,KAAKA;wBAAgCE;oBAAI;gBACxD,EAAE,OAAOU,KAAK;oBACZV,IAAIW,OAAO,CAACC,MAAM,CAACC,KAAK,CAAC;wBAAEH;wBAAKI,KAAK,CAAC,gDAAgD,EAAEhB,IAAIiB,EAAE,EAAE;oBAAC;gBACnG;YACF;QACF;QACA,IAAIX,SAAS,eAAeR,OAAOS,KAAK,EAAEY,oBAAoB;YAC5D,KAAK,MAAMV,QAAQX,OAAOS,KAAK,CAACY,kBAAkB,CAAE;gBAClD,IAAI;oBACF,MAAMV,KAAK;wBAAET,KAAKA;wBAAgCE;oBAAI;gBACxD,EAAE,OAAOU,KAAK;oBACZV,IAAIW,OAAO,CAACC,MAAM,CAACC,KAAK,CAAC;wBAAEH;wBAAKI,KAAK,CAAC,+CAA+C,EAAEhB,IAAIiB,EAAE,EAAE;oBAAC;gBAClG;YACF;QACF;QAEA,OAAOjB;IACT,EAAC"}
1
+ {"version":3,"sources":["../../../src/hooks/reservations/onStatusChange.ts"],"sourcesContent":["import type { CollectionAfterChangeHook } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nexport const onStatusChange =\n (config: ResolvedReservationPluginConfig): CollectionAfterChangeHook =>\n async ({ context, doc, operation, previousDoc, req }) => {\n if (context?.skipReservationHooks) {return doc}\n // On create Payload passes previousDoc: {} (not undefined) — there is no\n // previous status, so status-change hooks must not fire (afterBookingCreate\n // covers creation).\n if (operation !== 'update') {return doc}\n if (!previousDoc?.status || previousDoc.status === doc.status) {return doc}\n\n const prev = previousDoc.status as string\n const next = doc.status as string\n\n if (config.hooks?.afterStatusChange) {\n for (const hook of config.hooks.afterStatusChange) {\n try {\n await hook({ doc: doc as Record<string, unknown>, newStatus: next, previousStatus: prev, req })\n } catch (err) {\n req.payload.logger.error({ err, msg: `afterStatusChange hook failed for reservation ${doc.id}` })\n }\n }\n }\n\n if (next === config.statusMachine.confirmStatus && config.hooks?.afterBookingConfirm) {\n for (const hook of config.hooks.afterBookingConfirm) {\n try {\n await hook({ doc: doc as Record<string, unknown>, req })\n } catch (err) {\n req.payload.logger.error({ err, msg: `afterBookingConfirm hook failed for reservation ${doc.id}` })\n }\n }\n }\n if (next === config.statusMachine.cancelStatus && config.hooks?.afterBookingCancel) {\n for (const hook of config.hooks.afterBookingCancel) {\n try {\n await hook({ doc: doc as Record<string, unknown>, req })\n } catch (err) {\n req.payload.logger.error({ err, msg: `afterBookingCancel hook failed for reservation ${doc.id}` })\n }\n }\n }\n\n return doc\n }\n"],"names":["onStatusChange","config","context","doc","operation","previousDoc","req","skipReservationHooks","status","prev","next","hooks","afterStatusChange","hook","newStatus","previousStatus","err","payload","logger","error","msg","id","statusMachine","confirmStatus","afterBookingConfirm","cancelStatus","afterBookingCancel"],"mappings":"AAIA,OAAO,MAAMA,iBACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,SAAS,EAAEC,WAAW,EAAEC,GAAG,EAAE;QAClD,IAAIJ,SAASK,sBAAsB;YAAC,OAAOJ;QAAG;QAC9C,yEAAyE;QACzE,4EAA4E;QAC5E,oBAAoB;QACpB,IAAIC,cAAc,UAAU;YAAC,OAAOD;QAAG;QACvC,IAAI,CAACE,aAAaG,UAAUH,YAAYG,MAAM,KAAKL,IAAIK,MAAM,EAAE;YAAC,OAAOL;QAAG;QAE1E,MAAMM,OAAOJ,YAAYG,MAAM;QAC/B,MAAME,OAAOP,IAAIK,MAAM;QAEvB,IAAIP,OAAOU,KAAK,EAAEC,mBAAmB;YACnC,KAAK,MAAMC,QAAQZ,OAAOU,KAAK,CAACC,iBAAiB,CAAE;gBACjD,IAAI;oBACF,MAAMC,KAAK;wBAAEV,KAAKA;wBAAgCW,WAAWJ;wBAAMK,gBAAgBN;wBAAMH;oBAAI;gBAC/F,EAAE,OAAOU,KAAK;oBACZV,IAAIW,OAAO,CAACC,MAAM,CAACC,KAAK,CAAC;wBAAEH;wBAAKI,KAAK,CAAC,8CAA8C,EAAEjB,IAAIkB,EAAE,EAAE;oBAAC;gBACjG;YACF;QACF;QAEA,IAAIX,SAAST,OAAOqB,aAAa,CAACC,aAAa,IAAItB,OAAOU,KAAK,EAAEa,qBAAqB;YACpF,KAAK,MAAMX,QAAQZ,OAAOU,KAAK,CAACa,mBAAmB,CAAE;gBACnD,IAAI;oBACF,MAAMX,KAAK;wBAAEV,KAAKA;wBAAgCG;oBAAI;gBACxD,EAAE,OAAOU,KAAK;oBACZV,IAAIW,OAAO,CAACC,MAAM,CAACC,KAAK,CAAC;wBAAEH;wBAAKI,KAAK,CAAC,gDAAgD,EAAEjB,IAAIkB,EAAE,EAAE;oBAAC;gBACnG;YACF;QACF;QACA,IAAIX,SAAST,OAAOqB,aAAa,CAACG,YAAY,IAAIxB,OAAOU,KAAK,EAAEe,oBAAoB;YAClF,KAAK,MAAMb,QAAQZ,OAAOU,KAAK,CAACe,kBAAkB,CAAE;gBAClD,IAAI;oBACF,MAAMb,KAAK;wBAAEV,KAAKA;wBAAgCG;oBAAI;gBACxD,EAAE,OAAOU,KAAK;oBACZV,IAAIW,OAAO,CAACC,MAAM,CAACC,KAAK,CAAC;wBAAEH;wBAAKI,KAAK,CAAC,+CAA+C,EAAEjB,IAAIkB,EAAE,EAAE;oBAAC;gBAClG;YACF;QACF;QAEA,OAAOlB;IACT,EAAC"}
@@ -9,8 +9,9 @@ export const validateCancellation = (config)=>({ context, data, operation, origi
9
9
  }
10
10
  const newStatus = data?.status;
11
11
  const previousStatus = originalDoc?.status;
12
- // Only check when transitioning to cancelled
13
- if (newStatus !== 'cancelled' || previousStatus === 'cancelled') {
12
+ // Only check when transitioning to the cancel status
13
+ const cancelStatus = config.statusMachine.cancelStatus;
14
+ if (newStatus !== cancelStatus || previousStatus === cancelStatus) {
14
15
  return data;
15
16
  }
16
17
  const startTime = data?.startTime ?? originalDoc?.startTime;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/reservations/validateCancellation.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { PluginT } from '../../translations/index.js'\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { hoursUntil } from '../../utilities/slotUtils.js'\n\nexport const validateCancellation =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n ({ context, data, operation, originalDoc, req }) => {\n if (context?.skipReservationHooks) {return data}\n\n if (operation !== 'update') {return data}\n\n const newStatus = data?.status\n const previousStatus = originalDoc?.status\n\n // Only check when transitioning to cancelled\n if (newStatus !== 'cancelled' || previousStatus === 'cancelled') {return data}\n\n const startTime = data?.startTime ?? originalDoc?.startTime\n if (!startTime) {return data}\n\n const startDate = new Date(startTime)\n const hours = hoursUntil(startDate)\n\n if (hours > 0 && hours < config.cancellationNoticePeriod) {\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorCancellationNotice', {\n hours: String(Math.round(hours)),\n period: String(config.cancellationNoticePeriod),\n }),\n path: 'status',\n },\n ],\n })\n }\n\n return data\n }\n"],"names":["ValidationError","hoursUntil","validateCancellation","config","context","data","operation","originalDoc","req","skipReservationHooks","newStatus","status","previousStatus","startTime","startDate","Date","hours","cancellationNoticePeriod","errors","message","t","String","Math","round","period","path"],"mappings":"AAEA,SAASA,eAAe,QAAQ,UAAS;AAKzC,SAASC,UAAU,QAAQ,+BAA8B;AAEzD,OAAO,MAAMC,uBACX,CAACC,SACD,CAAC,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,WAAW,EAAEC,GAAG,EAAE;QAC7C,IAAIJ,SAASK,sBAAsB;YAAC,OAAOJ;QAAI;QAE/C,IAAIC,cAAc,UAAU;YAAC,OAAOD;QAAI;QAExC,MAAMK,YAAYL,MAAMM;QACxB,MAAMC,iBAAiBL,aAAaI;QAEpC,6CAA6C;QAC7C,IAAID,cAAc,eAAeE,mBAAmB,aAAa;YAAC,OAAOP;QAAI;QAE7E,MAAMQ,YAAYR,MAAMQ,aAAaN,aAAaM;QAClD,IAAI,CAACA,WAAW;YAAC,OAAOR;QAAI;QAE5B,MAAMS,YAAY,IAAIC,KAAKF;QAC3B,MAAMG,QAAQf,WAAWa;QAEzB,IAAIE,QAAQ,KAAKA,QAAQb,OAAOc,wBAAwB,EAAE;YACxD,MAAM,IAAIjB,gBAAgB;gBACxBkB,QAAQ;oBACN;wBACEC,SAAS,AAACX,IAAIY,CAAC,CAAa,uCAAuC;4BACjEJ,OAAOK,OAAOC,KAAKC,KAAK,CAACP;4BACzBQ,QAAQH,OAAOlB,OAAOc,wBAAwB;wBAChD;wBACAQ,MAAM;oBACR;iBACD;YACH;QACF;QAEA,OAAOpB;IACT,EAAC"}
1
+ {"version":3,"sources":["../../../src/hooks/reservations/validateCancellation.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { PluginT } from '../../translations/index.js'\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { hoursUntil } from '../../utilities/slotUtils.js'\n\nexport const validateCancellation =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n ({ context, data, operation, originalDoc, req }) => {\n if (context?.skipReservationHooks) {return data}\n\n if (operation !== 'update') {return data}\n\n const newStatus = data?.status\n const previousStatus = originalDoc?.status\n\n // Only check when transitioning to the cancel status\n const cancelStatus = config.statusMachine.cancelStatus\n if (newStatus !== cancelStatus || previousStatus === cancelStatus) {return data}\n\n const startTime = data?.startTime ?? originalDoc?.startTime\n if (!startTime) {return data}\n\n const startDate = new Date(startTime)\n const hours = hoursUntil(startDate)\n\n if (hours > 0 && hours < config.cancellationNoticePeriod) {\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorCancellationNotice', {\n hours: String(Math.round(hours)),\n period: String(config.cancellationNoticePeriod),\n }),\n path: 'status',\n },\n ],\n })\n }\n\n return data\n }\n"],"names":["ValidationError","hoursUntil","validateCancellation","config","context","data","operation","originalDoc","req","skipReservationHooks","newStatus","status","previousStatus","cancelStatus","statusMachine","startTime","startDate","Date","hours","cancellationNoticePeriod","errors","message","t","String","Math","round","period","path"],"mappings":"AAEA,SAASA,eAAe,QAAQ,UAAS;AAKzC,SAASC,UAAU,QAAQ,+BAA8B;AAEzD,OAAO,MAAMC,uBACX,CAACC,SACD,CAAC,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,WAAW,EAAEC,GAAG,EAAE;QAC7C,IAAIJ,SAASK,sBAAsB;YAAC,OAAOJ;QAAI;QAE/C,IAAIC,cAAc,UAAU;YAAC,OAAOD;QAAI;QAExC,MAAMK,YAAYL,MAAMM;QACxB,MAAMC,iBAAiBL,aAAaI;QAEpC,qDAAqD;QACrD,MAAME,eAAeV,OAAOW,aAAa,CAACD,YAAY;QACtD,IAAIH,cAAcG,gBAAgBD,mBAAmBC,cAAc;YAAC,OAAOR;QAAI;QAE/E,MAAMU,YAAYV,MAAMU,aAAaR,aAAaQ;QAClD,IAAI,CAACA,WAAW;YAAC,OAAOV;QAAI;QAE5B,MAAMW,YAAY,IAAIC,KAAKF;QAC3B,MAAMG,QAAQjB,WAAWe;QAEzB,IAAIE,QAAQ,KAAKA,QAAQf,OAAOgB,wBAAwB,EAAE;YACxD,MAAM,IAAInB,gBAAgB;gBACxBoB,QAAQ;oBACN;wBACEC,SAAS,AAACb,IAAIc,CAAC,CAAa,uCAAuC;4BACjEJ,OAAOK,OAAOC,KAAKC,KAAK,CAACP;4BACzBQ,QAAQH,OAAOpB,OAAOgB,wBAAwB;wBAChD;wBACAQ,MAAM;oBACR;iBACD;YACH;QACF;QAEA,OAAOtB;IACT,EAAC"}
@@ -1,11 +1,25 @@
1
1
  import { ValidationError } from 'payload';
2
2
  import { checkAvailability } from '../../services/AvailabilityService.js';
3
- import { resolveReservationItems } from '../../utilities/resolveReservationItems.js';
3
+ import { mergeReservationData, schedulingFieldsChanged } from '../../utilities/reservationChanges.js';
4
+ import { extractId, resolveReservationItems } from '../../utilities/resolveReservationItems.js';
4
5
  export const validateConflicts = (config)=>async ({ context, data, operation, originalDoc, req })=>{
5
6
  if (context?.skipReservationHooks) {
6
7
  return data;
7
8
  }
8
- const items = resolveReservationItems(data);
9
+ const isUpdate = operation === 'update';
10
+ // Skip when an update touches no scheduling-relevant field, so reservations
11
+ // booked under older buffers/schedules can still take benign edits.
12
+ if (isUpdate && !schedulingFieldsChanged({
13
+ blockingStatuses: config.statusMachine.blockingStatuses,
14
+ data: data,
15
+ originalDoc: originalDoc
16
+ })) {
17
+ return data;
18
+ }
19
+ // Validate the merged document — Payload usually backfills update patches
20
+ // from originalDoc before beforeChange, but the hook must not rely on it.
21
+ const source = isUpdate ? mergeReservationData(data, originalDoc) : data;
22
+ const items = resolveReservationItems(source);
9
23
  if (items.length === 0) {
10
24
  return data;
11
25
  }
@@ -15,7 +29,7 @@ export const validateConflicts = (config)=>async ({ context, data, operation, or
15
29
  continue;
16
30
  }
17
31
  // Fetch buffer times from the item's own service (not just the primary)
18
- const itemServiceId = item.service ?? (typeof data?.service === 'object' ? data.service.id : data?.service);
32
+ const itemServiceId = item.service ?? extractId(source.service);
19
33
  let bufferBefore = config.defaultBufferTime;
20
34
  let bufferAfter = config.defaultBufferTime;
21
35
  if (itemServiceId) {
@@ -34,18 +48,23 @@ export const validateConflicts = (config)=>async ({ context, data, operation, or
34
48
  // Use defaults if service lookup fails
35
49
  }
36
50
  }
51
+ // Other items of this same booking on the same resource count as occupancy
52
+ // so two items can't double-book one resource within one create (review A5).
53
+ const siblingItems = items.filter((other, j)=>j !== i && other.resource === item.resource);
37
54
  const result = await checkAvailability({
38
55
  blockingStatuses: config.statusMachine.blockingStatuses,
39
56
  bufferAfter,
40
57
  bufferBefore,
41
58
  endTime: new Date(item.endTime),
42
- excludeReservationId: operation === 'update' ? originalDoc?.id : undefined,
59
+ excludeReservationId: isUpdate ? originalDoc?.id : undefined,
43
60
  guestCount: item.guestCount,
44
61
  payload: req.payload,
45
62
  req,
46
63
  reservationSlug: config.slugs.reservations,
47
64
  resourceId: item.resource,
48
65
  resourceSlug: config.slugs.resources,
66
+ servicesSlug: config.slugs.services,
67
+ siblingItems,
49
68
  startTime: new Date(item.startTime)
50
69
  });
51
70
  if (!result.available) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/reservations/validateConflicts.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { PluginT } from '../../translations/index.js'\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { checkAvailability } from '../../services/AvailabilityService.js'\nimport { resolveReservationItems } from '../../utilities/resolveReservationItems.js'\n\nexport const validateConflicts =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, operation, originalDoc, req }) => {\n if (context?.skipReservationHooks) {return data}\n\n const items = resolveReservationItems(data as Record<string, unknown>)\n\n if (items.length === 0) {return data}\n\n for (let i = 0; i < items.length; i++) {\n const item = items[i]\n if (!item.endTime) {continue}\n\n // Fetch buffer times from the item's own service (not just the primary)\n const itemServiceId = item.service\n ?? (typeof data?.service === 'object' ? data.service.id : data?.service)\n let bufferBefore = config.defaultBufferTime\n let bufferAfter = config.defaultBufferTime\n\n if (itemServiceId) {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const service = await (req.payload.findByID as any)({\n id: itemServiceId,\n collection: config.slugs.services,\n req,\n })\n if (service) {\n bufferBefore = (service.bufferTimeBefore as number) ?? config.defaultBufferTime\n bufferAfter = (service.bufferTimeAfter as number) ?? config.defaultBufferTime\n }\n } catch {\n // Use defaults if service lookup fails\n }\n }\n\n const result = await checkAvailability({\n blockingStatuses: config.statusMachine.blockingStatuses,\n bufferAfter,\n bufferBefore,\n endTime: new Date(item.endTime),\n excludeReservationId: operation === 'update' ? originalDoc?.id : undefined,\n guestCount: item.guestCount,\n payload: req.payload,\n req,\n reservationSlug: config.slugs.reservations,\n resourceId: item.resource,\n resourceSlug: config.slugs.resources,\n startTime: new Date(item.startTime),\n })\n\n if (!result.available) {\n throw new ValidationError({\n errors: [\n {\n message: result.reason ?? (req.t as PluginT)('reservation:errorConflict'),\n path: items.length > 1 ? `items.${i}.startTime` : 'startTime',\n },\n ],\n })\n }\n }\n\n return data\n }\n"],"names":["ValidationError","checkAvailability","resolveReservationItems","validateConflicts","config","context","data","operation","originalDoc","req","skipReservationHooks","items","length","i","item","endTime","itemServiceId","service","id","bufferBefore","defaultBufferTime","bufferAfter","payload","findByID","collection","slugs","services","bufferTimeBefore","bufferTimeAfter","result","blockingStatuses","statusMachine","Date","excludeReservationId","undefined","guestCount","reservationSlug","reservations","resourceId","resource","resourceSlug","resources","startTime","available","errors","message","reason","t","path"],"mappings":"AAEA,SAASA,eAAe,QAAQ,UAAS;AAKzC,SAASC,iBAAiB,QAAQ,wCAAuC;AACzE,SAASC,uBAAuB,QAAQ,6CAA4C;AAEpF,OAAO,MAAMC,oBACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,WAAW,EAAEC,GAAG,EAAE;QACnD,IAAIJ,SAASK,sBAAsB;YAAC,OAAOJ;QAAI;QAE/C,MAAMK,QAAQT,wBAAwBI;QAEtC,IAAIK,MAAMC,MAAM,KAAK,GAAG;YAAC,OAAON;QAAI;QAEpC,IAAK,IAAIO,IAAI,GAAGA,IAAIF,MAAMC,MAAM,EAAEC,IAAK;YACrC,MAAMC,OAAOH,KAAK,CAACE,EAAE;YACrB,IAAI,CAACC,KAAKC,OAAO,EAAE;gBAAC;YAAQ;YAE5B,wEAAwE;YACxE,MAAMC,gBAAgBF,KAAKG,OAAO,IAC5B,CAAA,OAAOX,MAAMW,YAAY,WAAWX,KAAKW,OAAO,CAACC,EAAE,GAAGZ,MAAMW,OAAM;YACxE,IAAIE,eAAef,OAAOgB,iBAAiB;YAC3C,IAAIC,cAAcjB,OAAOgB,iBAAiB;YAE1C,IAAIJ,eAAe;gBACjB,IAAI;oBACF,8DAA8D;oBAC9D,MAAMC,UAAU,MAAM,AAACR,IAAIa,OAAO,CAACC,QAAQ,CAAS;wBAClDL,IAAIF;wBACJQ,YAAYpB,OAAOqB,KAAK,CAACC,QAAQ;wBACjCjB;oBACF;oBACA,IAAIQ,SAAS;wBACXE,eAAe,AAACF,QAAQU,gBAAgB,IAAevB,OAAOgB,iBAAiB;wBAC/EC,cAAc,AAACJ,QAAQW,eAAe,IAAexB,OAAOgB,iBAAiB;oBAC/E;gBACF,EAAE,OAAM;gBACN,uCAAuC;gBACzC;YACF;YAEA,MAAMS,SAAS,MAAM5B,kBAAkB;gBACrC6B,kBAAkB1B,OAAO2B,aAAa,CAACD,gBAAgB;gBACvDT;gBACAF;gBACAJ,SAAS,IAAIiB,KAAKlB,KAAKC,OAAO;gBAC9BkB,sBAAsB1B,cAAc,WAAWC,aAAaU,KAAKgB;gBACjEC,YAAYrB,KAAKqB,UAAU;gBAC3Bb,SAASb,IAAIa,OAAO;gBACpBb;gBACA2B,iBAAiBhC,OAAOqB,KAAK,CAACY,YAAY;gBAC1CC,YAAYxB,KAAKyB,QAAQ;gBACzBC,cAAcpC,OAAOqB,KAAK,CAACgB,SAAS;gBACpCC,WAAW,IAAIV,KAAKlB,KAAK4B,SAAS;YACpC;YAEA,IAAI,CAACb,OAAOc,SAAS,EAAE;gBACrB,MAAM,IAAI3C,gBAAgB;oBACxB4C,QAAQ;wBACN;4BACEC,SAAShB,OAAOiB,MAAM,IAAI,AAACrC,IAAIsC,CAAC,CAAa;4BAC7CC,MAAMrC,MAAMC,MAAM,GAAG,IAAI,CAAC,MAAM,EAAEC,EAAE,UAAU,CAAC,GAAG;wBACpD;qBACD;gBACH;YACF;QACF;QAEA,OAAOP;IACT,EAAC"}
1
+ {"version":3,"sources":["../../../src/hooks/reservations/validateConflicts.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { PluginT } from '../../translations/index.js'\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { checkAvailability } from '../../services/AvailabilityService.js'\nimport {\n mergeReservationData,\n schedulingFieldsChanged,\n} from '../../utilities/reservationChanges.js'\nimport { extractId, resolveReservationItems } from '../../utilities/resolveReservationItems.js'\n\nexport const validateConflicts =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, operation, originalDoc, req }) => {\n if (context?.skipReservationHooks) {\n return data\n }\n\n const isUpdate = operation === 'update'\n\n // Skip when an update touches no scheduling-relevant field, so reservations\n // booked under older buffers/schedules can still take benign edits.\n if (\n isUpdate &&\n !schedulingFieldsChanged({\n blockingStatuses: config.statusMachine.blockingStatuses,\n data: data as Record<string, unknown>,\n originalDoc: originalDoc as Record<string, unknown> | undefined,\n })\n ) {\n return data\n }\n\n // Validate the merged document — Payload usually backfills update patches\n // from originalDoc before beforeChange, but the hook must not rely on it.\n const source = isUpdate\n ? mergeReservationData(\n data as Record<string, unknown>,\n originalDoc as Record<string, unknown> | undefined,\n )\n : (data as Record<string, unknown>)\n\n const items = resolveReservationItems(source)\n\n if (items.length === 0) {\n return data\n }\n\n for (let i = 0; i < items.length; i++) {\n const item = items[i]\n if (!item.endTime) {\n continue\n }\n\n // Fetch buffer times from the item's own service (not just the primary)\n const itemServiceId = item.service ?? extractId(source.service)\n let bufferBefore = config.defaultBufferTime\n let bufferAfter = config.defaultBufferTime\n\n if (itemServiceId) {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const service = await (req.payload.findByID as any)({\n id: itemServiceId,\n collection: config.slugs.services,\n req,\n })\n if (service) {\n bufferBefore = (service.bufferTimeBefore as number) ?? config.defaultBufferTime\n bufferAfter = (service.bufferTimeAfter as number) ?? config.defaultBufferTime\n }\n } catch {\n // Use defaults if service lookup fails\n }\n }\n\n // Other items of this same booking on the same resource count as occupancy\n // so two items can't double-book one resource within one create (review A5).\n const siblingItems = items.filter((other, j) => j !== i && other.resource === item.resource)\n\n const result = await checkAvailability({\n blockingStatuses: config.statusMachine.blockingStatuses,\n bufferAfter,\n bufferBefore,\n endTime: new Date(item.endTime),\n excludeReservationId: isUpdate ? originalDoc?.id : undefined,\n guestCount: item.guestCount,\n payload: req.payload,\n req,\n reservationSlug: config.slugs.reservations,\n resourceId: item.resource,\n resourceSlug: config.slugs.resources,\n servicesSlug: config.slugs.services,\n siblingItems,\n startTime: new Date(item.startTime),\n })\n\n if (!result.available) {\n throw new ValidationError({\n errors: [\n {\n message: result.reason ?? (req.t as PluginT)('reservation:errorConflict'),\n path: items.length > 1 ? `items.${i}.startTime` : 'startTime',\n },\n ],\n })\n }\n }\n\n return data\n }\n"],"names":["ValidationError","checkAvailability","mergeReservationData","schedulingFieldsChanged","extractId","resolveReservationItems","validateConflicts","config","context","data","operation","originalDoc","req","skipReservationHooks","isUpdate","blockingStatuses","statusMachine","source","items","length","i","item","endTime","itemServiceId","service","bufferBefore","defaultBufferTime","bufferAfter","payload","findByID","id","collection","slugs","services","bufferTimeBefore","bufferTimeAfter","siblingItems","filter","other","j","resource","result","Date","excludeReservationId","undefined","guestCount","reservationSlug","reservations","resourceId","resourceSlug","resources","servicesSlug","startTime","available","errors","message","reason","t","path"],"mappings":"AAEA,SAASA,eAAe,QAAQ,UAAS;AAKzC,SAASC,iBAAiB,QAAQ,wCAAuC;AACzE,SACEC,oBAAoB,EACpBC,uBAAuB,QAClB,wCAAuC;AAC9C,SAASC,SAAS,EAAEC,uBAAuB,QAAQ,6CAA4C;AAE/F,OAAO,MAAMC,oBACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,WAAW,EAAEC,GAAG,EAAE;QACnD,IAAIJ,SAASK,sBAAsB;YACjC,OAAOJ;QACT;QAEA,MAAMK,WAAWJ,cAAc;QAE/B,4EAA4E;QAC5E,oEAAoE;QACpE,IACEI,YACA,CAACX,wBAAwB;YACvBY,kBAAkBR,OAAOS,aAAa,CAACD,gBAAgB;YACvDN,MAAMA;YACNE,aAAaA;QACf,IACA;YACA,OAAOF;QACT;QAEA,0EAA0E;QAC1E,0EAA0E;QAC1E,MAAMQ,SAASH,WACXZ,qBACEO,MACAE,eAEDF;QAEL,MAAMS,QAAQb,wBAAwBY;QAEtC,IAAIC,MAAMC,MAAM,KAAK,GAAG;YACtB,OAAOV;QACT;QAEA,IAAK,IAAIW,IAAI,GAAGA,IAAIF,MAAMC,MAAM,EAAEC,IAAK;YACrC,MAAMC,OAAOH,KAAK,CAACE,EAAE;YACrB,IAAI,CAACC,KAAKC,OAAO,EAAE;gBACjB;YACF;YAEA,wEAAwE;YACxE,MAAMC,gBAAgBF,KAAKG,OAAO,IAAIpB,UAAUa,OAAOO,OAAO;YAC9D,IAAIC,eAAelB,OAAOmB,iBAAiB;YAC3C,IAAIC,cAAcpB,OAAOmB,iBAAiB;YAE1C,IAAIH,eAAe;gBACjB,IAAI;oBACF,8DAA8D;oBAC9D,MAAMC,UAAU,MAAM,AAACZ,IAAIgB,OAAO,CAACC,QAAQ,CAAS;wBAClDC,IAAIP;wBACJQ,YAAYxB,OAAOyB,KAAK,CAACC,QAAQ;wBACjCrB;oBACF;oBACA,IAAIY,SAAS;wBACXC,eAAe,AAACD,QAAQU,gBAAgB,IAAe3B,OAAOmB,iBAAiB;wBAC/EC,cAAc,AAACH,QAAQW,eAAe,IAAe5B,OAAOmB,iBAAiB;oBAC/E;gBACF,EAAE,OAAM;gBACN,uCAAuC;gBACzC;YACF;YAEA,2EAA2E;YAC3E,6EAA6E;YAC7E,MAAMU,eAAelB,MAAMmB,MAAM,CAAC,CAACC,OAAOC,IAAMA,MAAMnB,KAAKkB,MAAME,QAAQ,KAAKnB,KAAKmB,QAAQ;YAE3F,MAAMC,SAAS,MAAMxC,kBAAkB;gBACrCc,kBAAkBR,OAAOS,aAAa,CAACD,gBAAgB;gBACvDY;gBACAF;gBACAH,SAAS,IAAIoB,KAAKrB,KAAKC,OAAO;gBAC9BqB,sBAAsB7B,WAAWH,aAAamB,KAAKc;gBACnDC,YAAYxB,KAAKwB,UAAU;gBAC3BjB,SAAShB,IAAIgB,OAAO;gBACpBhB;gBACAkC,iBAAiBvC,OAAOyB,KAAK,CAACe,YAAY;gBAC1CC,YAAY3B,KAAKmB,QAAQ;gBACzBS,cAAc1C,OAAOyB,KAAK,CAACkB,SAAS;gBACpCC,cAAc5C,OAAOyB,KAAK,CAACC,QAAQ;gBACnCG;gBACAgB,WAAW,IAAIV,KAAKrB,KAAK+B,SAAS;YACpC;YAEA,IAAI,CAACX,OAAOY,SAAS,EAAE;gBACrB,MAAM,IAAIrD,gBAAgB;oBACxBsD,QAAQ;wBACN;4BACEC,SAASd,OAAOe,MAAM,IAAI,AAAC5C,IAAI6C,CAAC,CAAa;4BAC7CC,MAAMxC,MAAMC,MAAM,GAAG,IAAI,CAAC,MAAM,EAAEC,EAAE,UAAU,CAAC,GAAG;wBACpD;qBACD;gBACH;YACF;QACF;QAEA,OAAOX;IACT,EAAC"}
@@ -83,10 +83,9 @@ export const validateGuestBooking = (config)=>async ({ context, data, operation,
83
83
  }
84
84
  }
85
85
  }
86
- // Generate a cancellation token the host project can deliver to the guest.
87
- if (!data.cancellationToken) {
88
- data.cancellationToken = randomUUID();
89
- }
86
+ // Always server-generate the cancellation token the host project delivers
87
+ // to the guest — never honor a caller-supplied value (it's a secret).
88
+ data.cancellationToken = randomUUID();
90
89
  return data;
91
90
  };
92
91
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/reservations/validateGuestBooking.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { randomUUID } from 'node:crypto'\nimport { ValidationError } from 'payload'\n\nimport type { PluginT } from '../../translations/index.js'\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { resolveGuestBookingAllowed } from '../../utilities/guestBooking.js'\n\ntype GuestData = { email?: string; name?: string; phone?: string }\n\nexport const validateGuestBooking =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, operation, req }) => {\n if (context?.skipReservationHooks) {\n return data\n }\n if (operation !== 'create') {\n return data\n }\n\n const customer = data?.customer\n const guest = data?.guest as GuestData | undefined\n const hasCustomer = customer != null && customer !== ''\n const hasGuest =\n guest != null && (Boolean(guest.name) || Boolean(guest.email) || Boolean(guest.phone))\n\n if (!hasCustomer && !hasGuest) {\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorGuestOrCustomerRequired'),\n path: 'customer',\n },\n ],\n })\n }\n\n if (hasCustomer && hasGuest) {\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorGuestAndCustomer'),\n path: 'guest',\n },\n ],\n })\n }\n\n if (hasCustomer) {\n return data\n }\n\n // Guest path\n if (!guest?.name) {\n throw new ValidationError({\n errors: [\n { message: (req.t as PluginT)('reservation:errorGuestNameRequired'), path: 'guest.name' },\n ],\n })\n }\n if (!guest.email && !guest.phone) {\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorGuestContactRequired'),\n path: 'guest.email',\n },\n ],\n })\n }\n\n // Gate by service — admins (non-customer collection users) bypass.\n const isAdmin = req.user != null && req.user.collection !== config.slugs.customers\n if (!isAdmin) {\n const serviceId =\n typeof data.service === 'object'\n ? (data.service as { id?: string } | null)?.id\n : data.service\n // `service` is a required field on the collection, so Payload's field\n // validation (which runs before this beforeChange hook) guarantees it is\n // present for any booking that reaches here. The guard is purely defensive.\n if (serviceId) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const service = await (req.payload.findByID as any)({\n id: serviceId,\n collection: config.slugs.services,\n depth: 0,\n req,\n })\n if (!resolveGuestBookingAllowed(service, config.allowGuestBooking)) {\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorGuestNotAllowed'),\n path: 'guest',\n },\n ],\n })\n }\n }\n }\n\n // Generate a cancellation token the host project can deliver to the guest.\n if (!data.cancellationToken) {\n data.cancellationToken = randomUUID()\n }\n\n return data\n }\n"],"names":["randomUUID","ValidationError","resolveGuestBookingAllowed","validateGuestBooking","config","context","data","operation","req","skipReservationHooks","customer","guest","hasCustomer","hasGuest","Boolean","name","email","phone","errors","message","t","path","isAdmin","user","collection","slugs","customers","serviceId","service","id","payload","findByID","services","depth","allowGuestBooking","cancellationToken"],"mappings":"AAEA,SAASA,UAAU,QAAQ,cAAa;AACxC,SAASC,eAAe,QAAQ,UAAS;AAKzC,SAASC,0BAA0B,QAAQ,kCAAiC;AAI5E,OAAO,MAAMC,uBACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,GAAG,EAAE;QACtC,IAAIH,SAASI,sBAAsB;YACjC,OAAOH;QACT;QACA,IAAIC,cAAc,UAAU;YAC1B,OAAOD;QACT;QAEA,MAAMI,WAAWJ,MAAMI;QACvB,MAAMC,QAAQL,MAAMK;QACpB,MAAMC,cAAcF,YAAY,QAAQA,aAAa;QACrD,MAAMG,WACJF,SAAS,QAASG,CAAAA,QAAQH,MAAMI,IAAI,KAAKD,QAAQH,MAAMK,KAAK,KAAKF,QAAQH,MAAMM,KAAK,CAAA;QAEtF,IAAI,CAACL,eAAe,CAACC,UAAU;YAC7B,MAAM,IAAIZ,gBAAgB;gBACxBiB,QAAQ;oBACN;wBACEC,SAAS,AAACX,IAAIY,CAAC,CAAa;wBAC5BC,MAAM;oBACR;iBACD;YACH;QACF;QAEA,IAAIT,eAAeC,UAAU;YAC3B,MAAM,IAAIZ,gBAAgB;gBACxBiB,QAAQ;oBACN;wBACEC,SAAS,AAACX,IAAIY,CAAC,CAAa;wBAC5BC,MAAM;oBACR;iBACD;YACH;QACF;QAEA,IAAIT,aAAa;YACf,OAAON;QACT;QAEA,aAAa;QACb,IAAI,CAACK,OAAOI,MAAM;YAChB,MAAM,IAAId,gBAAgB;gBACxBiB,QAAQ;oBACN;wBAAEC,SAAS,AAACX,IAAIY,CAAC,CAAa;wBAAuCC,MAAM;oBAAa;iBACzF;YACH;QACF;QACA,IAAI,CAACV,MAAMK,KAAK,IAAI,CAACL,MAAMM,KAAK,EAAE;YAChC,MAAM,IAAIhB,gBAAgB;gBACxBiB,QAAQ;oBACN;wBACEC,SAAS,AAACX,IAAIY,CAAC,CAAa;wBAC5BC,MAAM;oBACR;iBACD;YACH;QACF;QAEA,mEAAmE;QACnE,MAAMC,UAAUd,IAAIe,IAAI,IAAI,QAAQf,IAAIe,IAAI,CAACC,UAAU,KAAKpB,OAAOqB,KAAK,CAACC,SAAS;QAClF,IAAI,CAACJ,SAAS;YACZ,MAAMK,YACJ,OAAOrB,KAAKsB,OAAO,KAAK,WACnBtB,KAAKsB,OAAO,EAA6BC,KAC1CvB,KAAKsB,OAAO;YAClB,sEAAsE;YACtE,yEAAyE;YACzE,4EAA4E;YAC5E,IAAID,WAAW;gBACb,8DAA8D;gBAC9D,MAAMC,UAAU,MAAM,AAACpB,IAAIsB,OAAO,CAACC,QAAQ,CAAS;oBAClDF,IAAIF;oBACJH,YAAYpB,OAAOqB,KAAK,CAACO,QAAQ;oBACjCC,OAAO;oBACPzB;gBACF;gBACA,IAAI,CAACN,2BAA2B0B,SAASxB,OAAO8B,iBAAiB,GAAG;oBAClE,MAAM,IAAIjC,gBAAgB;wBACxBiB,QAAQ;4BACN;gCACEC,SAAS,AAACX,IAAIY,CAAC,CAAa;gCAC5BC,MAAM;4BACR;yBACD;oBACH;gBACF;YACF;QACF;QAEA,2EAA2E;QAC3E,IAAI,CAACf,KAAK6B,iBAAiB,EAAE;YAC3B7B,KAAK6B,iBAAiB,GAAGnC;QAC3B;QAEA,OAAOM;IACT,EAAC"}
1
+ {"version":3,"sources":["../../../src/hooks/reservations/validateGuestBooking.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { randomUUID } from 'node:crypto'\nimport { ValidationError } from 'payload'\n\nimport type { PluginT } from '../../translations/index.js'\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { resolveGuestBookingAllowed } from '../../utilities/guestBooking.js'\n\ntype GuestData = { email?: string; name?: string; phone?: string }\n\nexport const validateGuestBooking =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, operation, req }) => {\n if (context?.skipReservationHooks) {\n return data\n }\n if (operation !== 'create') {\n return data\n }\n\n const customer = data?.customer\n const guest = data?.guest as GuestData | undefined\n const hasCustomer = customer != null && customer !== ''\n const hasGuest =\n guest != null && (Boolean(guest.name) || Boolean(guest.email) || Boolean(guest.phone))\n\n if (!hasCustomer && !hasGuest) {\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorGuestOrCustomerRequired'),\n path: 'customer',\n },\n ],\n })\n }\n\n if (hasCustomer && hasGuest) {\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorGuestAndCustomer'),\n path: 'guest',\n },\n ],\n })\n }\n\n if (hasCustomer) {\n return data\n }\n\n // Guest path\n if (!guest?.name) {\n throw new ValidationError({\n errors: [\n { message: (req.t as PluginT)('reservation:errorGuestNameRequired'), path: 'guest.name' },\n ],\n })\n }\n if (!guest.email && !guest.phone) {\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorGuestContactRequired'),\n path: 'guest.email',\n },\n ],\n })\n }\n\n // Gate by service — admins (non-customer collection users) bypass.\n const isAdmin = req.user != null && req.user.collection !== config.slugs.customers\n if (!isAdmin) {\n const serviceId =\n typeof data.service === 'object'\n ? (data.service as { id?: string } | null)?.id\n : data.service\n // `service` is a required field on the collection, so Payload's field\n // validation (which runs before this beforeChange hook) guarantees it is\n // present for any booking that reaches here. The guard is purely defensive.\n if (serviceId) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const service = await (req.payload.findByID as any)({\n id: serviceId,\n collection: config.slugs.services,\n depth: 0,\n req,\n })\n if (!resolveGuestBookingAllowed(service, config.allowGuestBooking)) {\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorGuestNotAllowed'),\n path: 'guest',\n },\n ],\n })\n }\n }\n }\n\n // Always server-generate the cancellation token the host project delivers\n // to the guest — never honor a caller-supplied value (it's a secret).\n data.cancellationToken = randomUUID()\n\n return data\n }\n"],"names":["randomUUID","ValidationError","resolveGuestBookingAllowed","validateGuestBooking","config","context","data","operation","req","skipReservationHooks","customer","guest","hasCustomer","hasGuest","Boolean","name","email","phone","errors","message","t","path","isAdmin","user","collection","slugs","customers","serviceId","service","id","payload","findByID","services","depth","allowGuestBooking","cancellationToken"],"mappings":"AAEA,SAASA,UAAU,QAAQ,cAAa;AACxC,SAASC,eAAe,QAAQ,UAAS;AAKzC,SAASC,0BAA0B,QAAQ,kCAAiC;AAI5E,OAAO,MAAMC,uBACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,GAAG,EAAE;QACtC,IAAIH,SAASI,sBAAsB;YACjC,OAAOH;QACT;QACA,IAAIC,cAAc,UAAU;YAC1B,OAAOD;QACT;QAEA,MAAMI,WAAWJ,MAAMI;QACvB,MAAMC,QAAQL,MAAMK;QACpB,MAAMC,cAAcF,YAAY,QAAQA,aAAa;QACrD,MAAMG,WACJF,SAAS,QAASG,CAAAA,QAAQH,MAAMI,IAAI,KAAKD,QAAQH,MAAMK,KAAK,KAAKF,QAAQH,MAAMM,KAAK,CAAA;QAEtF,IAAI,CAACL,eAAe,CAACC,UAAU;YAC7B,MAAM,IAAIZ,gBAAgB;gBACxBiB,QAAQ;oBACN;wBACEC,SAAS,AAACX,IAAIY,CAAC,CAAa;wBAC5BC,MAAM;oBACR;iBACD;YACH;QACF;QAEA,IAAIT,eAAeC,UAAU;YAC3B,MAAM,IAAIZ,gBAAgB;gBACxBiB,QAAQ;oBACN;wBACEC,SAAS,AAACX,IAAIY,CAAC,CAAa;wBAC5BC,MAAM;oBACR;iBACD;YACH;QACF;QAEA,IAAIT,aAAa;YACf,OAAON;QACT;QAEA,aAAa;QACb,IAAI,CAACK,OAAOI,MAAM;YAChB,MAAM,IAAId,gBAAgB;gBACxBiB,QAAQ;oBACN;wBAAEC,SAAS,AAACX,IAAIY,CAAC,CAAa;wBAAuCC,MAAM;oBAAa;iBACzF;YACH;QACF;QACA,IAAI,CAACV,MAAMK,KAAK,IAAI,CAACL,MAAMM,KAAK,EAAE;YAChC,MAAM,IAAIhB,gBAAgB;gBACxBiB,QAAQ;oBACN;wBACEC,SAAS,AAACX,IAAIY,CAAC,CAAa;wBAC5BC,MAAM;oBACR;iBACD;YACH;QACF;QAEA,mEAAmE;QACnE,MAAMC,UAAUd,IAAIe,IAAI,IAAI,QAAQf,IAAIe,IAAI,CAACC,UAAU,KAAKpB,OAAOqB,KAAK,CAACC,SAAS;QAClF,IAAI,CAACJ,SAAS;YACZ,MAAMK,YACJ,OAAOrB,KAAKsB,OAAO,KAAK,WACnBtB,KAAKsB,OAAO,EAA6BC,KAC1CvB,KAAKsB,OAAO;YAClB,sEAAsE;YACtE,yEAAyE;YACzE,4EAA4E;YAC5E,IAAID,WAAW;gBACb,8DAA8D;gBAC9D,MAAMC,UAAU,MAAM,AAACpB,IAAIsB,OAAO,CAACC,QAAQ,CAAS;oBAClDF,IAAIF;oBACJH,YAAYpB,OAAOqB,KAAK,CAACO,QAAQ;oBACjCC,OAAO;oBACPzB;gBACF;gBACA,IAAI,CAACN,2BAA2B0B,SAASxB,OAAO8B,iBAAiB,GAAG;oBAClE,MAAM,IAAIjC,gBAAgB;wBACxBiB,QAAQ;4BACN;gCACEC,SAAS,AAACX,IAAIY,CAAC,CAAa;gCAC5BC,MAAM;4BACR;yBACD;oBACH;gBACF;YACF;QACF;QAEA,0EAA0E;QAC1E,sEAAsE;QACtEf,KAAK6B,iBAAiB,GAAGnC;QAEzB,OAAOM;IACT,EAAC"}
@@ -56,7 +56,7 @@ export const validateStatusTransition = (config)=>async ({ context, data, operat
56
56
  });
57
57
  }
58
58
  // Call beforeBookingConfirm plugin hooks
59
- if (newStatus === 'confirmed' && config.hooks?.beforeBookingConfirm) {
59
+ if (newStatus === config.statusMachine.confirmStatus && config.hooks?.beforeBookingConfirm) {
60
60
  for (const hook of config.hooks.beforeBookingConfirm){
61
61
  await hook({
62
62
  doc: {
@@ -69,7 +69,7 @@ export const validateStatusTransition = (config)=>async ({ context, data, operat
69
69
  }
70
70
  }
71
71
  // Call beforeBookingCancel plugin hooks
72
- if (newStatus === 'cancelled' && config.hooks?.beforeBookingCancel) {
72
+ if (newStatus === config.statusMachine.cancelStatus && config.hooks?.beforeBookingCancel) {
73
73
  for (const hook of config.hooks.beforeBookingCancel){
74
74
  await hook({
75
75
  doc: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/reservations/validateStatusTransition.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { PluginT } from '../../translations/index.js'\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { validateTransition } from '../../services/AvailabilityService.js'\nimport { isPrivilegedUser } from '../../utilities/userRoles.js'\n\nexport const validateStatusTransition =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, operation, originalDoc, req }) => {\n if (context?.skipReservationHooks) {return data}\n\n const newStatus = data?.status as string | undefined\n const { statusMachine } = config\n\n if (operation === 'create') {\n // context.allowConfirmedOnCreate is the escape hatch for payment hooks\n // that need to create confirmed reservations programmatically\n const hasContextBypass = Boolean(context?.allowConfirmedOnCreate)\n // Staff/admin detection: collection-based, with a role-based fallback for\n // single-collection deployments (userCollection set). See isPrivilegedUser.\n const isAdmin = isPrivilegedUser(req.user, config)\n const defaultStatus = statusMachine.defaultStatus\n const nonDefaultStatuses = statusMachine.transitions[defaultStatus] ?? []\n const allowedOnCreate: string[] = (isAdmin || hasContextBypass)\n ? [defaultStatus, ...nonDefaultStatuses]\n : [defaultStatus]\n\n if (newStatus && !allowedOnCreate.includes(newStatus)) {\n const allowed = allowedOnCreate.map((s) => `\"${s}\"`).join(' or ')\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorInvalidCreateStatus', { allowed }),\n path: 'status',\n },\n ],\n })\n }\n\n return data\n }\n\n // On update\n if (operation === 'update' && newStatus) {\n const previousStatus = originalDoc?.status as string | undefined\n\n if (previousStatus && previousStatus !== newStatus) {\n const result = validateTransition(previousStatus, newStatus, statusMachine)\n\n if (!result.valid) {\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorInvalidTransition', {\n from: previousStatus,\n to: newStatus,\n }),\n path: 'status',\n },\n ],\n })\n }\n\n // Call beforeBookingConfirm plugin hooks\n if (newStatus === 'confirmed' && config.hooks?.beforeBookingConfirm) {\n for (const hook of config.hooks.beforeBookingConfirm) {\n await hook({\n doc: { ...(originalDoc as Record<string, unknown>), ...(data as Record<string, unknown>) },\n newStatus,\n req,\n })\n }\n }\n\n // Call beforeBookingCancel plugin hooks\n if (newStatus === 'cancelled' && config.hooks?.beforeBookingCancel) {\n for (const hook of config.hooks.beforeBookingCancel) {\n await hook({\n doc: { ...(originalDoc as Record<string, unknown>), ...(data as Record<string, unknown>) },\n reason: data?.cancellationReason as string | undefined,\n req,\n })\n }\n }\n }\n }\n\n return data\n }\n"],"names":["ValidationError","validateTransition","isPrivilegedUser","validateStatusTransition","config","context","data","operation","originalDoc","req","skipReservationHooks","newStatus","status","statusMachine","hasContextBypass","Boolean","allowConfirmedOnCreate","isAdmin","user","defaultStatus","nonDefaultStatuses","transitions","allowedOnCreate","includes","allowed","map","s","join","errors","message","t","path","previousStatus","result","valid","from","to","hooks","beforeBookingConfirm","hook","doc","beforeBookingCancel","reason","cancellationReason"],"mappings":"AAEA,SAASA,eAAe,QAAQ,UAAS;AAKzC,SAASC,kBAAkB,QAAQ,wCAAuC;AAC1E,SAASC,gBAAgB,QAAQ,+BAA8B;AAE/D,OAAO,MAAMC,2BACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,WAAW,EAAEC,GAAG,EAAE;QACnD,IAAIJ,SAASK,sBAAsB;YAAC,OAAOJ;QAAI;QAE/C,MAAMK,YAAYL,MAAMM;QACxB,MAAM,EAAEC,aAAa,EAAE,GAAGT;QAE1B,IAAIG,cAAc,UAAU;YAC1B,uEAAuE;YACvE,8DAA8D;YAC9D,MAAMO,mBAAmBC,QAAQV,SAASW;YAC1C,0EAA0E;YAC1E,4EAA4E;YAC5E,MAAMC,UAAUf,iBAAiBO,IAAIS,IAAI,EAAEd;YAC3C,MAAMe,gBAAgBN,cAAcM,aAAa;YACjD,MAAMC,qBAAqBP,cAAcQ,WAAW,CAACF,cAAc,IAAI,EAAE;YACzE,MAAMG,kBAA4B,AAACL,WAAWH,mBAC1C;gBAACK;mBAAkBC;aAAmB,GACtC;gBAACD;aAAc;YAEnB,IAAIR,aAAa,CAACW,gBAAgBC,QAAQ,CAACZ,YAAY;gBACrD,MAAMa,UAAUF,gBAAgBG,GAAG,CAAC,CAACC,IAAM,CAAC,CAAC,EAAEA,EAAE,CAAC,CAAC,EAAEC,IAAI,CAAC;gBAC1D,MAAM,IAAI3B,gBAAgB;oBACxB4B,QAAQ;wBACN;4BACEC,SAAS,AAACpB,IAAIqB,CAAC,CAAa,wCAAwC;gCAAEN;4BAAQ;4BAC9EO,MAAM;wBACR;qBACD;gBACH;YACF;YAEA,OAAOzB;QACT;QAEA,YAAY;QACZ,IAAIC,cAAc,YAAYI,WAAW;YACvC,MAAMqB,iBAAiBxB,aAAaI;YAEpC,IAAIoB,kBAAkBA,mBAAmBrB,WAAW;gBAClD,MAAMsB,SAAShC,mBAAmB+B,gBAAgBrB,WAAWE;gBAE7D,IAAI,CAACoB,OAAOC,KAAK,EAAE;oBACjB,MAAM,IAAIlC,gBAAgB;wBACxB4B,QAAQ;4BACN;gCACEC,SAAS,AAACpB,IAAIqB,CAAC,CAAa,sCAAsC;oCAChEK,MAAMH;oCACNI,IAAIzB;gCACN;gCACAoB,MAAM;4BACR;yBACD;oBACH;gBACF;gBAEA,yCAAyC;gBACzC,IAAIpB,cAAc,eAAeP,OAAOiC,KAAK,EAAEC,sBAAsB;oBACnE,KAAK,MAAMC,QAAQnC,OAAOiC,KAAK,CAACC,oBAAoB,CAAE;wBACpD,MAAMC,KAAK;4BACTC,KAAK;gCAAE,GAAIhC,WAAW;gCAA8B,GAAIF,IAAI;4BAA6B;4BACzFK;4BACAF;wBACF;oBACF;gBACF;gBAEA,wCAAwC;gBACxC,IAAIE,cAAc,eAAeP,OAAOiC,KAAK,EAAEI,qBAAqB;oBAClE,KAAK,MAAMF,QAAQnC,OAAOiC,KAAK,CAACI,mBAAmB,CAAE;wBACnD,MAAMF,KAAK;4BACTC,KAAK;gCAAE,GAAIhC,WAAW;gCAA8B,GAAIF,IAAI;4BAA6B;4BACzFoC,QAAQpC,MAAMqC;4BACdlC;wBACF;oBACF;gBACF;YACF;QACF;QAEA,OAAOH;IACT,EAAC"}
1
+ {"version":3,"sources":["../../../src/hooks/reservations/validateStatusTransition.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { PluginT } from '../../translations/index.js'\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { validateTransition } from '../../services/AvailabilityService.js'\nimport { isPrivilegedUser } from '../../utilities/userRoles.js'\n\nexport const validateStatusTransition =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, operation, originalDoc, req }) => {\n if (context?.skipReservationHooks) {return data}\n\n const newStatus = data?.status as string | undefined\n const { statusMachine } = config\n\n if (operation === 'create') {\n // context.allowConfirmedOnCreate is the escape hatch for payment hooks\n // that need to create confirmed reservations programmatically\n const hasContextBypass = Boolean(context?.allowConfirmedOnCreate)\n // Staff/admin detection: collection-based, with a role-based fallback for\n // single-collection deployments (userCollection set). See isPrivilegedUser.\n const isAdmin = isPrivilegedUser(req.user, config)\n const defaultStatus = statusMachine.defaultStatus\n const nonDefaultStatuses = statusMachine.transitions[defaultStatus] ?? []\n const allowedOnCreate: string[] = (isAdmin || hasContextBypass)\n ? [defaultStatus, ...nonDefaultStatuses]\n : [defaultStatus]\n\n if (newStatus && !allowedOnCreate.includes(newStatus)) {\n const allowed = allowedOnCreate.map((s) => `\"${s}\"`).join(' or ')\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorInvalidCreateStatus', { allowed }),\n path: 'status',\n },\n ],\n })\n }\n\n return data\n }\n\n // On update\n if (operation === 'update' && newStatus) {\n const previousStatus = originalDoc?.status as string | undefined\n\n if (previousStatus && previousStatus !== newStatus) {\n const result = validateTransition(previousStatus, newStatus, statusMachine)\n\n if (!result.valid) {\n throw new ValidationError({\n errors: [\n {\n message: (req.t as PluginT)('reservation:errorInvalidTransition', {\n from: previousStatus,\n to: newStatus,\n }),\n path: 'status',\n },\n ],\n })\n }\n\n // Call beforeBookingConfirm plugin hooks\n if (newStatus === config.statusMachine.confirmStatus && config.hooks?.beforeBookingConfirm) {\n for (const hook of config.hooks.beforeBookingConfirm) {\n await hook({\n doc: { ...(originalDoc as Record<string, unknown>), ...(data as Record<string, unknown>) },\n newStatus,\n req,\n })\n }\n }\n\n // Call beforeBookingCancel plugin hooks\n if (newStatus === config.statusMachine.cancelStatus && config.hooks?.beforeBookingCancel) {\n for (const hook of config.hooks.beforeBookingCancel) {\n await hook({\n doc: { ...(originalDoc as Record<string, unknown>), ...(data as Record<string, unknown>) },\n reason: data?.cancellationReason as string | undefined,\n req,\n })\n }\n }\n }\n }\n\n return data\n }\n"],"names":["ValidationError","validateTransition","isPrivilegedUser","validateStatusTransition","config","context","data","operation","originalDoc","req","skipReservationHooks","newStatus","status","statusMachine","hasContextBypass","Boolean","allowConfirmedOnCreate","isAdmin","user","defaultStatus","nonDefaultStatuses","transitions","allowedOnCreate","includes","allowed","map","s","join","errors","message","t","path","previousStatus","result","valid","from","to","confirmStatus","hooks","beforeBookingConfirm","hook","doc","cancelStatus","beforeBookingCancel","reason","cancellationReason"],"mappings":"AAEA,SAASA,eAAe,QAAQ,UAAS;AAKzC,SAASC,kBAAkB,QAAQ,wCAAuC;AAC1E,SAASC,gBAAgB,QAAQ,+BAA8B;AAE/D,OAAO,MAAMC,2BACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,WAAW,EAAEC,GAAG,EAAE;QACnD,IAAIJ,SAASK,sBAAsB;YAAC,OAAOJ;QAAI;QAE/C,MAAMK,YAAYL,MAAMM;QACxB,MAAM,EAAEC,aAAa,EAAE,GAAGT;QAE1B,IAAIG,cAAc,UAAU;YAC1B,uEAAuE;YACvE,8DAA8D;YAC9D,MAAMO,mBAAmBC,QAAQV,SAASW;YAC1C,0EAA0E;YAC1E,4EAA4E;YAC5E,MAAMC,UAAUf,iBAAiBO,IAAIS,IAAI,EAAEd;YAC3C,MAAMe,gBAAgBN,cAAcM,aAAa;YACjD,MAAMC,qBAAqBP,cAAcQ,WAAW,CAACF,cAAc,IAAI,EAAE;YACzE,MAAMG,kBAA4B,AAACL,WAAWH,mBAC1C;gBAACK;mBAAkBC;aAAmB,GACtC;gBAACD;aAAc;YAEnB,IAAIR,aAAa,CAACW,gBAAgBC,QAAQ,CAACZ,YAAY;gBACrD,MAAMa,UAAUF,gBAAgBG,GAAG,CAAC,CAACC,IAAM,CAAC,CAAC,EAAEA,EAAE,CAAC,CAAC,EAAEC,IAAI,CAAC;gBAC1D,MAAM,IAAI3B,gBAAgB;oBACxB4B,QAAQ;wBACN;4BACEC,SAAS,AAACpB,IAAIqB,CAAC,CAAa,wCAAwC;gCAAEN;4BAAQ;4BAC9EO,MAAM;wBACR;qBACD;gBACH;YACF;YAEA,OAAOzB;QACT;QAEA,YAAY;QACZ,IAAIC,cAAc,YAAYI,WAAW;YACvC,MAAMqB,iBAAiBxB,aAAaI;YAEpC,IAAIoB,kBAAkBA,mBAAmBrB,WAAW;gBAClD,MAAMsB,SAAShC,mBAAmB+B,gBAAgBrB,WAAWE;gBAE7D,IAAI,CAACoB,OAAOC,KAAK,EAAE;oBACjB,MAAM,IAAIlC,gBAAgB;wBACxB4B,QAAQ;4BACN;gCACEC,SAAS,AAACpB,IAAIqB,CAAC,CAAa,sCAAsC;oCAChEK,MAAMH;oCACNI,IAAIzB;gCACN;gCACAoB,MAAM;4BACR;yBACD;oBACH;gBACF;gBAEA,yCAAyC;gBACzC,IAAIpB,cAAcP,OAAOS,aAAa,CAACwB,aAAa,IAAIjC,OAAOkC,KAAK,EAAEC,sBAAsB;oBAC1F,KAAK,MAAMC,QAAQpC,OAAOkC,KAAK,CAACC,oBAAoB,CAAE;wBACpD,MAAMC,KAAK;4BACTC,KAAK;gCAAE,GAAIjC,WAAW;gCAA8B,GAAIF,IAAI;4BAA6B;4BACzFK;4BACAF;wBACF;oBACF;gBACF;gBAEA,wCAAwC;gBACxC,IAAIE,cAAcP,OAAOS,aAAa,CAAC6B,YAAY,IAAItC,OAAOkC,KAAK,EAAEK,qBAAqB;oBACxF,KAAK,MAAMH,QAAQpC,OAAOkC,KAAK,CAACK,mBAAmB,CAAE;wBACnD,MAAMH,KAAK;4BACTC,KAAK;gCAAE,GAAIjC,WAAW;gCAA8B,GAAIF,IAAI;4BAA6B;4BACzFsC,QAAQtC,MAAMuC;4BACdpC;wBACF;oBACF;gBACF;YACF;QACF;QAEA,OAAOH;IACT,EAAC"}
@@ -13,7 +13,7 @@
13
13
  * whose `user` is the new staff user (spreading the original req preserves
14
14
  * `transactionID` for atomicity). The owner field's force-owner-to-req.user
15
15
  * rule then assigns the correct owner with no bypass flag — nothing to exploit.
16
- */ export const provisionStaffResource = (config)=>async ({ context, doc, operation, previousDoc, req })=>{
16
+ */ export const provisionStaffResource = (config)=>async ({ context, doc, req })=>{
17
17
  if (context?.skipReservationHooks) {
18
18
  return doc;
19
19
  }
@@ -26,13 +26,10 @@
26
26
  if (!isStaffNow) {
27
27
  return doc;
28
28
  }
29
- if (operation === 'update') {
30
- // Skip if the user was already staff provisioned on a prior save.
31
- const wasStaff = roleMatches(previousDoc?.[sp.roleField], sp.staffRoles);
32
- if (wasStaff) {
33
- return doc;
34
- }
35
- }
29
+ // Idempotency is enforced by the dedup-by-owner query below — NOT by an
30
+ // early "was already staff" return. Relying on the query means pre-existing
31
+ // staff (enabled after the fact) and users whose Resource was deleted get
32
+ // (re)provisioned on their next save (review C5).
36
33
  const ownerField = config.resourceOwnerMode?.ownerField ?? 'owner';
37
34
  try {
38
35
  // Idempotency: skip if a resource already owns this user.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/users/provisionStaffResource.ts"],"sourcesContent":["import type { CollectionAfterChangeHook, CollectionSlug } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\n/** True if the user's role value (string or string[]) intersects staffRoles. */\nexport function roleMatches(role: unknown, staffRoles: string[]): boolean {\n if (Array.isArray(role)) {\n return role.some((r) => staffRoles.includes(r as string))\n }\n return typeof role === 'string' && staffRoles.includes(role)\n}\n\n/**\n * afterChange hook for the staff user collection. On create — or on an update\n * that promotes a user into a staff role — provisions a paired Resource owned\n * by that user, unless one already exists.\n *\n * Ownership is assigned via IMPERSONATION: the Resource is created with a `req`\n * whose `user` is the new staff user (spreading the original req preserves\n * `transactionID` for atomicity). The owner field's force-owner-to-req.user\n * rule then assigns the correct owner with no bypass flag — nothing to exploit.\n */\nexport const provisionStaffResource =\n (config: ResolvedReservationPluginConfig): CollectionAfterChangeHook =>\n async ({ context, doc, operation, previousDoc, req }) => {\n if (context?.skipReservationHooks) {\n return doc\n }\n\n const sp = config.staffProvisioning\n if (!sp) {\n return doc\n }\n\n const d = doc as Record<string, unknown>\n const isStaffNow = roleMatches(d[sp.roleField], sp.staffRoles)\n if (!isStaffNow) {\n return doc\n }\n\n if (operation === 'update') {\n // Skip if the user was already staff provisioned on a prior save.\n const wasStaff = roleMatches(\n (previousDoc as Record<string, unknown> | undefined)?.[sp.roleField],\n sp.staffRoles,\n )\n if (wasStaff) {\n return doc\n }\n }\n\n const ownerField = config.resourceOwnerMode?.ownerField ?? 'owner'\n\n try {\n // Idempotency: skip if a resource already owns this user.\n const existing = await req.payload.find({\n collection: config.slugs.resources as unknown as CollectionSlug,\n depth: 0,\n limit: 1,\n req,\n where: { [ownerField]: { equals: d.id } },\n })\n if (existing.docs.length > 0) {\n return doc\n }\n\n let data: Record<string, unknown> = {\n name: (d[sp.nameFrom] as string) ?? (d.email as string),\n [ownerField]: d.id,\n quantity: 1,\n resourceType: sp.resourceType,\n }\n\n if (sp.beforeCreate) {\n data = await sp.beforeCreate({ data, req, user: d })\n }\n\n // Impersonate the new staff user so the owner field resolves to them, not\n // the admin who triggered the create. Spread preserves the transaction.\n await req.payload.create({\n collection: config.slugs.resources as unknown as CollectionSlug,\n data,\n req: { ...req, user: { ...d, collection: sp.userCollection } } as typeof req,\n })\n } catch (err) {\n req.payload.logger.error({\n err,\n msg: `provisionStaffResource: failed to provision a resource for user ${String(d.id)}`,\n })\n }\n\n return doc\n }\n"],"names":["roleMatches","role","staffRoles","Array","isArray","some","r","includes","provisionStaffResource","config","context","doc","operation","previousDoc","req","skipReservationHooks","sp","staffProvisioning","d","isStaffNow","roleField","wasStaff","ownerField","resourceOwnerMode","existing","payload","find","collection","slugs","resources","depth","limit","where","equals","id","docs","length","data","name","nameFrom","email","quantity","resourceType","beforeCreate","user","create","userCollection","err","logger","error","msg","String"],"mappings":"AAIA,8EAA8E,GAC9E,OAAO,SAASA,YAAYC,IAAa,EAAEC,UAAoB;IAC7D,IAAIC,MAAMC,OAAO,CAACH,OAAO;QACvB,OAAOA,KAAKI,IAAI,CAAC,CAACC,IAAMJ,WAAWK,QAAQ,CAACD;IAC9C;IACA,OAAO,OAAOL,SAAS,YAAYC,WAAWK,QAAQ,CAACN;AACzD;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAMO,yBACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,SAAS,EAAEC,WAAW,EAAEC,GAAG,EAAE;QAClD,IAAIJ,SAASK,sBAAsB;YACjC,OAAOJ;QACT;QAEA,MAAMK,KAAKP,OAAOQ,iBAAiB;QACnC,IAAI,CAACD,IAAI;YACP,OAAOL;QACT;QAEA,MAAMO,IAAIP;QACV,MAAMQ,aAAanB,YAAYkB,CAAC,CAACF,GAAGI,SAAS,CAAC,EAAEJ,GAAGd,UAAU;QAC7D,IAAI,CAACiB,YAAY;YACf,OAAOR;QACT;QAEA,IAAIC,cAAc,UAAU;YAC1B,oEAAoE;YACpE,MAAMS,WAAWrB,YACda,aAAqD,CAACG,GAAGI,SAAS,CAAC,EACpEJ,GAAGd,UAAU;YAEf,IAAImB,UAAU;gBACZ,OAAOV;YACT;QACF;QAEA,MAAMW,aAAab,OAAOc,iBAAiB,EAAED,cAAc;QAE3D,IAAI;YACF,0DAA0D;YAC1D,MAAME,WAAW,MAAMV,IAAIW,OAAO,CAACC,IAAI,CAAC;gBACtCC,YAAYlB,OAAOmB,KAAK,CAACC,SAAS;gBAClCC,OAAO;gBACPC,OAAO;gBACPjB;gBACAkB,OAAO;oBAAE,CAACV,WAAW,EAAE;wBAAEW,QAAQf,EAAEgB,EAAE;oBAAC;gBAAE;YAC1C;YACA,IAAIV,SAASW,IAAI,CAACC,MAAM,GAAG,GAAG;gBAC5B,OAAOzB;YACT;YAEA,IAAI0B,OAAgC;gBAClCC,MAAM,AAACpB,CAAC,CAACF,GAAGuB,QAAQ,CAAC,IAAgBrB,EAAEsB,KAAK;gBAC5C,CAAClB,WAAW,EAAEJ,EAAEgB,EAAE;gBAClBO,UAAU;gBACVC,cAAc1B,GAAG0B,YAAY;YAC/B;YAEA,IAAI1B,GAAG2B,YAAY,EAAE;gBACnBN,OAAO,MAAMrB,GAAG2B,YAAY,CAAC;oBAAEN;oBAAMvB;oBAAK8B,MAAM1B;gBAAE;YACpD;YAEA,0EAA0E;YAC1E,wEAAwE;YACxE,MAAMJ,IAAIW,OAAO,CAACoB,MAAM,CAAC;gBACvBlB,YAAYlB,OAAOmB,KAAK,CAACC,SAAS;gBAClCQ;gBACAvB,KAAK;oBAAE,GAAGA,GAAG;oBAAE8B,MAAM;wBAAE,GAAG1B,CAAC;wBAAES,YAAYX,GAAG8B,cAAc;oBAAC;gBAAE;YAC/D;QACF,EAAE,OAAOC,KAAK;YACZjC,IAAIW,OAAO,CAACuB,MAAM,CAACC,KAAK,CAAC;gBACvBF;gBACAG,KAAK,CAAC,gEAAgE,EAAEC,OAAOjC,EAAEgB,EAAE,GAAG;YACxF;QACF;QAEA,OAAOvB;IACT,EAAC"}
1
+ {"version":3,"sources":["../../../src/hooks/users/provisionStaffResource.ts"],"sourcesContent":["import type { CollectionAfterChangeHook, CollectionSlug } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../../types.js'\n\n/** True if the user's role value (string or string[]) intersects staffRoles. */\nexport function roleMatches(role: unknown, staffRoles: string[]): boolean {\n if (Array.isArray(role)) {\n return role.some((r) => staffRoles.includes(r as string))\n }\n return typeof role === 'string' && staffRoles.includes(role)\n}\n\n/**\n * afterChange hook for the staff user collection. On create — or on an update\n * that promotes a user into a staff role — provisions a paired Resource owned\n * by that user, unless one already exists.\n *\n * Ownership is assigned via IMPERSONATION: the Resource is created with a `req`\n * whose `user` is the new staff user (spreading the original req preserves\n * `transactionID` for atomicity). The owner field's force-owner-to-req.user\n * rule then assigns the correct owner with no bypass flag — nothing to exploit.\n */\nexport const provisionStaffResource =\n (config: ResolvedReservationPluginConfig): CollectionAfterChangeHook =>\n async ({ context, doc, req }) => {\n if (context?.skipReservationHooks) {\n return doc\n }\n\n const sp = config.staffProvisioning\n if (!sp) {\n return doc\n }\n\n const d = doc as Record<string, unknown>\n const isStaffNow = roleMatches(d[sp.roleField], sp.staffRoles)\n if (!isStaffNow) {\n return doc\n }\n\n // Idempotency is enforced by the dedup-by-owner query below — NOT by an\n // early \"was already staff\" return. Relying on the query means pre-existing\n // staff (enabled after the fact) and users whose Resource was deleted get\n // (re)provisioned on their next save (review C5).\n const ownerField = config.resourceOwnerMode?.ownerField ?? 'owner'\n\n try {\n // Idempotency: skip if a resource already owns this user.\n const existing = await req.payload.find({\n collection: config.slugs.resources as unknown as CollectionSlug,\n depth: 0,\n limit: 1,\n req,\n where: { [ownerField]: { equals: d.id } },\n })\n if (existing.docs.length > 0) {\n return doc\n }\n\n let data: Record<string, unknown> = {\n name: (d[sp.nameFrom] as string) ?? (d.email as string),\n [ownerField]: d.id,\n quantity: 1,\n resourceType: sp.resourceType,\n }\n\n if (sp.beforeCreate) {\n data = await sp.beforeCreate({ data, req, user: d })\n }\n\n // Impersonate the new staff user so the owner field resolves to them, not\n // the admin who triggered the create. Spread preserves the transaction.\n await req.payload.create({\n collection: config.slugs.resources as unknown as CollectionSlug,\n data,\n req: { ...req, user: { ...d, collection: sp.userCollection } } as typeof req,\n })\n } catch (err) {\n req.payload.logger.error({\n err,\n msg: `provisionStaffResource: failed to provision a resource for user ${String(d.id)}`,\n })\n }\n\n return doc\n }\n"],"names":["roleMatches","role","staffRoles","Array","isArray","some","r","includes","provisionStaffResource","config","context","doc","req","skipReservationHooks","sp","staffProvisioning","d","isStaffNow","roleField","ownerField","resourceOwnerMode","existing","payload","find","collection","slugs","resources","depth","limit","where","equals","id","docs","length","data","name","nameFrom","email","quantity","resourceType","beforeCreate","user","create","userCollection","err","logger","error","msg","String"],"mappings":"AAIA,8EAA8E,GAC9E,OAAO,SAASA,YAAYC,IAAa,EAAEC,UAAoB;IAC7D,IAAIC,MAAMC,OAAO,CAACH,OAAO;QACvB,OAAOA,KAAKI,IAAI,CAAC,CAACC,IAAMJ,WAAWK,QAAQ,CAACD;IAC9C;IACA,OAAO,OAAOL,SAAS,YAAYC,WAAWK,QAAQ,CAACN;AACzD;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAMO,yBACX,CAACC,SACD,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,GAAG,EAAE;QAC1B,IAAIF,SAASG,sBAAsB;YACjC,OAAOF;QACT;QAEA,MAAMG,KAAKL,OAAOM,iBAAiB;QACnC,IAAI,CAACD,IAAI;YACP,OAAOH;QACT;QAEA,MAAMK,IAAIL;QACV,MAAMM,aAAajB,YAAYgB,CAAC,CAACF,GAAGI,SAAS,CAAC,EAAEJ,GAAGZ,UAAU;QAC7D,IAAI,CAACe,YAAY;YACf,OAAON;QACT;QAEA,wEAAwE;QACxE,4EAA4E;QAC5E,0EAA0E;QAC1E,kDAAkD;QAClD,MAAMQ,aAAaV,OAAOW,iBAAiB,EAAED,cAAc;QAE3D,IAAI;YACF,0DAA0D;YAC1D,MAAME,WAAW,MAAMT,IAAIU,OAAO,CAACC,IAAI,CAAC;gBACtCC,YAAYf,OAAOgB,KAAK,CAACC,SAAS;gBAClCC,OAAO;gBACPC,OAAO;gBACPhB;gBACAiB,OAAO;oBAAE,CAACV,WAAW,EAAE;wBAAEW,QAAQd,EAAEe,EAAE;oBAAC;gBAAE;YAC1C;YACA,IAAIV,SAASW,IAAI,CAACC,MAAM,GAAG,GAAG;gBAC5B,OAAOtB;YACT;YAEA,IAAIuB,OAAgC;gBAClCC,MAAM,AAACnB,CAAC,CAACF,GAAGsB,QAAQ,CAAC,IAAgBpB,EAAEqB,KAAK;gBAC5C,CAAClB,WAAW,EAAEH,EAAEe,EAAE;gBAClBO,UAAU;gBACVC,cAAczB,GAAGyB,YAAY;YAC/B;YAEA,IAAIzB,GAAG0B,YAAY,EAAE;gBACnBN,OAAO,MAAMpB,GAAG0B,YAAY,CAAC;oBAAEN;oBAAMtB;oBAAK6B,MAAMzB;gBAAE;YACpD;YAEA,0EAA0E;YAC1E,wEAAwE;YACxE,MAAMJ,IAAIU,OAAO,CAACoB,MAAM,CAAC;gBACvBlB,YAAYf,OAAOgB,KAAK,CAACC,SAAS;gBAClCQ;gBACAtB,KAAK;oBAAE,GAAGA,GAAG;oBAAE6B,MAAM;wBAAE,GAAGzB,CAAC;wBAAEQ,YAAYV,GAAG6B,cAAc;oBAAC;gBAAE;YAC/D;QACF,EAAE,OAAOC,KAAK;YACZhC,IAAIU,OAAO,CAACuB,MAAM,CAACC,KAAK,CAAC;gBACvBF;gBACAG,KAAK,CAAC,gEAAgE,EAAEC,OAAOhC,EAAEe,EAAE,GAAG;YACxF;QACF;QAEA,OAAOpB;IACT,EAAC"}
package/dist/plugin.js CHANGED
@@ -13,6 +13,36 @@ import { createGetSlotsEndpoint } from './endpoints/getSlots.js';
13
13
  import { createResourceAvailabilityEndpoint } from './endpoints/resourceAvailability.js';
14
14
  import { provisionStaffResource } from './hooks/users/provisionStaffResource.js';
15
15
  import { translations } from './translations/index.js';
16
+ import { applyCollectionOverride } from './utilities/collectionOverrides.js';
17
+ /**
18
+ * All named field paths reachable from a field list, descending through
19
+ * presentational containers (tabs, rows, collapsibles, unnamed groups) that
20
+ * don't create their own data nesting — so dedup catches a field declared
21
+ * inside one of them. Named groups/arrays DO nest data, so we don't recurse
22
+ * into them (a `name` inside a named group is a different path).
23
+ */ function collectFieldNames(fields) {
24
+ const names = new Set();
25
+ const walk = (list)=>{
26
+ for (const field of list){
27
+ if ('name' in field && field.name) {
28
+ names.add(field.name);
29
+ } else if ('tabs' in field && Array.isArray(field.tabs)) {
30
+ for (const tab of field.tabs){
31
+ if ('name' in tab && tab.name) {
32
+ names.add(tab.name);
33
+ } else if (Array.isArray(tab.fields)) {
34
+ walk(tab.fields);
35
+ }
36
+ }
37
+ } else if ('fields' in field && Array.isArray(field.fields)) {
38
+ // row / collapsible / unnamed group
39
+ walk(field.fields);
40
+ }
41
+ }
42
+ };
43
+ walk(fields);
44
+ return names;
45
+ }
16
46
  export const payloadReserve = (pluginOptions = {})=>(config)=>{
17
47
  const resolved = resolveConfig(pluginOptions);
18
48
  // Detect localization from the Payload config
@@ -22,24 +52,29 @@ export const payloadReserve = (pluginOptions = {})=>(config)=>{
22
52
  if (!config.collections) {
23
53
  config.collections = [];
24
54
  }
25
- if (resolved.disabled) {
26
- return config;
27
- }
28
55
  if (resolved.userCollection) {
29
56
  // Extend the existing auth collection with customer fields
30
57
  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));
58
+ if (!targetCollection) {
59
+ // Fail loudly rather than silently skipping field injection and pointing
60
+ // the customers slug at a collection that doesn't exist (review C2).
61
+ throw new Error(`payload-reserve: userCollection "${resolved.userCollection}" was not found in config.collections. ` + `Define it before payloadReserve() runs, or correct the slug.`);
62
+ }
63
+ {
64
+ // Collect existing field names — descend into presentational containers
65
+ // (tabs/rows/collapsibles/groups) so a field nested there isn't
66
+ // re-injected at the top level (review C4).
67
+ const existingFieldNames = collectFieldNames(targetCollection.fields);
34
68
  // Fields to inject if not already present. `name` is added so that
35
69
  // admin.useAsTitle: 'name' works out of the box on the extended user
36
70
  // collection (matches the v1.0.0 behaviour documented in README/SKILL).
71
+ // It is NOT required — an existing users collection may have rows
72
+ // without a name, and forcing required would fail their next update (C4).
37
73
  const fieldsToAdd = [
38
74
  {
39
75
  name: 'name',
40
76
  type: 'text',
41
- maxLength: 200,
42
- required: true
77
+ maxLength: 200
43
78
  },
44
79
  {
45
80
  name: 'phone',
@@ -67,11 +102,43 @@ export const payloadReserve = (pluginOptions = {})=>(config)=>{
67
102
  // Point the customers slug at the user collection so other parts of the
68
103
  // plugin (endpoints, hooks) reference the correct collection
69
104
  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));
105
+ }
106
+ // The slugs this plugin is about to register (Customers only in standalone mode)
107
+ const slugsToRegister = [
108
+ resolved.slugs.services,
109
+ resolved.slugs.resources,
110
+ resolved.slugs.schedules,
111
+ resolved.slugs.reservations,
112
+ ...resolved.userCollection ? [] : [
113
+ resolved.slugs.customers
114
+ ]
115
+ ];
116
+ // C11: fail with a clear, actionable error on slug collision instead of
117
+ // Payload's generic DuplicateCollection throw.
118
+ for (const slug of slugsToRegister){
119
+ if (config.collections.some((col)=>col.slug === slug)) {
120
+ throw new Error(`payload-reserve: a collection with slug "${slug}" already exists. ` + `Override the plugin's slug via the \`slugs\` option.`);
121
+ }
122
+ }
123
+ // Image upload fields are added only when the media collection actually
124
+ // exists, so installs without one don't hit an opaque init error (C8).
125
+ resolved.hasMediaCollection = config.collections.some((col)=>col.slug === resolved.slugs.media);
126
+ const ov = resolved.collectionOverrides;
127
+ 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
128
+ // mode the host owns that collection and can edit it directly.
129
+ ...resolved.userCollection ? [] : [
130
+ applyCollectionOverride(createCustomersCollection(resolved), ov.customers)
131
+ ]);
132
+ // C3: collections are registered (above) even when disabled so the DB schema
133
+ // stays stable; behavior (hooks, endpoints, admin, provisioning) is inert.
134
+ if (resolved.disabled) {
135
+ for (const slug of slugsToRegister){
136
+ const col = config.collections.find((c)=>c.slug === slug);
137
+ if (col) {
138
+ delete col.hooks;
139
+ }
140
+ }
141
+ return config;
75
142
  }
76
143
  // Register custom endpoints
77
144
  if (!config.endpoints) {
@@ -108,6 +175,8 @@ export const payloadReserve = (pluginOptions = {})=>(config)=>{
108
175
  ...resolved.slugs
109
176
  };
110
177
  config.admin.custom.reservationStatusMachine = resolved.statusMachine;
178
+ config.admin.custom.reservationTenant = resolved.multiTenant;
179
+ config.admin.custom.reservationTimezone = resolved.timezone;
111
180
  // Add dashboard widget
112
181
  if (!config.admin.dashboard) {
113
182
  config.admin.dashboard = {