@vicacha-devs/strapi-plugin-bookings 0.0.0-stage → 1.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 (82) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +23 -2
  3. package/dist/admin/App-BrB9n7EO.mjs +371 -0
  4. package/dist/admin/App-ZlqOsILc.js +371 -0
  5. package/dist/admin/SettingsPage-LR6F2vyx.js +410 -0
  6. package/dist/admin/SettingsPage-Xas3gvn4.mjs +410 -0
  7. package/dist/admin/ar-9_2mxMp2.js +339 -0
  8. package/dist/admin/ar-CrM4yxqb.mjs +339 -0
  9. package/dist/admin/de-D3NbAP6M.mjs +339 -0
  10. package/dist/admin/de-DBaO-D7Z.js +339 -0
  11. package/dist/admin/en-Cu-rn_xM.mjs +339 -0
  12. package/dist/admin/en-DAoI7qkk.js +339 -0
  13. package/dist/admin/es-DUqsu82s.js +339 -0
  14. package/dist/admin/es-Y0zfya4P.mjs +339 -0
  15. package/dist/admin/fr-BkLta_o8.js +339 -0
  16. package/dist/admin/fr-BrzKRqet.mjs +339 -0
  17. package/dist/admin/getTranslation-CZAJyMSt.mjs +5 -0
  18. package/dist/admin/getTranslation-Cd0_4UtW.js +4 -0
  19. package/dist/admin/id-BZz53ViW.mjs +339 -0
  20. package/dist/admin/id-ByxSagzq.js +339 -0
  21. package/dist/admin/index.js +1497 -0
  22. package/dist/admin/index.mjs +1492 -0
  23. package/dist/admin/pt-BSCDUkdd.js +339 -0
  24. package/dist/admin/pt-DUfISuZf.mjs +339 -0
  25. package/dist/admin/src/components/Initializer.d.ts +6 -0
  26. package/dist/admin/src/components/PluginIcon.d.ts +3 -0
  27. package/dist/admin/src/components/RRuleInput.d.ts +3 -0
  28. package/dist/admin/src/components/bookings/BookingSidePanel.d.ts +10 -0
  29. package/dist/admin/src/components/bookings/BookingsGridCanvas.d.ts +20 -0
  30. package/dist/admin/src/components/bookings/QuickCreateBookingModal.d.ts +11 -0
  31. package/dist/admin/src/index.d.ts +24 -0
  32. package/dist/admin/src/pages/App.d.ts +3 -0
  33. package/dist/admin/src/pages/CalendarPage.d.ts +3 -0
  34. package/dist/admin/src/pages/SettingsPage.d.ts +3 -0
  35. package/dist/admin/src/permissions.d.ts +29 -0
  36. package/dist/admin/src/pluginId.d.ts +1 -0
  37. package/dist/admin/src/translations/cmLabels.d.ts +1 -0
  38. package/dist/admin/src/translations/cmPlaceholders.d.ts +1 -0
  39. package/dist/admin/src/utils/getTranslation.d.ts +2 -0
  40. package/dist/admin/tr-_nhc9hk6.mjs +339 -0
  41. package/dist/admin/tr-r17l5F2K.js +339 -0
  42. package/dist/admin/ur-8edBd0Qo.mjs +339 -0
  43. package/dist/admin/ur-Dw_TAOZ3.js +339 -0
  44. package/dist/admin/zh-CeR0swEm.mjs +339 -0
  45. package/dist/admin/zh-D0z5sxxN.js +339 -0
  46. package/dist/server/index.js +3547 -0
  47. package/dist/server/index.mjs +3545 -0
  48. package/dist/server/src/bootstrap.d.ts +6 -0
  49. package/dist/server/src/config/index.d.ts +6 -0
  50. package/dist/server/src/content-types/booking/schema.json.d.ts +117 -0
  51. package/dist/server/src/content-types/index.d.ts +265 -0
  52. package/dist/server/src/content-types/resource/schema.json.d.ts +147 -0
  53. package/dist/server/src/controllers/admin.d.ts +30 -0
  54. package/dist/server/src/controllers/content-api.d.ts +32 -0
  55. package/dist/server/src/controllers/index.d.ts +28 -0
  56. package/dist/server/src/destroy.d.ts +5 -0
  57. package/dist/server/src/errors.d.ts +18 -0
  58. package/dist/server/src/index.d.ts +347 -0
  59. package/dist/server/src/register.d.ts +5 -0
  60. package/dist/server/src/routes/admin.d.ts +14 -0
  61. package/dist/server/src/routes/content-api.d.ts +9 -0
  62. package/dist/server/src/routes/index.d.ts +30 -0
  63. package/dist/server/src/services/availability.d.ts +8 -0
  64. package/dist/server/src/services/index.d.ts +12 -0
  65. package/dist/server/src/services/reservation.d.ts +6 -0
  66. package/dist/server/src/services/reservationHelpers.d.ts +41 -0
  67. package/dist/server/src/services/reservationHostCapacity.d.ts +16 -0
  68. package/dist/server/src/services/reservationMutations.d.ts +32 -0
  69. package/dist/server/src/services/reservationSources.d.ts +4 -0
  70. package/dist/server/src/services/reservationValidators.d.ts +27 -0
  71. package/dist/server/src/services/settings.d.ts +8 -0
  72. package/dist/server/src/types.d.ts +99 -0
  73. package/dist/shared/calendarSystems.d.ts +3 -0
  74. package/dist/shared/icalAndCsv.d.ts +16 -0
  75. package/dist/shared/index.d.ts +6 -0
  76. package/dist/shared/index.js +752 -0
  77. package/dist/shared/index.mjs +729 -0
  78. package/dist/shared/pluginId.d.ts +2 -0
  79. package/dist/shared/slotEngine.d.ts +19 -0
  80. package/dist/shared/slotTimezone.d.ts +45 -0
  81. package/dist/shared/types.d.ts +245 -0
  82. package/package.json +111 -3
@@ -0,0 +1,3547 @@
1
+ "use strict";
2
+ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
+ const shared = require("@vicacha-devs/strapi-plugin-calendar/shared");
4
+ const crypto = require("node:crypto");
5
+ const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
6
+ const crypto__default = /* @__PURE__ */ _interopDefault(crypto);
7
+ function formatSecondaryCalendars(date, systems, locale = "en") {
8
+ const result = {};
9
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
10
+ return result;
11
+ }
12
+ const dateStr = date.toISOString().slice(0, 10);
13
+ for (const sys of systems) {
14
+ if (!sys || !sys.enabled || !sys.id) continue;
15
+ const formatted = shared.formatDateInCalendarSystem(dateStr, sys, locale, {
16
+ compact: false
17
+ });
18
+ const compact = shared.formatDateInCalendarSystem(dateStr, sys, locale, {
19
+ compact: true
20
+ });
21
+ const parts = shared.extractCalendarParts(
22
+ date,
23
+ sys.intlCalendar,
24
+ sys.dayOffset ?? 0
25
+ );
26
+ if (formatted) {
27
+ result[sys.id] = {
28
+ calendarId: sys.id,
29
+ label: sys.label || sys.id,
30
+ formatted,
31
+ compact: compact || formatted,
32
+ intlCalendar: sys.intlCalendar,
33
+ year: parts?.year,
34
+ month: parts?.month,
35
+ day: parts?.day,
36
+ isOverridden: false
37
+ };
38
+ }
39
+ }
40
+ return result;
41
+ }
42
+ const PLUGIN_ID = "bookings";
43
+ const UNSAFE_OBJECT_KEYS = /* @__PURE__ */ new Set([
44
+ "__proto__",
45
+ "constructor",
46
+ "prototype"
47
+ ]);
48
+ function isSafeObjectKey(key) {
49
+ return typeof key === "string" && key.length > 0 && !UNSAFE_OBJECT_KEYS.has(key);
50
+ }
51
+ function sanitizePlainRecord(val, depth = 0) {
52
+ if (!val || typeof val !== "object" || Array.isArray(val) || depth > 6) {
53
+ return void 0;
54
+ }
55
+ const clean = {};
56
+ for (const [key, entry] of Object.entries(val)) {
57
+ if (!isSafeObjectKey(key)) continue;
58
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
59
+ clean[key] = sanitizePlainRecord(entry, depth + 1);
60
+ } else {
61
+ clean[key] = entry;
62
+ }
63
+ }
64
+ return clean;
65
+ }
66
+ class BookingValidationError extends Error {
67
+ statusCode = 400;
68
+ constructor(message) {
69
+ super(message);
70
+ this.name = "BookingValidationError";
71
+ }
72
+ }
73
+ class BookingForbiddenError extends Error {
74
+ statusCode = 403;
75
+ constructor(message) {
76
+ super(message);
77
+ this.name = "BookingForbiddenError";
78
+ }
79
+ }
80
+ class BookingNotFoundError extends Error {
81
+ statusCode = 404;
82
+ constructor(message) {
83
+ super(message);
84
+ this.name = "BookingNotFoundError";
85
+ }
86
+ }
87
+ const strapiResourceLocks = /* @__PURE__ */ new WeakMap();
88
+ async function withResourceLock(strapi, lockKey, fn) {
89
+ let locks = strapiResourceLocks.get(strapi);
90
+ if (!locks) {
91
+ locks = /* @__PURE__ */ new Map();
92
+ strapiResourceLocks.set(strapi, locks);
93
+ }
94
+ const prev = locks.get(lockKey) ?? Promise.resolve();
95
+ let release;
96
+ const current = new Promise((resolve) => {
97
+ release = resolve;
98
+ });
99
+ const tail = prev.then(
100
+ () => current,
101
+ () => current
102
+ );
103
+ locks.set(lockKey, tail);
104
+ await prev.catch(() => void 0);
105
+ try {
106
+ if (typeof strapi.db?.transaction === "function") {
107
+ return await strapi.db.transaction(async () => fn());
108
+ }
109
+ return await fn();
110
+ } finally {
111
+ release();
112
+ if (locks.get(lockKey) === tail) {
113
+ locks.delete(lockKey);
114
+ }
115
+ }
116
+ }
117
+ function clearResourceLocks(strapi) {
118
+ strapiResourceLocks.delete(strapi);
119
+ }
120
+ const registeredFormattersByStrapi = /* @__PURE__ */ new WeakMap();
121
+ function cleanupBootstrapState(strapi) {
122
+ const ids = registeredFormattersByStrapi.get(strapi);
123
+ if (ids) {
124
+ for (const id of ids) {
125
+ shared.unregisterCalendarSystemFormatter(id);
126
+ }
127
+ registeredFormattersByStrapi.delete(strapi);
128
+ }
129
+ }
130
+ const BOOKING_CALENDAR_SOURCE = {
131
+ id: "bookings",
132
+ label: "Bookings & Reservations",
133
+ uid: "plugin::bookings.booking",
134
+ color: "#0D9488",
135
+ enabled: true,
136
+ fields: {
137
+ title: "title",
138
+ startDate: "startDate",
139
+ endDate: "endDate",
140
+ status: "status",
141
+ location: "assignedHost",
142
+ summary: "notes",
143
+ category: "status"
144
+ }
145
+ };
146
+ async function registerCalendarCompanion(strapi) {
147
+ const calendarPlugin = strapi.plugin("calendar");
148
+ try {
149
+ const calendarRegistry = calendarPlugin?.service?.("registry");
150
+ if (typeof calendarRegistry?.registerSource === "function") {
151
+ await calendarRegistry.registerSource(BOOKING_CALENDAR_SOURCE);
152
+ }
153
+ const calendarSettingsSvc = calendarPlugin?.service?.("settings");
154
+ if (typeof calendarSettingsSvc?.registerCompanionSource === "function") {
155
+ calendarSettingsSvc.registerCompanionSource(BOOKING_CALENDAR_SOURCE);
156
+ }
157
+ } catch {
158
+ }
159
+ }
160
+ const bootstrap = async ({ strapi }) => {
161
+ const pluginConfig = strapi.config.get(`plugin::${PLUGIN_ID}`) ?? {};
162
+ if (pluginConfig.calendarSystems && typeof pluginConfig.calendarSystems === "object") {
163
+ const registeredIds = [];
164
+ for (const [systemId, formatter] of Object.entries(
165
+ pluginConfig.calendarSystems
166
+ )) {
167
+ if (isSafeObjectKey(systemId) && Object.hasOwn(pluginConfig.calendarSystems, systemId) && typeof formatter === "function") {
168
+ shared.registerCalendarSystemFormatter(systemId, formatter);
169
+ registeredIds.push(systemId);
170
+ }
171
+ }
172
+ if (registeredIds.length > 0) {
173
+ registeredFormattersByStrapi.set(strapi, registeredIds);
174
+ }
175
+ }
176
+ const actions = [
177
+ {
178
+ section: "plugins",
179
+ displayName: "Access the Bookings & Availability Workspace",
180
+ uid: "read",
181
+ pluginName: PLUGIN_ID
182
+ },
183
+ {
184
+ section: "plugins",
185
+ displayName: "Manage Bookings, Resources & Approvals",
186
+ uid: "manage",
187
+ pluginName: PLUGIN_ID
188
+ },
189
+ {
190
+ section: "plugins",
191
+ displayName: "Configure Bookings Settings",
192
+ uid: "settings",
193
+ pluginName: PLUGIN_ID
194
+ }
195
+ ];
196
+ const actionProvider = strapi.admin?.services?.permission?.actionProvider;
197
+ if (actionProvider?.registerMany) {
198
+ await actionProvider.registerMany(actions);
199
+ }
200
+ await registerCalendarCompanion(strapi);
201
+ };
202
+ const defaultConfig = {
203
+ defaultTimezone: "America/Bogota",
204
+ defaultLocale: "es",
205
+ theme: "breeze",
206
+ secondaryCalendarLanguage: "local",
207
+ defaultSlotDurationMinutes: 60,
208
+ defaultBufferBeforeMinutes: 0,
209
+ defaultBufferAfterMinutes: 0,
210
+ defaultCapacityPerSlot: 1,
211
+ defaultMinLeadTimeHours: 2,
212
+ defaultMaxAdvanceDays: 90,
213
+ defaultRequiresApproval: false,
214
+ sendEmailNotifications: true,
215
+ secondaryCalendars: [],
216
+ sources: [],
217
+ calendarSystems: {},
218
+ schemaExtensions: {},
219
+ customFields: []
220
+ };
221
+ const config = {
222
+ default: defaultConfig,
223
+ validator(config2) {
224
+ if (config2 && typeof config2 !== "object") {
225
+ throw new Error(
226
+ "@vicacha-devs/strapi-plugin-bookings config must be an object"
227
+ );
228
+ }
229
+ }
230
+ };
231
+ const kind$1 = "collectionType";
232
+ const collectionName$1 = "bookings";
233
+ const info$1 = { "singularName": "booking", "pluralName": "bookings", "displayName": "Booking", "description": "Reservation, appointment, or resource slot booking" };
234
+ const options$1 = { "draftAndPublish": false };
235
+ const pluginOptions$1 = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
236
+ const attributes$1 = { "bookingCode": { "type": "string", "required": true, "unique": true, "maxLength": 48 }, "title": { "type": "string", "maxLength": 200 }, "resource": { "type": "relation", "relation": "manyToOne", "target": "plugin::bookings.resource", "inversedBy": "bookings" }, "customerName": { "type": "string", "required": true, "maxLength": 120 }, "customerEmail": { "type": "email", "required": true }, "customerPhone": { "type": "string", "maxLength": 40 }, "organization": { "type": "string", "maxLength": 160 }, "startDate": { "type": "datetime", "required": true }, "endDate": { "type": "datetime", "required": true }, "slotLabel": { "type": "string", "maxLength": 80 }, "timezone": { "type": "string", "default": "UTC", "maxLength": 64 }, "partySize": { "type": "integer", "required": true, "default": 1, "min": 1 }, "status": { "type": "enumeration", "enum": ["pending", "confirmed", "completed", "cancelled", "no_show"], "default": "pending", "required": true }, "preferredLanguage": { "type": "string", "maxLength": 16 }, "assignedHost": { "type": "string", "maxLength": 120 }, "notes": { "type": "text", "maxLength": 2e3 }, "internalNotes": { "type": "text", "maxLength": 2e3, "private": true }, "cancelTokenHash": { "type": "string", "maxLength": 128, "private": true }, "sourceCollection": { "type": "string", "default": "plugin::bookings.booking" }, "metadata": { "type": "json" } };
237
+ const bookingSchema = {
238
+ kind: kind$1,
239
+ collectionName: collectionName$1,
240
+ info: info$1,
241
+ options: options$1,
242
+ pluginOptions: pluginOptions$1,
243
+ attributes: attributes$1
244
+ };
245
+ const kind = "collectionType";
246
+ const collectionName = "booking_resources";
247
+ const info = { "singularName": "resource", "pluralName": "resources", "displayName": "Booking Resource", "description": "Bookable service, appointment type, room, staff member, or equipment schedule" };
248
+ const options = { "draftAndPublish": false };
249
+ const pluginOptions = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
250
+ const attributes = { "name": { "type": "string", "required": true, "maxLength": 160 }, "slug": { "type": "uid", "targetField": "name", "required": true }, "resourceType": { "type": "enumeration", "enum": ["service", "appointment", "room", "staff", "equipment", "other"], "default": "service", "required": true }, "description": { "type": "text" }, "color": { "type": "string", "default": "#0d9488", "maxLength": 32 }, "timezone": { "type": "string", "default": "UTC", "maxLength": 64 }, "slotDurationMinutes": { "type": "integer", "default": 60, "min": 5, "max": 1440, "required": true }, "bufferBeforeMinutes": { "type": "integer", "default": 0, "min": 0, "max": 720 }, "bufferAfterMinutes": { "type": "integer", "default": 0, "min": 0, "max": 720 }, "capacityPerSlot": { "type": "integer", "default": 1, "min": 1, "required": true }, "maxPartySize": { "type": "integer", "default": 50, "min": 1 }, "minLeadTimeHours": { "type": "integer", "default": 2, "min": 0 }, "maxAdvanceDays": { "type": "integer", "default": 90, "min": 1 }, "requiresApproval": { "type": "boolean", "default": false }, "weeklySchedule": { "type": "json" }, "seasonalSchedules": { "type": "json" }, "holidayRules": { "type": "json" }, "blackoutDates": { "type": "json" }, "recurrenceRule": { "type": "string" }, "supportedLanguages": { "type": "json" }, "assignedHosts": { "type": "json" }, "location": { "type": "string", "maxLength": 240 }, "price": { "type": "decimal", "default": 0, "min": 0 }, "currency": { "type": "string", "default": "USD", "maxLength": 10 }, "isActive": { "type": "boolean", "default": true }, "metadata": { "type": "json" }, "bookings": { "type": "relation", "relation": "oneToMany", "target": "plugin::bookings.booking", "mappedBy": "resource" } };
251
+ const resourceSchema = {
252
+ kind,
253
+ collectionName,
254
+ info,
255
+ options,
256
+ pluginOptions,
257
+ attributes
258
+ };
259
+ const contentTypes = {
260
+ resource: { schema: resourceSchema },
261
+ booking: { schema: bookingSchema }
262
+ };
263
+ function respondControllerError(ctx, strapi, err, fallbackMessage) {
264
+ if (err instanceof BookingNotFoundError) {
265
+ if (typeof ctx.notFound === "function") {
266
+ return ctx.notFound(err.message);
267
+ }
268
+ ctx.status = 404;
269
+ ctx.body = {
270
+ error: { status: 404, name: "NotFoundError", message: err.message }
271
+ };
272
+ return void 0;
273
+ }
274
+ if (err instanceof BookingForbiddenError) {
275
+ if (typeof ctx.forbidden === "function") {
276
+ return ctx.forbidden(err.message);
277
+ }
278
+ ctx.status = 403;
279
+ ctx.body = {
280
+ error: { status: 403, name: "ForbiddenError", message: err.message }
281
+ };
282
+ return void 0;
283
+ }
284
+ if (err instanceof BookingValidationError) {
285
+ return ctx.badRequest(err.message);
286
+ }
287
+ strapi.log?.error?.(
288
+ `[bookings] ${fallbackMessage}: ${err instanceof Error ? err.message : "unknown error"}`
289
+ );
290
+ return ctx.badRequest(fallbackMessage);
291
+ }
292
+ const admin = ({ strapi }) => {
293
+ const getSettingsService = () => strapi.plugin(PLUGIN_ID).service("settings");
294
+ const getAvailabilityService = () => strapi.plugin(PLUGIN_ID).service("availability");
295
+ const getReservationService = () => strapi.plugin(PLUGIN_ID).service("reservation");
296
+ return {
297
+ async getSettings(ctx) {
298
+ const settings2 = await getSettingsService().getSettings();
299
+ ctx.body = { data: settings2 };
300
+ },
301
+ async updateSettings(ctx) {
302
+ try {
303
+ const payload = ctx.request.body ?? {};
304
+ const updated = await getSettingsService().updateSettings(payload);
305
+ ctx.body = { data: updated };
306
+ } catch (err) {
307
+ return respondControllerError(
308
+ ctx,
309
+ strapi,
310
+ err,
311
+ "Failed to update bookings settings"
312
+ );
313
+ }
314
+ },
315
+ async discoverSchemas(ctx) {
316
+ const schemas = await getSettingsService().discoverSchemas();
317
+ ctx.body = { data: schemas };
318
+ },
319
+ async listResources(ctx) {
320
+ const activeOnly = ctx.query.activeOnly === "true";
321
+ const resources = await getAvailabilityService().listResources(activeOnly);
322
+ ctx.body = { data: resources };
323
+ },
324
+ async saveResource(ctx) {
325
+ try {
326
+ const body = ctx.request.body ?? {};
327
+ if (typeof body.name !== "string" || !body.name.trim()) {
328
+ return ctx.badRequest("Resource name is required");
329
+ }
330
+ const saved = await getAvailabilityService().saveResource({
331
+ ...body,
332
+ name: body.name
333
+ });
334
+ ctx.body = { data: saved };
335
+ } catch (err) {
336
+ return respondControllerError(
337
+ ctx,
338
+ strapi,
339
+ err,
340
+ "Failed to save booking resource"
341
+ );
342
+ }
343
+ },
344
+ async getCalendar(ctx) {
345
+ const query = {
346
+ from: ctx.query.from,
347
+ to: ctx.query.to,
348
+ resourceDocumentId: ctx.query.resourceDocumentId,
349
+ resourceSlug: ctx.query.resourceSlug,
350
+ status: ctx.query.status ?? "",
351
+ preferredLanguage: ctx.query.preferredLanguage,
352
+ search: ctx.query.search,
353
+ source: ctx.query.source
354
+ };
355
+ const [calendarData, resources, settings2] = await Promise.all([
356
+ getAvailabilityService().getCalendarOccurrences(query),
357
+ getAvailabilityService().listResources(false),
358
+ getSettingsService().getSettings()
359
+ ]);
360
+ const { icalFeedToken: _omit, ...safeSettings } = settings2;
361
+ ctx.body = {
362
+ data: {
363
+ ...calendarData,
364
+ resources,
365
+ settings: safeSettings
366
+ }
367
+ };
368
+ },
369
+ async createBooking(ctx) {
370
+ try {
371
+ const body = ctx.request.body ?? {};
372
+ const result = await getReservationService().createReservation(body, {
373
+ isAdmin: true,
374
+ skipLeadTimeCheck: true
375
+ });
376
+ ctx.body = { data: result };
377
+ } catch (err) {
378
+ return respondControllerError(
379
+ ctx,
380
+ strapi,
381
+ err,
382
+ "Invalid booking payload"
383
+ );
384
+ }
385
+ },
386
+ async updateBookingStatus(ctx) {
387
+ try {
388
+ const documentId = ctx.params.documentId;
389
+ if (!documentId) {
390
+ return ctx.badRequest("documentId parameter is required");
391
+ }
392
+ const body = ctx.request.body ?? {};
393
+ if (!body.status) {
394
+ return ctx.badRequest("status is required");
395
+ }
396
+ const updated = await getReservationService().updateBookingStatus(
397
+ documentId,
398
+ body.status,
399
+ {
400
+ assignedHost: body.assignedHost,
401
+ internalNotes: body.internalNotes
402
+ }
403
+ );
404
+ ctx.body = { data: updated };
405
+ } catch (err) {
406
+ return respondControllerError(ctx, strapi, err, "Status update failed");
407
+ }
408
+ },
409
+ async rescheduleBooking(ctx) {
410
+ try {
411
+ const body = ctx.request.body ?? {};
412
+ if (!body.newStartDate) {
413
+ return ctx.badRequest("newStartDate is required");
414
+ }
415
+ const updated = await getReservationService().rescheduleBooking(
416
+ {
417
+ bookingDocumentId: body.bookingDocumentId ?? ctx.params.documentId,
418
+ bookingCode: body.bookingCode,
419
+ newStartDate: body.newStartDate,
420
+ newEndDate: body.newEndDate,
421
+ newSlotLabel: body.newSlotLabel,
422
+ partySize: body.partySize
423
+ },
424
+ { isAdmin: true }
425
+ );
426
+ ctx.body = { data: updated };
427
+ } catch (err) {
428
+ return respondControllerError(ctx, strapi, err, "Reschedule failed");
429
+ }
430
+ },
431
+ async exportCsv(ctx) {
432
+ const csv = await getReservationService().exportCsv({
433
+ from: ctx.query.from,
434
+ to: ctx.query.to,
435
+ resourceDocumentId: ctx.query.resourceDocumentId,
436
+ status: ctx.query.status ?? "",
437
+ search: ctx.query.search
438
+ });
439
+ ctx.set("Content-Type", "text/csv; charset=utf-8");
440
+ ctx.set("Content-Disposition", 'attachment; filename="bookings.csv"');
441
+ ctx.set("X-Content-Type-Options", "nosniff");
442
+ ctx.body = csv;
443
+ },
444
+ async exportICal(ctx) {
445
+ const ics = await getReservationService().exportICal(
446
+ {
447
+ from: ctx.query.from,
448
+ to: ctx.query.to,
449
+ resourceDocumentId: ctx.query.resourceDocumentId,
450
+ status: ctx.query.status ?? ""
451
+ },
452
+ { anonymize: false }
453
+ );
454
+ ctx.set("Content-Type", "text/calendar; charset=utf-8");
455
+ ctx.set("Content-Disposition", 'attachment; filename="bookings.ics"');
456
+ ctx.set("X-Content-Type-Options", "nosniff");
457
+ ctx.body = ics;
458
+ }
459
+ };
460
+ };
461
+ const DEFAULT_WEEKLY_SCHEDULE = {
462
+ mode: "interval",
463
+ days: {
464
+ "1": [{ start: "09:00", end: "17:00", label: "09:00-17:00" }],
465
+ "2": [{ start: "09:00", end: "17:00", label: "09:00-17:00" }],
466
+ "3": [{ start: "09:00", end: "17:00", label: "09:00-17:00" }],
467
+ "4": [{ start: "09:00", end: "17:00", label: "09:00-17:00" }],
468
+ "5": [{ start: "09:00", end: "17:00", label: "09:00-17:00" }]
469
+ }
470
+ };
471
+ const WEEKDAY_TO_KEY = {
472
+ Sun: "0",
473
+ Mon: "1",
474
+ Tue: "2",
475
+ Wed: "3",
476
+ Thu: "4",
477
+ Fri: "5",
478
+ Sat: "6"
479
+ };
480
+ const formatterCache = /* @__PURE__ */ new Map();
481
+ function getZonedFormatter(timeZone) {
482
+ const cached = formatterCache.get(timeZone);
483
+ if (cached) return cached;
484
+ const dtf = new Intl.DateTimeFormat("en-US", {
485
+ timeZone,
486
+ year: "numeric",
487
+ month: "2-digit",
488
+ day: "2-digit",
489
+ weekday: "short",
490
+ hour: "2-digit",
491
+ minute: "2-digit",
492
+ second: "2-digit",
493
+ hourCycle: "h23"
494
+ });
495
+ if (formatterCache.size > 128) {
496
+ formatterCache.clear();
497
+ }
498
+ formatterCache.set(timeZone, dtf);
499
+ return dtf;
500
+ }
501
+ function normalizeTimeZone(tz) {
502
+ const trimmed = typeof tz === "string" ? tz.trim() : "";
503
+ if (!trimmed) return "UTC";
504
+ try {
505
+ getZonedFormatter(trimmed);
506
+ return trimmed;
507
+ } catch {
508
+ return "UTC";
509
+ }
510
+ }
511
+ function pad2(n) {
512
+ return String(n).padStart(2, "0");
513
+ }
514
+ function extractZonedWallClock(epochMs, rawTimeZone) {
515
+ const timeZone = normalizeTimeZone(rawTimeZone);
516
+ const dtf = getZonedFormatter(timeZone);
517
+ const parts = dtf.formatToParts(new Date(epochMs));
518
+ let year = 1970;
519
+ let month = 1;
520
+ let day = 1;
521
+ let hours = 0;
522
+ let minutes = 0;
523
+ let seconds = 0;
524
+ let dayKey = "4";
525
+ for (const part of parts) {
526
+ if (part.type === "year") year = Number.parseInt(part.value, 10);
527
+ else if (part.type === "month") month = Number.parseInt(part.value, 10);
528
+ else if (part.type === "day") day = Number.parseInt(part.value, 10);
529
+ else if (part.type === "hour") {
530
+ const h = Number.parseInt(part.value, 10);
531
+ hours = h === 24 ? 0 : h;
532
+ } else if (part.type === "minute") {
533
+ minutes = Number.parseInt(part.value, 10);
534
+ } else if (part.type === "second") {
535
+ seconds = Number.parseInt(part.value, 10);
536
+ } else if (part.type === "weekday") {
537
+ dayKey = WEEKDAY_TO_KEY[part.value] ?? "0";
538
+ }
539
+ }
540
+ const dateStr = `${String(year).padStart(4, "0")}-${pad2(month)}-${pad2(day)}`;
541
+ const ms = (epochMs % 1e3 + 1e3) % 1e3;
542
+ return {
543
+ year,
544
+ month,
545
+ day,
546
+ dateStr,
547
+ dayKey,
548
+ hours,
549
+ minutes,
550
+ seconds,
551
+ ms,
552
+ minutesOfDay: hours * 60 + minutes
553
+ };
554
+ }
555
+ function getTimeZoneOffsetMs(instantMs, timeZone) {
556
+ const parts = extractZonedWallClock(instantMs, timeZone);
557
+ const zonedAsUtcMs = Date.UTC(
558
+ parts.year,
559
+ parts.month - 1,
560
+ parts.day,
561
+ parts.hours,
562
+ parts.minutes,
563
+ parts.seconds,
564
+ 0
565
+ );
566
+ const truncatedInstant = Math.floor(instantMs / 1e3) * 1e3;
567
+ return zonedAsUtcMs - truncatedInstant;
568
+ }
569
+ function parseIsoDateYmd(dateStr) {
570
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr.trim());
571
+ if (!match) return null;
572
+ const year = Number.parseInt(match[1], 10);
573
+ const month = Number.parseInt(match[2], 10);
574
+ const day = Number.parseInt(match[3], 10);
575
+ if (month < 1 || month > 12 || day < 1 || day > 31) return null;
576
+ const check = new Date(Date.UTC(year, month - 1, day));
577
+ if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) {
578
+ return null;
579
+ }
580
+ return { year, month, day };
581
+ }
582
+ function wallClockToUtcMs(dateStr, totalMinutes, rawTimeZone, strictGap = false) {
583
+ const ymd = parseIsoDateYmd(dateStr);
584
+ if (!ymd) return null;
585
+ const timeZone = normalizeTimeZone(rawTimeZone);
586
+ const extraDays = Math.floor(totalMinutes / 1440);
587
+ const remMins = (totalMinutes % 1440 + 1440) % 1440;
588
+ const targetUtcMidnight = Date.UTC(
589
+ ymd.year,
590
+ ymd.month - 1,
591
+ ymd.day + extraDays,
592
+ 0,
593
+ 0,
594
+ 0,
595
+ 0
596
+ );
597
+ const naiveUtcMs = targetUtcMidnight + remMins * 6e4;
598
+ const offset1 = getTimeZoneOffsetMs(naiveUtcMs, timeZone);
599
+ const guess1 = naiveUtcMs - offset1;
600
+ const offset2 = getTimeZoneOffsetMs(guess1, timeZone);
601
+ const guess2 = naiveUtcMs - offset2;
602
+ const targetDateObj = new Date(targetUtcMidnight);
603
+ const expectedDateStr = targetDateObj.toISOString().slice(0, 10);
604
+ const check2 = extractZonedWallClock(guess2, timeZone);
605
+ if (check2.dateStr === expectedDateStr && check2.minutesOfDay === remMins) {
606
+ return guess2;
607
+ }
608
+ const check1 = extractZonedWallClock(guess1, timeZone);
609
+ if (check1.dateStr === expectedDateStr && check1.minutesOfDay === remMins) {
610
+ return guess1;
611
+ }
612
+ return strictGap ? null : guess1;
613
+ }
614
+ function parsePeriodTimestampInZone(raw, endOfDay, rawTimeZone) {
615
+ const trimmed = raw.trim();
616
+ if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
617
+ const ms = wallClockToUtcMs(
618
+ trimmed,
619
+ endOfDay ? 1440 : 0,
620
+ rawTimeZone,
621
+ false
622
+ );
623
+ if (ms === null) return Number.NaN;
624
+ return endOfDay ? ms - 1 : ms;
625
+ }
626
+ if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) {
627
+ if (!parseIsoDateYmd(trimmed.slice(0, 10))) {
628
+ return Number.NaN;
629
+ }
630
+ }
631
+ return Date.parse(trimmed);
632
+ }
633
+ function enumerateLocalDatesInRange(rangeStartMs, rangeEndMs, rawTimeZone, maxDays = 366) {
634
+ const timeZone = normalizeTimeZone(rawTimeZone);
635
+ const startWall = extractZonedWallClock(rangeStartMs, timeZone);
636
+ const endWall = extractZonedWallClock(rangeEndMs, timeZone);
637
+ const startUtcDay = Date.UTC(
638
+ startWall.year,
639
+ startWall.month - 1,
640
+ startWall.day
641
+ );
642
+ const endUtcDay = Date.UTC(endWall.year, endWall.month - 1, endWall.day);
643
+ const results = [];
644
+ for (let cursorUtc = startUtcDay, count = 0; cursorUtc <= endUtcDay && count < maxDays; cursorUtc += 864e5, count += 1) {
645
+ const d = new Date(cursorUtc);
646
+ results.push({
647
+ dateStr: d.toISOString().slice(0, 10),
648
+ dayKey: String(d.getUTCDay())
649
+ });
650
+ }
651
+ return results;
652
+ }
653
+ function parseTimeMinutes(hhmm) {
654
+ const match = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
655
+ if (!match) return 9 * 60;
656
+ const hours = Number.parseInt(match[1], 10);
657
+ const minutes = Number.parseInt(match[2], 10);
658
+ if (hours === 24 && minutes === 0) return 1440;
659
+ const clampedH = Math.min(23, Math.max(0, hours));
660
+ const clampedM = Math.min(59, Math.max(0, minutes));
661
+ return clampedH * 60 + clampedM;
662
+ }
663
+ function formatTimeMinutes(totalMinutes) {
664
+ const clamped = Math.max(0, Math.min(24 * 60, totalMinutes));
665
+ const hours = Math.floor(clamped / 60);
666
+ const mins = clamped % 60;
667
+ return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
668
+ }
669
+ function extractMonthDay(dateOrMmDd) {
670
+ const trimmed = dateOrMmDd.trim();
671
+ const isoDay = trimmed.length >= 10 ? trimmed.slice(0, 10) : trimmed;
672
+ if (/^\d{4}-\d{2}-\d{2}$/.test(isoDay)) return isoDay.slice(5, 10);
673
+ if (/^\d{2}-\d{2}$/.test(isoDay)) return isoDay;
674
+ return "";
675
+ }
676
+ function matchesYearlessRange(dateStr, startStr, endStr) {
677
+ const [t, s, e] = [dateStr, startStr, endStr].map(extractMonthDay);
678
+ if (!t || !s || !e) return false;
679
+ return s <= e ? t >= s && t <= e : t >= s || t <= e;
680
+ }
681
+ function matchesDateOrYearlessRange(dateStr, startStr, endStr, yearless = false) {
682
+ if (!dateStr || !startStr || !endStr) return false;
683
+ if (yearless) return matchesYearlessRange(dateStr, startStr, endStr);
684
+ const [t, s, e] = [dateStr, startStr, endStr].map(
685
+ (d) => d.trim().slice(0, 10)
686
+ );
687
+ return t >= s && t <= e;
688
+ }
689
+ function matchesSingleDateOrYearless(dateStr, ruleDateStr, yearless = false) {
690
+ return matchesDateOrYearlessRange(
691
+ dateStr,
692
+ ruleDateStr,
693
+ ruleDateStr,
694
+ yearless
695
+ );
696
+ }
697
+ function isSlotBlackedOut(slotStartMs, slotEndMs, blackoutDates, rawTimeZone) {
698
+ const timeZone = normalizeTimeZone(rawTimeZone);
699
+ const slotDateStr = extractZonedWallClock(slotStartMs, timeZone).dateStr;
700
+ return blackoutDates.some((period) => {
701
+ if (!period?.start || !period?.end) return false;
702
+ if (period.yearless) {
703
+ return matchesDateOrYearlessRange(
704
+ slotDateStr,
705
+ period.start,
706
+ period.end,
707
+ true
708
+ );
709
+ }
710
+ const startMs = parsePeriodTimestampInZone(period.start, false, timeZone);
711
+ const endMs = parsePeriodTimestampInZone(period.end, true, timeZone);
712
+ return !Number.isNaN(startMs) && !Number.isNaN(endMs) && slotStartMs < endMs && slotEndMs > startMs;
713
+ });
714
+ }
715
+ function findMatchingHoliday(dateStr, globalHolidays = [], resourceHolidays = []) {
716
+ return [
717
+ ...Array.isArray(globalHolidays) ? globalHolidays : [],
718
+ ...Array.isArray(resourceHolidays) ? resourceHolidays : []
719
+ ].filter(
720
+ (r) => Boolean(r?.date) && matchesSingleDateOrYearless(dateStr, r.date, Boolean(r.yearless))
721
+ ).pop();
722
+ }
723
+ function findWinningSeasonalSchedule(dateStr, globalSeasonalSchedules = [], resourceSeasonals = []) {
724
+ return [
725
+ ...Array.isArray(globalSeasonalSchedules) ? globalSeasonalSchedules : [],
726
+ ...Array.isArray(resourceSeasonals) ? resourceSeasonals : []
727
+ ].filter(
728
+ (s) => s?.schedule && matchesDateOrYearlessRange(
729
+ dateStr,
730
+ s.startDate,
731
+ s.endDate,
732
+ Boolean(s.yearless)
733
+ )
734
+ ).sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)).pop();
735
+ }
736
+ function resolveDailyScheduleForDate(dateStr, dayKey, resource, globalSeasonalSchedules = [], globalHolidays = []) {
737
+ const baseSchedule = resource.weeklySchedule?.days ? resource.weeklySchedule : DEFAULT_WEEKLY_SCHEDULE;
738
+ const baseMode = baseSchedule.mode ?? "interval";
739
+ const baseDurationMins = Math.max(5, resource.slotDurationMinutes || 60);
740
+ const holiday = findMatchingHoliday(
741
+ dateStr,
742
+ globalHolidays,
743
+ resource.holidayRules
744
+ );
745
+ if (holiday) {
746
+ const hasCustom = Array.isArray(holiday.windows) && holiday.windows.length > 0;
747
+ const isClosed = (holiday.isClosed ?? !hasCustom) || !hasCustom;
748
+ return {
749
+ mode: isClosed ? baseMode : holiday.mode ?? baseMode,
750
+ durationMins: baseDurationMins,
751
+ windows: isClosed ? [] : holiday.windows ?? [],
752
+ scheduleLabel: holiday.name
753
+ };
754
+ }
755
+ const seasonal = findWinningSeasonalSchedule(
756
+ dateStr,
757
+ globalSeasonalSchedules,
758
+ resource.seasonalSchedules
759
+ );
760
+ if (seasonal) {
761
+ return {
762
+ mode: seasonal.schedule.mode ?? baseMode,
763
+ durationMins: Math.max(
764
+ 5,
765
+ seasonal.slotDurationMinutes ?? baseDurationMins
766
+ ),
767
+ windows: seasonal.schedule.days?.[dayKey] ?? [],
768
+ scheduleLabel: seasonal.label
769
+ };
770
+ }
771
+ return {
772
+ mode: baseMode,
773
+ durationMins: baseDurationMins,
774
+ windows: baseSchedule.days?.[dayKey] ?? []
775
+ };
776
+ }
777
+ function asStr$1(val, fallback = "") {
778
+ return val !== void 0 && val !== null ? String(val) : fallback;
779
+ }
780
+ function asOptStr$1(val) {
781
+ return val !== void 0 && val !== null && String(val) !== "" ? String(val) : void 0;
782
+ }
783
+ function resolveExternalSlotWindow(dateStr, slotLabel, initialEndIso, source) {
784
+ if (dateStr.length === 10) {
785
+ const defaultWindows = Object.assign(
786
+ /* @__PURE__ */ Object.create(null),
787
+ {
788
+ morning: { start: "09:00", end: "12:00" },
789
+ afternoon: { start: "14:00", end: "17:00" },
790
+ evening: { start: "18:00", end: "20:00" }
791
+ }
792
+ );
793
+ if (source.slotWindows && typeof source.slotWindows === "object") {
794
+ for (const [wKey, wVal] of Object.entries(source.slotWindows)) {
795
+ if (isSafeObjectKey(wKey) && Object.hasOwn(source.slotWindows, wKey) && wVal && typeof wVal.start === "string" && typeof wVal.end === "string") {
796
+ defaultWindows[wKey.toLowerCase()] = {
797
+ start: wVal.start,
798
+ end: wVal.end
799
+ };
800
+ }
801
+ }
802
+ }
803
+ const key = slotLabel.toLowerCase();
804
+ const windowSpec = slotLabel && isSafeObjectKey(key) && Object.hasOwn(defaultWindows, key) ? defaultWindows[key] : { start: "09:00", end: "10:00" };
805
+ return {
806
+ startIso: `${dateStr}T${windowSpec.start}:00.000Z`,
807
+ endIso: initialEndIso || `${dateStr}T${windowSpec.end}:00.000Z`
808
+ };
809
+ }
810
+ if (!initialEndIso) {
811
+ const parsedStart = Date.parse(dateStr);
812
+ const endIso = Number.isNaN(parsedStart) ? dateStr : new Date(parsedStart + 36e5).toISOString();
813
+ return { startIso: dateStr, endIso };
814
+ }
815
+ return { startIso: dateStr, endIso: initialEndIso };
816
+ }
817
+ function readMappedField(row, fieldName) {
818
+ if (!isSafeObjectKey(fieldName) || !Object.hasOwn(row, fieldName)) {
819
+ return void 0;
820
+ }
821
+ const value = row[fieldName];
822
+ return value !== void 0 && value !== null && String(value) !== "" ? String(value) : void 0;
823
+ }
824
+ function resolveExternalPartyAndStatus(row, source) {
825
+ const rawParty = readMappedField(row, source.partySizeField);
826
+ const parsed = rawParty ? Number(rawParty) : 1;
827
+ const partySize = Number.isFinite(parsed) ? Math.max(1, Math.trunc(parsed)) : 1;
828
+ const statusField = source.statusField;
829
+ const status = isSafeObjectKey(statusField) && Object.hasOwn(row, statusField) ? normalizeStatus(row[statusField]) : "confirmed";
830
+ return { partySize, status };
831
+ }
832
+ function mapExternalSourceRowToBooking(row, source) {
833
+ if (!isSafeObjectKey(source.dateField) || !Object.hasOwn(row, source.dateField)) {
834
+ return null;
835
+ }
836
+ const rawDate = row[source.dateField];
837
+ if (!rawDate) return null;
838
+ const dateStr = String(rawDate).trim();
839
+ const slotLabel = readMappedField(row, source.slotLabelField) ?? "";
840
+ const initialEndIso = readMappedField(row, source.endDateField) ?? "";
841
+ const { startIso, endIso } = resolveExternalSlotWindow(
842
+ dateStr,
843
+ slotLabel,
844
+ initialEndIso,
845
+ source
846
+ );
847
+ const documentId = asStr$1(row.documentId ?? row.id);
848
+ const customerName = readMappedField(row, source.customerNameField) ?? "Guest";
849
+ const { partySize, status } = resolveExternalPartyAndStatus(row, source);
850
+ return {
851
+ documentId,
852
+ bookingCode: `EXT-${documentId.slice(0, 8).toUpperCase()}`,
853
+ title: formatBookingDefaultTitle(customerName, source.label, partySize),
854
+ resourceDocumentId: source.id,
855
+ resourceName: source.label,
856
+ resourceColor: asStr$1(source.color, "#0d9488"),
857
+ customerName,
858
+ customerEmail: readMappedField(row, source.customerEmailField) ?? "",
859
+ customerPhone: readMappedField(row, source.customerPhoneField),
860
+ organization: readMappedField(row, source.organizationField),
861
+ startDate: startIso,
862
+ endDate: endIso,
863
+ slotLabel: asOptStr$1(slotLabel),
864
+ timezone: "UTC",
865
+ partySize,
866
+ status,
867
+ preferredLanguage: readMappedField(row, source.languageField),
868
+ notes: readMappedField(row, source.notesField),
869
+ sourceCollection: source.uid
870
+ };
871
+ }
872
+ function parseBoundaryMs(isoOrDate, endOfDay) {
873
+ if (!isoOrDate) return null;
874
+ const trimmed = isoOrDate.trim();
875
+ if (!trimmed) return null;
876
+ if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) {
877
+ if (!parseIsoDateYmd(trimmed.slice(0, 10))) {
878
+ return null;
879
+ }
880
+ }
881
+ const normalized = trimmed.length === 10 ? `${trimmed}T${endOfDay ? "23:59:59.999Z" : "00:00:00.000Z"}` : trimmed;
882
+ const ms = Date.parse(normalized);
883
+ return Number.isNaN(ms) ? null : ms;
884
+ }
885
+ function isWithinDateBoundaries(booking, fromMs, toMs) {
886
+ if (fromMs !== null) {
887
+ const endMs = Date.parse(booking.endDate);
888
+ if (!Number.isNaN(endMs) && endMs < fromMs) return false;
889
+ }
890
+ if (toMs !== null) {
891
+ const startMs = Date.parse(booking.startDate);
892
+ if (!Number.isNaN(startMs) && startMs > toMs) return false;
893
+ }
894
+ return true;
895
+ }
896
+ function matchesBookingAttributes(booking, targetResourceId, query) {
897
+ if (targetResourceId && booking.resourceDocumentId !== targetResourceId) {
898
+ return false;
899
+ }
900
+ if (query.activeOnly && booking.status !== "pending" && booking.status !== "confirmed") {
901
+ return false;
902
+ }
903
+ if (query.status && booking.status !== query.status) {
904
+ return false;
905
+ }
906
+ if (query.preferredLanguage && booking.preferredLanguage?.toLowerCase() !== query.preferredLanguage.toLowerCase()) {
907
+ return false;
908
+ }
909
+ return !query.source || booking.sourceCollection === query.source;
910
+ }
911
+ function matchesBookingSearch(booking, searchLower) {
912
+ if (!searchLower) return true;
913
+ const haystack = [
914
+ booking.bookingCode,
915
+ booking.customerName,
916
+ booking.customerEmail,
917
+ booking.organization,
918
+ booking.assignedHost,
919
+ booking.resourceName,
920
+ booking.notes
921
+ ].filter(Boolean).join(" ").toLowerCase();
922
+ return haystack.includes(searchLower);
923
+ }
924
+ function filterAndSortBookings(allBookings, resources, query) {
925
+ const fromMs = parseBoundaryMs(query.from, false);
926
+ const toMs = parseBoundaryMs(query.to, true);
927
+ const matchedResource = !query.resourceDocumentId && query.resourceSlug ? resources.find((r) => r.slug === query.resourceSlug) : void 0;
928
+ const targetResourceId = query.resourceDocumentId ?? matchedResource?.documentId;
929
+ const searchLower = query.search?.trim().toLowerCase();
930
+ const sorted = allBookings.filter(
931
+ (b) => isWithinDateBoundaries(b, fromMs, toMs) && matchesBookingAttributes(b, targetResourceId, query) && matchesBookingSearch(b, searchLower)
932
+ ).sort((a, b) => Date.parse(a.startDate) - Date.parse(b.startDate));
933
+ if (typeof query.limit === "number" && Number.isFinite(query.limit) && query.limit > 0) {
934
+ return sorted.slice(0, Math.trunc(query.limit));
935
+ }
936
+ return sorted;
937
+ }
938
+ const VALID_BOOKING_STATUSES = /* @__PURE__ */ new Set([
939
+ "pending",
940
+ "confirmed",
941
+ "completed",
942
+ "cancelled",
943
+ "no_show"
944
+ ]);
945
+ function parseStrictBookingStatus(raw) {
946
+ if (typeof raw !== "string") {
947
+ throw new BookingValidationError("A valid booking status is required");
948
+ }
949
+ const normalized = raw.trim().toLowerCase();
950
+ if (!VALID_BOOKING_STATUSES.has(normalized)) {
951
+ throw new BookingValidationError(`Invalid booking status: '${raw}'`);
952
+ }
953
+ return normalized;
954
+ }
955
+ function normalizeStatus(raw) {
956
+ const str = String(raw ?? "pending").toLowerCase().trim();
957
+ return VALID_BOOKING_STATUSES.has(str) ? str : "pending";
958
+ }
959
+ function hashCancelToken(token) {
960
+ return crypto__default.default.createHash("sha256").update(token, "utf8").digest("hex");
961
+ }
962
+ function verifyCancelToken(token, expectedHash) {
963
+ if (typeof token !== "string" || !token.trim() || typeof expectedHash !== "string" || !expectedHash.trim()) {
964
+ return false;
965
+ }
966
+ const actualHash = hashCancelToken(token);
967
+ const aBuf = Buffer.from(actualHash, "utf8");
968
+ const bBuf = Buffer.from(expectedHash, "utf8");
969
+ if (aBuf.length !== bBuf.length) {
970
+ return false;
971
+ }
972
+ return crypto__default.default.timingSafeEqual(aBuf, bBuf);
973
+ }
974
+ function verifySecretToken(provided, expected) {
975
+ if (typeof provided !== "string" || !provided.trim() || typeof expected !== "string" || !expected.trim()) {
976
+ return false;
977
+ }
978
+ const aBuf = Buffer.from(hashCancelToken(provided.trim()), "utf8");
979
+ const bBuf = Buffer.from(hashCancelToken(expected.trim()), "utf8");
980
+ return aBuf.length === bBuf.length && crypto__default.default.timingSafeEqual(aBuf, bBuf);
981
+ }
982
+ function computeOpaqueBookingUid(booking) {
983
+ const seed = `ical-feed:${booking.documentId}:${booking.bookingCode}:${booking.startDate}`;
984
+ return crypto__default.default.createHash("sha256").update(seed, "utf8").digest("hex").slice(0, 24);
985
+ }
986
+ function generateRawCancelToken() {
987
+ return crypto__default.default.randomBytes(24).toString("hex");
988
+ }
989
+ function generateBookingCode(now = /* @__PURE__ */ new Date()) {
990
+ const safeNow = now instanceof Date && !Number.isNaN(now.getTime()) ? now : /* @__PURE__ */ new Date();
991
+ const datePart = safeNow.toISOString().slice(0, 10).replace(/-/g, "");
992
+ const randomSuffix = crypto__default.default.randomBytes(3).toString("hex").toUpperCase();
993
+ return `BKG-${datePart}-${randomSuffix}`;
994
+ }
995
+ function asOptStr(val) {
996
+ return val !== void 0 && val !== null && String(val) !== "" ? String(val) : void 0;
997
+ }
998
+ function asObjRecord(val) {
999
+ return val && typeof val === "object" && !Array.isArray(val) ? val : void 0;
1000
+ }
1001
+ function asStr(val, fallback = "") {
1002
+ return val !== void 0 && val !== null ? String(val) : fallback;
1003
+ }
1004
+ function resolveBookingResourceRef(raw, resourceMap) {
1005
+ const relResource = asObjRecord(raw.resource);
1006
+ const rawResourceStr = typeof raw.resource === "string" ? raw.resource : "";
1007
+ const resourceDocumentId = String(
1008
+ relResource?.documentId ?? relResource?.id ?? raw.resourceDocumentId ?? rawResourceStr
1009
+ );
1010
+ const mappedResource = resourceDocumentId ? resourceMap.get(resourceDocumentId) : void 0;
1011
+ const resourceName = mappedResource?.name ?? asOptStr(relResource?.name);
1012
+ return {
1013
+ resourceDocumentId: resourceDocumentId || void 0,
1014
+ mappedResource,
1015
+ resourceName
1016
+ };
1017
+ }
1018
+ function formatBookingDefaultTitle(customerName, resourceName, partySize) {
1019
+ const resourceSuffix = resourceName ? ` — ${resourceName}` : "";
1020
+ const sizeSuffix = partySize > 1 ? ` (${partySize})` : "";
1021
+ return `${customerName}${resourceSuffix}${sizeSuffix}`;
1022
+ }
1023
+ function normalizeBookingOptionals(raw) {
1024
+ return {
1025
+ customerPhone: asOptStr(raw.customerPhone),
1026
+ organization: asOptStr(raw.organization),
1027
+ slotLabel: asOptStr(raw.slotLabel),
1028
+ preferredLanguage: asOptStr(raw.preferredLanguage),
1029
+ assignedHost: asOptStr(raw.assignedHost),
1030
+ notes: asOptStr(raw.notes),
1031
+ internalNotes: asOptStr(raw.internalNotes),
1032
+ metadata: sanitizePlainRecord(raw.metadata),
1033
+ createdAt: asOptStr(raw.createdAt),
1034
+ updatedAt: asOptStr(raw.updatedAt)
1035
+ };
1036
+ }
1037
+ function normalizeBookingRecord(raw, resourceMap) {
1038
+ const { resourceDocumentId, mappedResource, resourceName } = resolveBookingResourceRef(raw, resourceMap);
1039
+ const customerName = asStr(raw.customerName, "Guest");
1040
+ const rawParty = Number(raw.partySize ?? 1);
1041
+ const partySize = Number.isFinite(rawParty) ? Math.max(1, Math.trunc(rawParty)) : 1;
1042
+ const documentId = asStr(raw.documentId ?? raw.id);
1043
+ const startDate = asStr(raw.startDate);
1044
+ const defaultTitle = formatBookingDefaultTitle(
1045
+ customerName,
1046
+ resourceName,
1047
+ partySize
1048
+ );
1049
+ return {
1050
+ documentId,
1051
+ bookingCode: asStr(raw.bookingCode, `BKG-${documentId}`),
1052
+ title: asOptStr(raw.title) ?? defaultTitle,
1053
+ resourceDocumentId,
1054
+ resourceName,
1055
+ resourceColor: mappedResource?.color,
1056
+ customerName,
1057
+ customerEmail: asStr(raw.customerEmail),
1058
+ startDate,
1059
+ endDate: asStr(raw.endDate, startDate),
1060
+ timezone: normalizeTimeZone(
1061
+ asStr(raw.timezone ?? mappedResource?.timezone, "UTC")
1062
+ ),
1063
+ partySize,
1064
+ status: normalizeStatus(raw.status),
1065
+ sourceCollection: asStr(raw.sourceCollection, "plugin::bookings.booking"),
1066
+ ...normalizeBookingOptionals(raw)
1067
+ };
1068
+ }
1069
+ function trimOrUndefined(val) {
1070
+ if (typeof val !== "string") return void 0;
1071
+ const trimmed = val.trim();
1072
+ return trimmed ? trimmed : void 0;
1073
+ }
1074
+ function buildBookingCreatePayload(params) {
1075
+ const {
1076
+ bookingCode,
1077
+ customerName,
1078
+ customerEmail,
1079
+ startMs,
1080
+ endMs,
1081
+ partySize,
1082
+ status,
1083
+ assignedHost,
1084
+ cancelTokenHash,
1085
+ resource,
1086
+ settings: settings2,
1087
+ input
1088
+ } = params;
1089
+ return {
1090
+ bookingCode,
1091
+ title: formatBookingDefaultTitle(customerName, resource?.name, partySize),
1092
+ resource: resource?.documentId ?? void 0,
1093
+ customerName,
1094
+ customerEmail,
1095
+ customerPhone: trimOrUndefined(input.customerPhone),
1096
+ organization: trimOrUndefined(input.organization),
1097
+ startDate: new Date(startMs).toISOString(),
1098
+ endDate: new Date(endMs).toISOString(),
1099
+ slotLabel: trimOrUndefined(input.slotLabel),
1100
+ timezone: normalizeTimeZone(
1101
+ asStr(
1102
+ input.timezone || resource?.timezone || settings2.defaultTimezone,
1103
+ "UTC"
1104
+ )
1105
+ ),
1106
+ partySize,
1107
+ status,
1108
+ preferredLanguage: trimOrUndefined(input.preferredLanguage),
1109
+ assignedHost,
1110
+ notes: trimOrUndefined(input.notes),
1111
+ cancelTokenHash,
1112
+ sourceCollection: "plugin::bookings.booking",
1113
+ metadata: sanitizePlainRecord(input.metadata)
1114
+ };
1115
+ }
1116
+ const CAPACITY_WINDOW_PAD_MS = 864e5;
1117
+ function buildCapacityWindowQuery(resourceDocumentId, startMs, endMs) {
1118
+ return {
1119
+ resourceDocumentId,
1120
+ from: new Date(startMs - CAPACITY_WINDOW_PAD_MS).toISOString(),
1121
+ to: new Date(endMs + CAPACITY_WINDOW_PAD_MS).toISOString(),
1122
+ activeOnly: true,
1123
+ limit: 1e3
1124
+ };
1125
+ }
1126
+ function assertNonRolloverDatePrefix(raw, errorMessage) {
1127
+ const trimmed = raw.trim();
1128
+ if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) {
1129
+ if (!parseIsoDateYmd(trimmed.slice(0, 10))) {
1130
+ throw new BookingValidationError(errorMessage);
1131
+ }
1132
+ }
1133
+ }
1134
+ function computeRescheduleWindow(targetRaw, input) {
1135
+ if (typeof input.newStartDate !== "string" || !input.newStartDate.trim()) {
1136
+ throw new BookingValidationError("A valid newStartDate is required");
1137
+ }
1138
+ assertNonRolloverDatePrefix(
1139
+ input.newStartDate,
1140
+ "A valid newStartDate is required"
1141
+ );
1142
+ const newStartMs = Date.parse(input.newStartDate);
1143
+ if (Number.isNaN(newStartMs)) {
1144
+ throw new BookingValidationError("A valid newStartDate is required");
1145
+ }
1146
+ const oldStartMs = Date.parse(String(targetRaw.startDate));
1147
+ const oldEndMs = Date.parse(String(targetRaw.endDate));
1148
+ const hasValidOldRange = !Number.isNaN(oldStartMs) && !Number.isNaN(oldEndMs) && oldEndMs > oldStartMs;
1149
+ const defaultDurationMs = hasValidOldRange ? oldEndMs - oldStartMs : 36e5;
1150
+ if (typeof input.newEndDate === "string" && input.newEndDate.trim()) {
1151
+ assertNonRolloverDatePrefix(
1152
+ input.newEndDate,
1153
+ "newEndDate must be a valid ISO date"
1154
+ );
1155
+ }
1156
+ const newEndMs = input.newEndDate ? Date.parse(input.newEndDate) : newStartMs + defaultDurationMs;
1157
+ if (Number.isNaN(newEndMs) || newEndMs <= newStartMs) {
1158
+ throw new BookingValidationError("newEndDate must be after newStartDate");
1159
+ }
1160
+ return { newStartMs, newEndMs };
1161
+ }
1162
+ const MAX_PUBLIC_SLOTS_RANGE_MS = 90 * 864e5;
1163
+ const MAX_PUBLIC_FEED_RANGE_MS = 366 * 864e5;
1164
+ function sanitizePublicHost(host) {
1165
+ return {
1166
+ id: host.id,
1167
+ name: host.name,
1168
+ ...Array.isArray(host.languages) ? { languages: host.languages } : {},
1169
+ ...host.role !== void 0 ? { role: host.role } : {}
1170
+ };
1171
+ }
1172
+ function sanitizePublicResource(resource) {
1173
+ const hosts = Array.isArray(resource.assignedHosts) ? resource.assignedHosts.filter(
1174
+ (h) => Boolean(h) && typeof h.id === "string" && typeof h.name === "string"
1175
+ ).map(sanitizePublicHost) : [];
1176
+ return {
1177
+ documentId: resource.documentId,
1178
+ name: resource.name,
1179
+ slug: resource.slug,
1180
+ resourceType: resource.resourceType,
1181
+ ...resource.description !== void 0 ? { description: resource.description } : {},
1182
+ color: resource.color,
1183
+ timezone: resource.timezone,
1184
+ slotDurationMinutes: resource.slotDurationMinutes,
1185
+ bufferBeforeMinutes: resource.bufferBeforeMinutes,
1186
+ bufferAfterMinutes: resource.bufferAfterMinutes,
1187
+ capacityPerSlot: resource.capacityPerSlot,
1188
+ maxPartySize: resource.maxPartySize,
1189
+ minLeadTimeHours: resource.minLeadTimeHours,
1190
+ maxAdvanceDays: resource.maxAdvanceDays,
1191
+ requiresApproval: resource.requiresApproval,
1192
+ weeklySchedule: resource.weeklySchedule,
1193
+ ...resource.seasonalSchedules !== void 0 ? { seasonalSchedules: resource.seasonalSchedules } : {},
1194
+ ...resource.holidayRules !== void 0 ? { holidayRules: resource.holidayRules } : {},
1195
+ blackoutDates: resource.blackoutDates,
1196
+ ...resource.recurrenceRule !== void 0 ? { recurrenceRule: resource.recurrenceRule } : {},
1197
+ supportedLanguages: resource.supportedLanguages,
1198
+ assignedHosts: hosts,
1199
+ ...resource.location !== void 0 ? { location: resource.location } : {},
1200
+ ...resource.price !== void 0 ? { price: resource.price } : {},
1201
+ ...resource.currency !== void 0 ? { currency: resource.currency } : {},
1202
+ isActive: resource.isActive
1203
+ };
1204
+ }
1205
+ function sanitizePublicSlot(slot) {
1206
+ return {
1207
+ ...slot,
1208
+ availableHosts: (slot.availableHosts ?? []).map(sanitizePublicHost)
1209
+ };
1210
+ }
1211
+ function sanitizePublicBooking(booking) {
1212
+ return {
1213
+ documentId: booking.documentId,
1214
+ bookingCode: booking.bookingCode,
1215
+ title: booking.title,
1216
+ ...booking.resourceDocumentId !== void 0 ? { resourceDocumentId: booking.resourceDocumentId } : {},
1217
+ ...booking.resourceName !== void 0 ? { resourceName: booking.resourceName } : {},
1218
+ ...booking.resourceColor !== void 0 ? { resourceColor: booking.resourceColor } : {},
1219
+ customerName: booking.customerName,
1220
+ customerEmail: booking.customerEmail,
1221
+ ...booking.customerPhone !== void 0 ? { customerPhone: booking.customerPhone } : {},
1222
+ ...booking.organization !== void 0 ? { organization: booking.organization } : {},
1223
+ startDate: booking.startDate,
1224
+ endDate: booking.endDate,
1225
+ ...booking.slotLabel !== void 0 ? { slotLabel: booking.slotLabel } : {},
1226
+ timezone: booking.timezone,
1227
+ partySize: booking.partySize,
1228
+ status: booking.status,
1229
+ ...booking.preferredLanguage !== void 0 ? { preferredLanguage: booking.preferredLanguage } : {},
1230
+ ...booking.assignedHost !== void 0 ? { assignedHost: booking.assignedHost } : {},
1231
+ ...booking.notes !== void 0 ? { notes: booking.notes } : {},
1232
+ ...booking.secondaryDates !== void 0 ? { secondaryDates: booking.secondaryDates } : {},
1233
+ ...booking.createdAt !== void 0 ? { createdAt: booking.createdAt } : {},
1234
+ ...booking.updatedAt !== void 0 ? { updatedAt: booking.updatedAt } : {}
1235
+ };
1236
+ }
1237
+ function clampQueryLimit(raw, fallback, max) {
1238
+ if (!raw) return fallback;
1239
+ const parsed = Number.parseInt(raw, 10);
1240
+ if (!Number.isFinite(parsed) || parsed < 1) return fallback;
1241
+ return Math.min(max, parsed);
1242
+ }
1243
+ function respondPublicError(ctx, strapi, err, fallbackMessage) {
1244
+ if (err instanceof BookingNotFoundError) {
1245
+ if (typeof ctx.notFound === "function") {
1246
+ return ctx.notFound(err.message);
1247
+ }
1248
+ ctx.status = 404;
1249
+ ctx.body = {
1250
+ error: { status: 404, name: "NotFoundError", message: err.message }
1251
+ };
1252
+ return void 0;
1253
+ }
1254
+ if (err instanceof BookingForbiddenError) {
1255
+ if (typeof ctx.forbidden === "function") {
1256
+ return ctx.forbidden(err.message);
1257
+ }
1258
+ ctx.status = 403;
1259
+ ctx.body = {
1260
+ error: { status: 403, name: "ForbiddenError", message: err.message }
1261
+ };
1262
+ return void 0;
1263
+ }
1264
+ if (err instanceof BookingValidationError) {
1265
+ return ctx.badRequest(err.message);
1266
+ }
1267
+ strapi.log?.error?.(
1268
+ `[bookings] ${fallbackMessage}: ${err instanceof Error ? err.message : "unknown error"}`
1269
+ );
1270
+ return ctx.badRequest(fallbackMessage);
1271
+ }
1272
+ function extractFeedTokenFromContext(ctx) {
1273
+ if (typeof ctx.query.token === "string" && ctx.query.token.trim()) {
1274
+ return ctx.query.token.trim();
1275
+ }
1276
+ const authHeader = ctx.request?.headers?.authorization ?? ctx.request?.headers?.Authorization;
1277
+ if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
1278
+ return authHeader.slice(7).trim() || void 0;
1279
+ }
1280
+ return void 0;
1281
+ }
1282
+ const contentApi = ({ strapi }) => {
1283
+ const getAvailabilityService = () => strapi.plugin(PLUGIN_ID).service("availability");
1284
+ const getReservationService = () => strapi.plugin(PLUGIN_ID).service("reservation");
1285
+ const getSettingsService = () => strapi.plugin(PLUGIN_ID).service("settings");
1286
+ return {
1287
+ async listPublicResources(ctx) {
1288
+ const limit = clampQueryLimit(ctx.query.limit, 100, 100);
1289
+ const resources = await getAvailabilityService().listResources(
1290
+ true,
1291
+ limit
1292
+ );
1293
+ ctx.body = { data: resources.map(sanitizePublicResource) };
1294
+ },
1295
+ async getAvailableSlots(ctx) {
1296
+ const now = /* @__PURE__ */ new Date();
1297
+ const fromRaw = ctx.query.from || now.toISOString().slice(0, 10);
1298
+ const defaultTo = new Date(now.getTime() + 14 * 864e5).toISOString().slice(0, 10);
1299
+ const toRaw = ctx.query.to || defaultTo;
1300
+ const fromMs = parseBoundaryMs(fromRaw, false);
1301
+ const toMs = parseBoundaryMs(toRaw, true);
1302
+ if (fromMs === null || toMs === null || toMs < fromMs) {
1303
+ return ctx.badRequest("Invalid from or to date range");
1304
+ }
1305
+ const clampedTo = toMs - fromMs > MAX_PUBLIC_SLOTS_RANGE_MS ? new Date(fromMs + MAX_PUBLIC_SLOTS_RANGE_MS).toISOString().slice(0, 10) : toRaw;
1306
+ let partySize = 1;
1307
+ if (ctx.query.partySize !== void 0 && ctx.query.partySize !== "") {
1308
+ const rawParty = ctx.query.partySize.trim();
1309
+ if (!/^[1-9]\d*$/.test(rawParty)) {
1310
+ return ctx.badRequest(
1311
+ "partySize must be an integer greater than or equal to 1"
1312
+ );
1313
+ }
1314
+ const parsed = Number(rawParty);
1315
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
1316
+ return ctx.badRequest(
1317
+ "partySize must be an integer greater than or equal to 1"
1318
+ );
1319
+ }
1320
+ partySize = parsed;
1321
+ }
1322
+ const limit = clampQueryLimit(ctx.query.limit, 500, 500);
1323
+ const slots = await getAvailabilityService().getAvailableSlots({
1324
+ resourceDocumentId: ctx.query.resourceDocumentId,
1325
+ resourceSlug: ctx.query.resourceSlug,
1326
+ from: fromRaw,
1327
+ to: clampedTo,
1328
+ partySize,
1329
+ preferredLanguage: ctx.query.preferredLanguage,
1330
+ referenceNow: now
1331
+ });
1332
+ ctx.body = { data: slots.slice(0, limit).map(sanitizePublicSlot) };
1333
+ },
1334
+ async createReservation(ctx) {
1335
+ try {
1336
+ const body = ctx.request.body ?? {};
1337
+ const result = await getReservationService().createReservation(body, {
1338
+ isAdmin: false,
1339
+ referenceNow: /* @__PURE__ */ new Date()
1340
+ });
1341
+ ctx.body = {
1342
+ data: {
1343
+ booking: sanitizePublicBooking(result.booking),
1344
+ cancelToken: result.cancelToken,
1345
+ waitlisted: result.waitlisted
1346
+ }
1347
+ };
1348
+ } catch (err) {
1349
+ return respondPublicError(
1350
+ ctx,
1351
+ strapi,
1352
+ err,
1353
+ "Unable to complete reservation"
1354
+ );
1355
+ }
1356
+ },
1357
+ async rescheduleReservation(ctx) {
1358
+ try {
1359
+ const body = ctx.request.body ?? {};
1360
+ const updated = await getReservationService().rescheduleBooking(body, {
1361
+ isAdmin: false,
1362
+ referenceNow: /* @__PURE__ */ new Date()
1363
+ });
1364
+ ctx.body = { data: sanitizePublicBooking(updated) };
1365
+ } catch (err) {
1366
+ return respondPublicError(
1367
+ ctx,
1368
+ strapi,
1369
+ err,
1370
+ "Unable to reschedule booking"
1371
+ );
1372
+ }
1373
+ },
1374
+ async cancelReservation(ctx) {
1375
+ try {
1376
+ const body = ctx.request.body ?? {};
1377
+ const cancelled = await getReservationService().cancelBooking(body, {
1378
+ isAdmin: false,
1379
+ referenceNow: /* @__PURE__ */ new Date()
1380
+ });
1381
+ ctx.body = { data: sanitizePublicBooking(cancelled) };
1382
+ } catch (err) {
1383
+ return respondPublicError(ctx, strapi, err, "Unable to cancel booking");
1384
+ }
1385
+ },
1386
+ async getICalFeed(ctx) {
1387
+ const now = /* @__PURE__ */ new Date();
1388
+ const defaultFrom = new Date(now.getTime() - 30 * 864e5).toISOString().slice(0, 10);
1389
+ const defaultTo = new Date(now.getTime() + 180 * 864e5).toISOString().slice(0, 10);
1390
+ const fromRaw = ctx.query.from || defaultFrom;
1391
+ const toRaw = ctx.query.to || defaultTo;
1392
+ const fromMs = parseBoundaryMs(fromRaw, false);
1393
+ const toMs = parseBoundaryMs(toRaw, true);
1394
+ if (fromMs === null || toMs === null || toMs < fromMs) {
1395
+ return ctx.badRequest("Invalid from or to date range");
1396
+ }
1397
+ const clampedTo = toMs - fromMs > MAX_PUBLIC_FEED_RANGE_MS ? new Date(fromMs + MAX_PUBLIC_FEED_RANGE_MS).toISOString().slice(0, 10) : toRaw;
1398
+ const limit = clampQueryLimit(ctx.query.limit, 250, 500);
1399
+ const settings2 = await getSettingsService().getSettings();
1400
+ const pluginConfig = strapi.config.get(`plugin::${PLUGIN_ID}`) ?? {};
1401
+ const configuredToken = settings2.icalFeedToken ?? pluginConfig.icalFeedToken;
1402
+ const providedToken = extractFeedTokenFromContext(ctx);
1403
+ const authorizedFullFeed = verifySecretToken(
1404
+ providedToken,
1405
+ configuredToken
1406
+ );
1407
+ const ics = await getReservationService().exportICal(
1408
+ {
1409
+ from: fromRaw,
1410
+ to: clampedTo,
1411
+ resourceDocumentId: ctx.query.resourceDocumentId,
1412
+ resourceSlug: ctx.query.resourceSlug,
1413
+ status: "confirmed",
1414
+ limit
1415
+ },
1416
+ { anonymize: !authorizedFullFeed }
1417
+ );
1418
+ ctx.set("Content-Type", "text/calendar; charset=utf-8");
1419
+ ctx.set("Content-Disposition", 'inline; filename="bookings.ics"');
1420
+ ctx.set("X-Content-Type-Options", "nosniff");
1421
+ ctx.body = ics;
1422
+ }
1423
+ };
1424
+ };
1425
+ const controllers = {
1426
+ admin,
1427
+ "content-api": contentApi
1428
+ };
1429
+ const STORE_KEY = "settings";
1430
+ const runtimeSourcesByStrapi = /* @__PURE__ */ new WeakMap();
1431
+ function getRuntimeSourceMap(strapi) {
1432
+ let map = runtimeSourcesByStrapi.get(strapi);
1433
+ if (!map) {
1434
+ map = /* @__PURE__ */ new Map();
1435
+ runtimeSourcesByStrapi.set(strapi, map);
1436
+ }
1437
+ return map;
1438
+ }
1439
+ function clearRuntimeSettingsState(strapi) {
1440
+ runtimeSourcesByStrapi.delete(strapi);
1441
+ }
1442
+ const VALID_THEMES = /* @__PURE__ */ new Set([
1443
+ "breeze",
1444
+ "emerald",
1445
+ "sand",
1446
+ "midnight",
1447
+ "classic"
1448
+ ]);
1449
+ const VALID_SECONDARY_LANGS = /* @__PURE__ */ new Set(["local", "ar", "off"]);
1450
+ const IGNORED_SYSTEM_UIDS = /* @__PURE__ */ new Set([
1451
+ "plugin::upload.file",
1452
+ "plugin::upload.folder",
1453
+ "plugin::i18n.locale",
1454
+ "plugin::content-releases.release",
1455
+ "plugin::review-workflows.workflow"
1456
+ ]);
1457
+ const BLOCKED_UID_PREFIXES = [
1458
+ "admin::",
1459
+ "strapi::",
1460
+ "plugin::users-permissions."
1461
+ ];
1462
+ const STRING_ATTR_TYPES = /* @__PURE__ */ new Set(["string", "text", "email", "uid"]);
1463
+ const NUMBER_ATTR_TYPES = /* @__PURE__ */ new Set([
1464
+ "integer",
1465
+ "biginteger",
1466
+ "float",
1467
+ "decimal"
1468
+ ]);
1469
+ function isDiscoverableCollectionUid(uid, kind2) {
1470
+ if (!isSafeObjectKey(uid)) return false;
1471
+ if (kind2 && kind2 !== "collectionType") return false;
1472
+ if (BLOCKED_UID_PREFIXES.some((prefix) => uid.startsWith(prefix))) {
1473
+ return false;
1474
+ }
1475
+ return !IGNORED_SYSTEM_UIDS.has(uid);
1476
+ }
1477
+ function sanitizeSourceMapping(raw) {
1478
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1479
+ return null;
1480
+ }
1481
+ const src = raw;
1482
+ if (!isDiscoverableCollectionUid(src.uid)) {
1483
+ return null;
1484
+ }
1485
+ let cleanSlotWindows;
1486
+ if (src.slotWindows && typeof src.slotWindows === "object") {
1487
+ cleanSlotWindows = {};
1488
+ for (const [k, v] of Object.entries(src.slotWindows)) {
1489
+ if (isSafeObjectKey(k) && v && typeof v.start === "string" && typeof v.end === "string") {
1490
+ cleanSlotWindows[k] = { start: v.start, end: v.end };
1491
+ }
1492
+ }
1493
+ }
1494
+ return {
1495
+ ...src,
1496
+ uid: src.uid,
1497
+ slotWindows: cleanSlotWindows
1498
+ };
1499
+ }
1500
+ function sanitizeSourceArray(val) {
1501
+ if (!Array.isArray(val)) return [];
1502
+ return val.map(sanitizeSourceMapping).filter((item) => item !== null);
1503
+ }
1504
+ function asArrayOr(val, fallback) {
1505
+ if (Array.isArray(val)) return val;
1506
+ return Array.isArray(fallback) ? fallback : [];
1507
+ }
1508
+ function finiteIntOr(val, fallback, min, max) {
1509
+ const num = typeof val === "number" ? val : Number(val);
1510
+ const safe = Number.isFinite(num) ? Math.trunc(num) : fallback;
1511
+ const clamped = Math.max(min, safe);
1512
+ return max !== void 0 ? Math.min(max, clamped) : clamped;
1513
+ }
1514
+ function buildDefaultSlotSettings(pluginConfig) {
1515
+ return {
1516
+ defaultTimezone: pluginConfig.defaultTimezone ?? "UTC",
1517
+ defaultSlotDurationMinutes: finiteIntOr(
1518
+ pluginConfig.defaultSlotDurationMinutes,
1519
+ 60,
1520
+ 5,
1521
+ 1440
1522
+ ),
1523
+ defaultBufferBeforeMinutes: finiteIntOr(
1524
+ pluginConfig.defaultBufferBeforeMinutes,
1525
+ 0,
1526
+ 0,
1527
+ 720
1528
+ ),
1529
+ defaultBufferAfterMinutes: finiteIntOr(
1530
+ pluginConfig.defaultBufferAfterMinutes,
1531
+ 0,
1532
+ 0,
1533
+ 720
1534
+ ),
1535
+ defaultCapacityPerSlot: finiteIntOr(
1536
+ pluginConfig.defaultCapacityPerSlot,
1537
+ 1,
1538
+ 1
1539
+ ),
1540
+ defaultMinLeadTimeHours: finiteIntOr(
1541
+ pluginConfig.defaultMinLeadTimeHours,
1542
+ 2,
1543
+ 0
1544
+ ),
1545
+ defaultMaxAdvanceDays: finiteIntOr(
1546
+ pluginConfig.defaultMaxAdvanceDays,
1547
+ 90,
1548
+ 1
1549
+ )
1550
+ };
1551
+ }
1552
+ function buildDefaultSettings(strapi) {
1553
+ const pluginConfig = strapi.config.get(`plugin::${PLUGIN_ID}`) ?? {};
1554
+ return {
1555
+ theme: pluginConfig.theme ?? "breeze",
1556
+ defaultLocale: pluginConfig.defaultLocale ?? "es",
1557
+ secondaryCalendarLanguage: pluginConfig.secondaryCalendarLanguage ?? "local",
1558
+ ...buildDefaultSlotSettings(pluginConfig),
1559
+ defaultRequiresApproval: pluginConfig.defaultRequiresApproval ?? false,
1560
+ sendEmailNotifications: pluginConfig.sendEmailNotifications ?? true,
1561
+ icalFeedToken: typeof pluginConfig.icalFeedToken === "string" && pluginConfig.icalFeedToken.trim() ? pluginConfig.icalFeedToken.trim() : void 0,
1562
+ secondaryCalendars: asArrayOr(pluginConfig.secondaryCalendars, []),
1563
+ defaultSeasonalSchedules: asArrayOr(
1564
+ pluginConfig.defaultSeasonalSchedules,
1565
+ []
1566
+ ),
1567
+ holidays: asArrayOr(pluginConfig.holidays, []),
1568
+ sources: sanitizeSourceArray(pluginConfig.sources)
1569
+ };
1570
+ }
1571
+ function mergeConfiguredSources(strapi, defaults, stored) {
1572
+ const mergedSources = /* @__PURE__ */ new Map();
1573
+ for (const src of defaults.sources) {
1574
+ mergedSources.set(src.uid, src);
1575
+ }
1576
+ for (const [uid, src] of getRuntimeSourceMap(strapi).entries()) {
1577
+ if (isDiscoverableCollectionUid(uid)) {
1578
+ mergedSources.set(uid, src);
1579
+ }
1580
+ }
1581
+ for (const src of sanitizeSourceArray(stored?.sources)) {
1582
+ mergedSources.set(src.uid, src);
1583
+ }
1584
+ return Array.from(mergedSources.values());
1585
+ }
1586
+ function resolveSlotSettings(primary, fallback) {
1587
+ const defaultTimezone = typeof primary?.defaultTimezone === "string" && primary.defaultTimezone.trim() ? primary.defaultTimezone.trim() : fallback.defaultTimezone;
1588
+ return {
1589
+ defaultTimezone,
1590
+ defaultSlotDurationMinutes: finiteIntOr(
1591
+ primary?.defaultSlotDurationMinutes,
1592
+ fallback.defaultSlotDurationMinutes,
1593
+ 5,
1594
+ 1440
1595
+ ),
1596
+ defaultBufferBeforeMinutes: finiteIntOr(
1597
+ primary?.defaultBufferBeforeMinutes,
1598
+ fallback.defaultBufferBeforeMinutes,
1599
+ 0,
1600
+ 720
1601
+ ),
1602
+ defaultBufferAfterMinutes: finiteIntOr(
1603
+ primary?.defaultBufferAfterMinutes,
1604
+ fallback.defaultBufferAfterMinutes,
1605
+ 0,
1606
+ 720
1607
+ ),
1608
+ defaultCapacityPerSlot: finiteIntOr(
1609
+ primary?.defaultCapacityPerSlot,
1610
+ fallback.defaultCapacityPerSlot,
1611
+ 1
1612
+ ),
1613
+ defaultMinLeadTimeHours: finiteIntOr(
1614
+ primary?.defaultMinLeadTimeHours,
1615
+ fallback.defaultMinLeadTimeHours,
1616
+ 0
1617
+ ),
1618
+ defaultMaxAdvanceDays: finiteIntOr(
1619
+ primary?.defaultMaxAdvanceDays,
1620
+ fallback.defaultMaxAdvanceDays,
1621
+ 1
1622
+ )
1623
+ };
1624
+ }
1625
+ function resolveUpdatedAppearance(partial, current) {
1626
+ const theme = partial.theme && VALID_THEMES.has(partial.theme) ? partial.theme : current.theme ?? "breeze";
1627
+ const defaultLocale = typeof partial.defaultLocale === "string" && partial.defaultLocale.trim() ? partial.defaultLocale.trim() : current.defaultLocale ?? "es";
1628
+ const secondaryCalendarLanguage = partial.secondaryCalendarLanguage && VALID_SECONDARY_LANGS.has(partial.secondaryCalendarLanguage) ? partial.secondaryCalendarLanguage : current.secondaryCalendarLanguage ?? "local";
1629
+ return { theme, defaultLocale, secondaryCalendarLanguage };
1630
+ }
1631
+ function classifySchemaAttributes(attributes2) {
1632
+ const fields = [];
1633
+ const dateFields = [];
1634
+ const stringFields = [];
1635
+ const numberFields = [];
1636
+ const enumFields = [];
1637
+ for (const [attrName, attrDef] of Object.entries(attributes2)) {
1638
+ if (!isSafeObjectKey(attrName) || !Object.hasOwn(attributes2, attrName)) {
1639
+ continue;
1640
+ }
1641
+ const attrType = String(attrDef?.type ?? "string");
1642
+ fields.push({ name: attrName, type: attrType });
1643
+ if (attrType === "date" || attrType === "datetime") {
1644
+ dateFields.push(attrName);
1645
+ } else if (STRING_ATTR_TYPES.has(attrType)) {
1646
+ stringFields.push(attrName);
1647
+ } else if (NUMBER_ATTR_TYPES.has(attrType)) {
1648
+ numberFields.push(attrName);
1649
+ } else if (attrType === "enumeration") {
1650
+ enumFields.push(attrName);
1651
+ stringFields.push(attrName);
1652
+ }
1653
+ }
1654
+ return { fields, dateFields, stringFields, numberFields, enumFields };
1655
+ }
1656
+ const settings = ({ strapi }) => ({
1657
+ registerSource(source) {
1658
+ const clean = sanitizeSourceMapping(source);
1659
+ if (!clean) return;
1660
+ getRuntimeSourceMap(strapi).set(clean.uid, clean);
1661
+ },
1662
+ async getSettings() {
1663
+ const defaults = buildDefaultSettings(strapi);
1664
+ const store = strapi.store({
1665
+ type: "plugin",
1666
+ name: PLUGIN_ID
1667
+ });
1668
+ const stored = await store.get({ key: STORE_KEY });
1669
+ return {
1670
+ theme: stored?.theme ?? defaults.theme ?? "breeze",
1671
+ defaultLocale: stored?.defaultLocale ?? defaults.defaultLocale ?? "es",
1672
+ secondaryCalendarLanguage: stored?.secondaryCalendarLanguage ?? defaults.secondaryCalendarLanguage ?? "local",
1673
+ ...resolveSlotSettings(stored, defaults),
1674
+ defaultRequiresApproval: stored?.defaultRequiresApproval ?? defaults.defaultRequiresApproval,
1675
+ sendEmailNotifications: stored?.sendEmailNotifications ?? defaults.sendEmailNotifications,
1676
+ icalFeedToken: typeof stored?.icalFeedToken === "string" && stored.icalFeedToken.trim() ? stored.icalFeedToken.trim() : defaults.icalFeedToken,
1677
+ secondaryCalendars: asArrayOr(
1678
+ stored?.secondaryCalendars,
1679
+ defaults.secondaryCalendars
1680
+ ),
1681
+ defaultSeasonalSchedules: asArrayOr(
1682
+ stored?.defaultSeasonalSchedules,
1683
+ defaults.defaultSeasonalSchedules
1684
+ ),
1685
+ holidays: asArrayOr(stored?.holidays, defaults.holidays),
1686
+ sources: mergeConfiguredSources(strapi, defaults, stored)
1687
+ };
1688
+ },
1689
+ async updateSettings(partial) {
1690
+ const current = await this.getSettings();
1691
+ const sources = partial.sources !== void 0 ? sanitizeSourceArray(partial.sources) : current.sources;
1692
+ const icalFeedToken = partial.icalFeedToken !== void 0 ? typeof partial.icalFeedToken === "string" && partial.icalFeedToken.trim() ? partial.icalFeedToken.trim() : void 0 : current.icalFeedToken;
1693
+ const updated = {
1694
+ ...resolveUpdatedAppearance(partial, current),
1695
+ ...resolveSlotSettings(partial, current),
1696
+ defaultRequiresApproval: typeof partial.defaultRequiresApproval === "boolean" ? partial.defaultRequiresApproval : current.defaultRequiresApproval,
1697
+ sendEmailNotifications: typeof partial.sendEmailNotifications === "boolean" ? partial.sendEmailNotifications : current.sendEmailNotifications,
1698
+ icalFeedToken,
1699
+ secondaryCalendars: asArrayOr(
1700
+ partial.secondaryCalendars,
1701
+ current.secondaryCalendars
1702
+ ),
1703
+ defaultSeasonalSchedules: asArrayOr(
1704
+ partial.defaultSeasonalSchedules,
1705
+ current.defaultSeasonalSchedules
1706
+ ),
1707
+ holidays: asArrayOr(partial.holidays, current.holidays),
1708
+ sources
1709
+ };
1710
+ const store = strapi.store({
1711
+ type: "plugin",
1712
+ name: PLUGIN_ID
1713
+ });
1714
+ await store.set({
1715
+ key: STORE_KEY,
1716
+ value: updated
1717
+ });
1718
+ return updated;
1719
+ },
1720
+ async discoverSchemas() {
1721
+ const contentTypes2 = strapi.contentTypes ?? {};
1722
+ const result = [];
1723
+ for (const [key, ct] of Object.entries(contentTypes2)) {
1724
+ if (!isSafeObjectKey(key)) continue;
1725
+ const uid = ct?.uid || key;
1726
+ if (!isDiscoverableCollectionUid(uid, ct?.kind)) continue;
1727
+ const classified = classifySchemaAttributes(ct?.attributes ?? {});
1728
+ result.push({
1729
+ uid,
1730
+ displayName: ct.info?.displayName || uid,
1731
+ singularName: ct.info?.singularName || uid,
1732
+ pluralName: ct.info?.pluralName || uid,
1733
+ ...classified
1734
+ });
1735
+ }
1736
+ return result;
1737
+ }
1738
+ });
1739
+ const destroy = ({ strapi }) => {
1740
+ clearRuntimeSettingsState(strapi);
1741
+ clearResourceLocks(strapi);
1742
+ cleanupBootstrapState(strapi);
1743
+ };
1744
+ function mergeAttributeIntoTarget(attributes2, attrName, attrDef) {
1745
+ if (!attributes2 || !isSafeObjectKey(attrName)) return;
1746
+ const existing = Object.hasOwn(attributes2, attrName) ? attributes2[attrName] : void 0;
1747
+ attributes2[attrName] = {
1748
+ ...existing && typeof existing === "object" ? existing : {},
1749
+ ...attrDef
1750
+ };
1751
+ }
1752
+ const register = ({ strapi }) => {
1753
+ const pluginConfig = strapi.config.get(`plugin::${PLUGIN_ID}`) ?? {};
1754
+ if (Array.isArray(pluginConfig.customFields)) {
1755
+ for (const field of pluginConfig.customFields) {
1756
+ if (field?.name && isSafeObjectKey(field.name) && field?.type) {
1757
+ strapi.customFields.register({
1758
+ name: field.name,
1759
+ plugin: PLUGIN_ID,
1760
+ type: field.type
1761
+ });
1762
+ }
1763
+ }
1764
+ }
1765
+ const contentTypes2 = strapi.contentTypes ?? {};
1766
+ const applyExtensions = (uid, extra) => {
1767
+ const schema = extra && typeof extra === "object" && Object.hasOwn(contentTypes2, uid) ? contentTypes2[uid] : void 0;
1768
+ if (!schema || !extra) return;
1769
+ for (const [attrName, attrDef] of Object.entries(extra)) {
1770
+ if (!isSafeObjectKey(attrName) || !Object.hasOwn(extra, attrName)) {
1771
+ continue;
1772
+ }
1773
+ const cleanDef = sanitizePlainRecord(attrDef);
1774
+ if (!cleanDef) continue;
1775
+ mergeAttributeIntoTarget(schema.attributes, attrName, cleanDef);
1776
+ mergeAttributeIntoTarget(
1777
+ schema.__schema__?.attributes,
1778
+ attrName,
1779
+ cleanDef
1780
+ );
1781
+ }
1782
+ };
1783
+ applyExtensions(
1784
+ "plugin::bookings.resource",
1785
+ pluginConfig.schemaExtensions?.resource
1786
+ );
1787
+ applyExtensions(
1788
+ "plugin::bookings.booking",
1789
+ pluginConfig.schemaExtensions?.booking
1790
+ );
1791
+ };
1792
+ const READ_POLICIES = [
1793
+ "admin::isAuthenticatedAdmin",
1794
+ {
1795
+ name: "admin::hasPermissions",
1796
+ config: { actions: ["plugin::bookings.read"] }
1797
+ }
1798
+ ];
1799
+ const MANAGE_POLICIES = [
1800
+ "admin::isAuthenticatedAdmin",
1801
+ {
1802
+ name: "admin::hasPermissions",
1803
+ config: { actions: ["plugin::bookings.manage"] }
1804
+ }
1805
+ ];
1806
+ const SETTINGS_POLICIES = [
1807
+ "admin::isAuthenticatedAdmin",
1808
+ {
1809
+ name: "admin::hasPermissions",
1810
+ config: { actions: ["plugin::bookings.settings"] }
1811
+ }
1812
+ ];
1813
+ const adminRoutes = [
1814
+ {
1815
+ method: "GET",
1816
+ path: "/settings",
1817
+ handler: "admin.getSettings",
1818
+ config: {
1819
+ policies: SETTINGS_POLICIES
1820
+ }
1821
+ },
1822
+ {
1823
+ method: "PUT",
1824
+ path: "/settings",
1825
+ handler: "admin.updateSettings",
1826
+ config: {
1827
+ policies: SETTINGS_POLICIES
1828
+ }
1829
+ },
1830
+ {
1831
+ method: "GET",
1832
+ path: "/schemas",
1833
+ handler: "admin.discoverSchemas",
1834
+ config: {
1835
+ policies: SETTINGS_POLICIES
1836
+ }
1837
+ },
1838
+ {
1839
+ method: "GET",
1840
+ path: "/resources",
1841
+ handler: "admin.listResources",
1842
+ config: {
1843
+ policies: READ_POLICIES
1844
+ }
1845
+ },
1846
+ {
1847
+ method: "POST",
1848
+ path: "/resources",
1849
+ handler: "admin.saveResource",
1850
+ config: {
1851
+ policies: MANAGE_POLICIES
1852
+ }
1853
+ },
1854
+ {
1855
+ method: "GET",
1856
+ path: "/calendar",
1857
+ handler: "admin.getCalendar",
1858
+ config: {
1859
+ policies: READ_POLICIES
1860
+ }
1861
+ },
1862
+ {
1863
+ method: "POST",
1864
+ path: "/bookings",
1865
+ handler: "admin.createBooking",
1866
+ config: {
1867
+ policies: MANAGE_POLICIES
1868
+ }
1869
+ },
1870
+ {
1871
+ method: "PUT",
1872
+ path: "/bookings/:documentId/status",
1873
+ handler: "admin.updateBookingStatus",
1874
+ config: {
1875
+ policies: MANAGE_POLICIES
1876
+ }
1877
+ },
1878
+ {
1879
+ method: "POST",
1880
+ path: "/bookings/reschedule",
1881
+ handler: "admin.rescheduleBooking",
1882
+ config: {
1883
+ policies: MANAGE_POLICIES
1884
+ }
1885
+ },
1886
+ {
1887
+ method: "GET",
1888
+ path: "/export.csv",
1889
+ handler: "admin.exportCsv",
1890
+ config: {
1891
+ policies: READ_POLICIES
1892
+ }
1893
+ },
1894
+ {
1895
+ method: "GET",
1896
+ path: "/export.ics",
1897
+ handler: "admin.exportICal",
1898
+ config: {
1899
+ policies: READ_POLICIES
1900
+ }
1901
+ }
1902
+ ];
1903
+ const contentApiRoutes = [
1904
+ {
1905
+ method: "GET",
1906
+ path: "/resources",
1907
+ handler: "content-api.listPublicResources",
1908
+ config: {
1909
+ policies: []
1910
+ }
1911
+ },
1912
+ {
1913
+ method: "GET",
1914
+ path: "/available-slots",
1915
+ handler: "content-api.getAvailableSlots",
1916
+ config: {
1917
+ policies: []
1918
+ }
1919
+ },
1920
+ {
1921
+ method: "POST",
1922
+ path: "/reserve",
1923
+ handler: "content-api.createReservation",
1924
+ config: {
1925
+ policies: []
1926
+ }
1927
+ },
1928
+ {
1929
+ method: "POST",
1930
+ path: "/reschedule",
1931
+ handler: "content-api.rescheduleReservation",
1932
+ config: {
1933
+ policies: []
1934
+ }
1935
+ },
1936
+ {
1937
+ method: "POST",
1938
+ path: "/cancel",
1939
+ handler: "content-api.cancelReservation",
1940
+ config: {
1941
+ policies: []
1942
+ }
1943
+ },
1944
+ {
1945
+ method: "GET",
1946
+ path: "/feed.ics",
1947
+ handler: "content-api.getICalFeed",
1948
+ config: {
1949
+ policies: []
1950
+ }
1951
+ }
1952
+ ];
1953
+ const routes = {
1954
+ "admin": {
1955
+ type: "admin",
1956
+ routes: adminRoutes
1957
+ },
1958
+ "content-api": {
1959
+ type: "content-api",
1960
+ routes: contentApiRoutes
1961
+ }
1962
+ };
1963
+ function trimEdgeChars(value, char) {
1964
+ let start = 0;
1965
+ let end = value.length;
1966
+ while (start < end && value[start] === char)
1967
+ start++;
1968
+ while (end > start && value[end - 1] === char)
1969
+ end--;
1970
+ return value.slice(start, end);
1971
+ }
1972
+ function slugify(input, options2 = {}) {
1973
+ const { separator = "-", maxLength, normalization = "NFD" } = options2;
1974
+ let text = input.toLowerCase();
1975
+ if (normalization) {
1976
+ text = text.normalize(normalization).replace(/[̀-ͯ]/g, "");
1977
+ }
1978
+ const slug = trimEdgeChars(text.replace(/[^a-z0-9]+/g, separator), separator);
1979
+ return maxLength === void 0 ? slug : slug.slice(0, maxLength);
1980
+ }
1981
+ function isValidEmail(value) {
1982
+ if (/\s/.test(value))
1983
+ return false;
1984
+ const at = value.indexOf("@");
1985
+ if (at < 1 || at !== value.lastIndexOf("@"))
1986
+ return false;
1987
+ const domain = value.slice(at + 1);
1988
+ const dot = domain.indexOf(".");
1989
+ return dot > 0 && dot < domain.length - 1;
1990
+ }
1991
+ function freezeOptions(items) {
1992
+ return Object.freeze(items.map((item) => Object.freeze({ ...item })));
1993
+ }
1994
+ freezeOptions([
1995
+ { value: "es", label: "es — Español (Spanish)" },
1996
+ { value: "en", label: "en — English" },
1997
+ { value: "ar", label: "ar — العربية (Arabic)" },
1998
+ { value: "tr", label: "tr — Türkçe (Turkish)" },
1999
+ { value: "fr", label: "fr — Français (French)" },
2000
+ { value: "de", label: "de — Deutsch (German)" },
2001
+ { value: "pt", label: "pt — Português (Portuguese)" },
2002
+ { value: "it", label: "it — Italiano (Italian)" },
2003
+ { value: "nl", label: "nl — Nederlands (Dutch)" },
2004
+ { value: "pl", label: "pl — Polski (Polish)" },
2005
+ { value: "ru", label: "ru — Русский (Russian)" },
2006
+ { value: "id", label: "id — Bahasa Indonesia" },
2007
+ { value: "ur", label: "ur — اردو (Urdu)" },
2008
+ { value: "zh", label: "zh — 中文 (Chinese)" }
2009
+ ]);
2010
+ freezeOptions([
2011
+ {
2012
+ value: "islamic-umalqura",
2013
+ label: "islamic-umalqura (Hijri Umm al-Qura)"
2014
+ },
2015
+ { value: "islamic-civil", label: "islamic-civil (Tabular Islamic Civil)" },
2016
+ { value: "islamic-tbla", label: "islamic-tbla (Tabular Astronomical)" },
2017
+ { value: "hebrew", label: "hebrew (Hebrew)" },
2018
+ { value: "persian", label: "persian (Solar Hijri)" },
2019
+ { value: "none", label: "none (Gregorian Only)" }
2020
+ ]);
2021
+ function isActiveBookingStatus(status) {
2022
+ return status === "pending" || status === "confirmed";
2023
+ }
2024
+ function doesBookingOverlapSlot(booking, slotStartMs, slotEndMs, bufferBeforeMinutes = 0, bufferAfterMinutes = 0) {
2025
+ if (!isActiveBookingStatus(booking.status)) return false;
2026
+ const bStart = Date.parse(booking.startDate);
2027
+ const bEnd = Date.parse(booking.endDate);
2028
+ if (Number.isNaN(bStart) || Number.isNaN(bEnd)) return false;
2029
+ return slotStartMs < bEnd + bufferAfterMinutes * 6e4 && slotEndMs > bStart - bufferBeforeMinutes * 6e4;
2030
+ }
2031
+ function selectAvailableHostsForSlot(resource, overlappingBookings, preferredLanguage) {
2032
+ const rawHosts = Array.isArray(resource.assignedHosts) ? resource.assignedHosts : [];
2033
+ const hosts = rawHosts.filter(
2034
+ (h) => Boolean(h) && typeof h.id === "string" && h.id.trim().length > 0 && typeof h.name === "string" && h.name.trim().length > 0
2035
+ );
2036
+ if (hosts.length === 0) return [];
2037
+ const busy = new Set(
2038
+ overlappingBookings.map(
2039
+ (b) => typeof b.assignedHost === "string" ? b.assignedHost.trim().toLowerCase() : ""
2040
+ ).filter((v) => Boolean(v))
2041
+ );
2042
+ const freeHosts = hosts.filter(
2043
+ (h) => !busy.has(h.id.trim().toLowerCase()) && !busy.has(h.name.trim().toLowerCase())
2044
+ );
2045
+ if (!preferredLanguage) return freeHosts;
2046
+ const lang = preferredLanguage.trim().toLowerCase();
2047
+ const matching = freeHosts.filter(
2048
+ (h) => Array.isArray(h.languages) && h.languages.some(
2049
+ (l) => typeof l === "string" && l.trim().toLowerCase() === lang
2050
+ )
2051
+ );
2052
+ return matching.length > 0 ? matching : freeHosts;
2053
+ }
2054
+ function buildWindowCandidateBlocks(win, mode, durationMins, bufferBefore, bufferAfter, defaultCapacity) {
2055
+ const winStartMins = parseTimeMinutes(win.start);
2056
+ const winEndMins = parseTimeMinutes(win.end);
2057
+ if (winEndMins <= winStartMins) return [];
2058
+ const cap = win.capacity ?? defaultCapacity;
2059
+ if (mode === "window") {
2060
+ return [
2061
+ {
2062
+ startMins: winStartMins,
2063
+ endMins: winEndMins,
2064
+ label: win.label || `${win.start}-${win.end}`,
2065
+ capacity: cap
2066
+ }
2067
+ ];
2068
+ }
2069
+ const blocks = [];
2070
+ const stepMins = Math.max(5, durationMins + bufferBefore + bufferAfter);
2071
+ for (let cursorMins = winStartMins; cursorMins + durationMins <= winEndMins; cursorMins += stepMins) {
2072
+ const blockEndMins = cursorMins + durationMins;
2073
+ const isExactMatch = Boolean(
2074
+ win.label && winStartMins + durationMins === winEndMins
2075
+ );
2076
+ blocks.push({
2077
+ startMins: cursorMins,
2078
+ endMins: blockEndMins,
2079
+ label: isExactMatch ? String(win.label) : `${formatTimeMinutes(cursorMins)}-${formatTimeMinutes(blockEndMins)}`,
2080
+ capacity: cap
2081
+ });
2082
+ }
2083
+ return blocks;
2084
+ }
2085
+ function evaluateCandidateBlockToSlot(block, slotStartMs, slotEndMs, dateStr, scheduleLabel, params, resourceBookings) {
2086
+ const {
2087
+ resource,
2088
+ partySize = 1,
2089
+ preferredLanguage,
2090
+ secondaryCalendars = []
2091
+ } = params;
2092
+ const bufferBefore = Math.max(0, resource.bufferBeforeMinutes || 0);
2093
+ const bufferAfter = Math.max(0, resource.bufferAfterMinutes || 0);
2094
+ const overlapping = resourceBookings.filter(
2095
+ (b) => doesBookingOverlapSlot(b, slotStartMs, slotEndMs, bufferBefore, bufferAfter)
2096
+ );
2097
+ const bookedCount = overlapping.reduce(
2098
+ (sum, b) => sum + Math.max(1, Number(b.partySize) || 1),
2099
+ 0
2100
+ );
2101
+ const capacity = Math.max(1, block.capacity || resource.capacityPerSlot || 1);
2102
+ const remainingCapacity = Math.max(0, capacity - bookedCount);
2103
+ const maxParty = resource.maxPartySize > 0 ? Math.min(capacity, resource.maxPartySize) : capacity;
2104
+ const validParty = typeof partySize === "number" && Number.isInteger(partySize) && partySize >= 1;
2105
+ return {
2106
+ slotId: `${resource.documentId}:${dateStr}:${block.label}`,
2107
+ resourceDocumentId: resource.documentId,
2108
+ resourceName: resource.name,
2109
+ resourceSlug: resource.slug,
2110
+ start: new Date(slotStartMs).toISOString(),
2111
+ end: new Date(slotEndMs).toISOString(),
2112
+ date: dateStr,
2113
+ slotLabel: block.label,
2114
+ scheduleLabel,
2115
+ capacity,
2116
+ bookedCount,
2117
+ remainingCapacity,
2118
+ isAvailable: validParty && remainingCapacity >= partySize && partySize <= maxParty,
2119
+ availableHosts: selectAvailableHostsForSlot(
2120
+ resource,
2121
+ overlapping,
2122
+ preferredLanguage
2123
+ ),
2124
+ secondaryDates: secondaryCalendars.length > 0 ? formatSecondaryCalendars(new Date(slotStartMs), secondaryCalendars) : void 0
2125
+ };
2126
+ }
2127
+ function computeAvailableSlots(params) {
2128
+ const {
2129
+ resource,
2130
+ from,
2131
+ to,
2132
+ existingBookings,
2133
+ globalSeasonalSchedules = [],
2134
+ globalHolidays = [],
2135
+ referenceNow
2136
+ } = params;
2137
+ const timeZone = normalizeTimeZone(resource.timezone);
2138
+ const rangeStartMs = parsePeriodTimestampInZone(from, false, timeZone);
2139
+ const rangeEndMs = parsePeriodTimestampInZone(to, true, timeZone);
2140
+ if (!resource.isActive || Number.isNaN(rangeStartMs) || Number.isNaN(rangeEndMs) || rangeEndMs < rangeStartMs) {
2141
+ return [];
2142
+ }
2143
+ const bufferBefore = Math.max(0, resource.bufferBeforeMinutes || 0);
2144
+ const bufferAfter = Math.max(0, resource.bufferAfterMinutes || 0);
2145
+ const resourceBookings = existingBookings.filter(
2146
+ (b) => b.resourceDocumentId === resource.documentId && isActiveBookingStatus(b.status)
2147
+ );
2148
+ const nowMs = referenceNow instanceof Date && !Number.isNaN(referenceNow.getTime()) ? referenceNow.getTime() : null;
2149
+ const minAllowedMs = nowMs !== null ? nowMs + Math.max(0, resource.minLeadTimeHours || 0) * 36e5 : null;
2150
+ const maxAllowedMs = nowMs !== null && resource.maxAdvanceDays > 0 ? nowMs + resource.maxAdvanceDays * 864e5 : null;
2151
+ const blackoutDates = resource.blackoutDates ?? [];
2152
+ const slots = [];
2153
+ const localDates = enumerateLocalDatesInRange(
2154
+ rangeStartMs,
2155
+ rangeEndMs,
2156
+ timeZone,
2157
+ 366
2158
+ );
2159
+ for (const { dateStr, dayKey } of localDates) {
2160
+ const { mode, durationMins, windows, scheduleLabel } = resolveDailyScheduleForDate(
2161
+ dateStr,
2162
+ dayKey,
2163
+ resource,
2164
+ globalSeasonalSchedules,
2165
+ globalHolidays
2166
+ );
2167
+ const dayBlocks = windows.flatMap(
2168
+ (win) => buildWindowCandidateBlocks(
2169
+ win,
2170
+ mode,
2171
+ durationMins,
2172
+ bufferBefore,
2173
+ bufferAfter,
2174
+ resource.capacityPerSlot
2175
+ )
2176
+ );
2177
+ for (const block of dayBlocks) {
2178
+ const slotStartMs = wallClockToUtcMs(
2179
+ dateStr,
2180
+ block.startMins,
2181
+ timeZone,
2182
+ true
2183
+ );
2184
+ const slotEndMs = wallClockToUtcMs(
2185
+ dateStr,
2186
+ block.endMins,
2187
+ timeZone,
2188
+ false
2189
+ );
2190
+ if (slotStartMs === null || slotEndMs === null || slotEndMs <= slotStartMs) {
2191
+ continue;
2192
+ }
2193
+ const permitted = slotEndMs > rangeStartMs && slotStartMs <= rangeEndMs && (minAllowedMs === null || slotStartMs >= minAllowedMs) && (maxAllowedMs === null || slotStartMs <= maxAllowedMs) && !isSlotBlackedOut(slotStartMs, slotEndMs, blackoutDates, timeZone);
2194
+ if (permitted) {
2195
+ slots.push(
2196
+ evaluateCandidateBlockToSlot(
2197
+ block,
2198
+ slotStartMs,
2199
+ slotEndMs,
2200
+ dateStr,
2201
+ scheduleLabel,
2202
+ params,
2203
+ resourceBookings
2204
+ )
2205
+ );
2206
+ }
2207
+ }
2208
+ }
2209
+ return slots;
2210
+ }
2211
+ const RESOURCE_UID = "plugin::bookings.resource";
2212
+ const STATUS_COLORS = {
2213
+ confirmed: "#059669",
2214
+ pending: "#d97706",
2215
+ completed: "#4f46e5",
2216
+ cancelled: "#64748b",
2217
+ no_show: "#e11d48"
2218
+ };
2219
+ function slugifyName(value) {
2220
+ return slugify(value, { normalization: false });
2221
+ }
2222
+ function asArray(val) {
2223
+ return Array.isArray(val) ? val : [];
2224
+ }
2225
+ function asOptionalString(val) {
2226
+ return val ? String(val) : void 0;
2227
+ }
2228
+ function clampNumber(val, fallback, min, max) {
2229
+ const num = typeof val === "number" ? val : Number(val);
2230
+ const safe = Number.isFinite(num) ? Math.trunc(num) : fallback;
2231
+ const clampedMin = Math.max(min, safe);
2232
+ return max !== void 0 ? Math.min(max, clampedMin) : clampedMin;
2233
+ }
2234
+ function resolveWeeklySchedule(val) {
2235
+ if (val && typeof val === "object" && !Array.isArray(val) && "days" in val) {
2236
+ return val;
2237
+ }
2238
+ return DEFAULT_WEEKLY_SCHEDULE;
2239
+ }
2240
+ function normalizeResourceLimits(raw) {
2241
+ return {
2242
+ slotDurationMinutes: clampNumber(raw.slotDurationMinutes, 60, 5, 1440),
2243
+ bufferBeforeMinutes: clampNumber(raw.bufferBeforeMinutes, 0, 0, 720),
2244
+ bufferAfterMinutes: clampNumber(raw.bufferAfterMinutes, 0, 0, 720),
2245
+ capacityPerSlot: clampNumber(raw.capacityPerSlot, 1, 1),
2246
+ maxPartySize: clampNumber(raw.maxPartySize, 50, 1),
2247
+ minLeadTimeHours: clampNumber(raw.minLeadTimeHours, 2, 0),
2248
+ maxAdvanceDays: clampNumber(raw.maxAdvanceDays, 90, 1),
2249
+ requiresApproval: Boolean(raw.requiresApproval ?? false)
2250
+ };
2251
+ }
2252
+ function normalizeResourceDetails(raw) {
2253
+ const supportedLanguages = asArray(raw.supportedLanguages).map(
2254
+ (l) => String(l)
2255
+ );
2256
+ const metadata = sanitizePlainRecord(raw.metadata);
2257
+ const rawPrice = raw.price !== void 0 ? Number(raw.price) : void 0;
2258
+ const price = rawPrice !== void 0 && Number.isFinite(rawPrice) ? Math.max(0, rawPrice) : void 0;
2259
+ return {
2260
+ description: asOptionalString(raw.description),
2261
+ recurrenceRule: asOptionalString(raw.recurrenceRule),
2262
+ supportedLanguages,
2263
+ assignedHosts: asArray(raw.assignedHosts),
2264
+ location: asOptionalString(raw.location),
2265
+ price,
2266
+ currency: raw.currency ? String(raw.currency) : "USD",
2267
+ isActive: raw.isActive !== void 0 ? Boolean(raw.isActive) : true,
2268
+ metadata
2269
+ };
2270
+ }
2271
+ function normalizeResourceRecord(raw) {
2272
+ const name = String(raw.name ?? "Unnamed Resource");
2273
+ return {
2274
+ documentId: String(raw.documentId ?? raw.id ?? ""),
2275
+ name,
2276
+ slug: String(raw.slug ?? slugifyName(String(raw.name ?? "resource"))),
2277
+ resourceType: raw.resourceType ?? "service",
2278
+ color: String(raw.color ?? "#0d9488"),
2279
+ timezone: normalizeTimeZone(
2280
+ raw.timezone !== void 0 ? String(raw.timezone) : "UTC"
2281
+ ),
2282
+ ...normalizeResourceLimits(raw),
2283
+ weeklySchedule: resolveWeeklySchedule(raw.weeklySchedule),
2284
+ seasonalSchedules: asArray(raw.seasonalSchedules),
2285
+ holidayRules: asArray(raw.holidayRules),
2286
+ blackoutDates: asArray(raw.blackoutDates),
2287
+ ...normalizeResourceDetails(raw)
2288
+ };
2289
+ }
2290
+ function buildResourceScheduleData(payload, settings2) {
2291
+ return {
2292
+ timezone: normalizeTimeZone(payload.timezone ?? settings2.defaultTimezone),
2293
+ slotDurationMinutes: clampNumber(
2294
+ payload.slotDurationMinutes,
2295
+ settings2.defaultSlotDurationMinutes,
2296
+ 5,
2297
+ 1440
2298
+ ),
2299
+ bufferBeforeMinutes: clampNumber(
2300
+ payload.bufferBeforeMinutes,
2301
+ settings2.defaultBufferBeforeMinutes,
2302
+ 0,
2303
+ 720
2304
+ ),
2305
+ bufferAfterMinutes: clampNumber(
2306
+ payload.bufferAfterMinutes,
2307
+ settings2.defaultBufferAfterMinutes,
2308
+ 0,
2309
+ 720
2310
+ ),
2311
+ capacityPerSlot: clampNumber(
2312
+ payload.capacityPerSlot,
2313
+ settings2.defaultCapacityPerSlot,
2314
+ 1
2315
+ ),
2316
+ maxPartySize: clampNumber(payload.maxPartySize, 50, 1),
2317
+ minLeadTimeHours: clampNumber(
2318
+ payload.minLeadTimeHours,
2319
+ settings2.defaultMinLeadTimeHours,
2320
+ 0
2321
+ ),
2322
+ maxAdvanceDays: clampNumber(
2323
+ payload.maxAdvanceDays,
2324
+ settings2.defaultMaxAdvanceDays,
2325
+ 1
2326
+ ),
2327
+ requiresApproval: payload.requiresApproval ?? settings2.defaultRequiresApproval
2328
+ };
2329
+ }
2330
+ function buildResourceMetaData(payload) {
2331
+ return {
2332
+ name: payload.name.trim(),
2333
+ slug: payload.slug || slugifyName(payload.name),
2334
+ resourceType: payload.resourceType ?? "service",
2335
+ description: payload.description ?? "",
2336
+ color: payload.color ?? "#0d9488",
2337
+ weeklySchedule: payload.weeklySchedule ?? DEFAULT_WEEKLY_SCHEDULE,
2338
+ seasonalSchedules: payload.seasonalSchedules ?? [],
2339
+ holidayRules: payload.holidayRules ?? [],
2340
+ blackoutDates: payload.blackoutDates ?? []
2341
+ };
2342
+ }
2343
+ function buildResourceExtrasData(payload) {
2344
+ return {
2345
+ recurrenceRule: payload.recurrenceRule ?? void 0,
2346
+ supportedLanguages: payload.supportedLanguages ?? [],
2347
+ assignedHosts: payload.assignedHosts ?? [],
2348
+ location: payload.location ?? void 0,
2349
+ price: payload.price ?? 0,
2350
+ currency: payload.currency ?? "USD",
2351
+ isActive: payload.isActive ?? true,
2352
+ metadata: sanitizePlainRecord(payload.metadata)
2353
+ };
2354
+ }
2355
+ function resolveOccurrenceTitle(booking) {
2356
+ if (booking.title) return booking.title;
2357
+ const suffix = booking.partySize > 1 ? ` (${booking.partySize})` : "";
2358
+ return `${booking.customerName}${suffix}`;
2359
+ }
2360
+ function resolveOccurrenceStatus(status) {
2361
+ return status === "cancelled" || status === "no_show" ? "cancelled" : "scheduled";
2362
+ }
2363
+ function mapBookingToOccurrence(booking, settings2) {
2364
+ const secondaryDates = settings2.secondaryCalendars.length > 0 ? formatSecondaryCalendars(
2365
+ new Date(booking.startDate),
2366
+ settings2.secondaryCalendars
2367
+ ) : void 0;
2368
+ const uid = booking.sourceCollection || "plugin::bookings.booking";
2369
+ const datePart = booking.startDate.slice(0, 10);
2370
+ return {
2371
+ occurrenceId: `${uid}:${booking.documentId}:${datePart}`,
2372
+ documentId: booking.documentId,
2373
+ uid,
2374
+ sourceId: booking.resourceDocumentId || "bookings",
2375
+ sourceLabel: booking.resourceName || "Bookings",
2376
+ occurrenceDate: datePart,
2377
+ startDate: booking.startDate,
2378
+ endDate: booking.endDate,
2379
+ allDay: false,
2380
+ timezone: booking.timezone || settings2.defaultTimezone || "UTC",
2381
+ title: resolveOccurrenceTitle(booking),
2382
+ color: STATUS_COLORS[booking.status] || booking.resourceColor || "#0d9488",
2383
+ category: booking.status,
2384
+ status: resolveOccurrenceStatus(booking.status),
2385
+ location: booking.assignedHost ? `Host: ${booking.assignedHost}` : booking.organization,
2386
+ summary: booking.notes,
2387
+ isRecurring: false,
2388
+ calendarLinks: {
2389
+ google: "",
2390
+ outlook: "",
2391
+ yahoo: "",
2392
+ icsPath: `/api/bookings/feed.ics`
2393
+ },
2394
+ alternateDates: secondaryDates,
2395
+ customFields: {
2396
+ bookingCode: booking.bookingCode,
2397
+ customerName: booking.customerName,
2398
+ customerEmail: booking.customerEmail,
2399
+ customerPhone: booking.customerPhone,
2400
+ organization: booking.organization,
2401
+ partySize: booking.partySize,
2402
+ status: booking.status,
2403
+ preferredLanguage: booking.preferredLanguage,
2404
+ assignedHost: booking.assignedHost,
2405
+ slotLabel: booking.slotLabel,
2406
+ resourceDocumentId: booking.resourceDocumentId,
2407
+ resourceName: booking.resourceName
2408
+ }
2409
+ };
2410
+ }
2411
+ const availability = ({ strapi }) => ({
2412
+ async listResources(activeOnly = false, limit = 200) {
2413
+ const safeLimit = clampNumber(limit, 200, 1, 500);
2414
+ const filters = activeOnly ? { isActive: { $eq: true } } : void 0;
2415
+ const rows = await strapi.documents(RESOURCE_UID).findMany({
2416
+ filters,
2417
+ sort: "name:asc",
2418
+ limit: safeLimit
2419
+ });
2420
+ const normalized = (Array.isArray(rows) ? rows : []).map(
2421
+ normalizeResourceRecord
2422
+ );
2423
+ const filtered = activeOnly ? normalized.filter((r) => r.isActive) : normalized;
2424
+ return filtered.slice(0, safeLimit);
2425
+ },
2426
+ async getResource(idOrSlug) {
2427
+ const trimmed = typeof idOrSlug === "string" ? idOrSlug.trim() : "";
2428
+ if (!trimmed) return null;
2429
+ const rows = await strapi.documents(RESOURCE_UID).findMany({
2430
+ filters: {
2431
+ $or: [{ documentId: { $eq: trimmed } }, { slug: { $eq: trimmed } }]
2432
+ },
2433
+ limit: 50
2434
+ });
2435
+ const normalized = (Array.isArray(rows) ? rows : []).map(
2436
+ normalizeResourceRecord
2437
+ );
2438
+ const match = normalized.find(
2439
+ (r) => r.documentId === trimmed || r.slug === trimmed
2440
+ );
2441
+ return match ?? null;
2442
+ },
2443
+ async saveResource(payload) {
2444
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
2445
+ const settings2 = await settingsService.getSettings();
2446
+ const data = {
2447
+ ...buildResourceMetaData(payload),
2448
+ ...buildResourceScheduleData(payload, settings2),
2449
+ ...buildResourceExtrasData(payload)
2450
+ };
2451
+ if (payload.documentId) {
2452
+ const updated = await strapi.documents(RESOURCE_UID).update({
2453
+ documentId: payload.documentId,
2454
+ data
2455
+ });
2456
+ if (!updated) {
2457
+ throw new BookingNotFoundError("Booking resource not found");
2458
+ }
2459
+ return normalizeResourceRecord(updated);
2460
+ }
2461
+ const created = await strapi.documents(RESOURCE_UID).create({
2462
+ data
2463
+ });
2464
+ return normalizeResourceRecord(created);
2465
+ },
2466
+ async getAvailableSlots(params) {
2467
+ const {
2468
+ resourceDocumentId,
2469
+ resourceSlug,
2470
+ from,
2471
+ to,
2472
+ partySize = 1,
2473
+ preferredLanguage,
2474
+ referenceNow
2475
+ } = params;
2476
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
2477
+ const reservationService = strapi.plugin(PLUGIN_ID).service("reservation");
2478
+ const settings2 = await settingsService.getSettings();
2479
+ let targetResources = [];
2480
+ if (resourceDocumentId || resourceSlug) {
2481
+ const single = await this.getResource(
2482
+ resourceDocumentId || resourceSlug
2483
+ );
2484
+ if (single && single.isActive) {
2485
+ targetResources = [single];
2486
+ }
2487
+ } else {
2488
+ targetResources = await this.listResources(true);
2489
+ }
2490
+ if (targetResources.length === 0) {
2491
+ return [];
2492
+ }
2493
+ const fromMs = parseBoundaryMs(from, false);
2494
+ const toMs = parseBoundaryMs(to, true);
2495
+ if (fromMs === null || toMs === null || toMs < fromMs) {
2496
+ return [];
2497
+ }
2498
+ const existingBookings = await reservationService.listBookings({
2499
+ from: new Date(fromMs - 864e5).toISOString(),
2500
+ to: new Date(toMs + 864e5).toISOString(),
2501
+ activeOnly: true,
2502
+ limit: 1e3,
2503
+ resourceDocumentId: targetResources.length === 1 ? targetResources[0].documentId : void 0
2504
+ });
2505
+ const allSlots = [];
2506
+ for (const resource of targetResources) {
2507
+ const resourceSlots = computeAvailableSlots({
2508
+ resource,
2509
+ from,
2510
+ to,
2511
+ existingBookings,
2512
+ partySize,
2513
+ preferredLanguage,
2514
+ secondaryCalendars: settings2.secondaryCalendars,
2515
+ globalSeasonalSchedules: settings2.defaultSeasonalSchedules,
2516
+ globalHolidays: settings2.holidays,
2517
+ referenceNow
2518
+ });
2519
+ allSlots.push(...resourceSlots);
2520
+ }
2521
+ return allSlots.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
2522
+ },
2523
+ async getCalendarOccurrences(query) {
2524
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
2525
+ const reservationService = strapi.plugin(PLUGIN_ID).service("reservation");
2526
+ const [settings2, resources, bookings] = await Promise.all([
2527
+ settingsService.getSettings(),
2528
+ this.listResources(false),
2529
+ reservationService.listBookings(query)
2530
+ ]);
2531
+ const occurrences = bookings.map(
2532
+ (booking) => mapBookingToOccurrence(booking, settings2)
2533
+ );
2534
+ const stats = {
2535
+ totalBookings: bookings.length,
2536
+ confirmedBookings: bookings.filter((b) => b.status === "confirmed").length,
2537
+ pendingBookings: bookings.filter((b) => b.status === "pending").length,
2538
+ cancelledBookings: bookings.filter(
2539
+ (b) => b.status === "cancelled" || b.status === "no_show"
2540
+ ).length,
2541
+ completedBookings: bookings.filter((b) => b.status === "completed").length,
2542
+ totalGuests: bookings.filter((b) => b.status !== "cancelled" && b.status !== "no_show").reduce((sum, b) => sum + Math.max(1, b.partySize), 0),
2543
+ activeResources: resources.filter((r) => r.isActive).length
2544
+ };
2545
+ return {
2546
+ occurrences,
2547
+ bookings,
2548
+ stats
2549
+ };
2550
+ }
2551
+ });
2552
+ function escapeICalText(text) {
2553
+ return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n");
2554
+ }
2555
+ function formatICalDateUtc(isoDate) {
2556
+ const date = new Date(isoDate);
2557
+ if (Number.isNaN(date.getTime())) {
2558
+ return "19700101T000000Z";
2559
+ }
2560
+ return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
2561
+ }
2562
+ function computeFallbackOpaqueUid(seed) {
2563
+ let h1 = 3735928559 ^ seed.length;
2564
+ let h2 = 1103547991 ^ seed.length;
2565
+ for (let i = 0; i < seed.length; i += 1) {
2566
+ const ch = seed.charCodeAt(i);
2567
+ h1 = Math.imul(h1 ^ ch, 2654435761);
2568
+ h2 = Math.imul(h2 ^ ch, 1597334677);
2569
+ }
2570
+ h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
2571
+ h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
2572
+ const hex1 = (h1 >>> 0).toString(16).padStart(8, "0");
2573
+ const hex2 = (h2 >>> 0).toString(16).padStart(8, "0");
2574
+ return `opaque-${hex1}${hex2}`;
2575
+ }
2576
+ function foldICalLine(line) {
2577
+ const encoder = new TextEncoder();
2578
+ const maxOctets = 75;
2579
+ if (encoder.encode(line).length <= maxOctets) {
2580
+ return line;
2581
+ }
2582
+ const chunks = [];
2583
+ let current = "";
2584
+ for (const char of line) {
2585
+ const candidate = current + char;
2586
+ const limit = chunks.length === 0 ? maxOctets : maxOctets - 1;
2587
+ if (encoder.encode(candidate).length > limit) {
2588
+ chunks.push(current);
2589
+ current = char;
2590
+ } else {
2591
+ current = candidate;
2592
+ }
2593
+ }
2594
+ if (current.length > 0) {
2595
+ chunks.push(current);
2596
+ }
2597
+ return chunks.join("\r\n ");
2598
+ }
2599
+ function buildBookingVEventLines(booking, nowStamp, options2) {
2600
+ const anonymize = Boolean(options2?.anonymize);
2601
+ const dtStart = formatICalDateUtc(booking.startDate);
2602
+ const dtEnd = formatICalDateUtc(booking.endDate);
2603
+ const statusMap = {
2604
+ confirmed: "CONFIRMED",
2605
+ completed: "CONFIRMED",
2606
+ pending: "TENTATIVE",
2607
+ cancelled: "CANCELLED",
2608
+ no_show: "CANCELLED"
2609
+ };
2610
+ const uidLocal = anonymize ? options2?.opaqueUids?.[booking.documentId] ?? computeFallbackOpaqueUid(`${booking.documentId}:${booking.startDate}`) : booking.bookingCode || booking.documentId;
2611
+ const uid = `${uidLocal}@strapi-plugin-bookings`;
2612
+ const summary = anonymize ? "Reserved" : booking.title || booking.customerName;
2613
+ const descriptionParts = anonymize ? [
2614
+ "Status: Busy",
2615
+ ...booking.resourceName ? [`Resource: ${booking.resourceName}`] : []
2616
+ ] : [
2617
+ `Booking Code: ${booking.bookingCode}`,
2618
+ `Customer: ${booking.customerName}`,
2619
+ `Party Size: ${booking.partySize}`,
2620
+ `Status: ${booking.status}`,
2621
+ ...booking.resourceName ? [`Resource: ${booking.resourceName}`] : [],
2622
+ ...booking.assignedHost ? [`Assigned Staff/Host: ${booking.assignedHost}`] : [],
2623
+ ...booking.notes ? [`Notes: ${booking.notes}`] : []
2624
+ ];
2625
+ return [
2626
+ "BEGIN:VEVENT",
2627
+ foldICalLine(`UID:${escapeICalText(uid)}`),
2628
+ `DTSTAMP:${nowStamp}`,
2629
+ `DTSTART:${dtStart}`,
2630
+ `DTEND:${dtEnd}`,
2631
+ foldICalLine(`SUMMARY:${escapeICalText(summary)}`),
2632
+ foldICalLine(`DESCRIPTION:${escapeICalText(descriptionParts.join("\n"))}`),
2633
+ `STATUS:${statusMap[booking.status] || "TENTATIVE"}`,
2634
+ "END:VEVENT"
2635
+ ];
2636
+ }
2637
+ function generateBookingsICalFeed(bookings, calendarName = "Strapi Bookings & Reservations", options2) {
2638
+ const nowStamp = formatICalDateUtc((/* @__PURE__ */ new Date()).toISOString());
2639
+ const lines = [
2640
+ "BEGIN:VCALENDAR",
2641
+ "VERSION:2.0",
2642
+ "PRODID:-//Vicacha Devs//Strapi Plugin Bookings//EN",
2643
+ "CALSCALE:GREGORIAN",
2644
+ "METHOD:PUBLISH",
2645
+ foldICalLine(`X-WR-CALNAME:${escapeICalText(calendarName)}`)
2646
+ ];
2647
+ for (const booking of bookings) {
2648
+ lines.push(...buildBookingVEventLines(booking, nowStamp, options2));
2649
+ }
2650
+ lines.push("END:VCALENDAR");
2651
+ return `${lines.join("\r\n")}\r
2652
+ `;
2653
+ }
2654
+ function sanitizeCsvCell(value) {
2655
+ if (value === null || value === void 0) {
2656
+ return '""';
2657
+ }
2658
+ let str = String(value);
2659
+ if (/^[\s\u0085\u2028\u2029]*[=+\-@|]|^[\t\r\n]/.test(str)) {
2660
+ str = `'${str}`;
2661
+ }
2662
+ const escaped = str.replace(/"/g, '""');
2663
+ return `"${escaped}"`;
2664
+ }
2665
+ function generateBookingsCsv(bookings) {
2666
+ const headers = [
2667
+ "Booking Code",
2668
+ "Resource",
2669
+ "Customer Name",
2670
+ "Customer Email",
2671
+ "Customer Phone",
2672
+ "Organization",
2673
+ "Start Date",
2674
+ "End Date",
2675
+ "Slot Label",
2676
+ "Party Size",
2677
+ "Status",
2678
+ "Preferred Language",
2679
+ "Assigned Host",
2680
+ "Notes"
2681
+ ];
2682
+ const rows = bookings.map(
2683
+ (b) => [
2684
+ b.bookingCode,
2685
+ b.resourceName ?? "",
2686
+ b.customerName,
2687
+ b.customerEmail,
2688
+ b.customerPhone ?? "",
2689
+ b.organization ?? "",
2690
+ b.startDate,
2691
+ b.endDate,
2692
+ b.slotLabel ?? "",
2693
+ b.partySize,
2694
+ b.status,
2695
+ b.preferredLanguage ?? "",
2696
+ b.assignedHost ?? "",
2697
+ b.notes ?? ""
2698
+ ].map(sanitizeCsvCell).join(",")
2699
+ );
2700
+ return [headers.map(sanitizeCsvCell).join(","), ...rows].join("\r\n");
2701
+ }
2702
+ function getValidResourceHosts(resource) {
2703
+ if (!resource || !Array.isArray(resource.assignedHosts)) return [];
2704
+ return resource.assignedHosts.filter(
2705
+ (h) => Boolean(h) && typeof h.id === "string" && h.id.trim().length > 0 && typeof h.name === "string" && h.name.trim().length > 0
2706
+ );
2707
+ }
2708
+ function findConfiguredHost(hosts, idOrName) {
2709
+ const target = idOrName.trim().toLowerCase();
2710
+ return hosts.find(
2711
+ (h) => h.id.trim().toLowerCase() === target || h.name.trim().toLowerCase() === target
2712
+ );
2713
+ }
2714
+ function isHostBusy(host, busySet) {
2715
+ return busySet.has(host.id.trim().toLowerCase()) || busySet.has(host.name.trim().toLowerCase());
2716
+ }
2717
+ function verifySlotCapacityAndResolveHost(params) {
2718
+ const {
2719
+ existing,
2720
+ startMs,
2721
+ endMs,
2722
+ partySize,
2723
+ resource,
2724
+ settings: settings2,
2725
+ preferredLanguage,
2726
+ explicitHostRaw,
2727
+ isAdmin = false,
2728
+ hostResolutionMode = "strict",
2729
+ windowCapacityOverride,
2730
+ excludeDocumentId
2731
+ } = params;
2732
+ const bufferBefore = resource?.bufferBeforeMinutes ?? settings2.defaultBufferBeforeMinutes ?? 0;
2733
+ const bufferAfter = resource?.bufferAfterMinutes ?? settings2.defaultBufferAfterMinutes ?? 0;
2734
+ const scopedBookings = existing.filter((b) => {
2735
+ if (excludeDocumentId && b.documentId === excludeDocumentId) return false;
2736
+ if (resource) return b.resourceDocumentId === resource.documentId;
2737
+ return !b.resourceDocumentId;
2738
+ });
2739
+ const overlapping = scopedBookings.filter(
2740
+ (b) => doesBookingOverlapSlot(b, startMs, endMs, bufferBefore, bufferAfter)
2741
+ );
2742
+ const capacity = windowCapacityOverride ?? resource?.capacityPerSlot ?? settings2.defaultCapacityPerSlot ?? 1;
2743
+ const bookedCount = overlapping.reduce(
2744
+ (sum, b) => sum + Math.max(1, b.partySize),
2745
+ 0
2746
+ );
2747
+ if (bookedCount + partySize > capacity) {
2748
+ throw new BookingValidationError(
2749
+ `Insufficient slot capacity: ${Math.max(0, capacity - bookedCount)} remaining, ${partySize} requested`
2750
+ );
2751
+ }
2752
+ const busyHosts = new Set(
2753
+ overlapping.map((b) => b.assignedHost?.trim().toLowerCase()).filter((v) => Boolean(v))
2754
+ );
2755
+ const configuredHosts = getValidResourceHosts(resource);
2756
+ const explicitHost = trimOrUndefined(explicitHostRaw);
2757
+ if (hostResolutionMode === "reschedule") {
2758
+ if (explicitHost) {
2759
+ const configured = findConfiguredHost(configuredHosts, explicitHost);
2760
+ if (configured && !isHostBusy(configured, busyHosts)) {
2761
+ return configured.name;
2762
+ }
2763
+ if (isAdmin && !configured && configuredHosts.length === 0 && !busyHosts.has(explicitHost.toLowerCase())) {
2764
+ return explicitHost;
2765
+ }
2766
+ }
2767
+ if (!resource) return void 0;
2768
+ const fallback = selectAvailableHostsForSlot(
2769
+ resource,
2770
+ overlapping,
2771
+ preferredLanguage
2772
+ );
2773
+ return fallback[0]?.name;
2774
+ }
2775
+ if (explicitHost) {
2776
+ if (!isAdmin) {
2777
+ if (!resource) {
2778
+ throw new BookingValidationError(
2779
+ "Cannot request a specific host without a target resource"
2780
+ );
2781
+ }
2782
+ const freeHosts = selectAvailableHostsForSlot(resource, overlapping);
2783
+ const matched = findConfiguredHost(freeHosts, explicitHost);
2784
+ if (!matched) {
2785
+ throw new BookingValidationError(
2786
+ "Requested host is unavailable or not assigned to this resource"
2787
+ );
2788
+ }
2789
+ return matched.name;
2790
+ }
2791
+ const configured = findConfiguredHost(configuredHosts, explicitHost);
2792
+ if (configured) {
2793
+ if (isHostBusy(configured, busyHosts)) {
2794
+ throw new BookingValidationError(
2795
+ `Assigned host '${configured.name}' is already booked for this time slot`
2796
+ );
2797
+ }
2798
+ return configured.name;
2799
+ }
2800
+ if (busyHosts.has(explicitHost.toLowerCase())) {
2801
+ throw new BookingValidationError(
2802
+ `Assigned host '${explicitHost}' is already booked for this time slot`
2803
+ );
2804
+ }
2805
+ return explicitHost;
2806
+ }
2807
+ if (!resource) return void 0;
2808
+ const candidates = selectAvailableHostsForSlot(
2809
+ resource,
2810
+ overlapping,
2811
+ preferredLanguage
2812
+ );
2813
+ return candidates[0]?.name;
2814
+ }
2815
+ function hasInvalidRolloverPrefix(raw) {
2816
+ const trimmed = raw.trim();
2817
+ return /^\d{4}-\d{2}-\d{2}/.test(trimmed) && parseIsoDateYmd(trimmed.slice(0, 10)) === null;
2818
+ }
2819
+ function parseStrictPartySize(val, defaultValue = 1) {
2820
+ if (val === void 0) {
2821
+ return defaultValue;
2822
+ }
2823
+ if (typeof val !== "number" || !Number.isInteger(val) || val < 1) {
2824
+ throw new BookingValidationError(
2825
+ "partySize must be an integer greater than or equal to 1"
2826
+ );
2827
+ }
2828
+ return val;
2829
+ }
2830
+ function resolveEffectiveMaxPartySize(resource, settings2) {
2831
+ const capacity = resource?.capacityPerSlot ?? settings2.defaultCapacityPerSlot ?? 1;
2832
+ if (resource) {
2833
+ return resource.maxPartySize > 0 ? Math.min(capacity, resource.maxPartySize) : capacity;
2834
+ }
2835
+ return 50;
2836
+ }
2837
+ function validateReservationCustomerAndStart(input) {
2838
+ const customerName = trimOrUndefined(input.customerName);
2839
+ const customerEmail = trimOrUndefined(input.customerEmail)?.toLowerCase();
2840
+ if (!customerName) {
2841
+ throw new BookingValidationError("customerName is required");
2842
+ }
2843
+ if (!customerEmail || !isValidEmail(customerEmail)) {
2844
+ throw new BookingValidationError("A valid customerEmail is required");
2845
+ }
2846
+ if (typeof input.startDate !== "string" || !input.startDate.trim() || hasInvalidRolloverPrefix(input.startDate)) {
2847
+ throw new BookingValidationError("A valid ISO startDate is required");
2848
+ }
2849
+ const startMs = Date.parse(input.startDate);
2850
+ if (Number.isNaN(startMs)) {
2851
+ throw new BookingValidationError("A valid ISO startDate is required");
2852
+ }
2853
+ return { customerName, customerEmail, startMs };
2854
+ }
2855
+ function resolveDefaultBookingEndMs(startMs, resource, settings2) {
2856
+ if (!resource) {
2857
+ const mins = settings2.defaultSlotDurationMinutes ?? 60;
2858
+ return startMs + mins * 6e4;
2859
+ }
2860
+ const timeZone = normalizeTimeZone(resource.timezone);
2861
+ const startWall = extractZonedWallClock(startMs, timeZone);
2862
+ const schedule = resolveDailyScheduleForDate(
2863
+ startWall.dateStr,
2864
+ startWall.dayKey,
2865
+ resource,
2866
+ settings2.defaultSeasonalSchedules,
2867
+ settings2.holidays
2868
+ );
2869
+ if (schedule.mode === "window") {
2870
+ const matchingWin = schedule.windows.find(
2871
+ (w) => parseTimeMinutes(w.start) === startWall.minutesOfDay
2872
+ );
2873
+ if (matchingWin) {
2874
+ const winEndMs = wallClockToUtcMs(
2875
+ startWall.dateStr,
2876
+ parseTimeMinutes(matchingWin.end),
2877
+ timeZone,
2878
+ false
2879
+ );
2880
+ if (winEndMs !== null && winEndMs > startMs) {
2881
+ return winEndMs;
2882
+ }
2883
+ }
2884
+ }
2885
+ const zonedEndMs = wallClockToUtcMs(
2886
+ startWall.dateStr,
2887
+ startWall.minutesOfDay + schedule.durationMins,
2888
+ timeZone,
2889
+ false
2890
+ );
2891
+ return zonedEndMs !== null && zonedEndMs > startMs ? zonedEndMs : startMs + schedule.durationMins * 6e4;
2892
+ }
2893
+ function computeReservationTimingAndParty(input, startMs, resource, settings2) {
2894
+ if (typeof input.endDate === "string" && input.endDate.trim() && hasInvalidRolloverPrefix(input.endDate)) {
2895
+ throw new BookingValidationError("endDate must be a valid ISO date");
2896
+ }
2897
+ const endMs = input.endDate ? Date.parse(input.endDate) : resolveDefaultBookingEndMs(startMs, resource, settings2);
2898
+ if (Number.isNaN(endMs) || endMs <= startMs) {
2899
+ throw new BookingValidationError("endDate must be after startDate");
2900
+ }
2901
+ const partySize = parseStrictPartySize(input.partySize, 1);
2902
+ const maxParty = resolveEffectiveMaxPartySize(resource, settings2);
2903
+ if (partySize > maxParty) {
2904
+ throw new BookingValidationError(
2905
+ `Requested partySize (${partySize}) exceeds maximum allowed (${maxParty})`
2906
+ );
2907
+ }
2908
+ return { endMs, partySize };
2909
+ }
2910
+ function isNextCalendarDay(startDateStr, endDateStr) {
2911
+ const startDayMs = Date.parse(`${startDateStr}T00:00:00.000Z`);
2912
+ const endDayMs = Date.parse(`${endDateStr}T00:00:00.000Z`);
2913
+ return !Number.isNaN(startDayMs) && !Number.isNaN(endDayMs) && endDayMs - startDayMs === 864e5;
2914
+ }
2915
+ function validateBookingOperatingWindow(params) {
2916
+ const {
2917
+ resource,
2918
+ settings: settings2,
2919
+ startMs,
2920
+ endMs,
2921
+ isAdmin = false,
2922
+ skipLeadTimeCheck = false,
2923
+ referenceNow
2924
+ } = params;
2925
+ if (!resource) return void 0;
2926
+ const timeZone = normalizeTimeZone(resource.timezone);
2927
+ const blackouts = resource.blackoutDates || [];
2928
+ if (isSlotBlackedOut(startMs, endMs, blackouts, timeZone)) {
2929
+ throw new BookingValidationError(
2930
+ "Requested time slot falls on a blackout period"
2931
+ );
2932
+ }
2933
+ if (isAdmin) {
2934
+ return void 0;
2935
+ }
2936
+ if (!skipLeadTimeCheck) {
2937
+ const now = referenceNow instanceof Date && !Number.isNaN(referenceNow.getTime()) ? referenceNow : /* @__PURE__ */ new Date();
2938
+ const nowMs = now.getTime();
2939
+ const minLeadMs = nowMs + Math.max(0, resource.minLeadTimeHours || 0) * 36e5;
2940
+ if (startMs < minLeadMs) {
2941
+ throw new BookingValidationError(
2942
+ `Bookings require at least ${resource.minLeadTimeHours} hours advance notice`
2943
+ );
2944
+ }
2945
+ if (resource.maxAdvanceDays > 0) {
2946
+ const maxAdvanceMs = nowMs + resource.maxAdvanceDays * 864e5;
2947
+ if (startMs > maxAdvanceMs) {
2948
+ throw new BookingValidationError(
2949
+ `Bookings cannot be scheduled more than ${resource.maxAdvanceDays} days in advance`
2950
+ );
2951
+ }
2952
+ }
2953
+ }
2954
+ const startWall = extractZonedWallClock(startMs, timeZone);
2955
+ const endWall = extractZonedWallClock(endMs, timeZone);
2956
+ if (startWall.seconds !== 0 || startWall.ms !== 0 || endWall.seconds !== 0 || endWall.ms !== 0) {
2957
+ throw new BookingValidationError(
2958
+ "Booking start and end times must align to exact minute boundaries"
2959
+ );
2960
+ }
2961
+ let endMinsOfStartDay = endWall.minutesOfDay;
2962
+ if (endWall.dateStr !== startWall.dateStr) {
2963
+ if (endWall.minutesOfDay === 0 && isNextCalendarDay(startWall.dateStr, endWall.dateStr)) {
2964
+ endMinsOfStartDay = 1440;
2965
+ } else {
2966
+ throw new BookingValidationError(
2967
+ "Booking must start and end on the same operating day"
2968
+ );
2969
+ }
2970
+ }
2971
+ const schedule = resolveDailyScheduleForDate(
2972
+ startWall.dateStr,
2973
+ startWall.dayKey,
2974
+ resource,
2975
+ settings2.defaultSeasonalSchedules,
2976
+ settings2.holidays
2977
+ );
2978
+ if (schedule.windows.length === 0) {
2979
+ throw new BookingValidationError(
2980
+ "Requested resource is closed on the selected date"
2981
+ );
2982
+ }
2983
+ const containingWindows = schedule.windows.filter((win) => {
2984
+ const winStart = parseTimeMinutes(win.start);
2985
+ const winEnd = parseTimeMinutes(win.end);
2986
+ return winEnd > winStart && startWall.minutesOfDay >= winStart && endMinsOfStartDay <= winEnd;
2987
+ });
2988
+ if (containingWindows.length === 0) {
2989
+ throw new BookingValidationError(
2990
+ "Requested time slot falls outside operating hours"
2991
+ );
2992
+ }
2993
+ if (schedule.mode === "window") {
2994
+ const exactWin = containingWindows.find(
2995
+ (win) => startWall.minutesOfDay === parseTimeMinutes(win.start) && endMinsOfStartDay === parseTimeMinutes(win.end)
2996
+ );
2997
+ if (!exactWin) {
2998
+ throw new BookingValidationError(
2999
+ "Requested time must match an exact configured booking window"
3000
+ );
3001
+ }
3002
+ return exactWin.capacity;
3003
+ }
3004
+ const wallDurationMins = endMinsOfStartDay - startWall.minutesOfDay;
3005
+ if (wallDurationMins > schedule.durationMins) {
3006
+ throw new BookingValidationError(
3007
+ `Requested booking duration exceeds maximum slot duration (${schedule.durationMins} minutes)`
3008
+ );
3009
+ }
3010
+ const bufferBefore = Math.max(0, resource.bufferBeforeMinutes || 0);
3011
+ const bufferAfter = Math.max(0, resource.bufferAfterMinutes || 0);
3012
+ const stepMins = Math.max(
3013
+ 5,
3014
+ schedule.durationMins + bufferBefore + bufferAfter
3015
+ );
3016
+ const alignedWindow = containingWindows.find((win) => {
3017
+ const winStart = parseTimeMinutes(win.start);
3018
+ return wallDurationMins === schedule.durationMins && (startWall.minutesOfDay - winStart) % stepMins === 0;
3019
+ });
3020
+ if (!alignedWindow) {
3021
+ throw new BookingValidationError(
3022
+ "Requested time slot must align with an exact configured interval slot"
3023
+ );
3024
+ }
3025
+ return alignedWindow.capacity;
3026
+ }
3027
+ const BOOKING_UID$1 = "plugin::bookings.booking";
3028
+ function resolveBookingLockKey(resourceDocumentId) {
3029
+ return `resource:${resourceDocumentId || "__unassigned__"}`;
3030
+ }
3031
+ async function findRawBookingOrThrow(strapi, key) {
3032
+ const trimmedKey = trimOrUndefined(key);
3033
+ if (!trimmedKey) {
3034
+ throw new BookingValidationError(
3035
+ "bookingDocumentId or bookingCode is required"
3036
+ );
3037
+ }
3038
+ const rawRows = await strapi.documents(BOOKING_UID$1).findMany({
3039
+ filters: {
3040
+ $or: [
3041
+ { documentId: { $eq: trimmedKey } },
3042
+ { bookingCode: { $eq: trimmedKey } }
3043
+ ]
3044
+ },
3045
+ populate: ["resource"],
3046
+ limit: 20
3047
+ });
3048
+ const targetRaw = (Array.isArray(rawRows) ? rawRows : []).find(
3049
+ (r) => String(r.documentId ?? r.id) === trimmedKey || String(r.bookingCode) === trimmedKey
3050
+ );
3051
+ if (!targetRaw) {
3052
+ throw new BookingNotFoundError("Booking not found");
3053
+ }
3054
+ return targetRaw;
3055
+ }
3056
+ function assertBookingActiveAndNotEnded(raw, action, isAdmin, referenceNow) {
3057
+ const currentStatus = normalizeStatus(raw.status);
3058
+ if (currentStatus !== "pending" && currentStatus !== "confirmed") {
3059
+ throw new BookingValidationError(
3060
+ `Cannot ${action} a booking with status '${currentStatus}'`
3061
+ );
3062
+ }
3063
+ if (!isAdmin) {
3064
+ const now = referenceNow instanceof Date && !Number.isNaN(referenceNow.getTime()) ? referenceNow : /* @__PURE__ */ new Date();
3065
+ const endMs = Date.parse(String(raw.endDate ?? raw.startDate ?? ""));
3066
+ if (!Number.isNaN(endMs) && endMs <= now.getTime()) {
3067
+ throw new BookingValidationError(
3068
+ `Cannot ${action} a booking that has already ended`
3069
+ );
3070
+ }
3071
+ }
3072
+ }
3073
+ async function executeUpdateBookingStatus(params) {
3074
+ const { strapi, reservationService, documentId, status, options: options2 } = params;
3075
+ const normalizedStatus = parseStrictBookingStatus(status);
3076
+ const availabilityService = strapi.plugin(PLUGIN_ID).service("availability");
3077
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
3078
+ const [resources, settings2, initialRaw] = await Promise.all([
3079
+ availabilityService.listResources(false),
3080
+ settingsService.getSettings(),
3081
+ findRawBookingOrThrow(strapi, documentId)
3082
+ ]);
3083
+ const resourceMap = new Map(resources.map((r) => [r.documentId, r]));
3084
+ const initialBooking = normalizeBookingRecord(initialRaw, resourceMap);
3085
+ const lockKey = resolveBookingLockKey(initialBooking.resourceDocumentId);
3086
+ return withResourceLock(strapi, lockKey, async () => {
3087
+ const freshRaw = await findRawBookingOrThrow(strapi, documentId);
3088
+ const currentBooking = normalizeBookingRecord(freshRaw, resourceMap);
3089
+ const prevStatus = currentBooking.status;
3090
+ const isReactivating = (prevStatus === "cancelled" || prevStatus === "no_show") && (normalizedStatus === "confirmed" || normalizedStatus === "pending");
3091
+ let resolvedReactivationHost;
3092
+ if (isReactivating) {
3093
+ const resource = currentBooking.resourceDocumentId ? resourceMap.get(currentBooking.resourceDocumentId) ?? null : null;
3094
+ const startMs = Date.parse(currentBooking.startDate);
3095
+ const endMs = Date.parse(currentBooking.endDate);
3096
+ const existing = await reservationService.listBookings(
3097
+ buildCapacityWindowQuery(resource?.documentId, startMs, endMs)
3098
+ );
3099
+ resolvedReactivationHost = verifySlotCapacityAndResolveHost({
3100
+ existing,
3101
+ startMs,
3102
+ endMs,
3103
+ partySize: currentBooking.partySize,
3104
+ resource,
3105
+ settings: settings2,
3106
+ explicitHostRaw: options2?.assignedHost ?? currentBooking.assignedHost,
3107
+ isAdmin: true,
3108
+ excludeDocumentId: currentBooking.documentId
3109
+ });
3110
+ }
3111
+ const data = { status: normalizedStatus };
3112
+ if (normalizedStatus === "cancelled") {
3113
+ data.cancelTokenHash = null;
3114
+ }
3115
+ if (options2?.assignedHost !== void 0) {
3116
+ data.assignedHost = resolvedReactivationHost ?? options2.assignedHost;
3117
+ }
3118
+ if (options2?.internalNotes !== void 0) {
3119
+ data.internalNotes = options2.internalNotes;
3120
+ }
3121
+ const updated = await strapi.documents(BOOKING_UID$1).update({
3122
+ documentId: currentBooking.documentId,
3123
+ data,
3124
+ populate: ["resource"]
3125
+ });
3126
+ if (!updated) {
3127
+ throw new BookingNotFoundError("Booking not found");
3128
+ }
3129
+ const booking = normalizeBookingRecord(
3130
+ { ...updated, resource: updated.resource ?? freshRaw.resource },
3131
+ resourceMap
3132
+ );
3133
+ strapi.eventHub?.emit(`bookings.${normalizedStatus}`, { booking });
3134
+ return booking;
3135
+ });
3136
+ }
3137
+ async function executeRescheduleBooking(params) {
3138
+ const { strapi, reservationService, input, options: options2 } = params;
3139
+ const isAdmin = Boolean(options2?.isAdmin);
3140
+ const key = input.bookingDocumentId || input.bookingCode;
3141
+ const availabilityService = strapi.plugin(PLUGIN_ID).service("availability");
3142
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
3143
+ const [resources, settings2, initialRaw] = await Promise.all([
3144
+ availabilityService.listResources(false),
3145
+ settingsService.getSettings(),
3146
+ findRawBookingOrThrow(strapi, key)
3147
+ ]);
3148
+ const resourceMap = new Map(resources.map((r) => [r.documentId, r]));
3149
+ const initialBooking = normalizeBookingRecord(initialRaw, resourceMap);
3150
+ const lockKey = resolveBookingLockKey(initialBooking.resourceDocumentId);
3151
+ return withResourceLock(strapi, lockKey, async () => {
3152
+ const targetRaw = await findRawBookingOrThrow(strapi, key);
3153
+ if (!isAdmin && !verifyCancelToken(
3154
+ input.cancelToken,
3155
+ String(targetRaw.cancelTokenHash ?? "")
3156
+ )) {
3157
+ throw new BookingForbiddenError(
3158
+ "Invalid or missing cancelToken for rescheduling"
3159
+ );
3160
+ }
3161
+ assertBookingActiveAndNotEnded(
3162
+ targetRaw,
3163
+ "reschedule",
3164
+ isAdmin,
3165
+ options2?.referenceNow
3166
+ );
3167
+ const { newStartMs, newEndMs } = computeRescheduleWindow(targetRaw, input);
3168
+ const currentBooking = normalizeBookingRecord(targetRaw, resourceMap);
3169
+ const documentId = currentBooking.documentId;
3170
+ const resource = currentBooking.resourceDocumentId ? resourceMap.get(currentBooking.resourceDocumentId) ?? null : null;
3171
+ if (!isAdmin && resource && !resource.isActive) {
3172
+ throw new BookingValidationError(
3173
+ "Requested booking resource is inactive"
3174
+ );
3175
+ }
3176
+ const partySize = parseStrictPartySize(
3177
+ input.partySize,
3178
+ currentBooking.partySize
3179
+ );
3180
+ const maxParty = resolveEffectiveMaxPartySize(
3181
+ resource,
3182
+ settings2
3183
+ );
3184
+ if (partySize > maxParty) {
3185
+ throw new BookingValidationError(
3186
+ `Requested partySize (${partySize}) exceeds maximum allowed (${maxParty})`
3187
+ );
3188
+ }
3189
+ const windowCapacityOverride = validateBookingOperatingWindow({
3190
+ resource,
3191
+ settings: settings2,
3192
+ startMs: newStartMs,
3193
+ endMs: newEndMs,
3194
+ isAdmin,
3195
+ referenceNow: options2?.referenceNow
3196
+ });
3197
+ const existing = await reservationService.listBookings(
3198
+ buildCapacityWindowQuery(resource?.documentId, newStartMs, newEndMs)
3199
+ );
3200
+ const resolvedHost = verifySlotCapacityAndResolveHost({
3201
+ existing,
3202
+ startMs: newStartMs,
3203
+ endMs: newEndMs,
3204
+ partySize,
3205
+ resource,
3206
+ settings: settings2,
3207
+ preferredLanguage: currentBooking.preferredLanguage,
3208
+ explicitHostRaw: currentBooking.assignedHost,
3209
+ isAdmin,
3210
+ hostResolutionMode: "reschedule",
3211
+ windowCapacityOverride,
3212
+ excludeDocumentId: documentId
3213
+ });
3214
+ const updated = await strapi.documents(BOOKING_UID$1).update({
3215
+ documentId,
3216
+ data: {
3217
+ startDate: new Date(newStartMs).toISOString(),
3218
+ endDate: new Date(newEndMs).toISOString(),
3219
+ slotLabel: input.newSlotLabel ?? currentBooking.slotLabel,
3220
+ partySize,
3221
+ assignedHost: resolvedHost ?? null
3222
+ },
3223
+ populate: ["resource"]
3224
+ });
3225
+ if (!updated) {
3226
+ throw new BookingNotFoundError("Booking not found");
3227
+ }
3228
+ const booking = normalizeBookingRecord(
3229
+ { ...updated, resource: updated.resource ?? targetRaw.resource },
3230
+ resourceMap
3231
+ );
3232
+ strapi.eventHub?.emit("bookings.rescheduled", { booking });
3233
+ return booking;
3234
+ });
3235
+ }
3236
+ async function executeCancelBooking(params) {
3237
+ const { strapi, input, options: options2 } = params;
3238
+ const isAdmin = Boolean(options2?.isAdmin);
3239
+ const key = input.bookingDocumentId || input.bookingCode;
3240
+ const availabilityService = strapi.plugin(PLUGIN_ID).service("availability");
3241
+ const [resources, initialRaw] = await Promise.all([
3242
+ availabilityService.listResources(false),
3243
+ findRawBookingOrThrow(strapi, key)
3244
+ ]);
3245
+ const resourceMap = new Map(resources.map((r) => [r.documentId, r]));
3246
+ const initialBooking = normalizeBookingRecord(initialRaw, resourceMap);
3247
+ const lockKey = resolveBookingLockKey(initialBooking.resourceDocumentId);
3248
+ return withResourceLock(strapi, lockKey, async () => {
3249
+ const targetRaw = await findRawBookingOrThrow(strapi, key);
3250
+ if (!isAdmin && !verifyCancelToken(
3251
+ input.cancelToken,
3252
+ String(targetRaw.cancelTokenHash ?? "")
3253
+ )) {
3254
+ throw new BookingForbiddenError(
3255
+ "Invalid or missing cancelToken for cancellation"
3256
+ );
3257
+ }
3258
+ assertBookingActiveAndNotEnded(
3259
+ targetRaw,
3260
+ "cancel",
3261
+ isAdmin,
3262
+ options2?.referenceNow
3263
+ );
3264
+ const documentId = String(targetRaw.documentId ?? targetRaw.id);
3265
+ const existingNotes = trimOrUndefined(targetRaw.internalNotes);
3266
+ const reason = trimOrUndefined(input.reason);
3267
+ const cancelNote = reason ? `Cancelled: ${reason}` : void 0;
3268
+ const mergedNotes = cancelNote ? existingNotes ? `${existingNotes}
3269
+ ${cancelNote}` : cancelNote : existingNotes;
3270
+ const updated = await strapi.documents(BOOKING_UID$1).update({
3271
+ documentId,
3272
+ data: {
3273
+ status: "cancelled",
3274
+ cancelTokenHash: null,
3275
+ ...mergedNotes !== void 0 ? { internalNotes: mergedNotes } : {}
3276
+ },
3277
+ populate: ["resource"]
3278
+ });
3279
+ if (!updated) {
3280
+ throw new BookingNotFoundError("Booking not found");
3281
+ }
3282
+ const booking = normalizeBookingRecord(
3283
+ { ...updated, resource: updated.resource ?? targetRaw.resource },
3284
+ resourceMap
3285
+ );
3286
+ strapi.eventHub?.emit("bookings.cancelled", { booking });
3287
+ return booking;
3288
+ });
3289
+ }
3290
+ const BOOKING_UID = "plugin::bookings.booking";
3291
+ function buildDbBookingFilters(query) {
3292
+ const clauses = {};
3293
+ const fromMs = parseBoundaryMs(query.from, false);
3294
+ const toMs = parseBoundaryMs(query.to, true);
3295
+ if (fromMs !== null) {
3296
+ clauses.endDate = { $gte: new Date(fromMs).toISOString() };
3297
+ }
3298
+ if (toMs !== null) {
3299
+ clauses.startDate = { $lte: new Date(toMs).toISOString() };
3300
+ }
3301
+ if (query.activeOnly) {
3302
+ clauses.status = { $in: ["pending", "confirmed"] };
3303
+ } else if (query.status) {
3304
+ clauses.status = { $eq: query.status };
3305
+ }
3306
+ if (query.resourceDocumentId) {
3307
+ clauses.resource = { documentId: { $eq: query.resourceDocumentId } };
3308
+ }
3309
+ return Object.keys(clauses).length > 0 ? clauses : void 0;
3310
+ }
3311
+ function buildExternalDateFilters(dateField, query) {
3312
+ if (!isSafeObjectKey(dateField)) return void 0;
3313
+ const fromMs = parseBoundaryMs(query.from, false);
3314
+ const toMs = parseBoundaryMs(query.to, true);
3315
+ if (fromMs === null && toMs === null) return void 0;
3316
+ const bounds = {};
3317
+ if (fromMs !== null) {
3318
+ bounds.$gte = new Date(fromMs - 864e5).toISOString().slice(0, 10);
3319
+ }
3320
+ if (toMs !== null) {
3321
+ bounds.$lte = new Date(toMs + 864e5).toISOString();
3322
+ }
3323
+ return { [dateField]: bounds };
3324
+ }
3325
+ async function loadExternalSourceBookings(strapi, sources, query) {
3326
+ const results = [];
3327
+ for (const source of sources) {
3328
+ if (!source?.enabled || !isDiscoverableCollectionUid(source.uid) || source.uid === BOOKING_UID) {
3329
+ continue;
3330
+ }
3331
+ try {
3332
+ const filters = buildExternalDateFilters(source.dateField, query);
3333
+ const extRows = await strapi.documents(source.uid).findMany({
3334
+ filters,
3335
+ limit: 500
3336
+ });
3337
+ for (const extRow of Array.isArray(extRows) ? extRows : []) {
3338
+ const mapped = mapExternalSourceRowToBooking(extRow, source);
3339
+ if (mapped) results.push(mapped);
3340
+ }
3341
+ } catch {
3342
+ }
3343
+ }
3344
+ return results;
3345
+ }
3346
+ async function resolveTargetBookingResource(availabilityService, input, isAdmin) {
3347
+ const key = trimOrUndefined(input.resourceDocumentId) || trimOrUndefined(input.resourceSlug);
3348
+ if (!key) {
3349
+ if (!isAdmin) {
3350
+ throw new BookingValidationError(
3351
+ "resourceDocumentId or resourceSlug is required"
3352
+ );
3353
+ }
3354
+ return null;
3355
+ }
3356
+ const resource = await availabilityService.getResource(key);
3357
+ if (!resource || !resource.isActive) {
3358
+ throw new BookingNotFoundError(
3359
+ "Requested booking resource is not found or inactive"
3360
+ );
3361
+ }
3362
+ return resource;
3363
+ }
3364
+ const reservation = ({ strapi }) => ({
3365
+ async listBookings(query = {}) {
3366
+ const availabilityService = strapi.plugin(PLUGIN_ID).service("availability");
3367
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
3368
+ const rawLimit = typeof query.limit === "number" && Number.isFinite(query.limit) ? Math.min(1e3, Math.max(1, Math.trunc(query.limit))) : 500;
3369
+ const filters = buildDbBookingFilters(query);
3370
+ const [resources, settings2, rawRows] = await Promise.all([
3371
+ availabilityService.listResources(false),
3372
+ settingsService.getSettings(),
3373
+ strapi.documents(BOOKING_UID).findMany({
3374
+ filters,
3375
+ populate: ["resource"],
3376
+ sort: "startDate:asc",
3377
+ limit: rawLimit
3378
+ })
3379
+ ]);
3380
+ const resourceMap = new Map(resources.map((res) => [res.documentId, res]));
3381
+ const rows = Array.isArray(rawRows) ? rawRows : [];
3382
+ const allBookings = rows.map(
3383
+ (row) => normalizeBookingRecord(row, resourceMap)
3384
+ );
3385
+ const extBookings = await loadExternalSourceBookings(
3386
+ strapi,
3387
+ settings2.sources,
3388
+ query
3389
+ );
3390
+ allBookings.push(...extBookings);
3391
+ return filterAndSortBookings(allBookings, resources, query);
3392
+ },
3393
+ async getBooking(documentIdOrCode) {
3394
+ const trimmed = trimOrUndefined(documentIdOrCode);
3395
+ if (!trimmed) return null;
3396
+ try {
3397
+ const raw = await findRawBookingOrThrow(strapi, trimmed);
3398
+ const availabilityService = strapi.plugin(PLUGIN_ID).service("availability");
3399
+ const resources = await availabilityService.listResources(false);
3400
+ const resourceMap = new Map(resources.map((r) => [r.documentId, r]));
3401
+ return normalizeBookingRecord(raw, resourceMap);
3402
+ } catch (err) {
3403
+ if (err instanceof BookingNotFoundError) return null;
3404
+ throw err;
3405
+ }
3406
+ },
3407
+ async createReservation(input, options2) {
3408
+ const isAdmin = Boolean(options2?.isAdmin);
3409
+ const { customerName, customerEmail, startMs } = validateReservationCustomerAndStart(input);
3410
+ const availabilityService = strapi.plugin(PLUGIN_ID).service("availability");
3411
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
3412
+ const settings2 = await settingsService.getSettings();
3413
+ const resource = await resolveTargetBookingResource(
3414
+ availabilityService,
3415
+ input,
3416
+ isAdmin
3417
+ );
3418
+ const lockKey = resolveBookingLockKey(resource?.documentId);
3419
+ return withResourceLock(strapi, lockKey, async () => {
3420
+ const { endMs, partySize } = computeReservationTimingAndParty(
3421
+ input,
3422
+ startMs,
3423
+ resource,
3424
+ settings2
3425
+ );
3426
+ const windowCapacityOverride = validateBookingOperatingWindow({
3427
+ resource,
3428
+ settings: settings2,
3429
+ startMs,
3430
+ endMs,
3431
+ isAdmin,
3432
+ skipLeadTimeCheck: options2?.skipLeadTimeCheck,
3433
+ referenceNow: options2?.referenceNow
3434
+ });
3435
+ const existing = await this.listBookings(
3436
+ buildCapacityWindowQuery(resource?.documentId, startMs, endMs)
3437
+ );
3438
+ const assignedHost = verifySlotCapacityAndResolveHost({
3439
+ existing,
3440
+ startMs,
3441
+ endMs,
3442
+ partySize,
3443
+ resource,
3444
+ settings: settings2,
3445
+ preferredLanguage: input.preferredLanguage,
3446
+ explicitHostRaw: input.assignedHost,
3447
+ isAdmin,
3448
+ windowCapacityOverride
3449
+ });
3450
+ const requiresApproval = resource?.requiresApproval ?? settings2.defaultRequiresApproval ?? false;
3451
+ const status = requiresApproval ? "pending" : "confirmed";
3452
+ const bookingCode = generateBookingCode(options2?.referenceNow);
3453
+ const cancelToken = generateRawCancelToken();
3454
+ const cancelTokenHash = hashCancelToken(cancelToken);
3455
+ const createdRaw = await strapi.documents(BOOKING_UID).create({
3456
+ data: buildBookingCreatePayload({
3457
+ bookingCode,
3458
+ customerName,
3459
+ customerEmail,
3460
+ startMs,
3461
+ endMs,
3462
+ partySize,
3463
+ status,
3464
+ assignedHost,
3465
+ cancelTokenHash,
3466
+ resource,
3467
+ settings: settings2,
3468
+ input
3469
+ }),
3470
+ populate: ["resource"]
3471
+ });
3472
+ const resourceMap = /* @__PURE__ */ new Map();
3473
+ if (resource) resourceMap.set(resource.documentId, resource);
3474
+ const booking = normalizeBookingRecord(
3475
+ {
3476
+ ...createdRaw,
3477
+ resource: createdRaw.resource ?? resource?.documentId
3478
+ },
3479
+ resourceMap
3480
+ );
3481
+ strapi.eventHub?.emit("bookings.created", { booking });
3482
+ return {
3483
+ booking,
3484
+ cancelToken,
3485
+ waitlisted: false
3486
+ };
3487
+ });
3488
+ },
3489
+ async updateBookingStatus(documentId, status, options2) {
3490
+ return executeUpdateBookingStatus({
3491
+ strapi,
3492
+ reservationService: this,
3493
+ documentId,
3494
+ status,
3495
+ options: options2
3496
+ });
3497
+ },
3498
+ async rescheduleBooking(input, options2) {
3499
+ return executeRescheduleBooking({
3500
+ strapi,
3501
+ reservationService: this,
3502
+ input,
3503
+ options: options2
3504
+ });
3505
+ },
3506
+ async cancelBooking(input, options2) {
3507
+ return executeCancelBooking({
3508
+ strapi,
3509
+ input,
3510
+ options: options2
3511
+ });
3512
+ },
3513
+ async exportCsv(query = {}) {
3514
+ const bookings = await this.listBookings(query);
3515
+ return generateBookingsCsv(bookings);
3516
+ },
3517
+ async exportICal(query = {}, options2) {
3518
+ const bookings = await this.listBookings(query);
3519
+ if (options2?.anonymize) {
3520
+ const opaqueUids = {};
3521
+ for (const b of bookings) {
3522
+ opaqueUids[b.documentId] = computeOpaqueBookingUid(b);
3523
+ }
3524
+ return generateBookingsICalFeed(bookings, void 0, {
3525
+ anonymize: true,
3526
+ opaqueUids
3527
+ });
3528
+ }
3529
+ return generateBookingsICalFeed(bookings);
3530
+ }
3531
+ });
3532
+ const services = {
3533
+ settings,
3534
+ availability,
3535
+ reservation
3536
+ };
3537
+ const index = {
3538
+ register,
3539
+ bootstrap,
3540
+ destroy,
3541
+ config,
3542
+ controllers,
3543
+ routes,
3544
+ services,
3545
+ contentTypes
3546
+ };
3547
+ exports.default = index;