@proveanything/smartlinks 2.0.71 → 2.1.1
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/docs/API_SUMMARY.md +33 -1
- package/dist/docs/app-objects.md +3 -0
- package/dist/docs/bookings.md +27 -0
- package/dist/docs/server-functions.md +29 -1
- package/dist/openapi.yaml +15 -0
- package/dist/testing/bookingsMemory.d.ts +75 -0
- package/dist/testing/bookingsMemory.js +413 -0
- package/dist/testing/index.d.ts +10 -0
- package/dist/testing/index.js +31 -10
- package/dist/types/appManifest.d.ts +55 -0
- package/docs/API_SUMMARY.md +33 -1
- package/docs/app-objects.md +3 -0
- package/docs/bookings.md +27 -0
- package/docs/server-functions.md +29 -1
- package/openapi.yaml +15 -0
- package/package.json +1 -1
package/dist/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.1 | Generated: 2026-10-25T08:26:22.390Z
|
|
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> };
|
package/dist/docs/app-objects.md
CHANGED
|
@@ -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/dist/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/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
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/testing/index.js
CHANGED
|
@@ -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
|
|
18
|
-
// in-memory store with the platform's
|
|
19
|
-
// email/phone, fill-empty only
|
|
20
|
-
//
|
|
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: (
|
|
290
|
-
anonymous: (
|
|
291
|
-
origin: (
|
|
292
|
-
ip: (
|
|
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`
|
package/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.1 | Generated: 2026-10-25T08:26:22.390Z
|
|
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> };
|
package/docs/app-objects.md
CHANGED
|
@@ -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:
|
package/docs/server-functions.md
CHANGED
|
@@ -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
|