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