@proveanything/smartlinks 2.0.69 → 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.
- package/dist/api/asset.d.ts +5 -0
- package/dist/api/asset.js +11 -0
- package/dist/api/authKit.d.ts +28 -4
- package/dist/api/authKit.js +35 -1
- package/dist/api/bookings.d.ts +89 -0
- package/dist/api/bookings.js +179 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/docs/API_SUMMARY.md +503 -11
- package/dist/docs/app-objects.md +4 -0
- package/dist/docs/assets.md +63 -2
- package/dist/docs/auth-kit.md +46 -5
- package/dist/docs/bookings.md +324 -0
- package/dist/docs/members.md +182 -0
- package/dist/docs/overview.md +477 -475
- package/dist/docs/server-functions.md +4 -1
- package/dist/http.js +3 -0
- package/dist/openapi.yaml +5254 -4037
- package/dist/testing/index.js +1 -1
- package/dist/types/appManifest.d.ts +16 -4
- package/dist/types/asset.d.ts +25 -1
- package/dist/types/authKit.d.ts +58 -7
- package/dist/types/bookings.d.ts +301 -0
- package/dist/types/bookings.js +3 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/freeTimes.d.ts +57 -0
- package/dist/utils/freeTimes.js +206 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/docs/API_SUMMARY.md +503 -11
- package/docs/app-objects.md +4 -0
- package/docs/assets.md +63 -2
- package/docs/auth-kit.md +46 -5
- package/docs/bookings.md +324 -0
- package/docs/members.md +182 -0
- package/docs/overview.md +477 -475
- package/docs/server-functions.md +4 -1
- package/openapi.yaml +5254 -4037
- package/package.json +1 -1
package/docs/assets.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Reference for asset types, endpoints, and public (token-based) uploads.
|
|
4
4
|
|
|
5
|
+
> **Showing an asset in a page, email or other static content? Use `assetUrl`.** It's the asset's own
|
|
6
|
+
> address and always shows its current file, so replacing the file needs no code change or redeploy. See
|
|
7
|
+
> [Asset addresses: `assetUrl` vs `url`](#asset-addresses-asseturl-vs-url).
|
|
8
|
+
|
|
5
9
|
---
|
|
6
10
|
|
|
7
11
|
## Asset object
|
|
@@ -17,7 +21,9 @@ interface Asset {
|
|
|
17
21
|
appId: string | null // app that owns this asset, e.g. 'homepage'
|
|
18
22
|
|
|
19
23
|
// File
|
|
20
|
-
url: string //
|
|
24
|
+
url: string // address of the current FILE — versioned, changes when the file is replaced
|
|
25
|
+
assetUrl: string | null // the ASSET's own address — always its current file; use in static content
|
|
26
|
+
// (see "Asset addresses" below)
|
|
21
27
|
thumbnail: string | null // CDN URL of WebP thumbnail (max 512px longest edge, no crop)
|
|
22
28
|
// Always .webp — null until thumbnail generation has run
|
|
23
29
|
name: string // original filename
|
|
@@ -84,6 +90,12 @@ interface AssetRef {
|
|
|
84
90
|
}
|
|
85
91
|
```
|
|
86
92
|
|
|
93
|
+
An `AssetRef` has no `assetUrl`, on purpose. A product's image is part of the product record: to change it,
|
|
94
|
+
point the record at a new asset (upload, then save the product). That's a record change, so the product's
|
|
95
|
+
change event fires and integrations and syndication pick it up. Retailers cache images by URL, so the new
|
|
96
|
+
address is what makes them fetch the new image. Show product images from the record, never copied into
|
|
97
|
+
static HTML.
|
|
98
|
+
|
|
87
99
|
Render the thumbnail wherever a compact preview is needed; fall back to `url` if null:
|
|
88
100
|
|
|
89
101
|
```typescript
|
|
@@ -185,7 +197,10 @@ await Api.asset.updateAdmin({
|
|
|
185
197
|
POST /asset/:assetId/replace
|
|
186
198
|
```
|
|
187
199
|
|
|
188
|
-
Replaces the file; the previous URL is snapshotted into `versions[]`.
|
|
200
|
+
Replaces the file; the previous URL is snapshotted into `versions[]`. The id and `assetUrl` stay the same,
|
|
201
|
+
and `assetUrl` serves the new file straight away. `url` becomes the new file's address. See
|
|
202
|
+
[Asset addresses](#asset-addresses-asseturl-vs-url). For a large file, use a resumable upload with
|
|
203
|
+
`replaceAssetId`.
|
|
189
204
|
|
|
190
205
|
```typescript
|
|
191
206
|
await Api.asset.replaceFile({
|
|
@@ -304,6 +319,7 @@ interface CreateResumableUploadOptions {
|
|
|
304
319
|
appId?: string
|
|
305
320
|
admin?: boolean // admin route (default is the public route)
|
|
306
321
|
token?: string // upload token for public/unauthenticated uploads
|
|
322
|
+
replaceAssetId?: string // admin: replace this asset's file (any size) instead of creating a new asset
|
|
307
323
|
}
|
|
308
324
|
|
|
309
325
|
interface ResumableUploadHandle {
|
|
@@ -321,6 +337,51 @@ interface ResumableUploadHandle {
|
|
|
321
337
|
- On completion, `start()`/`resume()` resolves to the finalized `Asset` record.
|
|
322
338
|
- `pause()` causes the in-flight `start()`/`resume()` promise to reject with an `UploadPausedError`; call `resume()` to continue.
|
|
323
339
|
- `asset.upload()` also now accepts an `AbortSignal` (`upload({ ..., signal })`) for cancelling a stalled single-shot upload.
|
|
340
|
+
- With `replaceAssetId`, the completed upload becomes a new version of that asset (same id and `assetUrl`), so a
|
|
341
|
+
200 MB video can be swapped without touching the pages that show it.
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## Asset addresses: `assetUrl` vs `url`
|
|
346
|
+
|
|
347
|
+
An asset is a named thing in the library, such as "Hero image". Its file can be replaced over time, so it has
|
|
348
|
+
two addresses:
|
|
349
|
+
|
|
350
|
+
| Field | Is the address of | When the file is replaced |
|
|
351
|
+
|---|---|---|
|
|
352
|
+
| `assetUrl` | the **asset**: `https://cdn.smartlinks.app/sites/{collectionId}/a/{assetId}` | stays the same, and shows the new file |
|
|
353
|
+
| `url` | the asset's current **file** | becomes the new file's address (the old one moves to `versions[]`) |
|
|
354
|
+
|
|
355
|
+
### Which to use
|
|
356
|
+
|
|
357
|
+
| You're showing it in… | Use |
|
|
358
|
+
|---|---|
|
|
359
|
+
| Static content: a built site's HTML or CSS, an email, a banner, anything written once and published | `assetUrl` |
|
|
360
|
+
| A product or collection image (`heroImage`, `additionalImages`, `logoImage`, `headerImage`) | the record's `AssetRef`, read from the record (see [AssetRef](#assetref--slim-embedded-shape)) |
|
|
361
|
+
| Version history, a "compare versions" view, an exact file for an audit | `url` and `versions[]` |
|
|
362
|
+
|
|
363
|
+
```html
|
|
364
|
+
<!-- In a built page: swap "Hero image" in the library and this shows the new one, no rebuild -->
|
|
365
|
+
<img src="https://cdn.smartlinks.app/sites/my-collection/a/3f0c…" alt="…">
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
const hero = await Api.asset.getAdmin('my-collection', heroId)
|
|
370
|
+
const src = hero.assetUrl ?? hero.url // fall back for outside links and older assets
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### How it behaves
|
|
374
|
+
|
|
375
|
+
- **Replace** (`replaceFile`, or a resumable upload with `replaceAssetId`): `assetUrl` shows the new file. The CDN
|
|
376
|
+
copy is cleared at once, and browsers re-check within a minute. There's no redeploy and no extra API call.
|
|
377
|
+
- **Delete**: `assetUrl` stops serving (the asset is withdrawn). **Restore** brings it back.
|
|
378
|
+
- `assetUrl` is `null` for an outside link (an asset that is a link to someone else's file). Fall back to `url`.
|
|
379
|
+
- Assets made before asset addresses existed have `assetUrl: null` until they're given one:
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
const a = await Api.asset.ensureAssetUrl('my-collection', 'abc123')
|
|
383
|
+
a.assetUrl // https://cdn.smartlinks.app/sites/my-collection/a/abc123
|
|
384
|
+
```
|
|
324
385
|
|
|
325
386
|
---
|
|
326
387
|
|
package/docs/auth-kit.md
CHANGED
|
@@ -305,11 +305,16 @@ clearPersistedTokens();
|
|
|
305
305
|
|
|
306
306
|
---
|
|
307
307
|
|
|
308
|
-
## Step-up MFA
|
|
308
|
+
## Step-up MFA
|
|
309
309
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
310
|
+
Factors: **email code, SMS code, authenticator app (`totp`), recovery codes**. The shared login UI
|
|
311
|
+
(`@proveanything/smartlinks-auth-ui`) has the challenge and the Settings → Security screens. WhatsApp
|
|
312
|
+
codes and passkeys are planned; don't build against them yet.
|
|
313
|
+
|
|
314
|
+
**Authenticator apps** (Google Authenticator, Microsoft Authenticator, 1Password, Authy…) are the
|
|
315
|
+
recommended second step: nothing is sent (no SMS cost, nothing to intercept), and they are offered first
|
|
316
|
+
when someone has one. A client can switch any factor off with `mfa.factorsEnabled` (e.g.
|
|
317
|
+
`{ totp: false }`).
|
|
313
318
|
|
|
314
319
|
The step-up gate applies to **every login method that issues a session**:
|
|
315
320
|
|
|
@@ -408,15 +413,25 @@ await authKit.confirmEmailMfa(clientId, mfaSessionToken, code);
|
|
|
408
413
|
const { mfaSessionToken: smsToken } = await authKit.enrollSmsMfa(clientId, '+61400000000');
|
|
409
414
|
await authKit.confirmSmsMfa(clientId, smsToken, code);
|
|
410
415
|
|
|
416
|
+
// Enroll an authenticator app: show otpauthUri as a QR code (and secret for typing in),
|
|
417
|
+
// then confirm with the 6-digit code the app shows
|
|
418
|
+
const { mfaSessionToken: appToken, otpauthUri, secret } = await authKit.enrollTotpMfa(clientId);
|
|
419
|
+
await authKit.confirmTotpMfa(clientId, appToken, codeFromApp);
|
|
420
|
+
|
|
411
421
|
// Recovery codes — plaintext, shown exactly once; nothing else in the API returns them again
|
|
412
422
|
const { recoveryCodes } = await authKit.generateMfaRecoveryCodes(clientId, password);
|
|
413
423
|
|
|
414
424
|
// Inspect / remove
|
|
415
425
|
const factors = await authKit.getMfaFactors(clientId);
|
|
416
|
-
// factors: { enrolledFactors: { email?, sms? }, recoveryCodesRemaining, mfaEnabledForClient }
|
|
426
|
+
// factors: { enrolledFactors: { email?, sms?, totp? }, recoveryCodesRemaining, mfaEnabledForClient }
|
|
417
427
|
await authKit.removeMfaFactor(clientId, 'sms', password);
|
|
418
428
|
```
|
|
419
429
|
|
|
430
|
+
At sign-in, `MFA_REQUIRED` lists `availableFactors` with `preferredFactor` (`totp`, then `sms`, then
|
|
431
|
+
`email`). For `totp`, `mfaChallengeSend(clientId, token, 'totp')` sends nothing; it just says the code
|
|
432
|
+
comes from the app. Then call `mfaChallengeVerify` with that code, as for the others. Each authenticator
|
|
433
|
+
code works once. The secret is sealed on the server and never returned after `enrollTotpMfa`.
|
|
434
|
+
|
|
420
435
|
> Enrollment endpoints don't currently check the client's `mfa.mode` — enrolling is possible
|
|
421
436
|
> even when MFA is configured `'off'` for a client. Those factors simply won't be challenged
|
|
422
437
|
> at login until an admin turns the mode on (via the existing admin AuthKit config update,
|
|
@@ -633,6 +648,31 @@ await authKit.setIdentity(collectionId, authKitId, { mode: 'own' })
|
|
|
633
648
|
resets) works the same way; nothing changes in the login UI or your code.
|
|
634
649
|
- `identity` is read-only on `create` / `update`; it is shown on the config (`authKit.get`).
|
|
635
650
|
|
|
651
|
+
### Invitation only
|
|
652
|
+
|
|
653
|
+
A site with its own logins can let in only the people it invites:
|
|
654
|
+
|
|
655
|
+
```ts
|
|
656
|
+
await authKit.update(collectionId, authKitId, { signUp: 'invite' }) // 'open' (default) | 'invite'
|
|
657
|
+
await authKit.invite(collectionId, authKitId, { email: 'jo@example.com', firstName: 'Jo', customFields: { level: 'member' } })
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
- **Enforced on the server, for every route.** Nobody without an account can get one: `register`,
|
|
661
|
+
`verifyMagicLink`, `verifyPhoneCode`, Google, Apple, LinkedIn and WhatsApp answer `SIGN_UP_CLOSED`
|
|
662
|
+
(403). `sendMagicLink` and `sendPhoneCode` answer as if sent, but send nothing to someone who isn't a
|
|
663
|
+
member, so nobody can find out who is.
|
|
664
|
+
- **`invite`** makes the person's account if they have none, finds or creates their contact in that
|
|
665
|
+
collection with the fields you pass, links the two, and emails a sign-in link to the site (`redirectUrl`,
|
|
666
|
+
default the kit's) that works once for 7 days. It lands like any magic link (`mode=magicLink&token=…`).
|
|
667
|
+
Inviting someone again sends a fresh link. The link is never returned to the admin.
|
|
668
|
+
- **Invited members then sign in any way the site offers.** A magic link, "forgot password" to set a
|
|
669
|
+
password, or Google/Apple/LinkedIn with the same verified email: an invited account nobody has signed
|
|
670
|
+
in to yet can be claimed that way.
|
|
671
|
+
- `signUp` is on the public config (`load`): hide "Create an account" when it's `invite`. Setting it
|
|
672
|
+
needs own logins first (`OWN_LOGINS_REQUIRED`, 409).
|
|
673
|
+
- On any site, a member signing in with a verified email or phone is attached to the contact the
|
|
674
|
+
collection already has for it, so fields the staff set beforehand (a level) are theirs.
|
|
675
|
+
|
|
636
676
|
## Relationship to other parts of the SDK
|
|
637
677
|
|
|
638
678
|
| Concern | Where it lives |
|
|
@@ -646,5 +686,6 @@ await authKit.setIdentity(collectionId, authKitId, { mode: 'own' })
|
|
|
646
686
|
|
|
647
687
|
## Further reading
|
|
648
688
|
|
|
689
|
+
- [members.md](members.md) — members with a level: clubs, leagues, members-only content
|
|
649
690
|
- [app-data-storage.md](app-data-storage.md) — storing user-specific data after login
|
|
650
691
|
- [app-manifest.md](app-manifest.md) — `app.admin.json` setup questions for configuring `clientId`
|
package/docs/bookings.md
ADDED
|
@@ -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]]`.
|