@aglyn/plugins-bookings 1.0.0-beta.229 → 1.0.0-beta.231

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 (35) hide show
  1. package/README.md +1 -1
  2. package/package.json +11 -11
  3. package/src/lib/components/book-meeting-action.js +3 -2
  4. package/src/lib/components/book-meeting-action.js.map +1 -1
  5. package/src/lib/components/booking.js +4 -0
  6. package/src/lib/components/booking.js.map +1 -1
  7. package/src/lib/components/bookings-console-page.js +62 -4
  8. package/src/lib/components/bookings-console-page.js.map +1 -1
  9. package/src/lib/model/booking-in-person.d.ts +72 -0
  10. package/src/lib/model/booking-in-person.js +71 -0
  11. package/src/lib/model/booking-in-person.js.map +1 -0
  12. package/src/lib/model/booking-manage.d.ts +66 -0
  13. package/src/lib/model/booking-manage.js +90 -0
  14. package/src/lib/model/booking-manage.js.map +1 -0
  15. package/src/lib/model/bookings.d.ts +22 -0
  16. package/src/lib/model/bookings.js +11 -0
  17. package/src/lib/model/bookings.js.map +1 -1
  18. package/src/lib/server/booking-manage.d.ts +77 -0
  19. package/src/lib/server/booking-manage.js +306 -0
  20. package/src/lib/server/booking-manage.js.map +1 -0
  21. package/src/lib/server/booking-service-ai-capability.d.ts +50 -0
  22. package/src/lib/server/booking-service-ai-capability.js +203 -0
  23. package/src/lib/server/booking-service-ai-capability.js.map +1 -0
  24. package/src/lib/server/booking-service-drafts.d.ts +107 -0
  25. package/src/lib/server/booking-service-drafts.js +320 -0
  26. package/src/lib/server/booking-service-drafts.js.map +1 -0
  27. package/src/lib/server/in-person-payment.d.ts +20 -0
  28. package/src/lib/server/in-person-payment.js +469 -0
  29. package/src/lib/server/in-person-payment.js.map +1 -0
  30. package/src/lib/server.js +24 -4
  31. package/src/lib/server.js.map +1 -1
  32. package/src/lib/tenant-emails.js +59 -0
  33. package/src/lib/tenant-emails.js.map +1 -1
  34. package/src/lib/utils/use-booking-purchase-event.js +5 -0
  35. package/src/lib/utils/use-booking-purchase-event.js.map +1 -1
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * What a site's team may do to a booking once it exists (AGL-3621): check
18
+ * the guest in, move it, cancel it. One pure rule set, read by the routes
19
+ * that do it (`bookings/check-in`, `bookings/reschedule`) under their own
20
+ * transaction, and the rules a client mirrors to decide what to OFFER (the
21
+ * native Aglyn app's bookings screens port them, cased by this file's spec),
22
+ * so a screen can only offer what the route would then do.
23
+ *
24
+ * No I/O and no React, so client and server code alike may import it.
25
+ */ /** `hosts/{hostId}/bookings/{id}`, the fields these rules read. */ /**
26
+ * The booking's state as a reader should treat it. A payment hold whose
27
+ * fifteen minutes ran out released its slot (AGL-170) even while the
28
+ * document still says `pendingPayment`.
29
+ */ export function bookingState(booking, nowMs) {
30
+ if (booking.status === 'canceled') return 'canceled';
31
+ if (booking.status === 'pendingPayment') {
32
+ var _booking_expiresAtMs;
33
+ return Number((_booking_expiresAtMs = booking.expiresAtMs) != null ? _booking_expiresAtMs : 0) < nowMs ? 'expired' : 'pendingPayment';
34
+ }
35
+ return 'confirmed';
36
+ }
37
+ export const BOOKING_STATE_LABELS = {
38
+ confirmed: 'Confirmed',
39
+ pendingPayment: 'Awaiting payment',
40
+ expired: 'Payment not finished',
41
+ canceled: 'Canceled'
42
+ };
43
+ /** The paid amount not yet refunded, in cents. */ export function bookingOutstandingCents(booking) {
44
+ var _booking_paidAmountCents, _booking_refundedCents;
45
+ const paid = Math.max(0, Math.round(Number((_booking_paidAmountCents = booking.paidAmountCents) != null ? _booking_paidAmountCents : 0)));
46
+ const refunded = Math.max(0, Math.round(Number((_booking_refundedCents = booking.refundedCents) != null ? _booking_refundedCents : 0)));
47
+ return Math.max(0, paid - refunded);
48
+ }
49
+ /**
50
+ * What may be done to a booking now. A guest is checked in only on a
51
+ * confirmed booking; a booking moves only while it is confirmed and has not
52
+ * ended; and nothing is done to a canceled one.
53
+ */ export function bookingActions(booking, nowMs) {
54
+ var _booking_checkedInAtMs, _booking_endsAtMs;
55
+ const state = bookingState(booking, nowMs);
56
+ const confirmed = state === 'confirmed';
57
+ const checkedIn = Number((_booking_checkedInAtMs = booking.checkedInAtMs) != null ? _booking_checkedInAtMs : 0) > 0;
58
+ const ended = Number((_booking_endsAtMs = booking.endsAtMs) != null ? _booking_endsAtMs : 0) <= nowMs;
59
+ return {
60
+ checkIn: confirmed && !checkedIn,
61
+ undoCheckIn: confirmed && checkedIn,
62
+ reschedule: confirmed && !checkedIn && !ended,
63
+ cancel: state !== 'canceled' && !checkedIn,
64
+ refundCents: bookingOutstandingCents(booking)
65
+ };
66
+ }
67
+ /** Why a check-in (or its undo) cannot be written, or null. The route's own refusal. */ export function checkInRefusal(booking, checkedIn, nowMs) {
68
+ var _booking_checkedInAtMs;
69
+ const state = bookingState(booking, nowMs);
70
+ if (state === 'canceled') return 'This booking was canceled';
71
+ if (state !== 'confirmed') return 'This booking is not paid yet';
72
+ return checkedIn || Number((_booking_checkedInAtMs = booking.checkedInAtMs) != null ? _booking_checkedInAtMs : 0) > 0 ? null : 'This guest is not checked in';
73
+ }
74
+ /** Why a booking cannot move to `startsAtMs`, or null. The slot itself is checked apart. */ export function rescheduleRefusal(booking, startsAtMs, nowMs) {
75
+ var _booking_checkedInAtMs, _booking_endsAtMs;
76
+ const state = bookingState(booking, nowMs);
77
+ if (state === 'canceled') return 'This booking was canceled';
78
+ if (state !== 'confirmed') return 'This booking is not paid yet';
79
+ if (Number((_booking_checkedInAtMs = booking.checkedInAtMs) != null ? _booking_checkedInAtMs : 0) > 0) return 'This guest is already checked in';
80
+ if (Number((_booking_endsAtMs = booking.endsAtMs) != null ? _booking_endsAtMs : 0) <= nowMs) return 'This booking has already ended';
81
+ if (!Number.isFinite(startsAtMs) || startsAtMs <= nowMs) return 'Pick a time that has not passed';
82
+ return null;
83
+ }
84
+ /** The booking's length, kept when it moves: what the guest booked, not what the service says today. */ export function bookingDurationMs(booking, fallbackMinutes) {
85
+ var _booking_endsAtMs, _booking_startsAtMs;
86
+ const stored = Number((_booking_endsAtMs = booking.endsAtMs) != null ? _booking_endsAtMs : 0) - Number((_booking_startsAtMs = booking.startsAtMs) != null ? _booking_startsAtMs : 0);
87
+ return stored > 0 ? stored : Math.max(5, Math.round(fallbackMinutes || 30)) * 60000;
88
+ }
89
+
90
+ //# sourceMappingURL=booking-manage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/booking-manage.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * What a site's team may do to a booking once it exists (AGL-3621): check\n * the guest in, move it, cancel it. One pure rule set, read by the routes\n * that do it (`bookings/check-in`, `bookings/reschedule`) under their own\n * transaction, and the rules a client mirrors to decide what to OFFER (the\n * native Aglyn app's bookings screens port them, cased by this file's spec),\n * so a screen can only offer what the route would then do.\n *\n * No I/O and no React, so client and server code alike may import it.\n */\n\n/** `hosts/{hostId}/bookings/{id}`, the fields these rules read. */\nexport interface ManagedBooking {\n status?: string\n startsAtMs?: number\n endsAtMs?: number\n expiresAtMs?: number\n paidAmountCents?: number\n refundedCents?: number\n checkedInAtMs?: number\n}\n\nexport type BookingState = 'confirmed' | 'pendingPayment' | 'expired' | 'canceled'\n\n/**\n * The booking's state as a reader should treat it. A payment hold whose\n * fifteen minutes ran out released its slot (AGL-170) even while the\n * document still says `pendingPayment`.\n */\nexport function bookingState(booking: ManagedBooking, nowMs: number): BookingState {\n if (booking.status === 'canceled') return 'canceled'\n if (booking.status === 'pendingPayment') {\n return Number(booking.expiresAtMs ?? 0) < nowMs ? 'expired' : 'pendingPayment'\n }\n return 'confirmed'\n}\n\nexport const BOOKING_STATE_LABELS: Readonly<Record<BookingState, string>> = {\n confirmed: 'Confirmed',\n pendingPayment: 'Awaiting payment',\n expired: 'Payment not finished',\n canceled: 'Canceled',\n}\n\n/** The paid amount not yet refunded, in cents. */\nexport function bookingOutstandingCents(booking: ManagedBooking): number {\n const paid = Math.max(0, Math.round(Number(booking.paidAmountCents ?? 0)))\n const refunded = Math.max(0, Math.round(Number(booking.refundedCents ?? 0)))\n return Math.max(0, paid - refunded)\n}\n\nexport interface BookingActions {\n checkIn: boolean\n undoCheckIn: boolean\n reschedule: boolean\n /** Cancel; with `refundCents` above zero the cancel goes through the refund route. */\n cancel: boolean\n refundCents: number\n}\n\n/**\n * What may be done to a booking now. A guest is checked in only on a\n * confirmed booking; a booking moves only while it is confirmed and has not\n * ended; and nothing is done to a canceled one.\n */\nexport function bookingActions(booking: ManagedBooking, nowMs: number): BookingActions {\n const state = bookingState(booking, nowMs)\n const confirmed = state === 'confirmed'\n const checkedIn = Number(booking.checkedInAtMs ?? 0) > 0\n const ended = Number(booking.endsAtMs ?? 0) <= nowMs\n return {\n checkIn: confirmed && !checkedIn,\n undoCheckIn: confirmed && checkedIn,\n reschedule: confirmed && !checkedIn && !ended,\n cancel: state !== 'canceled' && !checkedIn,\n refundCents: bookingOutstandingCents(booking),\n }\n}\n\n/** Why a check-in (or its undo) cannot be written, or null. The route's own refusal. */\nexport function checkInRefusal(booking: ManagedBooking, checkedIn: boolean, nowMs: number): string | null {\n const state = bookingState(booking, nowMs)\n if (state === 'canceled') return 'This booking was canceled'\n if (state !== 'confirmed') return 'This booking is not paid yet'\n return checkedIn || Number(booking.checkedInAtMs ?? 0) > 0 ? null : 'This guest is not checked in'\n}\n\n/** Why a booking cannot move to `startsAtMs`, or null. The slot itself is checked apart. */\nexport function rescheduleRefusal(booking: ManagedBooking, startsAtMs: number, nowMs: number): string | null {\n const state = bookingState(booking, nowMs)\n if (state === 'canceled') return 'This booking was canceled'\n if (state !== 'confirmed') return 'This booking is not paid yet'\n if (Number(booking.checkedInAtMs ?? 0) > 0) return 'This guest is already checked in'\n if (Number(booking.endsAtMs ?? 0) <= nowMs) return 'This booking has already ended'\n if (!Number.isFinite(startsAtMs) || startsAtMs <= nowMs) return 'Pick a time that has not passed'\n return null\n}\n\n/** The booking's length, kept when it moves: what the guest booked, not what the service says today. */\nexport function bookingDurationMs(booking: ManagedBooking, fallbackMinutes: number): number {\n const stored = Number(booking.endsAtMs ?? 0) - Number(booking.startsAtMs ?? 0)\n return stored > 0 ? stored : Math.max(5, Math.round(fallbackMinutes || 30)) * 60_000\n}\n"],"names":["bookingState","booking","nowMs","status","Number","expiresAtMs","BOOKING_STATE_LABELS","confirmed","pendingPayment","expired","canceled","bookingOutstandingCents","paid","Math","max","round","paidAmountCents","refunded","refundedCents","bookingActions","state","checkedIn","checkedInAtMs","ended","endsAtMs","checkIn","undoCheckIn","reschedule","cancel","refundCents","checkInRefusal","rescheduleRefusal","startsAtMs","isFinite","bookingDurationMs","fallbackMinutes","stored"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;CASC,GAED,iEAAiE,GAajE;;;;CAIC,GACD,OAAO,SAASA,aAAaC,OAAuB,EAAEC,KAAa;IACjE,IAAID,QAAQE,MAAM,KAAK,YAAY,OAAO;IAC1C,IAAIF,QAAQE,MAAM,KAAK,kBAAkB;YACzBF;QAAd,OAAOG,QAAOH,uBAAAA,QAAQI,WAAW,YAAnBJ,uBAAuB,KAAKC,QAAQ,YAAY;IAChE;IACA,OAAO;AACT;AAEA,OAAO,MAAMI,uBAA+D;IAC1EC,WAAW;IACXC,gBAAgB;IAChBC,SAAS;IACTC,UAAU;AACZ,EAAC;AAED,gDAAgD,GAChD,OAAO,SAASC,wBAAwBV,OAAuB;QAClBA,0BACIA;IAD/C,MAAMW,OAAOC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACX,QAAOH,2BAAAA,QAAQe,eAAe,YAAvBf,2BAA2B;IACtE,MAAMgB,WAAWJ,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACX,QAAOH,yBAAAA,QAAQiB,aAAa,YAArBjB,yBAAyB;IACxE,OAAOY,KAAKC,GAAG,CAAC,GAAGF,OAAOK;AAC5B;AAWA;;;;CAIC,GACD,OAAO,SAASE,eAAelB,OAAuB,EAAEC,KAAa;QAG1CD,wBACJA;IAHrB,MAAMmB,QAAQpB,aAAaC,SAASC;IACpC,MAAMK,YAAYa,UAAU;IAC5B,MAAMC,YAAYjB,QAAOH,yBAAAA,QAAQqB,aAAa,YAArBrB,yBAAyB,KAAK;IACvD,MAAMsB,QAAQnB,QAAOH,oBAAAA,QAAQuB,QAAQ,YAAhBvB,oBAAoB,MAAMC;IAC/C,OAAO;QACLuB,SAASlB,aAAa,CAACc;QACvBK,aAAanB,aAAac;QAC1BM,YAAYpB,aAAa,CAACc,aAAa,CAACE;QACxCK,QAAQR,UAAU,cAAc,CAACC;QACjCQ,aAAalB,wBAAwBV;IACvC;AACF;AAEA,sFAAsF,GACtF,OAAO,SAAS6B,eAAe7B,OAAuB,EAAEoB,SAAkB,EAAEnB,KAAa;QAI5DD;IAH3B,MAAMmB,QAAQpB,aAAaC,SAASC;IACpC,IAAIkB,UAAU,YAAY,OAAO;IACjC,IAAIA,UAAU,aAAa,OAAO;IAClC,OAAOC,aAAajB,QAAOH,yBAAAA,QAAQqB,aAAa,YAArBrB,yBAAyB,KAAK,IAAI,OAAO;AACtE;AAEA,0FAA0F,GAC1F,OAAO,SAAS8B,kBAAkB9B,OAAuB,EAAE+B,UAAkB,EAAE9B,KAAa;QAI/ED,wBACAA;IAJX,MAAMmB,QAAQpB,aAAaC,SAASC;IACpC,IAAIkB,UAAU,YAAY,OAAO;IACjC,IAAIA,UAAU,aAAa,OAAO;IAClC,IAAIhB,QAAOH,yBAAAA,QAAQqB,aAAa,YAArBrB,yBAAyB,KAAK,GAAG,OAAO;IACnD,IAAIG,QAAOH,oBAAAA,QAAQuB,QAAQ,YAAhBvB,oBAAoB,MAAMC,OAAO,OAAO;IACnD,IAAI,CAACE,OAAO6B,QAAQ,CAACD,eAAeA,cAAc9B,OAAO,OAAO;IAChE,OAAO;AACT;AAEA,sGAAsG,GACtG,OAAO,SAASgC,kBAAkBjC,OAAuB,EAAEkC,eAAuB;QAC1DlC,mBAAgCA;IAAtD,MAAMmC,SAAShC,QAAOH,oBAAAA,QAAQuB,QAAQ,YAAhBvB,oBAAoB,KAAKG,QAAOH,sBAAAA,QAAQ+B,UAAU,YAAlB/B,sBAAsB;IAC5E,OAAOmC,SAAS,IAAIA,SAASvB,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACoB,mBAAmB,OAAO;AAChF"}
@@ -64,7 +64,29 @@ export interface HostBookingService {
64
64
  askPhone?: 'off' | 'optional' | 'required';
65
65
  /** Ask the booker for the address the service is at (AGL-3493); as above. */
66
66
  askAddress?: 'off' | 'optional' | 'required';
67
+ /**
68
+ * Whether the service takes bookings (AGL-3616). A `draft` is set up and
69
+ * offered nowhere: the public listing leaves it out, its slots are not
70
+ * served and the booking route refuses it, until someone activates it in
71
+ * the console. Absent is `active`, so every service made before the field
72
+ * existed keeps taking bookings. Read through `bookingServiceStatus`.
73
+ */
74
+ status?: BookingServiceStatus;
67
75
  }
76
+ /** Whether a service takes bookings (AGL-3616). */
77
+ export type BookingServiceStatus = 'draft' | 'active';
78
+ /** The status as stored, read so only an explicit `draft` holds a service back. */
79
+ export declare function bookingServiceStatus(value: unknown): BookingServiceStatus;
80
+ /**
81
+ * Whether a stored service is offered to visitors (AGL-3616): not deleted,
82
+ * and not a draft. The one predicate every public read asks — the service
83
+ * directory, a service's slots, the booking route — and the console's
84
+ * booking-link picker, which hands visitors a link to the same door.
85
+ */
86
+ export declare function bookingServiceIsOffered(service: {
87
+ deletedAt?: unknown;
88
+ status?: unknown;
89
+ } | null | undefined): boolean;
68
90
  /** Booked interval as epoch-ms instants. */
69
91
  export interface BookedInterval {
70
92
  startsAtMs: number;
@@ -21,6 +21,17 @@ import { hostPublicOrigin } from "@aglyn/aglyn/app-utils/host-naming";
21
21
  // and this model is read on published pages.
22
22
  import { zonedDateTime } from "@aglyn/shared-util-timestamp/zoned-time";
23
23
  import { BOOKING_RECORD_PARAM, BOOKING_SERVICE_PARAM, formatBookingRecordRef } from "./booking-record.js";
24
+ /** The status as stored, read so only an explicit `draft` holds a service back. */ export function bookingServiceStatus(value) {
25
+ return value === 'draft' ? 'draft' : 'active';
26
+ }
27
+ /**
28
+ * Whether a stored service is offered to visitors (AGL-3616): not deleted,
29
+ * and not a draft. The one predicate every public read asks — the service
30
+ * directory, a service's slots, the booking route — and the console's
31
+ * booking-link picker, which hands visitors a link to the same door.
32
+ */ export function bookingServiceIsOffered(service) {
33
+ return Boolean(service) && !(service == null ? void 0 : service.deletedAt) && bookingServiceStatus(service == null ? void 0 : service.status) === 'active';
34
+ }
24
35
  export const BOOKING_MIN_DURATION_MINUTES = 5;
25
36
  export const BOOKING_MAX_DURATION_MINUTES = 8 * 60;
26
37
  /** Slot enumeration horizon; the console/API never look further out. */ export const BOOKING_MAX_DAYS_AHEAD = 60;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/bookings.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Leaf paths, not the barrel: this model is read by the client bundle and\n// the `/server` entry alike, and either barrel would drag the other's\n// surface into a bundle that has no use for it.\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\n// The leaf entry: the package root extends the Firestore SDK's `Timestamp`,\n// and this model is read on published pages.\nimport { zonedDateTime } from '@aglyn/shared-util-timestamp/zoned-time'\nimport {\n BOOKING_RECORD_PARAM,\n BOOKING_SERVICE_PARAM,\n type BookingRecordRef,\n formatBookingRecordRef,\n} from './booking-record'\n\n/**\n * Bookings v1 (AGL-159): services with weekly availability windows and\n * pure, timezone-explicit slot computation. All times are minutes since\n * midnight in the HOST's timezone; instants are epoch milliseconds. No\n * I/O here — collision checks against stored bookings happen in the\n * booking API over the values these helpers produce.\n */\n\n/** `hosts/{hostId}/services/{id}` doc. */\nexport interface HostBookingService {\n name: string\n durationMinutes: number\n description?: string\n /** Optional price; 0/absent means free. */\n priceUsd?: number\n /**\n * How the price is stated (AGL-3475): `fixed` (absent) charges `priceUsd`;\n * `varies`, `estimate` and `contact` show their label and book with no\n * charge. Read through `bookingPriceDisplay` / `bookingChargeUsd`.\n */\n priceDisplay?: 'fixed' | 'varies' | 'estimate' | 'contact'\n /**\n * Weekly availability: `windows[weekday]` (0 = Sunday … 6 = Saturday)\n * lists open intervals in minutes since midnight, host-local.\n */\n windows?: Partial<Record<number, Array<{ start: number; end: number }>>>\n /** IANA timezone the windows are defined in, e.g. \"America/Chicago\". */\n timezone?: string\n /**\n * Create a follow-up task on the CRM record when this service is booked\n * (AGL-2660) — due one business day after the slot, on whoever holds the\n * relationship. Off unless the service says so.\n */\n crmFollowUpTask?: boolean\n /**\n * File the booking as a `meeting` on the CRM record's timeline (AGL-2660).\n * Absent means ON: a booking is a meeting the record has, and the service\n * has to opt out of saying so.\n */\n crmMeetingActivity?: boolean\n /**\n * Ask the booker for a phone number (AGL-3493): `optional` or `required`;\n * absent is off. Read through `bookingFieldAsk`.\n */\n askPhone?: 'off' | 'optional' | 'required'\n /** Ask the booker for the address the service is at (AGL-3493); as above. */\n askAddress?: 'off' | 'optional' | 'required'\n}\n\n/** Booked interval as epoch-ms instants. */\nexport interface BookedInterval {\n startsAtMs: number\n endsAtMs: number\n}\n\nexport const BOOKING_MIN_DURATION_MINUTES = 5\nexport const BOOKING_MAX_DURATION_MINUTES = 8 * 60\n/** Slot enumeration horizon; the console/API never look further out. */\nexport const BOOKING_MAX_DAYS_AHEAD = 60\n\n/**\n * The band a booking has to fall in to earn its 24-hour reminder, in hours\n * from now (AGL-160, scheduled in AGL-2431).\n *\n * IN THE MODEL, not beside the scan that uses them, so the console card can\n * apply the identical window without importing server code into a client\n * bundle. That sharing is the point rather than a convenience: a card that\n * drew its own boundary would report a queue depth the job does not act on,\n * and a merchant checking whether reminders are working would be reading a\n * number produced by different arithmetic than the sender's.\n */\nexport const REMINDER_WINDOW_START_HOURS = 23\nexport const REMINDER_WINDOW_END_HOURS = 25\n\n/**\n * Whether `booking` is one this pass would mail — the three tests the scan\n * applies, in one place both halves call.\n *\n * `nowMs` is a parameter and not `Date.now()` so the card and the job can be\n * asked the same question about the same instant, and so a test can pin a\n * booking exactly on either edge of the band.\n */\nexport function isBookingReminderDue(\n booking: {\n status?: string\n email?: string\n reminderSentAt?: unknown\n startsAtMs?: number\n },\n nowMs: number,\n): boolean {\n const startsAtMs = Number(booking.startsAtMs ?? 0)\n return (\n booking.status !== 'canceled' &&\n !booking.reminderSentAt &&\n Boolean(booking.email) &&\n startsAtMs >= nowMs + REMINDER_WINDOW_START_HOURS * 60 * 60 * 1000 &&\n startsAtMs <= nowMs + REMINDER_WINDOW_END_HOURS * 60 * 60 * 1000\n )\n}\n\nexport interface BookingSlot {\n startsAtMs: number\n endsAtMs: number\n}\n\n/** The walk's resolution: a slot may start on any quarter hour. */\nexport const BOOKING_SLOT_STEP_MINUTES = 15\n\n/**\n * The most starts one calendar day can hold at that resolution — 96. The\n * per-day unit every bound on a slot listing is counted in.\n */\nexport const BOOKING_MAX_SLOTS_PER_DAY =\n (24 * 60) / BOOKING_SLOT_STEP_MINUTES\n\n/**\n * Days of open times one slot listing answers with, and the length of the\n * Booking widget's day strip (AGL-3492). One constant, read by both halves:\n * the listing is bounded in DAYS so every day the strip shows carries every\n * open time it has. A flat slot count was the bound before, and at 15-minute\n * steps 120 of them ran out three and a half days in — the widget then drew\n * those days as the whole calendar and cut the fourth at lunch.\n */\nexport const BOOKING_SLOT_PAGE_DAYS = 14\n\n/**\n * The hard ceiling on one listing: a page of days, every one of them open\n * around the clock. Only a service that really is open that much reaches it,\n * and the page then ends early with its next page named, never truncated\n * silently.\n */\nexport const BOOKING_SLOT_PAGE_MAX_SLOTS =\n BOOKING_SLOT_PAGE_DAYS * BOOKING_MAX_SLOTS_PER_DAY\n\n/**\n * Walks the open slots of a service from `fromMs`, handing each to `take`\n * with the service-local day it falls on, until `take` answers `false` or\n * the walk reaches `toMs` (never more than BOOKING_MAX_DAYS_AHEAD past\n * `fromMs`). Answers where it stopped: the first start it did not hand over\n * — the slot `take` refused, or the first one past the walked range — and\n * whether that was `toMs` itself, so nothing is left to walk.\n *\n * Windows are read in the service timezone, 15 minutes at a time —\n * O(minutes/15) and immune to DST arithmetic because weekday and minutes\n * come from Intl per instant. Intervals overlapping `booked` drop out.\n */\nfunction walkOpenSlots(\n service: HostBookingService,\n fromMs: number,\n toMs: number,\n booked: BookedInterval[],\n take: (slot: BookingSlot, dayKey: string) => boolean,\n): { stoppedAtMs: number; reachedEnd: boolean } {\n const timezone = service.timezone || 'UTC'\n const duration = Math.min(\n Math.max(\n Math.round(service.durationMinutes || 0),\n BOOKING_MIN_DURATION_MINUTES,\n ),\n BOOKING_MAX_DURATION_MINUTES,\n )\n const durationMs = duration * 60_000\n const horizonMs = Math.min(\n toMs,\n fromMs + BOOKING_MAX_DAYS_AHEAD * 24 * 60 * 60_000,\n )\n const stepMs = BOOKING_SLOT_STEP_MINUTES * 60_000\n let atMs = Math.ceil(fromMs / stepMs) * stepMs\n for (; atMs + durationMs <= horizonMs; atMs += stepMs) {\n const { year, month, day, weekday, hour, minute } = zonedDateTime(\n atMs,\n timezone,\n )\n const minutes = hour * 60 + minute\n const windows = service.windows?.[weekday] ?? []\n const fitsWindow = windows.some(\n (window) =>\n minutes >= window.start && minutes + duration <= window.end,\n )\n if (!fitsWindow) continue\n const endMs = atMs + durationMs\n const collides = booked.some(\n (interval) =>\n atMs < interval.endsAtMs && endMs > interval.startsAtMs,\n )\n if (collides) continue\n if (!take({ startsAtMs: atMs, endsAtMs: endMs }, `${year}-${month}-${day}`)) {\n return { stoppedAtMs: atMs, reachedEnd: false }\n }\n }\n // The first start the walk did not try, not the horizon itself: a slot\n // that starts before the cap and ends after it belongs to the next walk.\n return { stoppedAtMs: atMs, reachedEnd: horizonMs >= toMs }\n}\n\n/**\n * Enumerates open slots for a service between two instants. Bounded: at\n * most `limit` slots, never beyond BOOKING_MAX_DAYS_AHEAD.\n */\nexport function computeOpenSlots(\n service: HostBookingService,\n fromMs: number,\n toMs: number,\n booked: BookedInterval[] = [],\n limit = 200,\n): BookingSlot[] {\n const slots: BookingSlot[] = []\n if (limit <= 0) return slots\n walkOpenSlots(service, fromMs, toMs, booked, (slot) => {\n slots.push(slot)\n return slots.length < limit\n })\n return slots\n}\n\n/** One page of a slot listing (AGL-3492). */\nexport interface BookingSlotPage {\n /** Every open slot of the page's days, in order. */\n slots: BookingSlot[]\n /**\n * Where the next page starts, or `null` once the page reached `toMs`.\n * Every open slot before it is in `slots` — the guarantee a reader needs\n * to tell a day it holds whole from one the page ended inside.\n */\n nextFromMs: number | null\n}\n\n/**\n * The open slots of the next `days` service-local days that have any, from\n * `fromMs` up to `toMs` — whole days, never a day cut part way through\n * (AGL-3492). `maxSlots` is the ceiling for a service open around the clock;\n * reaching it ends the page early with `nextFromMs` naming the first slot\n * left out. A walk is also never longer than BOOKING_MAX_DAYS_AHEAD, so a\n * horizon further out than that is answered a page at a time.\n */\nexport function computeOpenSlotPage(\n service: HostBookingService,\n fromMs: number,\n toMs: number,\n booked: BookedInterval[] = [],\n {\n days = BOOKING_SLOT_PAGE_DAYS,\n maxSlots = BOOKING_SLOT_PAGE_MAX_SLOTS,\n }: { days?: number; maxSlots?: number } = {},\n): BookingSlotPage {\n const slots: BookingSlot[] = []\n const dayKeys = new Set<string>()\n const { stoppedAtMs, reachedEnd } = walkOpenSlots(\n service,\n fromMs,\n toMs,\n booked,\n (slot, dayKey) => {\n if (\n slots.length >= maxSlots ||\n (!dayKeys.has(dayKey) && dayKeys.size >= days)\n ) {\n return false\n }\n dayKeys.add(dayKey)\n slots.push(slot)\n return true\n },\n )\n return {\n slots,\n nextFromMs: reachedEnd ? null : stoppedAtMs,\n }\n}\n\n/** True when the exact slot is open for the service (booking API check). */\nexport function isSlotOpen(\n service: HostBookingService,\n startsAtMs: number,\n booked: BookedInterval[] = [],\n): boolean {\n const slots = computeOpenSlots(\n service,\n startsAtMs,\n startsAtMs + BOOKING_MAX_DURATION_MINUTES * 60_000,\n booked,\n 1,\n )\n return slots.length > 0 && slots[0].startsAtMs === startsAtMs\n}\n\n/**\n * The page on the site that holds the Booking block, as the plugin's\n * `bookingPath` setting stores it (AGL-2660). The site root by default: a\n * site that put the block on its home page needs no setting, and a site\n * that put it elsewhere says where once, per site.\n */\nexport const BOOKING_PATH_DEFAULT = '/'\n\n/** `/book`, `book/`, ` /book?x ` → `/book`; anything empty → the root. */\nexport function normalizeBookingPath(raw: unknown): string {\n const text = String(raw ?? '')\n .trim()\n .split(/[?#]/)[0]\n if (!text) return BOOKING_PATH_DEFAULT\n const leading = text.startsWith('/') ? text : `/${text}`\n const trimmed = leading.replace(/\\/+$/, '')\n return trimmed || BOOKING_PATH_DEFAULT\n}\n\nexport interface BookingLinkInput {\n /** The host's public naming — its custom domain or its subdomain. */\n site: { cname?: string | null; subdomain?: string | null } | null | undefined\n /** The service the link opens the widget on. */\n service: { id: string }\n /** The site's `bookingPath` setting; the root when unset. */\n path?: string | null\n /** The record the link is dropped from, when there is one. */\n recordRef?: BookingRecordRef | null\n}\n\n/**\n * The public URL a visitor books a service at (AGL-2660), or `null` for a\n * site with no public origin yet.\n *\n * PER SERVICE, not per member: the model carries no staff or per-member\n * notion — a service has one calendar — so there is one link per service\n * and nothing narrower. The Bookings plugin renders no route of its own\n * either; the widget is a block the site owner placed on a page, so the\n * link is that page (the `bookingPath` setting) with the service\n * preselected and, when dropped from a record, the record carried\n * along so the booking can be attributed even when the booker uses a\n * different address.\n */\nexport function bookingLinkFor(input: BookingLinkInput): string | null {\n const origin = hostPublicOrigin(input.site)\n if (!origin) return null\n const query = new URLSearchParams({ [BOOKING_SERVICE_PARAM]: input.service.id })\n if (input.recordRef) query.set(BOOKING_RECORD_PARAM, formatBookingRecordRef(input.recordRef))\n return `${origin}${normalizeBookingPath(input.path)}?${query.toString()}`\n}\n"],"names":["hostPublicOrigin","zonedDateTime","BOOKING_RECORD_PARAM","BOOKING_SERVICE_PARAM","formatBookingRecordRef","BOOKING_MIN_DURATION_MINUTES","BOOKING_MAX_DURATION_MINUTES","BOOKING_MAX_DAYS_AHEAD","REMINDER_WINDOW_START_HOURS","REMINDER_WINDOW_END_HOURS","isBookingReminderDue","booking","nowMs","startsAtMs","Number","status","reminderSentAt","Boolean","email","BOOKING_SLOT_STEP_MINUTES","BOOKING_MAX_SLOTS_PER_DAY","BOOKING_SLOT_PAGE_DAYS","BOOKING_SLOT_PAGE_MAX_SLOTS","walkOpenSlots","service","fromMs","toMs","booked","take","timezone","duration","Math","min","max","round","durationMinutes","durationMs","horizonMs","stepMs","atMs","ceil","year","month","day","weekday","hour","minute","minutes","windows","fitsWindow","some","window","start","end","endMs","collides","interval","endsAtMs","stoppedAtMs","reachedEnd","computeOpenSlots","limit","slots","slot","push","length","computeOpenSlotPage","days","maxSlots","dayKeys","Set","dayKey","has","size","add","nextFromMs","isSlotOpen","BOOKING_PATH_DEFAULT","normalizeBookingPath","raw","text","String","trim","split","leading","startsWith","trimmed","replace","bookingLinkFor","input","origin","site","query","URLSearchParams","id","recordRef","set","path","toString"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/bookings.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Leaf paths, not the barrel: this model is read by the client bundle and\n// the `/server` entry alike, and either barrel would drag the other's\n// surface into a bundle that has no use for it.\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\n// The leaf entry: the package root extends the Firestore SDK's `Timestamp`,\n// and this model is read on published pages.\nimport { zonedDateTime } from '@aglyn/shared-util-timestamp/zoned-time'\nimport {\n BOOKING_RECORD_PARAM,\n BOOKING_SERVICE_PARAM,\n type BookingRecordRef,\n formatBookingRecordRef,\n} from './booking-record'\n\n/**\n * Bookings v1 (AGL-159): services with weekly availability windows and\n * pure, timezone-explicit slot computation. All times are minutes since\n * midnight in the HOST's timezone; instants are epoch milliseconds. No\n * I/O here — collision checks against stored bookings happen in the\n * booking API over the values these helpers produce.\n */\n\n/** `hosts/{hostId}/services/{id}` doc. */\nexport interface HostBookingService {\n name: string\n durationMinutes: number\n description?: string\n /** Optional price; 0/absent means free. */\n priceUsd?: number\n /**\n * How the price is stated (AGL-3475): `fixed` (absent) charges `priceUsd`;\n * `varies`, `estimate` and `contact` show their label and book with no\n * charge. Read through `bookingPriceDisplay` / `bookingChargeUsd`.\n */\n priceDisplay?: 'fixed' | 'varies' | 'estimate' | 'contact'\n /**\n * Weekly availability: `windows[weekday]` (0 = Sunday … 6 = Saturday)\n * lists open intervals in minutes since midnight, host-local.\n */\n windows?: Partial<Record<number, Array<{ start: number; end: number }>>>\n /** IANA timezone the windows are defined in, e.g. \"America/Chicago\". */\n timezone?: string\n /**\n * Create a follow-up task on the CRM record when this service is booked\n * (AGL-2660) — due one business day after the slot, on whoever holds the\n * relationship. Off unless the service says so.\n */\n crmFollowUpTask?: boolean\n /**\n * File the booking as a `meeting` on the CRM record's timeline (AGL-2660).\n * Absent means ON: a booking is a meeting the record has, and the service\n * has to opt out of saying so.\n */\n crmMeetingActivity?: boolean\n /**\n * Ask the booker for a phone number (AGL-3493): `optional` or `required`;\n * absent is off. Read through `bookingFieldAsk`.\n */\n askPhone?: 'off' | 'optional' | 'required'\n /** Ask the booker for the address the service is at (AGL-3493); as above. */\n askAddress?: 'off' | 'optional' | 'required'\n /**\n * Whether the service takes bookings (AGL-3616). A `draft` is set up and\n * offered nowhere: the public listing leaves it out, its slots are not\n * served and the booking route refuses it, until someone activates it in\n * the console. Absent is `active`, so every service made before the field\n * existed keeps taking bookings. Read through `bookingServiceStatus`.\n */\n status?: BookingServiceStatus\n}\n\n/** Whether a service takes bookings (AGL-3616). */\nexport type BookingServiceStatus = 'draft' | 'active'\n\n/** The status as stored, read so only an explicit `draft` holds a service back. */\nexport function bookingServiceStatus(value: unknown): BookingServiceStatus {\n return value === 'draft' ? 'draft' : 'active'\n}\n\n/**\n * Whether a stored service is offered to visitors (AGL-3616): not deleted,\n * and not a draft. The one predicate every public read asks — the service\n * directory, a service's slots, the booking route — and the console's\n * booking-link picker, which hands visitors a link to the same door.\n */\nexport function bookingServiceIsOffered(\n service: { deletedAt?: unknown; status?: unknown } | null | undefined,\n): boolean {\n return Boolean(service) && !service?.deletedAt && bookingServiceStatus(service?.status) === 'active'\n}\n\n/** Booked interval as epoch-ms instants. */\nexport interface BookedInterval {\n startsAtMs: number\n endsAtMs: number\n}\n\nexport const BOOKING_MIN_DURATION_MINUTES = 5\nexport const BOOKING_MAX_DURATION_MINUTES = 8 * 60\n/** Slot enumeration horizon; the console/API never look further out. */\nexport const BOOKING_MAX_DAYS_AHEAD = 60\n\n/**\n * The band a booking has to fall in to earn its 24-hour reminder, in hours\n * from now (AGL-160, scheduled in AGL-2431).\n *\n * IN THE MODEL, not beside the scan that uses them, so the console card can\n * apply the identical window without importing server code into a client\n * bundle. That sharing is the point rather than a convenience: a card that\n * drew its own boundary would report a queue depth the job does not act on,\n * and a merchant checking whether reminders are working would be reading a\n * number produced by different arithmetic than the sender's.\n */\nexport const REMINDER_WINDOW_START_HOURS = 23\nexport const REMINDER_WINDOW_END_HOURS = 25\n\n/**\n * Whether `booking` is one this pass would mail — the three tests the scan\n * applies, in one place both halves call.\n *\n * `nowMs` is a parameter and not `Date.now()` so the card and the job can be\n * asked the same question about the same instant, and so a test can pin a\n * booking exactly on either edge of the band.\n */\nexport function isBookingReminderDue(\n booking: {\n status?: string\n email?: string\n reminderSentAt?: unknown\n startsAtMs?: number\n },\n nowMs: number,\n): boolean {\n const startsAtMs = Number(booking.startsAtMs ?? 0)\n return (\n booking.status !== 'canceled' &&\n !booking.reminderSentAt &&\n Boolean(booking.email) &&\n startsAtMs >= nowMs + REMINDER_WINDOW_START_HOURS * 60 * 60 * 1000 &&\n startsAtMs <= nowMs + REMINDER_WINDOW_END_HOURS * 60 * 60 * 1000\n )\n}\n\nexport interface BookingSlot {\n startsAtMs: number\n endsAtMs: number\n}\n\n/** The walk's resolution: a slot may start on any quarter hour. */\nexport const BOOKING_SLOT_STEP_MINUTES = 15\n\n/**\n * The most starts one calendar day can hold at that resolution — 96. The\n * per-day unit every bound on a slot listing is counted in.\n */\nexport const BOOKING_MAX_SLOTS_PER_DAY =\n (24 * 60) / BOOKING_SLOT_STEP_MINUTES\n\n/**\n * Days of open times one slot listing answers with, and the length of the\n * Booking widget's day strip (AGL-3492). One constant, read by both halves:\n * the listing is bounded in DAYS so every day the strip shows carries every\n * open time it has. A flat slot count was the bound before, and at 15-minute\n * steps 120 of them ran out three and a half days in — the widget then drew\n * those days as the whole calendar and cut the fourth at lunch.\n */\nexport const BOOKING_SLOT_PAGE_DAYS = 14\n\n/**\n * The hard ceiling on one listing: a page of days, every one of them open\n * around the clock. Only a service that really is open that much reaches it,\n * and the page then ends early with its next page named, never truncated\n * silently.\n */\nexport const BOOKING_SLOT_PAGE_MAX_SLOTS =\n BOOKING_SLOT_PAGE_DAYS * BOOKING_MAX_SLOTS_PER_DAY\n\n/**\n * Walks the open slots of a service from `fromMs`, handing each to `take`\n * with the service-local day it falls on, until `take` answers `false` or\n * the walk reaches `toMs` (never more than BOOKING_MAX_DAYS_AHEAD past\n * `fromMs`). Answers where it stopped: the first start it did not hand over\n * — the slot `take` refused, or the first one past the walked range — and\n * whether that was `toMs` itself, so nothing is left to walk.\n *\n * Windows are read in the service timezone, 15 minutes at a time —\n * O(minutes/15) and immune to DST arithmetic because weekday and minutes\n * come from Intl per instant. Intervals overlapping `booked` drop out.\n */\nfunction walkOpenSlots(\n service: HostBookingService,\n fromMs: number,\n toMs: number,\n booked: BookedInterval[],\n take: (slot: BookingSlot, dayKey: string) => boolean,\n): { stoppedAtMs: number; reachedEnd: boolean } {\n const timezone = service.timezone || 'UTC'\n const duration = Math.min(\n Math.max(\n Math.round(service.durationMinutes || 0),\n BOOKING_MIN_DURATION_MINUTES,\n ),\n BOOKING_MAX_DURATION_MINUTES,\n )\n const durationMs = duration * 60_000\n const horizonMs = Math.min(\n toMs,\n fromMs + BOOKING_MAX_DAYS_AHEAD * 24 * 60 * 60_000,\n )\n const stepMs = BOOKING_SLOT_STEP_MINUTES * 60_000\n let atMs = Math.ceil(fromMs / stepMs) * stepMs\n for (; atMs + durationMs <= horizonMs; atMs += stepMs) {\n const { year, month, day, weekday, hour, minute } = zonedDateTime(\n atMs,\n timezone,\n )\n const minutes = hour * 60 + minute\n const windows = service.windows?.[weekday] ?? []\n const fitsWindow = windows.some(\n (window) =>\n minutes >= window.start && minutes + duration <= window.end,\n )\n if (!fitsWindow) continue\n const endMs = atMs + durationMs\n const collides = booked.some(\n (interval) =>\n atMs < interval.endsAtMs && endMs > interval.startsAtMs,\n )\n if (collides) continue\n if (!take({ startsAtMs: atMs, endsAtMs: endMs }, `${year}-${month}-${day}`)) {\n return { stoppedAtMs: atMs, reachedEnd: false }\n }\n }\n // The first start the walk did not try, not the horizon itself: a slot\n // that starts before the cap and ends after it belongs to the next walk.\n return { stoppedAtMs: atMs, reachedEnd: horizonMs >= toMs }\n}\n\n/**\n * Enumerates open slots for a service between two instants. Bounded: at\n * most `limit` slots, never beyond BOOKING_MAX_DAYS_AHEAD.\n */\nexport function computeOpenSlots(\n service: HostBookingService,\n fromMs: number,\n toMs: number,\n booked: BookedInterval[] = [],\n limit = 200,\n): BookingSlot[] {\n const slots: BookingSlot[] = []\n if (limit <= 0) return slots\n walkOpenSlots(service, fromMs, toMs, booked, (slot) => {\n slots.push(slot)\n return slots.length < limit\n })\n return slots\n}\n\n/** One page of a slot listing (AGL-3492). */\nexport interface BookingSlotPage {\n /** Every open slot of the page's days, in order. */\n slots: BookingSlot[]\n /**\n * Where the next page starts, or `null` once the page reached `toMs`.\n * Every open slot before it is in `slots` — the guarantee a reader needs\n * to tell a day it holds whole from one the page ended inside.\n */\n nextFromMs: number | null\n}\n\n/**\n * The open slots of the next `days` service-local days that have any, from\n * `fromMs` up to `toMs` — whole days, never a day cut part way through\n * (AGL-3492). `maxSlots` is the ceiling for a service open around the clock;\n * reaching it ends the page early with `nextFromMs` naming the first slot\n * left out. A walk is also never longer than BOOKING_MAX_DAYS_AHEAD, so a\n * horizon further out than that is answered a page at a time.\n */\nexport function computeOpenSlotPage(\n service: HostBookingService,\n fromMs: number,\n toMs: number,\n booked: BookedInterval[] = [],\n {\n days = BOOKING_SLOT_PAGE_DAYS,\n maxSlots = BOOKING_SLOT_PAGE_MAX_SLOTS,\n }: { days?: number; maxSlots?: number } = {},\n): BookingSlotPage {\n const slots: BookingSlot[] = []\n const dayKeys = new Set<string>()\n const { stoppedAtMs, reachedEnd } = walkOpenSlots(\n service,\n fromMs,\n toMs,\n booked,\n (slot, dayKey) => {\n if (\n slots.length >= maxSlots ||\n (!dayKeys.has(dayKey) && dayKeys.size >= days)\n ) {\n return false\n }\n dayKeys.add(dayKey)\n slots.push(slot)\n return true\n },\n )\n return {\n slots,\n nextFromMs: reachedEnd ? null : stoppedAtMs,\n }\n}\n\n/** True when the exact slot is open for the service (booking API check). */\nexport function isSlotOpen(\n service: HostBookingService,\n startsAtMs: number,\n booked: BookedInterval[] = [],\n): boolean {\n const slots = computeOpenSlots(\n service,\n startsAtMs,\n startsAtMs + BOOKING_MAX_DURATION_MINUTES * 60_000,\n booked,\n 1,\n )\n return slots.length > 0 && slots[0].startsAtMs === startsAtMs\n}\n\n/**\n * The page on the site that holds the Booking block, as the plugin's\n * `bookingPath` setting stores it (AGL-2660). The site root by default: a\n * site that put the block on its home page needs no setting, and a site\n * that put it elsewhere says where once, per site.\n */\nexport const BOOKING_PATH_DEFAULT = '/'\n\n/** `/book`, `book/`, ` /book?x ` → `/book`; anything empty → the root. */\nexport function normalizeBookingPath(raw: unknown): string {\n const text = String(raw ?? '')\n .trim()\n .split(/[?#]/)[0]\n if (!text) return BOOKING_PATH_DEFAULT\n const leading = text.startsWith('/') ? text : `/${text}`\n const trimmed = leading.replace(/\\/+$/, '')\n return trimmed || BOOKING_PATH_DEFAULT\n}\n\nexport interface BookingLinkInput {\n /** The host's public naming — its custom domain or its subdomain. */\n site: { cname?: string | null; subdomain?: string | null } | null | undefined\n /** The service the link opens the widget on. */\n service: { id: string }\n /** The site's `bookingPath` setting; the root when unset. */\n path?: string | null\n /** The record the link is dropped from, when there is one. */\n recordRef?: BookingRecordRef | null\n}\n\n/**\n * The public URL a visitor books a service at (AGL-2660), or `null` for a\n * site with no public origin yet.\n *\n * PER SERVICE, not per member: the model carries no staff or per-member\n * notion — a service has one calendar — so there is one link per service\n * and nothing narrower. The Bookings plugin renders no route of its own\n * either; the widget is a block the site owner placed on a page, so the\n * link is that page (the `bookingPath` setting) with the service\n * preselected and, when dropped from a record, the record carried\n * along so the booking can be attributed even when the booker uses a\n * different address.\n */\nexport function bookingLinkFor(input: BookingLinkInput): string | null {\n const origin = hostPublicOrigin(input.site)\n if (!origin) return null\n const query = new URLSearchParams({ [BOOKING_SERVICE_PARAM]: input.service.id })\n if (input.recordRef) query.set(BOOKING_RECORD_PARAM, formatBookingRecordRef(input.recordRef))\n return `${origin}${normalizeBookingPath(input.path)}?${query.toString()}`\n}\n"],"names":["hostPublicOrigin","zonedDateTime","BOOKING_RECORD_PARAM","BOOKING_SERVICE_PARAM","formatBookingRecordRef","bookingServiceStatus","value","bookingServiceIsOffered","service","Boolean","deletedAt","status","BOOKING_MIN_DURATION_MINUTES","BOOKING_MAX_DURATION_MINUTES","BOOKING_MAX_DAYS_AHEAD","REMINDER_WINDOW_START_HOURS","REMINDER_WINDOW_END_HOURS","isBookingReminderDue","booking","nowMs","startsAtMs","Number","reminderSentAt","email","BOOKING_SLOT_STEP_MINUTES","BOOKING_MAX_SLOTS_PER_DAY","BOOKING_SLOT_PAGE_DAYS","BOOKING_SLOT_PAGE_MAX_SLOTS","walkOpenSlots","fromMs","toMs","booked","take","timezone","duration","Math","min","max","round","durationMinutes","durationMs","horizonMs","stepMs","atMs","ceil","year","month","day","weekday","hour","minute","minutes","windows","fitsWindow","some","window","start","end","endMs","collides","interval","endsAtMs","stoppedAtMs","reachedEnd","computeOpenSlots","limit","slots","slot","push","length","computeOpenSlotPage","days","maxSlots","dayKeys","Set","dayKey","has","size","add","nextFromMs","isSlotOpen","BOOKING_PATH_DEFAULT","normalizeBookingPath","raw","text","String","trim","split","leading","startsWith","trimmed","replace","bookingLinkFor","input","origin","site","query","URLSearchParams","id","recordRef","set","path","toString"],"mappings":"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"}
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginApiHandler } from '@aglyn/aglyn/app-utils/api-plugins';
18
+ import { type HostBookingService } from '../model/bookings';
19
+ /** The Firestore surface these routes use: the Admin SDK's, narrowed so a spec can hand in a double. */
20
+ export interface ManageDocSnapshot {
21
+ readonly exists: boolean;
22
+ readonly id?: string;
23
+ get(field: string): unknown;
24
+ data(): Record<string, unknown> | undefined;
25
+ }
26
+ export interface ManageDocRef {
27
+ readonly id: string;
28
+ }
29
+ export interface ManageQuery {
30
+ where(field: string, op: '==' | '>=' | '<', value: unknown): ManageQuery;
31
+ limit(count: number): ManageQuery;
32
+ }
33
+ export interface ManageTransaction {
34
+ get(ref: ManageDocRef): Promise<ManageDocSnapshot>;
35
+ get(query: ManageQuery): Promise<{
36
+ docs: ManageDocSnapshot[];
37
+ }>;
38
+ update(ref: ManageDocRef, data: Record<string, unknown>): unknown;
39
+ }
40
+ export interface ManageFirestore {
41
+ doc(path: string): ManageDocRef & {
42
+ get(): Promise<ManageDocSnapshot>;
43
+ };
44
+ collection(path: string): ManageQuery;
45
+ runTransaction<T>(fn: (transaction: ManageTransaction) => Promise<T>): Promise<T>;
46
+ }
47
+ export interface RescheduleNotice {
48
+ hostId: string;
49
+ bookingId: string;
50
+ to: string;
51
+ name: string;
52
+ serviceName: string;
53
+ when: string;
54
+ previousWhen: string;
55
+ timezone: string;
56
+ }
57
+ export interface BookingManageDeps {
58
+ firestore: () => ManageFirestore;
59
+ verifyIdToken: (token: string) => Promise<{
60
+ uid: string;
61
+ }>;
62
+ /** True when the site is locked down and must not take writes. */
63
+ siteLocked: (hostId: string) => Promise<boolean>;
64
+ /** The zone a booking is told in when the document carries none. */
65
+ timeZoneFor: (hostId: string, service: HostBookingService | undefined) => Promise<string>;
66
+ notifyRescheduled: (notice: RescheduleNotice) => Promise<void>;
67
+ now: () => number;
68
+ }
69
+ /** Field the booking stamps when the guest arrives. */
70
+ export declare const CHECKED_IN_AT = "checkedInAtMs";
71
+ export declare const CHECKED_IN_BY = "checkedInBy";
72
+ export declare function createBookingCheckInHandler(deps: BookingManageDeps): PluginApiHandler;
73
+ export declare function createBookingRescheduleHandler(deps: BookingManageDeps): PluginApiHandler;
74
+ /** The email that tells a guest their booking moved, as the confirmation is sent. */
75
+ export declare function sendRescheduledNotice(notice: RescheduleNotice): Promise<void>;
76
+ /** The production wiring: the Admin SDK, the site lockdown verdict and the real send. */
77
+ export declare function bookingManageDeps(): BookingManageDeps;
@@ -0,0 +1,306 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { hostRoleCanWrite } from "@aglyn/aglyn/app-utils/organizations";
18
+ import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
19
+ import { firebaseAdmin, getOrgForHost, hostSendingIdentity, meterHostEmail, renderHostEmailWithTokens } from "@aglyn/tenant-data-admin";
20
+ import { getSiteLockdown } from "@aglyn/tenant-data-admin/server/tenant-write-lockdown";
21
+ import { resolveBrandingProfile } from "@aglyn/aglyn/server";
22
+ import { bookingDurationMs, checkInRefusal, rescheduleRefusal } from "../model/booking-manage.js";
23
+ import { isSlotOpen } from "../model/bookings.js";
24
+ import { bookingTimeZone, formatBookingWhen, storedBookingTimeZone } from "../model/booking-time.js";
25
+ import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
26
+ /** Field the booking stamps when the guest arrives. */ export const CHECKED_IN_AT = 'checkedInAtMs';
27
+ export const CHECKED_IN_BY = 'checkedInBy';
28
+ let Refusal = class Refusal extends Error {
29
+ constructor(status, message){
30
+ super(message), this.status = status;
31
+ }
32
+ };
33
+ const bearerOf = (req)=>{
34
+ var _ref, _req_headers_authorization;
35
+ const header = String((_ref = (_req_headers_authorization = req.headers['authorization']) != null ? _req_headers_authorization : req.headers['Authorization']) != null ? _ref : '');
36
+ return header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
37
+ };
38
+ const bodyOf = (req)=>{
39
+ var _req_body;
40
+ if (typeof req.body === 'string') {
41
+ try {
42
+ return JSON.parse(req.body);
43
+ } catch (unused) {
44
+ return {};
45
+ }
46
+ }
47
+ return (_req_body = req.body) != null ? _req_body : {};
48
+ };
49
+ /** The member, their right to write this site's bookings, and the site being open. */ async function authorize(deps, req, hostId) {
50
+ var _host_get;
51
+ const token = bearerOf(req);
52
+ if (!token) throw new Refusal(401, 'Unauthenticated');
53
+ let uid;
54
+ try {
55
+ uid = (await deps.verifyIdToken(token)).uid;
56
+ } catch (error) {
57
+ if (!isRefusedIdToken(error)) throw error;
58
+ throw new Refusal(401, 'Unauthenticated');
59
+ }
60
+ const host = await deps.firestore().doc(`hosts/${hostId}`).get();
61
+ if (!host.exists) throw new Refusal(404, 'Unknown site');
62
+ const role = ((_host_get = host.get('memberRoles')) != null ? _host_get : {})[uid];
63
+ if (!hostRoleCanWrite(role)) throw new Refusal(403, 'Your role on this site cannot change bookings');
64
+ if (await deps.siteLocked(hostId)) throw new Refusal(423, 'This site is locked and cannot take changes right now');
65
+ return {
66
+ uid
67
+ };
68
+ }
69
+ function answer(res, error) {
70
+ if (error instanceof Refusal) {
71
+ res.status(error.status).json({
72
+ error: error.message
73
+ });
74
+ return;
75
+ }
76
+ console.error('[bookings/manage]', error);
77
+ res.status(500).json({
78
+ error: 'That did not go through. Try again.'
79
+ });
80
+ }
81
+ export function createBookingCheckInHandler(deps) {
82
+ return async (req, res)=>{
83
+ var _body_hostId, _body_bookingId;
84
+ if (req.method !== 'POST') return res.status(405).json({
85
+ error: 'Method not allowed'
86
+ });
87
+ const body = bodyOf(req);
88
+ const hostId = String((_body_hostId = body['hostId']) != null ? _body_hostId : '');
89
+ const bookingId = String((_body_bookingId = body['bookingId']) != null ? _body_bookingId : '');
90
+ const checkedIn = body['checkedIn'] !== false;
91
+ if (!hostId || !bookingId) return res.status(400).json({
92
+ error: 'Missing hostId or bookingId'
93
+ });
94
+ try {
95
+ const { uid } = await authorize(deps, req, hostId);
96
+ const firestore = deps.firestore();
97
+ const ref = firestore.doc(`hosts/${hostId}/bookings/${bookingId}`);
98
+ const result = await firestore.runTransaction(async (transaction)=>{
99
+ var _snapshot_data, _booking_checkedInAtMs;
100
+ const snapshot = await transaction.get(ref);
101
+ if (!snapshot.exists) throw new Refusal(404, 'Unknown booking');
102
+ const booking = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
103
+ const nowMs = deps.now();
104
+ const refusal = checkInRefusal(booking, checkedIn, nowMs);
105
+ if (refusal) throw new Refusal(409, refusal);
106
+ const already = Number((_booking_checkedInAtMs = booking.checkedInAtMs) != null ? _booking_checkedInAtMs : 0) > 0;
107
+ // Already where it was asked to be: a retried tap changes nothing.
108
+ if (already === checkedIn) return {
109
+ checkedInAtMs: already ? Number(booking.checkedInAtMs) : null
110
+ };
111
+ if (checkedIn) {
112
+ transaction.update(ref, {
113
+ [CHECKED_IN_AT]: nowMs,
114
+ [CHECKED_IN_BY]: uid
115
+ });
116
+ return {
117
+ checkedInAtMs: nowMs
118
+ };
119
+ }
120
+ transaction.update(ref, {
121
+ [CHECKED_IN_AT]: null,
122
+ [CHECKED_IN_BY]: null
123
+ });
124
+ return {
125
+ checkedInAtMs: null
126
+ };
127
+ });
128
+ return res.status(200).json(_extends({
129
+ ok: true
130
+ }, result));
131
+ } catch (error) {
132
+ return answer(res, error);
133
+ }
134
+ };
135
+ }
136
+ export function createBookingRescheduleHandler(deps) {
137
+ return async (req, res)=>{
138
+ var _body_hostId, _body_bookingId;
139
+ if (req.method !== 'POST') return res.status(405).json({
140
+ error: 'Method not allowed'
141
+ });
142
+ const body = bodyOf(req);
143
+ const hostId = String((_body_hostId = body['hostId']) != null ? _body_hostId : '');
144
+ const bookingId = String((_body_bookingId = body['bookingId']) != null ? _body_bookingId : '');
145
+ const startsAtMs = Number(body['startsAtMs']);
146
+ if (!hostId || !bookingId) return res.status(400).json({
147
+ error: 'Missing hostId or bookingId'
148
+ });
149
+ if (!Number.isFinite(startsAtMs) || startsAtMs <= 0) return res.status(400).json({
150
+ error: 'Pick a new time'
151
+ });
152
+ try {
153
+ var _first_get, _moved_booking_email;
154
+ const { uid } = await authorize(deps, req, hostId);
155
+ const firestore = deps.firestore();
156
+ const ref = firestore.doc(`hosts/${hostId}/bookings/${bookingId}`);
157
+ const first = await ref.get();
158
+ if (!first.exists) throw new Refusal(404, 'Unknown booking');
159
+ const serviceId = String((_first_get = first.get('serviceId')) != null ? _first_get : '');
160
+ const serviceSnapshot = serviceId ? await firestore.doc(`hosts/${hostId}/services/${serviceId}`).get() : null;
161
+ const service = (serviceSnapshot == null ? void 0 : serviceSnapshot.exists) ? serviceSnapshot.data() : undefined;
162
+ if (!service || (serviceSnapshot == null ? void 0 : serviceSnapshot.get('deletedAt'))) {
163
+ throw new Refusal(409, 'This booking’s service no longer exists, so it cannot move');
164
+ }
165
+ const moved = await firestore.runTransaction(async (transaction)=>{
166
+ var _snapshot_data, _booking_startsAtMs;
167
+ const snapshot = await transaction.get(ref);
168
+ if (!snapshot.exists) throw new Refusal(404, 'Unknown booking');
169
+ const booking = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
170
+ const nowMs = deps.now();
171
+ const refusal = rescheduleRefusal(booking, startsAtMs, nowMs);
172
+ if (refusal) throw new Refusal(409, refusal);
173
+ const previousStartsAtMs = Number((_booking_startsAtMs = booking.startsAtMs) != null ? _booking_startsAtMs : 0);
174
+ // Already at that time: a retried move writes nothing and tells no one twice.
175
+ if (previousStartsAtMs === startsAtMs) return null;
176
+ const durationMs = bookingDurationMs(booking, service.durationMinutes);
177
+ // The public booking route's own read: every booking of this service
178
+ // from a day before the new time, so one running into it counts.
179
+ const nearby = await transaction.get(firestore.collection(`hosts/${hostId}/bookings`).where('serviceId', '==', serviceId).where('startsAtMs', '>=', startsAtMs - 24 * 60 * 60000).limit(500));
180
+ const booked = nearby.docs.filter((doc)=>doc.id !== bookingId).filter((doc)=>{
181
+ var _doc_get;
182
+ const status = doc.get('status');
183
+ if (status === 'canceled') return false;
184
+ return !(status === 'pendingPayment' && Number((_doc_get = doc.get('expiresAtMs')) != null ? _doc_get : 0) < nowMs);
185
+ }).map((doc)=>{
186
+ var _doc_get, _doc_get1;
187
+ return {
188
+ startsAtMs: Number((_doc_get = doc.get('startsAtMs')) != null ? _doc_get : 0),
189
+ endsAtMs: Number((_doc_get1 = doc.get('endsAtMs')) != null ? _doc_get1 : 0)
190
+ };
191
+ });
192
+ if (!isSlotOpen(service, startsAtMs, booked)) throw new Refusal(409, 'That time is not open. Pick another.');
193
+ const endsAtMs = startsAtMs + durationMs;
194
+ transaction.update(ref, {
195
+ startsAtMs,
196
+ endsAtMs,
197
+ rescheduledAtMs: nowMs,
198
+ rescheduledBy: uid,
199
+ rescheduledFromMs: previousStartsAtMs,
200
+ // The reminder is owed again, for the new time.
201
+ reminderSentAt: null
202
+ });
203
+ return {
204
+ booking,
205
+ previousStartsAtMs,
206
+ endsAtMs
207
+ };
208
+ });
209
+ if (!moved) {
210
+ var _first_get1;
211
+ return res.status(200).json({
212
+ ok: true,
213
+ startsAtMs,
214
+ endsAtMs: Number((_first_get1 = first.get('endsAtMs')) != null ? _first_get1 : 0),
215
+ notified: false
216
+ });
217
+ }
218
+ const to = String((_moved_booking_email = moved.booking['email']) != null ? _moved_booking_email : '').trim();
219
+ let notified = false;
220
+ if (to) {
221
+ var _storedBookingTimeZone;
222
+ const timezone = (_storedBookingTimeZone = storedBookingTimeZone({
223
+ timezone: moved.booking['timezone']
224
+ })) != null ? _storedBookingTimeZone : await deps.timeZoneFor(hostId, service);
225
+ try {
226
+ var _moved_booking_name, _ref, _moved_booking_serviceName;
227
+ await deps.notifyRescheduled({
228
+ hostId,
229
+ bookingId,
230
+ to,
231
+ name: String((_moved_booking_name = moved.booking['name']) != null ? _moved_booking_name : ''),
232
+ serviceName: String((_ref = (_moved_booking_serviceName = moved.booking['serviceName']) != null ? _moved_booking_serviceName : service.name) != null ? _ref : ''),
233
+ when: formatBookingWhen(startsAtMs, timezone),
234
+ previousWhen: formatBookingWhen(moved.previousStartsAtMs, timezone),
235
+ timezone
236
+ });
237
+ notified = true;
238
+ } catch (error) {
239
+ // The booking has moved whatever the mail did; the screen says the guest was not told.
240
+ console.error('[bookings/reschedule] notice failed', hostId, bookingId, error);
241
+ }
242
+ }
243
+ return res.status(200).json({
244
+ ok: true,
245
+ startsAtMs,
246
+ endsAtMs: moved.endsAtMs,
247
+ notified
248
+ });
249
+ } catch (error) {
250
+ return answer(res, error);
251
+ }
252
+ };
253
+ }
254
+ /** The email that tells a guest their booking moved, as the confirmation is sent. */ export async function sendRescheduledNotice(notice) {
255
+ var _ref;
256
+ var _this;
257
+ if (!isEmailConfigured()) throw new Error('Email is not configured');
258
+ const firestore = firebaseAdmin.app().firestore();
259
+ const fallbackText = `Hi ${notice.name},\n\nYour booking for "${notice.serviceName}" is now on ` + `${notice.when} (${notice.timezone}). It was on ${notice.previousWhen}.\n\n` + `Reference: ${notice.bookingId}`;
260
+ const designed = await renderHostEmailWithTokens(firestore, notice.hostId, 'booking-rescheduled', {
261
+ name: notice.name,
262
+ 'service.name': notice.serviceName,
263
+ when: notice.when,
264
+ previousWhen: notice.previousWhen,
265
+ timezone: notice.timezone,
266
+ 'booking.ref': notice.bookingId
267
+ });
268
+ const branding = resolveBrandingProfile((_this = await getOrgForHost(notice.hostId).catch(()=>null)) == null ? void 0 : _this.org);
269
+ await sendEmail(_extends({
270
+ to: notice.to,
271
+ subject: (_ref = designed == null ? void 0 : designed.subject) != null ? _ref : `New time for ${notice.serviceName}`,
272
+ text: (designed == null ? void 0 : designed.text) || fallbackText
273
+ }, (designed == null ? void 0 : designed.html) ? {
274
+ html: designed.html
275
+ } : {}, {
276
+ fromName: branding.fromName,
277
+ sendingIdentity: await hostSendingIdentity(notice.hostId),
278
+ audience: 'tenant',
279
+ context: 'booking rescheduled',
280
+ owedFor: 'booking'
281
+ }));
282
+ await meterHostEmail(notice.hostId);
283
+ }
284
+ /** The production wiring: the Admin SDK, the site lockdown verdict and the real send. */ export function bookingManageDeps() {
285
+ return {
286
+ firestore: ()=>firebaseAdmin.app().firestore(),
287
+ verifyIdToken: (token)=>firebaseAdmin.app().auth().verifyIdToken(token),
288
+ siteLocked: async (hostId)=>Boolean(await getSiteLockdown(hostId)),
289
+ timeZoneFor: async (hostId, service)=>{
290
+ const firestore = firebaseAdmin.app().firestore();
291
+ const [host, owner] = await Promise.all([
292
+ firestore.collection('hosts').doc(hostId).get(),
293
+ getOrgForHost(hostId).catch(()=>null)
294
+ ]);
295
+ return bookingTimeZone({
296
+ service: service != null ? service : null,
297
+ host: host.data(),
298
+ org: owner == null ? void 0 : owner.org
299
+ });
300
+ },
301
+ notifyRescheduled: sendRescheduledNotice,
302
+ now: ()=>Date.now()
303
+ };
304
+ }
305
+
306
+ //# sourceMappingURL=booking-manage.js.map