@proveanything/smartlinks 2.1.1 → 2.1.2

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.
@@ -1,4 +1,4 @@
1
- import type { Booking, BookingSlot, BookingAllocation, CreateBookingInput, UpdateBookingInput, MoveBookingInput, BookingListQueryParams, CreateSlotInput, UpdateSlotInput, SlotListQueryParams, CreateBlockInput, BlockListQueryParams, FreeBusyQuery, FreeBusyResponse, BookingAccessOptions } from '../types/bookings.js';
1
+ import type { Booking, BookingSlot, BookingAllocation, CreateBookingInput, UpdateBookingInput, MoveBookingInput, BookingListQueryParams, CreateSlotInput, UpdateSlotInput, SlotListQueryParams, CreateBlockInput, BlockListQueryParams, FreeBusyQuery, FreeBusyResponse, BookingAccessOptions, MessageBookersInput, MessageBookersResult } from '../types/bookings.js';
2
2
  import type { PaginatedResponse } from '../types/appObjects.js';
3
3
  export declare namespace bookings {
4
4
  /**
@@ -55,6 +55,12 @@ export declare namespace bookings {
55
55
  * GET /bookings/freebusy
56
56
  */
57
57
  function freebusy(collectionId: string, appId: string, q: FreeBusyQuery, admin?: boolean): Promise<FreeBusyResponse>;
58
+ /**
59
+ * Message the people booked (admin): a template to everyone on a slot, or everyone booked in a date
60
+ * range (optionally with one person / room), each send hydrated with their booking ({{ booking }}).
61
+ * No segment to make. POST /bookings/message
62
+ */
63
+ function message(collectionId: string, appId: string, input: MessageBookersInput): Promise<MessageBookersResult>;
58
64
  namespace slots {
59
65
  /** Create a slot (admin). POST /bookings/slots */
60
66
  function create(collectionId: string, appId: string, input: CreateSlotInput): Promise<BookingSlot>;
@@ -122,6 +122,15 @@ export var bookings;
122
122
  return request(`${basePath(collectionId, appId, admin)}/freebusy${query(params)}`);
123
123
  }
124
124
  bookings.freebusy = freebusy;
125
+ /**
126
+ * Message the people booked (admin): a template to everyone on a slot, or everyone booked in a date
127
+ * range (optionally with one person / room), each send hydrated with their booking ({{ booking }}).
128
+ * No segment to make. POST /bookings/message
129
+ */
130
+ async function message(collectionId, appId, input) {
131
+ return post(`${basePath(collectionId, appId, true)}/message`, input);
132
+ }
133
+ bookings.message = message;
125
134
  // ==================== SLOTS ====================
126
135
  let slots;
127
136
  (function (slots_1) {
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.1.1 | Generated: 2026-10-25T08:26:22.390Z
3
+ Version: 2.1.2 | Generated: 2026-10-26T14:58:53.408Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -2816,6 +2816,7 @@ interface ServerFunctionBookings {
2816
2816
  complete(bookingId: string): Promise<Booking>;
2817
2817
  noShow(bookingId: string): Promise<Booking>;
2818
2818
  freebusy(q: FreeBusyQuery): Promise<FreeBusyResponse>;
2819
+ message(input: MessageBookersInput): Promise<MessageBookersResult>;
2819
2820
  slots: {
2820
2821
  create(input: CreateSlotInput): Promise<BookingSlot>;
2821
2822
  createMany(inputs: CreateSlotInput[], opts?: { seriesId?: string }): Promise<BookingSlot[]>;
@@ -5366,6 +5367,32 @@ interface FreeBusyResponse {
5366
5367
  }
5367
5368
  ```
5368
5369
 
5370
+ **MessageBookersInput** (interface)
5371
+ ```typescript
5372
+ interface MessageBookersInput {
5373
+ templateId: string
5374
+ channel?: 'email' | 'sms' | 'whatsapp' | 'push' | 'preferred'
5375
+ props?: Record<string, any>
5376
+ slotId?: string
5377
+ from?: string | Date
5378
+ to?: string | Date
5379
+ resourceType?: string
5380
+ resourceId?: string
5381
+ statuses?: BookingStatus[]
5382
+ ref?: string
5383
+ }
5384
+ ```
5385
+
5386
+ **MessageBookersResult** (interface)
5387
+ ```typescript
5388
+ interface MessageBookersResult {
5389
+ matched: number
5390
+ sent: number
5391
+ skipped: { noContact: number; duplicate: number; failed: number }
5392
+ failures: Array<{ bookingId: string; error: string }>
5393
+ }
5394
+ ```
5395
+
5369
5396
  **BookingAccessOptions** (interface)
5370
5397
  ```typescript
5371
5398
  interface BookingAccessOptions {
@@ -11902,6 +11929,9 @@ confirmed → no_show (admin).
11902
11929
  **freebusy**(collectionId: string, appId: string, q: FreeBusyQuery, admin: boolean = false) → `Promise<FreeBusyResponse>`
11903
11930
  Busy intervals per resource (bookings, blocks, slots' own blocks) — never booking details. Public only when the app sets `publicRead.bookings.freebusy: true`. GET /bookings/freebusy
11904
11931
 
11932
+ **message**(collectionId: string, appId: string, input: MessageBookersInput) → `Promise<MessageBookersResult>`
11933
+ Message the people booked (admin): a template to everyone on a slot, or everyone booked in a date range (optionally with one person / room), each send hydrated with their booking ({{ booking }}). No segment to make. POST /bookings/message
11934
+
11905
11935
  ### bookings.blocks
11906
11936
 
11907
11937
  **create**(collectionId: string, appId: string, input: CreateBlockInput) → `Promise<BookingAllocation>`
@@ -324,8 +324,80 @@ Recorded after each write commits, for flows and app functions (`trigger: { type
324
324
  `booking.no_show`, `booking.hold_expired`, `slot.full`, `slot.place_freed` (for waitlist promotion).
325
325
  Context: `{ appId, status, slotIds }` (bookings) / `{ appId, remaining }` (slots).
326
326
 
327
- Reminders before a booking (`booking.starts_in`) are coming; until then, use a cron function that lists
328
- upcoming bookings.
327
+ Reminders before a booking (`booking.starts_in`) are coming; until then, send a reminder template to
328
+ tomorrow's bookers with `bookings.message` (below) from a cron function.
329
+
330
+ ---
331
+
332
+ ## Confirmations and messages
333
+
334
+ Nothing is emailed by default: your app decides what goes out, in **flows on booking events**, with
335
+ **templates the business can reword in Studio** (Comms → Templates). The platform does the sending,
336
+ the hydration and the consent.
337
+
338
+ ### Flows on booking events
339
+
340
+ A site, or an app on behalf of a business, sets them up through the cloud blueprint
341
+ (`cloud/flows.json` in a site; `POST /admin/collection/:cId/blueprint/apply` with
342
+ `{ appId, blueprint: { templates, flows } }` from an app's admin screen). Additive: re-applying never
343
+ removes what is there, and a template edited in Studio keeps its edits unless the blueprint sends new
344
+ wording.
345
+
346
+ ```json
347
+ {
348
+ "templates": {
349
+ "booking-confirmed": { "name": "You're booked", "email": { "subject": "{{ booking.serviceName }} on {{ booking.when.date }}", "body": "<p>Hi {{ contact.firstName }}, see you at {{ booking.when.time }}.</p>" }, "sms": { "body": "Booked: {{ booking.when.shortDate }} {{ booking.when.time }}" } },
350
+ "booking-requested": { "name": "Request received", "email": { "subject": "We got your request", "body": "<p>We'll confirm shortly.</p>" } },
351
+ "staff-new-booking": { "name": "You've been booked", "email": { "subject": "New appointment", "body": "<p>{{ booking.customer.name }}, {{ booking.when.dateTime }}</p>" } }
352
+ },
353
+ "flows": [
354
+ { "when": { "event": "booking.confirmed" }, "do": [{ "send": "booking-confirmed", "to": "contact" }, { "send": "staff-new-booking", "to": "resource" }, { "send": "team-new-booking", "to": "team" }] },
355
+ { "when": { "event": "booking.created", "status": "pending" }, "do": [{ "send": "booking-requested", "to": "contact" }] }
356
+ ]
357
+ }
358
+ ```
359
+
360
+ - `when.event`: `booking.created` / `confirmed` / `cancelled` / `moved` / `completed` / `no_show` /
361
+ `hold_expired`, `slot.full` / `slot.place_freed`; `when.status` narrows to the booking's status at the
362
+ time (`"pending"` for "request received").
363
+ - `to`: `contact` — the booker, by their preferred channel (`channel` to force one; a template needs an
364
+ `sms` part to go by text); `team` — the collection's team addresses; `resource` — the booked
365
+ person or room: a resource that is a contact, or a record with `data.email` / `data.contactId`.
366
+ A **public** resource record (a staff profile the booking page lists) keeps its address on a
367
+ **private child record** instead (`visibility: 'admin'`, `parentType: 'record'`, `parentId` = the
368
+ resource) — the platform looks there too, and the address never ships with the public listing.
369
+ - Who gets what, in templates: `{{ booking }}` is the booking **ready-formatted**: its fields and
370
+ `data`, plus `serviceName`, `staffName`, `customer`, `durationMinutes`, `when` (`date`,
371
+ `shortDate`, `time`, `endTime`, `dateTime`, `timezone` — in the business's timezone) and, when the
372
+ recipient's timezone is known and differs, `whenFor` (the same in theirs); `editToken` only when the
373
+ recipient is the booker (a guest's manage link). `{{ contact }}` is the recipient; `{{ appConfig }}`
374
+ the app's public config (a booking page's address, the business name) for sends to the contact.
375
+
376
+ ### Who is reachable
377
+
378
+ A **guest** who books with an email or phone gets a **contact** (found by that identity or made,
379
+ filling only empty fields: a form's details never overwrite a known contact), and so does a customer
380
+ **staff book in by hand** (`customer: { name, email, phone }` on the body or in `data`). Admin
381
+ creates may pass `contactId` instead. A booking with no contact gets no messages: show the booker why
382
+ an email is worth giving.
383
+
384
+ ### Messaging everyone booked — `bookings.message`
385
+
386
+ No list to build, no segment to save: send a template to everyone on a slot, or everyone booked in a
387
+ window (optionally with one resource), each hydrated with **their own** booking.
388
+
389
+ ```ts
390
+ // Everyone on tomorrow's 10:00 class
391
+ await SL.bookings.message(collectionId, appId, { templateId, slotId })
392
+ // Everyone booked with Sam next week, by email even if they prefer texts
393
+ await SL.bookings.message(collectionId, appId, { templateId, channel: 'email', from, to, resourceType: 'record', resourceId: samId })
394
+ // → { matched, sent, skipped: { noContact, duplicate, failed }, failures: [{ bookingId, reason }] }
395
+ ```
396
+
397
+ Statuses default to held, pending and confirmed; at most 500 recipients per call (narrow the window
398
+ for more). One message per contact on a slot, one per booking in a window. Also
399
+ `ctx.sl.bookings.message(input)` in a server function (`sl:bookings:write`) — a cron function that
400
+ messages tomorrow's bookers is a reminder system in five lines.
329
401
 
330
402
  ---
331
403
 
@@ -343,6 +415,7 @@ All take `(collectionId, appId, …)`; `admin` is the last parameter where both
343
415
  | `bookings.confirm / cancel / move(…, admin?, { editToken }?)` | ✓ | per `ownerActions.bookings` |
344
416
  | `bookings.extendHold / complete / noShow` | ✓ | — |
345
417
  | `bookings.freebusy(q, admin?)` | ✓ | if `publicRead.bookings.freebusy` |
418
+ | `bookings.message(input)` | ✓ | — |
346
419
  | `bookings.slots.create / createMany / update / remove` | ✓ | — |
347
420
  | `bookings.slots.list / get(…, admin?)` | ✓ | open, public slots |
348
421
  | `bookings.blocks.create / list / remove` | ✓ | — |
@@ -517,7 +517,7 @@ Prefer the platform primitives above over a dependency; when you do need one, pi
517
517
  | Check a tapped tag is real | `ctx.sl.tags.resolve(proofId)` → `{ id, claimSetId, code }` or `null` | `sl:tags:read` |
518
518
  | Put a visitor's details on a contact | `ctx.sl.contacts.upsert({ email, firstName, … })` → `{ contactId, created }` (fills empty fields only) | `sl:contacts:write` |
519
519
  | Record "they did X" | `ctx.sl.interactions.record({ interactionId, contactId, outcome, metadata })` | `sl:interactions:write` |
520
- | Take a booking with your own checks (hours, queue token, payment) | `ctx.sl.bookings.create({ slotId \| resource + time, contactId, status, idempotencyKey })` — never oversold, never double-booked; also `confirm` / `cancel` / `move` / `mine` / `freebusy` / `slots.*` / `blocks.*` (see [bookings.md](bookings.md)) | `sl:bookings:write` (`sl:bookings:read` to read) |
520
+ | Take a booking with your own checks (hours, queue token, payment) | `ctx.sl.bookings.create({ slotId \| resource + time, contactId, status, idempotencyKey })` — never oversold, never double-booked; also `confirm` / `cancel` / `move` / `mine` / `freebusy` / `message` (everyone booked, hydrated) / `slots.*` / `blocks.*` (see [bookings.md](bookings.md)) | `sl:bookings:write` (`sl:bookings:read` to read) |
521
521
 
522
522
  Notes:
523
523
  - **Talking to the SmartLinks core is `ctx.sl`, not HTTP.** The common pattern — *validate /
@@ -636,6 +636,18 @@ try {
636
636
  }
637
637
  ```
638
638
 
639
+ `ctx.sl.bookings.message({ templateId, slotId | from, to, resourceType?, resourceId?, channel? })` sends a
640
+ template to everyone booked (each hydrated with their own booking) — the five-line reminder cron:
641
+
642
+ ```ts
643
+ // manifest: { name: 'remind', trigger: { type: 'cron', schedule: '0 17 * * *' }, capabilities: ['sl:bookings:write'] }
644
+ export async function remind(ctx) {
645
+ const from = new Date(); from.setDate(from.getDate() + 1); from.setHours(0, 0, 0, 0)
646
+ const to = new Date(from); to.setDate(to.getDate() + 1)
647
+ return ctx.sl.bookings.message({ templateId: 'bp.' + ctx.collectionId + '.reminder', from, to })
648
+ }
649
+ ```
650
+
639
651
  The local harness runs bookings in memory with the same rules (capacity, per-person limits, no
640
652
  overlaps, idempotency, holds): `ctx.memory.bookings`.
641
653
 
package/dist/openapi.yaml CHANGED
@@ -10291,6 +10291,32 @@ paths:
10291
10291
  description: Unauthorized
10292
10292
  404:
10293
10293
  description: Not found
10294
+ /message:
10295
+ post:
10296
+ tags:
10297
+ - bookings
10298
+ summary: "Message the people booked (admin): a template to everyone on a slot, or everyone booked in a date range (optionally with one person / room), each send hydrated with their booking ({{ booking }})."
10299
+ operationId: bookings_message
10300
+ security: []
10301
+ responses:
10302
+ 200:
10303
+ description: Success
10304
+ content:
10305
+ application/json:
10306
+ schema:
10307
+ $ref: "#/components/schemas/MessageBookersResult"
10308
+ 400:
10309
+ description: Bad request
10310
+ 401:
10311
+ description: Unauthorized
10312
+ 404:
10313
+ description: Not found
10314
+ requestBody:
10315
+ required: true
10316
+ content:
10317
+ application/json:
10318
+ schema:
10319
+ $ref: "#/components/schemas/MessageBookersInput"
10294
10320
  /platform/location:
10295
10321
  post:
10296
10322
  tags:
@@ -24706,6 +24732,62 @@ components:
24706
24732
  - from
24707
24733
  - to
24708
24734
  - resources
24735
+ MessageBookersInput:
24736
+ type: object
24737
+ properties:
24738
+ templateId:
24739
+ type: string
24740
+ channel:
24741
+ type: string
24742
+ enum:
24743
+ - email
24744
+ - sms
24745
+ - whatsapp
24746
+ - push
24747
+ - preferred
24748
+ props:
24749
+ type: object
24750
+ additionalProperties: true
24751
+ slotId:
24752
+ type: string
24753
+ from:
24754
+ type: object
24755
+ additionalProperties: true
24756
+ to:
24757
+ type: object
24758
+ additionalProperties: true
24759
+ resourceType:
24760
+ type: string
24761
+ resourceId:
24762
+ type: string
24763
+ statuses:
24764
+ type: array
24765
+ items:
24766
+ $ref: "#/components/schemas/BookingStatus"
24767
+ ref:
24768
+ type: string
24769
+ required:
24770
+ - templateId
24771
+ MessageBookersResult:
24772
+ type: object
24773
+ properties:
24774
+ matched:
24775
+ type: number
24776
+ sent:
24777
+ type: number
24778
+ skipped:
24779
+ type: object
24780
+ additionalProperties: true
24781
+ failures:
24782
+ type: array
24783
+ items:
24784
+ type: object
24785
+ additionalProperties: true
24786
+ required:
24787
+ - matched
24788
+ - sent
24789
+ - skipped
24790
+ - failures
24709
24791
  BookingAccessOptions:
24710
24792
  type: object
24711
24793
  properties:
@@ -32,6 +32,17 @@ export declare function bookingsMemoryImpl(mem: BookingsMemory, caller?: {
32
32
  cancel(bookingId: string, opts?: any): Promise<any>;
33
33
  move(bookingId: string, input?: any): Promise<any>;
34
34
  extendHold(bookingId: string, holdMinutes: number): Promise<any>;
35
+ /** Counts who would be messaged (no sends in memory); inject `sl.bookings.message` to assert sends. */
36
+ message(input?: any): Promise<{
37
+ matched: number;
38
+ sent: number;
39
+ skipped: {
40
+ noContact: number;
41
+ duplicate: number;
42
+ failed: number;
43
+ };
44
+ failures: any[];
45
+ }>;
35
46
  complete(bookingId: string): Promise<any>;
36
47
  noShow(bookingId: string): Promise<any>;
37
48
  freebusy(q?: any): Promise<{
@@ -280,6 +280,28 @@ export function bookingsMemoryImpl(mem, caller = {}) {
280
280
  b.holdExpiresAt = iso(Math.min(now() + (holdMinutes !== null && holdMinutes !== void 0 ? holdMinutes : 15) * 60000, ms(b.createdAt) + 24 * 3600000));
281
281
  return copy(withAllocations(b));
282
282
  },
283
+ /** Counts who would be messaged (no sends in memory); inject `sl.bookings.message` to assert sends. */
284
+ async message(input = {}) {
285
+ const list = input.slotId
286
+ ? (await this.query({ slotId: input.slotId, status: (input.statuses || ['held', 'pending', 'confirmed']).join(',') })).data
287
+ : (await this.query({ from: input.from, to: input.to, status: (input.statuses || ['held', 'pending', 'confirmed']).join(',') })).data;
288
+ const seen = new Set();
289
+ const out = { matched: list.length, sent: 0, skipped: { noContact: 0, duplicate: 0, failed: 0 }, failures: [] };
290
+ for (const b of list) {
291
+ if (!b.contactId) {
292
+ out.skipped.noContact++;
293
+ continue;
294
+ }
295
+ const key = input.slotId ? b.contactId : `${b.contactId}:${b.id}`;
296
+ if (seen.has(key)) {
297
+ out.skipped.duplicate++;
298
+ continue;
299
+ }
300
+ seen.add(key);
301
+ out.sent++;
302
+ }
303
+ return out;
304
+ },
283
305
  async complete(bookingId) { return outcome(find(bookingId), 'completed'); },
284
306
  async noShow(bookingId) { return outcome(find(bookingId), 'no_show'); },
285
307
  async freebusy(q = {}) {
@@ -274,7 +274,7 @@ export function createFunctionTestContext(opts) {
274
274
  const bkFn = (obj, name) => (obj && typeof obj[name] === 'function' ? obj[name].bind(obj) : undefined);
275
275
  const BOOKING_READS = new Set(['get', 'query', 'mine', 'freebusy']);
276
276
  sl.bookings = {};
277
- for (const m of ['create', 'get', 'query', 'mine', 'update', 'confirm', 'cancel', 'move', 'extendHold', 'complete', 'noShow', 'freebusy']) {
277
+ for (const m of ['create', 'get', 'query', 'mine', 'update', 'confirm', 'cancel', 'move', 'extendHold', 'complete', 'noShow', 'freebusy', 'message']) {
278
278
  sl.bookings[m] = gated('bookings', BOOKING_READS.has(m) ? 'read' : 'write', bkFn(bk, m), `bookings.${m}`);
279
279
  }
280
280
  sl.bookings.slots = {};
@@ -4,7 +4,7 @@ import type { AppDataDeclaration, AppHeadlessDeclaration } from './headless.js';
4
4
  import type { RealtimeChannelDeclaration } from './realtime.js';
5
5
  import type { AppManifestAdminCards } from './adminCards.js';
6
6
  import type { PaginatedResponse } from './appObjects.js';
7
- import type { Booking, BookingSlot, BookingAllocation, CreateBookingInput, UpdateBookingInput, MoveBookingInput, BookingListQueryParams, CreateSlotInput, UpdateSlotInput, SlotListQueryParams, CreateBlockInput, BlockListQueryParams, FreeBusyQuery, FreeBusyResponse } from './bookings.js';
7
+ import type { Booking, BookingSlot, BookingAllocation, CreateBookingInput, UpdateBookingInput, MoveBookingInput, BookingListQueryParams, CreateSlotInput, UpdateSlotInput, SlotListQueryParams, CreateBlockInput, BlockListQueryParams, FreeBusyQuery, FreeBusyResponse, MessageBookersInput, MessageBookersResult } from './bookings.js';
8
8
  /**
9
9
  * A bundle (widget or container) as returned by the collection widgets endpoint.
10
10
  *
@@ -449,6 +449,8 @@ export interface ServerFunctionBookings {
449
449
  noShow(bookingId: string): Promise<Booking>;
450
450
  /** Busy intervals per resource — feed them to `utils.freeTimes` with your schedule. */
451
451
  freebusy(q: FreeBusyQuery): Promise<FreeBusyResponse>;
452
+ /** A template to everyone booked on a slot / in a range, each hydrated with their booking. */
453
+ message(input: MessageBookersInput): Promise<MessageBookersResult>;
452
454
  slots: {
453
455
  create(input: CreateSlotInput): Promise<BookingSlot>;
454
456
  createMany(inputs: CreateSlotInput[], opts?: {
@@ -266,6 +266,37 @@ export interface FreeBusyResponse {
266
266
  busy: FreeBusyInterval[];
267
267
  }>;
268
268
  }
269
+ /** `bookings.message`: a template to everyone booked on a slot, or in a date range. */
270
+ export interface MessageBookersInput {
271
+ /** A comms template of the collection (edited in Studio). */
272
+ templateId: string;
273
+ channel?: 'email' | 'sms' | 'whatsapp' | 'push' | 'preferred';
274
+ /** Extra Liquid variables, on top of {{ booking }}, {{ contact }} and {{ appConfig }}. */
275
+ props?: Record<string, any>;
276
+ /** Everyone on this slot … */
277
+ slotId?: string;
278
+ /** … or everyone booked in [from, to), optionally with one resource. */
279
+ from?: string | Date;
280
+ to?: string | Date;
281
+ resourceType?: string;
282
+ resourceId?: string;
283
+ /** Default: held, pending, confirmed. */
284
+ statuses?: BookingStatus[];
285
+ ref?: string;
286
+ }
287
+ export interface MessageBookersResult {
288
+ matched: number;
289
+ sent: number;
290
+ skipped: {
291
+ noContact: number;
292
+ duplicate: number;
293
+ failed: number;
294
+ };
295
+ failures: Array<{
296
+ bookingId: string;
297
+ error: string;
298
+ }>;
299
+ }
269
300
  /** Public calls on a booking made by an anonymous guest: pass the edit token returned at create. */
270
301
  export interface BookingAccessOptions {
271
302
  editToken?: string;
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.1.1 | Generated: 2026-10-25T08:26:22.390Z
3
+ Version: 2.1.2 | Generated: 2026-10-26T14:58:53.408Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -2816,6 +2816,7 @@ interface ServerFunctionBookings {
2816
2816
  complete(bookingId: string): Promise<Booking>;
2817
2817
  noShow(bookingId: string): Promise<Booking>;
2818
2818
  freebusy(q: FreeBusyQuery): Promise<FreeBusyResponse>;
2819
+ message(input: MessageBookersInput): Promise<MessageBookersResult>;
2819
2820
  slots: {
2820
2821
  create(input: CreateSlotInput): Promise<BookingSlot>;
2821
2822
  createMany(inputs: CreateSlotInput[], opts?: { seriesId?: string }): Promise<BookingSlot[]>;
@@ -5366,6 +5367,32 @@ interface FreeBusyResponse {
5366
5367
  }
5367
5368
  ```
5368
5369
 
5370
+ **MessageBookersInput** (interface)
5371
+ ```typescript
5372
+ interface MessageBookersInput {
5373
+ templateId: string
5374
+ channel?: 'email' | 'sms' | 'whatsapp' | 'push' | 'preferred'
5375
+ props?: Record<string, any>
5376
+ slotId?: string
5377
+ from?: string | Date
5378
+ to?: string | Date
5379
+ resourceType?: string
5380
+ resourceId?: string
5381
+ statuses?: BookingStatus[]
5382
+ ref?: string
5383
+ }
5384
+ ```
5385
+
5386
+ **MessageBookersResult** (interface)
5387
+ ```typescript
5388
+ interface MessageBookersResult {
5389
+ matched: number
5390
+ sent: number
5391
+ skipped: { noContact: number; duplicate: number; failed: number }
5392
+ failures: Array<{ bookingId: string; error: string }>
5393
+ }
5394
+ ```
5395
+
5369
5396
  **BookingAccessOptions** (interface)
5370
5397
  ```typescript
5371
5398
  interface BookingAccessOptions {
@@ -11902,6 +11929,9 @@ confirmed → no_show (admin).
11902
11929
  **freebusy**(collectionId: string, appId: string, q: FreeBusyQuery, admin: boolean = false) → `Promise<FreeBusyResponse>`
11903
11930
  Busy intervals per resource (bookings, blocks, slots' own blocks) — never booking details. Public only when the app sets `publicRead.bookings.freebusy: true`. GET /bookings/freebusy
11904
11931
 
11932
+ **message**(collectionId: string, appId: string, input: MessageBookersInput) → `Promise<MessageBookersResult>`
11933
+ Message the people booked (admin): a template to everyone on a slot, or everyone booked in a date range (optionally with one person / room), each send hydrated with their booking ({{ booking }}). No segment to make. POST /bookings/message
11934
+
11905
11935
  ### bookings.blocks
11906
11936
 
11907
11937
  **create**(collectionId: string, appId: string, input: CreateBlockInput) → `Promise<BookingAllocation>`
package/docs/bookings.md CHANGED
@@ -324,8 +324,80 @@ Recorded after each write commits, for flows and app functions (`trigger: { type
324
324
  `booking.no_show`, `booking.hold_expired`, `slot.full`, `slot.place_freed` (for waitlist promotion).
325
325
  Context: `{ appId, status, slotIds }` (bookings) / `{ appId, remaining }` (slots).
326
326
 
327
- Reminders before a booking (`booking.starts_in`) are coming; until then, use a cron function that lists
328
- upcoming bookings.
327
+ Reminders before a booking (`booking.starts_in`) are coming; until then, send a reminder template to
328
+ tomorrow's bookers with `bookings.message` (below) from a cron function.
329
+
330
+ ---
331
+
332
+ ## Confirmations and messages
333
+
334
+ Nothing is emailed by default: your app decides what goes out, in **flows on booking events**, with
335
+ **templates the business can reword in Studio** (Comms → Templates). The platform does the sending,
336
+ the hydration and the consent.
337
+
338
+ ### Flows on booking events
339
+
340
+ A site, or an app on behalf of a business, sets them up through the cloud blueprint
341
+ (`cloud/flows.json` in a site; `POST /admin/collection/:cId/blueprint/apply` with
342
+ `{ appId, blueprint: { templates, flows } }` from an app's admin screen). Additive: re-applying never
343
+ removes what is there, and a template edited in Studio keeps its edits unless the blueprint sends new
344
+ wording.
345
+
346
+ ```json
347
+ {
348
+ "templates": {
349
+ "booking-confirmed": { "name": "You're booked", "email": { "subject": "{{ booking.serviceName }} on {{ booking.when.date }}", "body": "<p>Hi {{ contact.firstName }}, see you at {{ booking.when.time }}.</p>" }, "sms": { "body": "Booked: {{ booking.when.shortDate }} {{ booking.when.time }}" } },
350
+ "booking-requested": { "name": "Request received", "email": { "subject": "We got your request", "body": "<p>We'll confirm shortly.</p>" } },
351
+ "staff-new-booking": { "name": "You've been booked", "email": { "subject": "New appointment", "body": "<p>{{ booking.customer.name }}, {{ booking.when.dateTime }}</p>" } }
352
+ },
353
+ "flows": [
354
+ { "when": { "event": "booking.confirmed" }, "do": [{ "send": "booking-confirmed", "to": "contact" }, { "send": "staff-new-booking", "to": "resource" }, { "send": "team-new-booking", "to": "team" }] },
355
+ { "when": { "event": "booking.created", "status": "pending" }, "do": [{ "send": "booking-requested", "to": "contact" }] }
356
+ ]
357
+ }
358
+ ```
359
+
360
+ - `when.event`: `booking.created` / `confirmed` / `cancelled` / `moved` / `completed` / `no_show` /
361
+ `hold_expired`, `slot.full` / `slot.place_freed`; `when.status` narrows to the booking's status at the
362
+ time (`"pending"` for "request received").
363
+ - `to`: `contact` — the booker, by their preferred channel (`channel` to force one; a template needs an
364
+ `sms` part to go by text); `team` — the collection's team addresses; `resource` — the booked
365
+ person or room: a resource that is a contact, or a record with `data.email` / `data.contactId`.
366
+ A **public** resource record (a staff profile the booking page lists) keeps its address on a
367
+ **private child record** instead (`visibility: 'admin'`, `parentType: 'record'`, `parentId` = the
368
+ resource) — the platform looks there too, and the address never ships with the public listing.
369
+ - Who gets what, in templates: `{{ booking }}` is the booking **ready-formatted**: its fields and
370
+ `data`, plus `serviceName`, `staffName`, `customer`, `durationMinutes`, `when` (`date`,
371
+ `shortDate`, `time`, `endTime`, `dateTime`, `timezone` — in the business's timezone) and, when the
372
+ recipient's timezone is known and differs, `whenFor` (the same in theirs); `editToken` only when the
373
+ recipient is the booker (a guest's manage link). `{{ contact }}` is the recipient; `{{ appConfig }}`
374
+ the app's public config (a booking page's address, the business name) for sends to the contact.
375
+
376
+ ### Who is reachable
377
+
378
+ A **guest** who books with an email or phone gets a **contact** (found by that identity or made,
379
+ filling only empty fields: a form's details never overwrite a known contact), and so does a customer
380
+ **staff book in by hand** (`customer: { name, email, phone }` on the body or in `data`). Admin
381
+ creates may pass `contactId` instead. A booking with no contact gets no messages: show the booker why
382
+ an email is worth giving.
383
+
384
+ ### Messaging everyone booked — `bookings.message`
385
+
386
+ No list to build, no segment to save: send a template to everyone on a slot, or everyone booked in a
387
+ window (optionally with one resource), each hydrated with **their own** booking.
388
+
389
+ ```ts
390
+ // Everyone on tomorrow's 10:00 class
391
+ await SL.bookings.message(collectionId, appId, { templateId, slotId })
392
+ // Everyone booked with Sam next week, by email even if they prefer texts
393
+ await SL.bookings.message(collectionId, appId, { templateId, channel: 'email', from, to, resourceType: 'record', resourceId: samId })
394
+ // → { matched, sent, skipped: { noContact, duplicate, failed }, failures: [{ bookingId, reason }] }
395
+ ```
396
+
397
+ Statuses default to held, pending and confirmed; at most 500 recipients per call (narrow the window
398
+ for more). One message per contact on a slot, one per booking in a window. Also
399
+ `ctx.sl.bookings.message(input)` in a server function (`sl:bookings:write`) — a cron function that
400
+ messages tomorrow's bookers is a reminder system in five lines.
329
401
 
330
402
  ---
331
403
 
@@ -343,6 +415,7 @@ All take `(collectionId, appId, …)`; `admin` is the last parameter where both
343
415
  | `bookings.confirm / cancel / move(…, admin?, { editToken }?)` | ✓ | per `ownerActions.bookings` |
344
416
  | `bookings.extendHold / complete / noShow` | ✓ | — |
345
417
  | `bookings.freebusy(q, admin?)` | ✓ | if `publicRead.bookings.freebusy` |
418
+ | `bookings.message(input)` | ✓ | — |
346
419
  | `bookings.slots.create / createMany / update / remove` | ✓ | — |
347
420
  | `bookings.slots.list / get(…, admin?)` | ✓ | open, public slots |
348
421
  | `bookings.blocks.create / list / remove` | ✓ | — |
@@ -517,7 +517,7 @@ Prefer the platform primitives above over a dependency; when you do need one, pi
517
517
  | Check a tapped tag is real | `ctx.sl.tags.resolve(proofId)` → `{ id, claimSetId, code }` or `null` | `sl:tags:read` |
518
518
  | Put a visitor's details on a contact | `ctx.sl.contacts.upsert({ email, firstName, … })` → `{ contactId, created }` (fills empty fields only) | `sl:contacts:write` |
519
519
  | Record "they did X" | `ctx.sl.interactions.record({ interactionId, contactId, outcome, metadata })` | `sl:interactions:write` |
520
- | Take a booking with your own checks (hours, queue token, payment) | `ctx.sl.bookings.create({ slotId \| resource + time, contactId, status, idempotencyKey })` — never oversold, never double-booked; also `confirm` / `cancel` / `move` / `mine` / `freebusy` / `slots.*` / `blocks.*` (see [bookings.md](bookings.md)) | `sl:bookings:write` (`sl:bookings:read` to read) |
520
+ | Take a booking with your own checks (hours, queue token, payment) | `ctx.sl.bookings.create({ slotId \| resource + time, contactId, status, idempotencyKey })` — never oversold, never double-booked; also `confirm` / `cancel` / `move` / `mine` / `freebusy` / `message` (everyone booked, hydrated) / `slots.*` / `blocks.*` (see [bookings.md](bookings.md)) | `sl:bookings:write` (`sl:bookings:read` to read) |
521
521
 
522
522
  Notes:
523
523
  - **Talking to the SmartLinks core is `ctx.sl`, not HTTP.** The common pattern — *validate /
@@ -636,6 +636,18 @@ try {
636
636
  }
637
637
  ```
638
638
 
639
+ `ctx.sl.bookings.message({ templateId, slotId | from, to, resourceType?, resourceId?, channel? })` sends a
640
+ template to everyone booked (each hydrated with their own booking) — the five-line reminder cron:
641
+
642
+ ```ts
643
+ // manifest: { name: 'remind', trigger: { type: 'cron', schedule: '0 17 * * *' }, capabilities: ['sl:bookings:write'] }
644
+ export async function remind(ctx) {
645
+ const from = new Date(); from.setDate(from.getDate() + 1); from.setHours(0, 0, 0, 0)
646
+ const to = new Date(from); to.setDate(to.getDate() + 1)
647
+ return ctx.sl.bookings.message({ templateId: 'bp.' + ctx.collectionId + '.reminder', from, to })
648
+ }
649
+ ```
650
+
639
651
  The local harness runs bookings in memory with the same rules (capacity, per-person limits, no
640
652
  overlaps, idempotency, holds): `ctx.memory.bookings`.
641
653
 
package/openapi.yaml CHANGED
@@ -10291,6 +10291,32 @@ paths:
10291
10291
  description: Unauthorized
10292
10292
  404:
10293
10293
  description: Not found
10294
+ /message:
10295
+ post:
10296
+ tags:
10297
+ - bookings
10298
+ summary: "Message the people booked (admin): a template to everyone on a slot, or everyone booked in a date range (optionally with one person / room), each send hydrated with their booking ({{ booking }})."
10299
+ operationId: bookings_message
10300
+ security: []
10301
+ responses:
10302
+ 200:
10303
+ description: Success
10304
+ content:
10305
+ application/json:
10306
+ schema:
10307
+ $ref: "#/components/schemas/MessageBookersResult"
10308
+ 400:
10309
+ description: Bad request
10310
+ 401:
10311
+ description: Unauthorized
10312
+ 404:
10313
+ description: Not found
10314
+ requestBody:
10315
+ required: true
10316
+ content:
10317
+ application/json:
10318
+ schema:
10319
+ $ref: "#/components/schemas/MessageBookersInput"
10294
10320
  /platform/location:
10295
10321
  post:
10296
10322
  tags:
@@ -24706,6 +24732,62 @@ components:
24706
24732
  - from
24707
24733
  - to
24708
24734
  - resources
24735
+ MessageBookersInput:
24736
+ type: object
24737
+ properties:
24738
+ templateId:
24739
+ type: string
24740
+ channel:
24741
+ type: string
24742
+ enum:
24743
+ - email
24744
+ - sms
24745
+ - whatsapp
24746
+ - push
24747
+ - preferred
24748
+ props:
24749
+ type: object
24750
+ additionalProperties: true
24751
+ slotId:
24752
+ type: string
24753
+ from:
24754
+ type: object
24755
+ additionalProperties: true
24756
+ to:
24757
+ type: object
24758
+ additionalProperties: true
24759
+ resourceType:
24760
+ type: string
24761
+ resourceId:
24762
+ type: string
24763
+ statuses:
24764
+ type: array
24765
+ items:
24766
+ $ref: "#/components/schemas/BookingStatus"
24767
+ ref:
24768
+ type: string
24769
+ required:
24770
+ - templateId
24771
+ MessageBookersResult:
24772
+ type: object
24773
+ properties:
24774
+ matched:
24775
+ type: number
24776
+ sent:
24777
+ type: number
24778
+ skipped:
24779
+ type: object
24780
+ additionalProperties: true
24781
+ failures:
24782
+ type: array
24783
+ items:
24784
+ type: object
24785
+ additionalProperties: true
24786
+ required:
24787
+ - matched
24788
+ - sent
24789
+ - skipped
24790
+ - failures
24709
24791
  BookingAccessOptions:
24710
24792
  type: object
24711
24793
  properties:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",