@aglyn/plugins-bookings 1.0.0-beta.221 → 1.0.0-beta.222
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.
- package/README.md +3 -2
- package/package.json +11 -11
- package/src/lib/components/book-meeting-action.js +5 -3
- package/src/lib/components/book-meeting-action.js.map +1 -1
- package/src/lib/components/booking.d.ts +33 -0
- package/src/lib/components/booking.js +260 -54
- package/src/lib/components/booking.js.map +1 -1
- package/src/lib/components/bookings-console-page.js +139 -15
- package/src/lib/components/bookings-console-page.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +34 -0
- package/src/lib/declarations.console-server.js +84 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/model/booking-contact-fields.d.ts +88 -0
- package/src/lib/model/booking-contact-fields.js +130 -0
- package/src/lib/model/booking-contact-fields.js.map +1 -0
- package/src/lib/model/booking-price.d.ts +51 -0
- package/src/lib/model/booking-price.js +61 -0
- package/src/lib/model/booking-price.js.map +1 -0
- package/src/lib/model/booking-record.d.ts +7 -0
- package/src/lib/model/booking-record.js +11 -2
- package/src/lib/model/booking-record.js.map +1 -1
- package/src/lib/model/bookings-record-counts.d.ts +25 -0
- package/src/lib/model/bookings-record-counts.js +34 -0
- package/src/lib/model/bookings-record-counts.js.map +1 -0
- package/src/lib/model/bookings.d.ts +61 -4
- package/src/lib/model/bookings.js +81 -15
- package/src/lib/model/bookings.js.map +1 -1
- package/src/lib/model/index.d.ts +2 -0
- package/src/lib/model/index.js +2 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/plugin.js +14 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/billing-webhook.js +33 -15
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/booking-crm.js +4 -1
- package/src/lib/server/booking-crm.js.map +1 -1
- package/src/lib/server/person-eraser.d.ts +36 -0
- package/src/lib/server/person-eraser.js +48 -0
- package/src/lib/server/person-eraser.js.map +1 -0
- package/src/lib/server/refund.js +2 -2
- package/src/lib/server/refund.js.map +1 -1
- package/src/lib/server.js +97 -36
- package/src/lib/server.js.map +1 -1
- package/src/lib/transfer/bookings-transfer-common.d.ts +68 -0
- package/src/lib/transfer/bookings-transfer-common.js +130 -0
- package/src/lib/transfer/bookings-transfer-common.js.map +1 -0
- package/src/lib/transfer/bookings-transfer.d.ts +86 -0
- package/src/lib/transfer/bookings-transfer.js +613 -0
- package/src/lib/transfer/bookings-transfer.js.map +1 -0
- package/src/lib/transfer/bookings-transfer.server.d.ts +26 -0
- package/src/lib/transfer/bookings-transfer.server.js +31 -0
- package/src/lib/transfer/bookings-transfer.server.js.map +1 -0
|
@@ -29,6 +29,12 @@ export interface HostBookingService {
|
|
|
29
29
|
description?: string;
|
|
30
30
|
/** Optional price; 0/absent means free. */
|
|
31
31
|
priceUsd?: number;
|
|
32
|
+
/**
|
|
33
|
+
* How the price is stated (AGL-3475): `fixed` (absent) charges `priceUsd`;
|
|
34
|
+
* `varies`, `estimate` and `contact` show their label and book with no
|
|
35
|
+
* charge. Read through `bookingPriceDisplay` / `bookingChargeUsd`.
|
|
36
|
+
*/
|
|
37
|
+
priceDisplay?: 'fixed' | 'varies' | 'estimate' | 'contact';
|
|
32
38
|
/**
|
|
33
39
|
* Weekly availability: `windows[weekday]` (0 = Sunday … 6 = Saturday)
|
|
34
40
|
* lists open intervals in minutes since midnight, host-local.
|
|
@@ -51,6 +57,13 @@ export interface HostBookingService {
|
|
|
51
57
|
* has to opt out of saying so.
|
|
52
58
|
*/
|
|
53
59
|
crmMeetingActivity?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Ask the booker for a phone number (AGL-3493): `optional` or `required`;
|
|
62
|
+
* absent is off. Read through `bookingFieldAsk`.
|
|
63
|
+
*/
|
|
64
|
+
askPhone?: 'off' | 'optional' | 'required';
|
|
65
|
+
/** Ask the booker for the address the service is at (AGL-3493); as above. */
|
|
66
|
+
askAddress?: 'off' | 'optional' | 'required';
|
|
54
67
|
}
|
|
55
68
|
/** Booked interval as epoch-ms instants. */
|
|
56
69
|
export interface BookedInterval {
|
|
@@ -92,13 +105,57 @@ export interface BookingSlot {
|
|
|
92
105
|
startsAtMs: number;
|
|
93
106
|
endsAtMs: number;
|
|
94
107
|
}
|
|
108
|
+
/** The walk's resolution: a slot may start on any quarter hour. */
|
|
109
|
+
export declare const BOOKING_SLOT_STEP_MINUTES = 15;
|
|
110
|
+
/**
|
|
111
|
+
* The most starts one calendar day can hold at that resolution — 96. The
|
|
112
|
+
* per-day unit every bound on a slot listing is counted in.
|
|
113
|
+
*/
|
|
114
|
+
export declare const BOOKING_MAX_SLOTS_PER_DAY: number;
|
|
115
|
+
/**
|
|
116
|
+
* Days of open times one slot listing answers with, and the length of the
|
|
117
|
+
* Booking widget's day strip (AGL-3492). One constant, read by both halves:
|
|
118
|
+
* the listing is bounded in DAYS so every day the strip shows carries every
|
|
119
|
+
* open time it has. A flat slot count was the bound before, and at 15-minute
|
|
120
|
+
* steps 120 of them ran out three and a half days in — the widget then drew
|
|
121
|
+
* those days as the whole calendar and cut the fourth at lunch.
|
|
122
|
+
*/
|
|
123
|
+
export declare const BOOKING_SLOT_PAGE_DAYS = 14;
|
|
95
124
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
125
|
+
* The hard ceiling on one listing: a page of days, every one of them open
|
|
126
|
+
* around the clock. Only a service that really is open that much reaches it,
|
|
127
|
+
* and the page then ends early with its next page named, never truncated
|
|
128
|
+
* silently.
|
|
129
|
+
*/
|
|
130
|
+
export declare const BOOKING_SLOT_PAGE_MAX_SLOTS: number;
|
|
131
|
+
/**
|
|
132
|
+
* Enumerates open slots for a service between two instants. Bounded: at
|
|
133
|
+
* most `limit` slots, never beyond BOOKING_MAX_DAYS_AHEAD.
|
|
100
134
|
*/
|
|
101
135
|
export declare function computeOpenSlots(service: HostBookingService, fromMs: number, toMs: number, booked?: BookedInterval[], limit?: number): BookingSlot[];
|
|
136
|
+
/** One page of a slot listing (AGL-3492). */
|
|
137
|
+
export interface BookingSlotPage {
|
|
138
|
+
/** Every open slot of the page's days, in order. */
|
|
139
|
+
slots: BookingSlot[];
|
|
140
|
+
/**
|
|
141
|
+
* Where the next page starts, or `null` once the page reached `toMs`.
|
|
142
|
+
* Every open slot before it is in `slots` — the guarantee a reader needs
|
|
143
|
+
* to tell a day it holds whole from one the page ended inside.
|
|
144
|
+
*/
|
|
145
|
+
nextFromMs: number | null;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The open slots of the next `days` service-local days that have any, from
|
|
149
|
+
* `fromMs` up to `toMs` — whole days, never a day cut part way through
|
|
150
|
+
* (AGL-3492). `maxSlots` is the ceiling for a service open around the clock;
|
|
151
|
+
* reaching it ends the page early with `nextFromMs` naming the first slot
|
|
152
|
+
* left out. A walk is also never longer than BOOKING_MAX_DAYS_AHEAD, so a
|
|
153
|
+
* horizon further out than that is answered a page at a time.
|
|
154
|
+
*/
|
|
155
|
+
export declare function computeOpenSlotPage(service: HostBookingService, fromMs: number, toMs: number, booked?: BookedInterval[], { days, maxSlots, }?: {
|
|
156
|
+
days?: number;
|
|
157
|
+
maxSlots?: number;
|
|
158
|
+
}): BookingSlotPage;
|
|
102
159
|
/** True when the exact slot is open for the service (booking API check). */
|
|
103
160
|
export declare function isSlotOpen(service: HostBookingService, startsAtMs: number, booked?: BookedInterval[]): boolean;
|
|
104
161
|
/**
|
|
@@ -48,26 +48,47 @@ export const REMINDER_WINDOW_END_HOURS = 25;
|
|
|
48
48
|
const startsAtMs = Number((_booking_startsAtMs = booking.startsAtMs) != null ? _booking_startsAtMs : 0);
|
|
49
49
|
return booking.status !== 'canceled' && !booking.reminderSentAt && Boolean(booking.email) && startsAtMs >= nowMs + REMINDER_WINDOW_START_HOURS * 60 * 60 * 1000 && startsAtMs <= nowMs + REMINDER_WINDOW_END_HOURS * 60 * 60 * 1000;
|
|
50
50
|
}
|
|
51
|
+
/** The walk's resolution: a slot may start on any quarter hour. */ export const BOOKING_SLOT_STEP_MINUTES = 15;
|
|
51
52
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
* The most starts one calendar day can hold at that resolution — 96. The
|
|
54
|
+
* per-day unit every bound on a slot listing is counted in.
|
|
55
|
+
*/ export const BOOKING_MAX_SLOTS_PER_DAY = 24 * 60 / BOOKING_SLOT_STEP_MINUTES;
|
|
56
|
+
/**
|
|
57
|
+
* Days of open times one slot listing answers with, and the length of the
|
|
58
|
+
* Booking widget's day strip (AGL-3492). One constant, read by both halves:
|
|
59
|
+
* the listing is bounded in DAYS so every day the strip shows carries every
|
|
60
|
+
* open time it has. A flat slot count was the bound before, and at 15-minute
|
|
61
|
+
* steps 120 of them ran out three and a half days in — the widget then drew
|
|
62
|
+
* those days as the whole calendar and cut the fourth at lunch.
|
|
63
|
+
*/ export const BOOKING_SLOT_PAGE_DAYS = 14;
|
|
64
|
+
/**
|
|
65
|
+
* The hard ceiling on one listing: a page of days, every one of them open
|
|
66
|
+
* around the clock. Only a service that really is open that much reaches it,
|
|
67
|
+
* and the page then ends early with its next page named, never truncated
|
|
68
|
+
* silently.
|
|
69
|
+
*/ export const BOOKING_SLOT_PAGE_MAX_SLOTS = BOOKING_SLOT_PAGE_DAYS * BOOKING_MAX_SLOTS_PER_DAY;
|
|
70
|
+
/**
|
|
71
|
+
* Walks the open slots of a service from `fromMs`, handing each to `take`
|
|
72
|
+
* with the service-local day it falls on, until `take` answers `false` or
|
|
73
|
+
* the walk reaches `toMs` (never more than BOOKING_MAX_DAYS_AHEAD past
|
|
74
|
+
* `fromMs`). Answers where it stopped: the first start it did not hand over
|
|
75
|
+
* — the slot `take` refused, or the first one past the walked range — and
|
|
76
|
+
* whether that was `toMs` itself, so nothing is left to walk.
|
|
77
|
+
*
|
|
78
|
+
* Windows are read in the service timezone, 15 minutes at a time —
|
|
79
|
+
* O(minutes/15) and immune to DST arithmetic because weekday and minutes
|
|
80
|
+
* come from Intl per instant. Intervals overlapping `booked` drop out.
|
|
81
|
+
*/ function walkOpenSlots(service, fromMs, toMs, booked, take) {
|
|
57
82
|
const timezone = service.timezone || 'UTC';
|
|
58
83
|
const duration = Math.min(Math.max(Math.round(service.durationMinutes || 0), BOOKING_MIN_DURATION_MINUTES), BOOKING_MAX_DURATION_MINUTES);
|
|
59
84
|
const durationMs = duration * 60000;
|
|
60
85
|
const horizonMs = Math.min(toMs, fromMs + BOOKING_MAX_DAYS_AHEAD * 24 * 60 * 60000);
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// minutes come from Intl per instant.
|
|
65
|
-
const stepMs = 15 * 60000;
|
|
66
|
-
const alignedFrom = Math.ceil(fromMs / stepMs) * stepMs;
|
|
67
|
-
for(let atMs = alignedFrom; atMs + durationMs <= horizonMs && slots.length < limit; atMs += stepMs){
|
|
86
|
+
const stepMs = BOOKING_SLOT_STEP_MINUTES * 60000;
|
|
87
|
+
let atMs = Math.ceil(fromMs / stepMs) * stepMs;
|
|
88
|
+
for(; atMs + durationMs <= horizonMs; atMs += stepMs){
|
|
68
89
|
var _ref;
|
|
69
90
|
var _service_windows;
|
|
70
|
-
const { weekday, hour, minute } = zonedDateTime(atMs, timezone);
|
|
91
|
+
const { year, month, day, weekday, hour, minute } = zonedDateTime(atMs, timezone);
|
|
71
92
|
const minutes = hour * 60 + minute;
|
|
72
93
|
const windows = (_ref = (_service_windows = service.windows) == null ? void 0 : _service_windows[weekday]) != null ? _ref : [];
|
|
73
94
|
const fitsWindow = windows.some((window)=>minutes >= window.start && minutes + duration <= window.end);
|
|
@@ -75,13 +96,58 @@ export const REMINDER_WINDOW_END_HOURS = 25;
|
|
|
75
96
|
const endMs = atMs + durationMs;
|
|
76
97
|
const collides = booked.some((interval)=>atMs < interval.endsAtMs && endMs > interval.startsAtMs);
|
|
77
98
|
if (collides) continue;
|
|
78
|
-
|
|
99
|
+
if (!take({
|
|
79
100
|
startsAtMs: atMs,
|
|
80
101
|
endsAtMs: endMs
|
|
81
|
-
})
|
|
102
|
+
}, `${year}-${month}-${day}`)) {
|
|
103
|
+
return {
|
|
104
|
+
stoppedAtMs: atMs,
|
|
105
|
+
reachedEnd: false
|
|
106
|
+
};
|
|
107
|
+
}
|
|
82
108
|
}
|
|
109
|
+
// The first start the walk did not try, not the horizon itself: a slot
|
|
110
|
+
// that starts before the cap and ends after it belongs to the next walk.
|
|
111
|
+
return {
|
|
112
|
+
stoppedAtMs: atMs,
|
|
113
|
+
reachedEnd: horizonMs >= toMs
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Enumerates open slots for a service between two instants. Bounded: at
|
|
118
|
+
* most `limit` slots, never beyond BOOKING_MAX_DAYS_AHEAD.
|
|
119
|
+
*/ export function computeOpenSlots(service, fromMs, toMs, booked = [], limit = 200) {
|
|
120
|
+
const slots = [];
|
|
121
|
+
if (limit <= 0) return slots;
|
|
122
|
+
walkOpenSlots(service, fromMs, toMs, booked, (slot)=>{
|
|
123
|
+
slots.push(slot);
|
|
124
|
+
return slots.length < limit;
|
|
125
|
+
});
|
|
83
126
|
return slots;
|
|
84
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* The open slots of the next `days` service-local days that have any, from
|
|
130
|
+
* `fromMs` up to `toMs` — whole days, never a day cut part way through
|
|
131
|
+
* (AGL-3492). `maxSlots` is the ceiling for a service open around the clock;
|
|
132
|
+
* reaching it ends the page early with `nextFromMs` naming the first slot
|
|
133
|
+
* left out. A walk is also never longer than BOOKING_MAX_DAYS_AHEAD, so a
|
|
134
|
+
* horizon further out than that is answered a page at a time.
|
|
135
|
+
*/ export function computeOpenSlotPage(service, fromMs, toMs, booked = [], { days = BOOKING_SLOT_PAGE_DAYS, maxSlots = BOOKING_SLOT_PAGE_MAX_SLOTS } = {}) {
|
|
136
|
+
const slots = [];
|
|
137
|
+
const dayKeys = new Set();
|
|
138
|
+
const { stoppedAtMs, reachedEnd } = walkOpenSlots(service, fromMs, toMs, booked, (slot, dayKey)=>{
|
|
139
|
+
if (slots.length >= maxSlots || !dayKeys.has(dayKey) && dayKeys.size >= days) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
dayKeys.add(dayKey);
|
|
143
|
+
slots.push(slot);
|
|
144
|
+
return true;
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
slots,
|
|
148
|
+
nextFromMs: reachedEnd ? null : stoppedAtMs
|
|
149
|
+
};
|
|
150
|
+
}
|
|
85
151
|
/** True when the exact slot is open for the service (booking API check). */ export function isSlotOpen(service, startsAtMs, booked = []) {
|
|
86
152
|
const slots = computeOpenSlots(service, startsAtMs, startsAtMs + BOOKING_MAX_DURATION_MINUTES * 60000, booked, 1);
|
|
87
153
|
return slots.length > 0 && slots[0].startsAtMs === startsAtMs;
|
|
@@ -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 * 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\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/**\n * Enumerates open slots for a service between two instants: windows are\n * walked in the service timezone, quantized to the service duration, and\n * intervals overlapping `booked` (or starting before `fromMs`) drop out.\n * Bounded: at 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 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 slots: BookingSlot[] = []\n // Walk in 15-minute steps and keep instants that start a window-aligned\n // slot — O(minutes/15) and immune to DST arithmetic because weekday and\n // minutes come from Intl per instant.\n const stepMs = 15 * 60_000\n const alignedFrom = Math.ceil(fromMs / stepMs) * stepMs\n for (\n let atMs = alignedFrom;\n atMs + durationMs <= horizonMs && slots.length < limit;\n atMs += stepMs\n ) {\n const { weekday, hour, minute } = zonedDateTime(atMs, timezone)\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 slots.push({ startsAtMs: atMs, endsAtMs: endMs })\n }\n return slots\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","computeOpenSlots","service","fromMs","toMs","booked","limit","timezone","duration","Math","min","max","round","durationMinutes","durationMs","horizonMs","slots","stepMs","alignedFrom","ceil","atMs","length","weekday","hour","minute","minutes","windows","fitsWindow","some","window","start","end","endMs","collides","interval","endsAtMs","push","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\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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"}
|
package/src/lib/model/index.d.ts
CHANGED
package/src/lib/model/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/index.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 * Bookings domain model (AGL-412): slot computation relocated from core app-utils.\n * Context-free — importable by client components, /server handlers, and\n * other plugins/apps via `@aglyn/plugins-bookings/model`.\n */\nexport * from './bookings'\nexport * from './booking-record'\nexport * from './booking-time'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;CAIC,GACD,cAAc,gBAAY;AAC1B,cAAc,sBAAkB;AAChC,cAAc,oBAAgB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/index.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 * Bookings domain model (AGL-412): slot computation relocated from core app-utils.\n * Context-free — importable by client components, /server handlers, and\n * other plugins/apps via `@aglyn/plugins-bookings/model`.\n */\nexport * from './bookings'\nexport * from './booking-record'\nexport * from './booking-time'\nexport * from './booking-contact-fields'\nexport * from './booking-price'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;CAIC,GACD,cAAc,gBAAY;AAC1B,cAAc,sBAAkB;AAChC,cAAc,oBAAgB;AAC9B,cAAc,8BAA0B;AACxC,cAAc,qBAAiB"}
|
package/src/lib/plugin.js
CHANGED
|
@@ -14,12 +14,15 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import * as Aglyn from "@aglyn/aglyn";
|
|
17
|
+
import { registerPluginTransferResourceUi } from "@aglyn/aglyn/plugin-manager/plugin-transfer-resources";
|
|
17
18
|
import { mdiCalendarClock } from "@aglyn/shared-data-mdi";
|
|
18
19
|
import { lazy } from "react";
|
|
19
20
|
import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
21
|
+
import { registerBookingsRecordCounts } from "./model/bookings-record-counts.js";
|
|
20
22
|
import { registerBookingsRecordRoutes } from "./model/bookings-record-routes.js";
|
|
21
23
|
import { BOOKINGS_SEARCH_SOURCES } from "./model/bookings-search-sources.js";
|
|
22
24
|
import { BOOKINGS_CONFIG_SCHEMA } from "./plugin-config.js";
|
|
25
|
+
import { BOOKINGS_TRANSFER_RESOURCE } from "./transfer/bookings-transfer-common.js";
|
|
23
26
|
/** Code-split: the Bookings console page only loads when opened. */ const BookingsConsolePage = lazy(()=>import("./components/bookings-console-page.js"));
|
|
24
27
|
/** "Book a meeting", loaded where a record page draws the zone for it. */ const BookMeetingButton = lazy(()=>import("./components/book-meeting-action.js"));
|
|
25
28
|
/**
|
|
@@ -27,9 +30,20 @@ import { BOOKINGS_CONFIG_SCHEMA } from "./plugin-config.js";
|
|
|
27
30
|
* at console app load — the page is lazy (no besigner/canvas code).
|
|
28
31
|
*/ export function registerBookingsConsole() {
|
|
29
32
|
registerBookingsRecordRoutes();
|
|
33
|
+
// How many bookings a site took, counted for another plugin's figure.
|
|
34
|
+
registerBookingsRecordCounts();
|
|
30
35
|
// Per-plugin settings (AGL-428): the schema powers the generic form on
|
|
31
36
|
// the Plugins & add-ons hub and defaults-merged reads everywhere.
|
|
32
37
|
Aglyn.registerPluginConfigSchema(BOOKINGS_CONFIG_SCHEMA);
|
|
38
|
+
// The export dialog's name for the site's bookings; exported, never imported.
|
|
39
|
+
registerPluginTransferResourceUi(BOOKINGS_TRANSFER_RESOURCE, {
|
|
40
|
+
label: 'Bookings',
|
|
41
|
+
icon: {
|
|
42
|
+
path: mdiCalendarClock.path
|
|
43
|
+
}
|
|
44
|
+
}, {
|
|
45
|
+
pluginId: BUNDLE_ID
|
|
46
|
+
});
|
|
33
47
|
Aglyn.registerConsoleExtension({
|
|
34
48
|
pluginId: BUNDLE_ID,
|
|
35
49
|
displayName: 'Bookings',
|
package/src/lib/plugin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/bookings/src/lib/plugin.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\nimport * as Aglyn from '@aglyn/aglyn'\nimport { mdiCalendarClock } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { registerBookingsRecordRoutes } from './model/bookings-record-routes'\nimport { BOOKINGS_SEARCH_SOURCES } from './model/bookings-search-sources'\nimport { BOOKINGS_CONFIG_SCHEMA } from './plugin-config'\n\n/** Code-split: the Bookings console page only loads when opened. */\nconst BookingsConsolePage = lazy(\n () => import('./components/bookings-console-page'),\n)\n\n/** \"Book a meeting\", loaded where a record page draws the zone for it. */\nconst BookMeetingButton = lazy(() => import('./components/book-meeting-action'))\n\n/**\n * Console half only: registers the Bookings nav item + page. Safe to call\n * at console app load — the page is lazy (no besigner/canvas code).\n */\nexport function registerBookingsConsole(): void {\n registerBookingsRecordRoutes()\n // Per-plugin settings (AGL-428): the schema powers the generic form on\n // the Plugins & add-ons hub and defaults-merged reads everywhere.\n Aglyn.registerPluginConfigSchema(BOOKINGS_CONFIG_SCHEMA)\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Bookings',\n featureFlag: 'bookings',\n searchSources: BOOKINGS_SEARCH_SOURCES,\n // The control a CRM record carries for booking a meeting with the person.\n // The CRM hosts the zone and hands it the record; the services, the page\n // they are booked on and the link are this plugin's, so the control is\n // too. It draws nothing where this site takes no bookings.\n widgets: [\n {\n slot: 'crmRecordBooking',\n widgetId: 'bookings-book-meeting',\n title: 'Book a meeting',\n Component: BookMeetingButton,\n },\n ],\n navItems: [\n {\n label: 'Bookings',\n href: '/bookings',\n // Reuse the existing release-flag nav-tab so staff-preview gating is\n // unchanged now that the tab comes from the plugin (AGL-395).\n navTabId: 'nav-tab-bookings',\n icon: { path: mdiCalendarClock.path },\n header: {\n title: 'Bookings',\n icon: { path: mdiCalendarClock.path },\n docsTopic: 'bookings',\n },\n Component: BookingsConsolePage,\n },\n ],\n })\n}\n\nexport * from './site'\n"],"names":["Aglyn","mdiCalendarClock","lazy","BUNDLE_ID","registerBookingsRecordRoutes","BOOKINGS_SEARCH_SOURCES","BOOKINGS_CONFIG_SCHEMA","BookingsConsolePage","BookMeetingButton","registerBookingsConsole","registerPluginConfigSchema","
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/bookings/src/lib/plugin.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\nimport * as Aglyn from '@aglyn/aglyn'\nimport { registerPluginTransferResourceUi } from '@aglyn/aglyn/plugin-manager/plugin-transfer-resources'\nimport { mdiCalendarClock } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { registerBookingsRecordCounts } from './model/bookings-record-counts'\nimport { registerBookingsRecordRoutes } from './model/bookings-record-routes'\nimport { BOOKINGS_SEARCH_SOURCES } from './model/bookings-search-sources'\nimport { BOOKINGS_CONFIG_SCHEMA } from './plugin-config'\nimport { BOOKINGS_TRANSFER_RESOURCE } from './transfer/bookings-transfer-common'\n\n/** Code-split: the Bookings console page only loads when opened. */\nconst BookingsConsolePage = lazy(\n () => import('./components/bookings-console-page'),\n)\n\n/** \"Book a meeting\", loaded where a record page draws the zone for it. */\nconst BookMeetingButton = lazy(() => import('./components/book-meeting-action'))\n\n/**\n * Console half only: registers the Bookings nav item + page. Safe to call\n * at console app load — the page is lazy (no besigner/canvas code).\n */\nexport function registerBookingsConsole(): void {\n registerBookingsRecordRoutes()\n // How many bookings a site took, counted for another plugin's figure.\n registerBookingsRecordCounts()\n // Per-plugin settings (AGL-428): the schema powers the generic form on\n // the Plugins & add-ons hub and defaults-merged reads everywhere.\n Aglyn.registerPluginConfigSchema(BOOKINGS_CONFIG_SCHEMA)\n // The export dialog's name for the site's bookings; exported, never imported.\n registerPluginTransferResourceUi(\n BOOKINGS_TRANSFER_RESOURCE,\n { label: 'Bookings', icon: { path: mdiCalendarClock.path } },\n { pluginId: BUNDLE_ID },\n )\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Bookings',\n featureFlag: 'bookings',\n searchSources: BOOKINGS_SEARCH_SOURCES,\n // The control a CRM record carries for booking a meeting with the person.\n // The CRM hosts the zone and hands it the record; the services, the page\n // they are booked on and the link are this plugin's, so the control is\n // too. It draws nothing where this site takes no bookings.\n widgets: [\n {\n slot: 'crmRecordBooking',\n widgetId: 'bookings-book-meeting',\n title: 'Book a meeting',\n Component: BookMeetingButton,\n },\n ],\n navItems: [\n {\n label: 'Bookings',\n href: '/bookings',\n // Reuse the existing release-flag nav-tab so staff-preview gating is\n // unchanged now that the tab comes from the plugin (AGL-395).\n navTabId: 'nav-tab-bookings',\n icon: { path: mdiCalendarClock.path },\n header: {\n title: 'Bookings',\n icon: { path: mdiCalendarClock.path },\n docsTopic: 'bookings',\n },\n Component: BookingsConsolePage,\n },\n ],\n })\n}\n\nexport * from './site'\n"],"names":["Aglyn","registerPluginTransferResourceUi","mdiCalendarClock","lazy","BUNDLE_ID","registerBookingsRecordCounts","registerBookingsRecordRoutes","BOOKINGS_SEARCH_SOURCES","BOOKINGS_CONFIG_SCHEMA","BOOKINGS_TRANSFER_RESOURCE","BookingsConsolePage","BookMeetingButton","registerBookingsConsole","registerPluginConfigSchema","label","icon","path","pluginId","registerConsoleExtension","displayName","featureFlag","searchSources","widgets","slot","widgetId","title","Component","navItems","href","navTabId","header","docsTopic"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAASC,gCAAgC,QAAQ,wDAAuD;AACxG,SAASC,gBAAgB,QAAQ,yBAAwB;AACzD,SAASC,IAAI,QAAQ,QAAO;AAC5B,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,4BAA4B,QAAQ,oCAAgC;AAC7E,SAASC,4BAA4B,QAAQ,oCAAgC;AAC7E,SAASC,uBAAuB,QAAQ,qCAAiC;AACzE,SAASC,sBAAsB,QAAQ,qBAAiB;AACxD,SAASC,0BAA0B,QAAQ,yCAAqC;AAEhF,kEAAkE,GAClE,MAAMC,sBAAsBP,KAC1B,IAAM,MAAM,CAAC;AAGf,wEAAwE,GACxE,MAAMQ,oBAAoBR,KAAK,IAAM,MAAM,CAAC;AAE5C;;;CAGC,GACD,OAAO,SAASS;IACdN;IACA,sEAAsE;IACtED;IACA,uEAAuE;IACvE,kEAAkE;IAClEL,MAAMa,0BAA0B,CAACL;IACjC,8EAA8E;IAC9EP,iCACEQ,4BACA;QAAEK,OAAO;QAAYC,MAAM;YAAEC,MAAMd,iBAAiBc,IAAI;QAAC;IAAE,GAC3D;QAAEC,UAAUb;IAAU;IAExBJ,MAAMkB,wBAAwB,CAAC;QAC7BD,UAAUb;QACVe,aAAa;QACbC,aAAa;QACbC,eAAed;QACf,0EAA0E;QAC1E,yEAAyE;QACzE,uEAAuE;QACvE,2DAA2D;QAC3De,SAAS;YACP;gBACEC,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWf;YACb;SACD;QACDgB,UAAU;YACR;gBACEb,OAAO;gBACPc,MAAM;gBACN,qEAAqE;gBACrE,8DAA8D;gBAC9DC,UAAU;gBACVd,MAAM;oBAAEC,MAAMd,iBAAiBc,IAAI;gBAAC;gBACpCc,QAAQ;oBACNL,OAAO;oBACPV,MAAM;wBAAEC,MAAMd,iBAAiBc,IAAI;oBAAC;oBACpCe,WAAW;gBACb;gBACAL,WAAWhB;YACb;SACD;IACH;AACF;AAEA,cAAc,YAAQ"}
|
|
@@ -31,6 +31,7 @@ import { pluginTaxProfile } from "@aglyn/aglyn/plugin-manager/plugin-tax-profile
|
|
|
31
31
|
import { fileBookingOnCrm } from "./booking-crm.js";
|
|
32
32
|
import { bookingTimeZoneFor } from "./booking-time-zone.js";
|
|
33
33
|
import { formatBookingWhen } from "../model/booking-time.js";
|
|
34
|
+
import { bookingContactLines, readBookingPhone } from "../model/booking-contact-fields.js";
|
|
34
35
|
/**
|
|
35
36
|
* Paid-booking section of the platform Stripe webhook (AGL-170/418):
|
|
36
37
|
* payment confirms the pendingPayment hold — relocated verbatim from the
|
|
@@ -218,7 +219,7 @@ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
|
|
|
218
219
|
var _object_metadata1;
|
|
219
220
|
const { hostId, bookingId } = (_object_metadata1 = object.metadata) != null ? _object_metadata1 : {};
|
|
220
221
|
if (hostId && bookingId) {
|
|
221
|
-
var _ref6, _ref7, _ref8, _ref9;
|
|
222
|
+
var _ref6, _ref7, _ref8, _ref9, _booking_name, _booking_serviceName;
|
|
222
223
|
var _object_metadata2, _object_payment_intent1, _object_metadata3;
|
|
223
224
|
const firestore = firebaseAdmin.app().firestore();
|
|
224
225
|
const bookingRef = firestore.collection('hosts').doc(String(hostId)).collection('bookings').doc(String(bookingId));
|
|
@@ -461,7 +462,7 @@ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
|
|
|
461
462
|
// The amount is `amount_total`, the money that moved, per AGL-1698 and
|
|
462
463
|
// AGL-1711 — the booking document stores no price to re-derive it from.
|
|
463
464
|
if (booking['email']) {
|
|
464
|
-
var
|
|
465
|
+
var _booking_serviceName1;
|
|
465
466
|
void recordCapturedContact(_extends({
|
|
466
467
|
orgId: '',
|
|
467
468
|
hostId: String(hostId),
|
|
@@ -472,7 +473,12 @@ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
|
|
|
472
473
|
} : {}),
|
|
473
474
|
// Paid, so a relationship (AGL-3232): the lead the request-time
|
|
474
475
|
// capture filed for the address is closed onto the contact.
|
|
475
|
-
surface: 'relationship'
|
|
476
|
+
surface: 'relationship'
|
|
477
|
+
}, readBookingPhone(booking['phone']) ? {
|
|
478
|
+
profileFill: {
|
|
479
|
+
phone: readBookingPhone(booking['phone'])
|
|
480
|
+
}
|
|
481
|
+
} : {}, {
|
|
476
482
|
// Paid, so a customer (AGL-2612) — the request-time capture
|
|
477
483
|
// said `lead`, and this is the door that has seen the money.
|
|
478
484
|
lifecycleFloor: 'customer'
|
|
@@ -482,27 +488,39 @@ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
|
|
|
482
488
|
interaction: {
|
|
483
489
|
source: 'booking',
|
|
484
490
|
refId: String(bookingId),
|
|
485
|
-
summary: `Paid for "${String((
|
|
491
|
+
summary: `Paid for "${String((_booking_serviceName1 = booking['serviceName']) != null ? _booking_serviceName1 : 'a service').slice(0, 60)}" ($${(serviceCents / 100).toFixed(2)})`
|
|
486
492
|
}
|
|
487
493
|
}));
|
|
488
494
|
}
|
|
495
|
+
// In the booking's own zone, and named (AGL-3432): a formatter with
|
|
496
|
+
// no zone reads in the server's — UTC — and tells a paying guest the
|
|
497
|
+
// wrong hour, and an empty zone prints "()".
|
|
498
|
+
const timezone = await bookingTimeZoneFor(firestore, String(hostId), booking);
|
|
499
|
+
const when = formatBookingWhen(Number(booking['startsAtMs']), timezone);
|
|
500
|
+
const paid = `$${(paidAmountCents / 100).toFixed(2)}`;
|
|
501
|
+
// The site's managers hear of a PAID booking too (AGL-3500): the
|
|
502
|
+
// free path tells them when the request lands, and a paid one is
|
|
503
|
+
// only a booking once this event has cleared the charge, so this is
|
|
504
|
+
// its moment. Who, what, when, what was paid, and the phone and
|
|
505
|
+
// address the service asked for.
|
|
506
|
+
const contactLines = bookingContactLines(booking);
|
|
507
|
+
void notifyHostManagers(String(hostId), {
|
|
508
|
+
type: 'content.booking',
|
|
509
|
+
title: 'New booking on {site}',
|
|
510
|
+
body: `${String((_booking_name = booking['name']) != null ? _booking_name : '') || 'A guest'}` + (booking['email'] ? ` (${String(booking['email'])})` : '') + ` booked ${String((_booking_serviceName = booking['serviceName']) != null ? _booking_serviceName : '') || 'a service'} ` + `on {site} for ${when} (${timezone}). ${paid} was paid.` + (contactLines.length ? ` ${contactLines.join('. ')}.` : ''),
|
|
511
|
+
link: `/${hostId}/bookings`
|
|
512
|
+
});
|
|
489
513
|
// Confirmation email now that payment cleared (env-gated inside
|
|
490
514
|
// sendEmail, which no-ops when Resend isn't configured).
|
|
491
515
|
if (booking['email']) {
|
|
492
|
-
var
|
|
516
|
+
var _booking_name1, _booking_serviceName2, _booking_name2, _booking_serviceName3, _ref10, _booking_serviceName4;
|
|
493
517
|
var _this;
|
|
494
|
-
|
|
495
|
-
// no zone reads in the server's — UTC — and tells a paying guest the
|
|
496
|
-
// wrong hour, and an empty zone prints "()".
|
|
497
|
-
const timezone = await bookingTimeZoneFor(firestore, String(hostId), booking);
|
|
498
|
-
const when = formatBookingWhen(Number(booking['startsAtMs']), timezone);
|
|
499
|
-
const paid = `$${(paidAmountCents / 100).toFixed(2)}`;
|
|
500
|
-
const fallbackText = `Hi ${(_booking_name = booking['name']) != null ? _booking_name : ''},\n\nPayment received — ` + `"${(_booking_serviceName1 = booking['serviceName']) != null ? _booking_serviceName1 : 'your booking'}" is ` + `confirmed for ${when} (${timezone}). You paid ${paid}.` + `\n\nReference: ${bookingId}`;
|
|
518
|
+
const fallbackText = `Hi ${(_booking_name1 = booking['name']) != null ? _booking_name1 : ''},\n\nPayment received — ` + `"${(_booking_serviceName2 = booking['serviceName']) != null ? _booking_serviceName2 : 'your booking'}" is ` + `confirmed for ${when} (${timezone}). You paid ${paid}.` + `\n\nReference: ${bookingId}`;
|
|
501
519
|
// Site-owner-designed template when published (AGL-770); null keeps
|
|
502
520
|
// the built-in copy.
|
|
503
521
|
const designed = await renderHostEmailWithTokens(firebaseAdmin.app().firestore(), String(hostId), 'booking-confirmed', {
|
|
504
|
-
name: String((
|
|
505
|
-
'service.name': String((
|
|
522
|
+
name: String((_booking_name2 = booking['name']) != null ? _booking_name2 : ''),
|
|
523
|
+
'service.name': String((_booking_serviceName3 = booking['serviceName']) != null ? _booking_serviceName3 : ''),
|
|
506
524
|
when,
|
|
507
525
|
timezone,
|
|
508
526
|
'booking.payment': `You paid ${paid}.`,
|
|
@@ -513,7 +531,7 @@ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
|
|
|
513
531
|
const branding = resolveBrandingProfile((_this = await getOrgForHost(String(hostId)).catch(()=>null)) == null ? void 0 : _this.org);
|
|
514
532
|
await sendEmail(_extends({
|
|
515
533
|
to: String(booking['email']),
|
|
516
|
-
subject: (_ref10 = designed == null ? void 0 : designed.subject) != null ? _ref10 : `Booking confirmed: ${(
|
|
534
|
+
subject: (_ref10 = designed == null ? void 0 : designed.subject) != null ? _ref10 : `Booking confirmed: ${(_booking_serviceName4 = booking['serviceName']) != null ? _booking_serviceName4 : ''}`,
|
|
517
535
|
text: (designed == null ? void 0 : designed.text) || fallbackText
|
|
518
536
|
}, (designed == null ? void 0 : designed.html) ? {
|
|
519
537
|
html: designed.html
|