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
@@ -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\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
+ {"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 limitRaw = Number(url.searchParams.get('limit') ?? '10')\n const pageRaw = Number(url.searchParams.get('page') ?? '1')\n // Non-numeric input falls back to defaults instead of passing NaN to the DB\n const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(Math.floor(limitRaw), 1), 50) : 10\n const page = Number.isFinite(pageRaw) ? Math.max(Math.floor(pageRaw), 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","limitRaw","Number","pageRaw","limit","isFinite","Math","min","max","floor","page","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":"AAIA,SAASA,gBAAgB,EAAEC,eAAe,QAAQ,4BAA2B;AAE7E;;;;CAIC,GACD,SAASC,eAAeC,MAAe;IACrC,MAAMC,QAAQ,IAAIC;IAClB,KAAK,MAAMC,SAASH,OAAQ;QAC1B,IAAI,UAAUG,OAAO;YACnBF,MAAMG,GAAG,CAACD,MAAME,IAAI;QACtB;IACF;IACA,OAAOJ;AACT;AAEA,OAAO,SAASK,6BACdC,MAAuC;IAEvC,OAAO;QACLC,SAAS,OAAOC;YACd,IAAI,CAACA,IAAIC,IAAI,EAAE;gBACb,OAAOC,SAASC,IAAI,CAAC;oBAAEC,SAAS;gBAAe,GAAG;oBAAEC,QAAQ;gBAAI;YAClE;YAEA,2EAA2E;YAC3E,gEAAgE;YAChE,IAAI,CAACjB,iBAAiBY,IAAIC,IAAI,EAAEH,SAAS;gBACvC,OAAOI,SAASC,IAAI,CAAC;oBAAEC,SAAS;gBAAY,GAAG;oBAAEC,QAAQ;gBAAI;YAC/D;YAEA,MAAMC,MAAM,IAAIC,IAAIP,IAAIM,GAAG;YAC3B,MAAME,SAASF,IAAIG,YAAY,CAACC,GAAG,CAAC,aAAa;YACjD,MAAMC,WAAWC,OAAON,IAAIG,YAAY,CAACC,GAAG,CAAC,YAAY;YACzD,MAAMG,UAAUD,OAAON,IAAIG,YAAY,CAACC,GAAG,CAAC,WAAW;YACvD,4EAA4E;YAC5E,MAAMI,QAAQF,OAAOG,QAAQ,CAACJ,YAAYK,KAAKC,GAAG,CAACD,KAAKE,GAAG,CAACF,KAAKG,KAAK,CAACR,WAAW,IAAI,MAAM;YAC5F,MAAMS,OAAOR,OAAOG,QAAQ,CAACF,WAAWG,KAAKE,GAAG,CAACF,KAAKG,KAAK,CAACN,UAAU,KAAK;YAE3E,gEAAgE;YAChE,MAAMQ,mBAAmBrB,IAAIsB,OAAO,CAACC,WAAW,CAACzB,OAAO0B,KAAK,CAACC,SAAS,CAA8B,EAAE3B;YACvG,MAAM4B,kBAA+BL,mBACjC/B,eAAe+B,iBAAiB9B,MAAM,IACtC,IAAIE;YAER,MAAMkC,UAAUD,gBAAgBE,GAAG,CAAC;YACpC,MAAMC,eAAeH,gBAAgBE,GAAG,CAAC;YACzC,MAAME,cAAcJ,gBAAgBE,GAAG,CAAC;YACxC,MAAMG,WAAWL,gBAAgBE,GAAG,CAAC;YAErC,MAAMI,aAAsB,EAAE;YAE9B,IAAIxB,QAAQ;gBACV,MAAMyB,YAAqB,EAAE;gBAE7B,IAAIN,SAAS;oBACXM,UAAUC,IAAI,CAAC;wBAAEtC,MAAM;4BAAEuC,UAAU3B;wBAAO;oBAAE;gBAC9C;gBACA,IAAIqB,cAAc;oBAChBI,UAAUC,IAAI,CAAC;wBAAEE,WAAW;4BAAED,UAAU3B;wBAAO;oBAAE;gBACnD;gBACA,IAAIsB,aAAa;oBACfG,UAAUC,IAAI,CAAC;wBAAEG,UAAU;4BAAEF,UAAU3B;wBAAO;oBAAE;gBAClD;gBACA,8CAA8C;gBAC9CyB,UAAUC,IAAI,CAAC;oBAAEI,OAAO;wBAAEH,UAAU3B;oBAAO;gBAAE;gBAC7C,IAAIuB,UAAU;oBACZE,UAAUC,IAAI,CAAC;wBAAEK,OAAO;4BAAEJ,UAAU3B;wBAAO;oBAAE;gBAC/C;gBAEAwB,WAAWE,IAAI,CAAC;oBAAEM,IAAIP;gBAAU;YAClC;YAEA,qEAAqE;YACrE,yEAAyE;YACzE,gDAAgD;YAChD,IAAInC,OAAO2C,cAAc,EAAE;gBACzB,MAAMC,YAAY5C,OAAO6C,iBAAiB,EAAED,aAAa;gBACzD,MAAME,OAAOvD,gBAAgBS;gBAC7B,IAAI8C,KAAKC,MAAM,GAAG,GAAG;oBACnBb,WAAWE,IAAI,CAAC;wBAAE,CAACQ,UAAU,EAAE;4BAAEI,QAAQF;wBAAK;oBAAE;gBAClD;YACF;YAEA,MAAMG,QACJf,WAAWa,MAAM,KAAK,IAClB,CAAC,IACDb,WAAWa,MAAM,KAAK,IACpBb,UAAU,CAAC,EAAE,GACb;gBAAEgB,KAAKhB;YAAW;YAE1B,8DAA8D;YAC9D,MAAMiB,SAAS,MAAM,AAACjD,IAAIsB,OAAO,CAAC4B,IAAI,CAAS;gBAC7CC,YAAYrD,OAAO0B,KAAK,CAACC,SAAS;gBAClCX;gBACAM;gBACA2B;YACF;YAEA,OAAO7C,SAASC,IAAI,CAAC;gBACnBiD,MAAM,AAACH,OAAOG,IAAI,CAA+BC,GAAG,CAAC,CAACC;oBACpD,MAAMC,QAAiC;wBACrCC,IAAIF,GAAG,CAAC,KAAK;wBACbhB,OAAOgB,GAAG,CAAC,QAAQ,IAAI;oBACzB;oBAEA,IAAI3B,SAAS;wBACX4B,KAAK,CAAC,OAAO,GAAGD,GAAG,CAAC,OAAO,IAAI;oBACjC;oBACA,IAAIzB,cAAc;wBAChB0B,KAAK,CAAC,YAAY,GAAGD,GAAG,CAAC,YAAY,IAAI;oBAC3C;oBACA,IAAIxB,aAAa;wBACfyB,KAAK,CAAC,WAAW,GAAGD,GAAG,CAAC,WAAW,IAAI;oBACzC;oBACA,IAAIvB,UAAU;wBACZwB,KAAK,CAAC,QAAQ,GAAGD,GAAG,CAAC,QAAQ,IAAI;oBACnC;oBAEA,OAAOC;gBACT;gBACAE,aAAaR,OAAOQ,WAAW;gBAC/BC,WAAWT,OAAOS,SAAS;YAC7B;QACF;QACAC,QAAQ;QACRC,MAAM;IACR;AACF"}
@@ -1,5 +1,6 @@
1
1
  import { getAvailableSlots } from '../services/AvailabilityService.js';
2
2
  import { extractId, mergeResourceIds } from '../utilities/resolveRequiredResources.js';
3
+ import { getDayKeyInTimezone, isValidDayKey } from '../utilities/timezoneUtils.js';
3
4
  export function createGetSlotsEndpoint(config) {
4
5
  return {
5
6
  handler: async (req)=>{
@@ -14,15 +15,39 @@ export function createGetSlotsEndpoint(config) {
14
15
  status: 400
15
16
  });
16
17
  }
17
- const parsedDate = new Date(date);
18
- if (isNaN(parsedDate.getTime())) {
18
+ // YYYY-MM-DD is taken as a business-TZ calendar day verbatim; any other
19
+ // parseable date is re-keyed into the business timezone. Never
20
+ // `new Date('YYYY-MM-DD')` — that pins the day to UTC midnight.
21
+ let dayKey;
22
+ if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
23
+ if (!isValidDayKey(date)) {
24
+ return Response.json({
25
+ error: 'Invalid date format. Expected YYYY-MM-DD'
26
+ }, {
27
+ status: 400
28
+ });
29
+ }
30
+ dayKey = date;
31
+ } else {
32
+ const parsed = new Date(date);
33
+ if (isNaN(parsed.getTime())) {
34
+ return Response.json({
35
+ error: 'Invalid date format. Expected YYYY-MM-DD'
36
+ }, {
37
+ status: 400
38
+ });
39
+ }
40
+ dayKey = getDayKeyInTimezone(parsed, config.timezone);
41
+ }
42
+ const guestCountRaw = Number(url.searchParams.get('guestCount') ?? '1');
43
+ if (!Number.isFinite(guestCountRaw)) {
19
44
  return Response.json({
20
- error: 'Invalid date format. Expected YYYY-MM-DD'
45
+ error: 'Invalid guestCount'
21
46
  }, {
22
47
  status: 400
23
48
  });
24
49
  }
25
- const guestCount = Math.max(Number(url.searchParams.get('guestCount') ?? '1'), 1);
50
+ const guestCount = Math.max(Math.floor(guestCountRaw), 1);
26
51
  // Resolve required resource set: caller resource(s) ∪ service.requiredResources
27
52
  const explicit = url.searchParams.get('resources');
28
53
  const callerIds = explicit ? explicit.split(',').map((s)=>s.trim()).filter(Boolean) : [
@@ -34,12 +59,35 @@ export function createGetSlotsEndpoint(config) {
34
59
  collection: config.slugs.services,
35
60
  depth: 0,
36
61
  req
37
- });
62
+ }).catch(()=>null);
63
+ if (!svcDoc) {
64
+ return Response.json({
65
+ error: 'Service not found'
66
+ }, {
67
+ status: 404
68
+ });
69
+ }
70
+ // Validate the primary resource id up front — a malformed id in a query
71
+ // surfaces as an adapter cast error (500) instead of a clean 404.
72
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
+ const resourceDoc = await req.payload.findByID({
74
+ id: resource,
75
+ collection: config.slugs.resources,
76
+ depth: 0,
77
+ req
78
+ }).catch(()=>null);
79
+ if (!resourceDoc) {
80
+ return Response.json({
81
+ error: 'Resource not found'
82
+ }, {
83
+ status: 404
84
+ });
85
+ }
38
86
  const requiredIds = (svcDoc?.requiredResources ?? []).map((r)=>extractId(r)).filter((r)=>r !== undefined);
39
87
  const resourceIds = mergeResourceIds(callerIds, requiredIds);
40
88
  const slots = await getAvailableSlots({
41
89
  blockingStatuses: config.statusMachine.blockingStatuses,
42
- date: parsedDate,
90
+ date: dayKey,
43
91
  guestCount,
44
92
  payload: req.payload,
45
93
  req,
@@ -48,7 +96,8 @@ export function createGetSlotsEndpoint(config) {
48
96
  resourceSlug: config.slugs.resources,
49
97
  scheduleSlug: config.slugs.schedules,
50
98
  serviceId: service,
51
- serviceSlug: config.slugs.services
99
+ serviceSlug: config.slugs.services,
100
+ timeZone: config.timezone
52
101
  });
53
102
  return Response.json({
54
103
  date,
@@ -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'\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"}
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'\nimport { getDayKeyInTimezone, isValidDayKey } from '../utilities/timezoneUtils.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 // YYYY-MM-DD is taken as a business-TZ calendar day verbatim; any other\n // parseable date is re-keyed into the business timezone. Never\n // `new Date('YYYY-MM-DD')` — that pins the day to UTC midnight.\n let dayKey: string\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(date)) {\n if (!isValidDayKey(date)) {\n return Response.json(\n { error: 'Invalid date format. Expected YYYY-MM-DD' },\n { status: 400 },\n )\n }\n dayKey = date\n } else {\n const parsed = new Date(date)\n if (isNaN(parsed.getTime())) {\n return Response.json(\n { error: 'Invalid date format. Expected YYYY-MM-DD' },\n { status: 400 },\n )\n }\n dayKey = getDayKeyInTimezone(parsed, config.timezone)\n }\n\n const guestCountRaw = Number(url.searchParams.get('guestCount') ?? '1')\n if (!Number.isFinite(guestCountRaw)) {\n return Response.json({ error: 'Invalid guestCount' }, { status: 400 })\n }\n const guestCount = Math.max(Math.floor(guestCountRaw), 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 }).catch(() => null)\n if (!svcDoc) {\n return Response.json({ error: 'Service not found' }, { status: 404 })\n }\n\n // Validate the primary resource id up front — a malformed id in a query\n // surfaces as an adapter cast error (500) instead of a clean 404.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const resourceDoc = await (req.payload.findByID as any)({\n id: resource,\n collection: config.slugs.resources,\n depth: 0,\n req,\n }).catch(() => null)\n if (!resourceDoc) {\n return Response.json({ error: 'Resource not found' }, { status: 404 })\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: dayKey,\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 timeZone: config.timezone,\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","getDayKeyInTimezone","isValidDayKey","createGetSlotsEndpoint","config","handler","req","url","URL","date","searchParams","get","resource","service","Response","json","error","status","dayKey","test","parsed","Date","isNaN","getTime","timezone","guestCountRaw","Number","isFinite","guestCount","Math","max","floor","explicit","callerIds","split","map","s","trim","filter","Boolean","svcDoc","payload","findByID","id","collection","slugs","services","depth","catch","resourceDoc","resources","requiredIds","requiredResources","r","undefined","resourceIds","slots","blockingStatuses","statusMachine","reservationSlug","reservations","resourceSlug","scheduleSlug","schedules","serviceId","serviceSlug","timeZone","end","toISOString","start","method","path"],"mappings":"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"}
@@ -38,6 +38,7 @@ export declare function buildResourceAvailability(params: {
38
38
  resourceSlug: string;
39
39
  scheduleSlug: string;
40
40
  start: Date;
41
- }): Promise<ResourceAvailability>;
41
+ timeZone: string;
42
+ }): Promise<null | ResourceAvailability>;
42
43
  export declare function createResourceAvailabilityEndpoint(config: ResolvedReservationPluginConfig): Endpoint;
43
44
  export {};
@@ -1,5 +1,7 @@
1
1
  import { resolveScheduleForDate } from '../utilities/scheduleUtils.js';
2
- import { localDayKey } from '../utilities/slotUtils.js';
2
+ import { addDaysToDayKey, combineDayKeyAndTime, getDayKeyInTimezone } from '../utilities/timezoneUtils.js';
3
+ import { isPrivilegedUser } from '../utilities/userRoles.js';
4
+ const MAX_RANGE_MS = 90 * 86_400_000;
3
5
  /** Busy intervals (with capacity units) for one resource over [start, end). */ async function busyFor(args) {
4
6
  const { blockingStatuses, capacityMode, end, payload, reservationSlug, resourceId, start } = args;
5
7
  const where = {
@@ -35,11 +37,13 @@ import { localDayKey } from '../utilities/slotUtils.js';
35
37
  }
36
38
  ]
37
39
  };
40
+ // limit:0 = all matching — bounded by the endpoint's 90-day range cap, so this
41
+ // can't run away, and the grid no longer silently drops busy intervals (D9).
38
42
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
43
  const { docs } = await payload.find({
40
44
  collection: reservationSlug,
41
45
  depth: 0,
42
- limit: 500,
46
+ limit: 0,
43
47
  where
44
48
  });
45
49
  return docs.filter((r)=>r.startTime && r.endTime).map((r)=>({
@@ -49,14 +53,17 @@ import { localDayKey } from '../utilities/slotUtils.js';
49
53
  }));
50
54
  }
51
55
  export async function buildResourceAvailability(params) {
52
- const { blockingStatuses, end, payload, reservationSlug, resourceId, resourceSlug, scheduleSlug, start } = params;
56
+ const { blockingStatuses, end, payload, reservationSlug, resourceId, resourceSlug, scheduleSlug, start, timeZone } = params;
53
57
  // depth 1 so `services` are populated (their `requiredResources` come back as ids)
54
58
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
59
  const resource = await payload.findByID({
56
60
  id: resourceId,
57
61
  collection: resourceSlug,
58
62
  depth: 1
59
- });
63
+ }).catch(()=>null);
64
+ if (!resource) {
65
+ return null;
66
+ }
60
67
  const quantity = resource?.quantity ?? 1;
61
68
  const capacityMode = resource?.capacityMode ?? 'per-reservation';
62
69
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -80,32 +87,46 @@ export async function buildResourceAvailability(params) {
80
87
  }
81
88
  });
82
89
  const days = [];
83
- for(let d = new Date(start); d < end; d = new Date(d.getTime() + 86_400_000)){
84
- const date = localDayKey(d);
90
+ const startKey = getDayKeyInTimezone(start, timeZone);
91
+ const lastKey = getDayKeyInTimezone(new Date(end.getTime() - 1), timeZone);
92
+ for(let date = startKey; date <= lastKey; date = addDaysToDayKey(date, 1)){
85
93
  const shiftWindows = [];
86
94
  const timeOff = [];
87
- const localMidnight = new Date(d.getFullYear(), d.getMonth(), d.getDate());
95
+ // A11: an exception on ANY of the resource's schedules makes the whole
96
+ // resource unavailable that day. Find the first matching exception (if any)
97
+ // — it suppresses all shift windows and marks the full day as time-off.
98
+ let dayException;
88
99
  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
100
  const exceptions = sched.exceptions ?? [];
98
101
  for (const exc of exceptions){
99
- const excStart = localDayKey(new Date(exc.date));
100
- const excEnd = exc.endDate ? localDayKey(new Date(exc.endDate)) : excStart;
102
+ const excStart = getDayKeyInTimezone(new Date(exc.date), timeZone);
103
+ const excEnd = exc.endDate ? getDayKeyInTimezone(new Date(exc.endDate), timeZone) : excStart;
101
104
  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()
105
+ dayException = exc;
106
+ break;
107
+ }
108
+ }
109
+ if (dayException) {
110
+ break;
111
+ }
112
+ }
113
+ if (dayException) {
114
+ const dayStart = combineDayKeyAndTime(date, '00:00', timeZone);
115
+ const dayEnd = new Date(combineDayKeyAndTime(date, '23:59', timeZone).getTime() + 59_999);
116
+ timeOff.push({
117
+ type: dayException.type,
118
+ end: dayEnd.toISOString(),
119
+ reason: dayException.reason,
120
+ start: dayStart.toISOString()
121
+ });
122
+ } else {
123
+ for (const sched of schedules){
124
+ // resolveScheduleForDate accepts a Schedule-shaped object; cast through unknown
125
+ const ranges = resolveScheduleForDate(sched, date, timeZone);
126
+ for (const r of ranges){
127
+ shiftWindows.push({
128
+ end: r.end.toISOString(),
129
+ start: r.start.toISOString()
109
130
  });
110
131
  }
111
132
  }
@@ -174,6 +195,22 @@ export async function buildResourceAvailability(params) {
174
195
  export function createResourceAvailabilityEndpoint(config) {
175
196
  return {
176
197
  handler: async (req)=>{
198
+ // The grid data (every reservation's busy window) is staff/admin-only —
199
+ // same gate as customer search.
200
+ if (!req.user) {
201
+ return Response.json({
202
+ error: 'Unauthorized'
203
+ }, {
204
+ status: 401
205
+ });
206
+ }
207
+ if (!isPrivilegedUser(req.user, config)) {
208
+ return Response.json({
209
+ error: 'Forbidden'
210
+ }, {
211
+ status: 403
212
+ });
213
+ }
177
214
  const url = new URL(req.url);
178
215
  const resource = url.searchParams.get('resource');
179
216
  const start = url.searchParams.get('start');
@@ -194,6 +231,21 @@ export function createResourceAvailabilityEndpoint(config) {
194
231
  status: 400
195
232
  });
196
233
  }
234
+ if (endDate <= startDate) {
235
+ return Response.json({
236
+ error: 'end must be after start'
237
+ }, {
238
+ status: 400
239
+ });
240
+ }
241
+ // Unbounded ranges turn the per-day resolution loop into a CPU sink
242
+ if (endDate.getTime() - startDate.getTime() > MAX_RANGE_MS) {
243
+ return Response.json({
244
+ error: 'Date range too large (max 90 days)'
245
+ }, {
246
+ status: 400
247
+ });
248
+ }
197
249
  const result = await buildResourceAvailability({
198
250
  blockingStatuses: config.statusMachine.blockingStatuses,
199
251
  end: endDate,
@@ -202,8 +254,16 @@ export function createResourceAvailabilityEndpoint(config) {
202
254
  resourceId: resource,
203
255
  resourceSlug: config.slugs.resources,
204
256
  scheduleSlug: config.slugs.schedules,
205
- start: startDate
257
+ start: startDate,
258
+ timeZone: config.timezone
206
259
  });
260
+ if (!result) {
261
+ return Response.json({
262
+ error: 'Resource not found'
263
+ }, {
264
+ status: 404
265
+ });
266
+ }
207
267
  return Response.json(result);
208
268
  },
209
269
  method: 'get',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/resourceAvailability.ts"],"sourcesContent":["import type { Endpoint, Payload, Where } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { resolveScheduleForDate } from '../utilities/scheduleUtils.js'\nimport { localDayKey } from '../utilities/slotUtils.js'\n\ntype DayAvailability = {\n date: string\n shiftWindows: Array<{ end: string; start: string }>\n timeOff: Array<{ end: string; reason?: string; start: string; type?: string }>\n}\n\ntype Busy = Array<{ end: string; start: string; units: number }>\n\nexport type ResourceAvailability = {\n busy: Busy\n capacityMode: 'per-guest' | 'per-reservation'\n days: DayAvailability[]\n quantity: number\n /** Capacity of resources this resource's services also require (e.g. a chair pool). */\n requiredPools: Array<{ busy: Busy; quantity: number }>\n}\n\n/** Busy intervals (with capacity units) for one resource over [start, end). */\nasync function busyFor(args: {\n blockingStatuses: string[]\n capacityMode: 'per-guest' | 'per-reservation'\n end: Date\n payload: Payload\n reservationSlug: string\n resourceId: number | string\n start: Date\n}): Promise<Busy> {\n const { blockingStatuses, capacityMode, end, payload, reservationSlug, resourceId, start } = args\n const where: Where = {\n and: [\n { status: { in: blockingStatuses } },\n { startTime: { less_than: end.toISOString() } },\n { endTime: { greater_than: start.toISOString() } },\n { or: [{ resource: { equals: resourceId } }, { 'items.resource': { equals: resourceId } }] },\n ],\n }\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const { docs } = await (payload.find as any)({ collection: reservationSlug, depth: 0, limit: 500, where })\n return (docs as Array<Record<string, unknown>>)\n .filter((r) => r.startTime && r.endTime)\n .map((r) => ({\n end: new Date(r.endTime as string).toISOString(),\n start: new Date(r.startTime as string).toISOString(),\n units: capacityMode === 'per-guest' ? ((r.guestCount as number) ?? 1) : 1,\n }))\n}\n\nexport async function buildResourceAvailability(params: {\n blockingStatuses: string[]\n end: Date\n payload: Payload\n reservationSlug: string\n resourceId: number | string\n resourceSlug: string\n scheduleSlug: string\n start: Date\n}): Promise<ResourceAvailability> {\n const {\n blockingStatuses,\n end,\n payload,\n reservationSlug,\n resourceId,\n resourceSlug,\n scheduleSlug,\n start,\n } = params\n\n // depth 1 so `services` are populated (their `requiredResources` come back as ids)\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const resource = await (payload.findByID as any)({\n id: resourceId,\n collection: resourceSlug,\n depth: 1,\n })\n const quantity = (resource?.quantity as number) ?? 1\n const capacityMode = (resource?.capacityMode as 'per-guest' | 'per-reservation') ?? 'per-reservation'\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const { docs: schedules } = await (payload.find as any)({\n collection: scheduleSlug,\n depth: 0,\n limit: 100,\n where: { and: [{ active: { equals: true } }, { resource: { equals: resourceId } }] },\n })\n\n type RawException = {\n date: string\n endDate?: string\n reason?: string\n type?: string\n }\n\n const days: DayAvailability[] = []\n for (let d = new Date(start); d < end; d = new Date(d.getTime() + 86_400_000)) {\n const date = localDayKey(d)\n const shiftWindows: DayAvailability['shiftWindows'] = []\n const timeOff: DayAvailability['timeOff'] = []\n const localMidnight = new Date(d.getFullYear(), d.getMonth(), d.getDate())\n\n for (const sched of schedules as Array<Record<string, unknown>>) {\n // resolveScheduleForDate accepts a Schedule-shaped object; cast through unknown\n const ranges = resolveScheduleForDate(\n sched as unknown as Parameters<typeof resolveScheduleForDate>[0],\n localMidnight,\n )\n for (const r of ranges) {\n shiftWindows.push({ end: r.end.toISOString(), start: r.start.toISOString() })\n }\n\n const exceptions = (sched.exceptions as RawException[] | undefined) ?? []\n for (const exc of exceptions) {\n const excStart = localDayKey(new Date(exc.date))\n const excEnd = exc.endDate ? localDayKey(new Date(exc.endDate)) : excStart\n if (date >= excStart && date <= excEnd) {\n const localStart = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0)\n const localEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999)\n timeOff.push({\n type: exc.type,\n end: localEnd.toISOString(),\n reason: exc.reason,\n start: localStart.toISOString(),\n })\n }\n }\n }\n\n days.push({ date, shiftWindows, timeOff })\n }\n\n const busy = await busyFor({\n blockingStatuses,\n capacityMode,\n end,\n payload,\n reservationSlug,\n resourceId,\n start,\n })\n\n // Resources this resource's services ALSO require (e.g. a shared chair pool).\n // A slot isn't truly bookable if any of these is at capacity, even when the\n // resource itself is free — so the calendar reflects real availability.\n const poolIds = new Set<string>()\n for (const svc of (resource?.services as Array<Record<string, unknown>>) ?? []) {\n const reqs = (typeof svc === 'object' ? (svc.requiredResources as unknown[]) : []) ?? []\n for (const rr of reqs) {\n const id: number | string | undefined =\n typeof rr === 'object' && rr !== null\n ? (rr as { id?: number | string }).id\n : (rr as number | string)\n if (id != null && String(id) !== String(resourceId)) {\n poolIds.add(String(id))\n }\n }\n }\n\n const requiredPools: ResourceAvailability['requiredPools'] = []\n for (const poolId of poolIds) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const pool = await (payload.findByID as any)({ id: poolId, collection: resourceSlug, depth: 0 }).catch(\n () => null,\n )\n if (!pool) {\n continue\n }\n const poolCapacityMode =\n (pool.capacityMode as 'per-guest' | 'per-reservation') ?? 'per-reservation'\n requiredPools.push({\n busy: await busyFor({\n blockingStatuses,\n capacityMode: poolCapacityMode,\n end,\n payload,\n reservationSlug,\n resourceId: poolId,\n start,\n }),\n quantity: (pool.quantity as number) ?? 1,\n })\n }\n\n return { busy, capacityMode, days, quantity, requiredPools }\n}\n\nexport function createResourceAvailabilityEndpoint(\n config: ResolvedReservationPluginConfig,\n): Endpoint {\n return {\n handler: async (req) => {\n const url = new URL(req.url!)\n const resource = url.searchParams.get('resource')\n const start = url.searchParams.get('start')\n const end = url.searchParams.get('end')\n\n if (!resource || !start || !end) {\n return Response.json(\n { error: 'Missing required query params: resource, start, end' },\n { status: 400 },\n )\n }\n\n const startDate = new Date(start)\n const endDate = new Date(end)\n if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {\n return Response.json({ error: 'Invalid start/end date' }, { status: 400 })\n }\n\n const result = await buildResourceAvailability({\n blockingStatuses: config.statusMachine.blockingStatuses,\n end: endDate,\n payload: req.payload,\n reservationSlug: config.slugs.reservations,\n resourceId: resource,\n resourceSlug: config.slugs.resources,\n scheduleSlug: config.slugs.schedules,\n start: startDate,\n })\n\n return Response.json(result)\n },\n method: 'get',\n path: '/reserve/resource-availability',\n }\n}\n"],"names":["resolveScheduleForDate","localDayKey","busyFor","args","blockingStatuses","capacityMode","end","payload","reservationSlug","resourceId","start","where","and","status","in","startTime","less_than","toISOString","endTime","greater_than","or","resource","equals","docs","find","collection","depth","limit","filter","r","map","Date","units","guestCount","buildResourceAvailability","params","resourceSlug","scheduleSlug","findByID","id","quantity","schedules","active","days","d","getTime","date","shiftWindows","timeOff","localMidnight","getFullYear","getMonth","getDate","sched","ranges","push","exceptions","exc","excStart","excEnd","endDate","localStart","localEnd","type","reason","busy","poolIds","Set","svc","services","reqs","requiredResources","rr","String","add","requiredPools","poolId","pool","catch","poolCapacityMode","createResourceAvailabilityEndpoint","config","handler","req","url","URL","searchParams","get","Response","json","error","startDate","isNaN","result","statusMachine","slugs","reservations","resources","method","path"],"mappings":"AAIA,SAASA,sBAAsB,QAAQ,gCAA+B;AACtE,SAASC,WAAW,QAAQ,4BAA2B;AAmBvD,6EAA6E,GAC7E,eAAeC,QAAQC,IAQtB;IACC,MAAM,EAAEC,gBAAgB,EAAEC,YAAY,EAAEC,GAAG,EAAEC,OAAO,EAAEC,eAAe,EAAEC,UAAU,EAAEC,KAAK,EAAE,GAAGP;IAC7F,MAAMQ,QAAe;QACnBC,KAAK;YACH;gBAAEC,QAAQ;oBAAEC,IAAIV;gBAAiB;YAAE;YACnC;gBAAEW,WAAW;oBAAEC,WAAWV,IAAIW,WAAW;gBAAG;YAAE;YAC9C;gBAAEC,SAAS;oBAAEC,cAAcT,MAAMO,WAAW;gBAAG;YAAE;YACjD;gBAAEG,IAAI;oBAAC;wBAAEC,UAAU;4BAAEC,QAAQb;wBAAW;oBAAE;oBAAG;wBAAE,kBAAkB;4BAAEa,QAAQb;wBAAW;oBAAE;iBAAE;YAAC;SAC5F;IACH;IACA,8DAA8D;IAC9D,MAAM,EAAEc,IAAI,EAAE,GAAG,MAAM,AAAChB,QAAQiB,IAAI,CAAS;QAAEC,YAAYjB;QAAiBkB,OAAO;QAAGC,OAAO;QAAKhB;IAAM;IACxG,OAAO,AAACY,KACLK,MAAM,CAAC,CAACC,IAAMA,EAAEd,SAAS,IAAIc,EAAEX,OAAO,EACtCY,GAAG,CAAC,CAACD,IAAO,CAAA;YACXvB,KAAK,IAAIyB,KAAKF,EAAEX,OAAO,EAAYD,WAAW;YAC9CP,OAAO,IAAIqB,KAAKF,EAAEd,SAAS,EAAYE,WAAW;YAClDe,OAAO3B,iBAAiB,cAAe,AAACwB,EAAEI,UAAU,IAAe,IAAK;QAC1E,CAAA;AACJ;AAEA,OAAO,eAAeC,0BAA0BC,MAS/C;IACC,MAAM,EACJ/B,gBAAgB,EAChBE,GAAG,EACHC,OAAO,EACPC,eAAe,EACfC,UAAU,EACV2B,YAAY,EACZC,YAAY,EACZ3B,KAAK,EACN,GAAGyB;IAEJ,mFAAmF;IACnF,8DAA8D;IAC9D,MAAMd,WAAW,MAAM,AAACd,QAAQ+B,QAAQ,CAAS;QAC/CC,IAAI9B;QACJgB,YAAYW;QACZV,OAAO;IACT;IACA,MAAMc,WAAW,AAACnB,UAAUmB,YAAuB;IACnD,MAAMnC,eAAe,AAACgB,UAAUhB,gBAAoD;IAEpF,8DAA8D;IAC9D,MAAM,EAAEkB,MAAMkB,SAAS,EAAE,GAAG,MAAM,AAAClC,QAAQiB,IAAI,CAAS;QACtDC,YAAYY;QACZX,OAAO;QACPC,OAAO;QACPhB,OAAO;YAAEC,KAAK;gBAAC;oBAAE8B,QAAQ;wBAAEpB,QAAQ;oBAAK;gBAAE;gBAAG;oBAAED,UAAU;wBAAEC,QAAQb;oBAAW;gBAAE;aAAE;QAAC;IACrF;IASA,MAAMkC,OAA0B,EAAE;IAClC,IAAK,IAAIC,IAAI,IAAIb,KAAKrB,QAAQkC,IAAItC,KAAKsC,IAAI,IAAIb,KAAKa,EAAEC,OAAO,KAAK,YAAa;QAC7E,MAAMC,OAAO7C,YAAY2C;QACzB,MAAMG,eAAgD,EAAE;QACxD,MAAMC,UAAsC,EAAE;QAC9C,MAAMC,gBAAgB,IAAIlB,KAAKa,EAAEM,WAAW,IAAIN,EAAEO,QAAQ,IAAIP,EAAEQ,OAAO;QAEvE,KAAK,MAAMC,SAASZ,UAA6C;YAC/D,gFAAgF;YAChF,MAAMa,SAAStD,uBACbqD,OACAJ;YAEF,KAAK,MAAMpB,KAAKyB,OAAQ;gBACtBP,aAAaQ,IAAI,CAAC;oBAAEjD,KAAKuB,EAAEvB,GAAG,CAACW,WAAW;oBAAIP,OAAOmB,EAAEnB,KAAK,CAACO,WAAW;gBAAG;YAC7E;YAEA,MAAMuC,aAAa,AAACH,MAAMG,UAAU,IAAmC,EAAE;YACzE,KAAK,MAAMC,OAAOD,WAAY;gBAC5B,MAAME,WAAWzD,YAAY,IAAI8B,KAAK0B,IAAIX,IAAI;gBAC9C,MAAMa,SAASF,IAAIG,OAAO,GAAG3D,YAAY,IAAI8B,KAAK0B,IAAIG,OAAO,KAAKF;gBAClE,IAAIZ,QAAQY,YAAYZ,QAAQa,QAAQ;oBACtC,MAAME,aAAa,IAAI9B,KAAKa,EAAEM,WAAW,IAAIN,EAAEO,QAAQ,IAAIP,EAAEQ,OAAO,IAAI,GAAG,GAAG,GAAG;oBACjF,MAAMU,WAAW,IAAI/B,KAAKa,EAAEM,WAAW,IAAIN,EAAEO,QAAQ,IAAIP,EAAEQ,OAAO,IAAI,IAAI,IAAI,IAAI;oBAClFJ,QAAQO,IAAI,CAAC;wBACXQ,MAAMN,IAAIM,IAAI;wBACdzD,KAAKwD,SAAS7C,WAAW;wBACzB+C,QAAQP,IAAIO,MAAM;wBAClBtD,OAAOmD,WAAW5C,WAAW;oBAC/B;gBACF;YACF;QACF;QAEA0B,KAAKY,IAAI,CAAC;YAAET;YAAMC;YAAcC;QAAQ;IAC1C;IAEA,MAAMiB,OAAO,MAAM/D,QAAQ;QACzBE;QACAC;QACAC;QACAC;QACAC;QACAC;QACAC;IACF;IAEA,8EAA8E;IAC9E,4EAA4E;IAC5E,wEAAwE;IACxE,MAAMwD,UAAU,IAAIC;IACpB,KAAK,MAAMC,OAAO,AAAC/C,UAAUgD,YAA+C,EAAE,CAAE;QAC9E,MAAMC,OAAO,AAAC,CAAA,OAAOF,QAAQ,WAAYA,IAAIG,iBAAiB,GAAiB,EAAE,AAAD,KAAM,EAAE;QACxF,KAAK,MAAMC,MAAMF,KAAM;YACrB,MAAM/B,KACJ,OAAOiC,OAAO,YAAYA,OAAO,OAC7B,AAACA,GAAgCjC,EAAE,GAClCiC;YACP,IAAIjC,MAAM,QAAQkC,OAAOlC,QAAQkC,OAAOhE,aAAa;gBACnDyD,QAAQQ,GAAG,CAACD,OAAOlC;YACrB;QACF;IACF;IAEA,MAAMoC,gBAAuD,EAAE;IAC/D,KAAK,MAAMC,UAAUV,QAAS;QAC5B,8DAA8D;QAC9D,MAAMW,OAAO,MAAM,AAACtE,QAAQ+B,QAAQ,CAAS;YAAEC,IAAIqC;YAAQnD,YAAYW;YAAcV,OAAO;QAAE,GAAGoD,KAAK,CACpG,IAAM;QAER,IAAI,CAACD,MAAM;YACT;QACF;QACA,MAAME,mBACJ,AAACF,KAAKxE,YAAY,IAAwC;QAC5DsE,cAAcpB,IAAI,CAAC;YACjBU,MAAM,MAAM/D,QAAQ;gBAClBE;gBACAC,cAAc0E;gBACdzE;gBACAC;gBACAC;gBACAC,YAAYmE;gBACZlE;YACF;YACA8B,UAAU,AAACqC,KAAKrC,QAAQ,IAAe;QACzC;IACF;IAEA,OAAO;QAAEyB;QAAM5D;QAAcsC;QAAMH;QAAUmC;IAAc;AAC7D;AAEA,OAAO,SAASK,mCACdC,MAAuC;IAEvC,OAAO;QACLC,SAAS,OAAOC;YACd,MAAMC,MAAM,IAAIC,IAAIF,IAAIC,GAAG;YAC3B,MAAM/D,WAAW+D,IAAIE,YAAY,CAACC,GAAG,CAAC;YACtC,MAAM7E,QAAQ0E,IAAIE,YAAY,CAACC,GAAG,CAAC;YACnC,MAAMjF,MAAM8E,IAAIE,YAAY,CAACC,GAAG,CAAC;YAEjC,IAAI,CAAClE,YAAY,CAACX,SAAS,CAACJ,KAAK;gBAC/B,OAAOkF,SAASC,IAAI,CAClB;oBAAEC,OAAO;gBAAsD,GAC/D;oBAAE7E,QAAQ;gBAAI;YAElB;YAEA,MAAM8E,YAAY,IAAI5D,KAAKrB;YAC3B,MAAMkD,UAAU,IAAI7B,KAAKzB;YACzB,IAAIsF,MAAMD,UAAU9C,OAAO,OAAO+C,MAAMhC,QAAQf,OAAO,KAAK;gBAC1D,OAAO2C,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAyB,GAAG;oBAAE7E,QAAQ;gBAAI;YAC1E;YAEA,MAAMgF,SAAS,MAAM3D,0BAA0B;gBAC7C9B,kBAAkB6E,OAAOa,aAAa,CAAC1F,gBAAgB;gBACvDE,KAAKsD;gBACLrD,SAAS4E,IAAI5E,OAAO;gBACpBC,iBAAiByE,OAAOc,KAAK,CAACC,YAAY;gBAC1CvF,YAAYY;gBACZe,cAAc6C,OAAOc,KAAK,CAACE,SAAS;gBACpC5D,cAAc4C,OAAOc,KAAK,CAACtD,SAAS;gBACpC/B,OAAOiF;YACT;YAEA,OAAOH,SAASC,IAAI,CAACI;QACvB;QACAK,QAAQ;QACRC,MAAM;IACR;AACF"}
1
+ {"version":3,"sources":["../../src/endpoints/resourceAvailability.ts"],"sourcesContent":["import type { Endpoint, Payload, Where } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { resolveScheduleForDate } from '../utilities/scheduleUtils.js'\nimport {\n addDaysToDayKey,\n combineDayKeyAndTime,\n getDayKeyInTimezone,\n} from '../utilities/timezoneUtils.js'\nimport { isPrivilegedUser } from '../utilities/userRoles.js'\n\nconst MAX_RANGE_MS = 90 * 86_400_000\n\ntype DayAvailability = {\n date: string\n shiftWindows: Array<{ end: string; start: string }>\n timeOff: Array<{ end: string; reason?: string; start: string; type?: string }>\n}\n\ntype Busy = Array<{ end: string; start: string; units: number }>\n\nexport type ResourceAvailability = {\n busy: Busy\n capacityMode: 'per-guest' | 'per-reservation'\n days: DayAvailability[]\n quantity: number\n /** Capacity of resources this resource's services also require (e.g. a chair pool). */\n requiredPools: Array<{ busy: Busy; quantity: number }>\n}\n\n/** Busy intervals (with capacity units) for one resource over [start, end). */\nasync function busyFor(args: {\n blockingStatuses: string[]\n capacityMode: 'per-guest' | 'per-reservation'\n end: Date\n payload: Payload\n reservationSlug: string\n resourceId: number | string\n start: Date\n}): Promise<Busy> {\n const { blockingStatuses, capacityMode, end, payload, reservationSlug, resourceId, start } = args\n const where: Where = {\n and: [\n { status: { in: blockingStatuses } },\n { startTime: { less_than: end.toISOString() } },\n { endTime: { greater_than: start.toISOString() } },\n { or: [{ resource: { equals: resourceId } }, { 'items.resource': { equals: resourceId } }] },\n ],\n }\n // limit:0 = all matching — bounded by the endpoint's 90-day range cap, so this\n // can't run away, and the grid no longer silently drops busy intervals (D9).\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const { docs } = await (payload.find as any)({ collection: reservationSlug, depth: 0, limit: 0, where })\n return (docs as Array<Record<string, unknown>>)\n .filter((r) => r.startTime && r.endTime)\n .map((r) => ({\n end: new Date(r.endTime as string).toISOString(),\n start: new Date(r.startTime as string).toISOString(),\n units: capacityMode === 'per-guest' ? ((r.guestCount as number) ?? 1) : 1,\n }))\n}\n\nexport async function buildResourceAvailability(params: {\n blockingStatuses: string[]\n end: Date\n payload: Payload\n reservationSlug: string\n resourceId: number | string\n resourceSlug: string\n scheduleSlug: string\n start: Date\n timeZone: string\n}): Promise<null | ResourceAvailability> {\n const {\n blockingStatuses,\n end,\n payload,\n reservationSlug,\n resourceId,\n resourceSlug,\n scheduleSlug,\n start,\n timeZone,\n } = params\n\n // depth 1 so `services` are populated (their `requiredResources` come back as ids)\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const resource = await (payload.findByID as any)({\n id: resourceId,\n collection: resourceSlug,\n depth: 1,\n }).catch(() => null)\n if (!resource) {\n return null\n }\n const quantity = (resource?.quantity as number) ?? 1\n const capacityMode = (resource?.capacityMode as 'per-guest' | 'per-reservation') ?? 'per-reservation'\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const { docs: schedules } = await (payload.find as any)({\n collection: scheduleSlug,\n depth: 0,\n limit: 100,\n where: { and: [{ active: { equals: true } }, { resource: { equals: resourceId } }] },\n })\n\n type RawException = {\n date: string\n endDate?: string\n reason?: string\n type?: string\n }\n\n const days: DayAvailability[] = []\n const startKey = getDayKeyInTimezone(start, timeZone)\n const lastKey = getDayKeyInTimezone(new Date(end.getTime() - 1), timeZone)\n for (let date = startKey; date <= lastKey; date = addDaysToDayKey(date, 1)) {\n const shiftWindows: DayAvailability['shiftWindows'] = []\n const timeOff: DayAvailability['timeOff'] = []\n\n // A11: an exception on ANY of the resource's schedules makes the whole\n // resource unavailable that day. Find the first matching exception (if any)\n // — it suppresses all shift windows and marks the full day as time-off.\n let dayException: RawException | undefined\n for (const sched of schedules as Array<Record<string, unknown>>) {\n const exceptions = (sched.exceptions as RawException[] | undefined) ?? []\n for (const exc of exceptions) {\n const excStart = getDayKeyInTimezone(new Date(exc.date), timeZone)\n const excEnd = exc.endDate ? getDayKeyInTimezone(new Date(exc.endDate), timeZone) : excStart\n if (date >= excStart && date <= excEnd) {\n dayException = exc\n break\n }\n }\n if (dayException) {\n break\n }\n }\n\n if (dayException) {\n const dayStart = combineDayKeyAndTime(date, '00:00', timeZone)\n const dayEnd = new Date(combineDayKeyAndTime(date, '23:59', timeZone).getTime() + 59_999)\n timeOff.push({\n type: dayException.type,\n end: dayEnd.toISOString(),\n reason: dayException.reason,\n start: dayStart.toISOString(),\n })\n } else {\n for (const sched of schedules as Array<Record<string, unknown>>) {\n // resolveScheduleForDate accepts a Schedule-shaped object; cast through unknown\n const ranges = resolveScheduleForDate(\n sched as unknown as Parameters<typeof resolveScheduleForDate>[0],\n date,\n timeZone,\n )\n for (const r of ranges) {\n shiftWindows.push({ end: r.end.toISOString(), start: r.start.toISOString() })\n }\n }\n }\n\n days.push({ date, shiftWindows, timeOff })\n }\n\n const busy = await busyFor({\n blockingStatuses,\n capacityMode,\n end,\n payload,\n reservationSlug,\n resourceId,\n start,\n })\n\n // Resources this resource's services ALSO require (e.g. a shared chair pool).\n // A slot isn't truly bookable if any of these is at capacity, even when the\n // resource itself is free — so the calendar reflects real availability.\n const poolIds = new Set<string>()\n for (const svc of (resource?.services as Array<Record<string, unknown>>) ?? []) {\n const reqs = (typeof svc === 'object' ? (svc.requiredResources as unknown[]) : []) ?? []\n for (const rr of reqs) {\n const id: number | string | undefined =\n typeof rr === 'object' && rr !== null\n ? (rr as { id?: number | string }).id\n : (rr as number | string)\n if (id != null && String(id) !== String(resourceId)) {\n poolIds.add(String(id))\n }\n }\n }\n\n const requiredPools: ResourceAvailability['requiredPools'] = []\n for (const poolId of poolIds) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const pool = await (payload.findByID as any)({ id: poolId, collection: resourceSlug, depth: 0 }).catch(\n () => null,\n )\n if (!pool) {\n continue\n }\n const poolCapacityMode =\n (pool.capacityMode as 'per-guest' | 'per-reservation') ?? 'per-reservation'\n requiredPools.push({\n busy: await busyFor({\n blockingStatuses,\n capacityMode: poolCapacityMode,\n end,\n payload,\n reservationSlug,\n resourceId: poolId,\n start,\n }),\n quantity: (pool.quantity as number) ?? 1,\n })\n }\n\n return { busy, capacityMode, days, quantity, requiredPools }\n}\n\nexport function createResourceAvailabilityEndpoint(\n config: ResolvedReservationPluginConfig,\n): Endpoint {\n return {\n handler: async (req) => {\n // The grid data (every reservation's busy window) is staff/admin-only —\n // same gate as customer search.\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n if (!isPrivilegedUser(req.user, config)) {\n return Response.json({ error: 'Forbidden' }, { status: 403 })\n }\n\n const url = new URL(req.url!)\n const resource = url.searchParams.get('resource')\n const start = url.searchParams.get('start')\n const end = url.searchParams.get('end')\n\n if (!resource || !start || !end) {\n return Response.json(\n { error: 'Missing required query params: resource, start, end' },\n { status: 400 },\n )\n }\n\n const startDate = new Date(start)\n const endDate = new Date(end)\n if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {\n return Response.json({ error: 'Invalid start/end date' }, { status: 400 })\n }\n if (endDate <= startDate) {\n return Response.json({ error: 'end must be after start' }, { status: 400 })\n }\n // Unbounded ranges turn the per-day resolution loop into a CPU sink\n if (endDate.getTime() - startDate.getTime() > MAX_RANGE_MS) {\n return Response.json({ error: 'Date range too large (max 90 days)' }, { status: 400 })\n }\n\n const result = await buildResourceAvailability({\n blockingStatuses: config.statusMachine.blockingStatuses,\n end: endDate,\n payload: req.payload,\n reservationSlug: config.slugs.reservations,\n resourceId: resource,\n resourceSlug: config.slugs.resources,\n scheduleSlug: config.slugs.schedules,\n start: startDate,\n timeZone: config.timezone,\n })\n\n if (!result) {\n return Response.json({ error: 'Resource not found' }, { status: 404 })\n }\n\n return Response.json(result)\n },\n method: 'get',\n path: '/reserve/resource-availability',\n }\n}\n"],"names":["resolveScheduleForDate","addDaysToDayKey","combineDayKeyAndTime","getDayKeyInTimezone","isPrivilegedUser","MAX_RANGE_MS","busyFor","args","blockingStatuses","capacityMode","end","payload","reservationSlug","resourceId","start","where","and","status","in","startTime","less_than","toISOString","endTime","greater_than","or","resource","equals","docs","find","collection","depth","limit","filter","r","map","Date","units","guestCount","buildResourceAvailability","params","resourceSlug","scheduleSlug","timeZone","findByID","id","catch","quantity","schedules","active","days","startKey","lastKey","getTime","date","shiftWindows","timeOff","dayException","sched","exceptions","exc","excStart","excEnd","endDate","dayStart","dayEnd","push","type","reason","ranges","busy","poolIds","Set","svc","services","reqs","requiredResources","rr","String","add","requiredPools","poolId","pool","poolCapacityMode","createResourceAvailabilityEndpoint","config","handler","req","user","Response","json","error","url","URL","searchParams","get","startDate","isNaN","result","statusMachine","slugs","reservations","resources","timezone","method","path"],"mappings":"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"}
@@ -1,17 +1,30 @@
1
1
  import { ValidationError } from 'payload';
2
2
  import { computeEndTime } from '../../services/AvailabilityService.js';
3
- import { resolveReservationItems } from '../../utilities/resolveReservationItems.js';
4
- export const calculateEndTime = (config)=>async ({ context, data, req })=>{
3
+ import { mergeReservationData, schedulingFieldsChanged } from '../../utilities/reservationChanges.js';
4
+ import { extractId, resolveReservationItems } from '../../utilities/resolveReservationItems.js';
5
+ export const calculateEndTime = (config)=>async ({ context, data, operation, originalDoc, req })=>{
5
6
  if (context?.skipReservationHooks) {
6
7
  return data;
7
8
  }
8
- if (!data?.startTime || !data?.service) {
9
+ const isUpdate = operation === 'update';
10
+ // Skip when an update touches no scheduling-relevant field — a notes or
11
+ // status edit must not recompute (or invalidate) the stored times.
12
+ if (isUpdate && !schedulingFieldsChanged({
13
+ blockingStatuses: config.statusMachine.blockingStatuses,
14
+ data: data,
15
+ originalDoc: originalDoc
16
+ })) {
9
17
  return data;
10
18
  }
11
- const items = resolveReservationItems(data);
19
+ // On update `data` is a partial patch — compute from the merged document.
20
+ const merged = isUpdate ? mergeReservationData(data, originalDoc) : data;
21
+ if (!merged?.startTime || !merged?.service) {
22
+ return data;
23
+ }
24
+ const items = resolveReservationItems(merged);
12
25
  if (items.length <= 1) {
13
26
  // Single-resource: compute top-level endTime
14
- const serviceId = typeof data.service === 'object' ? data.service.id : data.service;
27
+ const serviceId = extractId(merged.service);
15
28
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
29
  const service = await req.payload.findByID({
17
30
  id: serviceId,
@@ -22,9 +35,9 @@ export const calculateEndTime = (config)=>async ({ context, data, req })=>{
22
35
  return data;
23
36
  }
24
37
  const durationType = service.durationType ?? 'fixed';
25
- const startDate = new Date(data.startTime);
38
+ const startDate = new Date(merged.startTime);
26
39
  if (durationType === 'flexible') {
27
- if (!data.endTime) {
40
+ if (!merged.endTime) {
28
41
  throw new ValidationError({
29
42
  errors: [
30
43
  {
@@ -34,32 +47,46 @@ export const calculateEndTime = (config)=>async ({ context, data, req })=>{
34
47
  ]
35
48
  });
36
49
  }
37
- // Validate customer-provided endTime (computeEndTime returns it back)
38
- computeEndTime({
39
- durationType: 'flexible',
40
- endTime: new Date(data.endTime),
41
- serviceDuration: service.duration,
42
- startTime: startDate
43
- });
50
+ // An inverted window would be invisible to overlap queries — reject it
51
+ // (computeEndTime performs no validation for flexible durations).
52
+ if (new Date(merged.endTime) <= startDate) {
53
+ throw new ValidationError({
54
+ errors: [
55
+ {
56
+ message: 'endTime must be after startTime',
57
+ path: 'endTime'
58
+ }
59
+ ]
60
+ });
61
+ }
44
62
  } else {
45
63
  const result = computeEndTime({
46
64
  durationType,
47
65
  serviceDuration: service.duration ?? 0,
48
- startTime: startDate
66
+ startTime: startDate,
67
+ timeZone: config.timezone
49
68
  });
50
69
  data.endTime = result.endTime.toISOString();
51
70
  }
52
71
  } else {
53
- // Multi-resource: compute endTime per item, then set a top-level endTime
54
- // that spans all items so conflict detection (which queries top-level
55
- // startTime/endTime) can see this reservation.
72
+ // Multi-resource: recompute only when the patch carries items[]. In
73
+ // practice Payload backfills items from originalDoc on API updates, so
74
+ // this guard mainly protects direct programmatic invocation; the
75
+ // schedulingFieldsChanged gate above is the real skip for benign edits.
76
+ // Rewriting items from a partial patch is A4 territory and out of scope.
77
+ if (isUpdate && !data.items) {
78
+ return data;
79
+ }
80
+ // Compute endTime per item, then set a top-level endTime that spans all
81
+ // items so conflict detection (which queries top-level startTime/endTime)
82
+ // can see this reservation.
56
83
  let earliestStart;
57
84
  let latestEnd;
58
85
  for (const item of data.items){
59
86
  if (!item.startTime) {
60
87
  continue;
61
88
  }
62
- const itemServiceId = typeof item.service === 'object' ? item.service.id : item.service ?? (typeof data.service === 'object' ? data.service.id : data.service);
89
+ const itemServiceId = extractId(item.service) ?? extractId(merged.service);
63
90
  if (!itemServiceId) {
64
91
  continue;
65
92
  }
@@ -80,7 +107,8 @@ export const calculateEndTime = (config)=>async ({ context, data, req })=>{
80
107
  const result = computeEndTime({
81
108
  durationType,
82
109
  serviceDuration: service.duration ?? 0,
83
- startTime: new Date(item.startTime)
110
+ startTime: new Date(item.startTime),
111
+ timeZone: config.timezone
84
112
  });
85
113
  item.endTime = result.endTime.toISOString();
86
114
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/reservations/calculateEndTime.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { DurationType, ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { computeEndTime } from '../../services/AvailabilityService.js'\nimport { resolveReservationItems } from '../../utilities/resolveReservationItems.js'\n\nexport const calculateEndTime =\n (config: ResolvedReservationPluginConfig): CollectionBeforeChangeHook =>\n async ({ context, data, req }) => {\n if (context?.skipReservationHooks) {return data}\n\n if (!data?.startTime || !data?.service) {return data}\n\n const items = resolveReservationItems(data)\n\n if (items.length <= 1) {\n // Single-resource: compute top-level endTime\n const serviceId = typeof data.service === 'object' ? data.service.id : data.service\n\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 req,\n })\n\n if (!service?.duration && service?.durationType !== 'full-day') {return data}\n\n const durationType = ((service.durationType as string) ?? 'fixed') as DurationType\n const startDate = new Date(data.startTime)\n\n if (durationType === 'flexible') {\n if (!data.endTime) {\n throw new ValidationError({\n errors: [{ message: 'endTime is required for flexible duration services', path: 'endTime' }],\n })\n }\n // Validate customer-provided endTime (computeEndTime returns it back)\n computeEndTime({\n durationType: 'flexible',\n endTime: new Date(data.endTime),\n serviceDuration: service.duration as number,\n startTime: startDate,\n })\n } else {\n const result = computeEndTime({\n durationType,\n serviceDuration: (service.duration as number) ?? 0,\n startTime: startDate,\n })\n data.endTime = result.endTime.toISOString()\n }\n } else {\n // Multi-resource: compute endTime per item, then set a top-level endTime\n // that spans all items so conflict detection (which queries top-level\n // startTime/endTime) can see this reservation.\n let earliestStart: Date | undefined\n let latestEnd: Date | undefined\n for (const item of data.items as Array<Record<string, unknown>>) {\n if (!item.startTime) {continue}\n\n const itemServiceId = typeof item.service === 'object'\n ? (item.service as { id: string }).id\n : (item.service as string) ?? (typeof data.service === 'object' ? data.service.id : data.service)\n\n if (!itemServiceId) {continue}\n\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\n if (!service?.duration && service?.durationType !== 'full-day') {continue}\n\n const durationType = ((service.durationType as string) ?? 'fixed') as DurationType\n\n if (durationType === 'flexible' && !item.endTime) {continue}\n\n if (durationType !== 'flexible') {\n const result = computeEndTime({\n durationType,\n serviceDuration: (service.duration as number) ?? 0,\n startTime: new Date(item.startTime as string),\n })\n item.endTime = result.endTime.toISOString()\n }\n\n const start = new Date(item.startTime as string)\n if (!earliestStart || start < earliestStart) {earliestStart = start}\n\n if (item.endTime) {\n const end = new Date(item.endTime as string)\n if (!latestEnd || end > latestEnd) {latestEnd = end}\n }\n }\n\n if (earliestStart) {\n data.startTime = earliestStart.toISOString()\n }\n\n if (latestEnd) {\n data.endTime = latestEnd.toISOString()\n }\n }\n\n return data\n }\n"],"names":["ValidationError","computeEndTime","resolveReservationItems","calculateEndTime","config","context","data","req","skipReservationHooks","startTime","service","items","length","serviceId","id","payload","findByID","collection","slugs","services","duration","durationType","startDate","Date","endTime","errors","message","path","serviceDuration","result","toISOString","earliestStart","latestEnd","item","itemServiceId","start","end"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../src/hooks/reservations/calculateEndTime.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { DurationType, ResolvedReservationPluginConfig } from '../../types.js'\n\nimport { computeEndTime } from '../../services/AvailabilityService.js'\nimport {\n mergeReservationData,\n schedulingFieldsChanged,\n} from '../../utilities/reservationChanges.js'\nimport { extractId, resolveReservationItems } from '../../utilities/resolveReservationItems.js'\n\nexport const calculateEndTime =\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 — a notes or\n // status edit must not recompute (or invalidate) the stored times.\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 // On update `data` is a partial patch — compute from the merged document.\n const merged = 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 if (!merged?.startTime || !merged?.service) {\n return data\n }\n\n const items = resolveReservationItems(merged)\n\n if (items.length <= 1) {\n // Single-resource: compute top-level endTime\n const serviceId = extractId(merged.service)\n\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 req,\n })\n\n if (!service?.duration && service?.durationType !== 'full-day') {\n return data\n }\n\n const durationType = ((service.durationType as string) ?? 'fixed') as DurationType\n const startDate = new Date(merged.startTime as string)\n\n if (durationType === 'flexible') {\n if (!merged.endTime) {\n throw new ValidationError({\n errors: [\n { message: 'endTime is required for flexible duration services', path: 'endTime' },\n ],\n })\n }\n // An inverted window would be invisible to overlap queries — reject it\n // (computeEndTime performs no validation for flexible durations).\n if (new Date(merged.endTime as string) <= startDate) {\n throw new ValidationError({\n errors: [{ message: 'endTime must be after startTime', path: 'endTime' }],\n })\n }\n } else {\n const result = computeEndTime({\n durationType,\n serviceDuration: (service.duration as number) ?? 0,\n startTime: startDate,\n timeZone: config.timezone,\n })\n data.endTime = result.endTime.toISOString()\n }\n } else {\n // Multi-resource: recompute only when the patch carries items[]. In\n // practice Payload backfills items from originalDoc on API updates, so\n // this guard mainly protects direct programmatic invocation; the\n // schedulingFieldsChanged gate above is the real skip for benign edits.\n // Rewriting items from a partial patch is A4 territory and out of scope.\n if (isUpdate && !data.items) {\n return data\n }\n\n // Compute endTime per item, then set a top-level endTime that spans all\n // items so conflict detection (which queries top-level startTime/endTime)\n // can see this reservation.\n let earliestStart: Date | undefined\n let latestEnd: Date | undefined\n for (const item of data.items as Array<Record<string, unknown>>) {\n if (!item.startTime) {\n continue\n }\n\n const itemServiceId = extractId(item.service) ?? extractId(merged.service)\n\n if (!itemServiceId) {\n continue\n }\n\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\n if (!service?.duration && service?.durationType !== 'full-day') {\n continue\n }\n\n const durationType = ((service.durationType as string) ?? 'fixed') as DurationType\n\n if (durationType === 'flexible' && !item.endTime) {\n continue\n }\n\n if (durationType !== 'flexible') {\n const result = computeEndTime({\n durationType,\n serviceDuration: (service.duration as number) ?? 0,\n startTime: new Date(item.startTime as string),\n timeZone: config.timezone,\n })\n item.endTime = result.endTime.toISOString()\n }\n\n const start = new Date(item.startTime as string)\n if (!earliestStart || start < earliestStart) {\n earliestStart = start\n }\n\n if (item.endTime) {\n const end = new Date(item.endTime as string)\n if (!latestEnd || end > latestEnd) {\n latestEnd = end\n }\n }\n }\n\n if (earliestStart) {\n data.startTime = earliestStart.toISOString()\n }\n\n if (latestEnd) {\n data.endTime = latestEnd.toISOString()\n }\n }\n\n return data\n }\n"],"names":["ValidationError","computeEndTime","mergeReservationData","schedulingFieldsChanged","extractId","resolveReservationItems","calculateEndTime","config","context","data","operation","originalDoc","req","skipReservationHooks","isUpdate","blockingStatuses","statusMachine","merged","startTime","service","items","length","serviceId","payload","findByID","id","collection","slugs","services","duration","durationType","startDate","Date","endTime","errors","message","path","result","serviceDuration","timeZone","timezone","toISOString","earliestStart","latestEnd","item","itemServiceId","start","end"],"mappings":"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"}