@proveanything/smartlinks 2.0.70 → 2.0.71

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.
@@ -0,0 +1,324 @@
1
+ # Bookings: slots, bookings and allocations
2
+
3
+ Generic booking objects for **any** booking app: event tickets, classes, tours, table reservations,
4
+ one-to-one appointments, room and equipment hire. The platform gives you the objects and the
5
+ guarantees. Your app owns the rest: schedule, policy, pricing, payment and UI.
6
+
7
+ ```ts
8
+ import { bookings, utils } from '@proveanything/smartlinks'
9
+ ```
10
+
11
+ ---
12
+
13
+ ## What the platform guarantees (and what it doesn't)
14
+
15
+ | The platform | Your app |
16
+ |---|---|
17
+ | **No double-booking** of a person / room / table, however many requests arrive at once | The schedule (opening hours) and its UI |
18
+ | **No overselling** a slot's capacity | Booking policy: notice, durations, who may book |
19
+ | **Atomic** writes: a booking with several parts (a stylist *and* a chair) succeeds whole or not at all | Pricing, ticket types, payment or credits, refunds |
20
+ | **Idempotent** creates (`idempotencyKey`): a retried request never books twice | Queues, waiting rooms, throttling for high-demand sales |
21
+ | **Holds** that expire and give the place back | Confirmation / reminder content (flows on booking events) |
22
+ | Who may book publicly, by **per-app rules** (like records / cases) | |
23
+
24
+ ---
25
+
26
+ ## The three objects
27
+
28
+ - **Slot** — a time window with a **capacity**: an event performance, a class, a tour at 2pm. Seats
29
+ are counted (`booked`, `remaining`).
30
+ - **Booking** — the customer's reservation and its lifecycle. This is what "my bookings" lists.
31
+ - **Allocation** — what a booking actually claims: seats on a slot (`mode: 'capacity'`) or
32
+ exclusive time on a resource (`mode: 'exclusive'`). Most apps never touch these directly: they're
33
+ returned on `booking.allocations`.
34
+
35
+ **Resources are not a new object.** Staff, rooms, tables and equipment are whatever you already use —
36
+ app records, contacts, products — referenced by type + id: `{ resourceType: 'record', resourceId: 'staff-sam' }`.
37
+
38
+ ### Booking statuses
39
+
40
+ | Status | Holds a place? | Meaning |
41
+ |---|---|---|
42
+ | `held` | yes, until `holdExpiresAt` | Claimed while the customer pays / confirms; expires automatically |
43
+ | `pending` | yes | Claimed, waiting for the business to approve |
44
+ | `confirmed` | yes | Booked |
45
+ | `completed` / `no_show` | yes (the time was used) | Outcome after the event (admin) |
46
+ | `cancelled` | no | `cancelReason`: `admin`, `owner`, `hold_expired` or your own |
47
+ | `waitlisted` | no | Waiting; confirming it claims the place if one is free |
48
+
49
+ ---
50
+
51
+ ## Events and classes — slots
52
+
53
+ ```ts
54
+ // Admin (Studio, or your app's server function): publish the sessions
55
+ const slot = await bookings.slots.create(collectionId, appId, {
56
+ startsAt: '2026-11-14T19:30:00Z', endsAt: '2026-11-14T21:00:00Z',
57
+ capacity: 200,
58
+ perContactLimit: 4, // max 4 seats per person on this slot, enforced atomically
59
+ data: { title: 'Autumn concert' },
60
+ })
61
+
62
+ // A class that also needs its instructor free: the slot blocks the instructor's time too
63
+ await bookings.slots.create(collectionId, appId, {
64
+ startsAt, endsAt, capacity: 12,
65
+ resourceType: 'record', resourceId: 'instructor-ana', blockResource: true,
66
+ })
67
+
68
+ // A series, all-or-nothing (max 500)
69
+ await bookings.slots.createMany(collectionId, appId, sessions, { seriesId })
70
+
71
+ // Public: open slots with places left
72
+ const { data } = await bookings.slots.list(collectionId, appId, { from, to, available: true })
73
+ data[0].remaining
74
+ ```
75
+
76
+ Book seats:
77
+
78
+ ```ts
79
+ const booking = await bookings.create(collectionId, appId, { slotId: slot.id, quantity: 2, idempotencyKey })
80
+ ```
81
+
82
+ Recurring events are **expanded into concrete slots** (`seriesId`); there's no recurrence rule
83
+ stored on a slot.
84
+
85
+ ---
86
+
87
+ ## Appointments — exclusive time on a resource
88
+
89
+ ```ts
90
+ await bookings.create(collectionId, appId, {
91
+ resourceType: 'record', resourceId: 'staff-sam',
92
+ startsAt: '2026-11-03T10:00:00Z', endsAt: '2026-11-03T10:30:00Z',
93
+ bufferAfter: 15, // admin: blocks 10:30–10:45 too (clean-up); the booking still shows 10:00–10:30
94
+ }, true)
95
+ ```
96
+
97
+ Several resources at once — all-or-nothing:
98
+
99
+ ```ts
100
+ await bookings.create(collectionId, appId, {
101
+ startsAt, endsAt,
102
+ allocations: [
103
+ { resourceType: 'record', resourceId: 'stylist-jo' },
104
+ { resourceType: 'record', resourceId: 'chair-2' },
105
+ ],
106
+ }, true)
107
+ ```
108
+
109
+ A pool with no fixed times (3 kayaks, bookable any time) is three resources; your app picks a free one.
110
+
111
+ ### Blocked time
112
+
113
+ ```ts
114
+ await bookings.blocks.create(collectionId, appId, { resourceType: 'record', resourceId: 'staff-sam', startsAt, endsAt, data: { reason: 'training' } })
115
+ ```
116
+
117
+ Planned time off usually belongs in your schedule (an override); blocks are for ad-hoc blocked time.
118
+
119
+ ---
120
+
121
+ ## Schedules and free times
122
+
123
+ **Your app owns the schedule.** Store it however you like — usually an app record. For the time maths,
124
+ the SDK ships a pure helper:
125
+
126
+ ```ts
127
+ const schedule = {
128
+ timezone: 'Europe/London',
129
+ weekly: { mon: [{ start: '09:00', end: '12:30' }, { start: '13:30', end: '17:00' }], tue: [{ start: '09:00', end: '17:00' }] },
130
+ overrides: [{ from: '2026-12-24', to: '2026-12-28', hours: [] }, { date: '2026-11-07', hours: [{ start: '10:00', end: '13:00' }] }],
131
+ }
132
+
133
+ const fb = await bookings.freebusy(collectionId, appId, { resources: ['record:staff-sam'], from, to }, true)
134
+ const times = utils.freeTimes({
135
+ hours: schedule, busy: fb.resources[0].busy,
136
+ from, to, duration: 30, step: 30, bufferAfter: 15, minNotice: 240,
137
+ })
138
+ // → [{ start, end }, …] in UTC; render in the booker's timezone
139
+ ```
140
+
141
+ - Times are **local wall-clock** in `timezone`, so "9 to 5" stays 9 to 5 across daylight-saving changes.
142
+ - A missing day is closed; hours never cross midnight (split them); `end` may be `'24:00'`.
143
+ - `overrides` win over `weekly` (`hours: []` = closed).
144
+ - Also exported: `utils.withinSchedule`, `utils.openIntervals`, `utils.subtractIntervals`, `utils.validateSchedule`.
145
+
146
+ `freebusy` returns busy intervals only (bookings, blocks, slots that block a resource) — never who
147
+ booked. It's collection-wide: a resource is busy whichever app booked it.
148
+
149
+ ---
150
+
151
+ ## Who can book — per-app rules
152
+
153
+ Exactly like records, cases and threads:
154
+
155
+ - **Admin calls are unconditional** — Studio users, and **your app's own server functions** (they call
156
+ the admin routes with your app's authority). Pass `admin = true`.
157
+ - **Public calls** (`admin = false`, the default for `create`) follow your app's config in
158
+ `sites/{collectionId}/apps/{appId}` (`POST /admin/collection/:cId/apps/:appId`). With no config,
159
+ **public booking is refused** — only admins (and your functions) can book.
160
+
161
+ Pick a mode per app:
162
+
163
+ | Mode | Config | Suits |
164
+ |---|---|---|
165
+ | **Public booking** | `publicCreate.bookings` allows anonymous and/or signed-in callers | "Book an appointment with me", simple events — no server code |
166
+ | **Admin-only booking** | no `publicCreate.bookings` | Extra validation, queues, payment-first, high demand: book through your server function |
167
+
168
+ ### `publicCreate.bookings`
169
+
170
+ ```json
171
+ {
172
+ "publicCreate": {
173
+ "bookings": {
174
+ "anonymous": {
175
+ "allow": true,
176
+ "enforce": { "status": "held" },
177
+ "limits": { "maxQuantity": 4, "holdMinutes": 15 },
178
+ "rules": { "targets": ["slots"], "minNoticeMinutes": 60, "horizonDays": 90 },
179
+ "edit": { "editToken": true }
180
+ },
181
+ "authenticated": {
182
+ "allow": true,
183
+ "enforce": { "status": "confirmed", "visibility": "owner" },
184
+ "rules": {
185
+ "targets": ["slots", "resources"],
186
+ "schedule": true,
187
+ "resourceTypes": ["record"],
188
+ "durations": [30, 60],
189
+ "minNoticeMinutes": 240,
190
+ "bufferAfter": 15
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ ```
197
+
198
+ | Field | Meaning |
199
+ |---|---|
200
+ | `enforce.status` | `confirmed` (instant), `pending` (business approves), `held` (pay / confirm next). Default `pending`. The caller can't override it. |
201
+ | `enforce.visibility` | Default: `owner` for signed-in, `admin` for anonymous |
202
+ | `limits.maxQuantity` | Max seats / claims per booking |
203
+ | `limits.holdMinutes` | Hold length for public `held` bookings (default 15, max 60) |
204
+ | `rules.targets` | `slots` (only your published slots) and/or `resources` (free-form time). Default `['slots']` |
205
+ | `rules.schedule` | **Required for `resources`.** The platform checks the requested time against the resource's schedule: an app record of **your app** with `recordType: 'availability'`, `parentType` / `parentId` = the resource, `data` in the standard shape above |
206
+ | `rules.resourceTypes` | Which resource types the public may book |
207
+ | `rules.durations` | Allowed lengths in minutes |
208
+ | `rules.minNoticeMinutes`, `rules.horizonDays` | How soon / how far ahead |
209
+ | `rules.bufferBefore`, `rules.bufferAfter` | Applied to every public booking (the caller can't set buffers) |
210
+ | `edit.editToken` | Anonymous only: the create response carries `editToken` **once** — store it so the guest can manage the booking. `edit.windowMinutes` limits how long it works |
211
+
212
+ Public callers can't set `contactId` (signed-in callers get their own contact; guests none), the
213
+ admin zone, buffers, `customId` or `sourceSystem`.
214
+
215
+ ### `ownerActions.bookings` — what the booker may do afterwards
216
+
217
+ ```json
218
+ {
219
+ "ownerActions": {
220
+ "bookings": {
221
+ "cancel": { "allow": true, "cutoffMinutes": 1440 },
222
+ "move": { "allow": true, "cutoffMinutes": 1440 },
223
+ "confirm": { "allow": true }
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ Default: nothing. `cutoffMinutes` = not within that time of the start (`CUTOFF_PASSED`). `move`
230
+ re-checks the create rules for the new time. `confirm` turns the booker's own **held** booking into
231
+ `confirmed` — only for apps that take no payment; `pending` and `waitlisted` are the business's to
232
+ confirm.
233
+
234
+ ```ts
235
+ // Signed-in booker
236
+ await bookings.cancel(collectionId, appId, bookingId)
237
+ const mine = await bookings.mine(collectionId, appId, { from: new Date() })
238
+
239
+ // Guest, with the token from create
240
+ const b = await bookings.get(collectionId, appId, bookingId, false, { editToken })
241
+ await bookings.move(collectionId, appId, bookingId, { slotId: other.id }, false, { editToken })
242
+ ```
243
+
244
+ ### `publicRead.bookings.freebusy`
245
+
246
+ `{ "publicRead": { "bookings": { "freebusy": true } } }` lets public pages call `bookings.freebusy`.
247
+ Off by default — most apps serve free times through their own function.
248
+
249
+ ---
250
+
251
+ ## Payments and high-demand sales
252
+
253
+ The platform takes no payments. It gives you what a payment or credits flow needs:
254
+
255
+ 1. Create the booking **`held`** (claims the place; `holdMinutes`).
256
+ 2. Take payment. Slow step? `bookings.extendHold(…, minutes)` (admin; capped at 24h from creation).
257
+ 3. **Confirm after payment** — from your function or payment webhook handler. Confirm is idempotent, so
258
+ a repeated webhook is harmless. A run-out hold fails with `HOLD_EXPIRED`.
259
+ 4. If it's never confirmed, the hold expires within a minute: the booking becomes `cancelled`
260
+ (`cancelReason: 'hold_expired'`) and the place goes back. Void the payment intent on the
261
+ `booking.hold_expired` event.
262
+
263
+ Keep payment references, amounts, credits and refunds in the booking's `admin` / `owner` zones.
264
+
265
+ **High demand** (queues, lotteries, bot checks): turn public booking **off** so the only way in is
266
+ your server function; it checks the queue token / per-person limit / throttle, then creates a short
267
+ `held` booking. The platform guarantees nothing is oversold however many requests arrive.
268
+
269
+ ---
270
+
271
+ ## Errors
272
+
273
+ Booking calls fail with HTTP 409 and an `errorCode` you can show or act on:
274
+
275
+ | Code | When |
276
+ |---|---|
277
+ | `SLOT_FULL` | Not enough places left (`details.remaining`) |
278
+ | `SLOT_CLOSED` | The slot isn't open |
279
+ | `RESOURCE_TAKEN` | The time isn't free on that resource (`details` names it) |
280
+ | `PER_CONTACT_LIMIT` / `QUANTITY_LIMIT` | Per-person / per-booking limit |
281
+ | `HOLD_EXPIRED` | The hold ran out before confirm / move |
282
+ | `INVALID_STATE` | e.g. confirming a cancelled booking |
283
+ | `SLOT_OVERBOOKED` / `SLOT_HAS_BOOKINGS` | Lowering capacity below what's booked / changing a booked slot's time or deleting it |
284
+ | `TOO_SOON` / `TOO_FAR_AHEAD` / `IN_THE_PAST` / `DURATION_NOT_ALLOWED` | Public time rules |
285
+ | `OUTSIDE_SCHEDULE` / `NO_SCHEDULE` / `SCHEDULE_INVALID` | Public schedule check |
286
+ | `CUTOFF_PASSED` | Owner action inside its cutoff |
287
+
288
+ `403 FORBIDDEN` when the app's rules don't allow the call; `404` for unknown ids.
289
+
290
+ ---
291
+
292
+ ## Events
293
+
294
+ Recorded after each write commits, for flows and app functions (`trigger: { type: 'event', eventTypes: [...] }`):
295
+
296
+ `booking.created`, `booking.confirmed`, `booking.cancelled`, `booking.moved`, `booking.completed`,
297
+ `booking.no_show`, `booking.hold_expired`, `slot.full`, `slot.place_freed` (for waitlist promotion).
298
+ Context: `{ appId, status, slotIds }` (bookings) / `{ appId, remaining }` (slots).
299
+
300
+ Reminders before a booking (`booking.starts_in`) are coming; until then, use a cron function that lists
301
+ upcoming bookings.
302
+
303
+ ---
304
+
305
+ ## API reference
306
+
307
+ All take `(collectionId, appId, …)`; `admin` is the last parameter where both exist.
308
+
309
+ | Call | Admin | Public |
310
+ |---|---|---|
311
+ | `bookings.create(input, admin?)` | any status, any field | per `publicCreate.bookings` |
312
+ | `bookings.list(params?)` | ✓ | — (use `mine`) |
313
+ | `bookings.mine(params?)` | — | signed-in booker |
314
+ | `bookings.get(id, admin?, { editToken }?)` | ✓ | booker / token |
315
+ | `bookings.update(id, input)` | ✓ | — |
316
+ | `bookings.confirm / cancel / move(…, admin?, { editToken }?)` | ✓ | per `ownerActions.bookings` |
317
+ | `bookings.extendHold / complete / noShow` | ✓ | — |
318
+ | `bookings.freebusy(q, admin?)` | ✓ | if `publicRead.bookings.freebusy` |
319
+ | `bookings.slots.create / createMany / update / remove` | ✓ | — |
320
+ | `bookings.slots.list / get(…, admin?)` | ✓ | open, public slots |
321
+ | `bookings.blocks.create / list / remove` | ✓ | — |
322
+ | `utils.freeTimes / withinSchedule / openIntervals / subtractIntervals / validateSchedule` | pure helpers | |
323
+
324
+ REST: `/{admin|public}/collection/:cId/app/:appId/bookings[/slots|/blocks|/freebusy|/mine|/:id[/confirm|/cancel|/move|/extend-hold|/complete|/no-show]]`.
@@ -88,6 +88,7 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
88
88
  | **Proof Ownership Transfer** | `docs/proof-ownership-transfer.md` | Moving a proof's single owner (directed transfer / open release), accept/cancel, and the state machine |
89
89
  | **appConfig / Feature Flags** | `docs/appConfig.md` | `appConfig` settings contract — installed apps, `system.features`/`entitledAppGroups`/`meters`, `isFeatureEnabled()` helper |
90
90
  | **App Objects** | `docs/app-objects.md` | Queryable domain objects — `app.records` / `app.cases` / `app.threads`: JSONB zones, visibility, policies, aggregations |
91
+ | **Bookings** | `docs/bookings.md` | Slots, bookings and allocations for events and appointments: no double-booking, holds, per-app public rules, `utils.freeTimes` |
91
92
  | **App Data Storage** | `docs/app-data-storage.md` | Decision guide: pick between `appConfiguration` / `userAppData` / `app.records` |
92
93
  | **Attestations** | `docs/attestations.md` | Postgres attestations API (proof-level user/admin data; replaces the legacy Firestore path) |
93
94
  | **Caching** | `docs/caching.md` | SDK GET cache tiers + TTLs, `invalidateCache({ exact })`, and `force`/push guidance |
package/dist/http.js CHANGED
@@ -216,6 +216,9 @@ function shouldSkipCache(path) {
216
216
  // Never cache /me endpoints (contact/me, loyalty/me, etc.) — always user-specific.
217
217
  if (/\/me(\?|$)/i.test(path))
218
218
  return true;
219
+ // Bookings change by the second (places left, busy times, a booking's status) — never serve them stale.
220
+ if (/\/app\/[^/]+\/bookings(\/|\?|$)/i.test(path))
221
+ return true;
219
222
  return false;
220
223
  }
221
224
  /** Evict the oldest (LRU) entry when the cache is at capacity. */