@proveanything/smartlinks 2.0.70 → 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.
@@ -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) => {