@substrat-run/engine-booking 0.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.
- package/LICENSE +661 -0
- package/dist/index.d.ts +308 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +868 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { dataSubjectId, moduleManifest, money, permissionKey, } from '@substrat-run/contracts';
|
|
3
|
+
import { assertAllowed, ulid, } from '@substrat-run/kernel';
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// The reservation engine (docs/design/engine-booking.md). Owns exactly one
|
|
6
|
+
// invariant: concurrent allocations against a resource never exceed its
|
|
7
|
+
// capacity over any overlapping interval.
|
|
8
|
+
//
|
|
9
|
+
// It knows NOTHING about pricing, opening hours, recurrence, cancellation
|
|
10
|
+
// windows, skill levels, or timezones — all vertical policy. It takes absolute
|
|
11
|
+
// instants and compares them (D-B); it never does calendar arithmetic.
|
|
12
|
+
// ============================================================================
|
|
13
|
+
export const PERM = {
|
|
14
|
+
create: permissionKey.parse('booking:create'),
|
|
15
|
+
read: permissionKey.parse('booking:read'),
|
|
16
|
+
hold: permissionKey.parse('booking:hold'),
|
|
17
|
+
confirm: permissionKey.parse('booking:confirm'),
|
|
18
|
+
cancel: permissionKey.parse('booking:cancel'),
|
|
19
|
+
move: permissionKey.parse('booking:move'),
|
|
20
|
+
complete: permissionKey.parse('booking:complete'),
|
|
21
|
+
manageResources: permissionKey.parse('booking:manage-resources'),
|
|
22
|
+
};
|
|
23
|
+
export const bookingManifest = moduleManifest.parse({
|
|
24
|
+
id: '@substrat-run/engine-booking',
|
|
25
|
+
version: '0.0.1',
|
|
26
|
+
kernelContract: '^0.0.1',
|
|
27
|
+
permissions: [
|
|
28
|
+
{ key: 'booking:create', description: 'Create reservations' },
|
|
29
|
+
{ key: 'booking:read', description: 'Read resources, reservations and availability' },
|
|
30
|
+
{ key: 'booking:hold', description: 'Place a tentative hold on a slot' },
|
|
31
|
+
{ key: 'booking:confirm', description: 'Confirm a held reservation' },
|
|
32
|
+
{ key: 'booking:cancel', description: 'Cancel a reservation or leave one' },
|
|
33
|
+
{ key: 'booking:move', description: 'Reschedule a reservation to another slot or resource' },
|
|
34
|
+
{ key: 'booking:complete', description: 'Start service, complete, or mark a no-show' },
|
|
35
|
+
{ key: 'booking:manage-resources', description: 'Create, edit and deactivate bookable resources' },
|
|
36
|
+
],
|
|
37
|
+
events: {
|
|
38
|
+
emits: [
|
|
39
|
+
{ type: 'booking.held', schemaVersion: 1 },
|
|
40
|
+
{ type: 'booking.confirmed', schemaVersion: 1 },
|
|
41
|
+
{ type: 'booking.expired', schemaVersion: 1 },
|
|
42
|
+
{ type: 'booking.cancelled', schemaVersion: 1 },
|
|
43
|
+
{ type: 'booking.moved', schemaVersion: 1 },
|
|
44
|
+
{ type: 'booking.started', schemaVersion: 1 },
|
|
45
|
+
{ type: 'booking.completed', schemaVersion: 1 },
|
|
46
|
+
{ type: 'booking.no-show', schemaVersion: 1 },
|
|
47
|
+
{ type: 'booking.participant-joined', schemaVersion: 1 },
|
|
48
|
+
{ type: 'booking.participant-left', schemaVersion: 1 },
|
|
49
|
+
{ type: 'booking.opened', schemaVersion: 1 },
|
|
50
|
+
{ type: 'booking.resource-created', schemaVersion: 1 },
|
|
51
|
+
],
|
|
52
|
+
consumes: [],
|
|
53
|
+
},
|
|
54
|
+
migrations: { journalDir: './migrations', compatibleFrom: '0.0.1' },
|
|
55
|
+
attachmentTargets: [{ entityType: 'reservation', readPermission: 'booking:read' }],
|
|
56
|
+
entityRelations: [{ entityType: 'reservation', parentType: 'resource' }],
|
|
57
|
+
entitlementKey: 'booking',
|
|
58
|
+
ui: {
|
|
59
|
+
routes: [
|
|
60
|
+
{ path: 'calendar', screen: './ui/Calendar', permission: 'booking:read' },
|
|
61
|
+
{ path: 'reservations/:id', screen: './ui/ReservationDetail', permission: 'booking:read' },
|
|
62
|
+
],
|
|
63
|
+
nav: [{ label: 'booking.nav', icon: 'calendar', to: 'calendar', permission: 'booking:read' }],
|
|
64
|
+
entityViews: [{ entityType: 'reservation', view: './ui/ReservationCard' }],
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
export const bookingMigrations = [
|
|
68
|
+
{
|
|
69
|
+
version: '0001-init',
|
|
70
|
+
sql: `
|
|
71
|
+
CREATE TABLE booking_resources (
|
|
72
|
+
id TEXT PRIMARY KEY,
|
|
73
|
+
kind TEXT NOT NULL,
|
|
74
|
+
name TEXT NOT NULL,
|
|
75
|
+
capacity INTEGER NOT NULL DEFAULT 1 CHECK (capacity >= 1),
|
|
76
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
77
|
+
created_at TEXT NOT NULL
|
|
78
|
+
);
|
|
79
|
+
CREATE TABLE booking_reservations (
|
|
80
|
+
id TEXT PRIMARY KEY,
|
|
81
|
+
resource_id TEXT NOT NULL REFERENCES booking_resources(id),
|
|
82
|
+
starts_at TEXT NOT NULL,
|
|
83
|
+
ends_at TEXT NOT NULL,
|
|
84
|
+
state TEXT NOT NULL CHECK (state IN
|
|
85
|
+
('held','confirmed','in_service','completed','expired','cancelled','no_show')),
|
|
86
|
+
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity >= 1),
|
|
87
|
+
expires_at TEXT,
|
|
88
|
+
fill_target INTEGER,
|
|
89
|
+
note TEXT,
|
|
90
|
+
created_by TEXT NOT NULL,
|
|
91
|
+
created_at TEXT NOT NULL,
|
|
92
|
+
CHECK (starts_at < ends_at),
|
|
93
|
+
CHECK (state != 'held' OR expires_at IS NOT NULL)
|
|
94
|
+
);
|
|
95
|
+
CREATE INDEX booking_reservations_slot
|
|
96
|
+
ON booking_reservations (resource_id, starts_at, ends_at);
|
|
97
|
+
CREATE TABLE booking_participants (
|
|
98
|
+
id TEXT PRIMARY KEY,
|
|
99
|
+
reservation_id TEXT NOT NULL REFERENCES booking_reservations(id),
|
|
100
|
+
party_ref TEXT NOT NULL,
|
|
101
|
+
share_amount TEXT,
|
|
102
|
+
share_currency TEXT,
|
|
103
|
+
joined_at TEXT NOT NULL,
|
|
104
|
+
left_at TEXT
|
|
105
|
+
);
|
|
106
|
+
CREATE INDEX booking_participants_reservation
|
|
107
|
+
ON booking_participants (reservation_id);
|
|
108
|
+
`,
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Instants
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
/**
|
|
115
|
+
* Canonicalise to UTC before storing or comparing.
|
|
116
|
+
*
|
|
117
|
+
* This is load-bearing, not hygiene. The overlap check compares instants as
|
|
118
|
+
* **strings** in SQL, and `contracts.instant` permits any offset — so
|
|
119
|
+
* `2026-07-18T19:00:00+02:00` and `2026-07-18T17:00:00Z` are the same moment but
|
|
120
|
+
* sort differently as text. Normalising every instant to `…Z` on the way in makes
|
|
121
|
+
* lexicographic comparison equal chronological comparison, which is the only
|
|
122
|
+
* reason the SQL in `allocatedOver` is correct.
|
|
123
|
+
*/
|
|
124
|
+
function toInstant(value) {
|
|
125
|
+
const ms = Date.parse(value);
|
|
126
|
+
if (Number.isNaN(ms))
|
|
127
|
+
throw new Error(`invalid instant: ${value}`);
|
|
128
|
+
return new Date(ms).toISOString();
|
|
129
|
+
}
|
|
130
|
+
const instantIn = z
|
|
131
|
+
.string()
|
|
132
|
+
.refine((s) => !Number.isNaN(Date.parse(s)), { message: 'invalid instant' })
|
|
133
|
+
.transform(toInstant);
|
|
134
|
+
/** `now` is injectable so hold expiry is testable and replayable; it defaults to wall clock. */
|
|
135
|
+
const nowOr = (now) => (now ? toInstant(now) : new Date().toISOString());
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Errors
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
/** The typed rejection a vertical surfaces as "that slot was just taken". */
|
|
140
|
+
export class SlotUnavailable extends Error {
|
|
141
|
+
resourceId;
|
|
142
|
+
startsAt;
|
|
143
|
+
endsAt;
|
|
144
|
+
code = 'SLOT_UNAVAILABLE';
|
|
145
|
+
constructor(resourceId, startsAt, endsAt) {
|
|
146
|
+
super(`slot unavailable on resource ${resourceId} for ${startsAt}/${endsAt}`);
|
|
147
|
+
this.resourceId = resourceId;
|
|
148
|
+
this.startsAt = startsAt;
|
|
149
|
+
this.endsAt = endsAt;
|
|
150
|
+
this.name = 'SlotUnavailable';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Schemas & shapes
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
export const reservationState = z.enum([
|
|
157
|
+
'held',
|
|
158
|
+
'confirmed',
|
|
159
|
+
'in_service',
|
|
160
|
+
'completed',
|
|
161
|
+
'expired',
|
|
162
|
+
'cancelled',
|
|
163
|
+
'no_show',
|
|
164
|
+
]);
|
|
165
|
+
/** States that consume capacity. `held` additionally requires an unexpired `expires_at`. */
|
|
166
|
+
const LIVE_STATES = ['held', 'confirmed', 'in_service'];
|
|
167
|
+
export const createResourceInput = z.object({
|
|
168
|
+
kind: z.string().min(1),
|
|
169
|
+
name: z.string().min(1),
|
|
170
|
+
capacity: z.number().int().min(1).optional(),
|
|
171
|
+
});
|
|
172
|
+
export const holdReservationInput = z.object({
|
|
173
|
+
resourceId: z.string().min(1),
|
|
174
|
+
startsAt: instantIn,
|
|
175
|
+
endsAt: instantIn,
|
|
176
|
+
expiresAt: instantIn,
|
|
177
|
+
quantity: z.number().int().min(1).optional(),
|
|
178
|
+
fillTarget: z.number().int().min(1).optional(),
|
|
179
|
+
note: z.string().optional(),
|
|
180
|
+
now: z.string().optional(),
|
|
181
|
+
});
|
|
182
|
+
export const joinReservationInput = z.object({
|
|
183
|
+
reservationId: z.string().min(1),
|
|
184
|
+
/**
|
|
185
|
+
* The participant, as an opaque **data-subject** id — never a `PrincipalId`.
|
|
186
|
+
* A participant is a person, so this must be shreddable: it keys the erasure
|
|
187
|
+
* of the `participant-joined` / `participant-left` events below.
|
|
188
|
+
*/
|
|
189
|
+
partyRef: dataSubjectId,
|
|
190
|
+
share: money.optional(),
|
|
191
|
+
now: z.string().optional(),
|
|
192
|
+
});
|
|
193
|
+
export const moveReservationInput = z.object({
|
|
194
|
+
reservationId: z.string().min(1),
|
|
195
|
+
/** Target resource. Omitted = stay on the current one. */
|
|
196
|
+
resourceId: z.string().min(1).optional(),
|
|
197
|
+
/** New start. Given alone, the booking is *shifted* — its duration is preserved. */
|
|
198
|
+
startsAt: instantIn.optional(),
|
|
199
|
+
/** New end. Given alone, the booking is re-sized from its existing start. */
|
|
200
|
+
endsAt: instantIn.optional(),
|
|
201
|
+
now: z.string().optional(),
|
|
202
|
+
});
|
|
203
|
+
const toResource = (r) => ({
|
|
204
|
+
id: r.id,
|
|
205
|
+
kind: r.kind,
|
|
206
|
+
name: r.name,
|
|
207
|
+
capacity: r.capacity,
|
|
208
|
+
active: r.active === 1,
|
|
209
|
+
createdAt: r.created_at,
|
|
210
|
+
});
|
|
211
|
+
/** The one definition of "a hold past its deadline is expired". */
|
|
212
|
+
export function effectiveStateOf(state, expiresAt, now) {
|
|
213
|
+
return state === 'held' && expiresAt !== null && expiresAt <= now ? 'expired' : state;
|
|
214
|
+
}
|
|
215
|
+
const toReservation = (r, now = new Date().toISOString()) => ({
|
|
216
|
+
id: r.id,
|
|
217
|
+
resourceId: r.resource_id,
|
|
218
|
+
startsAt: r.starts_at,
|
|
219
|
+
endsAt: r.ends_at,
|
|
220
|
+
state: r.state,
|
|
221
|
+
effectiveState: effectiveStateOf(r.state, r.expires_at, now),
|
|
222
|
+
quantity: r.quantity,
|
|
223
|
+
expiresAt: r.expires_at,
|
|
224
|
+
fillTarget: r.fill_target,
|
|
225
|
+
note: r.note,
|
|
226
|
+
createdBy: r.created_by,
|
|
227
|
+
createdAt: r.created_at,
|
|
228
|
+
});
|
|
229
|
+
const toParticipant = (r) => ({
|
|
230
|
+
id: r.id,
|
|
231
|
+
partyRef: r.party_ref,
|
|
232
|
+
share: r.share_amount && r.share_currency
|
|
233
|
+
? { amount: r.share_amount, currency: r.share_currency }
|
|
234
|
+
: null,
|
|
235
|
+
joinedAt: r.joined_at,
|
|
236
|
+
leftAt: r.left_at,
|
|
237
|
+
});
|
|
238
|
+
const reservationRef = (id) => ({ entityType: 'reservation', entityId: id });
|
|
239
|
+
const resourceRef = (id) => ({ entityType: 'resource', entityId: id });
|
|
240
|
+
function getResourceRow(ctx, id) {
|
|
241
|
+
const row = ctx.sql.query('SELECT * FROM booking_resources WHERE id = ?', [id])[0];
|
|
242
|
+
if (!row)
|
|
243
|
+
throw new Error(`resource not found: ${id}`);
|
|
244
|
+
return row;
|
|
245
|
+
}
|
|
246
|
+
function getRow(ctx, id) {
|
|
247
|
+
const row = ctx.sql.query('SELECT * FROM booking_reservations WHERE id = ?', [
|
|
248
|
+
id,
|
|
249
|
+
])[0];
|
|
250
|
+
if (!row)
|
|
251
|
+
throw new Error(`reservation not found: ${id}`);
|
|
252
|
+
return row;
|
|
253
|
+
}
|
|
254
|
+
function requireState(row, ...allowed) {
|
|
255
|
+
if (!allowed.includes(row.state)) {
|
|
256
|
+
throw new Error(`invalid transition: reservation ${row.id} is '${row.state}', requires ${allowed.join('|')}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Participants who have not left — the count the fill target is measured against. */
|
|
260
|
+
function activeParticipants(ctx, reservationId) {
|
|
261
|
+
return ctx.sql.query('SELECT * FROM booking_participants WHERE reservation_id = ? AND left_at IS NULL ORDER BY id', [reservationId]);
|
|
262
|
+
}
|
|
263
|
+
function allParticipants(ctx, reservationId) {
|
|
264
|
+
return ctx.sql
|
|
265
|
+
.query('SELECT * FROM booking_participants WHERE reservation_id = ? ORDER BY id', [reservationId])
|
|
266
|
+
.map(toParticipant);
|
|
267
|
+
}
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// The invariant
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
/**
|
|
272
|
+
* How much of `resourceId` is already allocated over `[startsAt, endsAt)`.
|
|
273
|
+
*
|
|
274
|
+
* Intervals are **half-open**: a reservation ending at 19:00 and one starting at
|
|
275
|
+
* 19:00 do not overlap. Expiry is **lazy** — a `held` row past `expires_at` stops
|
|
276
|
+
* counting without anyone sweeping it.
|
|
277
|
+
*
|
|
278
|
+
* There is no lock here, and none is needed: the scope is a single Durable
|
|
279
|
+
* Object, so this read and the write that follows it never interleave with
|
|
280
|
+
* another transaction. That guarantee is why a resource's whole calendar must
|
|
281
|
+
* live in one scope (docs/design/booking-social.md §3).
|
|
282
|
+
*/
|
|
283
|
+
function allocatedOver(ctx, resourceId, startsAt, endsAt, now, excludeReservationId) {
|
|
284
|
+
const row = ctx.sql.query(`SELECT COALESCE(SUM(quantity), 0) AS allocated
|
|
285
|
+
FROM booking_reservations
|
|
286
|
+
WHERE resource_id = ?
|
|
287
|
+
AND starts_at < ?
|
|
288
|
+
AND ends_at > ?
|
|
289
|
+
AND ( state IN ('confirmed','in_service')
|
|
290
|
+
OR (state = 'held' AND expires_at > ?) )
|
|
291
|
+
AND id != ?`, [resourceId, endsAt, startsAt, now, excludeReservationId ?? ''])[0];
|
|
292
|
+
return row?.allocated ?? 0;
|
|
293
|
+
}
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// In-scope functions (K-16) — composable from vertical operations, same
|
|
296
|
+
// transaction. The CALLER is responsible for the permission check.
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
export function createResource(ctx, rawInput) {
|
|
299
|
+
const input = createResourceInput.parse(rawInput);
|
|
300
|
+
const id = ulid();
|
|
301
|
+
const createdAt = new Date().toISOString();
|
|
302
|
+
ctx.sql.exec(`INSERT INTO booking_resources (id, kind, name, capacity, active, created_at)
|
|
303
|
+
VALUES (?, ?, ?, ?, 1, ?)`, [id, input.kind, input.name, input.capacity ?? 1, createdAt]);
|
|
304
|
+
ctx.emit({
|
|
305
|
+
type: 'booking.resource-created',
|
|
306
|
+
schemaVersion: 1,
|
|
307
|
+
entity: resourceRef(id),
|
|
308
|
+
piiClass: 'none',
|
|
309
|
+
payload: { resourceId: id, kind: input.kind, name: input.name, capacity: input.capacity ?? 1 },
|
|
310
|
+
});
|
|
311
|
+
return toResource(getResourceRow(ctx, id));
|
|
312
|
+
}
|
|
313
|
+
export function setResourceActive(ctx, input) {
|
|
314
|
+
const row = getResourceRow(ctx, input.resourceId);
|
|
315
|
+
ctx.sql.exec('UPDATE booking_resources SET active = ? WHERE id = ?', [
|
|
316
|
+
input.active ? 1 : 0,
|
|
317
|
+
row.id,
|
|
318
|
+
]);
|
|
319
|
+
return toResource(getResourceRow(ctx, row.id));
|
|
320
|
+
}
|
|
321
|
+
export function listResources(ctx, kind) {
|
|
322
|
+
const rows = kind
|
|
323
|
+
? ctx.sql.query('SELECT * FROM booking_resources WHERE kind = ? ORDER BY name', [kind])
|
|
324
|
+
: ctx.sql.query('SELECT * FROM booking_resources ORDER BY name');
|
|
325
|
+
return rows.map(toResource);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Place a tentative hold. Throws {@link SlotUnavailable} if the interval would
|
|
329
|
+
* overallocate the resource.
|
|
330
|
+
*
|
|
331
|
+
* A hold is never permanent — `expiresAt` is mandatory. The same mechanism serves
|
|
332
|
+
* a payment hold and an open match awaiting players (`fillTarget`).
|
|
333
|
+
*/
|
|
334
|
+
export function holdReservation(ctx, rawInput) {
|
|
335
|
+
const input = holdReservationInput.parse(rawInput);
|
|
336
|
+
const now = nowOr(input.now);
|
|
337
|
+
if (input.startsAt >= input.endsAt) {
|
|
338
|
+
throw new Error(`invalid interval: ${input.startsAt} is not before ${input.endsAt}`);
|
|
339
|
+
}
|
|
340
|
+
if (input.expiresAt <= now) {
|
|
341
|
+
throw new Error(`hold would already be expired: ${input.expiresAt} <= ${now}`);
|
|
342
|
+
}
|
|
343
|
+
const resource = getResourceRow(ctx, input.resourceId);
|
|
344
|
+
if (resource.active !== 1)
|
|
345
|
+
throw new Error(`resource is inactive: ${resource.id}`);
|
|
346
|
+
const quantity = input.quantity ?? 1;
|
|
347
|
+
const allocated = allocatedOver(ctx, resource.id, input.startsAt, input.endsAt, now);
|
|
348
|
+
if (allocated + quantity > resource.capacity) {
|
|
349
|
+
throw new SlotUnavailable(resource.id, input.startsAt, input.endsAt);
|
|
350
|
+
}
|
|
351
|
+
const id = ulid();
|
|
352
|
+
ctx.sql.exec(`INSERT INTO booking_reservations
|
|
353
|
+
(id, resource_id, starts_at, ends_at, state, quantity, expires_at, fill_target,
|
|
354
|
+
note, created_by, created_at)
|
|
355
|
+
VALUES (?, ?, ?, ?, 'held', ?, ?, ?, ?, ?, ?)`, [
|
|
356
|
+
id,
|
|
357
|
+
resource.id,
|
|
358
|
+
input.startsAt,
|
|
359
|
+
input.endsAt,
|
|
360
|
+
quantity,
|
|
361
|
+
input.expiresAt,
|
|
362
|
+
input.fillTarget ?? null,
|
|
363
|
+
input.note ?? null,
|
|
364
|
+
ctx.principal,
|
|
365
|
+
now,
|
|
366
|
+
]);
|
|
367
|
+
ctx.link(reservationRef(id), resourceRef(resource.id));
|
|
368
|
+
ctx.emit({
|
|
369
|
+
type: 'booking.held',
|
|
370
|
+
schemaVersion: 1,
|
|
371
|
+
entity: reservationRef(id),
|
|
372
|
+
piiClass: 'none',
|
|
373
|
+
payload: {
|
|
374
|
+
reservationId: id,
|
|
375
|
+
resource: { id: resource.id, kind: resource.kind, name: resource.name },
|
|
376
|
+
startsAt: input.startsAt,
|
|
377
|
+
endsAt: input.endsAt,
|
|
378
|
+
quantity,
|
|
379
|
+
expiresAt: input.expiresAt,
|
|
380
|
+
fillTarget: input.fillTarget ?? null,
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
return toReservation(getRow(ctx, id));
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* held → confirmed. Re-runs the allocation check excluding this reservation,
|
|
387
|
+
* because the hold may have expired and the slot been taken in the meantime.
|
|
388
|
+
*/
|
|
389
|
+
export function confirmReservation(ctx, input) {
|
|
390
|
+
const row = getRow(ctx, input.reservationId);
|
|
391
|
+
requireState(row, 'held');
|
|
392
|
+
const now = nowOr(input.now);
|
|
393
|
+
if (row.expires_at && row.expires_at <= now) {
|
|
394
|
+
throw new Error(`hold expired at ${row.expires_at}`);
|
|
395
|
+
}
|
|
396
|
+
const resource = getResourceRow(ctx, row.resource_id);
|
|
397
|
+
const allocated = allocatedOver(ctx, row.resource_id, row.starts_at, row.ends_at, now, row.id);
|
|
398
|
+
if (allocated + row.quantity > resource.capacity) {
|
|
399
|
+
throw new SlotUnavailable(row.resource_id, row.starts_at, row.ends_at);
|
|
400
|
+
}
|
|
401
|
+
ctx.sql.exec(`UPDATE booking_reservations SET state = 'confirmed', expires_at = NULL WHERE id = ?`, [row.id]);
|
|
402
|
+
ctx.emit({
|
|
403
|
+
type: 'booking.confirmed',
|
|
404
|
+
schemaVersion: 1,
|
|
405
|
+
entity: reservationRef(row.id),
|
|
406
|
+
piiClass: 'none',
|
|
407
|
+
payload: {
|
|
408
|
+
reservationId: row.id,
|
|
409
|
+
resource: { id: resource.id, kind: resource.kind, name: resource.name },
|
|
410
|
+
startsAt: row.starts_at,
|
|
411
|
+
endsAt: row.ends_at,
|
|
412
|
+
quantity: row.quantity,
|
|
413
|
+
participantCount: activeParticipants(ctx, row.id).length,
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
return toReservation(getRow(ctx, row.id));
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Expire a hold whose deadline has passed. Idempotent-ish: only `held` rows move.
|
|
420
|
+
* Because expiry is lazy, calling this is optional for correctness — it exists so
|
|
421
|
+
* a vertical can surface the transition (and its event) to a UI.
|
|
422
|
+
*/
|
|
423
|
+
export function expireReservation(ctx, input) {
|
|
424
|
+
const row = getRow(ctx, input.reservationId);
|
|
425
|
+
requireState(row, 'held');
|
|
426
|
+
const now = nowOr(input.now);
|
|
427
|
+
if (!row.expires_at || row.expires_at > now) {
|
|
428
|
+
throw new Error(`reservation ${row.id} has not expired yet`);
|
|
429
|
+
}
|
|
430
|
+
ctx.sql.exec(`UPDATE booking_reservations SET state = 'expired' WHERE id = ?`, [row.id]);
|
|
431
|
+
ctx.emit({
|
|
432
|
+
type: 'booking.expired',
|
|
433
|
+
schemaVersion: 1,
|
|
434
|
+
entity: reservationRef(row.id),
|
|
435
|
+
piiClass: 'none',
|
|
436
|
+
payload: {
|
|
437
|
+
reservationId: row.id,
|
|
438
|
+
resourceId: row.resource_id,
|
|
439
|
+
startsAt: row.starts_at,
|
|
440
|
+
endsAt: row.ends_at,
|
|
441
|
+
participantCount: activeParticipants(ctx, row.id).length,
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
return toReservation(getRow(ctx, row.id));
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Add a participant. When the reservation is `held` and reaching `fillTarget`,
|
|
448
|
+
* this auto-confirms — the open-match mechanic, built out of the payment hold.
|
|
449
|
+
*/
|
|
450
|
+
export function joinReservation(ctx, rawInput) {
|
|
451
|
+
const input = joinReservationInput.parse(rawInput);
|
|
452
|
+
const row = getRow(ctx, input.reservationId);
|
|
453
|
+
requireState(row, 'held', 'confirmed');
|
|
454
|
+
const now = nowOr(input.now);
|
|
455
|
+
const active = activeParticipants(ctx, row.id);
|
|
456
|
+
if (active.some((p) => p.party_ref === input.partyRef)) {
|
|
457
|
+
throw new Error(`party ${input.partyRef} has already joined ${row.id}`);
|
|
458
|
+
}
|
|
459
|
+
if (row.fill_target !== null && active.length >= row.fill_target) {
|
|
460
|
+
throw new Error(`reservation ${row.id} is full (${row.fill_target})`);
|
|
461
|
+
}
|
|
462
|
+
const id = ulid();
|
|
463
|
+
ctx.sql.exec(`INSERT INTO booking_participants
|
|
464
|
+
(id, reservation_id, party_ref, share_amount, share_currency, joined_at)
|
|
465
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [id, row.id, input.partyRef, input.share?.amount ?? null, input.share?.currency ?? null, now]);
|
|
466
|
+
ctx.emit({
|
|
467
|
+
type: 'booking.participant-joined',
|
|
468
|
+
schemaVersion: 1,
|
|
469
|
+
entity: reservationRef(row.id),
|
|
470
|
+
piiClass: 'pseudonymous',
|
|
471
|
+
subjectId: input.partyRef,
|
|
472
|
+
payload: {
|
|
473
|
+
reservationId: row.id,
|
|
474
|
+
participantId: id,
|
|
475
|
+
partyRef: input.partyRef,
|
|
476
|
+
share: input.share ?? null,
|
|
477
|
+
joined: active.length + 1,
|
|
478
|
+
fillTarget: row.fill_target,
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
const participant = ctx.sql
|
|
482
|
+
.query('SELECT * FROM booking_participants WHERE id = ?', [id])
|
|
483
|
+
.map(toParticipant)[0];
|
|
484
|
+
const filled = row.fill_target !== null && active.length + 1 >= row.fill_target;
|
|
485
|
+
const reservation = filled && row.state === 'held'
|
|
486
|
+
? confirmReservation(ctx, { reservationId: row.id, now })
|
|
487
|
+
: toReservation(getRow(ctx, row.id));
|
|
488
|
+
return { participant, reservation };
|
|
489
|
+
}
|
|
490
|
+
/** Soft-leave: the row is never deleted, so the record of who was in stays intact. */
|
|
491
|
+
/**
|
|
492
|
+
* Open an existing reservation to others, or change how many places are on offer.
|
|
493
|
+
*
|
|
494
|
+
* `fillTarget` is engine state — it drives the auto-confirm in `joinReservation`
|
|
495
|
+
* — so a booking cannot be opened up by a vertical keeping its own counter
|
|
496
|
+
* beside it and hoping the two agree. Additive: reservations made without a
|
|
497
|
+
* target are unaffected, and a target below the people already on it is refused
|
|
498
|
+
* rather than silently stranding someone.
|
|
499
|
+
*
|
|
500
|
+
* Passing `null` closes it again — a private booking with no places on offer.
|
|
501
|
+
*/
|
|
502
|
+
export function openReservation(ctx, input) {
|
|
503
|
+
const row = getRow(ctx, input.reservationId);
|
|
504
|
+
requireState(row, 'held', 'confirmed');
|
|
505
|
+
const joined = activeParticipants(ctx, row.id).length;
|
|
506
|
+
if (input.fillTarget !== null && input.fillTarget < joined) {
|
|
507
|
+
throw new Error(`cannot open ${input.fillTarget} places: ${joined} are already on this reservation`);
|
|
508
|
+
}
|
|
509
|
+
ctx.sql.exec('UPDATE booking_reservations SET fill_target = ? WHERE id = ?', [
|
|
510
|
+
input.fillTarget,
|
|
511
|
+
row.id,
|
|
512
|
+
]);
|
|
513
|
+
ctx.emit({
|
|
514
|
+
type: 'booking.opened',
|
|
515
|
+
schemaVersion: 1,
|
|
516
|
+
entity: reservationRef(row.id),
|
|
517
|
+
piiClass: 'none',
|
|
518
|
+
payload: {
|
|
519
|
+
reservationId: row.id,
|
|
520
|
+
resourceId: row.resource_id,
|
|
521
|
+
startsAt: row.starts_at,
|
|
522
|
+
endsAt: row.ends_at,
|
|
523
|
+
fillTarget: input.fillTarget,
|
|
524
|
+
participantCount: joined,
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
return toReservation(getRow(ctx, row.id), nowOr(input.now));
|
|
528
|
+
}
|
|
529
|
+
export function leaveReservation(ctx, input) {
|
|
530
|
+
const row = getRow(ctx, input.reservationId);
|
|
531
|
+
const now = nowOr(input.now);
|
|
532
|
+
const participant = ctx.sql.query('SELECT * FROM booking_participants WHERE id = ? AND reservation_id = ?', [input.participantId, row.id])[0];
|
|
533
|
+
if (!participant)
|
|
534
|
+
throw new Error(`participant not found: ${input.participantId}`);
|
|
535
|
+
if (participant.left_at)
|
|
536
|
+
throw new Error(`participant already left: ${input.participantId}`);
|
|
537
|
+
ctx.sql.exec('UPDATE booking_participants SET left_at = ? WHERE id = ?', [now, participant.id]);
|
|
538
|
+
ctx.emit({
|
|
539
|
+
type: 'booking.participant-left',
|
|
540
|
+
schemaVersion: 1,
|
|
541
|
+
entity: reservationRef(row.id),
|
|
542
|
+
piiClass: 'pseudonymous',
|
|
543
|
+
subjectId: dataSubjectId.parse(participant.party_ref),
|
|
544
|
+
payload: {
|
|
545
|
+
reservationId: row.id,
|
|
546
|
+
participantId: participant.id,
|
|
547
|
+
partyRef: participant.party_ref,
|
|
548
|
+
remaining: activeParticipants(ctx, row.id).length,
|
|
549
|
+
fillTarget: row.fill_target,
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
return toReservation(getRow(ctx, row.id));
|
|
553
|
+
}
|
|
554
|
+
export function cancelReservation(ctx, input) {
|
|
555
|
+
const row = getRow(ctx, input.reservationId);
|
|
556
|
+
requireState(row, 'held', 'confirmed');
|
|
557
|
+
ctx.sql.exec(`UPDATE booking_reservations SET state = 'cancelled' WHERE id = ?`, [row.id]);
|
|
558
|
+
ctx.emit({
|
|
559
|
+
type: 'booking.cancelled',
|
|
560
|
+
schemaVersion: 1,
|
|
561
|
+
entity: reservationRef(row.id),
|
|
562
|
+
piiClass: 'none',
|
|
563
|
+
payload: {
|
|
564
|
+
reservationId: row.id,
|
|
565
|
+
resourceId: row.resource_id,
|
|
566
|
+
startsAt: row.starts_at,
|
|
567
|
+
endsAt: row.ends_at,
|
|
568
|
+
reason: input.reason ?? null,
|
|
569
|
+
participantCount: activeParticipants(ctx, row.id).length,
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
return toReservation(getRow(ctx, row.id));
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Reschedule to another slot and/or resource, keeping the reservation's identity
|
|
576
|
+
* and its participants.
|
|
577
|
+
*
|
|
578
|
+
* Deliberately **not** a general `updateReservation`. Engines model named
|
|
579
|
+
* transitions rather than field patches (cf. `engine-workorder`), participants are
|
|
580
|
+
* an append-only log with per-subject events rather than a patchable field (D-C),
|
|
581
|
+
* and `booking.moved` carrying from/to is worth far more to a consumer than a
|
|
582
|
+
* generic diff — event payloads freeze once shipped.
|
|
583
|
+
*
|
|
584
|
+
* This is not cancel-then-rebook: that would lose the identity, the roster, and
|
|
585
|
+
* any payment already attached.
|
|
586
|
+
*/
|
|
587
|
+
export function moveReservation(ctx, rawInput) {
|
|
588
|
+
const input = moveReservationInput.parse(rawInput);
|
|
589
|
+
const row = getRow(ctx, input.reservationId);
|
|
590
|
+
requireState(row, 'held', 'confirmed');
|
|
591
|
+
const now = nowOr(input.now);
|
|
592
|
+
const targetResourceId = input.resourceId ?? row.resource_id;
|
|
593
|
+
let startsAt = input.startsAt ?? row.starts_at;
|
|
594
|
+
let endsAt;
|
|
595
|
+
if (input.endsAt) {
|
|
596
|
+
endsAt = input.endsAt;
|
|
597
|
+
}
|
|
598
|
+
else if (input.startsAt) {
|
|
599
|
+
// Shift: preserve the booked duration, which is what dragging a cell means.
|
|
600
|
+
const duration = Date.parse(row.ends_at) - Date.parse(row.starts_at);
|
|
601
|
+
endsAt = new Date(Date.parse(startsAt) + duration).toISOString();
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
endsAt = row.ends_at;
|
|
605
|
+
}
|
|
606
|
+
if (startsAt >= endsAt) {
|
|
607
|
+
throw new Error(`invalid interval: ${startsAt} is not before ${endsAt}`);
|
|
608
|
+
}
|
|
609
|
+
const target = getResourceRow(ctx, targetResourceId);
|
|
610
|
+
if (target.active !== 1)
|
|
611
|
+
throw new Error(`resource is inactive: ${target.id}`);
|
|
612
|
+
// Excluding self is what makes a small nudge (overlapping its own old slot) legal.
|
|
613
|
+
const allocated = allocatedOver(ctx, target.id, startsAt, endsAt, now, row.id);
|
|
614
|
+
if (allocated + row.quantity > target.capacity) {
|
|
615
|
+
throw new SlotUnavailable(target.id, startsAt, endsAt);
|
|
616
|
+
}
|
|
617
|
+
const from = { resourceId: row.resource_id, startsAt: row.starts_at, endsAt: row.ends_at };
|
|
618
|
+
ctx.sql.exec('UPDATE booking_reservations SET resource_id = ?, starts_at = ?, ends_at = ? WHERE id = ?', [target.id, startsAt, endsAt, row.id]);
|
|
619
|
+
ctx.emit({
|
|
620
|
+
type: 'booking.moved',
|
|
621
|
+
schemaVersion: 1,
|
|
622
|
+
entity: reservationRef(row.id),
|
|
623
|
+
piiClass: 'none',
|
|
624
|
+
payload: {
|
|
625
|
+
reservationId: row.id,
|
|
626
|
+
from,
|
|
627
|
+
to: { resourceId: target.id, startsAt, endsAt },
|
|
628
|
+
resource: { id: target.id, kind: target.kind, name: target.name },
|
|
629
|
+
participantCount: activeParticipants(ctx, row.id).length,
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
return toReservation(getRow(ctx, row.id), now);
|
|
633
|
+
}
|
|
634
|
+
export function startReservation(ctx, input) {
|
|
635
|
+
const row = getRow(ctx, input.reservationId);
|
|
636
|
+
requireState(row, 'confirmed');
|
|
637
|
+
ctx.sql.exec(`UPDATE booking_reservations SET state = 'in_service' WHERE id = ?`, [row.id]);
|
|
638
|
+
ctx.emit({
|
|
639
|
+
type: 'booking.started',
|
|
640
|
+
schemaVersion: 1,
|
|
641
|
+
entity: reservationRef(row.id),
|
|
642
|
+
piiClass: 'none',
|
|
643
|
+
payload: { reservationId: row.id, resourceId: row.resource_id },
|
|
644
|
+
});
|
|
645
|
+
return toReservation(getRow(ctx, row.id));
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The terminal success transition. The payload is deliberately **fat** — resource,
|
|
649
|
+
* interval and the full participant list — so an invoicing consumer can raise split
|
|
650
|
+
* charges and an out-of-kernel consumer can build cross-club history, neither
|
|
651
|
+
* needing a cross-module read.
|
|
652
|
+
*/
|
|
653
|
+
export function completeReservation(ctx, input) {
|
|
654
|
+
const row = getRow(ctx, input.reservationId);
|
|
655
|
+
requireState(row, 'confirmed', 'in_service');
|
|
656
|
+
const resource = getResourceRow(ctx, row.resource_id);
|
|
657
|
+
ctx.sql.exec(`UPDATE booking_reservations SET state = 'completed' WHERE id = ?`, [row.id]);
|
|
658
|
+
ctx.emit({
|
|
659
|
+
type: 'booking.completed',
|
|
660
|
+
schemaVersion: 1,
|
|
661
|
+
entity: reservationRef(row.id),
|
|
662
|
+
piiClass: 'none',
|
|
663
|
+
payload: {
|
|
664
|
+
reservationId: row.id,
|
|
665
|
+
resource: { id: resource.id, kind: resource.kind, name: resource.name },
|
|
666
|
+
startsAt: row.starts_at,
|
|
667
|
+
endsAt: row.ends_at,
|
|
668
|
+
quantity: row.quantity,
|
|
669
|
+
participantCount: activeParticipants(ctx, row.id).length,
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
return toReservation(getRow(ctx, row.id));
|
|
673
|
+
}
|
|
674
|
+
export function markNoShow(ctx, input) {
|
|
675
|
+
const row = getRow(ctx, input.reservationId);
|
|
676
|
+
requireState(row, 'confirmed', 'in_service');
|
|
677
|
+
ctx.sql.exec(`UPDATE booking_reservations SET state = 'no_show' WHERE id = ?`, [row.id]);
|
|
678
|
+
ctx.emit({
|
|
679
|
+
type: 'booking.no-show',
|
|
680
|
+
schemaVersion: 1,
|
|
681
|
+
entity: reservationRef(row.id),
|
|
682
|
+
piiClass: 'none',
|
|
683
|
+
payload: {
|
|
684
|
+
reservationId: row.id,
|
|
685
|
+
resourceId: row.resource_id,
|
|
686
|
+
startsAt: row.starts_at,
|
|
687
|
+
endsAt: row.ends_at,
|
|
688
|
+
participantCount: activeParticipants(ctx, row.id).length,
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
return toReservation(getRow(ctx, row.id));
|
|
692
|
+
}
|
|
693
|
+
export function getReservation(ctx, reservationId, now) {
|
|
694
|
+
return {
|
|
695
|
+
reservation: toReservation(getRow(ctx, reservationId), nowOr(now)),
|
|
696
|
+
participants: allParticipants(ctx, reservationId),
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
export function listReservations(ctx, input) {
|
|
700
|
+
const clauses = [];
|
|
701
|
+
const params = [];
|
|
702
|
+
if (input.resourceId) {
|
|
703
|
+
clauses.push('resource_id = ?');
|
|
704
|
+
params.push(input.resourceId);
|
|
705
|
+
}
|
|
706
|
+
if (input.to) {
|
|
707
|
+
clauses.push('starts_at < ?');
|
|
708
|
+
params.push(toInstant(input.to));
|
|
709
|
+
}
|
|
710
|
+
if (input.from) {
|
|
711
|
+
clauses.push('ends_at > ?');
|
|
712
|
+
params.push(toInstant(input.from));
|
|
713
|
+
}
|
|
714
|
+
const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';
|
|
715
|
+
const now = nowOr(input.now);
|
|
716
|
+
return ctx.sql
|
|
717
|
+
.query(`SELECT * FROM booking_reservations${where} ORDER BY starts_at, id`, params)
|
|
718
|
+
.map((r) => toReservation(r, now));
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Free capacity over `[from, to)`, as merged intervals.
|
|
722
|
+
*
|
|
723
|
+
* Returns raw gaps between reservations — it knows nothing of opening hours and
|
|
724
|
+
* will happily report 03:00 as free. Intersecting with the venue's bookable
|
|
725
|
+
* window is the **vertical's** job (docs/design/engine-booking.md §4.1).
|
|
726
|
+
*
|
|
727
|
+
* Implemented as a sweep over interval boundaries rather than a simple gap walk,
|
|
728
|
+
* because capacity may exceed 1 (fungible pools), where "free" is a number and not
|
|
729
|
+
* a boolean.
|
|
730
|
+
*/
|
|
731
|
+
export function availability(ctx, input) {
|
|
732
|
+
const from = toInstant(input.from);
|
|
733
|
+
const to = toInstant(input.to);
|
|
734
|
+
if (from >= to)
|
|
735
|
+
return [];
|
|
736
|
+
const now = nowOr(input.now);
|
|
737
|
+
const resource = getResourceRow(ctx, input.resourceId);
|
|
738
|
+
if (resource.active !== 1)
|
|
739
|
+
return [];
|
|
740
|
+
const live = ctx.sql.query(`SELECT * FROM booking_reservations
|
|
741
|
+
WHERE resource_id = ?
|
|
742
|
+
AND starts_at < ? AND ends_at > ?
|
|
743
|
+
AND ( state IN ('confirmed','in_service')
|
|
744
|
+
OR (state = 'held' AND expires_at > ?) )`, [resource.id, to, from, now]);
|
|
745
|
+
const edges = new Set([from, to]);
|
|
746
|
+
for (const r of live) {
|
|
747
|
+
if (r.starts_at > from && r.starts_at < to)
|
|
748
|
+
edges.add(r.starts_at);
|
|
749
|
+
if (r.ends_at > from && r.ends_at < to)
|
|
750
|
+
edges.add(r.ends_at);
|
|
751
|
+
}
|
|
752
|
+
const points = [...edges].sort();
|
|
753
|
+
const segments = [];
|
|
754
|
+
for (let i = 0; i < points.length - 1; i += 1) {
|
|
755
|
+
const segStart = points[i];
|
|
756
|
+
const segEnd = points[i + 1];
|
|
757
|
+
const allocated = live
|
|
758
|
+
.filter((r) => r.starts_at < segEnd && r.ends_at > segStart)
|
|
759
|
+
.reduce((sum, r) => sum + r.quantity, 0);
|
|
760
|
+
const free = resource.capacity - allocated;
|
|
761
|
+
if (free <= 0)
|
|
762
|
+
continue;
|
|
763
|
+
const prev = segments[segments.length - 1];
|
|
764
|
+
if (prev && prev.endsAt === segStart && prev.available === free) {
|
|
765
|
+
prev.endsAt = segEnd; // merge adjacent equal-availability segments
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
segments.push({ startsAt: segStart, endsAt: segEnd, available: free });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return segments;
|
|
772
|
+
}
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
// Default operation bindings — each starts with the permission check.
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
const createResourceOp = async (ctx, input) => {
|
|
777
|
+
assertAllowed(await ctx.check(PERM.manageResources));
|
|
778
|
+
return createResource(ctx, input);
|
|
779
|
+
};
|
|
780
|
+
const setResourceActiveOp = async (ctx, input) => {
|
|
781
|
+
assertAllowed(await ctx.check(PERM.manageResources));
|
|
782
|
+
return setResourceActive(ctx, input);
|
|
783
|
+
};
|
|
784
|
+
const listResourcesOp = async (ctx, input) => {
|
|
785
|
+
assertAllowed(await ctx.check(PERM.read));
|
|
786
|
+
return listResources(ctx, input?.kind);
|
|
787
|
+
};
|
|
788
|
+
const holdOp = async (ctx, input) => {
|
|
789
|
+
assertAllowed(await ctx.check(PERM.hold));
|
|
790
|
+
return holdReservation(ctx, input);
|
|
791
|
+
};
|
|
792
|
+
const confirmOp = async (ctx, input) => {
|
|
793
|
+
assertAllowed(await ctx.check(PERM.confirm, reservationRef(input.reservationId)));
|
|
794
|
+
return confirmReservation(ctx, input);
|
|
795
|
+
};
|
|
796
|
+
const expireOp = async (ctx, input) => {
|
|
797
|
+
assertAllowed(await ctx.check(PERM.confirm));
|
|
798
|
+
return expireReservation(ctx, input);
|
|
799
|
+
};
|
|
800
|
+
const joinOp = async (ctx, input) => {
|
|
801
|
+
assertAllowed(await ctx.check(PERM.create, reservationRef(input.reservationId)));
|
|
802
|
+
return joinReservation(ctx, input);
|
|
803
|
+
};
|
|
804
|
+
const leaveOp = async (ctx, input) => {
|
|
805
|
+
assertAllowed(await ctx.check(PERM.cancel, reservationRef(input.reservationId)));
|
|
806
|
+
return leaveReservation(ctx, input);
|
|
807
|
+
};
|
|
808
|
+
const cancelOp = async (ctx, input) => {
|
|
809
|
+
assertAllowed(await ctx.check(PERM.cancel, reservationRef(input.reservationId)));
|
|
810
|
+
return cancelReservation(ctx, input);
|
|
811
|
+
};
|
|
812
|
+
const moveOp = async (ctx, input) => {
|
|
813
|
+
assertAllowed(await ctx.check(PERM.move, reservationRef(input.reservationId)));
|
|
814
|
+
return moveReservation(ctx, input);
|
|
815
|
+
};
|
|
816
|
+
const openOp = async (ctx, input) => {
|
|
817
|
+
// Whoever may confirm a reservation may decide whether it is on offer.
|
|
818
|
+
assertAllowed(await ctx.check(PERM.confirm, reservationRef(input.reservationId)));
|
|
819
|
+
return openReservation(ctx, input);
|
|
820
|
+
};
|
|
821
|
+
const startOp = async (ctx, input) => {
|
|
822
|
+
assertAllowed(await ctx.check(PERM.complete));
|
|
823
|
+
return startReservation(ctx, input);
|
|
824
|
+
};
|
|
825
|
+
const completeOp = async (ctx, input) => {
|
|
826
|
+
assertAllowed(await ctx.check(PERM.complete));
|
|
827
|
+
return completeReservation(ctx, input);
|
|
828
|
+
};
|
|
829
|
+
const noShowOp = async (ctx, input) => {
|
|
830
|
+
assertAllowed(await ctx.check(PERM.complete));
|
|
831
|
+
return markNoShow(ctx, input);
|
|
832
|
+
};
|
|
833
|
+
const getOp = async (ctx, input) => {
|
|
834
|
+
assertAllowed(await ctx.check(PERM.read, reservationRef(input.reservationId)));
|
|
835
|
+
return getReservation(ctx, input.reservationId, input.now);
|
|
836
|
+
};
|
|
837
|
+
const listOp = async (ctx, input) => {
|
|
838
|
+
assertAllowed(await ctx.check(PERM.read));
|
|
839
|
+
return listReservations(ctx, input ?? {});
|
|
840
|
+
};
|
|
841
|
+
const availabilityOp = async (ctx, input) => {
|
|
842
|
+
assertAllowed(await ctx.check(PERM.read));
|
|
843
|
+
return availability(ctx, input);
|
|
844
|
+
};
|
|
845
|
+
export const bookingModule = {
|
|
846
|
+
manifest: bookingManifest,
|
|
847
|
+
migrations: bookingMigrations,
|
|
848
|
+
operations: {
|
|
849
|
+
'booking/create-resource': createResourceOp,
|
|
850
|
+
'booking/set-resource-active': setResourceActiveOp,
|
|
851
|
+
'booking/list-resources': listResourcesOp,
|
|
852
|
+
'booking/hold': holdOp,
|
|
853
|
+
'booking/confirm': confirmOp,
|
|
854
|
+
'booking/expire': expireOp,
|
|
855
|
+
'booking/join': joinOp,
|
|
856
|
+
'booking/leave': leaveOp,
|
|
857
|
+
'booking/cancel': cancelOp,
|
|
858
|
+
'booking/move': moveOp,
|
|
859
|
+
'booking/open': openOp,
|
|
860
|
+
'booking/start': startOp,
|
|
861
|
+
'booking/complete': completeOp,
|
|
862
|
+
'booking/no-show': noShowOp,
|
|
863
|
+
'booking/get': getOp,
|
|
864
|
+
'booking/list': listOp,
|
|
865
|
+
'booking/availability': availabilityOp,
|
|
866
|
+
},
|
|
867
|
+
};
|
|
868
|
+
//# sourceMappingURL=index.js.map
|