@aglyn/plugins-bookings 1.0.0-beta.221 → 1.0.0-beta.223

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 (52) hide show
  1. package/README.md +3 -2
  2. package/package.json +11 -11
  3. package/src/lib/components/book-meeting-action.js +5 -3
  4. package/src/lib/components/book-meeting-action.js.map +1 -1
  5. package/src/lib/components/booking.d.ts +33 -0
  6. package/src/lib/components/booking.js +260 -54
  7. package/src/lib/components/booking.js.map +1 -1
  8. package/src/lib/components/bookings-console-page.js +139 -15
  9. package/src/lib/components/bookings-console-page.js.map +1 -1
  10. package/src/lib/declarations.console-server.d.ts +34 -0
  11. package/src/lib/declarations.console-server.js +84 -0
  12. package/src/lib/declarations.console-server.js.map +1 -0
  13. package/src/lib/model/booking-contact-fields.d.ts +88 -0
  14. package/src/lib/model/booking-contact-fields.js +130 -0
  15. package/src/lib/model/booking-contact-fields.js.map +1 -0
  16. package/src/lib/model/booking-price.d.ts +51 -0
  17. package/src/lib/model/booking-price.js +61 -0
  18. package/src/lib/model/booking-price.js.map +1 -0
  19. package/src/lib/model/booking-record.d.ts +7 -0
  20. package/src/lib/model/booking-record.js +11 -2
  21. package/src/lib/model/booking-record.js.map +1 -1
  22. package/src/lib/model/bookings-record-counts.d.ts +25 -0
  23. package/src/lib/model/bookings-record-counts.js +34 -0
  24. package/src/lib/model/bookings-record-counts.js.map +1 -0
  25. package/src/lib/model/bookings.d.ts +61 -4
  26. package/src/lib/model/bookings.js +81 -15
  27. package/src/lib/model/bookings.js.map +1 -1
  28. package/src/lib/model/index.d.ts +2 -0
  29. package/src/lib/model/index.js +2 -0
  30. package/src/lib/model/index.js.map +1 -1
  31. package/src/lib/plugin.js +14 -0
  32. package/src/lib/plugin.js.map +1 -1
  33. package/src/lib/server/billing-webhook.js +33 -15
  34. package/src/lib/server/billing-webhook.js.map +1 -1
  35. package/src/lib/server/booking-crm.js +4 -1
  36. package/src/lib/server/booking-crm.js.map +1 -1
  37. package/src/lib/server/person-eraser.d.ts +36 -0
  38. package/src/lib/server/person-eraser.js +48 -0
  39. package/src/lib/server/person-eraser.js.map +1 -0
  40. package/src/lib/server/refund.js +2 -2
  41. package/src/lib/server/refund.js.map +1 -1
  42. package/src/lib/server.js +97 -36
  43. package/src/lib/server.js.map +1 -1
  44. package/src/lib/transfer/bookings-transfer-common.d.ts +68 -0
  45. package/src/lib/transfer/bookings-transfer-common.js +130 -0
  46. package/src/lib/transfer/bookings-transfer-common.js.map +1 -0
  47. package/src/lib/transfer/bookings-transfer.d.ts +86 -0
  48. package/src/lib/transfer/bookings-transfer.js +613 -0
  49. package/src/lib/transfer/bookings-transfer.js.map +1 -0
  50. package/src/lib/transfer/bookings-transfer.server.d.ts +26 -0
  51. package/src/lib/transfer/bookings-transfer.server.js +31 -0
  52. package/src/lib/transfer/bookings-transfer.server.js.map +1 -0
package/src/lib/server.js CHANGED
@@ -22,7 +22,7 @@ import { checkEntitlement, registerPluginConfigSchema, registerPluginJob, resolv
22
22
  // reach the real verdict.
23
23
  import { siteLockdownJobGate } from "@aglyn/tenant-data-admin/server/tenant-write-lockdown";
24
24
  import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn/tenant-data-admin/server/host-email-tokens";
25
- import { BOOKING_MAX_DAYS_AHEAD, bookingTimeZone, computeOpenSlots, formatBookingWhen, isBookingReminderDue, isSlotOpen, REMINDER_WINDOW_END_HOURS, REMINDER_WINDOW_START_HOURS } from "./model/index.js";
25
+ import { BOOKING_MAX_DAYS_AHEAD, bookingChargeUsd, bookingContactAsks, bookingPriceDisplay, bookingContactLines, readBookingContactFields, bookingTimeZone, computeOpenSlotPage, formatBookingWhen, isBookingReminderDue, isSlotOpen, REMINDER_WINDOW_END_HOURS, REMINDER_WINDOW_START_HOURS } from "./model/index.js";
26
26
  import { bookingTimeZoneFor } from "./server/booking-time-zone.js";
27
27
  import { registerBillingWebhookHandler, registerPluginApiRoute } from "@aglyn/aglyn/server";
28
28
  import { pluginTaxProfile } from "@aglyn/aglyn/plugin-manager/plugin-tax-profile";
@@ -85,11 +85,12 @@ registerPluginJob({
85
85
  }
86
86
  }
87
87
  });
88
- import { firebaseAdmin, getOrgForHost, meterHostEmail, notifyHostManagers, attributeCampaignConversion, resolveCampaignTouch, getPluginConfig, resolveOrgIdForHost, renderHostEmailWithTokens, hostSendingIdentity } from "@aglyn/tenant-data-admin";
88
+ import { firebaseAdmin, getOrgForHost, meterHostEmail, notifyHostManagers, getPluginConfig, resolveOrgIdForHost, renderHostEmailWithTokens, hostSendingIdentity } from "@aglyn/tenant-data-admin";
89
89
  import { merchantAccountIsReady } from "@aglyn/tenant-data-admin/server/payment-provider";
90
90
  import { checkoutSessionCardAuthenticationParams } from "@aglyn/tenant-data-admin/server/stripe-card-authentication";
91
91
  import { emitHostEvent } from "@aglyn/tenant-runtime";
92
92
  import recordCapturedContact from "@aglyn/aglyn/plugin-manager/record-captured-contact";
93
+ import { CONVERSION_TOUCH_DETAIL, creditConversion, resolveConversionTouch } from "@aglyn/aglyn/plugin-manager/plugin-conversion-credit";
93
94
  import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
94
95
  import { FieldValue } from "firebase-admin/firestore";
95
96
  import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/request-ip";
@@ -129,7 +130,7 @@ export const slotsHandler = async (req, res)=>{
129
130
  });
130
131
  }
131
132
  try {
132
- var _config_maxDaysAhead, _service_priceUsd, _service_timezone;
133
+ var _config_maxDaysAhead, _req_query_from, _service_timezone;
133
134
  const firestore = firebaseAdmin.app().firestore();
134
135
  const hostRef = firestore.collection('hosts').doc(hostId);
135
136
  // No serviceId → public service directory for the booking widget
@@ -138,13 +139,21 @@ export const slotsHandler = async (req, res)=>{
138
139
  const services = await hostRef.collection('services').limit(50).get();
139
140
  return res.status(200).json({
140
141
  services: services.docs.filter((doc)=>!doc.get('deletedAt')).map((doc)=>{
141
- var _doc_get, _doc_get1, _doc_get2, _doc_get3;
142
+ var _doc_get, _doc_get1, _doc_get2;
142
143
  return {
143
144
  $id: doc.id,
144
145
  name: (_doc_get = doc.get('name')) != null ? _doc_get : '',
145
146
  durationMinutes: Number((_doc_get1 = doc.get('durationMinutes')) != null ? _doc_get1 : 30),
146
- priceUsd: Number((_doc_get2 = doc.get('priceUsd')) != null ? _doc_get2 : 0),
147
- description: (_doc_get3 = doc.get('description')) != null ? _doc_get3 : ''
147
+ // What booking it charges, never a price a label stands in for
148
+ // (AGL-3475): a labeled service shows its label and charges 0.
149
+ priceUsd: bookingChargeUsd(doc.data()),
150
+ priceDisplay: bookingPriceDisplay(doc.get('priceDisplay')),
151
+ description: (_doc_get2 = doc.get('description')) != null ? _doc_get2 : '',
152
+ // What the widget asks for beyond a name and an email (AGL-3493),
153
+ // as the booking route will hold it: the widget renders and
154
+ // checks the fields from these, the route checks them again.
155
+ askPhone: bookingContactAsks(doc.data()).phone,
156
+ askAddress: bookingContactAsks(doc.data()).address
148
157
  };
149
158
  })
150
159
  });
@@ -156,7 +165,7 @@ export const slotsHandler = async (req, res)=>{
156
165
  error: 'Unknown service'
157
166
  });
158
167
  }
159
- const fromMs = Date.now();
168
+ const nowMs = Date.now();
160
169
  // Booking horizon (AGL-428): configurable via the plugin settings
161
170
  // framework; defaults to BOOKING_MAX_DAYS_AHEAD through the schema.
162
171
  //
@@ -169,9 +178,17 @@ export const slotsHandler = async (req, res)=>{
169
178
  hostId
170
179
  });
171
180
  const maxDaysAhead = Number((_config_maxDaysAhead = config.maxDaysAhead) != null ? _config_maxDaysAhead : BOOKING_MAX_DAYS_AHEAD);
172
- const toMs = fromMs + maxDaysAhead * 24 * 60 * 60000;
181
+ // Measured from NOW whichever page is asked for: the horizon is how far
182
+ // ahead a visitor may book, not how far past the page they are on.
183
+ const toMs = nowMs + maxDaysAhead * 24 * 60 * 60000;
184
+ // The page asked for (AGL-3492): `from` is the `nextFromMs` the previous
185
+ // page answered with. Clamped to now, so a stale or hand-written value
186
+ // can never list a time that has passed, and a value past the horizon
187
+ // lists nothing rather than reaching beyond it.
188
+ const askedFromMs = Number((_req_query_from = req.query['from']) != null ? _req_query_from : 0);
189
+ const fromMs = Number.isFinite(askedFromMs) ? Math.min(Math.max(askedFromMs, nowMs), toMs) : nowMs;
173
190
  // Bounded at BOTH ends, to the window the slots are actually computed
174
- // over. `computeOpenSlots` below is handed `fromMs`/`toMs` and ignores
191
+ // over. `computeOpenSlotPage` below is handed `fromMs`/`toMs` and ignores
175
192
  // anything outside them, so a booking past the horizon was read, billed
176
193
  // and discarded — and worse, it competed for the 500 with the bookings
177
194
  // that do matter, so a service booked far ahead could push the near-term
@@ -179,7 +196,10 @@ export const slotsHandler = async (req, res)=>{
179
196
  //
180
197
  // A second range on `startsAtMs` needs no new index: the field already
181
198
  // carries one, and the range it narrows is the one already here.
182
- const bookedSnapshot = await hostRef.collection('bookings').where('serviceId', '==', serviceId).where('startsAtMs', '>=', fromMs - 24 * 60 * 60000).where('startsAtMs', '<=', toMs).limit(500).get();
199
+ const bookedSnapshot = await hostRef.collection('bookings').where('serviceId', '==', serviceId).where('startsAtMs', '>=', fromMs - 24 * 60 * 60000)// One page never walks further than BOOKING_MAX_DAYS_AHEAD past its
200
+ // start, so a horizon set further out than that is read a page's
201
+ // worth at a time, not all at once.
202
+ .where('startsAtMs', '<=', Math.min(toMs, fromMs + BOOKING_MAX_DAYS_AHEAD * 24 * 60 * 60000)).limit(500).get();
183
203
  const booked = bookedSnapshot.docs.filter((doc)=>{
184
204
  var _doc_get;
185
205
  return doc.get('status') !== 'canceled' && // Expired payment holds release the slot (AGL-170).
@@ -191,15 +211,25 @@ export const slotsHandler = async (req, res)=>{
191
211
  endsAtMs: Number((_doc_get1 = doc.get('endsAtMs')) != null ? _doc_get1 : 0)
192
212
  };
193
213
  });
194
- const slots = computeOpenSlots(service, fromMs, toMs, booked, 120);
214
+ // Bounded in DAYS, not slots (AGL-3492): a page is the widget's day
215
+ // strip, whole, with every open time each of those days has. The flat
216
+ // cap of 120 it replaces ran out three and a half weekdays in at
217
+ // 15-minute steps, so the widget showed a calendar that ended on
218
+ // Thursday at lunch and a horizon that was never reachable.
219
+ const { slots, nextFromMs } = computeOpenSlotPage(service, fromMs, toMs, booked);
195
220
  return res.status(200).json({
196
221
  service: {
197
222
  name: service.name,
198
223
  durationMinutes: service.durationMinutes,
199
- priceUsd: (_service_priceUsd = service.priceUsd) != null ? _service_priceUsd : 0,
224
+ priceUsd: bookingChargeUsd(service),
225
+ priceDisplay: bookingPriceDisplay(service.priceDisplay),
200
226
  timezone: (_service_timezone = service.timezone) != null ? _service_timezone : 'UTC'
201
227
  },
202
- slots
228
+ slots,
229
+ // Where the next page starts; `null` once this one reached the horizon.
230
+ nextFromMs,
231
+ // The configured horizon, so the widget's empty state names it.
232
+ horizonDays: maxDaysAhead
203
233
  });
204
234
  } catch (error) {
205
235
  console.error(error);
@@ -269,8 +299,8 @@ export const slotsHandler = async (req, res)=>{
269
299
  });
270
300
  }
271
301
  try {
272
- var _service_priceUsd, _service_name, _service_name1, _service_name2, _service_name3;
273
- var _this, _req_body7;
302
+ var _service_name, _service_name1, _service_name2, _service_name3;
303
+ var _req_body7, _req_body8, _this, _req_body9;
274
304
  const firestore = firebaseAdmin.app().firestore();
275
305
  const hostRef = firestore.collection('hosts').doc(hostId);
276
306
  const [hostSnapshot, serviceSnapshot] = await Promise.all([
@@ -288,6 +318,20 @@ export const slotsHandler = async (req, res)=>{
288
318
  error: 'Unknown service'
289
319
  });
290
320
  }
321
+ // The phone and the address, held to what THIS service asks for
322
+ // (AGL-3493) and checked here whatever the widget already checked: this
323
+ // is a public door, and a request can be written by hand. A field the
324
+ // service does not ask for is dropped rather than stored.
325
+ const contact = readBookingContactFields(bookingContactAsks(service), {
326
+ phone: (_req_body7 = req.body) == null ? void 0 : _req_body7.phone,
327
+ address: (_req_body8 = req.body) == null ? void 0 : _req_body8.address
328
+ });
329
+ if ('error' in contact) return res.status(400).json({
330
+ error: contact.error
331
+ });
332
+ const { phone, address } = contact.fields;
333
+ // `Phone: …`, `Address: …`, for the managers' notification below.
334
+ const contactLines = bookingContactLines(contact.fields);
291
335
  // Plan gate (dark-launch rule preserved). Plan/quota gates ride the
292
336
  // owning org's doc (AGL-238). Hoisted out of its block for AGL-2315:
293
337
  // the same org doc now also prices the platform's cut below, and reading
@@ -315,7 +359,9 @@ export const slotsHandler = async (req, res)=>{
315
359
  // booking lands as `pendingPayment` with a 15-minute expiry (expired
316
360
  // holds release the slot in the collision filters), and the visitor
317
361
  // goes to Stripe Checkout; the webhook confirms + emails on payment.
318
- const priceUsd = Number((_service_priceUsd = service.priceUsd) != null ? _service_priceUsd : 0);
362
+ // A service that states its price as a label books with no charge
363
+ // (AGL-3475), whatever price it stores.
364
+ const priceUsd = bookingChargeUsd(service);
319
365
  const paid = priceUsd > 0;
320
366
  if (paid && !process.env.STRIPE_SECRET_KEY) {
321
367
  return res.status(501).json({
@@ -476,7 +522,12 @@ export const slotsHandler = async (req, res)=>{
476
522
  serviceId,
477
523
  serviceName: (_service_name = service.name) != null ? _service_name : '',
478
524
  name,
479
- email,
525
+ email
526
+ }, phone ? {
527
+ phone
528
+ } : {}, address ? {
529
+ address
530
+ } : {}, {
480
531
  startsAtMs,
481
532
  endsAtMs,
482
533
  timezone,
@@ -491,30 +542,29 @@ export const slotsHandler = async (req, res)=>{
491
542
  return bookingRef.id;
492
543
  });
493
544
  /*
494
- * THE CAMPAIGN TOUCH, RESOLVED ONCE FOR THE WHOLE BOOKING.
545
+ * WHERE THE VISITOR ARRIVED FROM, RESOLVED ONCE FOR THE WHOLE BOOKING.
495
546
  *
496
547
  * A booking is an identify moment: the visitor was anonymous while they
497
548
  * browsed and picked a slot, and this request is the first thing that
498
- * names them. Both channels are asked here — the labels their device
499
- * carried, and the campaign mail they clicked, joined on the address they
500
- * just typed — and the later of the two is credited, exactly as the
501
- * revenue join credits the later click.
549
+ * names them. The plugin that credits outcomes
550
+ * (`plugin-conversion-credit`) is asked with what their device carried
551
+ * and the address they just typed, and decides what earns the credit.
502
552
  *
503
553
  * Resolved once and handed to the booking record, the contact and the
504
- * lead below, so the email-channel lookup is one keyed read for the whole
505
- * request and the three records cannot name different campaigns.
554
+ * lead below, so the lookup is paid once for the whole request and the
555
+ * three records cannot be credited to different arrivals.
506
556
  */ const bookedAtMs = Date.now();
507
- const campaignTouch = await resolveCampaignTouch({
557
+ const arrival = await resolveConversionTouch({
508
558
  hostId,
509
- wire: (_req_body7 = req.body) == null ? void 0 : _req_body7.campaignTouch,
559
+ wire: (_req_body9 = req.body) == null ? void 0 : _req_body9.campaignTouch,
510
560
  email,
511
561
  atMs: bookedAtMs
512
562
  });
513
- void attributeCampaignConversion({
563
+ void creditConversion({
514
564
  hostId,
515
565
  kind: 'booking',
516
566
  refId: bookingId,
517
- touch: campaignTouch,
567
+ touch: arrival,
518
568
  convertedAtMs: bookedAtMs
519
569
  });
520
570
  /*
@@ -550,11 +600,15 @@ export const slotsHandler = async (req, res)=>{
550
600
  summary: `Booked "${String((_service_name = service.name) != null ? _service_name : 'a service').slice(0, 60)}"`
551
601
  },
552
602
  surface: 'lead'
553
- }, marketingConsent ? {
603
+ }, phone ? {
604
+ profileFill: {
605
+ phone
606
+ }
607
+ } : {}, marketingConsent ? {
554
608
  marketingConsent: true
555
- } : {}, campaignTouch ? {
609
+ } : {}, arrival ? {
556
610
  detail: {
557
- campaignTouch
611
+ [CONVERSION_TOUCH_DETAIL]: arrival
558
612
  }
559
613
  } : {}));
560
614
  if (paid) {
@@ -672,8 +726,8 @@ export const slotsHandler = async (req, res)=>{
672
726
  error: 'Payment setup failed'
673
727
  });
674
728
  }
675
- // The lead landed above; the confirmation email + workflow event fire
676
- // from the payment webhook.
729
+ // The lead landed above; the confirmation email and the managers'
730
+ // notification go out from the payment webhook.
677
731
  return res.status(200).json({
678
732
  bookingId,
679
733
  startsAtMs,
@@ -683,11 +737,13 @@ export const slotsHandler = async (req, res)=>{
683
737
  }
684
738
  // Event trigger (AGL-128/148/159).
685
739
  // In-app notification to the site's managers (AGL-259): who booked what,
686
- // and when, in the booking's own zone and naming it (AGL-3432).
740
+ // and when, in the booking's own zone and naming it (AGL-3432), and the
741
+ // phone and address the service asked for (AGL-3493) — the two things
742
+ // an on-site service needs before it can do anything with the booking.
687
743
  void notifyHostManagers(hostId, {
688
744
  type: 'content.booking',
689
745
  title: 'New booking on {site}',
690
- body: `${name} (${email}) booked ${String((_service_name1 = service.name) != null ? _service_name1 : 'a service')} ` + `on {site} for ${when} (${timezone}).`,
746
+ body: `${name} (${email}) booked ${String((_service_name1 = service.name) != null ? _service_name1 : 'a service')} ` + `on {site} for ${when} (${timezone}).` + (contactLines.length ? ` ${contactLines.join('. ')}.` : ''),
691
747
  link: `/${hostId}/bookings`
692
748
  });
693
749
  const { alerts } = await emitHostEvent(hostId, 'booking', {
@@ -753,7 +809,12 @@ export const slotsHandler = async (req, res)=>{
753
809
  booking: _extends({
754
810
  serviceId,
755
811
  serviceName: (_service_name3 = service.name) != null ? _service_name3 : '',
756
- email,
812
+ email
813
+ }, phone ? {
814
+ phone
815
+ } : {}, address ? {
816
+ address
817
+ } : {}, {
757
818
  startsAtMs,
758
819
  endsAtMs
759
820
  }, crmRef ? {