@proveanything/smartlinks 2.0.71 → 2.1.0

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,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.0.71 | Generated: 2026-10-24T17:23:20.711Z
3
+ Version: 2.1.0 | Generated: 2026-10-24T18:38:40.481Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -2801,9 +2801,41 @@ interface ServerFunctionInteractionEvent {
2801
2801
  }
2802
2802
  ```
2803
2803
 
2804
+ **ServerFunctionBookings** (interface)
2805
+ ```typescript
2806
+ interface ServerFunctionBookings {
2807
+ create(input: CreateBookingInput): Promise<Booking & { replayed: boolean }>;
2808
+ get(bookingId: string): Promise<Booking | null>;
2809
+ query(params?: BookingListQueryParams): Promise<PaginatedResponse<Booking>>;
2810
+ mine(params?: BookingListQueryParams): Promise<PaginatedResponse<Booking>>;
2811
+ update(bookingId: string, fields: UpdateBookingInput): Promise<Booking>;
2812
+ confirm(bookingId: string): Promise<Booking>;
2813
+ cancel(bookingId: string, opts?: { reason?: string }): Promise<Booking>;
2814
+ move(bookingId: string, input: MoveBookingInput): Promise<Booking>;
2815
+ extendHold(bookingId: string, holdMinutes: number): Promise<Booking>;
2816
+ complete(bookingId: string): Promise<Booking>;
2817
+ noShow(bookingId: string): Promise<Booking>;
2818
+ freebusy(q: FreeBusyQuery): Promise<FreeBusyResponse>;
2819
+ slots: {
2820
+ create(input: CreateSlotInput): Promise<BookingSlot>;
2821
+ createMany(inputs: CreateSlotInput[], opts?: { seriesId?: string }): Promise<BookingSlot[]>;
2822
+ get(slotId: string): Promise<BookingSlot | null>;
2823
+ query(params?: SlotListQueryParams): Promise<PaginatedResponse<BookingSlot>>;
2824
+ update(slotId: string, fields: UpdateSlotInput): Promise<BookingSlot>;
2825
+ delete(slotId: string): Promise<{ success: boolean }>;
2826
+ };
2827
+ blocks: {
2828
+ create(input: CreateBlockInput): Promise<BookingAllocation>;
2829
+ query(params?: BlockListQueryParams): Promise<PaginatedResponse<BookingAllocation>>;
2830
+ delete(blockId: string): Promise<{ success: boolean }>;
2831
+ };
2832
+ }
2833
+ ```
2834
+
2804
2835
  **ServerFunctionSl** (interface)
2805
2836
  ```typescript
2806
2837
  interface ServerFunctionSl {
2838
+ bookings: ServerFunctionBookings;
2807
2839
  appRecords: { claim(fields: Record<string, any>): Promise<AppRecordClaimResult>; [method: string]: any };
2808
2840
  counters: ServerFunctionCounters;
2809
2841
  tags: { resolve(id: string): Promise<ResolvedTag | null> };
@@ -927,6 +927,9 @@ The server reads this document at request time — no cache invalidation or serv
927
927
 
928
928
  ### Policy Structure
929
929
 
930
+ Bookings use the same policy as a fourth object type, `publicCreate.bookings`, with booking-specific
931
+ `limits` and `rules` — see [bookings.md](bookings.md).
932
+
930
933
  Each object type (`cases`, `threads`, `records`) has **independent branches** for anonymous and authenticated callers. Each branch carries its own `allow` flag, optional field overrides (`enforce`), and — for records — optional edit-token config (`edit`).
931
934
 
932
935
  ```typescript
@@ -248,6 +248,33 @@ Off by default — most apps serve free times through their own function.
248
248
 
249
249
  ---
250
250
 
251
+ ## In a server function — `ctx.sl.bookings`
252
+
253
+ When public booking is off, your app books through its own server function. `ctx.sl.bookings` is
254
+ the same API, admin-side, for **your app's** bookings only (`sl:bookings:read` / `sl:bookings:write`):
255
+
256
+ ```ts
257
+ // manifest: { name: 'book', trigger: { type: 'http' }, visibility: 'public', authority: 'collection',
258
+ // capabilities: ['sl:bookings:write', 'sl:contacts:write'] }
259
+ export async function book(ctx, req) {
260
+ const me = await ctx.sl.contacts.forCaller() // null when signed out
261
+ // … your rules: hours (utils.withinSchedule), a queue token, a member level, a price …
262
+ const b = await ctx.sl.bookings.create({
263
+ resourceType: 'record', resourceId: req.body.staffId,
264
+ startsAt: req.body.startsAt, endsAt: req.body.endsAt, bufferAfter: 10,
265
+ contactId: me?.contactId, status: 'confirmed', idempotencyKey: req.body.requestId,
266
+ })
267
+ return Response.json({ id: b.id, startsAt: b.startsAt })
268
+ }
269
+ ```
270
+
271
+ `create`, `get`, `query`, `mine` (the caller's own), `update`, `confirm`, `cancel`, `move`,
272
+ `extendHold`, `complete`, `noShow`, `freebusy`, `slots.*`, `blocks.*`. Errors carry `code`,
273
+ `status` and `details` exactly as over HTTP. The local test harness
274
+ (`@proveanything/smartlinks/testing`) runs bookings in memory with the same rules.
275
+
276
+ ---
277
+
251
278
  ## Payments and high-demand sales
252
279
 
253
280
  The platform takes no payments. It gives you what a payment or credits flow needs:
@@ -167,7 +167,7 @@ Declare the minimum you need.
167
167
 
168
168
  | Capability | Grants |
169
169
  |---|---|
170
- | `sl:<resource>:read` / `sl:<resource>:write` | SDK access to that resource, e.g. `sl:products:read`, `sl:records:write`, `sl:attestations:write`, `sl:contacts:write`. |
170
+ | `sl:<resource>:read` / `sl:<resource>:write` | SDK access to that resource, e.g. `sl:products:read`, `sl:records:write`, `sl:attestations:write`, `sl:contacts:write`, `sl:bookings:write`. |
171
171
  | `network` | `ctx.fetch` to any host. |
172
172
  | `network:<host>` | `ctx.fetch` to that host only (repeat for several). Prefer this over blanket `network`. |
173
173
  | `network:*.<domain>` | `ctx.fetch` to any subdomain of `<domain>` (not `<domain>` itself), for a customer's own endpoint at a provider, e.g. `network:*.servicebus.windows.net`. `<domain>` needs at least two labels (`*.com` is not honoured). |
@@ -517,6 +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
521
 
521
522
  Notes:
522
523
  - **Talking to the SmartLinks core is `ctx.sl`, not HTTP.** The common pattern — *validate /
@@ -611,6 +612,33 @@ await ctx.sl.interactions.record({
611
612
  })
612
613
  ```
613
614
 
615
+ ### `bookings.*` — places and time that can't be oversold
616
+
617
+ `ctx.sl.bookings` is your app's own bookings, slots and blocks (`sl:bookings:read` / `write`). A
618
+ function books **admin-side**: any status, the admin zone, any contact — your app's public booking
619
+ rules (`publicCreate.bookings`) don't apply, because the function *is* your rules. The platform still
620
+ guarantees no overbooking and no overlapping time, so a clash throws (`code`: `SLOT_FULL`,
621
+ `RESOURCE_TAKEN`, `PER_CONTACT_LIMIT`, `HOLD_EXPIRED`, …; with `status` and `details`). Pass an
622
+ `idempotencyKey` so a retried request returns the first booking (`replayed: true`).
623
+
624
+ ```ts
625
+ const me = await ctx.sl.contacts.forCaller()
626
+ if (!me) return Response.json({ error: 'sign in' }, { status: 401 })
627
+ try {
628
+ const booking = await ctx.sl.bookings.create({
629
+ slotId: body.slotId, quantity: body.quantity, contactId: me.contactId,
630
+ status: 'held', holdMinutes: 15, idempotencyKey: body.requestId,
631
+ })
632
+ return Response.json({ id: booking.id, holdExpiresAt: booking.holdExpiresAt })
633
+ } catch (e: any) {
634
+ if (e.code === 'SLOT_FULL') return Response.json({ error: 'sold out', remaining: e.details?.remaining }, { status: 409 })
635
+ throw e
636
+ }
637
+ ```
638
+
639
+ The local harness runs bookings in memory with the same rules (capacity, per-person limits, no
640
+ overlaps, idempotency, holds): `ctx.memory.bookings`.
641
+
614
642
  ---
615
643
 
616
644
  ## Platform tools — PDFs, labels, barcodes
package/dist/openapi.yaml CHANGED
@@ -20224,9 +20224,23 @@ components:
20224
20224
  type: string
20225
20225
  required:
20226
20226
  - interactionId
20227
+ ServerFunctionBookings:
20228
+ type: object
20229
+ properties:
20230
+ slots:
20231
+ type: object
20232
+ additionalProperties: true
20233
+ blocks:
20234
+ type: object
20235
+ additionalProperties: true
20236
+ required:
20237
+ - slots
20238
+ - blocks
20227
20239
  ServerFunctionSl:
20228
20240
  type: object
20229
20241
  properties:
20242
+ bookings:
20243
+ $ref: "#/components/schemas/ServerFunctionBookings"
20230
20244
  appRecords:
20231
20245
  type: object
20232
20246
  additionalProperties: true
@@ -20249,6 +20263,7 @@ components:
20249
20263
  appData:
20250
20264
  $ref: "#/components/schemas/AppDataHandle"
20251
20265
  required:
20266
+ - bookings
20252
20267
  - appRecords
20253
20268
  - counters
20254
20269
  - tags
@@ -0,0 +1,75 @@
1
+ export interface BookingsMemory {
2
+ slots: any[];
3
+ bookings: any[];
4
+ allocations: any[];
5
+ }
6
+ export declare function createBookingsMemory(): BookingsMemory;
7
+ export declare function bookingsMemoryImpl(mem: BookingsMemory, caller?: {
8
+ contactId?: string | null;
9
+ }): {
10
+ create(input?: any): Promise<any>;
11
+ get(bookingId: string): Promise<any>;
12
+ query(params?: any): Promise<{
13
+ data: any[];
14
+ pagination: {
15
+ total: number;
16
+ limit: number;
17
+ offset: number;
18
+ hasMore: boolean;
19
+ };
20
+ }>;
21
+ mine(params?: any): Promise<{
22
+ data: any[];
23
+ pagination: {
24
+ total: number;
25
+ limit: number;
26
+ offset: number;
27
+ hasMore: boolean;
28
+ };
29
+ }>;
30
+ update(bookingId: string, fields?: any): Promise<any>;
31
+ confirm(bookingId: string): Promise<any>;
32
+ cancel(bookingId: string, opts?: any): Promise<any>;
33
+ move(bookingId: string, input?: any): Promise<any>;
34
+ extendHold(bookingId: string, holdMinutes: number): Promise<any>;
35
+ complete(bookingId: string): Promise<any>;
36
+ noShow(bookingId: string): Promise<any>;
37
+ freebusy(q?: any): Promise<{
38
+ from: string;
39
+ to: string;
40
+ resources: any;
41
+ }>;
42
+ slots: {
43
+ create(input?: any): Promise<any>;
44
+ createMany(inputs: any[], opts?: any): Promise<any[]>;
45
+ get(slotId: string): Promise<any>;
46
+ query(params?: any): Promise<{
47
+ data: any[];
48
+ pagination: {
49
+ total: number;
50
+ limit: number;
51
+ offset: number;
52
+ hasMore: boolean;
53
+ };
54
+ }>;
55
+ update(slotId: string, fields?: any): Promise<any>;
56
+ delete(slotId: string): Promise<{
57
+ success: boolean;
58
+ }>;
59
+ };
60
+ blocks: {
61
+ create(input?: any): Promise<any>;
62
+ query(params?: any): Promise<{
63
+ data: any[];
64
+ pagination: {
65
+ total: number;
66
+ limit: number;
67
+ offset: number;
68
+ hasMore: boolean;
69
+ };
70
+ }>;
71
+ delete(blockId: string): Promise<{
72
+ success: boolean;
73
+ }>;
74
+ };
75
+ };
@@ -0,0 +1,413 @@
1
+ // src/testing/bookingsMemory.ts
2
+ //
3
+ // In-memory ctx.sl.bookings for the server-function test harness — the platform's booking rules,
4
+ // small enough to read: slot capacity, per-person limits, no overlapping exclusive time, idempotent
5
+ // creates, holds that expire, all-or-nothing writes. Each operation checks and writes without an
6
+ // `await` in between, so concurrent calls in one test behave like the database: of ten requests for
7
+ // the last seat, exactly one wins. Errors carry the platform's `code` / `status` / `details`.
8
+ export function createBookingsMemory() {
9
+ return { slots: [], bookings: [], allocations: [] };
10
+ }
11
+ const ACTIVE = new Set(['held', 'pending', 'confirmed', 'completed', 'no_show']);
12
+ function fail(code, message, status = 409, details) {
13
+ throw Object.assign(new Error(message), Object.assign({ code, status }, (details ? { details } : {})));
14
+ }
15
+ const ms = (v) => (v instanceof Date ? v.getTime() : new Date(v).getTime());
16
+ const iso = (t) => new Date(t).toISOString();
17
+ const overlaps = (a, b) => ms(a.startsAt) < ms(b.endsAt) && ms(b.startsAt) < ms(a.endsAt);
18
+ const copy = (v) => JSON.parse(JSON.stringify(v));
19
+ const page = (all, params = {}) => {
20
+ const limit = Math.min(Number(params.limit) || 25, 100);
21
+ const offset = Number(params.offset) || 0;
22
+ const data = all.slice(offset, offset + limit).map(copy);
23
+ return { data, pagination: { total: all.length, limit, offset, hasMore: offset + data.length < all.length } };
24
+ };
25
+ export function bookingsMemoryImpl(mem, caller = {}) {
26
+ let seq = 1;
27
+ const id = (p) => `${p}-${seq++}-${Math.random().toString(36).slice(2, 8)}`;
28
+ const now = () => Date.now();
29
+ const slotOf = (slotId) => mem.slots.find((s) => s.id === slotId && !s.deletedAt);
30
+ const withAllocations = (b) => (Object.assign(Object.assign({}, b), { allocations: mem.allocations.filter((a) => a.bookingId === b.id) }));
31
+ const remaining = (s) => Math.max(0, s.capacity - s.booked);
32
+ const slotView = (s) => copy(Object.assign(Object.assign({}, s), { remaining: remaining(s) }));
33
+ // Turn a request into claims (same forms as the platform).
34
+ function claimsOf(input) {
35
+ var _a, _b, _c, _d;
36
+ const quantity = input.quantity == null ? 1 : Number(input.quantity);
37
+ if (!Number.isInteger(quantity) || quantity < 1)
38
+ fail('BAD_REQUEST', 'quantity must be a positive integer', 400);
39
+ const raw = Array.isArray(input.allocations) && input.allocations.length ? input.allocations : [{
40
+ slotId: input.slotId, quantity,
41
+ resourceType: (_a = input.resourceType) !== null && _a !== void 0 ? _a : (_b = input.resource) === null || _b === void 0 ? void 0 : _b.type, resourceId: (_c = input.resourceId) !== null && _c !== void 0 ? _c : (_d = input.resource) === null || _d === void 0 ? void 0 : _d.id,
42
+ }];
43
+ return raw.map((c) => {
44
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
45
+ if (c.slotId)
46
+ return { kind: 'capacity', slotId: c.slotId, quantity: c.quantity == null ? quantity : Number(c.quantity) };
47
+ const startsAt = (_a = c.startsAt) !== null && _a !== void 0 ? _a : input.startsAt;
48
+ const endsAt = (_b = c.endsAt) !== null && _b !== void 0 ? _b : input.endsAt;
49
+ if (!((_c = c.resourceType) !== null && _c !== void 0 ? _c : (_d = c.resource) === null || _d === void 0 ? void 0 : _d.type) || !((_e = c.resourceId) !== null && _e !== void 0 ? _e : (_f = c.resource) === null || _f === void 0 ? void 0 : _f.id))
50
+ fail('BAD_REQUEST', 'A booking needs a slotId or a resource and a time', 400);
51
+ if (!startsAt || !endsAt || ms(endsAt) <= ms(startsAt))
52
+ fail('BAD_REQUEST', 'startsAt and endsAt are required, with endsAt after startsAt', 400);
53
+ const before = ((_h = (_g = c.bufferBefore) !== null && _g !== void 0 ? _g : input.bufferBefore) !== null && _h !== void 0 ? _h : 0) * 60000;
54
+ const after = ((_k = (_j = c.bufferAfter) !== null && _j !== void 0 ? _j : input.bufferAfter) !== null && _k !== void 0 ? _k : 0) * 60000;
55
+ return {
56
+ kind: 'exclusive', resourceType: String((_l = c.resourceType) !== null && _l !== void 0 ? _l : (_m = c.resource) === null || _m === void 0 ? void 0 : _m.type), resourceId: String((_o = c.resourceId) !== null && _o !== void 0 ? _o : (_p = c.resource) === null || _p === void 0 ? void 0 : _p.id),
57
+ startsAt: iso(ms(startsAt)), endsAt: iso(ms(endsAt)), blockStartsAt: iso(ms(startsAt) - before), blockEndsAt: iso(ms(endsAt) + after),
58
+ };
59
+ });
60
+ }
61
+ // Check every claim, then write them all — all-or-nothing, with no await in between.
62
+ function claimAll(bookingId, claims, contactId, active, ignoreBookingId) {
63
+ const writes = [];
64
+ const out = [];
65
+ for (const c of claims) {
66
+ if (c.kind === 'capacity') {
67
+ const s = slotOf(c.slotId);
68
+ if (!s)
69
+ fail('NOT_FOUND', 'Slot not found', 404);
70
+ if (active) {
71
+ if (s.status !== 'open')
72
+ fail('SLOT_CLOSED', 'This slot is not taking bookings', 409, { slotId: s.id });
73
+ if (s.booked + c.quantity > s.capacity)
74
+ fail('SLOT_FULL', 'Not enough places left on this slot', 409, { slotId: s.id, remaining: remaining(s) });
75
+ if (s.perContactLimit) {
76
+ if (c.quantity > s.perContactLimit)
77
+ fail('PER_CONTACT_LIMIT', `At most ${s.perContactLimit} per person on this slot`, 409, { slotId: s.id, limit: s.perContactLimit });
78
+ if (contactId) {
79
+ const held = mem.allocations.filter((a) => a.slotId === s.id && a.active && a.mode === 'capacity' && a.bookingId !== ignoreBookingId &&
80
+ mem.bookings.find((b) => b.id === a.bookingId && b.contactId === contactId)).reduce((n, a) => n + a.quantity, 0);
81
+ if (held + c.quantity > s.perContactLimit)
82
+ fail('PER_CONTACT_LIMIT', `At most ${s.perContactLimit} per person on this slot`, 409, { slotId: s.id, limit: s.perContactLimit, held });
83
+ }
84
+ }
85
+ writes.push(() => { s.booked += c.quantity; });
86
+ }
87
+ out.push({ bookingId, slotId: s.id, resourceType: 'slot', resourceId: s.id, startsAt: s.startsAt, endsAt: s.endsAt, quantity: c.quantity, mode: 'capacity', active, visible: [s.startsAt, s.endsAt] });
88
+ }
89
+ else {
90
+ const a = { bookingId, slotId: null, resourceType: c.resourceType, resourceId: c.resourceId, startsAt: c.blockStartsAt, endsAt: c.blockEndsAt, quantity: 1, mode: 'exclusive', active, visible: [c.startsAt, c.endsAt] };
91
+ if (active) {
92
+ const clash = [...mem.allocations, ...out].find((x) => x.active && x.mode === 'exclusive' && x.bookingId !== ignoreBookingId &&
93
+ x.resourceType === a.resourceType && x.resourceId === a.resourceId && overlaps(x, a));
94
+ if (clash)
95
+ fail('RESOURCE_TAKEN', 'That time is no longer free', 409, { resourceType: a.resourceType, resourceId: a.resourceId, startsAt: a.startsAt, endsAt: a.endsAt });
96
+ }
97
+ out.push(a);
98
+ }
99
+ }
100
+ for (const w of writes)
101
+ w();
102
+ let minStart = null;
103
+ let maxEnd = null;
104
+ for (const a of out) {
105
+ const [s, e] = a.visible;
106
+ delete a.visible;
107
+ if (!minStart || ms(s) < ms(minStart))
108
+ minStart = s;
109
+ if (!maxEnd || ms(e) > ms(maxEnd))
110
+ maxEnd = e;
111
+ mem.allocations.push(Object.assign({ id: id('alloc'), data: {}, sourceSystem: null }, a));
112
+ }
113
+ return { startsAt: minStart, endsAt: maxEnd };
114
+ }
115
+ function release(b, remove = false) {
116
+ for (const a of mem.allocations.filter((x) => x.bookingId === b.id)) {
117
+ if (a.active && a.mode === 'capacity') {
118
+ const s = mem.slots.find((x) => x.id === a.slotId);
119
+ if (s)
120
+ s.booked = Math.max(0, s.booked - a.quantity);
121
+ }
122
+ a.active = false;
123
+ }
124
+ if (remove)
125
+ mem.allocations = mem.allocations.filter((x) => x.bookingId !== b.id);
126
+ }
127
+ const find = (bookingId) => {
128
+ const b = mem.bookings.find((x) => x.id === bookingId && !x.deletedAt);
129
+ if (!b)
130
+ fail('NOT_FOUND', 'Booking not found', 404);
131
+ return b;
132
+ };
133
+ const holdLapsed = (b) => b.status === 'held' && b.holdExpiresAt && ms(b.holdExpiresAt) < now();
134
+ return {
135
+ async create(input = {}) {
136
+ var _a;
137
+ const status = input.status || 'confirmed';
138
+ if (!['held', 'pending', 'confirmed', 'waitlisted'].includes(status))
139
+ fail('BAD_REQUEST', 'status must be held, pending, confirmed or waitlisted', 400);
140
+ if (input.idempotencyKey) {
141
+ const prior = mem.bookings.find((b) => b.idempotencyKey === String(input.idempotencyKey));
142
+ if (prior)
143
+ return Object.assign(Object.assign({}, copy(withAllocations(prior))), { replayed: true });
144
+ }
145
+ const claims = claimsOf(input);
146
+ const bookingId = id('booking');
147
+ const contactId = input.contactId || null;
148
+ const visible = claimAll(bookingId, claims, contactId, status !== 'waitlisted');
149
+ const b = {
150
+ id: bookingId, status, contactId, visibility: input.visibility || 'owner',
151
+ startsAt: input.startsAt ? iso(ms(input.startsAt)) : visible.startsAt, endsAt: input.endsAt ? iso(ms(input.endsAt)) : visible.endsAt,
152
+ timezone: input.timezone || null, quantity: input.quantity == null ? 1 : Number(input.quantity),
153
+ holdExpiresAt: status === 'held' ? iso(now() + ((_a = input.holdMinutes) !== null && _a !== void 0 ? _a : 15) * 60000) : null,
154
+ flags: [], cancelReason: null, idempotencyKey: input.idempotencyKey ? String(input.idempotencyKey) : null,
155
+ ref: input.ref || null, customId: input.customId || null, productId: input.productId || null,
156
+ data: input.data || {}, owner: input.owner || {}, admin: input.admin || {},
157
+ createdAt: iso(now()), updatedAt: iso(now()), cancelledAt: null,
158
+ };
159
+ mem.bookings.push(b);
160
+ return Object.assign(Object.assign({}, copy(withAllocations(b))), { replayed: false });
161
+ },
162
+ async get(bookingId) {
163
+ const b = mem.bookings.find((x) => x.id === bookingId && !x.deletedAt);
164
+ return b ? copy(withAllocations(b)) : null;
165
+ },
166
+ async query(params = {}) {
167
+ let all = mem.bookings.filter((b) => !b.deletedAt);
168
+ if (params.status) {
169
+ const s = String(params.status).split(',');
170
+ all = all.filter((b) => s.includes(b.status));
171
+ }
172
+ if (params.contactId)
173
+ all = all.filter((b) => b.contactId === params.contactId);
174
+ if (params.slotId)
175
+ all = all.filter((b) => mem.allocations.some((a) => a.bookingId === b.id && a.slotId === params.slotId));
176
+ if (params.from)
177
+ all = all.filter((b) => ms(b.endsAt) > ms(params.from));
178
+ if (params.to)
179
+ all = all.filter((b) => ms(b.startsAt) < ms(params.to));
180
+ all.sort((a, b) => ms(a.startsAt) - ms(b.startsAt));
181
+ return page(all.map(withAllocations), params);
182
+ },
183
+ async mine(params = {}) {
184
+ if (!caller.contactId)
185
+ return { data: [], pagination: { total: 0, limit: 0, offset: 0, hasMore: false } };
186
+ return this.query(Object.assign(Object.assign({}, params), { contactId: caller.contactId }));
187
+ },
188
+ async update(bookingId, fields = {}) {
189
+ const b = find(bookingId);
190
+ for (const k of ['data', 'owner', 'admin', 'visibility', 'ref', 'timezone', 'productId', 'contactId', 'customId', 'flags'])
191
+ if (fields[k] !== undefined)
192
+ b[k] = fields[k];
193
+ b.updatedAt = iso(now());
194
+ return copy(withAllocations(b));
195
+ },
196
+ async confirm(bookingId) {
197
+ const b = find(bookingId);
198
+ if (b.status === 'confirmed')
199
+ return copy(withAllocations(b));
200
+ if (!['held', 'pending', 'waitlisted'].includes(b.status))
201
+ fail('INVALID_STATE', `A ${b.status} booking can't be confirmed`, 409, { status: b.status });
202
+ if (holdLapsed(b))
203
+ fail('HOLD_EXPIRED', 'The hold on this booking has run out');
204
+ if (b.status === 'waitlisted') {
205
+ const old = mem.allocations.filter((a) => a.bookingId === b.id);
206
+ const claims = old.map((a) => a.mode === 'capacity'
207
+ ? { kind: 'capacity', slotId: a.slotId, quantity: a.quantity }
208
+ : { kind: 'exclusive', resourceType: a.resourceType, resourceId: a.resourceId, startsAt: a.startsAt, endsAt: a.endsAt, blockStartsAt: a.startsAt, blockEndsAt: a.endsAt });
209
+ mem.allocations = mem.allocations.filter((a) => a.bookingId !== b.id);
210
+ try {
211
+ claimAll(b.id, claims, b.contactId, true);
212
+ }
213
+ catch (e) {
214
+ mem.allocations.push(...old);
215
+ throw e;
216
+ }
217
+ }
218
+ b.status = 'confirmed';
219
+ b.holdExpiresAt = null;
220
+ b.updatedAt = iso(now());
221
+ return copy(withAllocations(b));
222
+ },
223
+ async cancel(bookingId, opts = {}) {
224
+ const b = find(bookingId);
225
+ if (b.status === 'cancelled')
226
+ return copy(withAllocations(b));
227
+ if (!['held', 'pending', 'confirmed', 'waitlisted'].includes(b.status))
228
+ fail('INVALID_STATE', `A ${b.status} booking can't be cancelled`, 409, { status: b.status });
229
+ release(b);
230
+ b.status = 'cancelled';
231
+ b.cancelReason = opts.reason || 'admin';
232
+ b.cancelledAt = iso(now());
233
+ b.holdExpiresAt = null;
234
+ return copy(withAllocations(b));
235
+ },
236
+ async move(bookingId, input = {}) {
237
+ const b = find(bookingId);
238
+ if (!['held', 'pending', 'confirmed'].includes(b.status))
239
+ fail('INVALID_STATE', `A ${b.status} booking can't be moved`, 409, { status: b.status });
240
+ if (holdLapsed(b))
241
+ fail('HOLD_EXPIRED', 'The hold on this booking has run out');
242
+ const claims = claimsOf(Object.assign({ quantity: b.quantity }, input));
243
+ // Check against everything except this booking's own claims; only then release + claim.
244
+ const own = mem.allocations.filter((a) => a.bookingId === b.id);
245
+ const freedSeats = new Map();
246
+ for (const a of own)
247
+ if (a.active && a.mode === 'capacity')
248
+ freedSeats.set(a.slotId, (freedSeats.get(a.slotId) || 0) + a.quantity);
249
+ for (const [slotId, n] of freedSeats) {
250
+ const s = mem.slots.find((x) => x.id === slotId);
251
+ if (s)
252
+ s.booked -= n;
253
+ }
254
+ try {
255
+ const visible = claimAll(b.id + ':move', claims, b.contactId, true, b.id);
256
+ mem.allocations = mem.allocations.filter((a) => a.bookingId !== b.id);
257
+ for (const a of mem.allocations)
258
+ if (a.bookingId === b.id + ':move')
259
+ a.bookingId = b.id;
260
+ b.startsAt = input.startsAt ? iso(ms(input.startsAt)) : visible.startsAt;
261
+ b.endsAt = input.endsAt ? iso(ms(input.endsAt)) : visible.endsAt;
262
+ b.updatedAt = iso(now());
263
+ }
264
+ catch (e) {
265
+ for (const [slotId, n] of freedSeats) {
266
+ const s = mem.slots.find((x) => x.id === slotId);
267
+ if (s)
268
+ s.booked += n;
269
+ }
270
+ throw e;
271
+ }
272
+ return copy(withAllocations(b));
273
+ },
274
+ async extendHold(bookingId, holdMinutes) {
275
+ const b = find(bookingId);
276
+ if (b.status !== 'held')
277
+ fail('INVALID_STATE', 'Only a held booking has a hold to extend', 409, { status: b.status });
278
+ if (holdLapsed(b))
279
+ fail('HOLD_EXPIRED', 'The hold on this booking has run out');
280
+ b.holdExpiresAt = iso(Math.min(now() + (holdMinutes !== null && holdMinutes !== void 0 ? holdMinutes : 15) * 60000, ms(b.createdAt) + 24 * 3600000));
281
+ return copy(withAllocations(b));
282
+ },
283
+ async complete(bookingId) { return outcome(find(bookingId), 'completed'); },
284
+ async noShow(bookingId) { return outcome(find(bookingId), 'no_show'); },
285
+ async freebusy(q = {}) {
286
+ const refs = (q.resources || []).map((r) => (typeof r === 'string' ? { type: r.slice(0, r.indexOf(':')), id: r.slice(r.indexOf(':') + 1) } : r));
287
+ return {
288
+ from: iso(ms(q.from)), to: iso(ms(q.to)),
289
+ resources: refs.map((r) => {
290
+ const busy = mem.allocations
291
+ .filter((a) => a.active && a.mode === 'exclusive' && a.resourceType === r.type && a.resourceId === r.id && overlaps(a, { startsAt: q.from, endsAt: q.to }))
292
+ .map((a) => ({ start: Math.max(ms(a.startsAt), ms(q.from)), end: Math.min(ms(a.endsAt), ms(q.to)) }))
293
+ .sort((a, b) => a.start - b.start);
294
+ const merged = [];
295
+ for (const x of busy) {
296
+ const l = merged[merged.length - 1];
297
+ if (l && x.start <= l.end)
298
+ l.end = Math.max(l.end, x.end);
299
+ else
300
+ merged.push(Object.assign({}, x));
301
+ }
302
+ return { resourceType: r.type, resourceId: r.id, busy: merged.map((x) => ({ start: iso(x.start), end: iso(x.end) })) };
303
+ }),
304
+ };
305
+ },
306
+ slots: {
307
+ async create(input = {}) {
308
+ var _a, _b, _c, _d, _e, _f, _g;
309
+ if (!input.startsAt || !input.endsAt || ms(input.endsAt) <= ms(input.startsAt))
310
+ fail('BAD_REQUEST', 'startsAt and endsAt are required, with endsAt after startsAt', 400);
311
+ const s = {
312
+ id: id('slot'), startsAt: iso(ms(input.startsAt)), endsAt: iso(ms(input.endsAt)), capacity: Number(input.capacity), booked: 0,
313
+ perContactLimit: (_a = input.perContactLimit) !== null && _a !== void 0 ? _a : null, status: input.status || 'open', visibility: input.visibility || 'public',
314
+ resourceType: (_b = input.resourceType) !== null && _b !== void 0 ? _b : null, resourceId: (_c = input.resourceId) !== null && _c !== void 0 ? _c : null, seriesId: (_d = input.seriesId) !== null && _d !== void 0 ? _d : null,
315
+ timezone: (_e = input.timezone) !== null && _e !== void 0 ? _e : null, ref: (_f = input.ref) !== null && _f !== void 0 ? _f : null, productId: (_g = input.productId) !== null && _g !== void 0 ? _g : null,
316
+ data: input.data || {}, owner: input.owner || {}, admin: input.admin || {}, createdAt: iso(now()), deletedAt: null,
317
+ };
318
+ if (input.blockResource) {
319
+ if (!s.resourceType || !s.resourceId)
320
+ fail('BAD_REQUEST', 'blockResource needs resourceType + resourceId', 400);
321
+ claimAll(null, [{ kind: 'exclusive', resourceType: s.resourceType, resourceId: s.resourceId, startsAt: s.startsAt, endsAt: s.endsAt, blockStartsAt: s.startsAt, blockEndsAt: s.endsAt }], null, true);
322
+ mem.allocations[mem.allocations.length - 1].slotId = s.id;
323
+ }
324
+ mem.slots.push(s);
325
+ return slotView(s);
326
+ },
327
+ async createMany(inputs, opts = {}) {
328
+ const out = [];
329
+ for (const i of inputs || [])
330
+ out.push(await this.create(Object.assign({ seriesId: opts.seriesId }, i)));
331
+ return out;
332
+ },
333
+ async get(slotId) { const s = slotOf(slotId); return s ? slotView(s) : null; },
334
+ async query(params = {}) {
335
+ let all = mem.slots.filter((s) => !s.deletedAt);
336
+ if (params.status)
337
+ all = all.filter((s) => s.status === params.status);
338
+ if (params.available === true || params.available === 'true')
339
+ all = all.filter((s) => s.booked < s.capacity);
340
+ if (params.from)
341
+ all = all.filter((s) => ms(s.endsAt) > ms(params.from));
342
+ if (params.to)
343
+ all = all.filter((s) => ms(s.startsAt) < ms(params.to));
344
+ all.sort((a, b) => ms(a.startsAt) - ms(b.startsAt));
345
+ return page(all.map((s) => (Object.assign(Object.assign({}, s), { remaining: remaining(s) }))), params);
346
+ },
347
+ async update(slotId, fields = {}) {
348
+ const s = slotOf(slotId);
349
+ if (!s)
350
+ fail('NOT_FOUND', 'Slot not found', 404);
351
+ if (fields.capacity !== undefined && Number(fields.capacity) < s.booked)
352
+ fail('SLOT_OVERBOOKED', `${s.booked} places are already booked`, 409, { booked: s.booked });
353
+ if ((fields.startsAt || fields.endsAt) && s.booked > 0)
354
+ fail('SLOT_HAS_BOOKINGS', 'Move or cancel the bookings before changing the slot time', 409, { booked: s.booked });
355
+ for (const k of ['capacity', 'perContactLimit', 'status', 'visibility', 'timezone', 'ref', 'productId', 'data', 'owner', 'admin'])
356
+ if (fields[k] !== undefined)
357
+ s[k] = fields[k];
358
+ if (fields.startsAt)
359
+ s.startsAt = iso(ms(fields.startsAt));
360
+ if (fields.endsAt)
361
+ s.endsAt = iso(ms(fields.endsAt));
362
+ return slotView(s);
363
+ },
364
+ async delete(slotId) {
365
+ const s = slotOf(slotId);
366
+ if (!s)
367
+ fail('NOT_FOUND', 'Slot not found', 404);
368
+ if (s.booked > 0)
369
+ fail('SLOT_HAS_BOOKINGS', 'Cancel the bookings before deleting the slot', 409, { booked: s.booked });
370
+ s.deletedAt = iso(now());
371
+ s.status = 'cancelled';
372
+ mem.allocations = mem.allocations.filter((a) => !(a.slotId === s.id && a.bookingId === null));
373
+ return { success: true };
374
+ },
375
+ },
376
+ blocks: {
377
+ async create(input = {}) {
378
+ if (!input.resourceType || !input.resourceId)
379
+ fail('BAD_REQUEST', 'resourceType and resourceId are required', 400);
380
+ if (!input.startsAt || !input.endsAt || ms(input.endsAt) <= ms(input.startsAt))
381
+ fail('BAD_REQUEST', 'startsAt and endsAt are required, with endsAt after startsAt', 400);
382
+ const s = iso(ms(input.startsAt)), e = iso(ms(input.endsAt));
383
+ claimAll(null, [{ kind: 'exclusive', resourceType: String(input.resourceType), resourceId: String(input.resourceId), startsAt: s, endsAt: e, blockStartsAt: s, blockEndsAt: e }], null, true);
384
+ const a = mem.allocations[mem.allocations.length - 1];
385
+ a.data = input.data || {};
386
+ return copy(a);
387
+ },
388
+ async query(params = {}) {
389
+ let all = mem.allocations.filter((a) => a.bookingId === null && a.slotId === null);
390
+ if (params.resourceType)
391
+ all = all.filter((a) => a.resourceType === params.resourceType);
392
+ if (params.resourceId)
393
+ all = all.filter((a) => a.resourceId === params.resourceId);
394
+ return page(all, params);
395
+ },
396
+ async delete(blockId) {
397
+ const before = mem.allocations.length;
398
+ mem.allocations = mem.allocations.filter((a) => !(a.id === blockId && a.bookingId === null && a.slotId === null));
399
+ if (mem.allocations.length === before)
400
+ fail('NOT_FOUND', 'Block not found', 404);
401
+ return { success: true };
402
+ },
403
+ },
404
+ };
405
+ function outcome(b, to) {
406
+ if (b.status === to)
407
+ return copy(withAllocations(b));
408
+ if (b.status !== 'confirmed')
409
+ fail('INVALID_STATE', `Only a confirmed booking can be marked ${to}`, 409, { status: b.status });
410
+ b.status = to;
411
+ return copy(withAllocations(b));
412
+ }
413
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AppFunctionDef, ServerFunctionContext, ServerFunctionInputs } from '../types/appManifest.js';
2
+ import { type BookingsMemory } from './bookingsMemory.js';
2
3
  export declare class CapabilityError extends Error {
3
4
  capability: string;
4
5
  code: string;
@@ -59,9 +60,16 @@ export interface TestSlImpl {
59
60
  getData?(opts?: any): any;
60
61
  };
61
62
  };
63
+ /**
64
+ * Bookings. Omit to use the built-in in-memory engine (capacity, per-person limits, no overlapping
65
+ * time, idempotent creates, holds) — inspect it via `ctx.memory.bookings`.
66
+ */
67
+ bookings?: Record<string, any>;
62
68
  }
63
69
  export interface TestCaller {
64
70
  userId?: string | null;
71
+ /** The caller's contact id, for `ctx.sl.bookings.mine()`. Default: `contact-of-<userId>` when signed in. */
72
+ contactId?: string | null;
65
73
  anonymous?: boolean;
66
74
  origin?: string | null;
67
75
  ip?: string | null;
@@ -98,6 +106,8 @@ export interface FunctionTestMemory {
98
106
  [k: string]: any;
99
107
  }>;
100
108
  interactions: any[];
109
+ /** Slots, bookings and allocations of the in-memory bookings engine. */
110
+ bookings: BookingsMemory;
101
111
  }
102
112
  /** A fresh in-memory store — pass the same one to several test contexts to simulate concurrent callers. */
103
113
  export declare function createFunctionTestMemory(): FunctionTestMemory;
@@ -14,10 +14,11 @@
14
14
  // - `ctx.secrets` are FIXTURES you provide; real sealed secrets are server-only and
15
15
  // never resolvable locally.
16
16
  // - `ctx.fetch` is gated by the `network` capability just like production.
17
- // - Without an injected impl, appRecords / counters / contacts / interactions run against an
18
- // in-memory store with the platform's uniqueness rules (claim: singletonPer + customId; contacts by
19
- // email/phone, fill-empty only), so idempotency and "never two of the same number" are testable.
20
- // Inspect it via `ctx.memory`.
17
+ // - Without an injected impl, appRecords / counters / contacts / interactions / bookings run against
18
+ // an in-memory store with the platform's rules (claim: singletonPer + customId; contacts by
19
+ // email/phone, fill-empty only; bookings: capacity, per-person limits, no overlapping time,
20
+ // idempotent creates), so idempotency, "never two of the same number" and "never oversold" are
21
+ // testable. Inspect it via `ctx.memory`.
21
22
  var __rest = (this && this.__rest) || function (s, e) {
22
23
  var t = {};
23
24
  for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
@@ -29,6 +30,7 @@ var __rest = (this && this.__rest) || function (s, e) {
29
30
  }
30
31
  return t;
31
32
  };
33
+ import { bookingsMemoryImpl, createBookingsMemory } from './bookingsMemory.js';
32
34
  export class CapabilityError extends Error {
33
35
  constructor(capability, message) {
34
36
  super(message || `capability not granted: ${capability}`);
@@ -75,7 +77,7 @@ function allowsSl(p, resource, op) {
75
77
  }
76
78
  /** A fresh in-memory store — pass the same one to several test contexts to simulate concurrent callers. */
77
79
  export function createFunctionTestMemory() {
78
- return { records: [], counters: {}, contacts: [], interactions: [] };
80
+ return { records: [], counters: {}, contacts: [], interactions: [], bookings: createBookingsMemory() };
79
81
  }
80
82
  const stub = (method, args) => ({ __stub: true, method, args });
81
83
  /**
@@ -88,7 +90,7 @@ const stub = (method, args) => ({ __stub: true, method, args });
88
90
  * CapabilityError — exactly as it would in production.
89
91
  */
90
92
  export function createFunctionTestContext(opts) {
91
- var _a, _b, _c, _d;
93
+ var _a, _b, _c, _d, _e, _f, _g;
92
94
  const def = opts.def || {};
93
95
  const parsed = parseCapabilities(def.capabilities || []);
94
96
  const impl = opts.sl || {};
@@ -264,6 +266,25 @@ export function createFunctionTestContext(opts) {
264
266
  },
265
267
  };
266
268
  };
269
+ // Bookings — an injected impl, else the in-memory engine (same rules as the platform).
270
+ if (!mem.bookings)
271
+ mem.bookings = createBookingsMemory(); // a memory made before bookings existed
272
+ const callerContactId = (_b = (_a = opts.caller) === null || _a === void 0 ? void 0 : _a.contactId) !== null && _b !== void 0 ? _b : (((_c = opts.caller) === null || _c === void 0 ? void 0 : _c.userId) ? `contact-of-${opts.caller.userId}` : null);
273
+ const bk = impl.bookings || bookingsMemoryImpl(mem.bookings, { contactId: callerContactId });
274
+ const bkFn = (obj, name) => (obj && typeof obj[name] === 'function' ? obj[name].bind(obj) : undefined);
275
+ const BOOKING_READS = new Set(['get', 'query', 'mine', 'freebusy']);
276
+ sl.bookings = {};
277
+ for (const m of ['create', 'get', 'query', 'mine', 'update', 'confirm', 'cancel', 'move', 'extendHold', 'complete', 'noShow', 'freebusy']) {
278
+ sl.bookings[m] = gated('bookings', BOOKING_READS.has(m) ? 'read' : 'write', bkFn(bk, m), `bookings.${m}`);
279
+ }
280
+ sl.bookings.slots = {};
281
+ for (const m of ['create', 'createMany', 'get', 'query', 'update', 'delete']) {
282
+ sl.bookings.slots[m] = gated('bookings', m === 'get' || m === 'query' ? 'read' : 'write', bkFn(bk.slots, m), `bookings.slots.${m}`);
283
+ }
284
+ sl.bookings.blocks = {};
285
+ for (const m of ['create', 'query', 'delete']) {
286
+ sl.bookings.blocks[m] = gated('bookings', m === 'query' ? 'read' : 'write', bkFn(bk.blocks, m), `bookings.blocks.${m}`);
287
+ }
267
288
  const secretsMap = opts.secrets || {};
268
289
  const baseFetch = opts.fetch || (typeof fetch !== 'undefined' ? fetch : undefined);
269
290
  const via = (def.trigger && def.trigger.type) || 'http';
@@ -286,10 +307,10 @@ export function createFunctionTestContext(opts) {
286
307
  },
287
308
  },
288
309
  caller: {
289
- userId: (_a = caller.userId) !== null && _a !== void 0 ? _a : null,
290
- anonymous: (_b = caller.anonymous) !== null && _b !== void 0 ? _b : !caller.userId,
291
- origin: (_c = caller.origin) !== null && _c !== void 0 ? _c : null,
292
- ip: (_d = caller.ip) !== null && _d !== void 0 ? _d : null,
310
+ userId: (_d = caller.userId) !== null && _d !== void 0 ? _d : null,
311
+ anonymous: (_e = caller.anonymous) !== null && _e !== void 0 ? _e : !caller.userId,
312
+ origin: (_f = caller.origin) !== null && _f !== void 0 ? _f : null,
313
+ ip: (_g = caller.ip) !== null && _g !== void 0 ? _g : null,
293
314
  via,
294
315
  },
295
316
  fetch: (async (input, init) => {
@@ -3,6 +3,8 @@
3
3
  import type { AppDataDeclaration, AppHeadlessDeclaration } from './headless.js';
4
4
  import type { RealtimeChannelDeclaration } from './realtime.js';
5
5
  import type { AppManifestAdminCards } from './adminCards.js';
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';
6
8
  /**
7
9
  * A bundle (widget or container) as returned by the collection widgets endpoint.
8
10
  *
@@ -416,7 +418,60 @@ export interface ServerFunctionInteractionEvent {
416
418
  batchId?: string;
417
419
  eventType?: string;
418
420
  }
421
+ /**
422
+ * `ctx.sl.bookings` — THIS app's own bookings, slots and blocks (docs/bookings.md, "In a server
423
+ * function"). Gated by `sl:bookings:read` / `sl:bookings:write` only. Writes are admin-side: any
424
+ * status, the admin zone, any contact — the app's `publicCreate.bookings` rules don't apply, because
425
+ * the function IS the app's rules (hours, queue tokens, payment). The platform's guarantees still do:
426
+ * a clash fails with an Error carrying `code` (SLOT_FULL, RESOURCE_TAKEN, PER_CONTACT_LIMIT,
427
+ * HOLD_EXPIRED, …), `status` and `details`.
428
+ */
429
+ export interface ServerFunctionBookings {
430
+ /** Book; pass `idempotencyKey` (e.g. the payment intent id) so a retry returns the first booking (`replayed: true`). */
431
+ create(input: CreateBookingInput): Promise<Booking & {
432
+ replayed: boolean;
433
+ }>;
434
+ get(bookingId: string): Promise<Booking | null>;
435
+ query(params?: BookingListQueryParams): Promise<PaginatedResponse<Booking>>;
436
+ /** The signed-in caller's own bookings (empty when anonymous). */
437
+ mine(params?: BookingListQueryParams): Promise<PaginatedResponse<Booking>>;
438
+ update(bookingId: string, fields: UpdateBookingInput): Promise<Booking>;
439
+ /** held / pending / waitlisted → confirmed (idempotent). */
440
+ confirm(bookingId: string): Promise<Booking>;
441
+ /** Cancel and give the places back (idempotent). */
442
+ cancel(bookingId: string, opts?: {
443
+ reason?: string;
444
+ }): Promise<Booking>;
445
+ /** All-or-nothing move to new claims. */
446
+ move(bookingId: string, input: MoveBookingInput): Promise<Booking>;
447
+ extendHold(bookingId: string, holdMinutes: number): Promise<Booking>;
448
+ complete(bookingId: string): Promise<Booking>;
449
+ noShow(bookingId: string): Promise<Booking>;
450
+ /** Busy intervals per resource — feed them to `utils.freeTimes` with your schedule. */
451
+ freebusy(q: FreeBusyQuery): Promise<FreeBusyResponse>;
452
+ slots: {
453
+ create(input: CreateSlotInput): Promise<BookingSlot>;
454
+ createMany(inputs: CreateSlotInput[], opts?: {
455
+ seriesId?: string;
456
+ }): Promise<BookingSlot[]>;
457
+ get(slotId: string): Promise<BookingSlot | null>;
458
+ query(params?: SlotListQueryParams): Promise<PaginatedResponse<BookingSlot>>;
459
+ update(slotId: string, fields: UpdateSlotInput): Promise<BookingSlot>;
460
+ delete(slotId: string): Promise<{
461
+ success: boolean;
462
+ }>;
463
+ };
464
+ blocks: {
465
+ create(input: CreateBlockInput): Promise<BookingAllocation>;
466
+ query(params?: BlockListQueryParams): Promise<PaginatedResponse<BookingAllocation>>;
467
+ delete(blockId: string): Promise<{
468
+ success: boolean;
469
+ }>;
470
+ };
471
+ }
419
472
  export interface ServerFunctionSl {
473
+ /** THIS app's bookings, slots and blocks (`sl:bookings:read` / `sl:bookings:write`). */
474
+ bookings: ServerFunctionBookings;
420
475
  /**
421
476
  * THIS app's records (own namespace): create / update / upsert / delete / get / query / listTypes,
422
477
  * plus `claim(fields)` — a race-safe UNIQUE create. The record names a unique key — `singletonPer`
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.0.71 | Generated: 2026-10-24T17:23:20.711Z
3
+ Version: 2.1.0 | Generated: 2026-10-24T18:38:40.481Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -2801,9 +2801,41 @@ interface ServerFunctionInteractionEvent {
2801
2801
  }
2802
2802
  ```
2803
2803
 
2804
+ **ServerFunctionBookings** (interface)
2805
+ ```typescript
2806
+ interface ServerFunctionBookings {
2807
+ create(input: CreateBookingInput): Promise<Booking & { replayed: boolean }>;
2808
+ get(bookingId: string): Promise<Booking | null>;
2809
+ query(params?: BookingListQueryParams): Promise<PaginatedResponse<Booking>>;
2810
+ mine(params?: BookingListQueryParams): Promise<PaginatedResponse<Booking>>;
2811
+ update(bookingId: string, fields: UpdateBookingInput): Promise<Booking>;
2812
+ confirm(bookingId: string): Promise<Booking>;
2813
+ cancel(bookingId: string, opts?: { reason?: string }): Promise<Booking>;
2814
+ move(bookingId: string, input: MoveBookingInput): Promise<Booking>;
2815
+ extendHold(bookingId: string, holdMinutes: number): Promise<Booking>;
2816
+ complete(bookingId: string): Promise<Booking>;
2817
+ noShow(bookingId: string): Promise<Booking>;
2818
+ freebusy(q: FreeBusyQuery): Promise<FreeBusyResponse>;
2819
+ slots: {
2820
+ create(input: CreateSlotInput): Promise<BookingSlot>;
2821
+ createMany(inputs: CreateSlotInput[], opts?: { seriesId?: string }): Promise<BookingSlot[]>;
2822
+ get(slotId: string): Promise<BookingSlot | null>;
2823
+ query(params?: SlotListQueryParams): Promise<PaginatedResponse<BookingSlot>>;
2824
+ update(slotId: string, fields: UpdateSlotInput): Promise<BookingSlot>;
2825
+ delete(slotId: string): Promise<{ success: boolean }>;
2826
+ };
2827
+ blocks: {
2828
+ create(input: CreateBlockInput): Promise<BookingAllocation>;
2829
+ query(params?: BlockListQueryParams): Promise<PaginatedResponse<BookingAllocation>>;
2830
+ delete(blockId: string): Promise<{ success: boolean }>;
2831
+ };
2832
+ }
2833
+ ```
2834
+
2804
2835
  **ServerFunctionSl** (interface)
2805
2836
  ```typescript
2806
2837
  interface ServerFunctionSl {
2838
+ bookings: ServerFunctionBookings;
2807
2839
  appRecords: { claim(fields: Record<string, any>): Promise<AppRecordClaimResult>; [method: string]: any };
2808
2840
  counters: ServerFunctionCounters;
2809
2841
  tags: { resolve(id: string): Promise<ResolvedTag | null> };
@@ -927,6 +927,9 @@ The server reads this document at request time — no cache invalidation or serv
927
927
 
928
928
  ### Policy Structure
929
929
 
930
+ Bookings use the same policy as a fourth object type, `publicCreate.bookings`, with booking-specific
931
+ `limits` and `rules` — see [bookings.md](bookings.md).
932
+
930
933
  Each object type (`cases`, `threads`, `records`) has **independent branches** for anonymous and authenticated callers. Each branch carries its own `allow` flag, optional field overrides (`enforce`), and — for records — optional edit-token config (`edit`).
931
934
 
932
935
  ```typescript
package/docs/bookings.md CHANGED
@@ -248,6 +248,33 @@ Off by default — most apps serve free times through their own function.
248
248
 
249
249
  ---
250
250
 
251
+ ## In a server function — `ctx.sl.bookings`
252
+
253
+ When public booking is off, your app books through its own server function. `ctx.sl.bookings` is
254
+ the same API, admin-side, for **your app's** bookings only (`sl:bookings:read` / `sl:bookings:write`):
255
+
256
+ ```ts
257
+ // manifest: { name: 'book', trigger: { type: 'http' }, visibility: 'public', authority: 'collection',
258
+ // capabilities: ['sl:bookings:write', 'sl:contacts:write'] }
259
+ export async function book(ctx, req) {
260
+ const me = await ctx.sl.contacts.forCaller() // null when signed out
261
+ // … your rules: hours (utils.withinSchedule), a queue token, a member level, a price …
262
+ const b = await ctx.sl.bookings.create({
263
+ resourceType: 'record', resourceId: req.body.staffId,
264
+ startsAt: req.body.startsAt, endsAt: req.body.endsAt, bufferAfter: 10,
265
+ contactId: me?.contactId, status: 'confirmed', idempotencyKey: req.body.requestId,
266
+ })
267
+ return Response.json({ id: b.id, startsAt: b.startsAt })
268
+ }
269
+ ```
270
+
271
+ `create`, `get`, `query`, `mine` (the caller's own), `update`, `confirm`, `cancel`, `move`,
272
+ `extendHold`, `complete`, `noShow`, `freebusy`, `slots.*`, `blocks.*`. Errors carry `code`,
273
+ `status` and `details` exactly as over HTTP. The local test harness
274
+ (`@proveanything/smartlinks/testing`) runs bookings in memory with the same rules.
275
+
276
+ ---
277
+
251
278
  ## Payments and high-demand sales
252
279
 
253
280
  The platform takes no payments. It gives you what a payment or credits flow needs:
@@ -167,7 +167,7 @@ Declare the minimum you need.
167
167
 
168
168
  | Capability | Grants |
169
169
  |---|---|
170
- | `sl:<resource>:read` / `sl:<resource>:write` | SDK access to that resource, e.g. `sl:products:read`, `sl:records:write`, `sl:attestations:write`, `sl:contacts:write`. |
170
+ | `sl:<resource>:read` / `sl:<resource>:write` | SDK access to that resource, e.g. `sl:products:read`, `sl:records:write`, `sl:attestations:write`, `sl:contacts:write`, `sl:bookings:write`. |
171
171
  | `network` | `ctx.fetch` to any host. |
172
172
  | `network:<host>` | `ctx.fetch` to that host only (repeat for several). Prefer this over blanket `network`. |
173
173
  | `network:*.<domain>` | `ctx.fetch` to any subdomain of `<domain>` (not `<domain>` itself), for a customer's own endpoint at a provider, e.g. `network:*.servicebus.windows.net`. `<domain>` needs at least two labels (`*.com` is not honoured). |
@@ -517,6 +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
521
 
521
522
  Notes:
522
523
  - **Talking to the SmartLinks core is `ctx.sl`, not HTTP.** The common pattern — *validate /
@@ -611,6 +612,33 @@ await ctx.sl.interactions.record({
611
612
  })
612
613
  ```
613
614
 
615
+ ### `bookings.*` — places and time that can't be oversold
616
+
617
+ `ctx.sl.bookings` is your app's own bookings, slots and blocks (`sl:bookings:read` / `write`). A
618
+ function books **admin-side**: any status, the admin zone, any contact — your app's public booking
619
+ rules (`publicCreate.bookings`) don't apply, because the function *is* your rules. The platform still
620
+ guarantees no overbooking and no overlapping time, so a clash throws (`code`: `SLOT_FULL`,
621
+ `RESOURCE_TAKEN`, `PER_CONTACT_LIMIT`, `HOLD_EXPIRED`, …; with `status` and `details`). Pass an
622
+ `idempotencyKey` so a retried request returns the first booking (`replayed: true`).
623
+
624
+ ```ts
625
+ const me = await ctx.sl.contacts.forCaller()
626
+ if (!me) return Response.json({ error: 'sign in' }, { status: 401 })
627
+ try {
628
+ const booking = await ctx.sl.bookings.create({
629
+ slotId: body.slotId, quantity: body.quantity, contactId: me.contactId,
630
+ status: 'held', holdMinutes: 15, idempotencyKey: body.requestId,
631
+ })
632
+ return Response.json({ id: booking.id, holdExpiresAt: booking.holdExpiresAt })
633
+ } catch (e: any) {
634
+ if (e.code === 'SLOT_FULL') return Response.json({ error: 'sold out', remaining: e.details?.remaining }, { status: 409 })
635
+ throw e
636
+ }
637
+ ```
638
+
639
+ The local harness runs bookings in memory with the same rules (capacity, per-person limits, no
640
+ overlaps, idempotency, holds): `ctx.memory.bookings`.
641
+
614
642
  ---
615
643
 
616
644
  ## Platform tools — PDFs, labels, barcodes
package/openapi.yaml CHANGED
@@ -20224,9 +20224,23 @@ components:
20224
20224
  type: string
20225
20225
  required:
20226
20226
  - interactionId
20227
+ ServerFunctionBookings:
20228
+ type: object
20229
+ properties:
20230
+ slots:
20231
+ type: object
20232
+ additionalProperties: true
20233
+ blocks:
20234
+ type: object
20235
+ additionalProperties: true
20236
+ required:
20237
+ - slots
20238
+ - blocks
20227
20239
  ServerFunctionSl:
20228
20240
  type: object
20229
20241
  properties:
20242
+ bookings:
20243
+ $ref: "#/components/schemas/ServerFunctionBookings"
20230
20244
  appRecords:
20231
20245
  type: object
20232
20246
  additionalProperties: true
@@ -20249,6 +20263,7 @@ components:
20249
20263
  appData:
20250
20264
  $ref: "#/components/schemas/AppDataHandle"
20251
20265
  required:
20266
+ - bookings
20252
20267
  - appRecords
20253
20268
  - counters
20254
20269
  - tags
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "2.0.71",
3
+ "version": "2.1.0",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",