slotlock 0.6.2 → 0.7.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/CHANGELOG.md +492 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1544
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +560 -49
- package/agent-store-backend.d.ts +39 -7
- package/agent-store-backend.js +1439 -104
- package/api-keys.d.ts +69 -3
- package/api-keys.js +218 -29
- package/application-api.d.ts +59 -6
- package/application-api.js +842 -39
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +58 -27
- package/assistant.js +151 -53
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +42 -6
- package/config.js +114 -7
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1558 -64
- package/ddl.d.ts +67 -35
- package/ddl.js +2732 -162
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +53 -35
- package/event-feed.js +201 -64
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/interop.js +1 -4
- package/maintenance.d.ts +24 -10
- package/maintenance.js +59 -28
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +27 -2
- package/rate-limit.js +106 -23
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +15 -14
- package/rest-api.js +79 -15
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +46 -4
- package/self-host.js +990 -41
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +482 -165
- package/store.js +1460 -484
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.d.ts +25 -33
- package/timezone.js +50 -53
- package/types.d.ts +130 -57
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/agent-store-backend.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { SLOTLOCK_RESOURCE_PROFILE_FIELDS, SLOTLOCK_RECONCILE_TOLERANCE_PERCENT, slotlockResourceHasLabels, } from './resource-profile.js';
|
|
2
|
+
import { SLOTLOCK_USAGE_RETENTION_DAYS, slotlockUsageDay, slotlockUsageMonthToDate, slotlockUsageSpanDays, summarizeSlotlockUsage, } from './usage.js';
|
|
1
3
|
import { slotlockChangeFeedHead } from './event-feed.js';
|
|
2
4
|
import { slotlockCloudEvent } from './webhooks.js';
|
|
3
5
|
import { createHash } from 'node:crypto';
|
|
6
|
+
import { SLOTLOCK_DEFAULT_APPROVAL_MINUTES, SLOTLOCK_KEY_APPROVAL_POLICY, SLOTLOCK_MAX_APPROVAL_MINUTES, SlotlockApprovalPolicyError, } from './approvals.js';
|
|
4
7
|
import { SlotlockAgentOperationError, } from './agent-server.js';
|
|
5
8
|
import { bookingSearch, bookingSearchBlocked, bookingSearchReach, } from './booking-rules.js';
|
|
6
9
|
import { findNextAvailable, mergeIntervals } from './engine.js';
|
|
@@ -8,15 +11,42 @@ import { expandRules } from './rules.js';
|
|
|
8
11
|
import { emitVAvailability, parseVAvailability, toJSCalendarEvent } from './interop.js';
|
|
9
12
|
import { calendarEventRollingHorizon, } from './store.js';
|
|
10
13
|
import { maintainSlotlockTenant } from './maintenance.js';
|
|
14
|
+
import { fastForwardSlotlockSandbox, raceSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
|
|
15
|
+
import { SLOTLOCK_TEST_TENANT_SUFFIX } from './api-keys.js';
|
|
16
|
+
import { slotlockPartnerKeyOutput, slotlockPrincipalPartner, } from './partners.js';
|
|
17
|
+
/** A partner's account as the partner API shows it. */
|
|
18
|
+
function partnerAccountOutput(account) {
|
|
19
|
+
return {
|
|
20
|
+
account: account.account,
|
|
21
|
+
plan: account.plan,
|
|
22
|
+
status: account.status,
|
|
23
|
+
created_at: account.createdAt.toISOString(),
|
|
24
|
+
updated_at: account.updatedAt.toISOString(),
|
|
25
|
+
...(account.resources !== undefined ? { resources: account.resources } : {}),
|
|
26
|
+
...(account.apiKeys !== undefined ? { api_keys: account.apiKeys } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
11
29
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
12
30
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* and refuses it otherwise.
|
|
31
|
+
* Writes run under READ COMMITTED whatever the role's default: tenant limits are counted under a
|
|
32
|
+
* lock that needs that level.
|
|
16
33
|
*/
|
|
17
34
|
const WRITE = { isolation: 'read committed' };
|
|
18
|
-
|
|
19
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Keys a context carries only from inside this module: a request cannot set a symbol, and spreading
|
|
37
|
+
* a context (`{ ...context, operation }`) keeps them.
|
|
38
|
+
*/
|
|
39
|
+
const APPROVAL_GRANT = Symbol('slotlock.approvalGrant');
|
|
40
|
+
const OCCUPANCY_PROBE = Symbol('slotlock.occupancyProbe');
|
|
41
|
+
/** Thrown out of a dry run's transaction, with its answer, so the transaction rolls back. */
|
|
42
|
+
class DryRunRollback {
|
|
43
|
+
value;
|
|
44
|
+
constructor(value) {
|
|
45
|
+
this.value = value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// The UUIDs withTenant takes as a keyId (versions 1-5); any other from a custom authenticator is
|
|
49
|
+
// left out, not refused.
|
|
20
50
|
const KEY_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
21
51
|
/** Who a call's changes are recorded as in the history: the principal's subject, kept short. */
|
|
22
52
|
function actorOf(context) {
|
|
@@ -36,16 +66,51 @@ async function withTenant(store, context, callback, options) {
|
|
|
36
66
|
try {
|
|
37
67
|
const actor = actorOf(context);
|
|
38
68
|
const keyId = context.principal.keyId;
|
|
39
|
-
|
|
69
|
+
// A granted approval's write: its holds give way and it is marked granted in the same
|
|
70
|
+
// transaction, so the time passes from the approval to the booking with no gap between.
|
|
71
|
+
const grant = context[APPROVAL_GRANT];
|
|
72
|
+
const write = grant
|
|
73
|
+
? async (tenantStore) => {
|
|
74
|
+
const tenantRef = context.principal.tenantRef;
|
|
75
|
+
const begun = await tenantStore.beginApprovalGrant({ tenantRef, id: grant.id });
|
|
76
|
+
if (!begun.ok) {
|
|
77
|
+
throw new SlotlockAgentOperationError(begun.code, begun.code === 'approval_not_found' ? 404 : 409);
|
|
78
|
+
}
|
|
79
|
+
const value = await callback(tenantStore);
|
|
80
|
+
const booked = value.event?.resource_id;
|
|
81
|
+
if (grant.pool !== undefined && typeof booked === 'string') {
|
|
82
|
+
await tenantStore.notePoolBooking({ tenantRef, pool: grant.pool, resourceId: booked });
|
|
83
|
+
}
|
|
84
|
+
await tenantStore.finishApprovalGrant({ tenantRef, id: grant.id, decidedBy: grant.decidedBy });
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
: callback;
|
|
88
|
+
// A dry run's transaction does everything, constraints included, then is undone: its answer
|
|
89
|
+
// is carried out of it by the rollback.
|
|
90
|
+
const probe = context[OCCUPANCY_PROBE];
|
|
91
|
+
const run = context.dryRun
|
|
92
|
+
? async (tenantStore) => {
|
|
93
|
+
const value = await write(tenantStore);
|
|
94
|
+
if (probe)
|
|
95
|
+
await probe(tenantStore);
|
|
96
|
+
throw new DryRunRollback(value);
|
|
97
|
+
}
|
|
98
|
+
: write;
|
|
99
|
+
value = await store.withTenant(context.principal.tenantRef, run, {
|
|
40
100
|
...options,
|
|
41
101
|
signal: context.signal,
|
|
42
102
|
...(actor !== undefined ? { actor } : {}),
|
|
43
103
|
...(context.origin !== undefined ? { origin: context.origin } : {}),
|
|
44
|
-
// An authenticator's own principal may carry something else there: then it is left out.
|
|
45
104
|
...(typeof keyId === 'string' && KEY_ID.test(keyId) ? { keyId } : {}),
|
|
105
|
+
// A key limited to part of the tenant reaches only its resources, by the database's policies.
|
|
106
|
+
...(context.principal.resourceScope ? { resourceScope: context.principal.resourceScope } : {}),
|
|
46
107
|
});
|
|
47
108
|
}
|
|
48
109
|
catch (error) {
|
|
110
|
+
if (error instanceof DryRunRollback) {
|
|
111
|
+
assertActive(context);
|
|
112
|
+
return error.value;
|
|
113
|
+
}
|
|
49
114
|
const code = error?.code;
|
|
50
115
|
// A suspended tenant may read; a write it asks for is forbidden, not a server failure.
|
|
51
116
|
if (code === 'tenant_suspended')
|
|
@@ -60,22 +125,18 @@ async function withTenant(store, context, callback, options) {
|
|
|
60
125
|
return value;
|
|
61
126
|
}
|
|
62
127
|
/**
|
|
63
|
-
* Busy intervals one free/busy read or search may gather across all
|
|
64
|
-
*
|
|
65
|
-
* one resource 100 times, did 100 times the work of one.
|
|
128
|
+
* Busy intervals one free/busy read or search may gather across all its resources: as many as the
|
|
129
|
+
* store reads for one, so naming many resources cannot multiply the work.
|
|
66
130
|
*/
|
|
67
131
|
const MAX_REQUEST_BUSY_INTERVALS = 10_000;
|
|
68
132
|
/**
|
|
69
|
-
* Bookable windows one search may expand from its resources'
|
|
70
|
-
*
|
|
71
|
-
* resources over a year expanded 1.8 million windows and held the process for seven seconds.
|
|
133
|
+
* Bookable windows one search may expand from its resources' rules in all (five resources of 50
|
|
134
|
+
* daily rules over a year, or a hundred of two), so a broad search cannot hold the process.
|
|
72
135
|
*/
|
|
73
136
|
const MAX_REQUEST_RULE_WINDOWS = 100_000;
|
|
74
|
-
/** The resources a request names, each once, in the order first named. */
|
|
75
137
|
function distinctResourceIds(input) {
|
|
76
138
|
return [...new Set(input.resource_ids)];
|
|
77
139
|
}
|
|
78
|
-
/** Counts a request's busy intervals against MAX_REQUEST_BUSY_INTERVALS, refusing past it. */
|
|
79
140
|
function busyBudget() {
|
|
80
141
|
let left = MAX_REQUEST_BUSY_INTERVALS;
|
|
81
142
|
return (intervals) => {
|
|
@@ -169,12 +230,12 @@ function agentOwnerRef(context) {
|
|
|
169
230
|
.digest('hex')}`;
|
|
170
231
|
}
|
|
171
232
|
function agentExternalRef(context, idempotencyKey) {
|
|
172
|
-
//
|
|
233
|
+
// Already a stable client-visible identity; keep it unchanged.
|
|
173
234
|
return `agent:${agentPrincipalDigest(context, idempotencyKey)}`;
|
|
174
235
|
}
|
|
175
236
|
function agentCommandIdempotencyKey(context, idempotencyKey) {
|
|
176
|
-
//
|
|
177
|
-
//
|
|
237
|
+
// The principal stays in the digest so this remains safe with older tenant-only stores; hashing
|
|
238
|
+
// keeps the subject and raw retry key out of durable rows.
|
|
178
239
|
return `agent-command:${agentPrincipalDigest(context, idempotencyKey)}`;
|
|
179
240
|
}
|
|
180
241
|
function attendeeProjection(attendee) {
|
|
@@ -192,8 +253,8 @@ function attendeeProjection(attendee) {
|
|
|
192
253
|
*/
|
|
193
254
|
export function projectSlotlockCalendarEvent(event) {
|
|
194
255
|
return {
|
|
195
|
-
// External references are the
|
|
196
|
-
//
|
|
256
|
+
// External references are the tenant-visible identity: internal UUIDs never become an unscoped
|
|
257
|
+
// lookup at the protocol boundary.
|
|
197
258
|
id: event.externalRef,
|
|
198
259
|
resource_id: event.resourceId,
|
|
199
260
|
starts_at: event.start.toISOString(),
|
|
@@ -246,20 +307,41 @@ const BOOKING_RULE_CODES = Object.freeze({
|
|
|
246
307
|
outside_hours: 'booking_rule_outside_hours',
|
|
247
308
|
uncertain: 'booking_rule_uncertain',
|
|
248
309
|
});
|
|
249
|
-
function operationError(refusal) {
|
|
250
|
-
// A broken booking rule is named in the code, which is all an agent's error carries.
|
|
310
|
+
function operationError(refusal, details) {
|
|
251
311
|
const code = typeof refusal === 'string'
|
|
252
312
|
? refusal
|
|
253
313
|
: refusal.code === 'booking_rule_violated' && refusal.rule
|
|
254
314
|
? (BOOKING_RULE_CODES[refusal.rule] ?? refusal.code)
|
|
255
315
|
: refusal.code;
|
|
256
316
|
const status = slotlockAgentErrorStatus(code);
|
|
257
|
-
// A gone hold is named, so the agent holds again
|
|
258
|
-
return new SlotlockAgentOperationError(status === 404 && code !== 'hold_not_found' ? 'not_found' : code, status);
|
|
317
|
+
// A gone hold (another holder's, released or reaped) is named, so the agent holds again.
|
|
318
|
+
return new SlotlockAgentOperationError(status === 404 && code !== 'hold_not_found' ? 'not_found' : code, status, details);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* What an agent may be told about the booking in the way of its write: when that time is busy
|
|
322
|
+
* (as free/busy shows anyone who may read the resource), and the booking itself only when it is
|
|
323
|
+
* the agent's own hold or event, so it can release or move it. Nothing for another refusal, or for
|
|
324
|
+
* a booking the key may not see (its scope, or another tenant's).
|
|
325
|
+
*/
|
|
326
|
+
async function overlapDetails(tenantStore, context, refusal) {
|
|
327
|
+
if (refusal.code !== 'overlap' || typeof refusal.conflictingReservationId !== 'string')
|
|
328
|
+
return {};
|
|
329
|
+
const booking = await tenantStore.describeBooking({
|
|
330
|
+
tenantRef: context.principal.tenantRef,
|
|
331
|
+
id: refusal.conflictingReservationId,
|
|
332
|
+
});
|
|
333
|
+
if (!booking)
|
|
334
|
+
return {};
|
|
335
|
+
const owner = agentOwnerRef(context);
|
|
336
|
+
return {
|
|
337
|
+
busy_start: booking.start.toISOString(),
|
|
338
|
+
busy_end: booking.end.toISOString(),
|
|
339
|
+
...(booking.status === 'held' && booking.holderRef === owner ? { conflicting_hold_id: booking.id } : {}),
|
|
340
|
+
...(booking.eventRef !== null && booking.eventOwnerRef === owner ? { conflicting_event_id: booking.eventRef } : {}),
|
|
341
|
+
};
|
|
259
342
|
}
|
|
260
343
|
/** Resource and event ids: the UUIDs the store issues and validates. */
|
|
261
344
|
const STORE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
262
|
-
/** A resource page's cursor is the last resource id of the previous page. */
|
|
263
345
|
function parseResourceCursor(value) {
|
|
264
346
|
if (value === undefined)
|
|
265
347
|
return undefined;
|
|
@@ -299,12 +381,10 @@ function page(rows, limit) {
|
|
|
299
381
|
return { shown, next: rows.length > limit && last ? encodeEventCursor(last) : null };
|
|
300
382
|
}
|
|
301
383
|
/**
|
|
302
|
-
* The caller's resource with this id, or `resource_not_found`.
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* names nothing or another tenant's resource. With `visibility`, a resource of the other is not
|
|
307
|
-
* found either: agents ask for `shared`, so a private resource is invisible to them.
|
|
384
|
+
* The caller's resource with this id, or `resource_not_found`. Agents may send any id; one that is
|
|
385
|
+
* not a store-issued (v1-5) UUID names no resource and is not found, like another tenant's. With
|
|
386
|
+
* `visibility`, a resource of the other visibility is not found either, so private ones are
|
|
387
|
+
* invisible to agents.
|
|
308
388
|
*/
|
|
309
389
|
async function requireResource(tenantStore, tenantRef, resourceId, visibility) {
|
|
310
390
|
if (!STORE_ID_PATTERN.test(resourceId)) {
|
|
@@ -325,9 +405,8 @@ function requireAgentResource(tenantStore, tenantRef, resourceId) {
|
|
|
325
405
|
}
|
|
326
406
|
/**
|
|
327
407
|
* The store's change feed for live updates (`subscriptions.changes`): each tenant's changes read
|
|
328
|
-
* under its
|
|
329
|
-
*
|
|
330
|
-
* `SLOTLOCK_FEED_IDLE_MS`); `sql` is the store's client, used for the feed's head.
|
|
408
|
+
* under its RLS, one indexed query per read. Readers poll it (change-watch.ts); `sql` is the
|
|
409
|
+
* store's client, used for the feed's head.
|
|
331
410
|
*/
|
|
332
411
|
export function createSlotlockStoreChangeSource(store, sql) {
|
|
333
412
|
return {
|
|
@@ -340,11 +419,22 @@ export function createSlotlockStoreChangeSource(store, sql) {
|
|
|
340
419
|
}
|
|
341
420
|
/** Bind the protocol server to the production store without giving a request control of tenancy. */
|
|
342
421
|
export function createSlotlockStoreAgentBackend(store, options) {
|
|
343
|
-
|
|
422
|
+
const maxApprovalMinutes = options.maxApprovalMinutes ?? SLOTLOCK_MAX_APPROVAL_MINUTES;
|
|
423
|
+
if (!(Number.isSafeInteger(maxApprovalMinutes) && maxApprovalMinutes >= 1 && maxApprovalMinutes <= SLOTLOCK_MAX_APPROVAL_MINUTES)) {
|
|
424
|
+
throw new RangeError(`maxApprovalMinutes must be a whole number from 1 to ${SLOTLOCK_MAX_APPROVAL_MINUTES}`);
|
|
425
|
+
}
|
|
426
|
+
const backend = {
|
|
344
427
|
async holdSlots(context, input) {
|
|
345
428
|
const slots = input.slots;
|
|
346
429
|
const key = input.idempotency_key;
|
|
347
430
|
const holderRef = agentOwnerRef(context);
|
|
431
|
+
const limits = context.principal.limits;
|
|
432
|
+
const holderCaps = limits?.maxHolds !== undefined || limits?.maxHeldHours !== undefined
|
|
433
|
+
? {
|
|
434
|
+
...(limits.maxHolds !== undefined ? { maxHolds: limits.maxHolds } : {}),
|
|
435
|
+
...(limits.maxHeldHours !== undefined ? { maxHeldHours: limits.maxHeldHours } : {}),
|
|
436
|
+
}
|
|
437
|
+
: undefined;
|
|
348
438
|
// One group per agent and key, so a retry answers with the same holds and group.
|
|
349
439
|
const groupId = uuidFromDigest(`slotlock-hold-group\0${holderRef}\0${key}`);
|
|
350
440
|
return withTenant(store, context, async (tenantStore) => {
|
|
@@ -371,6 +461,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
371
461
|
end: new Date(slot.end),
|
|
372
462
|
ttlMs: input.ttl_seconds * 1_000,
|
|
373
463
|
source: 'agent-server',
|
|
464
|
+
// The key's own caps, below the server's (RFC 0004 A5).
|
|
465
|
+
...(holderCaps ? { holderCaps } : {}),
|
|
374
466
|
});
|
|
375
467
|
if (result.ok) {
|
|
376
468
|
holds.push({
|
|
@@ -430,15 +522,24 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
430
522
|
throw operationError(replay);
|
|
431
523
|
return answer(replay);
|
|
432
524
|
}
|
|
433
|
-
|
|
525
|
+
// In the pool's order (stable, least booked first, or round robin).
|
|
526
|
+
const members = await tenantStore.listPoolMembers({
|
|
527
|
+
tenantRef,
|
|
528
|
+
pool: input.pool,
|
|
529
|
+
visibility: 'shared',
|
|
530
|
+
ordered: true,
|
|
531
|
+
forBooking: true,
|
|
532
|
+
});
|
|
434
533
|
if (members.length === 0)
|
|
435
534
|
throw new SlotlockAgentOperationError('pool_not_found', 404);
|
|
436
|
-
// Each member in turn, each
|
|
437
|
-
//
|
|
535
|
+
// Each member in turn, each in its own savepoint: one taken or refused by its rules is
|
|
536
|
+
// passed over; the exclusion constraint still decides.
|
|
438
537
|
for (const member of members) {
|
|
439
538
|
const result = await put(member.id);
|
|
440
|
-
if (result.ok)
|
|
539
|
+
if (result.ok) {
|
|
540
|
+
await tenantStore.notePoolBooking({ tenantRef, pool: input.pool, resourceId: member.id });
|
|
441
541
|
return answer(result);
|
|
542
|
+
}
|
|
442
543
|
if (result.code !== 'overlap' && result.code !== 'booking_rule_violated') {
|
|
443
544
|
throw operationError(result);
|
|
444
545
|
}
|
|
@@ -457,7 +558,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
457
558
|
const members = await tenantStore.listPoolMembers({ tenantRef, pool, visibility: 'shared' });
|
|
458
559
|
if (members.length === 0)
|
|
459
560
|
throw new SlotlockAgentOperationError('pool_not_found', 404);
|
|
460
|
-
//
|
|
561
|
+
// Busy time as edges: a count of busy members that rises and falls.
|
|
461
562
|
const edges = new Map([
|
|
462
563
|
[window.start.getTime(), 0],
|
|
463
564
|
[window.end.getTime(), 0],
|
|
@@ -477,9 +578,9 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
477
578
|
charge(freeBusy.busy.length);
|
|
478
579
|
if (freeBusy.coverage.state !== 'complete')
|
|
479
580
|
complete = false;
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
581
|
+
// Also not free where its booking rules refuse a booking (notice, advance, enforced
|
|
582
|
+
// hours). Its slot step is not counted: it moves when a booking may start, not whether
|
|
583
|
+
// the member is free.
|
|
483
584
|
const rules = member.effectiveBookingRules;
|
|
484
585
|
const search = bookingSearch({ rules, timezone: member.timezone, window, now });
|
|
485
586
|
const latestStart = rules?.maxAdvanceDays !== undefined
|
|
@@ -524,7 +625,6 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
524
625
|
break;
|
|
525
626
|
const free = members.length - busy;
|
|
526
627
|
const last = intervals.at(-1);
|
|
527
|
-
// Parts with the same count are one.
|
|
528
628
|
if (last && last.free === free)
|
|
529
629
|
last.end = new Date(next).toISOString();
|
|
530
630
|
else
|
|
@@ -540,8 +640,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
540
640
|
},
|
|
541
641
|
async bookTogether(context, rawInput) {
|
|
542
642
|
const input = rawInput;
|
|
543
|
-
//
|
|
544
|
-
// and never deadlock.
|
|
643
|
+
// Sorted, so bundles over the same resources lock in one order and never deadlock.
|
|
545
644
|
const resourceIds = [...new Set(input.resource_ids)].sort();
|
|
546
645
|
if (resourceIds.length < 2)
|
|
547
646
|
throw new SlotlockAgentOperationError('invalid_arguments', 400);
|
|
@@ -582,10 +681,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
582
681
|
materializationWindow: materializationWindow(content),
|
|
583
682
|
enforceBookingRules: true,
|
|
584
683
|
});
|
|
585
|
-
// Thrown, so the whole transaction rolls back
|
|
684
|
+
// Thrown, so the whole transaction rolls back with the events already put.
|
|
586
685
|
if (!result.ok) {
|
|
587
686
|
const refusal = operationError(result);
|
|
588
|
-
throw new SlotlockAgentOperationError(refusal.code, refusal.status, {
|
|
687
|
+
throw new SlotlockAgentOperationError(refusal.code, refusal.status, {
|
|
688
|
+
resource_id: resourceId,
|
|
689
|
+
...(await overlapDetails(tenantStore, context, result)),
|
|
690
|
+
});
|
|
589
691
|
}
|
|
590
692
|
const created = await tenantStore.getCalendarEvent({
|
|
591
693
|
tenantRef: context.principal.tenantRef,
|
|
@@ -600,6 +702,61 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
600
702
|
return { events, replayed };
|
|
601
703
|
}, WRITE);
|
|
602
704
|
},
|
|
705
|
+
async confirmHold(context, rawInput) {
|
|
706
|
+
const input = rawInput;
|
|
707
|
+
// The holder's own hold, lapsed or live, and its resource's zone; then the booking exactly
|
|
708
|
+
// as create_event with its hold_id makes it, which judges the hold again under its lock.
|
|
709
|
+
const found = await withTenant(store, context, async (tenantStore) => {
|
|
710
|
+
const held = await tenantStore.getHolderHold({
|
|
711
|
+
tenantRef: context.principal.tenantRef,
|
|
712
|
+
holdId: input.hold_id,
|
|
713
|
+
holderRef: agentOwnerRef(context),
|
|
714
|
+
});
|
|
715
|
+
if (!held)
|
|
716
|
+
return null;
|
|
717
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, held.hold.resourceId);
|
|
718
|
+
return { ...held, timezone: resource.timezone };
|
|
719
|
+
});
|
|
720
|
+
if (!found) {
|
|
721
|
+
// Booked already: a retry answers the event this key made (the store compares the
|
|
722
|
+
// request, so another one with the same key is idempotency_conflict).
|
|
723
|
+
const booked = await withTenant(store, context, (tenantStore) => tenantStore.getCalendarEvent({
|
|
724
|
+
tenantRef: context.principal.tenantRef,
|
|
725
|
+
ownerRef: agentOwnerRef(context),
|
|
726
|
+
externalRef: agentExternalRef(context, input.idempotency_key),
|
|
727
|
+
}));
|
|
728
|
+
if (!booked)
|
|
729
|
+
throw new SlotlockAgentOperationError('hold_not_found', 404);
|
|
730
|
+
return this.createEvent({ ...context, operation: 'slotlock_create_event' }, {
|
|
731
|
+
resource_id: booked.resourceId,
|
|
732
|
+
starts_at: booked.start.toISOString(),
|
|
733
|
+
ends_at: booked.end.toISOString(),
|
|
734
|
+
timezone: input.timezone ?? booked.timezone,
|
|
735
|
+
...(input.title !== undefined ? { title: input.title } : {}),
|
|
736
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
737
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
738
|
+
transparency: 'opaque',
|
|
739
|
+
status: 'confirmed',
|
|
740
|
+
hold_id: input.hold_id,
|
|
741
|
+
idempotency_key: input.idempotency_key,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
if (!found.live)
|
|
745
|
+
throw new SlotlockAgentOperationError('hold_expired', 409);
|
|
746
|
+
return this.createEvent({ ...context, operation: 'slotlock_create_event' }, {
|
|
747
|
+
resource_id: found.hold.resourceId,
|
|
748
|
+
starts_at: found.hold.start.toISOString(),
|
|
749
|
+
ends_at: found.hold.end.toISOString(),
|
|
750
|
+
timezone: input.timezone ?? found.timezone,
|
|
751
|
+
...(input.title !== undefined ? { title: input.title } : {}),
|
|
752
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
753
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
754
|
+
transparency: 'opaque',
|
|
755
|
+
status: 'confirmed',
|
|
756
|
+
hold_id: input.hold_id,
|
|
757
|
+
idempotency_key: input.idempotency_key,
|
|
758
|
+
});
|
|
759
|
+
},
|
|
603
760
|
async releaseSlots(context, input) {
|
|
604
761
|
const id = input.hold_id;
|
|
605
762
|
if (!STORE_ID_PATTERN.test(id))
|
|
@@ -609,7 +766,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
609
766
|
holderRef: agentOwnerRef(context),
|
|
610
767
|
}).then(({ released }) => ({ released })));
|
|
611
768
|
if (result.released)
|
|
612
|
-
await offerFreedTime(store, context
|
|
769
|
+
await offerFreedTime(store, context);
|
|
613
770
|
return result;
|
|
614
771
|
},
|
|
615
772
|
async describeResource(context, resourceId) {
|
|
@@ -621,29 +778,87 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
621
778
|
id: resourceId,
|
|
622
779
|
visibility: 'shared',
|
|
623
780
|
});
|
|
781
|
+
// Its profile's name when it has one, as a person reading a confirmation knows it.
|
|
624
782
|
return resource
|
|
625
|
-
? { name: resource.externalRef ?? null, timezone: resource.timezone }
|
|
783
|
+
? { name: resource.name ?? resource.externalRef ?? null, timezone: resource.timezone }
|
|
626
784
|
: null;
|
|
627
785
|
});
|
|
628
786
|
},
|
|
787
|
+
async selectResources(context, selection) {
|
|
788
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
789
|
+
let resources;
|
|
790
|
+
try {
|
|
791
|
+
// Shared and in use, as an agent may search them, in the store's stable order.
|
|
792
|
+
resources = await tenantStore.listResources({
|
|
793
|
+
tenantRef: context.principal.tenantRef,
|
|
794
|
+
visibility: 'shared',
|
|
795
|
+
limit: selection.limit + 1,
|
|
796
|
+
...(selection.kind !== undefined ? { kind: selection.kind } : {}),
|
|
797
|
+
...(selection.labels !== undefined ? { labels: selection.labels } : {}),
|
|
798
|
+
...(selection.location !== undefined ? { location: selection.location } : {}),
|
|
799
|
+
...(selection.group !== undefined ? { group: selection.group } : {}),
|
|
800
|
+
...(selection.pool !== undefined ? { pool: selection.pool } : {}),
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
throw storeRefusal(error);
|
|
805
|
+
}
|
|
806
|
+
if (resources.length > selection.limit) {
|
|
807
|
+
throw new SlotlockAgentOperationError('selection_too_large', 400, { limit: String(selection.limit) });
|
|
808
|
+
}
|
|
809
|
+
return resources.map((resource) => resource.id);
|
|
810
|
+
});
|
|
811
|
+
},
|
|
629
812
|
async listResources(context, input) {
|
|
630
813
|
const cursor = parseResourceCursor(input.cursor);
|
|
814
|
+
const kind = input.kind;
|
|
815
|
+
const labels = input.labels;
|
|
816
|
+
const location = input.location;
|
|
817
|
+
const group = input.group;
|
|
818
|
+
// What a resource says about itself, for an agent to show and choose by; its attributes are
|
|
819
|
+
// the integrator's, and stay on the application API.
|
|
631
820
|
const output = (resource) => ({
|
|
632
821
|
id: resource.id,
|
|
633
822
|
external_ref: resource.externalRef,
|
|
634
823
|
timezone: resource.timezone,
|
|
635
824
|
...(resource.pool !== undefined ? { pool: resource.pool } : {}),
|
|
825
|
+
...(resource.name !== undefined ? { name: resource.name } : {}),
|
|
826
|
+
...(resource.description !== undefined
|
|
827
|
+
? {
|
|
828
|
+
description: [...resource.description].length > 500
|
|
829
|
+
? `${[...resource.description].slice(0, 500).join('')}…`
|
|
830
|
+
: resource.description,
|
|
831
|
+
}
|
|
832
|
+
: {}),
|
|
833
|
+
...(resource.kind !== undefined ? { kind: resource.kind } : {}),
|
|
834
|
+
...(resource.capacity !== undefined ? { capacity: resource.capacity } : {}),
|
|
835
|
+
...(resource.labels !== undefined ? { labels: resource.labels } : {}),
|
|
836
|
+
...(resource.location !== undefined
|
|
837
|
+
? { location: { ref: resource.location, name: resource.locationName ?? resource.location } }
|
|
838
|
+
: {}),
|
|
839
|
+
...(resource.groups !== undefined ? { groups: resource.groups } : {}),
|
|
840
|
+
...(resource.parentId !== undefined ? { part_of: resource.parentId } : {}),
|
|
636
841
|
});
|
|
637
842
|
if (typeof input.pool === 'string') {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
843
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
844
|
+
// A pool's members are filtered here: a location filter takes the locations under it.
|
|
845
|
+
const places = location === undefined
|
|
846
|
+
? null
|
|
847
|
+
: locationAndBelow(await tenantStore.listLocations({ tenantRef: context.principal.tenantRef }), location);
|
|
848
|
+
return {
|
|
849
|
+
resources: (await tenantStore.listPoolMembers({
|
|
850
|
+
tenantRef: context.principal.tenantRef,
|
|
851
|
+
pool: input.pool,
|
|
852
|
+
visibility: 'shared',
|
|
853
|
+
}))
|
|
854
|
+
.filter((resource) => (kind === undefined || (resource.kind ?? 'other') === kind) &&
|
|
855
|
+
(labels === undefined || slotlockResourceHasLabels(resource.labels, labels)) &&
|
|
856
|
+
(places === null || (resource.location !== undefined && places.has(resource.location))) &&
|
|
857
|
+
(group === undefined || resource.groups?.includes(group) === true))
|
|
858
|
+
.map(output),
|
|
859
|
+
next_cursor: null,
|
|
860
|
+
};
|
|
861
|
+
});
|
|
647
862
|
}
|
|
648
863
|
return withTenant(store, context, async (tenantStore) => {
|
|
649
864
|
const limit = input.limit;
|
|
@@ -652,6 +867,10 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
652
867
|
visibility: 'shared',
|
|
653
868
|
limit: limit + 1,
|
|
654
869
|
...(cursor !== undefined ? { after: cursor } : {}),
|
|
870
|
+
...(kind !== undefined ? { kind } : {}),
|
|
871
|
+
...(labels !== undefined ? { labels } : {}),
|
|
872
|
+
...(location !== undefined ? { location } : {}),
|
|
873
|
+
...(group !== undefined ? { group } : {}),
|
|
655
874
|
});
|
|
656
875
|
const page = resources.slice(0, limit);
|
|
657
876
|
return {
|
|
@@ -691,6 +910,53 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
691
910
|
return { resources };
|
|
692
911
|
});
|
|
693
912
|
},
|
|
913
|
+
async findNextSlots(context, input) {
|
|
914
|
+
const window = interval(input);
|
|
915
|
+
const durationMs = input.duration_minutes * 60_000;
|
|
916
|
+
const charge = busyBudget();
|
|
917
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
918
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, input.resource_id);
|
|
919
|
+
const search = bookingSearch({
|
|
920
|
+
rules: resource.effectiveBookingRules,
|
|
921
|
+
timezone: resource.timezone,
|
|
922
|
+
window,
|
|
923
|
+
now: new Date(),
|
|
924
|
+
});
|
|
925
|
+
const sources = await requiredSources(options, context, resource);
|
|
926
|
+
const freeBusy = await readFreeBusy(tenantStore, {
|
|
927
|
+
tenantRef: context.principal.tenantRef,
|
|
928
|
+
resourceId: input.resource_id,
|
|
929
|
+
window: search ? bookingSearchReach({ ...search, window }) : window,
|
|
930
|
+
requiredSources: [...sources],
|
|
931
|
+
...(options.coverageMaxAgeMs !== undefined ? { coverageMaxAgeMs: options.coverageMaxAgeMs } : {}),
|
|
932
|
+
});
|
|
933
|
+
charge(freeBusy.busy.length);
|
|
934
|
+
if (freeBusy.coverage.state !== 'complete')
|
|
935
|
+
return { slots: [], certain: false };
|
|
936
|
+
if (!search)
|
|
937
|
+
return { slots: [], certain: true };
|
|
938
|
+
const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
|
|
939
|
+
const windows = expandRules(rules, search.window, resource.timezone);
|
|
940
|
+
if (windows.length > MAX_REQUEST_RULE_WINDOWS)
|
|
941
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
942
|
+
const busy = bookingSearchBlocked(freeBusy.busy, search);
|
|
943
|
+
// Each next slot as a search from where the last one ended would find it: the same busy
|
|
944
|
+
// time and hours, with the hours before that end left out.
|
|
945
|
+
const slots = [];
|
|
946
|
+
let from = search.window.start.getTime();
|
|
947
|
+
while (slots.length < input.limit) {
|
|
948
|
+
const open = windows
|
|
949
|
+
.map((hours) => ({ start: new Date(Math.max(hours.start.getTime(), from)), end: hours.end }))
|
|
950
|
+
.filter((hours) => hours.end.getTime() - hours.start.getTime() >= durationMs);
|
|
951
|
+
const found = findNextAvailable({ busy, windows: open, durationMs, ...(search.align ? { align: search.align } : {}) });
|
|
952
|
+
if (!found)
|
|
953
|
+
break;
|
|
954
|
+
slots.push({ start: found.start.toISOString(), end: found.end.toISOString() });
|
|
955
|
+
from = found.end.getTime();
|
|
956
|
+
}
|
|
957
|
+
return { slots, certain: true };
|
|
958
|
+
});
|
|
959
|
+
},
|
|
694
960
|
async findNextAvailable(context, input) {
|
|
695
961
|
const window = interval(input);
|
|
696
962
|
const resourceIds = distinctResourceIds(input);
|
|
@@ -702,8 +968,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
702
968
|
let incomplete = false;
|
|
703
969
|
for (const resourceId of resourceIds) {
|
|
704
970
|
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
705
|
-
//
|
|
706
|
-
// as they bound the write that follows it.
|
|
971
|
+
// The resource's booking rules bound the search, as they bound the write that follows.
|
|
707
972
|
const search = bookingSearch({
|
|
708
973
|
rules: resource.effectiveBookingRules,
|
|
709
974
|
timezone: resource.timezone,
|
|
@@ -747,8 +1012,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
747
1012
|
best = { resourceId, interval: candidate };
|
|
748
1013
|
}
|
|
749
1014
|
}
|
|
750
|
-
// A cross-resource answer is only certain when every
|
|
751
|
-
//
|
|
1015
|
+
// A cross-resource answer is only certain when every resource is: one healthy calendar must
|
|
1016
|
+
// not mask a stale one.
|
|
752
1017
|
const complete = !incomplete;
|
|
753
1018
|
const certainBest = complete ? best : null;
|
|
754
1019
|
return {
|
|
@@ -803,7 +1068,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
803
1068
|
: {}),
|
|
804
1069
|
});
|
|
805
1070
|
if (!result.ok)
|
|
806
|
-
throw operationError(result);
|
|
1071
|
+
throw operationError(result, await overlapDetails(tenantStore, context, result));
|
|
807
1072
|
const created = await tenantStore.getCalendarEvent({
|
|
808
1073
|
tenantRef: context.principal.tenantRef,
|
|
809
1074
|
ownerRef: agentOwnerRef(context),
|
|
@@ -855,7 +1120,6 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
855
1120
|
},
|
|
856
1121
|
async updateEvent(context, rawInput) {
|
|
857
1122
|
const input = rawInput;
|
|
858
|
-
// No event is on a resource the store never issued (an external reference, say).
|
|
859
1123
|
if (input.expected_resource_id !== undefined &&
|
|
860
1124
|
!STORE_ID_PATTERN.test(input.expected_resource_id)) {
|
|
861
1125
|
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
@@ -929,8 +1193,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
929
1193
|
tenantRef: context.principal.tenantRef,
|
|
930
1194
|
ownerRef: agentOwnerRef(context),
|
|
931
1195
|
externalRef: current.externalRef,
|
|
932
|
-
// The caller's fence when
|
|
933
|
-
//
|
|
1196
|
+
// The caller's fence when sent, checked under the event's lock; otherwise the resource
|
|
1197
|
+
// just read, so a move in between is not overwritten.
|
|
934
1198
|
expectedResourceId: input.expected_resource_id ?? current.resourceId,
|
|
935
1199
|
resourceId,
|
|
936
1200
|
expectedRevision: input.expected_revision,
|
|
@@ -942,7 +1206,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
942
1206
|
enforceBookingRules: true,
|
|
943
1207
|
});
|
|
944
1208
|
if (!result.ok)
|
|
945
|
-
throw operationError(result);
|
|
1209
|
+
throw operationError(result, await overlapDetails(tenantStore, context, result));
|
|
946
1210
|
const updated = await tenantStore.getCalendarEvent({
|
|
947
1211
|
tenantRef: context.principal.tenantRef,
|
|
948
1212
|
ownerRef: agentOwnerRef(context),
|
|
@@ -952,9 +1216,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
952
1216
|
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
953
1217
|
return { event: projectSlotlockCalendarEvent(updated), replayed: result.idempotent };
|
|
954
1218
|
}, WRITE);
|
|
955
|
-
// A moved or shortened event may have freed the time it held.
|
|
956
1219
|
if (!moved.replayed)
|
|
957
|
-
await offerFreedTime(store, context
|
|
1220
|
+
await offerFreedTime(store, context);
|
|
958
1221
|
return moved;
|
|
959
1222
|
},
|
|
960
1223
|
async deleteEvent(context, input) {
|
|
@@ -965,13 +1228,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
965
1228
|
ownerRef: agentOwnerRef(context),
|
|
966
1229
|
externalRef,
|
|
967
1230
|
});
|
|
968
|
-
|
|
969
|
-
|
|
1231
|
+
// Gone already: a retry of a cancellation that succeeded, answered from its key, or
|
|
1232
|
+
// event_not_found from the store when there was none.
|
|
970
1233
|
const result = await tenantStore.cancelCalendarEvent({
|
|
971
1234
|
tenantRef: context.principal.tenantRef,
|
|
972
1235
|
ownerRef: agentOwnerRef(context),
|
|
973
1236
|
externalRef,
|
|
974
|
-
expectedResourceId: current.resourceId,
|
|
1237
|
+
...(current ? { expectedResourceId: current.resourceId } : {}),
|
|
975
1238
|
idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
|
|
976
1239
|
expectedRevision: input.expected_revision,
|
|
977
1240
|
});
|
|
@@ -985,19 +1248,39 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
985
1248
|
};
|
|
986
1249
|
}, WRITE);
|
|
987
1250
|
if (!deleted.replayed)
|
|
988
|
-
await offerFreedTime(store, context
|
|
1251
|
+
await offerFreedTime(store, context);
|
|
989
1252
|
return deleted;
|
|
990
1253
|
},
|
|
991
1254
|
};
|
|
1255
|
+
return withApprovalPolicies(store, options, maxApprovalMinutes, backend);
|
|
992
1256
|
}
|
|
993
|
-
/** A store failure thrown with a `code` the caller may see, or the error itself to report. */
|
|
994
1257
|
function storeRefusal(error) {
|
|
995
1258
|
const code = error?.code;
|
|
996
1259
|
if (typeof code !== 'string')
|
|
997
1260
|
return error;
|
|
1261
|
+
if (code === 'invalid_resource_profile' || code === 'invalid_location' || code === 'invalid_group') {
|
|
1262
|
+
// Which field, so a client can say what to change.
|
|
1263
|
+
const field = error.field;
|
|
1264
|
+
return new SlotlockAgentOperationError(code, 400, typeof field === 'string' ? { field } : undefined);
|
|
1265
|
+
}
|
|
1266
|
+
if (code === 'location_not_found' || code === 'group_not_found' || code === 'resource_not_found' || code === 'parent_not_found') {
|
|
1267
|
+
return new SlotlockAgentOperationError(code, 404);
|
|
1268
|
+
}
|
|
1269
|
+
if (code === 'location_in_use' ||
|
|
1270
|
+
code === 'location_too_deep' ||
|
|
1271
|
+
code === 'location_cycle' ||
|
|
1272
|
+
code === 'location_limit_reached' ||
|
|
1273
|
+
code === 'group_limit_reached' ||
|
|
1274
|
+
code === 'resource_group_limit_reached') {
|
|
1275
|
+
return new SlotlockAgentOperationError(code, 409);
|
|
1276
|
+
}
|
|
998
1277
|
if (code === 'resource_limit_reached' ||
|
|
999
1278
|
code === 'resource_visibility_conflict' ||
|
|
1000
|
-
code === 'pool_rules_limit_reached'
|
|
1279
|
+
code === 'pool_rules_limit_reached' ||
|
|
1280
|
+
code === 'pool_order_limit_reached' ||
|
|
1281
|
+
code === 'composite_too_deep' ||
|
|
1282
|
+
code === 'composite_parts_limit' ||
|
|
1283
|
+
code === 'composite_busy') {
|
|
1001
1284
|
return new SlotlockAgentOperationError(code, 409);
|
|
1002
1285
|
}
|
|
1003
1286
|
if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
|
|
@@ -1005,7 +1288,88 @@ function storeRefusal(error) {
|
|
|
1005
1288
|
}
|
|
1006
1289
|
return error;
|
|
1007
1290
|
}
|
|
1008
|
-
/**
|
|
1291
|
+
/** A key's limits as the API takes them (snake_case), as the key store takes them. */
|
|
1292
|
+
function keyLimitsFromInput(value) {
|
|
1293
|
+
const given = value;
|
|
1294
|
+
return {
|
|
1295
|
+
...(given.max_holds !== undefined ? { maxHolds: given.max_holds } : {}),
|
|
1296
|
+
...(given.max_held_hours !== undefined ? { maxHeldHours: given.max_held_hours } : {}),
|
|
1297
|
+
...(given.requests_per_minute !== undefined ? { requestsPerMinute: given.requests_per_minute } : {}),
|
|
1298
|
+
...(given.requires_approval === true ? { requiresApproval: true } : {}),
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
/** A sandbox refusal as the API answers it: 403 for a live tenant, 409 for one not empty. */
|
|
1302
|
+
function sandboxRefusal(error) {
|
|
1303
|
+
const code = error?.code;
|
|
1304
|
+
if (code === 'sandbox_test_only')
|
|
1305
|
+
return new SlotlockAgentOperationError(code, 403);
|
|
1306
|
+
if (code === 'sandbox_not_empty')
|
|
1307
|
+
return new SlotlockAgentOperationError(code, 409);
|
|
1308
|
+
return error;
|
|
1309
|
+
}
|
|
1310
|
+
/** An approval decision refused: 404 for none, 409 for one decided or past its wait. */
|
|
1311
|
+
function approvalRefusal(code) {
|
|
1312
|
+
return new SlotlockAgentOperationError(code, code === 'approval_not_found' ? 404 : 409);
|
|
1313
|
+
}
|
|
1314
|
+
function approvalPolicyOutput(policy) {
|
|
1315
|
+
const applies = policy.appliesTo;
|
|
1316
|
+
const conditions = policy.conditions;
|
|
1317
|
+
return {
|
|
1318
|
+
policy_ref: policy.ref,
|
|
1319
|
+
name: policy.name,
|
|
1320
|
+
applies_to: {
|
|
1321
|
+
...(applies.resourceIds ? { resource_ids: applies.resourceIds } : {}),
|
|
1322
|
+
...(applies.pools ? { pools: applies.pools } : {}),
|
|
1323
|
+
...(applies.locations ? { locations: applies.locations } : {}),
|
|
1324
|
+
...(applies.groups ? { groups: applies.groups } : {}),
|
|
1325
|
+
},
|
|
1326
|
+
conditions: {
|
|
1327
|
+
...(conditions.longerThanMinutes !== undefined ? { longer_than_minutes: conditions.longerThanMinutes } : {}),
|
|
1328
|
+
...(conditions.outsideHours !== undefined ? { outside_hours: conditions.outsideHours } : {}),
|
|
1329
|
+
...(conditions.kinds ? { kinds: conditions.kinds } : {}),
|
|
1330
|
+
},
|
|
1331
|
+
outcome: policy.outcome,
|
|
1332
|
+
approval_ttl_minutes: policy.approvalTtlMinutes,
|
|
1333
|
+
lead_minutes: policy.leadMinutes,
|
|
1334
|
+
created_at: policy.createdAt.toISOString(),
|
|
1335
|
+
updated_at: policy.updatedAt.toISOString(),
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
function approvalAppliesTo(value) {
|
|
1339
|
+
const given = value;
|
|
1340
|
+
return {
|
|
1341
|
+
...(given.resource_ids ? { resourceIds: given.resource_ids } : {}),
|
|
1342
|
+
...(given.pools ? { pools: given.pools } : {}),
|
|
1343
|
+
...(given.locations ? { locations: given.locations } : {}),
|
|
1344
|
+
...(given.groups ? { groups: given.groups } : {}),
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
function approvalConditions(value) {
|
|
1348
|
+
const given = value;
|
|
1349
|
+
return {
|
|
1350
|
+
...(given.longer_than_minutes !== undefined ? { longerThanMinutes: given.longer_than_minutes } : {}),
|
|
1351
|
+
...(given.outside_hours !== undefined ? { outsideHours: given.outside_hours } : {}),
|
|
1352
|
+
...(given.kinds ? { kinds: given.kinds } : {}),
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
function approvalOutput(approval) {
|
|
1356
|
+
return {
|
|
1357
|
+
approval_id: approval.id,
|
|
1358
|
+
policy_ref: approval.policyRef,
|
|
1359
|
+
status: approval.status,
|
|
1360
|
+
operation: approval.operation,
|
|
1361
|
+
request: approval.request,
|
|
1362
|
+
requested_by: approval.requestedBy,
|
|
1363
|
+
resource_ids: approval.resourceIds,
|
|
1364
|
+
starts_at: approval.start.toISOString(),
|
|
1365
|
+
ends_at: approval.end.toISOString(),
|
|
1366
|
+
expires_at: approval.expiresAt.toISOString(),
|
|
1367
|
+
decided_by: approval.decidedBy,
|
|
1368
|
+
decided_at: approval.decidedAt?.toISOString() ?? null,
|
|
1369
|
+
reason: approval.reason,
|
|
1370
|
+
created_at: approval.createdAt.toISOString(),
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1009
1373
|
function applicationRefusal(code) {
|
|
1010
1374
|
if (code === 'resource_tenant_mismatch') {
|
|
1011
1375
|
return new SlotlockAgentOperationError('resource_not_found', 404);
|
|
@@ -1033,11 +1397,379 @@ function waitlistOutput(entry) {
|
|
|
1033
1397
|
};
|
|
1034
1398
|
}
|
|
1035
1399
|
/**
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
|
|
1400
|
+
* The policy a key whose bookings wait for a person (`requiresApproval`, RFC 0004 A5) is held by:
|
|
1401
|
+
* every resource, a day's wait. A tenant cannot name a policy of its own `api-key`.
|
|
1402
|
+
*/
|
|
1403
|
+
function keyApprovalPolicy() {
|
|
1404
|
+
return {
|
|
1405
|
+
ref: SLOTLOCK_KEY_APPROVAL_POLICY,
|
|
1406
|
+
name: 'Bookings by this API key',
|
|
1407
|
+
appliesTo: {},
|
|
1408
|
+
conditions: {},
|
|
1409
|
+
outcome: 'require_approval',
|
|
1410
|
+
approvalTtlMinutes: SLOTLOCK_DEFAULT_APPROVAL_MINUTES,
|
|
1411
|
+
leadMinutes: 0,
|
|
1412
|
+
createdAt: new Date(0),
|
|
1413
|
+
updatedAt: new Date(0),
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
/** A JSON value with its object keys sorted, so two requests compare by content. */
|
|
1417
|
+
function canonical(value) {
|
|
1418
|
+
if (Array.isArray(value))
|
|
1419
|
+
return value.map(canonical);
|
|
1420
|
+
if (value !== null && typeof value === 'object') {
|
|
1421
|
+
return Object.fromEntries(Object.keys(value)
|
|
1422
|
+
.sort()
|
|
1423
|
+
.map((key) => [key, canonical(value[key])]));
|
|
1424
|
+
}
|
|
1425
|
+
return value;
|
|
1426
|
+
}
|
|
1427
|
+
/** Each interval of `wanted` less the parts `kept` already covers on the same resource. */
|
|
1428
|
+
function uncovered(wanted, kept) {
|
|
1429
|
+
const pieces = [];
|
|
1430
|
+
for (const want of wanted) {
|
|
1431
|
+
let open = [{ start: want.start, end: want.end }];
|
|
1432
|
+
for (const own of kept) {
|
|
1433
|
+
if (own.resourceId !== want.resourceId)
|
|
1434
|
+
continue;
|
|
1435
|
+
open = open.flatMap((piece) => {
|
|
1436
|
+
if (own.end <= piece.start || own.start >= piece.end)
|
|
1437
|
+
return [piece];
|
|
1438
|
+
return [
|
|
1439
|
+
...(own.start > piece.start ? [{ start: piece.start, end: own.start }] : []),
|
|
1440
|
+
...(own.end < piece.end ? [{ start: own.end, end: piece.end }] : []),
|
|
1441
|
+
];
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
pieces.push(...open.map((piece) => ({ ...piece, resourceId: want.resourceId })));
|
|
1445
|
+
}
|
|
1446
|
+
return pieces;
|
|
1447
|
+
}
|
|
1448
|
+
/** How a request already put to a person answers its retry: waiting, declined or expired. */
|
|
1449
|
+
function approvalAnswer(approval) {
|
|
1450
|
+
const id = { approval_id: approval.id, policy_ref: approval.policyRef };
|
|
1451
|
+
if (approval.status === 'declined') {
|
|
1452
|
+
return new SlotlockAgentOperationError('approval_declined', 403, {
|
|
1453
|
+
...id,
|
|
1454
|
+
...(approval.reason ? { reason: approval.reason.slice(0, 200) } : {}),
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
if (approval.status === 'expired' || approval.expiresAt.getTime() <= Date.now()) {
|
|
1458
|
+
return new SlotlockAgentOperationError('approval_expired', 409, id);
|
|
1459
|
+
}
|
|
1460
|
+
return new SlotlockAgentOperationError('approval_pending', 409, {
|
|
1461
|
+
...id,
|
|
1462
|
+
expires_at: approval.expiresAt.toISOString(),
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Approval policies (RFC 0004 A4) in front of the backend's writes. A write a policy covers is
|
|
1467
|
+
* tried first as a dry run, so it meets every check and the database's constraints as it would;
|
|
1468
|
+
* a `refuse` policy then refuses it, and a `require_approval` one holds the exact time it would
|
|
1469
|
+
* occupy for the approval and answers `approval_pending`. A retry with the same idempotency key
|
|
1470
|
+
* answers as the request stands, and once granted, with the booking itself. Tenants with no policy
|
|
1471
|
+
* pay one indexed read per write.
|
|
1472
|
+
*/
|
|
1473
|
+
function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
1474
|
+
const requestKeyOf = (context, stored, key) => createHash('sha256')
|
|
1475
|
+
.update(JSON.stringify(['slotlock-approval', context.principal.tenantRef, agentOwnerRef(context), stored, key]))
|
|
1476
|
+
.digest('hex');
|
|
1477
|
+
/** Whether [start, end) on the resource falls outside its bookable hours. */
|
|
1478
|
+
async function outsideHours(tenantStore, context, resourceId, start, end) {
|
|
1479
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
1480
|
+
const hours = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
|
|
1481
|
+
const windows = mergeIntervals(expandRules(hours, { start: new Date(start.getTime() - 86_400_000), end: new Date(end.getTime() + 86_400_000) }, resource.timezone));
|
|
1482
|
+
return !windows.some((window) => window.start <= start && window.end >= end);
|
|
1483
|
+
}
|
|
1484
|
+
/** The write's ask and the policies that may cover it, or null when none can. */
|
|
1485
|
+
async function plan(tenantStore, context, operation, input) {
|
|
1486
|
+
const tenantRef = context.principal.tenantRef;
|
|
1487
|
+
const ownerRef = agentOwnerRef(context);
|
|
1488
|
+
const key = input.idempotency_key;
|
|
1489
|
+
// A key whose bookings all wait for a person (RFC 0004 A5) is a policy of its own.
|
|
1490
|
+
const keyPolicy = context.principal.limits?.requiresApproval === true ? keyApprovalPolicy() : undefined;
|
|
1491
|
+
// Most tenants have none: one small indexed read, and nothing else looked up.
|
|
1492
|
+
if (!keyPolicy && (await tenantStore.listApprovalPolicies({ tenantRef })).length === 0)
|
|
1493
|
+
return null;
|
|
1494
|
+
let asked;
|
|
1495
|
+
if (operation === 'slotlock_create_event') {
|
|
1496
|
+
asked = {
|
|
1497
|
+
kind: 'single',
|
|
1498
|
+
stored: 'slotlock_create_event',
|
|
1499
|
+
resourceIds: [input.resource_id],
|
|
1500
|
+
externalRefs: [agentExternalRef(context, key)],
|
|
1501
|
+
start: new Date(input.starts_at),
|
|
1502
|
+
end: new Date(input.ends_at),
|
|
1503
|
+
series: typeof input.recurrence_rule === 'string',
|
|
1504
|
+
before: [],
|
|
1505
|
+
...(typeof input.hold_id === 'string' ? { consumeHold: { holdId: input.hold_id, holderRef: ownerRef } } : {}),
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
else if (operation === 'slotlock_book_pool') {
|
|
1509
|
+
const members = await tenantStore.listPoolMembers({ tenantRef, pool: input.pool, visibility: 'shared' });
|
|
1510
|
+
if (members.length === 0)
|
|
1511
|
+
return null;
|
|
1512
|
+
asked = {
|
|
1513
|
+
kind: 'pool',
|
|
1514
|
+
stored: 'slotlock_create_event',
|
|
1515
|
+
resourceIds: members.map((member) => member.id),
|
|
1516
|
+
externalRefs: [agentExternalRef(context, key)],
|
|
1517
|
+
start: new Date(input.starts_at),
|
|
1518
|
+
end: new Date(input.ends_at),
|
|
1519
|
+
series: false,
|
|
1520
|
+
before: [],
|
|
1521
|
+
pool: input.pool,
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
else if (operation === 'slotlock_book_together') {
|
|
1525
|
+
const resourceIds = [...new Set(input.resource_ids)].sort();
|
|
1526
|
+
asked = {
|
|
1527
|
+
kind: 'bundle',
|
|
1528
|
+
stored: 'slotlock_book_together',
|
|
1529
|
+
resourceIds,
|
|
1530
|
+
externalRefs: resourceIds.map((resourceId) => agentExternalRef(context, `${key}\0${resourceId}`)),
|
|
1531
|
+
start: new Date(input.starts_at),
|
|
1532
|
+
end: new Date(input.ends_at),
|
|
1533
|
+
series: false,
|
|
1534
|
+
before: [],
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
else {
|
|
1538
|
+
const current = await tenantStore.getCalendarEvent({ tenantRef, ownerRef, externalRef: input.event_id });
|
|
1539
|
+
if (!current)
|
|
1540
|
+
return null;
|
|
1541
|
+
const resourceId = input.resource_id ?? current.resourceId;
|
|
1542
|
+
const start = input.starts_at ? new Date(input.starts_at) : current.start;
|
|
1543
|
+
const end = input.ends_at ? new Date(input.ends_at) : current.end;
|
|
1544
|
+
// Only a move to another time or resource is a booking a policy can be about.
|
|
1545
|
+
if (resourceId === current.resourceId && start.getTime() === current.start.getTime() && end.getTime() === current.end.getTime()) {
|
|
1546
|
+
return null;
|
|
1547
|
+
}
|
|
1548
|
+
asked = {
|
|
1549
|
+
kind: 'move',
|
|
1550
|
+
stored: 'slotlock_update_event',
|
|
1551
|
+
resourceIds: [resourceId],
|
|
1552
|
+
externalRefs: [current.externalRef],
|
|
1553
|
+
start,
|
|
1554
|
+
end,
|
|
1555
|
+
series: input.recurrence_rule !== undefined ? typeof input.recurrence_rule === 'string' : current.recurrence !== undefined,
|
|
1556
|
+
before: await tenantStore.eventOccupancy({ tenantRef, ownerRef, externalRefs: [current.externalRef] }),
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
const minutes = (asked.end.getTime() - asked.start.getTime()) / 60_000;
|
|
1560
|
+
// What the ask alone decides; hours wait for the resource the write takes.
|
|
1561
|
+
const policies = (await tenantStore.approvalPoliciesFor({ tenantRef, resourceIds: asked.resourceIds })).filter((policy) => (policy.conditions.kinds === undefined || policy.conditions.kinds.includes(asked.kind)) &&
|
|
1562
|
+
(policy.conditions.longerThanMinutes === undefined || minutes > policy.conditions.longerThanMinutes));
|
|
1563
|
+
if (policies.length === 0 && !keyPolicy)
|
|
1564
|
+
return null;
|
|
1565
|
+
return { ...asked, policies, ...(keyPolicy ? { keyPolicy } : {}) };
|
|
1566
|
+
}
|
|
1567
|
+
function gated(operation, method) {
|
|
1568
|
+
return async function (context, input) {
|
|
1569
|
+
// The grant itself, or a write a person already approved and its retries.
|
|
1570
|
+
if (context[APPROVAL_GRANT])
|
|
1571
|
+
return method.call(this, context, input);
|
|
1572
|
+
const tenantRef = context.principal.tenantRef;
|
|
1573
|
+
const stored = operation === 'slotlock_book_together' || operation === 'slotlock_update_event' ? operation : 'slotlock_create_event';
|
|
1574
|
+
const requestKey = requestKeyOf(context, stored, input.idempotency_key);
|
|
1575
|
+
const asked = await withTenant(store, { ...context, dryRun: false }, async (tenantStore) => {
|
|
1576
|
+
const prior = await tenantStore.findApproval({ tenantRef, requestKey });
|
|
1577
|
+
if (prior)
|
|
1578
|
+
return { prior };
|
|
1579
|
+
return { plan: await plan(tenantStore, context, operation, input) };
|
|
1580
|
+
});
|
|
1581
|
+
if (asked.prior) {
|
|
1582
|
+
const { hold_id: _held, ...request } = input;
|
|
1583
|
+
// Granted: the booking was made with this key, so the write answers it as a replay (and
|
|
1584
|
+
// the store refuses another request with it). Without the hold the approval took over,
|
|
1585
|
+
// as the grant made it.
|
|
1586
|
+
if (asked.prior.status === 'granted')
|
|
1587
|
+
return method.call(this, context, request);
|
|
1588
|
+
const kept = { ...asked.prior.request };
|
|
1589
|
+
// A pool booking is kept as the member it would take.
|
|
1590
|
+
if (operation === 'slotlock_book_pool')
|
|
1591
|
+
delete kept.resource_id;
|
|
1592
|
+
if (JSON.stringify(canonical(kept)) !== JSON.stringify(canonical(request))) {
|
|
1593
|
+
throw new SlotlockAgentOperationError('idempotency_conflict', 409);
|
|
1594
|
+
}
|
|
1595
|
+
throw approvalAnswer(asked.prior);
|
|
1596
|
+
}
|
|
1597
|
+
const ask = asked.plan;
|
|
1598
|
+
if (!ask)
|
|
1599
|
+
return method.call(this, context, input);
|
|
1600
|
+
// Tried as a dry run, reading what it would occupy before it is undone: a write that would be
|
|
1601
|
+
// refused is refused as it would be.
|
|
1602
|
+
let occupied = [];
|
|
1603
|
+
const probed = await method.call(this, {
|
|
1604
|
+
...context,
|
|
1605
|
+
dryRun: true,
|
|
1606
|
+
[OCCUPANCY_PROBE]: async (tenantStore) => {
|
|
1607
|
+
occupied = await tenantStore.eventOccupancy({
|
|
1608
|
+
tenantRef,
|
|
1609
|
+
ownerRef: agentOwnerRef(context),
|
|
1610
|
+
externalRefs: ask.externalRefs,
|
|
1611
|
+
});
|
|
1612
|
+
},
|
|
1613
|
+
}, input);
|
|
1614
|
+
// Made already (before the policy, say), or takes no time (a transparent event): nothing to hold.
|
|
1615
|
+
if (probed.replayed === true || occupied.length === 0) {
|
|
1616
|
+
return context.dryRun ? probed : method.call(this, context, input);
|
|
1617
|
+
}
|
|
1618
|
+
const written = [...new Set(occupied.map((occupation) => occupation.resourceId))];
|
|
1619
|
+
const decided = await withTenant(store, { ...context, dryRun: false }, async (tenantStore) => {
|
|
1620
|
+
// A pool's policies are those of the member the write would take, and its pool.
|
|
1621
|
+
const candidates = [
|
|
1622
|
+
...(ask.pool
|
|
1623
|
+
? (await tenantStore.approvalPoliciesFor({ tenantRef, resourceIds: written })).filter((policy) => ask.policies.some((known) => known.ref === policy.ref))
|
|
1624
|
+
: ask.policies),
|
|
1625
|
+
...(ask.keyPolicy ? [ask.keyPolicy] : []),
|
|
1626
|
+
];
|
|
1627
|
+
const applying = [];
|
|
1628
|
+
for (const policy of candidates) {
|
|
1629
|
+
if (policy.conditions.outsideHours === true) {
|
|
1630
|
+
let outside = false;
|
|
1631
|
+
for (const resourceId of written) {
|
|
1632
|
+
if (await outsideHours(tenantStore, context, resourceId, ask.start, ask.end))
|
|
1633
|
+
outside = true;
|
|
1634
|
+
}
|
|
1635
|
+
if (!outside)
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
applying.push(policy);
|
|
1639
|
+
}
|
|
1640
|
+
return applying;
|
|
1641
|
+
});
|
|
1642
|
+
if (decided.length === 0)
|
|
1643
|
+
return context.dryRun ? probed : method.call(this, context, input);
|
|
1644
|
+
const refusing = decided.find((policy) => policy.outcome === 'refuse');
|
|
1645
|
+
if (refusing)
|
|
1646
|
+
throw new SlotlockAgentOperationError('approval_policy_refused', 403, { policy_ref: refusing.ref });
|
|
1647
|
+
// The strictest wait: the earliest a person must decide by, under each policy's own wait, its
|
|
1648
|
+
// lead before the start, and the server's ceiling.
|
|
1649
|
+
const now = Date.now();
|
|
1650
|
+
const deadline = (policy) => Math.min(now + Math.min(policy.approvalTtlMinutes, maxApprovalMinutes) * 60_000, ask.start.getTime() - policy.leadMinutes * 60_000);
|
|
1651
|
+
const policy = [...decided].sort((left, right) => deadline(left) - deadline(right) || left.ref.localeCompare(right.ref))[0];
|
|
1652
|
+
const expiresAt = new Date(deadline(policy));
|
|
1653
|
+
if (ask.series || occupied.some((occupation) => occupation.recurring)) {
|
|
1654
|
+
throw new SlotlockAgentOperationError('approval_series_unsupported', 409, { policy_ref: policy.ref });
|
|
1655
|
+
}
|
|
1656
|
+
if (expiresAt.getTime() < now + 60_000) {
|
|
1657
|
+
throw new SlotlockAgentOperationError('approval_too_late', 409, { policy_ref: policy.ref });
|
|
1658
|
+
}
|
|
1659
|
+
if (context.dryRun) {
|
|
1660
|
+
throw new SlotlockAgentOperationError('approval_required', 409, {
|
|
1661
|
+
policy_ref: policy.ref,
|
|
1662
|
+
expires_at: expiresAt.toISOString(),
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
const holds = uncovered(occupied, ask.before);
|
|
1666
|
+
if (holds.length === 0)
|
|
1667
|
+
return method.call(this, context, input);
|
|
1668
|
+
const chosen = ask.pool ? written[0] : undefined;
|
|
1669
|
+
const { hold_id: _hold, ...request } = input;
|
|
1670
|
+
const principal = context.principal;
|
|
1671
|
+
const result = await withTenant(store, context, (tenantStore) => tenantStore.requestApproval({
|
|
1672
|
+
tenantRef,
|
|
1673
|
+
policyRef: policy.ref,
|
|
1674
|
+
operation: ask.stored,
|
|
1675
|
+
request: canonical(chosen ? { ...request, resource_id: chosen } : request),
|
|
1676
|
+
requester: {
|
|
1677
|
+
subject: principal.subject,
|
|
1678
|
+
...(principal.scopes ? { scopes: [...principal.scopes] } : {}),
|
|
1679
|
+
...(principal.keyId !== undefined ? { key_id: principal.keyId } : {}),
|
|
1680
|
+
...(principal.resourceScope
|
|
1681
|
+
? { resource_scope: { locations: [...principal.resourceScope.locations], groups: [...principal.resourceScope.groups] } }
|
|
1682
|
+
: {}),
|
|
1683
|
+
...(context.origin !== undefined ? { origin: context.origin } : {}),
|
|
1684
|
+
...(ask.pool !== undefined ? { pool: ask.pool } : {}),
|
|
1685
|
+
},
|
|
1686
|
+
requesterRef: principal.subject,
|
|
1687
|
+
requestKey,
|
|
1688
|
+
holds,
|
|
1689
|
+
start: ask.start,
|
|
1690
|
+
end: ask.end,
|
|
1691
|
+
expiresAt,
|
|
1692
|
+
...(ask.consumeHold ? { consumeHold: ask.consumeHold } : {}),
|
|
1693
|
+
}), WRITE);
|
|
1694
|
+
if (!result.ok) {
|
|
1695
|
+
if (result.code === 'approval_limit_reached') {
|
|
1696
|
+
throw new SlotlockAgentOperationError('approval_limit_reached', 429, { limit: String(result.limit ?? '') });
|
|
1697
|
+
}
|
|
1698
|
+
throw new SlotlockAgentOperationError(result.code, 409);
|
|
1699
|
+
}
|
|
1700
|
+
throw approvalAnswer(result.approval);
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
const wrapped = { ...backend };
|
|
1704
|
+
wrapped.createEvent = gated('slotlock_create_event', backend.createEvent);
|
|
1705
|
+
wrapped.updateEvent = gated('slotlock_update_event', backend.updateEvent);
|
|
1706
|
+
if (backend.bookPool)
|
|
1707
|
+
wrapped.bookPool = gated('slotlock_book_pool', backend.bookPool);
|
|
1708
|
+
if (backend.bookTogether)
|
|
1709
|
+
wrapped.bookTogether = gated('slotlock_book_together', backend.bookTogether);
|
|
1710
|
+
if (backend.confirmHold) {
|
|
1711
|
+
const confirm = backend.confirmHold;
|
|
1712
|
+
// A confirm already put to a person answers as it stands, before its hold (now the
|
|
1713
|
+
// approval's) is looked for; otherwise it books through createEvent, policies and all.
|
|
1714
|
+
wrapped.confirmHold = async function (context, input) {
|
|
1715
|
+
if (!context[APPROVAL_GRANT]) {
|
|
1716
|
+
const requestKey = requestKeyOf(context, 'slotlock_create_event', input.idempotency_key);
|
|
1717
|
+
const prior = await withTenant(store, { ...context, dryRun: false }, (tenantStore) => tenantStore.findApproval({ tenantRef: context.principal.tenantRef, requestKey }));
|
|
1718
|
+
if (prior && prior.status !== 'granted')
|
|
1719
|
+
throw approvalAnswer(prior);
|
|
1720
|
+
}
|
|
1721
|
+
return confirm.call(this, context, input);
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
return wrapped;
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Run a granted approval's write as the agent that asked for it, in one transaction with the grant:
|
|
1728
|
+
* its holds give way, the write is made, and the approval is marked granted.
|
|
1729
|
+
*/
|
|
1730
|
+
export async function runSlotlockApprovalGrant(agentBackend, approval, grant) {
|
|
1731
|
+
const requester = approval.requester;
|
|
1732
|
+
if (typeof requester.subject !== 'string')
|
|
1733
|
+
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
1734
|
+
const scope = requester.resource_scope;
|
|
1735
|
+
const context = {
|
|
1736
|
+
principal: {
|
|
1737
|
+
subject: requester.subject,
|
|
1738
|
+
tenantRef: grant.tenantRef,
|
|
1739
|
+
...(Array.isArray(requester.scopes) ? { scopes: requester.scopes } : {}),
|
|
1740
|
+
...(typeof requester.key_id === 'string' ? { keyId: requester.key_id } : {}),
|
|
1741
|
+
...(scope && Array.isArray(scope.locations) && Array.isArray(scope.groups)
|
|
1742
|
+
? { resourceScope: { locations: scope.locations, groups: scope.groups } }
|
|
1743
|
+
: {}),
|
|
1744
|
+
},
|
|
1745
|
+
operation: approval.operation,
|
|
1746
|
+
signal: grant.signal,
|
|
1747
|
+
...(grant.origin !== undefined ? { origin: grant.origin } : {}),
|
|
1748
|
+
...(grant.dryRun ? { dryRun: true } : {}),
|
|
1749
|
+
[APPROVAL_GRANT]: {
|
|
1750
|
+
id: approval.id,
|
|
1751
|
+
decidedBy: grant.decidedBy,
|
|
1752
|
+
...(typeof requester.pool === 'string' ? { pool: requester.pool } : {}),
|
|
1753
|
+
},
|
|
1754
|
+
};
|
|
1755
|
+
const method = approval.operation === 'slotlock_book_together'
|
|
1756
|
+
? agentBackend.bookTogether
|
|
1757
|
+
: approval.operation === 'slotlock_update_event'
|
|
1758
|
+
? agentBackend.updateEvent
|
|
1759
|
+
: agentBackend.createEvent;
|
|
1760
|
+
if (!method)
|
|
1761
|
+
throw new SlotlockAgentOperationError('approvals_unavailable', 409);
|
|
1762
|
+
return method.call(agentBackend, context, approval.request);
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* Offer what a cancel or release just freed to the waitlist now. Best effort, after commit:
|
|
1766
|
+
* maintenance offers whatever this misses.
|
|
1039
1767
|
*/
|
|
1040
|
-
async function offerFreedTime(store,
|
|
1768
|
+
async function offerFreedTime(store, context) {
|
|
1769
|
+
// A dry run freed nothing.
|
|
1770
|
+
if (context.dryRun)
|
|
1771
|
+
return;
|
|
1772
|
+
const tenantRef = context.principal.tenantRef;
|
|
1041
1773
|
try {
|
|
1042
1774
|
await store.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), {
|
|
1043
1775
|
origin: 'maintenance',
|
|
@@ -1059,6 +1791,14 @@ function webhookOutput(endpoint) {
|
|
|
1059
1791
|
last_error: endpoint.lastError,
|
|
1060
1792
|
disabled_at: endpoint.disabledAt?.toISOString() ?? null,
|
|
1061
1793
|
disabled_reason: endpoint.disabledReason,
|
|
1794
|
+
filter: endpoint.filter
|
|
1795
|
+
? {
|
|
1796
|
+
...(endpoint.filter.resourceIds ? { resource_ids: endpoint.filter.resourceIds } : {}),
|
|
1797
|
+
...(endpoint.filter.pools ? { pools: endpoint.filter.pools } : {}),
|
|
1798
|
+
...(endpoint.filter.locations ? { locations: endpoint.filter.locations } : {}),
|
|
1799
|
+
...(endpoint.filter.groups ? { groups: endpoint.filter.groups } : {}),
|
|
1800
|
+
}
|
|
1801
|
+
: null,
|
|
1062
1802
|
};
|
|
1063
1803
|
}
|
|
1064
1804
|
function rulesOutput(rules) {
|
|
@@ -1085,7 +1825,6 @@ function rulesInput(value) {
|
|
|
1085
1825
|
throw storeRefusal(error);
|
|
1086
1826
|
}
|
|
1087
1827
|
}
|
|
1088
|
-
/** Booking rules as the application API writes them, snake_case; `null` for none. */
|
|
1089
1828
|
function bookingRulesOutput(rules) {
|
|
1090
1829
|
if (!rules)
|
|
1091
1830
|
return null;
|
|
@@ -1138,8 +1877,53 @@ function resourceOutput(resource) {
|
|
|
1138
1877
|
visibility: resource.visibility ?? 'shared',
|
|
1139
1878
|
archived_at: resource.archivedAt?.toISOString() ?? null,
|
|
1140
1879
|
pool: resource.pool ?? null,
|
|
1880
|
+
name: resource.name ?? null,
|
|
1881
|
+
description: resource.description ?? null,
|
|
1882
|
+
kind: resource.kind ?? 'other',
|
|
1883
|
+
capacity: resource.capacity ?? null,
|
|
1884
|
+
labels: resource.labels ?? [],
|
|
1885
|
+
attributes: resource.attributes ?? {},
|
|
1886
|
+
location: resource.location ?? null,
|
|
1887
|
+
groups: resource.groups ?? [],
|
|
1888
|
+
parent_id: resource.parentId ?? null,
|
|
1889
|
+
part_index: resource.partIndex ?? null,
|
|
1141
1890
|
};
|
|
1142
1891
|
}
|
|
1892
|
+
function locationOutput(location) {
|
|
1893
|
+
return {
|
|
1894
|
+
ref: location.ref,
|
|
1895
|
+
name: location.name,
|
|
1896
|
+
parent_ref: location.parentRef,
|
|
1897
|
+
timezone: location.timezone,
|
|
1898
|
+
resources: location.resources,
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
function groupOutput(group) {
|
|
1902
|
+
return { ref: group.ref, name: group.name, description: group.description, members: group.members };
|
|
1903
|
+
}
|
|
1904
|
+
/** The references of a location and every location under it, from the tenant's whole tree. */
|
|
1905
|
+
function locationAndBelow(locations, ref) {
|
|
1906
|
+
const found = new Set([ref]);
|
|
1907
|
+
for (let grew = true; grew;) {
|
|
1908
|
+
grew = false;
|
|
1909
|
+
for (const location of locations) {
|
|
1910
|
+
if (location.parentRef !== null && found.has(location.parentRef) && !found.has(location.ref)) {
|
|
1911
|
+
found.add(location.ref);
|
|
1912
|
+
grew = true;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
return found;
|
|
1917
|
+
}
|
|
1918
|
+
/** The profile fields of an operation's input, as the store takes them; absent ones left out. */
|
|
1919
|
+
function profileInput(input) {
|
|
1920
|
+
const profile = {};
|
|
1921
|
+
for (const field of SLOTLOCK_RESOURCE_PROFILE_FIELDS) {
|
|
1922
|
+
if (input[field] !== undefined)
|
|
1923
|
+
profile[field] = input[field];
|
|
1924
|
+
}
|
|
1925
|
+
return profile;
|
|
1926
|
+
}
|
|
1143
1927
|
/** Any of a tenant's events: the store's id, which the tenant-event routes take, and its owner's. */
|
|
1144
1928
|
function tenantEventOutput(event) {
|
|
1145
1929
|
return {
|
|
@@ -1166,7 +1950,7 @@ function reservationOutput(reservation) {
|
|
|
1166
1950
|
reservation_ref: reservation.externalRef,
|
|
1167
1951
|
start: reservation.start.toISOString(),
|
|
1168
1952
|
end: reservation.end.toISOString(),
|
|
1169
|
-
// The store keeps milliseconds;
|
|
1953
|
+
// The store keeps milliseconds; round up so a sub-minute buffer still shows as busy.
|
|
1170
1954
|
buffer_after_minutes: Math.ceil(reservation.bufferAfterMs / 60_000),
|
|
1171
1955
|
revision: reservation.revision,
|
|
1172
1956
|
source: reservation.source,
|
|
@@ -1193,9 +1977,9 @@ function applicationCommandKey(context, idempotencyKey) {
|
|
|
1193
1977
|
/** The most busy intervals one slotlock_list_busy answers, as one free/busy request reads. */
|
|
1194
1978
|
const MAX_LISTED_BUSY = MAX_REQUEST_BUSY_INTERVALS;
|
|
1195
1979
|
/**
|
|
1196
|
-
* Serve the application API (application-api.ts) from the
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1980
|
+
* Serve the application API (application-api.ts) from the store in the principal's tenant. Every
|
|
1981
|
+
* resource named must be the tenant's, or it is not found; holds are under the tenant's RLS, so
|
|
1982
|
+
* another tenant's is not found either.
|
|
1199
1983
|
*/
|
|
1200
1984
|
export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
1201
1985
|
const max = options.maxTenantResources;
|
|
@@ -1239,6 +2023,23 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1239
2023
|
};
|
|
1240
2024
|
});
|
|
1241
2025
|
}
|
|
2026
|
+
/** The partner store and the partner a partner key names; any other caller is forbidden. */
|
|
2027
|
+
function partnerOf(context) {
|
|
2028
|
+
assertActive(context);
|
|
2029
|
+
const partners = options.partners;
|
|
2030
|
+
if (!partners)
|
|
2031
|
+
throw new SlotlockAgentOperationError('partner_api_not_served', 404);
|
|
2032
|
+
return { partners, partner: slotlockPrincipalPartner(context.principal) };
|
|
2033
|
+
}
|
|
2034
|
+
/** As partnerOf, with the account's tenant: 404 account_not_found for one the partner lacks. */
|
|
2035
|
+
async function partnerAccount(context, input) {
|
|
2036
|
+
const { partners, partner } = partnerOf(context);
|
|
2037
|
+
const tenantRef = await partners.tenantOf(partner, input.account);
|
|
2038
|
+
if (!tenantRef)
|
|
2039
|
+
throw new SlotlockAgentOperationError('account_not_found', 404);
|
|
2040
|
+
// Its test environment (RFC 0003 G9): a tenant of its own, on the same plan, never billed.
|
|
2041
|
+
return { partners, partner, tenantRef: input.environment === 'test' ? `${tenantRef}${SLOTLOCK_TEST_TENANT_SUFFIX}` : tenantRef };
|
|
2042
|
+
}
|
|
1242
2043
|
return {
|
|
1243
2044
|
async createResource(context, input) {
|
|
1244
2045
|
// A capped create counts under READ COMMITTED; withTenant opens it for the call.
|
|
@@ -1247,11 +2048,15 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1247
2048
|
const resource = await tenant.createResource({
|
|
1248
2049
|
tenantRef: tenantOf(context),
|
|
1249
2050
|
externalRef: input.external_ref,
|
|
1250
|
-
timezone: input.timezone,
|
|
2051
|
+
...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
|
|
2052
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
1251
2053
|
...(input.visibility !== undefined
|
|
1252
2054
|
? { visibility: input.visibility }
|
|
1253
2055
|
: {}),
|
|
1254
2056
|
...(max !== undefined ? { maxTenantResources: max } : {}),
|
|
2057
|
+
...(SLOTLOCK_RESOURCE_PROFILE_FIELDS.some((field) => input[field] !== undefined)
|
|
2058
|
+
? { profile: profileInput(input) }
|
|
2059
|
+
: {}),
|
|
1255
2060
|
});
|
|
1256
2061
|
return { resource: resourceOutput(resource) };
|
|
1257
2062
|
}
|
|
@@ -1260,6 +2065,151 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1260
2065
|
}
|
|
1261
2066
|
}, max !== undefined ? { isolation: 'read committed' } : undefined);
|
|
1262
2067
|
},
|
|
2068
|
+
async updateResource(context, input) {
|
|
2069
|
+
const id = input.resource_id;
|
|
2070
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
2071
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
2072
|
+
return withTenant(store, context, async (tenant) => {
|
|
2073
|
+
let resource;
|
|
2074
|
+
try {
|
|
2075
|
+
resource = await tenant.updateResourceProfile({
|
|
2076
|
+
tenantRef: tenantOf(context),
|
|
2077
|
+
id,
|
|
2078
|
+
profile: profileInput(input),
|
|
2079
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
catch (error) {
|
|
2083
|
+
throw storeRefusal(error);
|
|
2084
|
+
}
|
|
2085
|
+
if (!resource)
|
|
2086
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
2087
|
+
return { resource: resourceOutput(resource) };
|
|
2088
|
+
}, WRITE);
|
|
2089
|
+
},
|
|
2090
|
+
async listLocations(context) {
|
|
2091
|
+
return withTenant(store, context, async (tenant) => ({
|
|
2092
|
+
locations: (await tenant.listLocations({ tenantRef: tenantOf(context) })).map(locationOutput),
|
|
2093
|
+
}));
|
|
2094
|
+
},
|
|
2095
|
+
async createLocation(context, input) {
|
|
2096
|
+
return withTenant(store, context, async (tenant) => {
|
|
2097
|
+
try {
|
|
2098
|
+
const location = await tenant.putLocation({
|
|
2099
|
+
tenantRef: tenantOf(context),
|
|
2100
|
+
ref: input.ref,
|
|
2101
|
+
name: input.name,
|
|
2102
|
+
...(input.parent_ref !== undefined ? { parentRef: input.parent_ref } : {}),
|
|
2103
|
+
...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
|
|
2104
|
+
});
|
|
2105
|
+
return { location: locationOutput(location) };
|
|
2106
|
+
}
|
|
2107
|
+
catch (error) {
|
|
2108
|
+
throw storeRefusal(error);
|
|
2109
|
+
}
|
|
2110
|
+
}, WRITE);
|
|
2111
|
+
},
|
|
2112
|
+
async updateLocation(context, input) {
|
|
2113
|
+
return withTenant(store, context, async (tenant) => {
|
|
2114
|
+
let location;
|
|
2115
|
+
try {
|
|
2116
|
+
location = await tenant.updateLocation({
|
|
2117
|
+
tenantRef: tenantOf(context),
|
|
2118
|
+
ref: input.location_ref,
|
|
2119
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
2120
|
+
...(input.parent_ref !== undefined ? { parentRef: input.parent_ref } : {}),
|
|
2121
|
+
...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
catch (error) {
|
|
2125
|
+
throw storeRefusal(error);
|
|
2126
|
+
}
|
|
2127
|
+
if (!location)
|
|
2128
|
+
throw new SlotlockAgentOperationError('location_not_found', 404);
|
|
2129
|
+
return { location: locationOutput(location) };
|
|
2130
|
+
}, WRITE);
|
|
2131
|
+
},
|
|
2132
|
+
async deleteLocation(context, input) {
|
|
2133
|
+
return withTenant(store, context, async (tenant) => {
|
|
2134
|
+
try {
|
|
2135
|
+
return {
|
|
2136
|
+
deleted: await tenant.deleteLocation({ tenantRef: tenantOf(context), ref: input.location_ref }),
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
catch (error) {
|
|
2140
|
+
throw storeRefusal(error);
|
|
2141
|
+
}
|
|
2142
|
+
}, WRITE);
|
|
2143
|
+
},
|
|
2144
|
+
async listGroups(context) {
|
|
2145
|
+
return withTenant(store, context, async (tenant) => ({
|
|
2146
|
+
groups: (await tenant.listGroups({ tenantRef: tenantOf(context) })).map(groupOutput),
|
|
2147
|
+
}));
|
|
2148
|
+
},
|
|
2149
|
+
async createGroup(context, input) {
|
|
2150
|
+
return withTenant(store, context, async (tenant) => {
|
|
2151
|
+
try {
|
|
2152
|
+
const group = await tenant.putGroup({
|
|
2153
|
+
tenantRef: tenantOf(context),
|
|
2154
|
+
ref: input.ref,
|
|
2155
|
+
name: input.name,
|
|
2156
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
2157
|
+
});
|
|
2158
|
+
return { group: groupOutput(group) };
|
|
2159
|
+
}
|
|
2160
|
+
catch (error) {
|
|
2161
|
+
throw storeRefusal(error);
|
|
2162
|
+
}
|
|
2163
|
+
}, WRITE);
|
|
2164
|
+
},
|
|
2165
|
+
async deleteGroup(context, input) {
|
|
2166
|
+
return withTenant(store, context, async (tenant) => {
|
|
2167
|
+
try {
|
|
2168
|
+
return { deleted: await tenant.deleteGroup({ tenantRef: tenantOf(context), ref: input.group_ref }) };
|
|
2169
|
+
}
|
|
2170
|
+
catch (error) {
|
|
2171
|
+
throw storeRefusal(error);
|
|
2172
|
+
}
|
|
2173
|
+
}, WRITE);
|
|
2174
|
+
},
|
|
2175
|
+
async addGroupMember(context, input) {
|
|
2176
|
+
const resourceId = input.resource_id;
|
|
2177
|
+
if (!STORE_ID_PATTERN.test(resourceId))
|
|
2178
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
2179
|
+
return withTenant(store, context, async (tenant) => {
|
|
2180
|
+
try {
|
|
2181
|
+
return {
|
|
2182
|
+
added: await tenant.addGroupMember({
|
|
2183
|
+
tenantRef: tenantOf(context),
|
|
2184
|
+
group: input.group_ref,
|
|
2185
|
+
resourceId,
|
|
2186
|
+
}),
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
catch (error) {
|
|
2190
|
+
throw storeRefusal(error);
|
|
2191
|
+
}
|
|
2192
|
+
}, WRITE);
|
|
2193
|
+
},
|
|
2194
|
+
async removeGroupMember(context, input) {
|
|
2195
|
+
const resourceId = input.resource_id;
|
|
2196
|
+
if (!STORE_ID_PATTERN.test(resourceId))
|
|
2197
|
+
return { removed: false };
|
|
2198
|
+
return withTenant(store, context, async (tenant) => {
|
|
2199
|
+
try {
|
|
2200
|
+
return {
|
|
2201
|
+
removed: await tenant.removeGroupMember({
|
|
2202
|
+
tenantRef: tenantOf(context),
|
|
2203
|
+
group: input.group_ref,
|
|
2204
|
+
resourceId,
|
|
2205
|
+
}),
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
catch (error) {
|
|
2209
|
+
throw storeRefusal(error);
|
|
2210
|
+
}
|
|
2211
|
+
}, WRITE);
|
|
2212
|
+
},
|
|
1263
2213
|
async getResource(context, input) {
|
|
1264
2214
|
return withTenant(store, context, async (tenant) => ({
|
|
1265
2215
|
resource: resourceOutput(await requireResource(tenant, tenantOf(context), input.resource_id)),
|
|
@@ -1289,7 +2239,6 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1289
2239
|
},
|
|
1290
2240
|
async deleteResource(context, input) {
|
|
1291
2241
|
const id = input.resource_id;
|
|
1292
|
-
// An id the store never issues names no resource: there is nothing to delete.
|
|
1293
2242
|
if (!STORE_ID_PATTERN.test(id))
|
|
1294
2243
|
return { deleted: false };
|
|
1295
2244
|
return withTenant(store, context, async (tenant) => {
|
|
@@ -1421,6 +2370,26 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1421
2370
|
return { resource: resourceOutput(resource) };
|
|
1422
2371
|
}, WRITE);
|
|
1423
2372
|
},
|
|
2373
|
+
async setResourceParent(context, input) {
|
|
2374
|
+
const id = input.resource_id;
|
|
2375
|
+
const parentId = input.parent_id;
|
|
2376
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
2377
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
2378
|
+
if (parentId !== null && !STORE_ID_PATTERN.test(parentId))
|
|
2379
|
+
throw new SlotlockAgentOperationError('parent_not_found', 404);
|
|
2380
|
+
return withTenant(store, context, async (tenant) => {
|
|
2381
|
+
let resource;
|
|
2382
|
+
try {
|
|
2383
|
+
resource = await tenant.setResourceParent({ tenantRef: tenantOf(context), id, parentId });
|
|
2384
|
+
}
|
|
2385
|
+
catch (error) {
|
|
2386
|
+
throw storeRefusal(error);
|
|
2387
|
+
}
|
|
2388
|
+
if (!resource)
|
|
2389
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
2390
|
+
return { resource: resourceOutput(resource) };
|
|
2391
|
+
}, WRITE);
|
|
2392
|
+
},
|
|
1424
2393
|
async getResourceHistory(context, input) {
|
|
1425
2394
|
return history(context, input);
|
|
1426
2395
|
},
|
|
@@ -1435,6 +2404,21 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1435
2404
|
pools: await tenant.listPools({ tenantRef: tenantOf(context) }),
|
|
1436
2405
|
}));
|
|
1437
2406
|
},
|
|
2407
|
+
async setPoolOrder(context, input) {
|
|
2408
|
+
return withTenant(store, context, async (tenant) => {
|
|
2409
|
+
try {
|
|
2410
|
+
const order = await tenant.setPoolOrder({
|
|
2411
|
+
tenantRef: tenantOf(context),
|
|
2412
|
+
pool: input.pool,
|
|
2413
|
+
order: input.order,
|
|
2414
|
+
});
|
|
2415
|
+
return { pool: input.pool, order };
|
|
2416
|
+
}
|
|
2417
|
+
catch (error) {
|
|
2418
|
+
throw storeRefusal(error);
|
|
2419
|
+
}
|
|
2420
|
+
}, WRITE);
|
|
2421
|
+
},
|
|
1438
2422
|
async getBookingRuleDefaults(context) {
|
|
1439
2423
|
return withTenant(store, context, async (tenant) => {
|
|
1440
2424
|
const defaults = await tenant.getBookingRuleDefaults({ tenantRef: tenantOf(context) });
|
|
@@ -1552,14 +2536,13 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1552
2536
|
},
|
|
1553
2537
|
async releaseHold(context, input) {
|
|
1554
2538
|
const id = input.hold_id;
|
|
1555
|
-
// An id that is not one the store issues names no hold: there is nothing to release.
|
|
1556
2539
|
if (!STORE_ID_PATTERN.test(id))
|
|
1557
2540
|
return { released: false };
|
|
1558
2541
|
const released = await withTenant(store, context, async (tenant) => ({
|
|
1559
2542
|
released: (await tenant.releaseHold(id, { tenantRef: tenantOf(context) })).released,
|
|
1560
2543
|
}));
|
|
1561
2544
|
if (released.released)
|
|
1562
|
-
await offerFreedTime(store,
|
|
2545
|
+
await offerFreedTime(store, context);
|
|
1563
2546
|
return released;
|
|
1564
2547
|
},
|
|
1565
2548
|
async getReservation(context, input) {
|
|
@@ -1608,9 +2591,8 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1608
2591
|
throw applicationRefusal(result.code);
|
|
1609
2592
|
return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
|
|
1610
2593
|
}, WRITE);
|
|
1611
|
-
// A move frees the time the booking held.
|
|
1612
2594
|
if (input.revision > 1 && !put.replayed) {
|
|
1613
|
-
await offerFreedTime(store,
|
|
2595
|
+
await offerFreedTime(store, context);
|
|
1614
2596
|
}
|
|
1615
2597
|
return put;
|
|
1616
2598
|
},
|
|
@@ -1626,7 +2608,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1626
2608
|
return { cancelled: result.cancelled, revision: result.revision };
|
|
1627
2609
|
}, WRITE);
|
|
1628
2610
|
if (cancelled.cancelled)
|
|
1629
|
-
await offerFreedTime(store,
|
|
2611
|
+
await offerFreedTime(store, context);
|
|
1630
2612
|
return cancelled;
|
|
1631
2613
|
},
|
|
1632
2614
|
async checkInReservation(context, input) {
|
|
@@ -1651,10 +2633,11 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1651
2633
|
end: new Date(input.end),
|
|
1652
2634
|
holderRef: input.holder_ref,
|
|
1653
2635
|
...(ttl !== undefined ? { offerTtlMs: ttl * 1000 } : {}),
|
|
2636
|
+
...(input.idempotency_key !== undefined ? { idempotencyKey: input.idempotency_key } : {}),
|
|
1654
2637
|
});
|
|
1655
2638
|
if (!result.ok)
|
|
1656
2639
|
throw applicationRefusal(result.code);
|
|
1657
|
-
return { entry: waitlistOutput(result.entry) };
|
|
2640
|
+
return { entry: waitlistOutput(result.entry), replayed: result.replayed };
|
|
1658
2641
|
});
|
|
1659
2642
|
},
|
|
1660
2643
|
async listWaitlist(context, input) {
|
|
@@ -1673,7 +2656,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1673
2656
|
left: await tenant.leaveWaitlist({ tenantRef: tenantOf(context), id: input.entry_id }),
|
|
1674
2657
|
}));
|
|
1675
2658
|
if (left.left)
|
|
1676
|
-
await offerFreedTime(store,
|
|
2659
|
+
await offerFreedTime(store, context);
|
|
1677
2660
|
return left;
|
|
1678
2661
|
},
|
|
1679
2662
|
async getCoverage(context, input) {
|
|
@@ -1778,7 +2761,6 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1778
2761
|
async cancelTenantEvent(context, input) {
|
|
1779
2762
|
const id = input.event_id;
|
|
1780
2763
|
const expectedResourceId = input.expected_resource_id;
|
|
1781
|
-
// An id the store never issues names no event, and no event is on a resource it never issued.
|
|
1782
2764
|
if (!STORE_ID_PATTERN.test(id) ||
|
|
1783
2765
|
(expectedResourceId !== undefined && !STORE_ID_PATTERN.test(expectedResourceId))) {
|
|
1784
2766
|
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
@@ -1801,7 +2783,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1801
2783
|
};
|
|
1802
2784
|
}, WRITE);
|
|
1803
2785
|
if (!cancelled.replayed)
|
|
1804
|
-
await offerFreedTime(store,
|
|
2786
|
+
await offerFreedTime(store, context);
|
|
1805
2787
|
return cancelled;
|
|
1806
2788
|
},
|
|
1807
2789
|
async getTenant(context) {
|
|
@@ -1820,12 +2802,18 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1820
2802
|
throw new SlotlockAgentOperationError('invalid_confirmation', 400);
|
|
1821
2803
|
}
|
|
1822
2804
|
return withTenant(store, context, async (tenant) => {
|
|
1823
|
-
// Read under the tenant's lock
|
|
1824
|
-
//
|
|
2805
|
+
// Read under the tenant's lock: a suspended tenant's data is kept for its operator to
|
|
2806
|
+
// erase.
|
|
1825
2807
|
if (await tenant.isTenantSuspended({ tenantRef: tenantOf(context) })) {
|
|
1826
2808
|
throw new SlotlockAgentOperationError('tenant_suspended', 403);
|
|
1827
2809
|
}
|
|
1828
|
-
const erased = await tenant.eraseTenant({ tenantRef: tenantOf(context) })
|
|
2810
|
+
const erased = await tenant.eraseTenant({ tenantRef: tenantOf(context) }).catch((error) => {
|
|
2811
|
+
// A paid hosted plan is cancelled first, so it does not renew for an erased tenant.
|
|
2812
|
+
if (error?.code === 'billing_active') {
|
|
2813
|
+
throw new SlotlockAgentOperationError('billing_active', 409);
|
|
2814
|
+
}
|
|
2815
|
+
throw error;
|
|
2816
|
+
});
|
|
1829
2817
|
return { total: erased.total, rows: erased.rows };
|
|
1830
2818
|
}, WRITE);
|
|
1831
2819
|
},
|
|
@@ -1848,11 +2836,26 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1848
2836
|
tenantRef: tenantOf(context),
|
|
1849
2837
|
url: input.url,
|
|
1850
2838
|
...(input.event_types !== undefined ? { eventTypes: input.event_types } : {}),
|
|
2839
|
+
...(['resource_ids', 'pools', 'locations', 'groups'].some((field) => input[field] !== undefined)
|
|
2840
|
+
? {
|
|
2841
|
+
filter: {
|
|
2842
|
+
...(input.resource_ids !== undefined ? { resourceIds: input.resource_ids } : {}),
|
|
2843
|
+
...(input.pools !== undefined ? { pools: input.pools } : {}),
|
|
2844
|
+
...(input.locations !== undefined ? { locations: input.locations } : {}),
|
|
2845
|
+
...(input.groups !== undefined ? { groups: input.groups } : {}),
|
|
2846
|
+
},
|
|
2847
|
+
}
|
|
2848
|
+
: {}),
|
|
2849
|
+
...(input.idempotency_key !== undefined ? { idempotencyKey: input.idempotency_key } : {}),
|
|
1851
2850
|
});
|
|
1852
2851
|
if (!created.ok) {
|
|
1853
|
-
throw new SlotlockAgentOperationError(created.code, created.code === 'webhook_limit_reached' ? 409 : 400);
|
|
2852
|
+
throw new SlotlockAgentOperationError(created.code, created.code === 'webhook_limit_reached' || created.code === 'idempotency_conflict' ? 409 : 400);
|
|
1854
2853
|
}
|
|
1855
|
-
return {
|
|
2854
|
+
return {
|
|
2855
|
+
webhook: webhookOutput(created.endpoint),
|
|
2856
|
+
secret: created.secret,
|
|
2857
|
+
replayed: created.replayed === true,
|
|
2858
|
+
};
|
|
1856
2859
|
}, WRITE);
|
|
1857
2860
|
},
|
|
1858
2861
|
async listWebhooks(context) {
|
|
@@ -1863,6 +2866,196 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1863
2866
|
async deleteWebhook(context, input) {
|
|
1864
2867
|
return withTenant(store, context, async (tenant) => tenant.deleteWebhookEndpoint({ tenantRef: tenantOf(context), id: input.webhook_id }), WRITE);
|
|
1865
2868
|
},
|
|
2869
|
+
// The partner API (RFC 0003 P3): the partner the key names, its own accounts only.
|
|
2870
|
+
async partnerPutAccount(context, input) {
|
|
2871
|
+
const { partners, partner } = partnerOf(context);
|
|
2872
|
+
const put = await partners.putAccount(partner, input.account, input.plan);
|
|
2873
|
+
if (!put)
|
|
2874
|
+
throw new SlotlockAgentOperationError('forbidden', 403);
|
|
2875
|
+
return { account: partnerAccountOutput(put.account), created: put.created };
|
|
2876
|
+
},
|
|
2877
|
+
async partnerListAccounts(context) {
|
|
2878
|
+
const { partners, partner } = partnerOf(context);
|
|
2879
|
+
return { accounts: (await partners.listAccounts(partner)).map(partnerAccountOutput) };
|
|
2880
|
+
},
|
|
2881
|
+
async partnerGetAccount(context, input) {
|
|
2882
|
+
const { partners, partner } = partnerOf(context);
|
|
2883
|
+
const found = await partners.getAccount(partner, input.account);
|
|
2884
|
+
if (!found)
|
|
2885
|
+
throw new SlotlockAgentOperationError('account_not_found', 404);
|
|
2886
|
+
return { account: partnerAccountOutput(found) };
|
|
2887
|
+
},
|
|
2888
|
+
async partnerListKeys(context, input) {
|
|
2889
|
+
const { partners, tenantRef } = await partnerAccount(context, input);
|
|
2890
|
+
return { keys: (await partners.keys.list({ tenantRef })).map(slotlockPartnerKeyOutput) };
|
|
2891
|
+
},
|
|
2892
|
+
async partnerCreateKey(context, input) {
|
|
2893
|
+
const { partners, partner, tenantRef } = await partnerAccount(context, input);
|
|
2894
|
+
const contract = await partners.contract(partner);
|
|
2895
|
+
if (!contract)
|
|
2896
|
+
throw new SlotlockAgentOperationError('forbidden', 403);
|
|
2897
|
+
const scopes = input.scopes ?? [...contract.keyScopes];
|
|
2898
|
+
if (scopes.some((scope) => !contract.keyScopes.includes(scope))) {
|
|
2899
|
+
throw new SlotlockAgentOperationError('scope_not_in_contract', 403);
|
|
2900
|
+
}
|
|
2901
|
+
const days = input.expires_in_days;
|
|
2902
|
+
try {
|
|
2903
|
+
const created = await partners.keys.create({
|
|
2904
|
+
tenantRef,
|
|
2905
|
+
name: input.name,
|
|
2906
|
+
scopes,
|
|
2907
|
+
createdBy: `partner:${partner}`,
|
|
2908
|
+
actor: context.principal.subject,
|
|
2909
|
+
origin: 'application',
|
|
2910
|
+
...(days !== undefined ? { expiresAt: new Date(Date.now() + days * 86_400_000) } : {}),
|
|
2911
|
+
...(input.limits !== undefined ? { limits: keyLimitsFromInput(input.limits) } : {}),
|
|
2912
|
+
});
|
|
2913
|
+
return { key: created.key, api_key: slotlockPartnerKeyOutput(created.apiKey) };
|
|
2914
|
+
}
|
|
2915
|
+
catch (error) {
|
|
2916
|
+
const code = error.code;
|
|
2917
|
+
if (code === 'api_key_limit_reached')
|
|
2918
|
+
throw new SlotlockAgentOperationError(code, 409);
|
|
2919
|
+
if (code === 'tenant_suspended')
|
|
2920
|
+
throw new SlotlockAgentOperationError(code, 403);
|
|
2921
|
+
throw error;
|
|
2922
|
+
}
|
|
2923
|
+
},
|
|
2924
|
+
async partnerRotateKey(context, input) {
|
|
2925
|
+
const { partners, tenantRef } = await partnerAccount(context, input);
|
|
2926
|
+
const rotated = await partners.keys
|
|
2927
|
+
.rotate({ tenantRef, id: input.key_id, actor: context.principal.subject, origin: 'application' })
|
|
2928
|
+
.catch((error) => {
|
|
2929
|
+
if (error.code === 'tenant_suspended') {
|
|
2930
|
+
throw new SlotlockAgentOperationError('tenant_suspended', 403);
|
|
2931
|
+
}
|
|
2932
|
+
throw error;
|
|
2933
|
+
});
|
|
2934
|
+
if (!rotated)
|
|
2935
|
+
throw new SlotlockAgentOperationError('key_not_found', 404);
|
|
2936
|
+
return { key: rotated.key, api_key: slotlockPartnerKeyOutput(rotated.apiKey) };
|
|
2937
|
+
},
|
|
2938
|
+
async partnerRevokeKey(context, input) {
|
|
2939
|
+
const { partners, tenantRef } = await partnerAccount(context, input);
|
|
2940
|
+
const revoked = await partners.keys.revoke({
|
|
2941
|
+
tenantRef,
|
|
2942
|
+
id: input.key_id,
|
|
2943
|
+
actor: context.principal.subject,
|
|
2944
|
+
origin: 'application',
|
|
2945
|
+
});
|
|
2946
|
+
if (!revoked)
|
|
2947
|
+
throw new SlotlockAgentOperationError('key_not_found', 404);
|
|
2948
|
+
return { api_key: slotlockPartnerKeyOutput(revoked) };
|
|
2949
|
+
},
|
|
2950
|
+
async partnerSuspendAccount(context, input) {
|
|
2951
|
+
const { partners, partner } = partnerOf(context);
|
|
2952
|
+
const changed = await partners.setSuspended(partner, input.account, true, input.reason);
|
|
2953
|
+
if (changed === null)
|
|
2954
|
+
throw new SlotlockAgentOperationError('account_not_found', 404);
|
|
2955
|
+
return { suspended: changed };
|
|
2956
|
+
},
|
|
2957
|
+
async partnerUnsuspendAccount(context, input) {
|
|
2958
|
+
const { partners, partner } = partnerOf(context);
|
|
2959
|
+
const changed = await partners.setSuspended(partner, input.account, false);
|
|
2960
|
+
if (changed === null)
|
|
2961
|
+
throw new SlotlockAgentOperationError('account_not_found', 404);
|
|
2962
|
+
return { unsuspended: changed };
|
|
2963
|
+
},
|
|
2964
|
+
async partnerEraseAccount(context, input) {
|
|
2965
|
+
const { partners, partner } = partnerOf(context);
|
|
2966
|
+
if (input.confirm !== input.account)
|
|
2967
|
+
throw new SlotlockAgentOperationError('confirmation_mismatch', 400);
|
|
2968
|
+
const erased = await partners.eraseAccount(partner, input.account);
|
|
2969
|
+
if (!erased.erased)
|
|
2970
|
+
throw new SlotlockAgentOperationError('account_not_found', 404);
|
|
2971
|
+
return { erased: true, rows: erased.total };
|
|
2972
|
+
},
|
|
2973
|
+
async partnerGetUsage(context, input) {
|
|
2974
|
+
const { partners, partner } = partnerOf(context);
|
|
2975
|
+
const now = Date.now();
|
|
2976
|
+
const range = slotlockUsageMonthToDate(now);
|
|
2977
|
+
const from = input.from ?? range.from;
|
|
2978
|
+
const to = input.to ?? range.to;
|
|
2979
|
+
const span = slotlockUsageSpanDays(from, to);
|
|
2980
|
+
if (span < 0 || span > SLOTLOCK_USAGE_RETENTION_DAYS || slotlockUsageSpanDays(to, slotlockUsageDay(now)) < 0) {
|
|
2981
|
+
throw new SlotlockAgentOperationError('invalid_usage_range', 400);
|
|
2982
|
+
}
|
|
2983
|
+
const rows = await partners.usage(partner, from, to, input.account);
|
|
2984
|
+
return { from, to, usage: rows };
|
|
2985
|
+
},
|
|
2986
|
+
async partnerReconcileUsage(context, input) {
|
|
2987
|
+
const { partners, partner } = partnerOf(context);
|
|
2988
|
+
const day = input.day;
|
|
2989
|
+
// An ended day: today's counts are still arriving.
|
|
2990
|
+
if (slotlockUsageSpanDays(day, slotlockUsageDay(Date.now())) < 1) {
|
|
2991
|
+
throw new SlotlockAgentOperationError('invalid_usage_range', 400);
|
|
2992
|
+
}
|
|
2993
|
+
const theirs = new Map();
|
|
2994
|
+
for (const row of input.accounts) {
|
|
2995
|
+
theirs.set(row.account, { operations: row.operations, ...(row.bookings !== undefined ? { bookings: row.bookings } : {}) });
|
|
2996
|
+
}
|
|
2997
|
+
const ours = new Map();
|
|
2998
|
+
for (const row of await partners.usage(partner, day, day)) {
|
|
2999
|
+
if (row.metric !== 'operations' && row.metric !== 'bookings')
|
|
3000
|
+
continue;
|
|
3001
|
+
const counted = ours.get(row.account) ?? { operations: 0, bookings: 0 };
|
|
3002
|
+
counted[row.metric] += row.count;
|
|
3003
|
+
ours.set(row.account, counted);
|
|
3004
|
+
}
|
|
3005
|
+
const accounts = [...new Set([...ours.keys(), ...theirs.keys()])].sort();
|
|
3006
|
+
const lines = [];
|
|
3007
|
+
const mismatched = new Set();
|
|
3008
|
+
let worst = 0;
|
|
3009
|
+
for (const account of accounts) {
|
|
3010
|
+
for (const metric of ['operations', 'bookings']) {
|
|
3011
|
+
const mine = ours.get(account)?.[metric] ?? 0;
|
|
3012
|
+
const told = theirs.get(account)?.[metric];
|
|
3013
|
+
if (told === undefined && (metric === 'bookings' || !theirs.has(account)) && mine === 0)
|
|
3014
|
+
continue;
|
|
3015
|
+
// Not sent by them, where we counted some: a difference of all of it.
|
|
3016
|
+
const theirCount = told ?? null;
|
|
3017
|
+
const difference = theirCount === null
|
|
3018
|
+
? mine === 0 ? 0 : 100
|
|
3019
|
+
: Math.max(mine, theirCount) === 0 ? 0 : (Math.abs(mine - theirCount) * 100) / Math.max(mine, theirCount);
|
|
3020
|
+
if (theirCount === null && metric === 'bookings') {
|
|
3021
|
+
lines.push({ account, metric, ours: mine, theirs: null, difference_percent: null });
|
|
3022
|
+
continue;
|
|
3023
|
+
}
|
|
3024
|
+
const rounded = Math.round(difference * 100) / 100;
|
|
3025
|
+
if (difference > SLOTLOCK_RECONCILE_TOLERANCE_PERCENT)
|
|
3026
|
+
mismatched.add(account);
|
|
3027
|
+
worst = Math.max(worst, rounded);
|
|
3028
|
+
lines.push({ account, metric, ours: mine, theirs: theirCount, difference_percent: rounded });
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
await partners.recordReconciliation(partner, day, accounts.length, mismatched.size, worst);
|
|
3032
|
+
return {
|
|
3033
|
+
day,
|
|
3034
|
+
within_tolerance: mismatched.size === 0,
|
|
3035
|
+
tolerance_percent: SLOTLOCK_RECONCILE_TOLERANCE_PERCENT,
|
|
3036
|
+
accounts: lines,
|
|
3037
|
+
};
|
|
3038
|
+
},
|
|
3039
|
+
async getUsage(context, input) {
|
|
3040
|
+
const now = Date.now();
|
|
3041
|
+
const range = slotlockUsageMonthToDate(now);
|
|
3042
|
+
const from = input.from ?? range.from;
|
|
3043
|
+
const to = input.to ?? range.to;
|
|
3044
|
+
const span = slotlockUsageSpanDays(from, to);
|
|
3045
|
+
if (span < 0 || span > SLOTLOCK_USAGE_RETENTION_DAYS || slotlockUsageSpanDays(to, slotlockUsageDay(now)) < 0) {
|
|
3046
|
+
throw new SlotlockAgentOperationError('invalid_usage_range', 400);
|
|
3047
|
+
}
|
|
3048
|
+
const rows = await withTenant(store, context, (tenant) => tenant.readUsage({ tenantRef: tenantOf(context), from, to }));
|
|
3049
|
+
const summary = summarizeSlotlockUsage(rows);
|
|
3050
|
+
return {
|
|
3051
|
+
from,
|
|
3052
|
+
to,
|
|
3053
|
+
totals: summary.totals,
|
|
3054
|
+
days: summary.days,
|
|
3055
|
+
keys: summary.keys.map(({ keyId, ...totals }) => ({ key_id: keyId, ...totals })),
|
|
3056
|
+
surfaces: summary.surfaces,
|
|
3057
|
+
};
|
|
3058
|
+
},
|
|
1866
3059
|
async enableWebhook(context, input) {
|
|
1867
3060
|
return withTenant(store, context, async (tenant) => {
|
|
1868
3061
|
const enabled = await tenant.enableWebhookEndpoint({
|
|
@@ -1876,11 +3069,152 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1876
3069
|
return { webhook: webhookOutput(enabled) };
|
|
1877
3070
|
}, WRITE);
|
|
1878
3071
|
},
|
|
3072
|
+
async listApprovalPolicies(context) {
|
|
3073
|
+
return withTenant(store, context, async (tenant) => ({
|
|
3074
|
+
policies: (await tenant.listApprovalPolicies({ tenantRef: tenantOf(context) })).map(approvalPolicyOutput),
|
|
3075
|
+
}));
|
|
3076
|
+
},
|
|
3077
|
+
async putApprovalPolicy(context, input) {
|
|
3078
|
+
return withTenant(store, context, async (tenant) => {
|
|
3079
|
+
let policy;
|
|
3080
|
+
try {
|
|
3081
|
+
policy = await tenant.putApprovalPolicy({
|
|
3082
|
+
tenantRef: tenantOf(context),
|
|
3083
|
+
ref: input.policy_ref,
|
|
3084
|
+
name: input.name,
|
|
3085
|
+
outcome: input.outcome,
|
|
3086
|
+
...(input.applies_to !== undefined ? { appliesTo: approvalAppliesTo(input.applies_to) } : {}),
|
|
3087
|
+
...(input.conditions !== undefined ? { conditions: approvalConditions(input.conditions) } : {}),
|
|
3088
|
+
...(input.approval_ttl_minutes !== undefined ? { approvalTtlMinutes: input.approval_ttl_minutes } : {}),
|
|
3089
|
+
...(input.lead_minutes !== undefined ? { leadMinutes: input.lead_minutes } : {}),
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
catch (error) {
|
|
3093
|
+
if (error instanceof SlotlockApprovalPolicyError) {
|
|
3094
|
+
throw new SlotlockAgentOperationError(error.code, 400, { reason: error.message.slice(0, 200) });
|
|
3095
|
+
}
|
|
3096
|
+
throw error;
|
|
3097
|
+
}
|
|
3098
|
+
if (!policy)
|
|
3099
|
+
throw new SlotlockAgentOperationError('approval_policy_limit_reached', 409);
|
|
3100
|
+
return { policy: approvalPolicyOutput(policy) };
|
|
3101
|
+
}, WRITE);
|
|
3102
|
+
},
|
|
3103
|
+
async deleteApprovalPolicy(context, input) {
|
|
3104
|
+
return withTenant(store, context, (tenant) => tenant.deleteApprovalPolicy({ tenantRef: tenantOf(context), ref: input.policy_ref }));
|
|
3105
|
+
},
|
|
3106
|
+
async listApprovals(context, input) {
|
|
3107
|
+
return withTenant(store, context, async (tenant) => ({
|
|
3108
|
+
approvals: (await tenant.listApprovals({
|
|
3109
|
+
tenantRef: tenantOf(context),
|
|
3110
|
+
limit: input.limit,
|
|
3111
|
+
...(input.status !== undefined ? { status: input.status } : {}),
|
|
3112
|
+
})).map(approvalOutput),
|
|
3113
|
+
}));
|
|
3114
|
+
},
|
|
3115
|
+
async getApproval(context, input) {
|
|
3116
|
+
return withTenant(store, context, async (tenant) => {
|
|
3117
|
+
const approval = await tenant.getApproval({ tenantRef: tenantOf(context), id: input.approval_id });
|
|
3118
|
+
if (!approval)
|
|
3119
|
+
throw new SlotlockAgentOperationError('approval_not_found', 404);
|
|
3120
|
+
return { approval: approvalOutput(approval) };
|
|
3121
|
+
});
|
|
3122
|
+
},
|
|
3123
|
+
async decideApproval(context, input) {
|
|
3124
|
+
const tenantRef = tenantOf(context);
|
|
3125
|
+
const id = input.approval_id;
|
|
3126
|
+
const decidedBy = actorOf(context) ?? 'unknown';
|
|
3127
|
+
if (input.decision === 'decline') {
|
|
3128
|
+
const declined = await withTenant(store, context, (tenant) => tenant.declineApproval({
|
|
3129
|
+
tenantRef,
|
|
3130
|
+
id,
|
|
3131
|
+
decidedBy,
|
|
3132
|
+
...(input.reason !== undefined ? { reason: input.reason } : {}),
|
|
3133
|
+
}), WRITE);
|
|
3134
|
+
if (!declined.ok)
|
|
3135
|
+
throw approvalRefusal(declined.code);
|
|
3136
|
+
await offerFreedTime(store, context);
|
|
3137
|
+
return { approval: approvalOutput(declined.approval), booking: null };
|
|
3138
|
+
}
|
|
3139
|
+
if (!options.agentBackend)
|
|
3140
|
+
throw new SlotlockAgentOperationError('approvals_unavailable', 409);
|
|
3141
|
+
const approval = await withTenant(store, context, (tenant) => tenant.getApproval({ tenantRef, id }));
|
|
3142
|
+
if (!approval)
|
|
3143
|
+
throw approvalRefusal('approval_not_found');
|
|
3144
|
+
if (approval.status !== 'pending')
|
|
3145
|
+
throw approvalRefusal('approval_not_pending');
|
|
3146
|
+
// A dry run's grant is a dry run of the booking: rolled back with it.
|
|
3147
|
+
const booking = await runSlotlockApprovalGrant(options.agentBackend, approval, {
|
|
3148
|
+
tenantRef,
|
|
3149
|
+
decidedBy,
|
|
3150
|
+
signal: context.signal,
|
|
3151
|
+
...(context.origin !== undefined ? { origin: context.origin } : {}),
|
|
3152
|
+
...(context.dryRun ? { dryRun: true } : {}),
|
|
3153
|
+
});
|
|
3154
|
+
const granted = context.dryRun
|
|
3155
|
+
? { ...approval, status: 'granted', decidedBy, decidedAt: new Date() }
|
|
3156
|
+
: await withTenant(store, context, (tenant) => tenant.getApproval({ tenantRef, id }));
|
|
3157
|
+
if (!granted)
|
|
3158
|
+
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
3159
|
+
return { approval: approvalOutput(granted), booking };
|
|
3160
|
+
},
|
|
3161
|
+
async sandboxSeed(context, input) {
|
|
3162
|
+
try {
|
|
3163
|
+
return { ...(await seedSlotlockSandbox(store, tenantOf(context), input.scenario)) };
|
|
3164
|
+
}
|
|
3165
|
+
catch (error) {
|
|
3166
|
+
throw sandboxRefusal(error);
|
|
3167
|
+
}
|
|
3168
|
+
},
|
|
3169
|
+
async sandboxReset(context) {
|
|
3170
|
+
try {
|
|
3171
|
+
return { removed: await resetSlotlockSandbox(store, tenantOf(context)) };
|
|
3172
|
+
}
|
|
3173
|
+
catch (error) {
|
|
3174
|
+
throw sandboxRefusal(error);
|
|
3175
|
+
}
|
|
3176
|
+
},
|
|
3177
|
+
async sandboxFastForward(context, input) {
|
|
3178
|
+
try {
|
|
3179
|
+
const forward = await fastForwardSlotlockSandbox(store, tenantOf(context), input.minutes);
|
|
3180
|
+
const { rollPending: _, ...maintenance } = forward.maintenance;
|
|
3181
|
+
return { minutes: forward.minutes, holds: forward.holds, approvals: forward.approvals, maintenance };
|
|
3182
|
+
}
|
|
3183
|
+
catch (error) {
|
|
3184
|
+
throw sandboxRefusal(error);
|
|
3185
|
+
}
|
|
3186
|
+
},
|
|
3187
|
+
async sandboxRace(context, input) {
|
|
3188
|
+
if (!options.agentBackend)
|
|
3189
|
+
throw new SlotlockAgentOperationError('sandbox_unavailable', 409);
|
|
3190
|
+
try {
|
|
3191
|
+
const raced = await raceSlotlockSandbox({
|
|
3192
|
+
backend: options.agentBackend,
|
|
3193
|
+
tenantRef: tenantOf(context),
|
|
3194
|
+
resourceId: input.resource_id,
|
|
3195
|
+
start: new Date(input.starts_at),
|
|
3196
|
+
end: new Date(input.ends_at),
|
|
3197
|
+
...(input.agents !== undefined ? { agents: input.agents } : {}),
|
|
3198
|
+
});
|
|
3199
|
+
return {
|
|
3200
|
+
agents: raced.agents,
|
|
3201
|
+
booked: raced.booked,
|
|
3202
|
+
refused: raced.refused,
|
|
3203
|
+
with_alternatives: raced.withAlternatives,
|
|
3204
|
+
server_errors: raced.serverErrors,
|
|
3205
|
+
p50_ms: raced.p50Ms,
|
|
3206
|
+
p99_ms: raced.p99Ms,
|
|
3207
|
+
max_ms: raced.maxMs,
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
3210
|
+
catch (error) {
|
|
3211
|
+
throw sandboxRefusal(error);
|
|
3212
|
+
}
|
|
3213
|
+
},
|
|
1879
3214
|
async runMaintenance(context) {
|
|
1880
3215
|
assertActive(context);
|
|
1881
3216
|
try {
|
|
1882
|
-
|
|
1883
|
-
const { rollPending: _, zoneShifts, staleSources, noShows, waitlistOffers, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
3217
|
+
const { rollPending: _, zoneShifts, staleSources, noShows, waitlistOffers, approvalsExpired, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
1884
3218
|
batches,
|
|
1885
3219
|
signal: context.signal,
|
|
1886
3220
|
});
|
|
@@ -1890,6 +3224,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1890
3224
|
stale_sources: staleSources,
|
|
1891
3225
|
no_shows: noShows,
|
|
1892
3226
|
waitlist_offers: waitlistOffers,
|
|
3227
|
+
approvals_expired: approvalsExpired,
|
|
1893
3228
|
};
|
|
1894
3229
|
}
|
|
1895
3230
|
catch (error) {
|