slotlock 0.7.0 → 0.9.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 +281 -0
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +861 -55
- package/application-api.d.ts +19 -1
- package/application-api.js +578 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +64 -9
- package/assistant.js +450 -82
- package/availability-store.d.ts +193 -0
- package/availability-store.js +558 -0
- package/booking-rules.d.ts +92 -5
- package/booking-rules.js +243 -15
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +539 -0
- package/dashboard-markdown.d.ts +2 -0
- package/dashboard-markdown.js +157 -0
- package/dashboard-settings-ui.d.ts +1 -0
- package/dashboard-settings-ui.js +78 -0
- package/dashboard.d.ts +84 -11
- package/dashboard.js +1654 -384
- package/ddl.d.ts +5 -1
- package/ddl.js +760 -9
- package/event-feed.d.ts +1 -1
- package/event-feed.js +18 -0
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +57 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +150 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +14 -3
- package/index.js +10 -2
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +19 -0
- package/maintenance.js +55 -1
- package/metrics.js +3 -0
- package/openapi.json +16237 -7795
- package/package.json +1 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- package/self-host.d.ts +1 -1
- package/self-host.js +221 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +367 -24
- package/store.js +1567 -97
- package/types.d.ts +198 -0
package/agent-store-backend.js
CHANGED
|
@@ -5,11 +5,15 @@ import { slotlockCloudEvent } from './webhooks.js';
|
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { SLOTLOCK_DEFAULT_APPROVAL_MINUTES, SLOTLOCK_KEY_APPROVAL_POLICY, SLOTLOCK_MAX_APPROVAL_MINUTES, SlotlockApprovalPolicyError, } from './approvals.js';
|
|
7
7
|
import { SlotlockAgentOperationError, } from './agent-server.js';
|
|
8
|
-
import { bookingSearch, bookingSearchBlocked, bookingSearchReach, } from './booking-rules.js';
|
|
8
|
+
import { bookingSearch, hoursApplyOf, bookingSearchBlocked, bookingSearchReach, } from './booking-rules.js';
|
|
9
9
|
import { findNextAvailable, mergeIntervals } from './engine.js';
|
|
10
|
-
import {
|
|
10
|
+
import { applicableHours, coveredByOpenWindows, dayToDate, epochDay, exceptionDays, exceptionRanges, openWindows, } from './hours.js';
|
|
11
|
+
import { readSlotlockHolidayCalendar } from './holiday-import.js';
|
|
12
|
+
import { SLOTLOCK_OVERRIDE_RESOURCES, } from './availability-store.js';
|
|
13
|
+
import { findEarliestRange } from './ranges.js';
|
|
11
14
|
import { emitVAvailability, parseVAvailability, toJSCalendarEvent } from './interop.js';
|
|
12
|
-
import { calendarEventRollingHorizon, } from './store.js';
|
|
15
|
+
import { SLOTLOCK_INTERNAL_EVENT_OWNER_REF, calendarEventRollingHorizon, } from './store.js';
|
|
16
|
+
import { slotlockResourceSearchWords, slotlockSearchResources } from './resource-search.js';
|
|
13
17
|
import { maintainSlotlockTenant } from './maintenance.js';
|
|
14
18
|
import { fastForwardSlotlockSandbox, raceSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
|
|
15
19
|
import { SLOTLOCK_TEST_TENANT_SUFFIX } from './api-keys.js';
|
|
@@ -38,6 +42,20 @@ const WRITE = { isolation: 'read committed' };
|
|
|
38
42
|
*/
|
|
39
43
|
const APPROVAL_GRANT = Symbol('slotlock.approvalGrant');
|
|
40
44
|
const OCCUPANCY_PROBE = Symbol('slotlock.occupancyProbe');
|
|
45
|
+
/**
|
|
46
|
+
* A booking the tenant's own systems make through the application API (RFC 0004 B7), not an
|
|
47
|
+
* agent's: its events are the tenant's (owner `internal`), outside any key's quota and kept when the
|
|
48
|
+
* key is revoked, and only approval policies that include the application hold it.
|
|
49
|
+
*/
|
|
50
|
+
const TENANT_ACTOR = Symbol('slotlock.tenantActor');
|
|
51
|
+
/** Whether the call books for the tenant's own systems (TENANT_ACTOR), not an agent. */
|
|
52
|
+
function forTenant(context) {
|
|
53
|
+
return context[TENANT_ACTOR] === true;
|
|
54
|
+
}
|
|
55
|
+
/** The tenant's own identity for a retry key: per tenant, so any of its keys replays it. */
|
|
56
|
+
function tenantDigest(context, idempotencyKey) {
|
|
57
|
+
return createHash('sha256').update(`${context.principal.tenantRef}\0tenant\0${idempotencyKey}`).digest('hex');
|
|
58
|
+
}
|
|
41
59
|
/** Thrown out of a dry run's transaction, with its answer, so the transaction rolls back. */
|
|
42
60
|
class DryRunRollback {
|
|
43
61
|
value;
|
|
@@ -225,15 +243,21 @@ function uuidFromDigest(value) {
|
|
|
225
243
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
226
244
|
}
|
|
227
245
|
function agentOwnerRef(context) {
|
|
246
|
+
if (forTenant(context))
|
|
247
|
+
return SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
|
|
228
248
|
return `agent:${createHash('sha256')
|
|
229
249
|
.update(`${context.principal.tenantRef}\0${context.principal.subject}`)
|
|
230
250
|
.digest('hex')}`;
|
|
231
251
|
}
|
|
232
252
|
function agentExternalRef(context, idempotencyKey) {
|
|
253
|
+
if (forTenant(context))
|
|
254
|
+
return `tenant:${tenantDigest(context, idempotencyKey)}`;
|
|
233
255
|
// Already a stable client-visible identity; keep it unchanged.
|
|
234
256
|
return `agent:${agentPrincipalDigest(context, idempotencyKey)}`;
|
|
235
257
|
}
|
|
236
258
|
function agentCommandIdempotencyKey(context, idempotencyKey) {
|
|
259
|
+
if (forTenant(context))
|
|
260
|
+
return `tenant-command:${tenantDigest(context, idempotencyKey)}`;
|
|
237
261
|
// The principal stays in the digest so this remains safe with older tenant-only stores; hashing
|
|
238
262
|
// keeps the subject and raw retry key out of durable rows.
|
|
239
263
|
return `agent-command:${agentPrincipalDigest(context, idempotencyKey)}`;
|
|
@@ -293,7 +317,7 @@ export function projectSlotlockCalendarEvent(event) {
|
|
|
293
317
|
export function slotlockAgentErrorStatus(code) {
|
|
294
318
|
if (code === 'resource_tenant_mismatch' || code.endsWith('_not_found'))
|
|
295
319
|
return 404;
|
|
296
|
-
if (code.startsWith('invalid_'))
|
|
320
|
+
if (code.startsWith('invalid_') || code === 'duration_required')
|
|
297
321
|
return 400;
|
|
298
322
|
if (code === 'owner_event_quota_exceeded' || code === 'owner_command_quota_exceeded')
|
|
299
323
|
return 429;
|
|
@@ -306,6 +330,13 @@ const BOOKING_RULE_CODES = Object.freeze({
|
|
|
306
330
|
slot_step: 'booking_rule_slot_step',
|
|
307
331
|
outside_hours: 'booking_rule_outside_hours',
|
|
308
332
|
uncertain: 'booking_rule_uncertain',
|
|
333
|
+
min_duration: 'booking_rule_min_duration',
|
|
334
|
+
max_duration: 'booking_rule_max_duration',
|
|
335
|
+
duration_not_allowed: 'booking_rule_duration_not_allowed',
|
|
336
|
+
pickup_time: 'booking_rule_pickup_time',
|
|
337
|
+
return_time: 'booking_rule_return_time',
|
|
338
|
+
cancel_cutoff: 'booking_rule_cancel_cutoff',
|
|
339
|
+
reschedule_cutoff: 'booking_rule_reschedule_cutoff',
|
|
309
340
|
});
|
|
310
341
|
function operationError(refusal, details) {
|
|
311
342
|
const code = typeof refusal === 'string'
|
|
@@ -340,6 +371,43 @@ async function overlapDetails(tenantStore, context, refusal) {
|
|
|
340
371
|
...(booking.eventRef !== null && booking.eventOwnerRef === owner ? { conflicting_event_id: booking.eventRef } : {}),
|
|
341
372
|
};
|
|
342
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Slots on a resource whose hours hold a hire only at its ends (`hoursApply`): up to `limit` of
|
|
376
|
+
* `durationMs` from `from` to the window's end, each the range search's earliest after the last,
|
|
377
|
+
* so a slot may run from one local day to another and is judged as a write of it is.
|
|
378
|
+
*/
|
|
379
|
+
async function rangedSlots(tenantStore, context, resource, window, from, durationMs, busy, limit, availabilityRules) {
|
|
380
|
+
const fallback = resource.availabilityRules === undefined && resource.inheritedAvailability === undefined
|
|
381
|
+
? await availabilityRules(context, resource)
|
|
382
|
+
: [];
|
|
383
|
+
const exceptions = (await tenantStore.availabilityExceptionsFor({
|
|
384
|
+
tenantRef: context.principal.tenantRef,
|
|
385
|
+
resourceIds: [resource.id],
|
|
386
|
+
window: { start: new Date(window.start.getTime() - 86_400_000), end: new Date(window.end.getTime() + 86_400_000) },
|
|
387
|
+
})).get(resource.id) ?? [];
|
|
388
|
+
const rules = { ...resource.effectiveBookingRules, allowedDurations: [Math.round(durationMs / 60_000)] };
|
|
389
|
+
const hours = applicableHours(resource, fallback).rules;
|
|
390
|
+
const now = new Date();
|
|
391
|
+
const lastStart = window.end.getTime() - durationMs;
|
|
392
|
+
const slots = [];
|
|
393
|
+
for (let start = from; slots.length < limit && start <= lastStart;) {
|
|
394
|
+
const range = findEarliestRange({
|
|
395
|
+
busy,
|
|
396
|
+
rules,
|
|
397
|
+
timezone: resource.timezone,
|
|
398
|
+
hours,
|
|
399
|
+
exceptions,
|
|
400
|
+
now,
|
|
401
|
+
pickup: { start: new Date(start), end: new Date(lastStart) },
|
|
402
|
+
return: { start: new Date(start + durationMs), end: window.end },
|
|
403
|
+
});
|
|
404
|
+
if (!range)
|
|
405
|
+
break;
|
|
406
|
+
slots.push(range);
|
|
407
|
+
start = range.end.getTime();
|
|
408
|
+
}
|
|
409
|
+
return slots;
|
|
410
|
+
}
|
|
343
411
|
/** Resource and event ids: the UUIDs the store issues and validates. */
|
|
344
412
|
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;
|
|
345
413
|
function parseResourceCursor(value) {
|
|
@@ -400,8 +468,59 @@ async function requireResource(tenantStore, tenantRef, resourceId, visibility) {
|
|
|
400
468
|
return resource;
|
|
401
469
|
}
|
|
402
470
|
/** The caller's shared resource with this id: what an agent may see and book. */
|
|
403
|
-
|
|
404
|
-
|
|
471
|
+
/** The most resources a search or a not-found's candidates look through: more than a tenant may have. */
|
|
472
|
+
const MAX_SEARCHED_RESOURCES = 5_000;
|
|
473
|
+
/** A tenant's resources agents may see, in pages, up to MAX_SEARCHED_RESOURCES. */
|
|
474
|
+
async function sharedResources(tenantStore, tenantRef, filter = {}) {
|
|
475
|
+
const all = [];
|
|
476
|
+
let after;
|
|
477
|
+
while (all.length < MAX_SEARCHED_RESOURCES) {
|
|
478
|
+
const page = await tenantStore.listResources({
|
|
479
|
+
tenantRef,
|
|
480
|
+
visibility: 'shared',
|
|
481
|
+
limit: 1_000,
|
|
482
|
+
...(after !== undefined ? { after } : {}),
|
|
483
|
+
...filter,
|
|
484
|
+
});
|
|
485
|
+
all.push(...page);
|
|
486
|
+
if (page.length < 1_000)
|
|
487
|
+
break;
|
|
488
|
+
after = page.at(-1)?.id;
|
|
489
|
+
}
|
|
490
|
+
return all.slice(0, MAX_SEARCHED_RESOURCES);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* The refusal for an id that names no resource the caller can see. When what was given is not an
|
|
494
|
+
* id at all (a name or a reference, as a model will pass), it names up to three resources that best
|
|
495
|
+
* match it, so the caller can try again with the right id.
|
|
496
|
+
*/
|
|
497
|
+
async function resourceNotFound(tenantStore, tenantRef, given) {
|
|
498
|
+
if (STORE_ID_PATTERN.test(given) || typeof given !== 'string' || given.trim() === '') {
|
|
499
|
+
return new SlotlockAgentOperationError('resource_not_found', 404);
|
|
500
|
+
}
|
|
501
|
+
let words;
|
|
502
|
+
try {
|
|
503
|
+
words = slotlockResourceSearchWords(given);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return new SlotlockAgentOperationError('resource_not_found', 404);
|
|
507
|
+
}
|
|
508
|
+
const best = slotlockSearchResources(await sharedResources(tenantStore, tenantRef), words).slice(0, 3);
|
|
509
|
+
const details = Object.fromEntries(best.map((match, index) => [
|
|
510
|
+
`candidate_${String(index + 1)}`,
|
|
511
|
+
`${match.resource.id} ${match.resource.name ?? match.resource.externalRef ?? ''}`.trim().slice(0, 200),
|
|
512
|
+
]));
|
|
513
|
+
return new SlotlockAgentOperationError('resource_not_found', 404, details);
|
|
514
|
+
}
|
|
515
|
+
async function requireAgentResource(tenantStore, tenantRef, resourceId) {
|
|
516
|
+
try {
|
|
517
|
+
return await requireResource(tenantStore, tenantRef, resourceId, 'shared');
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
if (error.code !== 'resource_not_found')
|
|
521
|
+
throw error;
|
|
522
|
+
throw await resourceNotFound(tenantStore, tenantRef, resourceId);
|
|
523
|
+
}
|
|
405
524
|
}
|
|
406
525
|
/**
|
|
407
526
|
* The store's change feed for live updates (`subscriptions.changes`): each tenant's changes read
|
|
@@ -418,6 +537,31 @@ export function createSlotlockStoreChangeSource(store, sql) {
|
|
|
418
537
|
};
|
|
419
538
|
}
|
|
420
539
|
/** Bind the protocol server to the production store without giving a request control of tenancy. */
|
|
540
|
+
/**
|
|
541
|
+
* Each resource's open hours within `window` (RFC 0004 B1, B2): its own weekly hours, else what it
|
|
542
|
+
* inherits from its location or tenant, else the server's, with the exceptions that reach it applied.
|
|
543
|
+
* One query reads every resource's exceptions.
|
|
544
|
+
*/
|
|
545
|
+
async function hoursWithin(tenantStore, context, resources, window, options) {
|
|
546
|
+
const exceptions = await tenantStore.availabilityExceptionsFor({
|
|
547
|
+
tenantRef: context.principal.tenantRef,
|
|
548
|
+
resourceIds: resources.map((resource) => resource.id),
|
|
549
|
+
window,
|
|
550
|
+
});
|
|
551
|
+
const open = new Map();
|
|
552
|
+
for (const resource of resources) {
|
|
553
|
+
const fallback = resource.availabilityRules === undefined && resource.inheritedAvailability === undefined
|
|
554
|
+
? await options.availabilityRules(context, resource)
|
|
555
|
+
: [];
|
|
556
|
+
open.set(resource.id, openWindows({
|
|
557
|
+
hours: applicableHours(resource, fallback).rules,
|
|
558
|
+
exceptions: exceptions.get(resource.id) ?? [],
|
|
559
|
+
timezone: resource.timezone,
|
|
560
|
+
window,
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
563
|
+
return open;
|
|
564
|
+
}
|
|
421
565
|
export function createSlotlockStoreAgentBackend(store, options) {
|
|
422
566
|
const maxApprovalMinutes = options.maxApprovalMinutes ?? SLOTLOCK_MAX_APPROVAL_MINUTES;
|
|
423
567
|
if (!(Number.isSafeInteger(maxApprovalMinutes) && maxApprovalMinutes >= 1 && maxApprovalMinutes <= SLOTLOCK_MAX_APPROVAL_MINUTES)) {
|
|
@@ -463,6 +607,9 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
463
607
|
source: 'agent-server',
|
|
464
608
|
// The key's own caps, below the server's (RFC 0004 A5).
|
|
465
609
|
...(holderCaps ? { holderCaps } : {}),
|
|
610
|
+
// Booked only once your system records its payment (RFC 0004 C3).
|
|
611
|
+
...(input.payment_required === true ? { paymentRequired: true } : {}),
|
|
612
|
+
...(input.payment !== undefined ? { payment: amountInput(input.payment) } : {}),
|
|
466
613
|
});
|
|
467
614
|
if (result.ok) {
|
|
468
615
|
holds.push({
|
|
@@ -471,6 +618,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
471
618
|
start: result.hold.start.toISOString(),
|
|
472
619
|
end: result.hold.end.toISOString(),
|
|
473
620
|
expires_at: result.hold.expiresAt.toISOString(),
|
|
621
|
+
...(result.hold.paymentRequired ? { payment_required: true, payment_amount: amountOutput(result.hold.paymentAmount) } : {}),
|
|
474
622
|
});
|
|
475
623
|
}
|
|
476
624
|
else {
|
|
@@ -480,6 +628,72 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
480
628
|
return { group_id: groupId, holds, refused };
|
|
481
629
|
});
|
|
482
630
|
},
|
|
631
|
+
async holdTogether(context, rawInput) {
|
|
632
|
+
const input = rawInput;
|
|
633
|
+
const holderRef = agentOwnerRef(context);
|
|
634
|
+
const limits = context.principal.limits;
|
|
635
|
+
const holderCaps = limits?.maxHolds !== undefined || limits?.maxHeldHours !== undefined
|
|
636
|
+
? {
|
|
637
|
+
...(limits.maxHolds !== undefined ? { maxHolds: limits.maxHolds } : {}),
|
|
638
|
+
...(limits.maxHeldHours !== undefined ? { maxHeldHours: limits.maxHeldHours } : {}),
|
|
639
|
+
}
|
|
640
|
+
: undefined;
|
|
641
|
+
// Sorted, so sets over the same resources lock in one order; one group per agent and key.
|
|
642
|
+
const resourceIds = [...new Set(input.resource_ids)].sort();
|
|
643
|
+
if (resourceIds.length < 2)
|
|
644
|
+
throw new SlotlockAgentOperationError('invalid_arguments', 400);
|
|
645
|
+
const groupId = uuidFromDigest(`slotlock-hold-together\0${holderRef}\0${input.idempotency_key}`);
|
|
646
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
647
|
+
for (const resourceId of resourceIds) {
|
|
648
|
+
try {
|
|
649
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
if (!(error instanceof SlotlockAgentOperationError))
|
|
653
|
+
throw error;
|
|
654
|
+
throw new SlotlockAgentOperationError(error.code, error.status, { resource_id: resourceId });
|
|
655
|
+
}
|
|
656
|
+
const result = await tenantStore.acquireHold({
|
|
657
|
+
tenantRef: context.principal.tenantRef,
|
|
658
|
+
principalRef: holderRef,
|
|
659
|
+
holderRef,
|
|
660
|
+
idempotencyKey: agentCommandIdempotencyKey(context, `${input.idempotency_key}\0together\0${resourceId}`),
|
|
661
|
+
groupId,
|
|
662
|
+
resourceId,
|
|
663
|
+
start: new Date(input.start),
|
|
664
|
+
end: new Date(input.end),
|
|
665
|
+
ttlMs: input.ttl_seconds * 1_000,
|
|
666
|
+
source: 'slotlock:together',
|
|
667
|
+
...(holderCaps ? { holderCaps } : {}),
|
|
668
|
+
});
|
|
669
|
+
// All or nothing: thrown, so the holds already made roll back with it.
|
|
670
|
+
if (!result.ok) {
|
|
671
|
+
const refusal = operationError(result);
|
|
672
|
+
throw new SlotlockAgentOperationError(refusal.code, refusal.status, { resource_id: resourceId });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// One time to live for the set: each expires when the earliest does.
|
|
676
|
+
const holds = await tenantStore.holdGroup({
|
|
677
|
+
tenantRef: context.principal.tenantRef,
|
|
678
|
+
holderRef,
|
|
679
|
+
groupId,
|
|
680
|
+
alignExpiry: true,
|
|
681
|
+
});
|
|
682
|
+
if (holds.length !== resourceIds.length)
|
|
683
|
+
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
684
|
+
return {
|
|
685
|
+
group_id: groupId,
|
|
686
|
+
holds: holds.map((hold) => ({
|
|
687
|
+
hold_id: hold.holdId,
|
|
688
|
+
resource_id: hold.resourceId,
|
|
689
|
+
start: hold.start.toISOString(),
|
|
690
|
+
end: hold.end.toISOString(),
|
|
691
|
+
expires_at: hold.expiresAt.toISOString(),
|
|
692
|
+
})),
|
|
693
|
+
expires_at: holds[0].expiresAt.toISOString(),
|
|
694
|
+
};
|
|
695
|
+
});
|
|
696
|
+
},
|
|
483
697
|
async bookPool(context, rawInput) {
|
|
484
698
|
const input = rawInput;
|
|
485
699
|
const content = {
|
|
@@ -564,6 +778,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
564
778
|
[window.end.getTime(), 0],
|
|
565
779
|
]);
|
|
566
780
|
let complete = true;
|
|
781
|
+
const enforced = members.filter((member) => member.effectiveBookingRules?.enforceHours === true);
|
|
782
|
+
const memberHours = enforced.length > 0 ? await hoursWithin(tenantStore, context, enforced, window, options) : new Map();
|
|
567
783
|
for (const member of members) {
|
|
568
784
|
const sources = await requiredSources(options, context, member);
|
|
569
785
|
const freeBusy = await readFreeBusy(tenantStore, {
|
|
@@ -590,8 +806,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
590
806
|
? [{ start: search.window.start, end: new Date(Math.min(search.window.end.getTime(), latestStart)) }]
|
|
591
807
|
: [];
|
|
592
808
|
if (rules?.enforceHours === true && open.length > 0) {
|
|
593
|
-
const
|
|
594
|
-
const windows = expandRules(hours, window, member.timezone);
|
|
809
|
+
const windows = memberHours.get(member.id) ?? [];
|
|
595
810
|
windowsLeft -= windows.length;
|
|
596
811
|
if (windowsLeft < 0)
|
|
597
812
|
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
@@ -665,11 +880,25 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
665
880
|
throw new SlotlockAgentOperationError(error.code, error.status, { resource_id: resourceId });
|
|
666
881
|
}
|
|
667
882
|
}
|
|
883
|
+
// A set held together (RFC 0004 B7) is booked from its holds: each must be the agent's, live,
|
|
884
|
+
// and for exactly this time.
|
|
885
|
+
const held = new Map();
|
|
886
|
+
if (input.hold_group_id !== undefined) {
|
|
887
|
+
const holds = await tenantStore.holdGroup({
|
|
888
|
+
tenantRef: context.principal.tenantRef,
|
|
889
|
+
holderRef: agentOwnerRef(context),
|
|
890
|
+
groupId: input.hold_group_id,
|
|
891
|
+
});
|
|
892
|
+
for (const hold of holds)
|
|
893
|
+
held.set(hold.resourceId, hold.holdId);
|
|
894
|
+
}
|
|
668
895
|
const events = [];
|
|
669
896
|
let replayed = true;
|
|
670
897
|
for (const resourceId of resourceIds) {
|
|
671
898
|
const key = `${input.idempotency_key}\0${resourceId}`;
|
|
899
|
+
const holdId = held.get(resourceId);
|
|
672
900
|
const result = await tenantStore.putCalendarEvent({
|
|
901
|
+
...(holdId !== undefined ? { fromHold: { holdId, holderRef: agentOwnerRef(context) } } : {}),
|
|
673
902
|
tenantRef: context.principal.tenantRef,
|
|
674
903
|
ownerRef: agentOwnerRef(context),
|
|
675
904
|
externalRef: agentExternalRef(context, key),
|
|
@@ -815,6 +1044,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
815
1044
|
const labels = input.labels;
|
|
816
1045
|
const location = input.location;
|
|
817
1046
|
const group = input.group;
|
|
1047
|
+
let words;
|
|
1048
|
+
try {
|
|
1049
|
+
words = typeof input.query === 'string' ? slotlockResourceSearchWords(input.query) : undefined;
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
throw new SlotlockAgentOperationError('invalid_arguments', 400);
|
|
1053
|
+
}
|
|
818
1054
|
// What a resource says about itself, for an agent to show and choose by; its attributes are
|
|
819
1055
|
// the integrator's, and stay on the application API.
|
|
820
1056
|
const output = (resource) => ({
|
|
@@ -845,19 +1081,34 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
845
1081
|
const places = location === undefined
|
|
846
1082
|
? null
|
|
847
1083
|
: locationAndBelow(await tenantStore.listLocations({ tenantRef: context.principal.tenantRef }), location);
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1084
|
+
const members = (await tenantStore.listPoolMembers({
|
|
1085
|
+
tenantRef: context.principal.tenantRef,
|
|
1086
|
+
pool: input.pool,
|
|
1087
|
+
visibility: 'shared',
|
|
1088
|
+
})).filter((resource) => (kind === undefined || (resource.kind ?? 'other') === kind) &&
|
|
1089
|
+
(labels === undefined || slotlockResourceHasLabels(resource.labels, labels)) &&
|
|
1090
|
+
(places === null || (resource.location !== undefined && places.has(resource.location))) &&
|
|
1091
|
+
(group === undefined || resource.groups?.includes(group) === true));
|
|
1092
|
+
// Searched, the best matches first; else in the order slotlock_book_pool tries them.
|
|
1093
|
+
const shown = words === undefined
|
|
1094
|
+
? members
|
|
1095
|
+
: slotlockSearchResources(members, words)
|
|
1096
|
+
.slice(0, input.limit)
|
|
1097
|
+
.map((match) => match.resource);
|
|
1098
|
+
return { resources: shown.map(output), next_cursor: null };
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
if (words !== undefined) {
|
|
1102
|
+
// A search ranks every resource the filters allow (as many as a tenant may have), best first.
|
|
1103
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
1104
|
+
const candidates = await sharedResources(tenantStore, context.principal.tenantRef, {
|
|
1105
|
+
...(kind !== undefined ? { kind } : {}),
|
|
1106
|
+
...(labels !== undefined ? { labels } : {}),
|
|
1107
|
+
...(location !== undefined ? { location } : {}),
|
|
1108
|
+
...(group !== undefined ? { group } : {}),
|
|
1109
|
+
});
|
|
1110
|
+
const best = slotlockSearchResources(candidates, words).slice(0, input.limit);
|
|
1111
|
+
return { resources: best.map((match) => output(match.resource)), next_cursor: null };
|
|
861
1112
|
});
|
|
862
1113
|
}
|
|
863
1114
|
return withTenant(store, context, async (tenantStore) => {
|
|
@@ -921,6 +1172,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
921
1172
|
timezone: resource.timezone,
|
|
922
1173
|
window,
|
|
923
1174
|
now: new Date(),
|
|
1175
|
+
durationMs,
|
|
924
1176
|
});
|
|
925
1177
|
const sources = await requiredSources(options, context, resource);
|
|
926
1178
|
const freeBusy = await readFreeBusy(tenantStore, {
|
|
@@ -935,8 +1187,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
935
1187
|
return { slots: [], certain: false };
|
|
936
1188
|
if (!search)
|
|
937
1189
|
return { slots: [], certain: true };
|
|
938
|
-
|
|
939
|
-
|
|
1190
|
+
// Where the hours hold a hire only at its ends (hoursApply), a slot may run from one local
|
|
1191
|
+
// day to another: each from the range search, judged as a write of it is.
|
|
1192
|
+
if (hoursApplyOf(resource.effectiveBookingRules) === 'ends') {
|
|
1193
|
+
const ranged = await rangedSlots(tenantStore, context, resource, window, search.window.start.getTime(), durationMs, freeBusy.busy, input.limit, options.availabilityRules);
|
|
1194
|
+
return { slots: ranged.map((slot) => ({ start: slot.start.toISOString(), end: slot.end.toISOString() })), certain: true };
|
|
1195
|
+
}
|
|
1196
|
+
const windows = (await hoursWithin(tenantStore, context, [resource], search.window, options)).get(resource.id) ?? [];
|
|
940
1197
|
if (windows.length > MAX_REQUEST_RULE_WINDOWS)
|
|
941
1198
|
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
942
1199
|
const busy = bookingSearchBlocked(freeBusy.busy, search);
|
|
@@ -957,6 +1214,96 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
957
1214
|
return { slots, certain: true };
|
|
958
1215
|
});
|
|
959
1216
|
},
|
|
1217
|
+
async defaultDuration(context, resourceIds) {
|
|
1218
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
1219
|
+
const lengths = new Set();
|
|
1220
|
+
for (const resourceId of resourceIds) {
|
|
1221
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
1222
|
+
lengths.add(resource.effectiveBookingRules?.defaultDurationMinutes);
|
|
1223
|
+
}
|
|
1224
|
+
const [only] = [...lengths];
|
|
1225
|
+
return lengths.size === 1 && only !== undefined ? only : null;
|
|
1226
|
+
});
|
|
1227
|
+
},
|
|
1228
|
+
async findAvailableRanges(context, input) {
|
|
1229
|
+
const tenantRef = context.principal.tenantRef;
|
|
1230
|
+
const pickup = { start: new Date(input.pickup_start), end: new Date(input.pickup_end) };
|
|
1231
|
+
const days = input.days;
|
|
1232
|
+
const back = input.return_start !== undefined
|
|
1233
|
+
? { start: new Date(input.return_start), end: new Date(input.return_end) }
|
|
1234
|
+
: undefined;
|
|
1235
|
+
const limit = input.limit;
|
|
1236
|
+
const charge = busyBudget();
|
|
1237
|
+
const now = new Date();
|
|
1238
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
1239
|
+
let resources;
|
|
1240
|
+
if (typeof input.pool === 'string') {
|
|
1241
|
+
resources = await tenantStore.listPoolMembers({ tenantRef, pool: input.pool, visibility: 'shared', ordered: true });
|
|
1242
|
+
if (resources.length === 0)
|
|
1243
|
+
throw new SlotlockAgentOperationError('pool_not_found', 404);
|
|
1244
|
+
resources = resources.slice(0, 100);
|
|
1245
|
+
}
|
|
1246
|
+
else {
|
|
1247
|
+
resources = [];
|
|
1248
|
+
for (const resourceId of distinctResourceIds(input)) {
|
|
1249
|
+
resources.push(await requireAgentResource(tenantStore, tenantRef, resourceId));
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
// What a range may reach: the pick-up window, the longest it may run, and its buffers.
|
|
1253
|
+
const lastEnd = back ? back.end.getTime() : pickup.end.getTime() + ((days ?? 1) + 1) * 86_400_000;
|
|
1254
|
+
const reach = { start: new Date(pickup.start.getTime() - 86_400_000), end: new Date(lastEnd + 8 * 86_400_000) };
|
|
1255
|
+
const exceptions = await tenantStore.availabilityExceptionsFor({
|
|
1256
|
+
tenantRef,
|
|
1257
|
+
resourceIds: resources.map((resource) => resource.id),
|
|
1258
|
+
window: reach,
|
|
1259
|
+
});
|
|
1260
|
+
const ranges = [];
|
|
1261
|
+
let complete = true;
|
|
1262
|
+
for (const resource of resources) {
|
|
1263
|
+
const sources = await requiredSources(options, context, resource);
|
|
1264
|
+
const freeBusy = await readFreeBusy(tenantStore, {
|
|
1265
|
+
tenantRef,
|
|
1266
|
+
resourceId: resource.id,
|
|
1267
|
+
window: reach,
|
|
1268
|
+
requiredSources: [...sources],
|
|
1269
|
+
...(options.coverageMaxAgeMs !== undefined ? { coverageMaxAgeMs: options.coverageMaxAgeMs } : {}),
|
|
1270
|
+
});
|
|
1271
|
+
charge(freeBusy.busy.length);
|
|
1272
|
+
if (freeBusy.coverage.state !== 'complete') {
|
|
1273
|
+
complete = false;
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
const fallback = resource.availabilityRules === undefined && resource.inheritedAvailability === undefined
|
|
1277
|
+
? await options.availabilityRules(context, resource)
|
|
1278
|
+
: [];
|
|
1279
|
+
const found = findEarliestRange({
|
|
1280
|
+
busy: freeBusy.busy,
|
|
1281
|
+
rules: resource.effectiveBookingRules,
|
|
1282
|
+
timezone: resource.timezone,
|
|
1283
|
+
hours: applicableHours(resource, fallback).rules,
|
|
1284
|
+
exceptions: exceptions.get(resource.id) ?? [],
|
|
1285
|
+
now,
|
|
1286
|
+
pickup,
|
|
1287
|
+
...(back ? { return: back } : {}),
|
|
1288
|
+
...(days !== undefined ? { days } : {}),
|
|
1289
|
+
});
|
|
1290
|
+
if (found)
|
|
1291
|
+
ranges.push({ resource_id: resource.id, start: found.start.toISOString(), end: found.end.toISOString() });
|
|
1292
|
+
}
|
|
1293
|
+
const window = { start: pickup.start, end: new Date(lastEnd) };
|
|
1294
|
+
const answer = {
|
|
1295
|
+
ranges: complete
|
|
1296
|
+
? ranges
|
|
1297
|
+
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start) || a.resource_id.localeCompare(b.resource_id))
|
|
1298
|
+
.slice(0, limit)
|
|
1299
|
+
: [],
|
|
1300
|
+
coverage: coverageProjection(window, complete),
|
|
1301
|
+
};
|
|
1302
|
+
return typeof input.pool === 'string'
|
|
1303
|
+
? { ...answer, selected_resource_ids: resources.map((resource) => resource.id) }
|
|
1304
|
+
: answer;
|
|
1305
|
+
});
|
|
1306
|
+
},
|
|
960
1307
|
async findNextAvailable(context, input) {
|
|
961
1308
|
const window = interval(input);
|
|
962
1309
|
const resourceIds = distinctResourceIds(input);
|
|
@@ -974,6 +1321,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
974
1321
|
timezone: resource.timezone,
|
|
975
1322
|
window,
|
|
976
1323
|
now: new Date(),
|
|
1324
|
+
durationMs,
|
|
977
1325
|
});
|
|
978
1326
|
const sources = await requiredSources(options, context, resource);
|
|
979
1327
|
const freeBusy = await readFreeBusy(tenantStore, {
|
|
@@ -993,17 +1341,23 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
993
1341
|
}
|
|
994
1342
|
if (!search)
|
|
995
1343
|
continue;
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
windows
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1344
|
+
let candidate;
|
|
1345
|
+
if (hoursApplyOf(resource.effectiveBookingRules) === 'ends') {
|
|
1346
|
+
// A slot may run from one local day to another: the range search's earliest.
|
|
1347
|
+
[candidate = null] = await rangedSlots(tenantStore, context, resource, window, search.window.start.getTime(), durationMs, freeBusy.busy, 1, options.availabilityRules);
|
|
1348
|
+
}
|
|
1349
|
+
else {
|
|
1350
|
+
const windows = (await hoursWithin(tenantStore, context, [resource], search.window, options)).get(resource.id) ?? [];
|
|
1351
|
+
windowsLeft -= windows.length;
|
|
1352
|
+
if (windowsLeft < 0)
|
|
1353
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
1354
|
+
candidate = findNextAvailable({
|
|
1355
|
+
busy: bookingSearchBlocked(freeBusy.busy, search),
|
|
1356
|
+
windows,
|
|
1357
|
+
durationMs,
|
|
1358
|
+
...(search.align ? { align: search.align } : {}),
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1007
1361
|
if (candidate &&
|
|
1008
1362
|
(!best ||
|
|
1009
1363
|
candidate.start.getTime() < best.interval.start.getTime() ||
|
|
@@ -1066,6 +1420,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
1066
1420
|
...(typeof input.hold_id === 'string'
|
|
1067
1421
|
? { fromHold: { holdId: input.hold_id, holderRef: agentOwnerRef(context) } }
|
|
1068
1422
|
: {}),
|
|
1423
|
+
// Tell no one about it (RFC 0004 P3).
|
|
1424
|
+
...(input.notify === false ? { notify: false } : {}),
|
|
1069
1425
|
});
|
|
1070
1426
|
if (!result.ok)
|
|
1071
1427
|
throw operationError(result, await overlapDetails(tenantStore, context, result));
|
|
@@ -1237,6 +1593,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
1237
1593
|
...(current ? { expectedResourceId: current.resourceId } : {}),
|
|
1238
1594
|
idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
|
|
1239
1595
|
expectedRevision: input.expected_revision,
|
|
1596
|
+
// An agent is held to the resource's cancel cutoff (RFC 0004 B5).
|
|
1597
|
+
enforceBookingRules: true,
|
|
1240
1598
|
});
|
|
1241
1599
|
if (!result.ok)
|
|
1242
1600
|
throw operationError(result);
|
|
@@ -1254,6 +1612,42 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
1254
1612
|
};
|
|
1255
1613
|
return withApprovalPolicies(store, options, maxApprovalMinutes, backend);
|
|
1256
1614
|
}
|
|
1615
|
+
/** An exception as the API answers it. */
|
|
1616
|
+
function exceptionOutput(exception) {
|
|
1617
|
+
return {
|
|
1618
|
+
id: exception.id,
|
|
1619
|
+
location: exception.location ?? null,
|
|
1620
|
+
resource_id: exception.resourceId ?? null,
|
|
1621
|
+
starts_on: exception.startsOn,
|
|
1622
|
+
ends_on: exception.endsOn,
|
|
1623
|
+
outcome: exception.outcome,
|
|
1624
|
+
hours: exception.hours?.map((window) => ({ start_minutes: window.startMinutes, duration_minutes: window.durationMinutes })) ??
|
|
1625
|
+
null,
|
|
1626
|
+
label: exception.label,
|
|
1627
|
+
source: exception.source,
|
|
1628
|
+
calendar_id: exception.calendarId ?? null,
|
|
1629
|
+
created_at: exception.createdAt.toISOString(),
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
function holidayCalendarOutput(calendar) {
|
|
1633
|
+
return {
|
|
1634
|
+
calendar_id: calendar.id,
|
|
1635
|
+
name: calendar.name,
|
|
1636
|
+
location: calendar.location ?? null,
|
|
1637
|
+
resource_id: calendar.resourceId ?? null,
|
|
1638
|
+
url: calendar.url ?? null,
|
|
1639
|
+
imported_at: calendar.importedAt?.toISOString() ?? null,
|
|
1640
|
+
next_import_at: calendar.nextImportAt?.toISOString() ?? null,
|
|
1641
|
+
last_error: calendar.lastError ?? null,
|
|
1642
|
+
closures: calendar.closures,
|
|
1643
|
+
created_at: calendar.createdAt.toISOString(),
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
/** Days the exports and imports of hours reach: a year on from yesterday. */
|
|
1647
|
+
function exportDays() {
|
|
1648
|
+
const today = Math.floor(Date.now() / 86_400_000);
|
|
1649
|
+
return { from: dayToDate(today - 1), to: dayToDate(today + 366) };
|
|
1650
|
+
}
|
|
1257
1651
|
function storeRefusal(error) {
|
|
1258
1652
|
const code = error?.code;
|
|
1259
1653
|
if (typeof code !== 'string')
|
|
@@ -1280,9 +1674,12 @@ function storeRefusal(error) {
|
|
|
1280
1674
|
code === 'pool_order_limit_reached' ||
|
|
1281
1675
|
code === 'composite_too_deep' ||
|
|
1282
1676
|
code === 'composite_parts_limit' ||
|
|
1283
|
-
code === 'composite_busy'
|
|
1677
|
+
code === 'composite_busy' ||
|
|
1678
|
+
code === 'availability_exceptions_limit_reached') {
|
|
1284
1679
|
return new SlotlockAgentOperationError(code, 409);
|
|
1285
1680
|
}
|
|
1681
|
+
if (code === 'holiday_calendar_unreachable')
|
|
1682
|
+
return new SlotlockAgentOperationError(code, 400);
|
|
1286
1683
|
if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
|
|
1287
1684
|
return new SlotlockAgentOperationError(code, 400);
|
|
1288
1685
|
}
|
|
@@ -1331,6 +1728,7 @@ function approvalPolicyOutput(policy) {
|
|
|
1331
1728
|
outcome: policy.outcome,
|
|
1332
1729
|
approval_ttl_minutes: policy.approvalTtlMinutes,
|
|
1333
1730
|
lead_minutes: policy.leadMinutes,
|
|
1731
|
+
include_application: policy.includeApplication,
|
|
1334
1732
|
created_at: policy.createdAt.toISOString(),
|
|
1335
1733
|
updated_at: policy.updatedAt.toISOString(),
|
|
1336
1734
|
};
|
|
@@ -1370,6 +1768,16 @@ function approvalOutput(approval) {
|
|
|
1370
1768
|
created_at: approval.createdAt.toISOString(),
|
|
1371
1769
|
};
|
|
1372
1770
|
}
|
|
1771
|
+
/** A store refusal as the application API answers it, a broken booking rule by its own code. */
|
|
1772
|
+
function applicationRuleRefusal(result) {
|
|
1773
|
+
return result.code === 'booking_rule_violated' && result.rule
|
|
1774
|
+
? new SlotlockAgentOperationError(`booking_rule_${result.rule}`, 409)
|
|
1775
|
+
: applicationRefusal(result.code);
|
|
1776
|
+
}
|
|
1777
|
+
/** A recorded cancellation fee as the API answers it (RFC 0004 B5). */
|
|
1778
|
+
function feeOutput(fee) {
|
|
1779
|
+
return fee ? { amount_minor: fee.amountMinor, currency: fee.currency } : null;
|
|
1780
|
+
}
|
|
1373
1781
|
function applicationRefusal(code) {
|
|
1374
1782
|
if (code === 'resource_tenant_mismatch') {
|
|
1375
1783
|
return new SlotlockAgentOperationError('resource_not_found', 404);
|
|
@@ -1393,6 +1801,8 @@ function waitlistOutput(entry) {
|
|
|
1393
1801
|
hold_id: entry.holdId,
|
|
1394
1802
|
offered_resource_id: entry.offeredResourceId,
|
|
1395
1803
|
offered_at: entry.offeredAt?.toISOString() ?? null,
|
|
1804
|
+
duration_minutes: entry.durationMs === null ? null : Math.round(entry.durationMs / 60_000),
|
|
1805
|
+
decided_at: entry.decidedAt?.toISOString() ?? null,
|
|
1396
1806
|
created_at: entry.createdAt.toISOString(),
|
|
1397
1807
|
};
|
|
1398
1808
|
}
|
|
@@ -1409,6 +1819,8 @@ function keyApprovalPolicy() {
|
|
|
1409
1819
|
outcome: 'require_approval',
|
|
1410
1820
|
approvalTtlMinutes: SLOTLOCK_DEFAULT_APPROVAL_MINUTES,
|
|
1411
1821
|
leadMinutes: 0,
|
|
1822
|
+
// The key's own approval holds whatever it books, for the tenant too.
|
|
1823
|
+
includeApplication: true,
|
|
1412
1824
|
createdAt: new Date(0),
|
|
1413
1825
|
updatedAt: new Date(0),
|
|
1414
1826
|
};
|
|
@@ -1477,9 +1889,9 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1477
1889
|
/** Whether [start, end) on the resource falls outside its bookable hours. */
|
|
1478
1890
|
async function outsideHours(tenantStore, context, resourceId, start, end) {
|
|
1479
1891
|
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
1480
|
-
const
|
|
1481
|
-
const windows =
|
|
1482
|
-
return !windows
|
|
1892
|
+
const reach = { start: new Date(start.getTime() - 86_400_000), end: new Date(end.getTime() + 86_400_000) };
|
|
1893
|
+
const windows = (await hoursWithin(tenantStore, context, [resource], reach, options)).get(resource.id) ?? [];
|
|
1894
|
+
return !coveredByOpenWindows(windows, { start, end });
|
|
1483
1895
|
}
|
|
1484
1896
|
/** The write's ask and the policies that may cover it, or null when none can. */
|
|
1485
1897
|
async function plan(tenantStore, context, operation, input) {
|
|
@@ -1558,7 +1970,10 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1558
1970
|
}
|
|
1559
1971
|
const minutes = (asked.end.getTime() - asked.start.getTime()) / 60_000;
|
|
1560
1972
|
// 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) =>
|
|
1973
|
+
const policies = (await tenantStore.approvalPoliciesFor({ tenantRef, resourceIds: asked.resourceIds })).filter((policy) =>
|
|
1974
|
+
// The tenant's own bookings are held only by policies that include the application.
|
|
1975
|
+
(!forTenant(context) || policy.includeApplication === true) &&
|
|
1976
|
+
(policy.conditions.kinds === undefined || policy.conditions.kinds.includes(asked.kind)) &&
|
|
1562
1977
|
(policy.conditions.longerThanMinutes === undefined || minutes > policy.conditions.longerThanMinutes));
|
|
1563
1978
|
if (policies.length === 0 && !keyPolicy)
|
|
1564
1979
|
return null;
|
|
@@ -1682,6 +2097,8 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1682
2097
|
: {}),
|
|
1683
2098
|
...(context.origin !== undefined ? { origin: context.origin } : {}),
|
|
1684
2099
|
...(ask.pool !== undefined ? { pool: ask.pool } : {}),
|
|
2100
|
+
// Granted, it is made as the tenant's again, never as the key's agent.
|
|
2101
|
+
...(forTenant(context) ? { for: 'tenant' } : {}),
|
|
1685
2102
|
},
|
|
1686
2103
|
requesterRef: principal.subject,
|
|
1687
2104
|
requestKey,
|
|
@@ -1751,6 +2168,7 @@ export async function runSlotlockApprovalGrant(agentBackend, approval, grant) {
|
|
|
1751
2168
|
decidedBy: grant.decidedBy,
|
|
1752
2169
|
...(typeof requester.pool === 'string' ? { pool: requester.pool } : {}),
|
|
1753
2170
|
},
|
|
2171
|
+
...(requester.for === 'tenant' ? { [TENANT_ACTOR]: true } : {}),
|
|
1754
2172
|
};
|
|
1755
2173
|
const method = approval.operation === 'slotlock_book_together'
|
|
1756
2174
|
? agentBackend.bookTogether
|
|
@@ -1838,8 +2256,31 @@ function bookingRulesOutput(rules) {
|
|
|
1838
2256
|
...(rules.enforceHours !== undefined ? { enforce_hours: rules.enforceHours } : {}),
|
|
1839
2257
|
...(rules.checkInMinutes !== undefined ? { check_in_minutes: rules.checkInMinutes } : {}),
|
|
1840
2258
|
...(rules.requireCertain !== undefined ? { require_certain: rules.requireCertain } : {}),
|
|
2259
|
+
...(rules.minDurationMinutes !== undefined ? { min_duration_minutes: rules.minDurationMinutes } : {}),
|
|
2260
|
+
...(rules.maxDurationMinutes !== undefined ? { max_duration_minutes: rules.maxDurationMinutes } : {}),
|
|
2261
|
+
...(rules.allowedDurations !== undefined ? { allowed_durations: [...rules.allowedDurations] } : {}),
|
|
2262
|
+
...(rules.defaultDurationMinutes !== undefined ? { default_duration_minutes: rules.defaultDurationMinutes } : {}),
|
|
2263
|
+
...(rules.pickupTimes !== undefined ? { pickup_times: dayWindowsOutput(rules.pickupTimes) } : {}),
|
|
2264
|
+
...(rules.returnTimes !== undefined ? { return_times: dayWindowsOutput(rules.returnTimes) } : {}),
|
|
2265
|
+
...(rules.hoursApply !== undefined ? { hours_apply: rules.hoursApply } : {}),
|
|
2266
|
+
...(rules.cancelCutoffMinutes !== undefined ? { cancel_cutoff_minutes: rules.cancelCutoffMinutes } : {}),
|
|
2267
|
+
...(rules.rescheduleCutoffMinutes !== undefined ? { reschedule_cutoff_minutes: rules.rescheduleCutoffMinutes } : {}),
|
|
2268
|
+
...(rules.cancellationFee !== undefined
|
|
2269
|
+
? {
|
|
2270
|
+
cancellation_fee: {
|
|
2271
|
+
amount_minor: rules.cancellationFee.amountMinor,
|
|
2272
|
+
currency: rules.cancellationFee.currency,
|
|
2273
|
+
...(rules.cancellationFee.withinMinutes !== undefined ? { within_minutes: rules.cancellationFee.withinMinutes } : {}),
|
|
2274
|
+
},
|
|
2275
|
+
}
|
|
2276
|
+
: {}),
|
|
1841
2277
|
};
|
|
1842
2278
|
}
|
|
2279
|
+
const dayWindowsOutput = (windows) => windows.map((window) => ({ start_minutes: window.startMinutes, duration_minutes: window.durationMinutes }));
|
|
2280
|
+
const dayWindowsInput = (value) => value.map((window) => ({
|
|
2281
|
+
startMinutes: window.start_minutes,
|
|
2282
|
+
durationMinutes: window.duration_minutes,
|
|
2283
|
+
}));
|
|
1843
2284
|
function bookingRulesInput(value) {
|
|
1844
2285
|
if (value === null || value === undefined)
|
|
1845
2286
|
return null;
|
|
@@ -1864,6 +2305,34 @@ function bookingRulesInput(value) {
|
|
|
1864
2305
|
rules.checkInMinutes = input.check_in_minutes;
|
|
1865
2306
|
if (input.require_certain !== undefined)
|
|
1866
2307
|
rules.requireCertain = input.require_certain;
|
|
2308
|
+
if (input.min_duration_minutes !== undefined)
|
|
2309
|
+
rules.minDurationMinutes = input.min_duration_minutes;
|
|
2310
|
+
if (input.max_duration_minutes !== undefined)
|
|
2311
|
+
rules.maxDurationMinutes = input.max_duration_minutes;
|
|
2312
|
+
if (input.allowed_durations !== undefined)
|
|
2313
|
+
rules.allowedDurations = [...input.allowed_durations];
|
|
2314
|
+
if (input.default_duration_minutes !== undefined) {
|
|
2315
|
+
rules.defaultDurationMinutes = input.default_duration_minutes;
|
|
2316
|
+
}
|
|
2317
|
+
if (input.pickup_times !== undefined)
|
|
2318
|
+
rules.pickupTimes = dayWindowsInput(input.pickup_times);
|
|
2319
|
+
if (input.return_times !== undefined)
|
|
2320
|
+
rules.returnTimes = dayWindowsInput(input.return_times);
|
|
2321
|
+
if (input.hours_apply === 'whole' || input.hours_apply === 'ends')
|
|
2322
|
+
rules.hoursApply = input.hours_apply;
|
|
2323
|
+
if (input.cancel_cutoff_minutes !== undefined)
|
|
2324
|
+
rules.cancelCutoffMinutes = input.cancel_cutoff_minutes;
|
|
2325
|
+
if (input.reschedule_cutoff_minutes !== undefined) {
|
|
2326
|
+
rules.rescheduleCutoffMinutes = input.reschedule_cutoff_minutes;
|
|
2327
|
+
}
|
|
2328
|
+
if (input.cancellation_fee !== undefined) {
|
|
2329
|
+
const fee = input.cancellation_fee;
|
|
2330
|
+
rules.cancellationFee = {
|
|
2331
|
+
amountMinor: fee.amount_minor,
|
|
2332
|
+
currency: fee.currency,
|
|
2333
|
+
...(fee.within_minutes !== undefined ? { withinMinutes: fee.within_minutes } : {}),
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
1867
2336
|
return rules;
|
|
1868
2337
|
}
|
|
1869
2338
|
function resourceOutput(resource) {
|
|
@@ -1886,6 +2355,13 @@ function resourceOutput(resource) {
|
|
|
1886
2355
|
location: resource.location ?? null,
|
|
1887
2356
|
groups: resource.groups ?? [],
|
|
1888
2357
|
parent_id: resource.parentId ?? null,
|
|
2358
|
+
inherited_availability: resource.inheritedAvailability
|
|
2359
|
+
? {
|
|
2360
|
+
source: resource.inheritedAvailability.source,
|
|
2361
|
+
location: resource.inheritedAvailability.location ?? null,
|
|
2362
|
+
rules: rulesOutput(resource.inheritedAvailability.rules) ?? [],
|
|
2363
|
+
}
|
|
2364
|
+
: null,
|
|
1889
2365
|
part_index: resource.partIndex ?? null,
|
|
1890
2366
|
};
|
|
1891
2367
|
}
|
|
@@ -1941,6 +2417,36 @@ function holdOutput(hold) {
|
|
|
1941
2417
|
end: hold.end.toISOString(),
|
|
1942
2418
|
expires_at: hold.expiresAt.toISOString(),
|
|
1943
2419
|
source: hold.source,
|
|
2420
|
+
...(hold.paymentRequired
|
|
2421
|
+
? {
|
|
2422
|
+
payment_required: true,
|
|
2423
|
+
payment_ref: hold.paymentRef ?? null,
|
|
2424
|
+
paid_at: hold.paidAt?.toISOString() ?? null,
|
|
2425
|
+
payment_amount: amountOutput(hold.paymentAmount),
|
|
2426
|
+
paid_amount: amountOutput(hold.paidAmount),
|
|
2427
|
+
}
|
|
2428
|
+
: {}),
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
function amountOutput(amount) {
|
|
2432
|
+
return amount ? { amount_minor: amount.amountMinor, currency: amount.currency } : null;
|
|
2433
|
+
}
|
|
2434
|
+
function amountInput(value) {
|
|
2435
|
+
const amount = value;
|
|
2436
|
+
return amount ? { amountMinor: amount.amount_minor, currency: amount.currency } : undefined;
|
|
2437
|
+
}
|
|
2438
|
+
function contactOutput(contact) {
|
|
2439
|
+
return {
|
|
2440
|
+
contact_ref: contact.ref,
|
|
2441
|
+
name: contact.name,
|
|
2442
|
+
email: contact.email,
|
|
2443
|
+
phone: contact.phone,
|
|
2444
|
+
whatsapp: contact.whatsapp,
|
|
2445
|
+
consent: contact.consent,
|
|
2446
|
+
consent_at: Object.fromEntries(Object.entries(contact.consentAt).map(([channel, at]) => [channel, at.toISOString()])),
|
|
2447
|
+
locale: contact.locale,
|
|
2448
|
+
created_at: contact.createdAt.toISOString(),
|
|
2449
|
+
updated_at: contact.updatedAt.toISOString(),
|
|
1944
2450
|
};
|
|
1945
2451
|
}
|
|
1946
2452
|
function reservationOutput(reservation) {
|
|
@@ -1954,6 +2460,9 @@ function reservationOutput(reservation) {
|
|
|
1954
2460
|
buffer_after_minutes: Math.ceil(reservation.bufferAfterMs / 60_000),
|
|
1955
2461
|
revision: reservation.revision,
|
|
1956
2462
|
source: reservation.source,
|
|
2463
|
+
...(reservation.contactRef ? { contact_ref: reservation.contactRef } : {}),
|
|
2464
|
+
...(reservation.notify === false ? { notify: false } : {}),
|
|
2465
|
+
...(reservation.reminderMinutes ? { reminder_minutes: reservation.reminderMinutes } : {}),
|
|
1957
2466
|
};
|
|
1958
2467
|
}
|
|
1959
2468
|
/**
|
|
@@ -2288,15 +2797,29 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2288
2797
|
async exportResourceAvailability(context, input) {
|
|
2289
2798
|
return withTenant(store, context, async (tenant) => {
|
|
2290
2799
|
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
2291
|
-
|
|
2800
|
+
const rules = resource.availabilityRules ?? resource.inheritedAvailability?.rules;
|
|
2801
|
+
if (rules === undefined)
|
|
2292
2802
|
return { icalendar: null };
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
}),
|
|
2803
|
+
// The year ahead's closures and special hours, as EXDATE and RDATE (RFC 0004 B1).
|
|
2804
|
+
const span = exportDays();
|
|
2805
|
+
const window = {
|
|
2806
|
+
start: new Date(epochDay(span.from) * 86_400_000),
|
|
2807
|
+
end: new Date((epochDay(span.to) + 1) * 86_400_000),
|
|
2299
2808
|
};
|
|
2809
|
+
const exceptions = await tenant.availabilityExceptionsFor({
|
|
2810
|
+
tenantRef: tenantOf(context),
|
|
2811
|
+
resourceIds: [resource.id],
|
|
2812
|
+
window,
|
|
2813
|
+
});
|
|
2814
|
+
const icalendar = emitVAvailability({
|
|
2815
|
+
rules,
|
|
2816
|
+
timezone: resource.timezone,
|
|
2817
|
+
uid: `${resource.id}-hours`,
|
|
2818
|
+
days: exceptionDays(exceptions.get(resource.id) ?? [], span.from, span.to),
|
|
2819
|
+
});
|
|
2820
|
+
if (icalendar.length > 200_000)
|
|
2821
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
2822
|
+
return { icalendar };
|
|
2300
2823
|
});
|
|
2301
2824
|
},
|
|
2302
2825
|
async importResourceAvailability(context, input) {
|
|
@@ -2326,6 +2849,14 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2326
2849
|
id,
|
|
2327
2850
|
rules: parsed.rules,
|
|
2328
2851
|
});
|
|
2852
|
+
if (resource) {
|
|
2853
|
+
// The days it names replace those an earlier import of its hours made.
|
|
2854
|
+
await tenant.replaceImportedExceptions({
|
|
2855
|
+
tenantRef: tenantOf(context),
|
|
2856
|
+
resourceId: id,
|
|
2857
|
+
ranges: exceptionRanges(parsed.days),
|
|
2858
|
+
});
|
|
2859
|
+
}
|
|
2329
2860
|
}
|
|
2330
2861
|
catch (error) {
|
|
2331
2862
|
throw storeRefusal(error);
|
|
@@ -2460,11 +2991,142 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2460
2991
|
const rules = rulesInput(input.rules);
|
|
2461
2992
|
return withTenant(store, context, async (tenant) => {
|
|
2462
2993
|
try {
|
|
2994
|
+
const result = await tenant.storeTenantAvailability({
|
|
2995
|
+
tenantRef: tenantOf(context),
|
|
2996
|
+
rules,
|
|
2997
|
+
overrideResources: SLOTLOCK_OVERRIDE_RESOURCES.includes(input.override_resources)
|
|
2998
|
+
? input.override_resources
|
|
2999
|
+
: 'none',
|
|
3000
|
+
});
|
|
3001
|
+
return { resources_updated: result.inheriting, resources_with_own_hours: result.ownHours };
|
|
3002
|
+
}
|
|
3003
|
+
catch (error) {
|
|
3004
|
+
throw storeRefusal(error);
|
|
3005
|
+
}
|
|
3006
|
+
});
|
|
3007
|
+
},
|
|
3008
|
+
async getAvailabilityDefaults(context) {
|
|
3009
|
+
return withTenant(store, context, async (tenant) => {
|
|
3010
|
+
const defaults = await tenant.getAvailabilityDefaults({ tenantRef: tenantOf(context) });
|
|
3011
|
+
return {
|
|
3012
|
+
tenant: rulesOutput(defaults.tenant),
|
|
3013
|
+
locations: defaults.locations.map((entry) => ({
|
|
3014
|
+
location: entry.location,
|
|
3015
|
+
rules: rulesOutput(entry.rules) ?? [],
|
|
3016
|
+
})),
|
|
3017
|
+
};
|
|
3018
|
+
});
|
|
3019
|
+
},
|
|
3020
|
+
async setLocationAvailability(context, input) {
|
|
3021
|
+
const rules = rulesInput(input.rules);
|
|
3022
|
+
const location = input.location;
|
|
3023
|
+
return withTenant(store, context, async (tenant) => {
|
|
3024
|
+
let result;
|
|
3025
|
+
try {
|
|
3026
|
+
result = await tenant.setLocationAvailability({ tenantRef: tenantOf(context), location, rules });
|
|
3027
|
+
}
|
|
3028
|
+
catch (error) {
|
|
3029
|
+
throw storeRefusal(error);
|
|
3030
|
+
}
|
|
3031
|
+
if (!result)
|
|
3032
|
+
throw new SlotlockAgentOperationError('location_not_found', 404);
|
|
3033
|
+
return { location, resources_updated: result.inheriting, resources_with_own_hours: result.ownHours };
|
|
3034
|
+
});
|
|
3035
|
+
},
|
|
3036
|
+
async listAvailabilityExceptions(context, input) {
|
|
3037
|
+
const limit = input.limit ?? 100;
|
|
3038
|
+
return withTenant(store, context, async (tenant) => {
|
|
3039
|
+
let exceptions;
|
|
3040
|
+
try {
|
|
3041
|
+
exceptions = await tenant.listAvailabilityExceptions({
|
|
3042
|
+
tenantRef: tenantOf(context),
|
|
3043
|
+
...(input.resource_id !== undefined ? { resourceId: input.resource_id } : {}),
|
|
3044
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
3045
|
+
...(input.from !== undefined ? { from: input.from } : {}),
|
|
3046
|
+
...(input.to !== undefined ? { to: input.to } : {}),
|
|
3047
|
+
...(input.after !== undefined ? { after: input.after } : {}),
|
|
3048
|
+
limit: limit + 1,
|
|
3049
|
+
});
|
|
3050
|
+
}
|
|
3051
|
+
catch (error) {
|
|
3052
|
+
throw storeRefusal(error);
|
|
3053
|
+
}
|
|
3054
|
+
const page = exceptions.slice(0, limit);
|
|
3055
|
+
return {
|
|
3056
|
+
exceptions: page.map(exceptionOutput),
|
|
3057
|
+
next: exceptions.length > limit ? (page.at(-1)?.id ?? null) : null,
|
|
3058
|
+
};
|
|
3059
|
+
});
|
|
3060
|
+
},
|
|
3061
|
+
async createAvailabilityException(context, input) {
|
|
3062
|
+
const hours = input.hours;
|
|
3063
|
+
return withTenant(store, context, async (tenant) => {
|
|
3064
|
+
try {
|
|
3065
|
+
const exception = await tenant.createAvailabilityException({
|
|
3066
|
+
tenantRef: tenantOf(context),
|
|
3067
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
3068
|
+
...(input.resource_id !== undefined ? { resourceId: input.resource_id } : {}),
|
|
3069
|
+
startsOn: input.starts_on,
|
|
3070
|
+
...(input.ends_on !== undefined ? { endsOn: input.ends_on } : {}),
|
|
3071
|
+
outcome: input.outcome,
|
|
3072
|
+
...(hours
|
|
3073
|
+
? { hours: hours.map((window) => ({ startMinutes: window.start_minutes, durationMinutes: window.duration_minutes })) }
|
|
3074
|
+
: {}),
|
|
3075
|
+
label: input.label,
|
|
3076
|
+
});
|
|
3077
|
+
return { exception: exceptionOutput(exception) };
|
|
3078
|
+
}
|
|
3079
|
+
catch (error) {
|
|
3080
|
+
throw storeRefusal(error);
|
|
3081
|
+
}
|
|
3082
|
+
});
|
|
3083
|
+
},
|
|
3084
|
+
async deleteAvailabilityException(context, input) {
|
|
3085
|
+
return withTenant(store, context, async (tenant) => {
|
|
3086
|
+
const deleted = await tenant.deleteAvailabilityException({
|
|
3087
|
+
tenantRef: tenantOf(context),
|
|
3088
|
+
id: input.exception_id,
|
|
3089
|
+
});
|
|
3090
|
+
if (!deleted)
|
|
3091
|
+
throw new SlotlockAgentOperationError('availability_exception_not_found', 404);
|
|
3092
|
+
return { deleted: true };
|
|
3093
|
+
});
|
|
3094
|
+
},
|
|
3095
|
+
async importHolidays(context, input) {
|
|
3096
|
+
if (input.location !== undefined && input.resource_id !== undefined) {
|
|
3097
|
+
throw new SlotlockAgentOperationError('invalid_holiday_calendar', 400, { reason: 'a location or a resource, not both' });
|
|
3098
|
+
}
|
|
3099
|
+
// Read (a URL fetched) before the transaction opens.
|
|
3100
|
+
let read;
|
|
3101
|
+
try {
|
|
3102
|
+
read = await readSlotlockHolidayCalendar({
|
|
3103
|
+
...(input.icalendar !== undefined ? { icalendar: input.icalendar } : {}),
|
|
3104
|
+
...(input.url !== undefined ? { url: input.url } : {}),
|
|
3105
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
3106
|
+
...(input.subscribe !== undefined ? { subscribe: input.subscribe } : {}),
|
|
3107
|
+
...(input.from !== undefined ? { from: input.from } : {}),
|
|
3108
|
+
...(input.to !== undefined ? { to: input.to } : {}),
|
|
3109
|
+
}, options.holidayFetch);
|
|
3110
|
+
}
|
|
3111
|
+
catch (error) {
|
|
3112
|
+
throw storeRefusal(error);
|
|
3113
|
+
}
|
|
3114
|
+
return withTenant(store, context, async (tenant) => {
|
|
3115
|
+
try {
|
|
3116
|
+
const result = await tenant.importHolidayCalendar({
|
|
3117
|
+
tenantRef: tenantOf(context),
|
|
3118
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
3119
|
+
...(input.resource_id !== undefined ? { resourceId: input.resource_id } : {}),
|
|
3120
|
+
name: read.name,
|
|
3121
|
+
...(read.url !== undefined ? { url: read.url } : {}),
|
|
3122
|
+
holidays: read.holidays,
|
|
3123
|
+
span: read.span,
|
|
3124
|
+
});
|
|
2463
3125
|
return {
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
3126
|
+
imported: result.imported,
|
|
3127
|
+
skipped: read.skipped,
|
|
3128
|
+
removed: result.removed,
|
|
3129
|
+
calendar: holidayCalendarOutput(result.calendar),
|
|
2468
3130
|
};
|
|
2469
3131
|
}
|
|
2470
3132
|
catch (error) {
|
|
@@ -2472,6 +3134,17 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2472
3134
|
}
|
|
2473
3135
|
});
|
|
2474
3136
|
},
|
|
3137
|
+
async listHolidayCalendars(context) {
|
|
3138
|
+
return withTenant(store, context, async (tenant) => ({
|
|
3139
|
+
calendars: (await tenant.listHolidayCalendars({ tenantRef: tenantOf(context) })).map(holidayCalendarOutput),
|
|
3140
|
+
}));
|
|
3141
|
+
},
|
|
3142
|
+
async deleteHolidayCalendar(context, input) {
|
|
3143
|
+
return withTenant(store, context, async (tenant) => {
|
|
3144
|
+
const deleted = await tenant.deleteHolidayCalendar({ tenantRef: tenantOf(context), id: input.calendar_id });
|
|
3145
|
+
return { deleted: deleted !== null, closures: deleted?.closures ?? 0 };
|
|
3146
|
+
});
|
|
3147
|
+
},
|
|
2475
3148
|
async acquireHold(context, input) {
|
|
2476
3149
|
return withTenant(store, context, async (tenant) => {
|
|
2477
3150
|
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
@@ -2485,6 +3158,8 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2485
3158
|
...(input.buffer_after_minutes !== undefined
|
|
2486
3159
|
? { bufferAfterMs: input.buffer_after_minutes * 60_000 }
|
|
2487
3160
|
: {}),
|
|
3161
|
+
...(input.payment_required === true ? { paymentRequired: true } : {}),
|
|
3162
|
+
...(input.payment !== undefined ? { payment: amountInput(input.payment) } : {}),
|
|
2488
3163
|
// A retried request with the caller's key answers the hold the first one made.
|
|
2489
3164
|
...(input.idempotency_key !== undefined
|
|
2490
3165
|
? {
|
|
@@ -2534,6 +3209,65 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2534
3209
|
return { reservation: reservationOutput(result.reservation) };
|
|
2535
3210
|
}, WRITE);
|
|
2536
3211
|
},
|
|
3212
|
+
async markHoldPaid(context, input) {
|
|
3213
|
+
const id = input.hold_id;
|
|
3214
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
3215
|
+
throw new SlotlockAgentOperationError('hold_not_found', 404);
|
|
3216
|
+
return withTenant(store, context, async (tenant) => {
|
|
3217
|
+
const result = await tenant.markHoldPaid({
|
|
3218
|
+
tenantRef: tenantOf(context),
|
|
3219
|
+
holdId: id,
|
|
3220
|
+
paymentRef: input.payment_ref,
|
|
3221
|
+
...(input.received !== undefined ? { received: amountInput(input.received) } : {}),
|
|
3222
|
+
});
|
|
3223
|
+
if (!result.ok)
|
|
3224
|
+
throw applicationRefusal(result.code);
|
|
3225
|
+
return { hold: holdOutput(result.hold), replayed: result.idempotent };
|
|
3226
|
+
}, WRITE);
|
|
3227
|
+
},
|
|
3228
|
+
async putContact(context, input) {
|
|
3229
|
+
return withTenant(store, context, async (tenant) => {
|
|
3230
|
+
const result = await tenant.putContact({
|
|
3231
|
+
tenantRef: tenantOf(context),
|
|
3232
|
+
ref: input.contact_ref,
|
|
3233
|
+
name: input.name ?? null,
|
|
3234
|
+
email: input.email ?? null,
|
|
3235
|
+
phone: input.phone ?? null,
|
|
3236
|
+
whatsapp: input.whatsapp ?? null,
|
|
3237
|
+
consent: input.consent ?? {},
|
|
3238
|
+
locale: input.locale ?? null,
|
|
3239
|
+
});
|
|
3240
|
+
if (!result.ok)
|
|
3241
|
+
throw applicationRefusal(result.code);
|
|
3242
|
+
return { contact: contactOutput(result.contact), created: result.created };
|
|
3243
|
+
}, WRITE);
|
|
3244
|
+
},
|
|
3245
|
+
async getContact(context, input) {
|
|
3246
|
+
return withTenant(store, context, async (tenant) => {
|
|
3247
|
+
const contact = await tenant.getContact({ tenantRef: tenantOf(context), ref: input.contact_ref });
|
|
3248
|
+
if (!contact)
|
|
3249
|
+
throw new SlotlockAgentOperationError('contact_not_found', 404);
|
|
3250
|
+
return { contact: contactOutput(contact) };
|
|
3251
|
+
});
|
|
3252
|
+
},
|
|
3253
|
+
async listContacts(context, input) {
|
|
3254
|
+
const limit = input.limit;
|
|
3255
|
+
return withTenant(store, context, async (tenant) => {
|
|
3256
|
+
const contacts = await tenant.listContacts({
|
|
3257
|
+
tenantRef: tenantOf(context),
|
|
3258
|
+
limit: limit + 1,
|
|
3259
|
+
...(input.cursor !== undefined ? { after: input.cursor } : {}),
|
|
3260
|
+
});
|
|
3261
|
+
const shown = contacts.slice(0, limit);
|
|
3262
|
+
return {
|
|
3263
|
+
contacts: shown.map(contactOutput),
|
|
3264
|
+
next_cursor: contacts.length > limit ? (shown.at(-1)?.ref ?? null) : null,
|
|
3265
|
+
};
|
|
3266
|
+
});
|
|
3267
|
+
},
|
|
3268
|
+
async deleteContact(context, input) {
|
|
3269
|
+
return withTenant(store, context, async (tenant) => tenant.deleteContact({ tenantRef: tenantOf(context), ref: input.contact_ref }), WRITE);
|
|
3270
|
+
},
|
|
2537
3271
|
async releaseHold(context, input) {
|
|
2538
3272
|
const id = input.hold_id;
|
|
2539
3273
|
if (!STORE_ID_PATTERN.test(id))
|
|
@@ -2583,12 +3317,17 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2583
3317
|
bufferAfterMs: input.buffer_after_minutes * 60_000,
|
|
2584
3318
|
revision: input.revision,
|
|
2585
3319
|
...(input.source !== undefined ? { source: input.source } : {}),
|
|
3320
|
+
...(input.override_reason !== undefined ? { overrideReason: input.override_reason } : {}),
|
|
3321
|
+
...(input.local_start !== undefined ? { localStart: input.local_start } : {}),
|
|
3322
|
+
...(input.contact_ref !== undefined ? { contactRef: input.contact_ref } : {}),
|
|
3323
|
+
...(input.notify !== undefined ? { notify: input.notify } : {}),
|
|
3324
|
+
...(input.reminder_minutes !== undefined ? { reminderMinutes: input.reminder_minutes } : {}),
|
|
2586
3325
|
};
|
|
2587
3326
|
const result = command.revision === 1
|
|
2588
3327
|
? await tenant.createExternalReservation(command)
|
|
2589
3328
|
: await tenant.rescheduleExternalReservation(command);
|
|
2590
3329
|
if (!result.ok)
|
|
2591
|
-
throw
|
|
3330
|
+
throw applicationRuleRefusal(result);
|
|
2592
3331
|
return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
|
|
2593
3332
|
}, WRITE);
|
|
2594
3333
|
if (input.revision > 1 && !put.replayed) {
|
|
@@ -2602,10 +3341,11 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2602
3341
|
tenantRef: tenantOf(context),
|
|
2603
3342
|
externalRef: input.reservation_ref,
|
|
2604
3343
|
revision: input.revision,
|
|
3344
|
+
...(input.override_reason !== undefined ? { overrideReason: input.override_reason } : {}),
|
|
2605
3345
|
});
|
|
2606
3346
|
if (!result.ok)
|
|
2607
|
-
throw
|
|
2608
|
-
return { cancelled: result.cancelled, revision: result.revision };
|
|
3347
|
+
throw applicationRuleRefusal(result);
|
|
3348
|
+
return { cancelled: result.cancelled, revision: result.revision, cancellation_fee: feeOutput(result.cancellationFee) };
|
|
2609
3349
|
}, WRITE);
|
|
2610
3350
|
if (cancelled.cancelled)
|
|
2611
3351
|
await offerFreedTime(store, context);
|
|
@@ -2633,6 +3373,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2633
3373
|
end: new Date(input.end),
|
|
2634
3374
|
holderRef: input.holder_ref,
|
|
2635
3375
|
...(ttl !== undefined ? { offerTtlMs: ttl * 1000 } : {}),
|
|
3376
|
+
...(input.duration_minutes !== undefined ? { durationMs: input.duration_minutes * 60_000 } : {}),
|
|
2636
3377
|
...(input.idempotency_key !== undefined ? { idempotencyKey: input.idempotency_key } : {}),
|
|
2637
3378
|
});
|
|
2638
3379
|
if (!result.ok)
|
|
@@ -2659,6 +3400,26 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2659
3400
|
await offerFreedTime(store, context);
|
|
2660
3401
|
return left;
|
|
2661
3402
|
},
|
|
3403
|
+
async tenantBookTogether(context, input) {
|
|
3404
|
+
if (!options.agentBackend?.bookTogether)
|
|
3405
|
+
throw new SlotlockAgentOperationError('bundles_unavailable', 409);
|
|
3406
|
+
// Through the agents' bundle (one transaction, the booking rules applied), as the tenant's:
|
|
3407
|
+
// owned by the tenant, not the key, and held only by policies that include the application.
|
|
3408
|
+
return options.agentBackend.bookTogether({ ...context, operation: 'slotlock_book_together', [TENANT_ACTOR]: true }, input);
|
|
3409
|
+
},
|
|
3410
|
+
async tenantBookPool(context, input) {
|
|
3411
|
+
if (!options.agentBackend?.bookPool)
|
|
3412
|
+
throw new SlotlockAgentOperationError('pools_unavailable', 409);
|
|
3413
|
+
return options.agentBackend.bookPool({ ...context, operation: 'slotlock_book_pool', [TENANT_ACTOR]: true }, input);
|
|
3414
|
+
},
|
|
3415
|
+
async declineWaitlistOffer(context, input) {
|
|
3416
|
+
const declined = await withTenant(store, context, (tenant) => tenant.declineWaitlistOffer({ tenantRef: tenantOf(context), id: input.entry_id }));
|
|
3417
|
+
if (!declined)
|
|
3418
|
+
throw new SlotlockAgentOperationError('waitlist_offer_not_found', 404);
|
|
3419
|
+
// The time it let go goes to the next who waits.
|
|
3420
|
+
await offerFreedTime(store, context);
|
|
3421
|
+
return { entry: waitlistOutput(declined) };
|
|
3422
|
+
},
|
|
2662
3423
|
async getCoverage(context, input) {
|
|
2663
3424
|
return withTenant(store, context, async (tenant) => {
|
|
2664
3425
|
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
@@ -2720,6 +3481,45 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2720
3481
|
return { revision: result.revision, replayed: result.idempotent };
|
|
2721
3482
|
}, WRITE);
|
|
2722
3483
|
},
|
|
3484
|
+
async putSourceBusy(context, input) {
|
|
3485
|
+
return withTenant(store, context, async (tenant) => {
|
|
3486
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
3487
|
+
const result = await tenant.replaceSourceBusy({
|
|
3488
|
+
tenantRef: tenantOf(context),
|
|
3489
|
+
resourceId: resource.id,
|
|
3490
|
+
source: input.source,
|
|
3491
|
+
window: interval(input),
|
|
3492
|
+
revision: input.revision,
|
|
3493
|
+
busy: input.busy.map((busy) => ({
|
|
3494
|
+
start: new Date(busy.start),
|
|
3495
|
+
end: new Date(busy.end),
|
|
3496
|
+
})),
|
|
3497
|
+
});
|
|
3498
|
+
if (!result.ok)
|
|
3499
|
+
throw applicationRefusal(result.code);
|
|
3500
|
+
return { revision: result.revision, replayed: result.idempotent, busy: result.busy, conflicts: result.conflicts };
|
|
3501
|
+
}, WRITE);
|
|
3502
|
+
},
|
|
3503
|
+
async reportCoverageConflict(context, input) {
|
|
3504
|
+
return withTenant(store, context, async (tenant) => {
|
|
3505
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
3506
|
+
const result = await tenant.reportCoverageConflict({
|
|
3507
|
+
tenantRef: tenantOf(context),
|
|
3508
|
+
resourceId: resource.id,
|
|
3509
|
+
source: input.source,
|
|
3510
|
+
booking: input.event_id !== undefined
|
|
3511
|
+
? { eventId: input.event_id }
|
|
3512
|
+
: { reservationRef: input.reservation_ref },
|
|
3513
|
+
reason: input.reason,
|
|
3514
|
+
...(input.calendar_start !== undefined
|
|
3515
|
+
? { calendar: interval({ start: input.calendar_start, end: input.calendar_end }) }
|
|
3516
|
+
: {}),
|
|
3517
|
+
});
|
|
3518
|
+
if (!result.ok)
|
|
3519
|
+
throw applicationRefusal(result.code);
|
|
3520
|
+
return { reported: true };
|
|
3521
|
+
}, WRITE);
|
|
3522
|
+
},
|
|
2723
3523
|
async listTenantEvents(context, input) {
|
|
2724
3524
|
const limit = input.limit;
|
|
2725
3525
|
const after = parseEventCursor(input.cursor);
|
|
@@ -2772,14 +3572,16 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2772
3572
|
idempotencyKey: applicationCommandKey(context, input.idempotency_key),
|
|
2773
3573
|
expectedRevision: input.expected_revision,
|
|
2774
3574
|
...(expectedResourceId !== undefined ? { expectedResourceId } : {}),
|
|
3575
|
+
...(input.override_reason !== undefined ? { overrideReason: input.override_reason } : {}),
|
|
2775
3576
|
});
|
|
2776
3577
|
if (!result.ok)
|
|
2777
|
-
throw
|
|
3578
|
+
throw applicationRuleRefusal(result);
|
|
2778
3579
|
return {
|
|
2779
3580
|
event_id: result.eventId,
|
|
2780
3581
|
revision: result.revision,
|
|
2781
3582
|
cancelled: true,
|
|
2782
3583
|
replayed: result.idempotent,
|
|
3584
|
+
cancellation_fee: feeOutput(result.cancellationFee),
|
|
2783
3585
|
};
|
|
2784
3586
|
}, WRITE);
|
|
2785
3587
|
if (!cancelled.replayed)
|
|
@@ -3087,6 +3889,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
3087
3889
|
...(input.conditions !== undefined ? { conditions: approvalConditions(input.conditions) } : {}),
|
|
3088
3890
|
...(input.approval_ttl_minutes !== undefined ? { approvalTtlMinutes: input.approval_ttl_minutes } : {}),
|
|
3089
3891
|
...(input.lead_minutes !== undefined ? { leadMinutes: input.lead_minutes } : {}),
|
|
3892
|
+
...(input.include_application !== undefined ? { includeApplication: input.include_application } : {}),
|
|
3090
3893
|
});
|
|
3091
3894
|
}
|
|
3092
3895
|
catch (error) {
|
|
@@ -3214,9 +4017,10 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
3214
4017
|
async runMaintenance(context) {
|
|
3215
4018
|
assertActive(context);
|
|
3216
4019
|
try {
|
|
3217
|
-
const { rollPending: _, zoneShifts, staleSources, noShows, waitlistOffers, approvalsExpired, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
4020
|
+
const { rollPending: _, holidayCalendarsImported: _holidays, zoneShifts, staleSources, noShows, waitlistOffers, approvalsExpired, bookingsCompleted, remindersDue, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
3218
4021
|
batches,
|
|
3219
4022
|
signal: context.signal,
|
|
4023
|
+
...(options.holidayFetch ? { holidayFetch: options.holidayFetch } : {}),
|
|
3220
4024
|
});
|
|
3221
4025
|
return {
|
|
3222
4026
|
...result,
|
|
@@ -3225,6 +4029,8 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
3225
4029
|
no_shows: noShows,
|
|
3226
4030
|
waitlist_offers: waitlistOffers,
|
|
3227
4031
|
approvals_expired: approvalsExpired,
|
|
4032
|
+
bookings_completed: bookingsCompleted,
|
|
4033
|
+
reminders_due: remindersDue,
|
|
3228
4034
|
};
|
|
3229
4035
|
}
|
|
3230
4036
|
catch (error) {
|