slotlock 0.8.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +237 -0
  2. package/agent-server.d.ts +25 -5
  3. package/agent-server.js +152 -7
  4. package/agent-store-backend.d.ts +6 -1
  5. package/agent-store-backend.js +861 -55
  6. package/application-api.d.ts +19 -1
  7. package/application-api.js +578 -21
  8. package/approvals.d.ts +2 -0
  9. package/approvals.js +12 -5
  10. package/assistant.d.ts +34 -8
  11. package/assistant.js +81 -26
  12. package/availability-store.d.ts +193 -0
  13. package/availability-store.js +558 -0
  14. package/booking-rules.d.ts +92 -5
  15. package/booking-rules.js +243 -15
  16. package/dashboard-assistant-ui.d.ts +1 -1
  17. package/dashboard-assistant-ui.js +109 -8
  18. package/dashboard.d.ts +55 -6
  19. package/dashboard.js +790 -105
  20. package/ddl.d.ts +5 -1
  21. package/ddl.js +760 -9
  22. package/event-feed.d.ts +1 -1
  23. package/event-feed.js +18 -0
  24. package/holiday-import.d.ts +38 -0
  25. package/holiday-import.js +57 -0
  26. package/holidays.d.ts +38 -0
  27. package/holidays.js +150 -0
  28. package/hours.d.ts +125 -0
  29. package/hours.js +224 -0
  30. package/index.d.ts +14 -3
  31. package/index.js +10 -2
  32. package/interop.d.ts +25 -7
  33. package/interop.js +145 -17
  34. package/lifecycle.d.ts +31 -0
  35. package/lifecycle.js +115 -0
  36. package/maintenance.d.ts +19 -0
  37. package/maintenance.js +55 -1
  38. package/metrics.js +3 -0
  39. package/openapi.json +16237 -7795
  40. package/package.json +1 -1
  41. package/ranges.d.ts +28 -0
  42. package/ranges.js +135 -0
  43. package/resource-search.d.ts +19 -0
  44. package/resource-search.js +153 -0
  45. package/rest-api.js +7 -0
  46. package/rules.d.ts +2 -0
  47. package/rules.js +2 -0
  48. package/self-host.d.ts +1 -1
  49. package/self-host.js +221 -21
  50. package/slotlock.manifest.json +1 -1
  51. package/store.d.ts +367 -24
  52. package/store.js +1567 -97
  53. package/types.d.ts +198 -0
@@ -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 { expandRules } from './rules.js';
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
- function requireAgentResource(tenantStore, tenantRef, resourceId) {
404
- return requireResource(tenantStore, tenantRef, resourceId, 'shared');
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 hours = member.availabilityRules ?? (await options.availabilityRules(context, member));
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
- 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
- };
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
- const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
939
- const windows = expandRules(rules, search.window, resource.timezone);
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
- const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
997
- const windows = expandRules(rules, search.window, resource.timezone);
998
- windowsLeft -= windows.length;
999
- if (windowsLeft < 0)
1000
- throw new SlotlockAgentOperationError('calendar_work_limit', 400);
1001
- const candidate = findNextAvailable({
1002
- busy: bookingSearchBlocked(freeBusy.busy, search),
1003
- windows,
1004
- durationMs,
1005
- ...(search.align ? { align: search.align } : {}),
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 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);
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) => (policy.conditions.kinds === undefined || policy.conditions.kinds.includes(asked.kind)) &&
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
- if (resource.availabilityRules === undefined)
2800
+ const rules = resource.availabilityRules ?? resource.inheritedAvailability?.rules;
2801
+ if (rules === undefined)
2292
2802
  return { icalendar: null };
2293
- return {
2294
- icalendar: emitVAvailability({
2295
- rules: resource.availabilityRules,
2296
- timezone: resource.timezone,
2297
- uid: `${resource.id}-hours`,
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
- resources_updated: await tenant.setTenantAvailability({
2465
- tenantRef: tenantOf(context),
2466
- rules,
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 applicationRefusal(result.code);
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 applicationRefusal(result.code);
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 applicationRefusal(result.code);
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) {