payload-reserve 1.3.2 → 1.5.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 (104) hide show
  1. package/README.md +185 -4
  2. package/dist/collections/Reservations.js +47 -2
  3. package/dist/collections/Reservations.js.map +1 -1
  4. package/dist/collections/Resources.d.ts +16 -0
  5. package/dist/collections/Resources.js +35 -10
  6. package/dist/collections/Resources.js.map +1 -1
  7. package/dist/collections/Schedules.js +34 -0
  8. package/dist/collections/Schedules.js.map +1 -1
  9. package/dist/collections/Services.js +34 -1
  10. package/dist/collections/Services.js.map +1 -1
  11. package/dist/components/AvailabilityTimeField/AvailabilityTimeField.module.css +7 -0
  12. package/dist/components/AvailabilityTimeField/index.d.ts +2 -0
  13. package/dist/components/AvailabilityTimeField/index.js +109 -0
  14. package/dist/components/AvailabilityTimeField/index.js.map +1 -0
  15. package/dist/components/CalendarView/CalendarView.module.css +114 -0
  16. package/dist/components/CalendarView/LaneTimelineView.d.ts +12 -0
  17. package/dist/components/CalendarView/LaneTimelineView.js +116 -0
  18. package/dist/components/CalendarView/LaneTimelineView.js.map +1 -0
  19. package/dist/components/CalendarView/index.js +224 -22
  20. package/dist/components/CalendarView/index.js.map +1 -1
  21. package/dist/components/CalendarView/useResourceAvailability.d.ts +9 -0
  22. package/dist/components/CalendarView/useResourceAvailability.js +40 -0
  23. package/dist/components/CalendarView/useResourceAvailability.js.map +1 -0
  24. package/dist/defaults.d.ts +3 -0
  25. package/dist/defaults.js +53 -0
  26. package/dist/defaults.js.map +1 -1
  27. package/dist/endpoints/cancelBooking.js +34 -21
  28. package/dist/endpoints/cancelBooking.js.map +1 -1
  29. package/dist/endpoints/checkAvailability.js +16 -1
  30. package/dist/endpoints/checkAvailability.js.map +1 -1
  31. package/dist/endpoints/createBooking.js +4 -1
  32. package/dist/endpoints/createBooking.js.map +1 -1
  33. package/dist/endpoints/customerSearch.js +24 -5
  34. package/dist/endpoints/customerSearch.js.map +1 -1
  35. package/dist/endpoints/getSlots.js +16 -1
  36. package/dist/endpoints/getSlots.js.map +1 -1
  37. package/dist/endpoints/resourceAvailability.d.ts +43 -0
  38. package/dist/endpoints/resourceAvailability.js +214 -0
  39. package/dist/endpoints/resourceAvailability.js.map +1 -0
  40. package/dist/exports/client.d.ts +1 -0
  41. package/dist/exports/client.js +1 -0
  42. package/dist/exports/client.js.map +1 -1
  43. package/dist/hooks/reservations/calculateEndTime.js +21 -1
  44. package/dist/hooks/reservations/calculateEndTime.js.map +1 -1
  45. package/dist/hooks/reservations/expandRequiredResources.d.ts +9 -0
  46. package/dist/hooks/reservations/expandRequiredResources.js +81 -0
  47. package/dist/hooks/reservations/expandRequiredResources.js.map +1 -0
  48. package/dist/hooks/reservations/validateGuestBooking.d.ts +3 -0
  49. package/dist/hooks/reservations/validateGuestBooking.js +93 -0
  50. package/dist/hooks/reservations/validateGuestBooking.js.map +1 -0
  51. package/dist/hooks/reservations/validateStatusTransition.js +4 -2
  52. package/dist/hooks/reservations/validateStatusTransition.js.map +1 -1
  53. package/dist/hooks/users/provisionStaffResource.d.ts +15 -0
  54. package/dist/hooks/users/provisionStaffResource.js +88 -0
  55. package/dist/hooks/users/provisionStaffResource.js.map +1 -0
  56. package/dist/index.d.ts +5 -1
  57. package/dist/index.js +4 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/plugin.js +28 -3
  60. package/dist/plugin.js.map +1 -1
  61. package/dist/services/AvailabilityService.d.ts +7 -6
  62. package/dist/services/AvailabilityService.js +86 -60
  63. package/dist/services/AvailabilityService.js.map +1 -1
  64. package/dist/translations/ar.json +156 -0
  65. package/dist/translations/de.json +156 -0
  66. package/dist/translations/en.json +32 -1
  67. package/dist/translations/es.json +156 -0
  68. package/dist/translations/fa.json +156 -0
  69. package/dist/translations/fr.json +156 -0
  70. package/dist/translations/hi.json +156 -0
  71. package/dist/translations/id.json +156 -0
  72. package/dist/translations/index.js +44 -0
  73. package/dist/translations/index.js.map +1 -1
  74. package/dist/translations/pl.json +156 -0
  75. package/dist/translations/ru.json +156 -0
  76. package/dist/translations/tr.json +156 -0
  77. package/dist/translations/zh.json +156 -0
  78. package/dist/types.d.ts +46 -0
  79. package/dist/types.js.map +1 -1
  80. package/dist/utilities/computeSlotStates.d.ts +39 -0
  81. package/dist/utilities/computeSlotStates.js +49 -0
  82. package/dist/utilities/computeSlotStates.js.map +1 -0
  83. package/dist/utilities/guestBooking.d.ts +10 -0
  84. package/dist/utilities/guestBooking.js +16 -0
  85. package/dist/utilities/guestBooking.js.map +1 -0
  86. package/dist/utilities/resolveRequiredResources.d.ts +8 -0
  87. package/dist/utilities/resolveRequiredResources.js +27 -0
  88. package/dist/utilities/resolveRequiredResources.js.map +1 -0
  89. package/dist/utilities/resolveReservationItems.d.ts +3 -2
  90. package/dist/utilities/resolveReservationItems.js +19 -6
  91. package/dist/utilities/resolveReservationItems.js.map +1 -1
  92. package/dist/utilities/scheduleUtils.d.ts +3 -0
  93. package/dist/utilities/scheduleUtils.js +5 -3
  94. package/dist/utilities/scheduleUtils.js.map +1 -1
  95. package/dist/utilities/selectOptions.d.ts +8 -0
  96. package/dist/utilities/selectOptions.js +11 -0
  97. package/dist/utilities/selectOptions.js.map +1 -0
  98. package/dist/utilities/slotUtils.d.ts +19 -0
  99. package/dist/utilities/slotUtils.js +28 -0
  100. package/dist/utilities/slotUtils.js.map +1 -1
  101. package/dist/utilities/userRoles.d.ts +20 -0
  102. package/dist/utilities/userRoles.js +32 -0
  103. package/dist/utilities/userRoles.js.map +1 -0
  104. package/package.json +2 -1
@@ -1,15 +1,9 @@
1
+ import { isPrivilegedUser } from '../utilities/userRoles.js';
1
2
  export function createCancelBookingEndpoint(config) {
2
3
  return {
3
4
  handler: async (req)=>{
4
- if (!req.user) {
5
- return Response.json({
6
- message: 'Unauthorized'
7
- }, {
8
- status: 401
9
- });
10
- }
11
5
  const body = await req.json?.();
12
- const { reason, reservationId } = body ?? {};
6
+ const { reason, reservationId, token } = body ?? {};
13
7
  if (!reservationId) {
14
8
  return Response.json({
15
9
  message: 'reservationId is required'
@@ -17,25 +11,37 @@ export function createCancelBookingEndpoint(config) {
17
11
  status: 400
18
12
  });
19
13
  }
20
- // Fetch the reservation to check ownership
14
+ // Fetch the reservation to check ownership / token.
21
15
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
16
  const existing = await req.payload.findByID({
23
17
  id: reservationId,
24
18
  collection: config.slugs.reservations,
25
19
  depth: 0,
20
+ overrideAccess: true,
26
21
  req
27
22
  });
28
- // Check ownership: customer must match req.user
29
- const customerId = typeof existing.customer === 'object' ? existing.customer?.id : existing.customer;
30
- const isOwner = customerId === req.user.id;
31
- // Admin = user from non-customer collection
32
- const isAdmin = req.user.collection !== config.slugs.customers;
33
- if (!isOwner && !isAdmin) {
34
- return Response.json({
35
- message: 'Forbidden'
36
- }, {
37
- status: 403
38
- });
23
+ if (req.user) {
24
+ // Authenticated path: owner (customer === req.user) or admin/staff.
25
+ const customerId = typeof existing.customer === 'object' ? existing.customer?.id : existing.customer;
26
+ const isOwner = customerId === req.user.id;
27
+ // Staff/admin detection (role-aware for single-collection deployments)
28
+ const isAdmin = isPrivilegedUser(req.user, config);
29
+ if (!isOwner && !isAdmin) {
30
+ return Response.json({
31
+ message: 'Forbidden'
32
+ }, {
33
+ status: 403
34
+ });
35
+ }
36
+ } else {
37
+ // Guest path: match the cancellation token.
38
+ if (!token || !existing.cancellationToken || token !== existing.cancellationToken) {
39
+ return Response.json({
40
+ message: 'Forbidden'
41
+ }, {
42
+ status: 403
43
+ });
44
+ }
39
45
  }
40
46
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
47
  const reservation = await req.payload.update({
@@ -45,9 +51,16 @@ export function createCancelBookingEndpoint(config) {
45
51
  cancellationReason: reason,
46
52
  status: 'cancelled'
47
53
  },
54
+ // Authorization (owner / admin / matching token) is already enforced above.
55
+ // overrideAccess lets the write proceed for anonymous guest cancellations,
56
+ // and avoids a spurious 500 when resourceOwnerMode restricts update access.
57
+ overrideAccess: true,
48
58
  req
49
59
  });
50
- return Response.json(reservation);
60
+ // Strip the cancellation token from the response, consistent with the book
61
+ // endpoint — it must never be echoed back over HTTP.
62
+ const { cancellationToken: _cancellationToken, ...safeReservation } = reservation;
63
+ return Response.json(safeReservation);
51
64
  },
52
65
  method: 'post',
53
66
  path: '/reserve/cancel'
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/cancelBooking.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nexport function createCancelBookingEndpoint(config: ResolvedReservationPluginConfig): Endpoint {\n return {\n handler: async (req) => {\n if (!req.user) {\n return Response.json({ message: 'Unauthorized' }, { status: 401 })\n }\n\n const body = await req.json?.()\n const { reason, reservationId } = (body ?? {}) as {\n reason?: string\n reservationId?: string\n }\n\n if (!reservationId) {\n return Response.json({ message: 'reservationId is required' }, { status: 400 })\n }\n\n // Fetch the reservation to check ownership\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const existing = await (req.payload.findByID as any)({\n id: reservationId,\n collection: config.slugs.reservations,\n depth: 0,\n req,\n })\n\n // Check ownership: customer must match req.user\n const customerId =\n typeof existing.customer === 'object' ? existing.customer?.id : existing.customer\n const isOwner = customerId === req.user.id\n // Admin = user from non-customer collection\n const isAdmin = req.user.collection !== config.slugs.customers\n\n if (!isOwner && !isAdmin) {\n return Response.json({ message: 'Forbidden' }, { status: 403 })\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const reservation = await (req.payload.update as any)({\n id: reservationId,\n collection: config.slugs.reservations,\n data: {\n cancellationReason: reason,\n status: 'cancelled',\n },\n req,\n })\n\n return Response.json(reservation)\n },\n method: 'post',\n path: '/reserve/cancel',\n }\n}\n"],"names":["createCancelBookingEndpoint","config","handler","req","user","Response","json","message","status","body","reason","reservationId","existing","payload","findByID","id","collection","slugs","reservations","depth","customerId","customer","isOwner","isAdmin","customers","reservation","update","data","cancellationReason","method","path"],"mappings":"AAIA,OAAO,SAASA,4BAA4BC,MAAuC;IACjF,OAAO;QACLC,SAAS,OAAOC;YACd,IAAI,CAACA,IAAIC,IAAI,EAAE;gBACb,OAAOC,SAASC,IAAI,CAAC;oBAAEC,SAAS;gBAAe,GAAG;oBAAEC,QAAQ;gBAAI;YAClE;YAEA,MAAMC,OAAO,MAAMN,IAAIG,IAAI;YAC3B,MAAM,EAAEI,MAAM,EAAEC,aAAa,EAAE,GAAIF,QAAQ,CAAC;YAK5C,IAAI,CAACE,eAAe;gBAClB,OAAON,SAASC,IAAI,CAAC;oBAAEC,SAAS;gBAA4B,GAAG;oBAAEC,QAAQ;gBAAI;YAC/E;YAEA,2CAA2C;YAC3C,8DAA8D;YAC9D,MAAMI,WAAW,MAAM,AAACT,IAAIU,OAAO,CAACC,QAAQ,CAAS;gBACnDC,IAAIJ;gBACJK,YAAYf,OAAOgB,KAAK,CAACC,YAAY;gBACrCC,OAAO;gBACPhB;YACF;YAEA,gDAAgD;YAChD,MAAMiB,aACJ,OAAOR,SAASS,QAAQ,KAAK,WAAWT,SAASS,QAAQ,EAAEN,KAAKH,SAASS,QAAQ;YACnF,MAAMC,UAAUF,eAAejB,IAAIC,IAAI,CAACW,EAAE;YAC1C,4CAA4C;YAC5C,MAAMQ,UAAUpB,IAAIC,IAAI,CAACY,UAAU,KAAKf,OAAOgB,KAAK,CAACO,SAAS;YAE9D,IAAI,CAACF,WAAW,CAACC,SAAS;gBACxB,OAAOlB,SAASC,IAAI,CAAC;oBAAEC,SAAS;gBAAY,GAAG;oBAAEC,QAAQ;gBAAI;YAC/D;YAEA,8DAA8D;YAC9D,MAAMiB,cAAc,MAAM,AAACtB,IAAIU,OAAO,CAACa,MAAM,CAAS;gBACpDX,IAAIJ;gBACJK,YAAYf,OAAOgB,KAAK,CAACC,YAAY;gBACrCS,MAAM;oBACJC,oBAAoBlB;oBACpBF,QAAQ;gBACV;gBACAL;YACF;YAEA,OAAOE,SAASC,IAAI,CAACmB;QACvB;QACAI,QAAQ;QACRC,MAAM;IACR;AACF"}
1
+ {"version":3,"sources":["../../src/endpoints/cancelBooking.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { isPrivilegedUser } from '../utilities/userRoles.js'\n\nexport function createCancelBookingEndpoint(config: ResolvedReservationPluginConfig): Endpoint {\n return {\n handler: async (req) => {\n const body = await req.json?.()\n const { reason, reservationId, token } = (body ?? {}) as {\n reason?: string\n reservationId?: string\n token?: string\n }\n\n if (!reservationId) {\n return Response.json({ message: 'reservationId is required' }, { status: 400 })\n }\n\n // Fetch the reservation to check ownership / token.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const existing = await (req.payload.findByID as any)({\n id: reservationId,\n collection: config.slugs.reservations,\n depth: 0,\n overrideAccess: true,\n req,\n })\n\n if (req.user) {\n // Authenticated path: owner (customer === req.user) or admin/staff.\n const customerId =\n typeof existing.customer === 'object' ? existing.customer?.id : existing.customer\n const isOwner = customerId === req.user.id\n // Staff/admin detection (role-aware for single-collection deployments)\n const isAdmin = isPrivilegedUser(req.user, config)\n if (!isOwner && !isAdmin) {\n return Response.json({ message: 'Forbidden' }, { status: 403 })\n }\n } else {\n // Guest path: match the cancellation token.\n if (!token || !existing.cancellationToken || token !== existing.cancellationToken) {\n return Response.json({ message: 'Forbidden' }, { status: 403 })\n }\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const reservation = await (req.payload.update as any)({\n id: reservationId,\n collection: config.slugs.reservations,\n data: {\n cancellationReason: reason,\n status: 'cancelled',\n },\n // Authorization (owner / admin / matching token) is already enforced above.\n // overrideAccess lets the write proceed for anonymous guest cancellations,\n // and avoids a spurious 500 when resourceOwnerMode restricts update access.\n overrideAccess: true,\n req,\n })\n\n // Strip the cancellation token from the response, consistent with the book\n // endpoint — it must never be echoed back over HTTP.\n const { cancellationToken: _cancellationToken, ...safeReservation } =\n reservation as Record<string, unknown>\n\n return Response.json(safeReservation)\n },\n method: 'post',\n path: '/reserve/cancel',\n }\n}\n"],"names":["isPrivilegedUser","createCancelBookingEndpoint","config","handler","req","body","json","reason","reservationId","token","Response","message","status","existing","payload","findByID","id","collection","slugs","reservations","depth","overrideAccess","user","customerId","customer","isOwner","isAdmin","cancellationToken","reservation","update","data","cancellationReason","_cancellationToken","safeReservation","method","path"],"mappings":"AAIA,SAASA,gBAAgB,QAAQ,4BAA2B;AAE5D,OAAO,SAASC,4BAA4BC,MAAuC;IACjF,OAAO;QACLC,SAAS,OAAOC;YACd,MAAMC,OAAO,MAAMD,IAAIE,IAAI;YAC3B,MAAM,EAAEC,MAAM,EAAEC,aAAa,EAAEC,KAAK,EAAE,GAAIJ,QAAQ,CAAC;YAMnD,IAAI,CAACG,eAAe;gBAClB,OAAOE,SAASJ,IAAI,CAAC;oBAAEK,SAAS;gBAA4B,GAAG;oBAAEC,QAAQ;gBAAI;YAC/E;YAEA,oDAAoD;YACpD,8DAA8D;YAC9D,MAAMC,WAAW,MAAM,AAACT,IAAIU,OAAO,CAACC,QAAQ,CAAS;gBACnDC,IAAIR;gBACJS,YAAYf,OAAOgB,KAAK,CAACC,YAAY;gBACrCC,OAAO;gBACPC,gBAAgB;gBAChBjB;YACF;YAEA,IAAIA,IAAIkB,IAAI,EAAE;gBACZ,oEAAoE;gBACpE,MAAMC,aACJ,OAAOV,SAASW,QAAQ,KAAK,WAAWX,SAASW,QAAQ,EAAER,KAAKH,SAASW,QAAQ;gBACnF,MAAMC,UAAUF,eAAenB,IAAIkB,IAAI,CAACN,EAAE;gBAC1C,uEAAuE;gBACvE,MAAMU,UAAU1B,iBAAiBI,IAAIkB,IAAI,EAAEpB;gBAC3C,IAAI,CAACuB,WAAW,CAACC,SAAS;oBACxB,OAAOhB,SAASJ,IAAI,CAAC;wBAAEK,SAAS;oBAAY,GAAG;wBAAEC,QAAQ;oBAAI;gBAC/D;YACF,OAAO;gBACL,4CAA4C;gBAC5C,IAAI,CAACH,SAAS,CAACI,SAASc,iBAAiB,IAAIlB,UAAUI,SAASc,iBAAiB,EAAE;oBACjF,OAAOjB,SAASJ,IAAI,CAAC;wBAAEK,SAAS;oBAAY,GAAG;wBAAEC,QAAQ;oBAAI;gBAC/D;YACF;YAEA,8DAA8D;YAC9D,MAAMgB,cAAc,MAAM,AAACxB,IAAIU,OAAO,CAACe,MAAM,CAAS;gBACpDb,IAAIR;gBACJS,YAAYf,OAAOgB,KAAK,CAACC,YAAY;gBACrCW,MAAM;oBACJC,oBAAoBxB;oBACpBK,QAAQ;gBACV;gBACA,4EAA4E;gBAC5E,2EAA2E;gBAC3E,4EAA4E;gBAC5ES,gBAAgB;gBAChBjB;YACF;YAEA,2EAA2E;YAC3E,qDAAqD;YACrD,MAAM,EAAEuB,mBAAmBK,kBAAkB,EAAE,GAAGC,iBAAiB,GACjEL;YAEF,OAAOlB,SAASJ,IAAI,CAAC2B;QACvB;QACAC,QAAQ;QACRC,MAAM;IACR;AACF"}
@@ -1,4 +1,5 @@
1
1
  import { getAvailableSlots } from '../services/AvailabilityService.js';
2
+ import { extractId, mergeResourceIds } from '../utilities/resolveRequiredResources.js';
2
3
  export function createCheckAvailabilityEndpoint(config) {
3
4
  return {
4
5
  handler: async (req)=>{
@@ -22,6 +23,20 @@ export function createCheckAvailabilityEndpoint(config) {
22
23
  });
23
24
  }
24
25
  const guestCount = Math.max(Number(url.searchParams.get('guestCount') ?? '1'), 1);
26
+ // Resolve required resource set: caller resource(s) ∪ service.requiredResources
27
+ const explicit = url.searchParams.get('resources');
28
+ const callerIds = explicit ? explicit.split(',').map((s)=>s.trim()).filter(Boolean) : [
29
+ resource
30
+ ];
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ const svcDoc = await req.payload.findByID({
33
+ id: service,
34
+ collection: config.slugs.services,
35
+ depth: 0,
36
+ req
37
+ });
38
+ const requiredIds = (svcDoc?.requiredResources ?? []).map((r)=>extractId(r)).filter((r)=>r !== undefined);
39
+ const resourceIds = mergeResourceIds(callerIds, requiredIds);
25
40
  const slots = await getAvailableSlots({
26
41
  blockingStatuses: config.statusMachine.blockingStatuses,
27
42
  date: parsedDate,
@@ -29,7 +44,7 @@ export function createCheckAvailabilityEndpoint(config) {
29
44
  payload: req.payload,
30
45
  req,
31
46
  reservationSlug: config.slugs.reservations,
32
- resourceId: resource,
47
+ resourceIds,
33
48
  resourceSlug: config.slugs.resources,
34
49
  scheduleSlug: config.slugs.schedules,
35
50
  serviceId: service,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/checkAvailability.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { getAvailableSlots } from '../services/AvailabilityService.js'\n\nexport function createCheckAvailabilityEndpoint(\n config: ResolvedReservationPluginConfig,\n): Endpoint {\n return {\n handler: async (req) => {\n const url = new URL(req.url!)\n const date = url.searchParams.get('date')\n const resource = url.searchParams.get('resource')\n const service = url.searchParams.get('service')\n\n if (!date || !resource || !service) {\n return Response.json(\n { message: 'Missing required query params: resource, date, service' },\n { status: 400 },\n )\n }\n\n const parsedDate = new Date(date)\n if (isNaN(parsedDate.getTime())) {\n return Response.json(\n { error: 'Invalid date format. Expected YYYY-MM-DD' },\n { status: 400 },\n )\n }\n\n const guestCount = Math.max(Number(url.searchParams.get('guestCount') ?? '1'), 1)\n\n const slots = await getAvailableSlots({\n blockingStatuses: config.statusMachine.blockingStatuses,\n date: parsedDate,\n guestCount,\n payload: req.payload,\n req,\n reservationSlug: config.slugs.reservations,\n resourceId: resource,\n resourceSlug: config.slugs.resources,\n scheduleSlug: config.slugs.schedules,\n serviceId: service,\n serviceSlug: config.slugs.services,\n })\n\n return Response.json({ slots })\n },\n method: 'get',\n path: '/reserve/availability',\n }\n}\n"],"names":["getAvailableSlots","createCheckAvailabilityEndpoint","config","handler","req","url","URL","date","searchParams","get","resource","service","Response","json","message","status","parsedDate","Date","isNaN","getTime","error","guestCount","Math","max","Number","slots","blockingStatuses","statusMachine","payload","reservationSlug","slugs","reservations","resourceId","resourceSlug","resources","scheduleSlug","schedules","serviceId","serviceSlug","services","method","path"],"mappings":"AAIA,SAASA,iBAAiB,QAAQ,qCAAoC;AAEtE,OAAO,SAASC,gCACdC,MAAuC;IAEvC,OAAO;QACLC,SAAS,OAAOC;YACd,MAAMC,MAAM,IAAIC,IAAIF,IAAIC,GAAG;YAC3B,MAAME,OAAOF,IAAIG,YAAY,CAACC,GAAG,CAAC;YAClC,MAAMC,WAAWL,IAAIG,YAAY,CAACC,GAAG,CAAC;YACtC,MAAME,UAAUN,IAAIG,YAAY,CAACC,GAAG,CAAC;YAErC,IAAI,CAACF,QAAQ,CAACG,YAAY,CAACC,SAAS;gBAClC,OAAOC,SAASC,IAAI,CAClB;oBAAEC,SAAS;gBAAyD,GACpE;oBAAEC,QAAQ;gBAAI;YAElB;YAEA,MAAMC,aAAa,IAAIC,KAAKV;YAC5B,IAAIW,MAAMF,WAAWG,OAAO,KAAK;gBAC/B,OAAOP,SAASC,IAAI,CAClB;oBAAEO,OAAO;gBAA2C,GACpD;oBAAEL,QAAQ;gBAAI;YAElB;YAEA,MAAMM,aAAaC,KAAKC,GAAG,CAACC,OAAOnB,IAAIG,YAAY,CAACC,GAAG,CAAC,iBAAiB,MAAM;YAE/E,MAAMgB,QAAQ,MAAMzB,kBAAkB;gBACpC0B,kBAAkBxB,OAAOyB,aAAa,CAACD,gBAAgB;gBACvDnB,MAAMS;gBACNK;gBACAO,SAASxB,IAAIwB,OAAO;gBACpBxB;gBACAyB,iBAAiB3B,OAAO4B,KAAK,CAACC,YAAY;gBAC1CC,YAAYtB;gBACZuB,cAAc/B,OAAO4B,KAAK,CAACI,SAAS;gBACpCC,cAAcjC,OAAO4B,KAAK,CAACM,SAAS;gBACpCC,WAAW1B;gBACX2B,aAAapC,OAAO4B,KAAK,CAACS,QAAQ;YACpC;YAEA,OAAO3B,SAASC,IAAI,CAAC;gBAAEY;YAAM;QAC/B;QACAe,QAAQ;QACRC,MAAM;IACR;AACF"}
1
+ {"version":3,"sources":["../../src/endpoints/checkAvailability.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { getAvailableSlots } from '../services/AvailabilityService.js'\nimport { extractId, mergeResourceIds } from '../utilities/resolveRequiredResources.js'\n\nexport function createCheckAvailabilityEndpoint(\n config: ResolvedReservationPluginConfig,\n): Endpoint {\n return {\n handler: async (req) => {\n const url = new URL(req.url!)\n const date = url.searchParams.get('date')\n const resource = url.searchParams.get('resource')\n const service = url.searchParams.get('service')\n\n if (!date || !resource || !service) {\n return Response.json(\n { message: 'Missing required query params: resource, date, service' },\n { status: 400 },\n )\n }\n\n const parsedDate = new Date(date)\n if (isNaN(parsedDate.getTime())) {\n return Response.json(\n { error: 'Invalid date format. Expected YYYY-MM-DD' },\n { status: 400 },\n )\n }\n\n const guestCount = Math.max(Number(url.searchParams.get('guestCount') ?? '1'), 1)\n\n // Resolve required resource set: caller resource(s) ∪ service.requiredResources\n const explicit = url.searchParams.get('resources')\n const callerIds = explicit ? explicit.split(',').map((s) => s.trim()).filter(Boolean) : [resource]\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const svcDoc = await (req.payload.findByID as any)({\n id: service,\n collection: config.slugs.services,\n depth: 0,\n req,\n })\n const requiredIds = ((svcDoc?.requiredResources as unknown[]) ?? [])\n .map((r) => extractId(r))\n .filter((r): r is number | string => r !== undefined)\n const resourceIds = mergeResourceIds(callerIds, requiredIds)\n\n const slots = await getAvailableSlots({\n blockingStatuses: config.statusMachine.blockingStatuses,\n date: parsedDate,\n guestCount,\n payload: req.payload,\n req,\n reservationSlug: config.slugs.reservations,\n resourceIds,\n resourceSlug: config.slugs.resources,\n scheduleSlug: config.slugs.schedules,\n serviceId: service,\n serviceSlug: config.slugs.services,\n })\n\n return Response.json({ slots })\n },\n method: 'get',\n path: '/reserve/availability',\n }\n}\n"],"names":["getAvailableSlots","extractId","mergeResourceIds","createCheckAvailabilityEndpoint","config","handler","req","url","URL","date","searchParams","get","resource","service","Response","json","message","status","parsedDate","Date","isNaN","getTime","error","guestCount","Math","max","Number","explicit","callerIds","split","map","s","trim","filter","Boolean","svcDoc","payload","findByID","id","collection","slugs","services","depth","requiredIds","requiredResources","r","undefined","resourceIds","slots","blockingStatuses","statusMachine","reservationSlug","reservations","resourceSlug","resources","scheduleSlug","schedules","serviceId","serviceSlug","method","path"],"mappings":"AAIA,SAASA,iBAAiB,QAAQ,qCAAoC;AACtE,SAASC,SAAS,EAAEC,gBAAgB,QAAQ,2CAA0C;AAEtF,OAAO,SAASC,gCACdC,MAAuC;IAEvC,OAAO;QACLC,SAAS,OAAOC;YACd,MAAMC,MAAM,IAAIC,IAAIF,IAAIC,GAAG;YAC3B,MAAME,OAAOF,IAAIG,YAAY,CAACC,GAAG,CAAC;YAClC,MAAMC,WAAWL,IAAIG,YAAY,CAACC,GAAG,CAAC;YACtC,MAAME,UAAUN,IAAIG,YAAY,CAACC,GAAG,CAAC;YAErC,IAAI,CAACF,QAAQ,CAACG,YAAY,CAACC,SAAS;gBAClC,OAAOC,SAASC,IAAI,CAClB;oBAAEC,SAAS;gBAAyD,GACpE;oBAAEC,QAAQ;gBAAI;YAElB;YAEA,MAAMC,aAAa,IAAIC,KAAKV;YAC5B,IAAIW,MAAMF,WAAWG,OAAO,KAAK;gBAC/B,OAAOP,SAASC,IAAI,CAClB;oBAAEO,OAAO;gBAA2C,GACpD;oBAAEL,QAAQ;gBAAI;YAElB;YAEA,MAAMM,aAAaC,KAAKC,GAAG,CAACC,OAAOnB,IAAIG,YAAY,CAACC,GAAG,CAAC,iBAAiB,MAAM;YAE/E,gFAAgF;YAChF,MAAMgB,WAAWpB,IAAIG,YAAY,CAACC,GAAG,CAAC;YACtC,MAAMiB,YAAYD,WAAWA,SAASE,KAAK,CAAC,KAAKC,GAAG,CAAC,CAACC,IAAMA,EAAEC,IAAI,IAAIC,MAAM,CAACC,WAAW;gBAACtB;aAAS;YAElG,8DAA8D;YAC9D,MAAMuB,SAAS,MAAM,AAAC7B,IAAI8B,OAAO,CAACC,QAAQ,CAAS;gBACjDC,IAAIzB;gBACJ0B,YAAYnC,OAAOoC,KAAK,CAACC,QAAQ;gBACjCC,OAAO;gBACPpC;YACF;YACA,MAAMqC,cAAc,AAAC,CAAA,AAACR,QAAQS,qBAAmC,EAAE,AAAD,EAC/Dd,GAAG,CAAC,CAACe,IAAM5C,UAAU4C,IACrBZ,MAAM,CAAC,CAACY,IAA4BA,MAAMC;YAC7C,MAAMC,cAAc7C,iBAAiB0B,WAAWe;YAEhD,MAAMK,QAAQ,MAAMhD,kBAAkB;gBACpCiD,kBAAkB7C,OAAO8C,aAAa,CAACD,gBAAgB;gBACvDxC,MAAMS;gBACNK;gBACAa,SAAS9B,IAAI8B,OAAO;gBACpB9B;gBACA6C,iBAAiB/C,OAAOoC,KAAK,CAACY,YAAY;gBAC1CL;gBACAM,cAAcjD,OAAOoC,KAAK,CAACc,SAAS;gBACpCC,cAAcnD,OAAOoC,KAAK,CAACgB,SAAS;gBACpCC,WAAW5C;gBACX6C,aAAatD,OAAOoC,KAAK,CAACC,QAAQ;YACpC;YAEA,OAAO3B,SAASC,IAAI,CAAC;gBAAEiC;YAAM;QAC/B;QACAW,QAAQ;QACRC,MAAM;IACR;AACF"}
@@ -20,7 +20,10 @@ export function createBookingEndpoint(config) {
20
20
  data: bookingData,
21
21
  req
22
22
  });
23
- return Response.json(reservation, {
23
+ // Never expose the cancellation token in the HTTP response — it is delivered
24
+ // to the guest by the host project via the afterBookingCreate hook.
25
+ const { cancellationToken: _cancellationToken, ...safeReservation } = reservation;
26
+ return Response.json(safeReservation, {
24
27
  status: 201
25
28
  });
26
29
  },
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/createBooking.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nexport function createBookingEndpoint(config: ResolvedReservationPluginConfig): Endpoint {\n return {\n handler: async (req) => {\n const data = (await req.json?.()) as Record<string, unknown>\n\n // Call beforeBookingCreate plugin hooks before creating the reservation\n let bookingData = data\n if (config.hooks?.beforeBookingCreate) {\n for (const hook of config.hooks.beforeBookingCreate) {\n bookingData = (await hook({ data: bookingData, req })) ?? bookingData\n }\n }\n\n // Create via Payload Local API — collection hooks handle conflict detection,\n // endTime calculation, and status transitions\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const reservation = await (req.payload.create as any)({\n collection: config.slugs.reservations,\n data: bookingData,\n req,\n })\n\n return Response.json(reservation, { status: 201 })\n },\n method: 'post',\n path: '/reserve/book',\n }\n}\n"],"names":["createBookingEndpoint","config","handler","req","data","json","bookingData","hooks","beforeBookingCreate","hook","reservation","payload","create","collection","slugs","reservations","Response","status","method","path"],"mappings":"AAIA,OAAO,SAASA,sBAAsBC,MAAuC;IAC3E,OAAO;QACLC,SAAS,OAAOC;YACd,MAAMC,OAAQ,MAAMD,IAAIE,IAAI;YAE5B,wEAAwE;YACxE,IAAIC,cAAcF;YAClB,IAAIH,OAAOM,KAAK,EAAEC,qBAAqB;gBACrC,KAAK,MAAMC,QAAQR,OAAOM,KAAK,CAACC,mBAAmB,CAAE;oBACnDF,cAAc,AAAC,MAAMG,KAAK;wBAAEL,MAAME;wBAAaH;oBAAI,MAAOG;gBAC5D;YACF;YAEA,6EAA6E;YAC7E,8CAA8C;YAC9C,8DAA8D;YAC9D,MAAMI,cAAc,MAAM,AAACP,IAAIQ,OAAO,CAACC,MAAM,CAAS;gBACpDC,YAAYZ,OAAOa,KAAK,CAACC,YAAY;gBACrCX,MAAME;gBACNH;YACF;YAEA,OAAOa,SAASX,IAAI,CAACK,aAAa;gBAAEO,QAAQ;YAAI;QAClD;QACAC,QAAQ;QACRC,MAAM;IACR;AACF"}
1
+ {"version":3,"sources":["../../src/endpoints/createBooking.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nexport function createBookingEndpoint(config: ResolvedReservationPluginConfig): Endpoint {\n return {\n handler: async (req) => {\n const data = (await req.json?.()) as Record<string, unknown>\n\n // Call beforeBookingCreate plugin hooks before creating the reservation\n let bookingData = data\n if (config.hooks?.beforeBookingCreate) {\n for (const hook of config.hooks.beforeBookingCreate) {\n bookingData = (await hook({ data: bookingData, req })) ?? bookingData\n }\n }\n\n // Create via Payload Local API — collection hooks handle conflict detection,\n // endTime calculation, and status transitions\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const reservation = await (req.payload.create as any)({\n collection: config.slugs.reservations,\n data: bookingData,\n req,\n })\n\n // Never expose the cancellation token in the HTTP response — it is delivered\n // to the guest by the host project via the afterBookingCreate hook.\n const { cancellationToken: _cancellationToken, ...safeReservation } =\n reservation as Record<string, unknown>\n\n return Response.json(safeReservation, { status: 201 })\n },\n method: 'post',\n path: '/reserve/book',\n }\n}\n"],"names":["createBookingEndpoint","config","handler","req","data","json","bookingData","hooks","beforeBookingCreate","hook","reservation","payload","create","collection","slugs","reservations","cancellationToken","_cancellationToken","safeReservation","Response","status","method","path"],"mappings":"AAIA,OAAO,SAASA,sBAAsBC,MAAuC;IAC3E,OAAO;QACLC,SAAS,OAAOC;YACd,MAAMC,OAAQ,MAAMD,IAAIE,IAAI;YAE5B,wEAAwE;YACxE,IAAIC,cAAcF;YAClB,IAAIH,OAAOM,KAAK,EAAEC,qBAAqB;gBACrC,KAAK,MAAMC,QAAQR,OAAOM,KAAK,CAACC,mBAAmB,CAAE;oBACnDF,cAAc,AAAC,MAAMG,KAAK;wBAAEL,MAAME;wBAAaH;oBAAI,MAAOG;gBAC5D;YACF;YAEA,6EAA6E;YAC7E,8CAA8C;YAC9C,8DAA8D;YAC9D,MAAMI,cAAc,MAAM,AAACP,IAAIQ,OAAO,CAACC,MAAM,CAAS;gBACpDC,YAAYZ,OAAOa,KAAK,CAACC,YAAY;gBACrCX,MAAME;gBACNH;YACF;YAEA,6EAA6E;YAC7E,oEAAoE;YACpE,MAAM,EAAEa,mBAAmBC,kBAAkB,EAAE,GAAGC,iBAAiB,GACjER;YAEF,OAAOS,SAASd,IAAI,CAACa,iBAAiB;gBAAEE,QAAQ;YAAI;QACtD;QACAC,QAAQ;QACRC,MAAM;IACR;AACF"}
@@ -1,3 +1,4 @@
1
+ import { isPrivilegedUser, privilegedRoles } from '../utilities/userRoles.js';
1
2
  /**
2
3
  * Inspect a collection's field list and return the set of top-level named
3
4
  * fields as a plain Set<string>. Unnamed fields (rows, groups without a name,
@@ -21,8 +22,9 @@ export function createCustomerSearchEndpoint(config) {
21
22
  status: 401
22
23
  });
23
24
  }
24
- // Only allow staff/admin users (non-customer collection) to search customers
25
- if (req.user.collection === config.slugs.customers) {
25
+ // Only staff/admin may search customers. Role-aware so it works when staff
26
+ // and customers share one auth collection (userCollection set).
27
+ if (!isPrivilegedUser(req.user, config)) {
26
28
  return Response.json({
27
29
  message: 'Forbidden'
28
30
  }, {
@@ -40,7 +42,7 @@ export function createCustomerSearchEndpoint(config) {
40
42
  const hasFirstName = availableFields.has('firstName');
41
43
  const hasLastName = availableFields.has('lastName');
42
44
  const hasPhone = availableFields.has('phone');
43
- let where = {};
45
+ const andClauses = [];
44
46
  if (search) {
45
47
  const orClauses = [];
46
48
  if (hasName) {
@@ -77,10 +79,27 @@ export function createCustomerSearchEndpoint(config) {
77
79
  }
78
80
  });
79
81
  }
80
- where = {
82
+ andClauses.push({
81
83
  or: orClauses
82
- };
84
+ });
85
+ }
86
+ // Single-collection mode: staff/admin live in the same collection as
87
+ // customers, so exclude privileged roles — the dropdown should list only
88
+ // actual customers, not bookable-looking staff.
89
+ if (config.userCollection) {
90
+ const roleField = config.staffProvisioning?.roleField ?? 'role';
91
+ const priv = privilegedRoles(config);
92
+ if (priv.length > 0) {
93
+ andClauses.push({
94
+ [roleField]: {
95
+ not_in: priv
96
+ }
97
+ });
98
+ }
83
99
  }
100
+ const where = andClauses.length === 0 ? {} : andClauses.length === 1 ? andClauses[0] : {
101
+ and: andClauses
102
+ };
84
103
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
85
104
  const result = await req.payload.find({
86
105
  collection: config.slugs.customers,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/customerSearch.ts"],"sourcesContent":["import type { CollectionSlug, Endpoint, Field, Where } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\n/**\n * Inspect a collection's field list and return the set of top-level named\n * fields as a plain Set<string>. Unnamed fields (rows, groups without a name,\n * etc.) are skipped.\n */\nfunction getNamedFields(fields: Field[]): Set<string> {\n const names = new Set<string>()\n for (const field of fields) {\n if ('name' in field) {\n names.add(field.name)\n }\n }\n return names\n}\n\nexport function createCustomerSearchEndpoint(\n config: ResolvedReservationPluginConfig,\n): Endpoint {\n return {\n handler: async (req) => {\n if (!req.user) {\n return Response.json({ message: 'Unauthorized' }, { status: 401 })\n }\n\n // Only allow staff/admin users (non-customer collection) to search customers\n if (req.user.collection === config.slugs.customers) {\n return Response.json({ message: 'Forbidden' }, { status: 403 })\n }\n\n const url = new URL(req.url!)\n const search = url.searchParams.get('search') ?? ''\n const limit = Math.min(Number(url.searchParams.get('limit') ?? '10'), 50)\n const page = Math.max(Number(url.searchParams.get('page') ?? '1'), 1)\n\n // Detect which fields exist on the target collection at runtime\n const collectionConfig = req.payload.collections[config.slugs.customers as unknown as CollectionSlug]?.config\n const availableFields: Set<string> = collectionConfig\n ? getNamedFields(collectionConfig.fields)\n : new Set()\n\n const hasName = availableFields.has('name')\n const hasFirstName = availableFields.has('firstName')\n const hasLastName = availableFields.has('lastName')\n const hasPhone = availableFields.has('phone')\n\n let where: Where = {}\n\n if (search) {\n const orClauses: Where[] = []\n\n if (hasName) {\n orClauses.push({ name: { contains: search } })\n }\n if (hasFirstName) {\n orClauses.push({ firstName: { contains: search } })\n }\n if (hasLastName) {\n orClauses.push({ lastName: { contains: search } })\n }\n // email is always present on auth collections\n orClauses.push({ email: { contains: search } })\n if (hasPhone) {\n orClauses.push({ phone: { contains: search } })\n }\n\n where = { or: orClauses }\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const result = await (req.payload.find as any)({\n collection: config.slugs.customers,\n limit,\n page,\n where,\n })\n\n return Response.json({\n docs: (result.docs as Record<string, unknown>[]).map((doc) => {\n const entry: Record<string, unknown> = {\n id: doc['id'],\n email: doc['email'] ?? '',\n }\n\n if (hasName) {\n entry['name'] = doc['name'] ?? ''\n }\n if (hasFirstName) {\n entry['firstName'] = doc['firstName'] ?? ''\n }\n if (hasLastName) {\n entry['lastName'] = doc['lastName'] ?? ''\n }\n if (hasPhone) {\n entry['phone'] = doc['phone'] ?? ''\n }\n\n return entry\n }),\n hasNextPage: result.hasNextPage,\n totalDocs: result.totalDocs,\n })\n },\n method: 'get',\n path: '/reservation-customer-search',\n }\n}\n"],"names":["getNamedFields","fields","names","Set","field","add","name","createCustomerSearchEndpoint","config","handler","req","user","Response","json","message","status","collection","slugs","customers","url","URL","search","searchParams","get","limit","Math","min","Number","page","max","collectionConfig","payload","collections","availableFields","hasName","has","hasFirstName","hasLastName","hasPhone","where","orClauses","push","contains","firstName","lastName","email","phone","or","result","find","docs","map","doc","entry","id","hasNextPage","totalDocs","method","path"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/endpoints/customerSearch.ts"],"sourcesContent":["import type { CollectionSlug, Endpoint, Field, Where } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { isPrivilegedUser, privilegedRoles } from '../utilities/userRoles.js'\n\n/**\n * Inspect a collection's field list and return the set of top-level named\n * fields as a plain Set<string>. Unnamed fields (rows, groups without a name,\n * etc.) are skipped.\n */\nfunction getNamedFields(fields: Field[]): Set<string> {\n const names = new Set<string>()\n for (const field of fields) {\n if ('name' in field) {\n names.add(field.name)\n }\n }\n return names\n}\n\nexport function createCustomerSearchEndpoint(\n config: ResolvedReservationPluginConfig,\n): Endpoint {\n return {\n handler: async (req) => {\n if (!req.user) {\n return Response.json({ message: 'Unauthorized' }, { status: 401 })\n }\n\n // Only staff/admin may search customers. Role-aware so it works when staff\n // and customers share one auth collection (userCollection set).\n if (!isPrivilegedUser(req.user, config)) {\n return Response.json({ message: 'Forbidden' }, { status: 403 })\n }\n\n const url = new URL(req.url!)\n const search = url.searchParams.get('search') ?? ''\n const limit = Math.min(Number(url.searchParams.get('limit') ?? '10'), 50)\n const page = Math.max(Number(url.searchParams.get('page') ?? '1'), 1)\n\n // Detect which fields exist on the target collection at runtime\n const collectionConfig = req.payload.collections[config.slugs.customers as unknown as CollectionSlug]?.config\n const availableFields: Set<string> = collectionConfig\n ? getNamedFields(collectionConfig.fields)\n : new Set()\n\n const hasName = availableFields.has('name')\n const hasFirstName = availableFields.has('firstName')\n const hasLastName = availableFields.has('lastName')\n const hasPhone = availableFields.has('phone')\n\n const andClauses: Where[] = []\n\n if (search) {\n const orClauses: Where[] = []\n\n if (hasName) {\n orClauses.push({ name: { contains: search } })\n }\n if (hasFirstName) {\n orClauses.push({ firstName: { contains: search } })\n }\n if (hasLastName) {\n orClauses.push({ lastName: { contains: search } })\n }\n // email is always present on auth collections\n orClauses.push({ email: { contains: search } })\n if (hasPhone) {\n orClauses.push({ phone: { contains: search } })\n }\n\n andClauses.push({ or: orClauses })\n }\n\n // Single-collection mode: staff/admin live in the same collection as\n // customers, so exclude privileged roles — the dropdown should list only\n // actual customers, not bookable-looking staff.\n if (config.userCollection) {\n const roleField = config.staffProvisioning?.roleField ?? 'role'\n const priv = privilegedRoles(config)\n if (priv.length > 0) {\n andClauses.push({ [roleField]: { not_in: priv } })\n }\n }\n\n const where: Where =\n andClauses.length === 0\n ? {}\n : andClauses.length === 1\n ? andClauses[0]\n : { and: andClauses }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const result = await (req.payload.find as any)({\n collection: config.slugs.customers,\n limit,\n page,\n where,\n })\n\n return Response.json({\n docs: (result.docs as Record<string, unknown>[]).map((doc) => {\n const entry: Record<string, unknown> = {\n id: doc['id'],\n email: doc['email'] ?? '',\n }\n\n if (hasName) {\n entry['name'] = doc['name'] ?? ''\n }\n if (hasFirstName) {\n entry['firstName'] = doc['firstName'] ?? ''\n }\n if (hasLastName) {\n entry['lastName'] = doc['lastName'] ?? ''\n }\n if (hasPhone) {\n entry['phone'] = doc['phone'] ?? ''\n }\n\n return entry\n }),\n hasNextPage: result.hasNextPage,\n totalDocs: result.totalDocs,\n })\n },\n method: 'get',\n path: '/reservation-customer-search',\n }\n}\n"],"names":["isPrivilegedUser","privilegedRoles","getNamedFields","fields","names","Set","field","add","name","createCustomerSearchEndpoint","config","handler","req","user","Response","json","message","status","url","URL","search","searchParams","get","limit","Math","min","Number","page","max","collectionConfig","payload","collections","slugs","customers","availableFields","hasName","has","hasFirstName","hasLastName","hasPhone","andClauses","orClauses","push","contains","firstName","lastName","email","phone","or","userCollection","roleField","staffProvisioning","priv","length","not_in","where","and","result","find","collection","docs","map","doc","entry","id","hasNextPage","totalDocs","method","path"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { getAvailableSlots } from '../services/AvailabilityService.js';
2
+ import { extractId, mergeResourceIds } from '../utilities/resolveRequiredResources.js';
2
3
  export function createGetSlotsEndpoint(config) {
3
4
  return {
4
5
  handler: async (req)=>{
@@ -22,6 +23,20 @@ export function createGetSlotsEndpoint(config) {
22
23
  });
23
24
  }
24
25
  const guestCount = Math.max(Number(url.searchParams.get('guestCount') ?? '1'), 1);
26
+ // Resolve required resource set: caller resource(s) ∪ service.requiredResources
27
+ const explicit = url.searchParams.get('resources');
28
+ const callerIds = explicit ? explicit.split(',').map((s)=>s.trim()).filter(Boolean) : [
29
+ resource
30
+ ];
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ const svcDoc = await req.payload.findByID({
33
+ id: service,
34
+ collection: config.slugs.services,
35
+ depth: 0,
36
+ req
37
+ });
38
+ const requiredIds = (svcDoc?.requiredResources ?? []).map((r)=>extractId(r)).filter((r)=>r !== undefined);
39
+ const resourceIds = mergeResourceIds(callerIds, requiredIds);
25
40
  const slots = await getAvailableSlots({
26
41
  blockingStatuses: config.statusMachine.blockingStatuses,
27
42
  date: parsedDate,
@@ -29,7 +44,7 @@ export function createGetSlotsEndpoint(config) {
29
44
  payload: req.payload,
30
45
  req,
31
46
  reservationSlug: config.slugs.reservations,
32
- resourceId: resource,
47
+ resourceIds,
33
48
  resourceSlug: config.slugs.resources,
34
49
  scheduleSlug: config.slugs.schedules,
35
50
  serviceId: service,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/getSlots.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { getAvailableSlots } from '../services/AvailabilityService.js'\n\nexport function createGetSlotsEndpoint(config: ResolvedReservationPluginConfig): Endpoint {\n return {\n handler: async (req) => {\n const url = new URL(req.url!)\n const date = url.searchParams.get('date')\n const resource = url.searchParams.get('resource')\n const service = url.searchParams.get('service')\n\n if (!date || !resource || !service) {\n return Response.json(\n { error: 'Missing required query params: resource, date, service' },\n { status: 400 },\n )\n }\n\n const parsedDate = new Date(date)\n if (isNaN(parsedDate.getTime())) {\n return Response.json(\n { error: 'Invalid date format. Expected YYYY-MM-DD' },\n { status: 400 },\n )\n }\n\n const guestCount = Math.max(Number(url.searchParams.get('guestCount') ?? '1'), 1)\n\n const slots = await getAvailableSlots({\n blockingStatuses: config.statusMachine.blockingStatuses,\n date: parsedDate,\n guestCount,\n payload: req.payload,\n req,\n reservationSlug: config.slugs.reservations,\n resourceId: resource,\n resourceSlug: config.slugs.resources,\n scheduleSlug: config.slugs.schedules,\n serviceId: service,\n serviceSlug: config.slugs.services,\n })\n\n return Response.json({\n date,\n guestCount,\n slots: slots.map((s) => ({ end: s.end.toISOString(), start: s.start.toISOString() })),\n })\n },\n method: 'get',\n path: '/reserve/slots',\n }\n}\n"],"names":["getAvailableSlots","createGetSlotsEndpoint","config","handler","req","url","URL","date","searchParams","get","resource","service","Response","json","error","status","parsedDate","Date","isNaN","getTime","guestCount","Math","max","Number","slots","blockingStatuses","statusMachine","payload","reservationSlug","slugs","reservations","resourceId","resourceSlug","resources","scheduleSlug","schedules","serviceId","serviceSlug","services","map","s","end","toISOString","start","method","path"],"mappings":"AAIA,SAASA,iBAAiB,QAAQ,qCAAoC;AAEtE,OAAO,SAASC,uBAAuBC,MAAuC;IAC5E,OAAO;QACLC,SAAS,OAAOC;YACd,MAAMC,MAAM,IAAIC,IAAIF,IAAIC,GAAG;YAC3B,MAAME,OAAOF,IAAIG,YAAY,CAACC,GAAG,CAAC;YAClC,MAAMC,WAAWL,IAAIG,YAAY,CAACC,GAAG,CAAC;YACtC,MAAME,UAAUN,IAAIG,YAAY,CAACC,GAAG,CAAC;YAErC,IAAI,CAACF,QAAQ,CAACG,YAAY,CAACC,SAAS;gBAClC,OAAOC,SAASC,IAAI,CAClB;oBAAEC,OAAO;gBAAyD,GAClE;oBAAEC,QAAQ;gBAAI;YAElB;YAEA,MAAMC,aAAa,IAAIC,KAAKV;YAC5B,IAAIW,MAAMF,WAAWG,OAAO,KAAK;gBAC/B,OAAOP,SAASC,IAAI,CAClB;oBAAEC,OAAO;gBAA2C,GACpD;oBAAEC,QAAQ;gBAAI;YAElB;YAEA,MAAMK,aAAaC,KAAKC,GAAG,CAACC,OAAOlB,IAAIG,YAAY,CAACC,GAAG,CAAC,iBAAiB,MAAM;YAE/E,MAAMe,QAAQ,MAAMxB,kBAAkB;gBACpCyB,kBAAkBvB,OAAOwB,aAAa,CAACD,gBAAgB;gBACvDlB,MAAMS;gBACNI;gBACAO,SAASvB,IAAIuB,OAAO;gBACpBvB;gBACAwB,iBAAiB1B,OAAO2B,KAAK,CAACC,YAAY;gBAC1CC,YAAYrB;gBACZsB,cAAc9B,OAAO2B,KAAK,CAACI,SAAS;gBACpCC,cAAchC,OAAO2B,KAAK,CAACM,SAAS;gBACpCC,WAAWzB;gBACX0B,aAAanC,OAAO2B,KAAK,CAACS,QAAQ;YACpC;YAEA,OAAO1B,SAASC,IAAI,CAAC;gBACnBN;gBACAa;gBACAI,OAAOA,MAAMe,GAAG,CAAC,CAACC,IAAO,CAAA;wBAAEC,KAAKD,EAAEC,GAAG,CAACC,WAAW;wBAAIC,OAAOH,EAAEG,KAAK,CAACD,WAAW;oBAAG,CAAA;YACpF;QACF;QACAE,QAAQ;QACRC,MAAM;IACR;AACF"}
1
+ {"version":3,"sources":["../../src/endpoints/getSlots.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { getAvailableSlots } from '../services/AvailabilityService.js'\nimport { extractId, mergeResourceIds } from '../utilities/resolveRequiredResources.js'\n\nexport function createGetSlotsEndpoint(config: ResolvedReservationPluginConfig): Endpoint {\n return {\n handler: async (req) => {\n const url = new URL(req.url!)\n const date = url.searchParams.get('date')\n const resource = url.searchParams.get('resource')\n const service = url.searchParams.get('service')\n\n if (!date || !resource || !service) {\n return Response.json(\n { error: 'Missing required query params: resource, date, service' },\n { status: 400 },\n )\n }\n\n const parsedDate = new Date(date)\n if (isNaN(parsedDate.getTime())) {\n return Response.json(\n { error: 'Invalid date format. Expected YYYY-MM-DD' },\n { status: 400 },\n )\n }\n\n const guestCount = Math.max(Number(url.searchParams.get('guestCount') ?? '1'), 1)\n\n // Resolve required resource set: caller resource(s) ∪ service.requiredResources\n const explicit = url.searchParams.get('resources')\n const callerIds = explicit ? explicit.split(',').map((s) => s.trim()).filter(Boolean) : [resource]\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const svcDoc = await (req.payload.findByID as any)({\n id: service,\n collection: config.slugs.services,\n depth: 0,\n req,\n })\n const requiredIds = ((svcDoc?.requiredResources as unknown[]) ?? [])\n .map((r) => extractId(r))\n .filter((r): r is number | string => r !== undefined)\n const resourceIds = mergeResourceIds(callerIds, requiredIds)\n\n const slots = await getAvailableSlots({\n blockingStatuses: config.statusMachine.blockingStatuses,\n date: parsedDate,\n guestCount,\n payload: req.payload,\n req,\n reservationSlug: config.slugs.reservations,\n resourceIds,\n resourceSlug: config.slugs.resources,\n scheduleSlug: config.slugs.schedules,\n serviceId: service,\n serviceSlug: config.slugs.services,\n })\n\n return Response.json({\n date,\n guestCount,\n slots: slots.map((s) => ({ end: s.end.toISOString(), start: s.start.toISOString() })),\n })\n },\n method: 'get',\n path: '/reserve/slots',\n }\n}\n"],"names":["getAvailableSlots","extractId","mergeResourceIds","createGetSlotsEndpoint","config","handler","req","url","URL","date","searchParams","get","resource","service","Response","json","error","status","parsedDate","Date","isNaN","getTime","guestCount","Math","max","Number","explicit","callerIds","split","map","s","trim","filter","Boolean","svcDoc","payload","findByID","id","collection","slugs","services","depth","requiredIds","requiredResources","r","undefined","resourceIds","slots","blockingStatuses","statusMachine","reservationSlug","reservations","resourceSlug","resources","scheduleSlug","schedules","serviceId","serviceSlug","end","toISOString","start","method","path"],"mappings":"AAIA,SAASA,iBAAiB,QAAQ,qCAAoC;AACtE,SAASC,SAAS,EAAEC,gBAAgB,QAAQ,2CAA0C;AAEtF,OAAO,SAASC,uBAAuBC,MAAuC;IAC5E,OAAO;QACLC,SAAS,OAAOC;YACd,MAAMC,MAAM,IAAIC,IAAIF,IAAIC,GAAG;YAC3B,MAAME,OAAOF,IAAIG,YAAY,CAACC,GAAG,CAAC;YAClC,MAAMC,WAAWL,IAAIG,YAAY,CAACC,GAAG,CAAC;YACtC,MAAME,UAAUN,IAAIG,YAAY,CAACC,GAAG,CAAC;YAErC,IAAI,CAACF,QAAQ,CAACG,YAAY,CAACC,SAAS;gBAClC,OAAOC,SAASC,IAAI,CAClB;oBAAEC,OAAO;gBAAyD,GAClE;oBAAEC,QAAQ;gBAAI;YAElB;YAEA,MAAMC,aAAa,IAAIC,KAAKV;YAC5B,IAAIW,MAAMF,WAAWG,OAAO,KAAK;gBAC/B,OAAOP,SAASC,IAAI,CAClB;oBAAEC,OAAO;gBAA2C,GACpD;oBAAEC,QAAQ;gBAAI;YAElB;YAEA,MAAMK,aAAaC,KAAKC,GAAG,CAACC,OAAOlB,IAAIG,YAAY,CAACC,GAAG,CAAC,iBAAiB,MAAM;YAE/E,gFAAgF;YAChF,MAAMe,WAAWnB,IAAIG,YAAY,CAACC,GAAG,CAAC;YACtC,MAAMgB,YAAYD,WAAWA,SAASE,KAAK,CAAC,KAAKC,GAAG,CAAC,CAACC,IAAMA,EAAEC,IAAI,IAAIC,MAAM,CAACC,WAAW;gBAACrB;aAAS;YAElG,8DAA8D;YAC9D,MAAMsB,SAAS,MAAM,AAAC5B,IAAI6B,OAAO,CAACC,QAAQ,CAAS;gBACjDC,IAAIxB;gBACJyB,YAAYlC,OAAOmC,KAAK,CAACC,QAAQ;gBACjCC,OAAO;gBACPnC;YACF;YACA,MAAMoC,cAAc,AAAC,CAAA,AAACR,QAAQS,qBAAmC,EAAE,AAAD,EAC/Dd,GAAG,CAAC,CAACe,IAAM3C,UAAU2C,IACrBZ,MAAM,CAAC,CAACY,IAA4BA,MAAMC;YAC7C,MAAMC,cAAc5C,iBAAiByB,WAAWe;YAEhD,MAAMK,QAAQ,MAAM/C,kBAAkB;gBACpCgD,kBAAkB5C,OAAO6C,aAAa,CAACD,gBAAgB;gBACvDvC,MAAMS;gBACNI;gBACAa,SAAS7B,IAAI6B,OAAO;gBACpB7B;gBACA4C,iBAAiB9C,OAAOmC,KAAK,CAACY,YAAY;gBAC1CL;gBACAM,cAAchD,OAAOmC,KAAK,CAACc,SAAS;gBACpCC,cAAclD,OAAOmC,KAAK,CAACgB,SAAS;gBACpCC,WAAW3C;gBACX4C,aAAarD,OAAOmC,KAAK,CAACC,QAAQ;YACpC;YAEA,OAAO1B,SAASC,IAAI,CAAC;gBACnBN;gBACAa;gBACAyB,OAAOA,MAAMlB,GAAG,CAAC,CAACC,IAAO,CAAA;wBAAE4B,KAAK5B,EAAE4B,GAAG,CAACC,WAAW;wBAAIC,OAAO9B,EAAE8B,KAAK,CAACD,WAAW;oBAAG,CAAA;YACpF;QACF;QACAE,QAAQ;QACRC,MAAM;IACR;AACF"}
@@ -0,0 +1,43 @@
1
+ import type { Endpoint, Payload } from 'payload';
2
+ import type { ResolvedReservationPluginConfig } from '../types.js';
3
+ type DayAvailability = {
4
+ date: string;
5
+ shiftWindows: Array<{
6
+ end: string;
7
+ start: string;
8
+ }>;
9
+ timeOff: Array<{
10
+ end: string;
11
+ reason?: string;
12
+ start: string;
13
+ type?: string;
14
+ }>;
15
+ };
16
+ type Busy = Array<{
17
+ end: string;
18
+ start: string;
19
+ units: number;
20
+ }>;
21
+ export type ResourceAvailability = {
22
+ busy: Busy;
23
+ capacityMode: 'per-guest' | 'per-reservation';
24
+ days: DayAvailability[];
25
+ quantity: number;
26
+ /** Capacity of resources this resource's services also require (e.g. a chair pool). */
27
+ requiredPools: Array<{
28
+ busy: Busy;
29
+ quantity: number;
30
+ }>;
31
+ };
32
+ export declare function buildResourceAvailability(params: {
33
+ blockingStatuses: string[];
34
+ end: Date;
35
+ payload: Payload;
36
+ reservationSlug: string;
37
+ resourceId: number | string;
38
+ resourceSlug: string;
39
+ scheduleSlug: string;
40
+ start: Date;
41
+ }): Promise<ResourceAvailability>;
42
+ export declare function createResourceAvailabilityEndpoint(config: ResolvedReservationPluginConfig): Endpoint;
43
+ export {};
@@ -0,0 +1,214 @@
1
+ import { resolveScheduleForDate } from '../utilities/scheduleUtils.js';
2
+ import { localDayKey } from '../utilities/slotUtils.js';
3
+ /** Busy intervals (with capacity units) for one resource over [start, end). */ async function busyFor(args) {
4
+ const { blockingStatuses, capacityMode, end, payload, reservationSlug, resourceId, start } = args;
5
+ const where = {
6
+ and: [
7
+ {
8
+ status: {
9
+ in: blockingStatuses
10
+ }
11
+ },
12
+ {
13
+ startTime: {
14
+ less_than: end.toISOString()
15
+ }
16
+ },
17
+ {
18
+ endTime: {
19
+ greater_than: start.toISOString()
20
+ }
21
+ },
22
+ {
23
+ or: [
24
+ {
25
+ resource: {
26
+ equals: resourceId
27
+ }
28
+ },
29
+ {
30
+ 'items.resource': {
31
+ equals: resourceId
32
+ }
33
+ }
34
+ ]
35
+ }
36
+ ]
37
+ };
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ const { docs } = await payload.find({
40
+ collection: reservationSlug,
41
+ depth: 0,
42
+ limit: 500,
43
+ where
44
+ });
45
+ return docs.filter((r)=>r.startTime && r.endTime).map((r)=>({
46
+ end: new Date(r.endTime).toISOString(),
47
+ start: new Date(r.startTime).toISOString(),
48
+ units: capacityMode === 'per-guest' ? r.guestCount ?? 1 : 1
49
+ }));
50
+ }
51
+ export async function buildResourceAvailability(params) {
52
+ const { blockingStatuses, end, payload, reservationSlug, resourceId, resourceSlug, scheduleSlug, start } = params;
53
+ // depth 1 so `services` are populated (their `requiredResources` come back as ids)
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ const resource = await payload.findByID({
56
+ id: resourceId,
57
+ collection: resourceSlug,
58
+ depth: 1
59
+ });
60
+ const quantity = resource?.quantity ?? 1;
61
+ const capacityMode = resource?.capacityMode ?? 'per-reservation';
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
+ const { docs: schedules } = await payload.find({
64
+ collection: scheduleSlug,
65
+ depth: 0,
66
+ limit: 100,
67
+ where: {
68
+ and: [
69
+ {
70
+ active: {
71
+ equals: true
72
+ }
73
+ },
74
+ {
75
+ resource: {
76
+ equals: resourceId
77
+ }
78
+ }
79
+ ]
80
+ }
81
+ });
82
+ const days = [];
83
+ for(let d = new Date(start); d < end; d = new Date(d.getTime() + 86_400_000)){
84
+ const date = localDayKey(d);
85
+ const shiftWindows = [];
86
+ const timeOff = [];
87
+ const localMidnight = new Date(d.getFullYear(), d.getMonth(), d.getDate());
88
+ for (const sched of schedules){
89
+ // resolveScheduleForDate accepts a Schedule-shaped object; cast through unknown
90
+ const ranges = resolveScheduleForDate(sched, localMidnight);
91
+ for (const r of ranges){
92
+ shiftWindows.push({
93
+ end: r.end.toISOString(),
94
+ start: r.start.toISOString()
95
+ });
96
+ }
97
+ const exceptions = sched.exceptions ?? [];
98
+ for (const exc of exceptions){
99
+ const excStart = localDayKey(new Date(exc.date));
100
+ const excEnd = exc.endDate ? localDayKey(new Date(exc.endDate)) : excStart;
101
+ if (date >= excStart && date <= excEnd) {
102
+ const localStart = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
103
+ const localEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
104
+ timeOff.push({
105
+ type: exc.type,
106
+ end: localEnd.toISOString(),
107
+ reason: exc.reason,
108
+ start: localStart.toISOString()
109
+ });
110
+ }
111
+ }
112
+ }
113
+ days.push({
114
+ date,
115
+ shiftWindows,
116
+ timeOff
117
+ });
118
+ }
119
+ const busy = await busyFor({
120
+ blockingStatuses,
121
+ capacityMode,
122
+ end,
123
+ payload,
124
+ reservationSlug,
125
+ resourceId,
126
+ start
127
+ });
128
+ // Resources this resource's services ALSO require (e.g. a shared chair pool).
129
+ // A slot isn't truly bookable if any of these is at capacity, even when the
130
+ // resource itself is free — so the calendar reflects real availability.
131
+ const poolIds = new Set();
132
+ for (const svc of resource?.services ?? []){
133
+ const reqs = (typeof svc === 'object' ? svc.requiredResources : []) ?? [];
134
+ for (const rr of reqs){
135
+ const id = typeof rr === 'object' && rr !== null ? rr.id : rr;
136
+ if (id != null && String(id) !== String(resourceId)) {
137
+ poolIds.add(String(id));
138
+ }
139
+ }
140
+ }
141
+ const requiredPools = [];
142
+ for (const poolId of poolIds){
143
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
144
+ const pool = await payload.findByID({
145
+ id: poolId,
146
+ collection: resourceSlug,
147
+ depth: 0
148
+ }).catch(()=>null);
149
+ if (!pool) {
150
+ continue;
151
+ }
152
+ const poolCapacityMode = pool.capacityMode ?? 'per-reservation';
153
+ requiredPools.push({
154
+ busy: await busyFor({
155
+ blockingStatuses,
156
+ capacityMode: poolCapacityMode,
157
+ end,
158
+ payload,
159
+ reservationSlug,
160
+ resourceId: poolId,
161
+ start
162
+ }),
163
+ quantity: pool.quantity ?? 1
164
+ });
165
+ }
166
+ return {
167
+ busy,
168
+ capacityMode,
169
+ days,
170
+ quantity,
171
+ requiredPools
172
+ };
173
+ }
174
+ export function createResourceAvailabilityEndpoint(config) {
175
+ return {
176
+ handler: async (req)=>{
177
+ const url = new URL(req.url);
178
+ const resource = url.searchParams.get('resource');
179
+ const start = url.searchParams.get('start');
180
+ const end = url.searchParams.get('end');
181
+ if (!resource || !start || !end) {
182
+ return Response.json({
183
+ error: 'Missing required query params: resource, start, end'
184
+ }, {
185
+ status: 400
186
+ });
187
+ }
188
+ const startDate = new Date(start);
189
+ const endDate = new Date(end);
190
+ if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
191
+ return Response.json({
192
+ error: 'Invalid start/end date'
193
+ }, {
194
+ status: 400
195
+ });
196
+ }
197
+ const result = await buildResourceAvailability({
198
+ blockingStatuses: config.statusMachine.blockingStatuses,
199
+ end: endDate,
200
+ payload: req.payload,
201
+ reservationSlug: config.slugs.reservations,
202
+ resourceId: resource,
203
+ resourceSlug: config.slugs.resources,
204
+ scheduleSlug: config.slugs.schedules,
205
+ start: startDate
206
+ });
207
+ return Response.json(result);
208
+ },
209
+ method: 'get',
210
+ path: '/reserve/resource-availability'
211
+ };
212
+ }
213
+
214
+ //# sourceMappingURL=resourceAvailability.js.map