slotlock 0.8.0 → 0.10.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 +542 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- 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 +1068 -68
- package/application-api.d.ts +28 -1
- package/application-api.js +789 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +34 -8
- package/assistant.js +81 -26
- package/availability-store.d.ts +193 -0
- package/availability-store.js +566 -0
- package/booking-rules.d.ts +103 -5
- package/booking-rules.js +263 -15
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-assistant-ui.d.ts +1 -1
- package/dashboard-assistant-ui.js +109 -8
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +152 -8
- package/dashboard.js +3868 -399
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +44 -9
- package/ddl.js +2872 -47
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +2 -2
- package/event-feed.js +24 -34
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +56 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +209 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +24 -5
- package/index.js +17 -3
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +26 -2
- package/maintenance.js +70 -2
- package/metrics.js +3 -0
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +20172 -9850
- package/package.json +2 -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/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +483 -28
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +637 -23
- package/store.js +2529 -147
- package/types.d.ts +230 -0
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- package/workspaces.js +433 -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 { buildSlotlockTenantExport, slotlockAuditEventRecord, 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;
|
|
@@ -60,6 +78,52 @@ function assertActive(context) {
|
|
|
60
78
|
if (context.signal.aborted)
|
|
61
79
|
throw new SlotlockAgentOperationError('request_aborted', 503);
|
|
62
80
|
}
|
|
81
|
+
/** A tenant export as the API gives it. */
|
|
82
|
+
function tenantExportOutput(made) {
|
|
83
|
+
return {
|
|
84
|
+
id: made.id,
|
|
85
|
+
status: made.status,
|
|
86
|
+
requested_by: made.requestedBy,
|
|
87
|
+
created_at: made.createdAt.toISOString(),
|
|
88
|
+
finished_at: made.finishedAt?.toISOString() ?? null,
|
|
89
|
+
expires_at: made.expiresAt.toISOString(),
|
|
90
|
+
parts: made.parts,
|
|
91
|
+
rows: made.rows,
|
|
92
|
+
bytes: made.bytes,
|
|
93
|
+
tables: made.tables,
|
|
94
|
+
error: made.error,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** A key's own caps on what its agent holds (RFC 0004 A5), or undefined when it has none. */
|
|
98
|
+
function keyHoldCaps(context) {
|
|
99
|
+
const limits = context.principal.limits;
|
|
100
|
+
return limits?.maxHolds !== undefined || limits?.maxHeldHours !== undefined
|
|
101
|
+
? {
|
|
102
|
+
...(limits.maxHolds !== undefined ? { maxHolds: limits.maxHolds } : {}),
|
|
103
|
+
...(limits.maxHeldHours !== undefined ? { maxHeldHours: limits.maxHeldHours } : {}),
|
|
104
|
+
}
|
|
105
|
+
: undefined;
|
|
106
|
+
}
|
|
107
|
+
/** The agent an approval holds time for, and its key's caps. */
|
|
108
|
+
function approvalHolder(context) {
|
|
109
|
+
const caps = keyHoldCaps(context);
|
|
110
|
+
return caps ? { ref: agentOwnerRef(context), caps } : { ref: agentOwnerRef(context) };
|
|
111
|
+
}
|
|
112
|
+
/** The person an access or erasure request names (RFC 0004 D3), as the schema checked it. */
|
|
113
|
+
function subjectOf(input) {
|
|
114
|
+
for (const kind of ['email', 'holder', 'contact', 'reference']) {
|
|
115
|
+
const value = input[kind];
|
|
116
|
+
if (typeof value === 'string')
|
|
117
|
+
return { [kind]: value };
|
|
118
|
+
}
|
|
119
|
+
throw new SlotlockAgentOperationError('invalid_subject', 400);
|
|
120
|
+
}
|
|
121
|
+
function subjectRefusal(error) {
|
|
122
|
+
if (error?.code === 'invalid_subject') {
|
|
123
|
+
throw new SlotlockAgentOperationError('invalid_subject', 400);
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
63
127
|
async function withTenant(store, context, callback, options) {
|
|
64
128
|
assertActive(context);
|
|
65
129
|
let value;
|
|
@@ -225,15 +289,21 @@ function uuidFromDigest(value) {
|
|
|
225
289
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
226
290
|
}
|
|
227
291
|
function agentOwnerRef(context) {
|
|
292
|
+
if (forTenant(context))
|
|
293
|
+
return SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
|
|
228
294
|
return `agent:${createHash('sha256')
|
|
229
295
|
.update(`${context.principal.tenantRef}\0${context.principal.subject}`)
|
|
230
296
|
.digest('hex')}`;
|
|
231
297
|
}
|
|
232
298
|
function agentExternalRef(context, idempotencyKey) {
|
|
299
|
+
if (forTenant(context))
|
|
300
|
+
return `tenant:${tenantDigest(context, idempotencyKey)}`;
|
|
233
301
|
// Already a stable client-visible identity; keep it unchanged.
|
|
234
302
|
return `agent:${agentPrincipalDigest(context, idempotencyKey)}`;
|
|
235
303
|
}
|
|
236
304
|
function agentCommandIdempotencyKey(context, idempotencyKey) {
|
|
305
|
+
if (forTenant(context))
|
|
306
|
+
return `tenant-command:${tenantDigest(context, idempotencyKey)}`;
|
|
237
307
|
// The principal stays in the digest so this remains safe with older tenant-only stores; hashing
|
|
238
308
|
// keeps the subject and raw retry key out of durable rows.
|
|
239
309
|
return `agent-command:${agentPrincipalDigest(context, idempotencyKey)}`;
|
|
@@ -293,7 +363,7 @@ export function projectSlotlockCalendarEvent(event) {
|
|
|
293
363
|
export function slotlockAgentErrorStatus(code) {
|
|
294
364
|
if (code === 'resource_tenant_mismatch' || code.endsWith('_not_found'))
|
|
295
365
|
return 404;
|
|
296
|
-
if (code.startsWith('invalid_'))
|
|
366
|
+
if (code.startsWith('invalid_') || code === 'duration_required')
|
|
297
367
|
return 400;
|
|
298
368
|
if (code === 'owner_event_quota_exceeded' || code === 'owner_command_quota_exceeded')
|
|
299
369
|
return 429;
|
|
@@ -306,6 +376,13 @@ const BOOKING_RULE_CODES = Object.freeze({
|
|
|
306
376
|
slot_step: 'booking_rule_slot_step',
|
|
307
377
|
outside_hours: 'booking_rule_outside_hours',
|
|
308
378
|
uncertain: 'booking_rule_uncertain',
|
|
379
|
+
min_duration: 'booking_rule_min_duration',
|
|
380
|
+
max_duration: 'booking_rule_max_duration',
|
|
381
|
+
duration_not_allowed: 'booking_rule_duration_not_allowed',
|
|
382
|
+
pickup_time: 'booking_rule_pickup_time',
|
|
383
|
+
return_time: 'booking_rule_return_time',
|
|
384
|
+
cancel_cutoff: 'booking_rule_cancel_cutoff',
|
|
385
|
+
reschedule_cutoff: 'booking_rule_reschedule_cutoff',
|
|
309
386
|
});
|
|
310
387
|
function operationError(refusal, details) {
|
|
311
388
|
const code = typeof refusal === 'string'
|
|
@@ -340,6 +417,43 @@ async function overlapDetails(tenantStore, context, refusal) {
|
|
|
340
417
|
...(booking.eventRef !== null && booking.eventOwnerRef === owner ? { conflicting_event_id: booking.eventRef } : {}),
|
|
341
418
|
};
|
|
342
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Slots on a resource whose hours hold a hire only at its ends (`hoursApply`): up to `limit` of
|
|
422
|
+
* `durationMs` from `from` to the window's end, each the range search's earliest after the last,
|
|
423
|
+
* so a slot may run from one local day to another and is judged as a write of it is.
|
|
424
|
+
*/
|
|
425
|
+
async function rangedSlots(tenantStore, context, resource, window, from, durationMs, busy, limit, availabilityRules) {
|
|
426
|
+
const fallback = resource.availabilityRules === undefined && resource.inheritedAvailability === undefined
|
|
427
|
+
? await availabilityRules(context, resource)
|
|
428
|
+
: [];
|
|
429
|
+
const exceptions = (await tenantStore.availabilityExceptionsFor({
|
|
430
|
+
tenantRef: context.principal.tenantRef,
|
|
431
|
+
resourceIds: [resource.id],
|
|
432
|
+
window: { start: new Date(window.start.getTime() - 86_400_000), end: new Date(window.end.getTime() + 86_400_000) },
|
|
433
|
+
})).get(resource.id) ?? [];
|
|
434
|
+
const rules = { ...resource.effectiveBookingRules, allowedDurations: [Math.round(durationMs / 60_000)] };
|
|
435
|
+
const hours = applicableHours(resource, fallback).rules;
|
|
436
|
+
const now = new Date();
|
|
437
|
+
const lastStart = window.end.getTime() - durationMs;
|
|
438
|
+
const slots = [];
|
|
439
|
+
for (let start = from; slots.length < limit && start <= lastStart;) {
|
|
440
|
+
const range = findEarliestRange({
|
|
441
|
+
busy,
|
|
442
|
+
rules,
|
|
443
|
+
timezone: resource.timezone,
|
|
444
|
+
hours,
|
|
445
|
+
exceptions,
|
|
446
|
+
now,
|
|
447
|
+
pickup: { start: new Date(start), end: new Date(lastStart) },
|
|
448
|
+
return: { start: new Date(start + durationMs), end: window.end },
|
|
449
|
+
});
|
|
450
|
+
if (!range)
|
|
451
|
+
break;
|
|
452
|
+
slots.push(range);
|
|
453
|
+
start = range.end.getTime();
|
|
454
|
+
}
|
|
455
|
+
return slots;
|
|
456
|
+
}
|
|
343
457
|
/** Resource and event ids: the UUIDs the store issues and validates. */
|
|
344
458
|
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
459
|
function parseResourceCursor(value) {
|
|
@@ -400,8 +514,59 @@ async function requireResource(tenantStore, tenantRef, resourceId, visibility) {
|
|
|
400
514
|
return resource;
|
|
401
515
|
}
|
|
402
516
|
/** The caller's shared resource with this id: what an agent may see and book. */
|
|
403
|
-
|
|
404
|
-
|
|
517
|
+
/** The most resources a search or a not-found's candidates look through: more than a tenant may have. */
|
|
518
|
+
const MAX_SEARCHED_RESOURCES = 5_000;
|
|
519
|
+
/** A tenant's resources agents may see, in pages, up to MAX_SEARCHED_RESOURCES. */
|
|
520
|
+
async function sharedResources(tenantStore, tenantRef, filter = {}) {
|
|
521
|
+
const all = [];
|
|
522
|
+
let after;
|
|
523
|
+
while (all.length < MAX_SEARCHED_RESOURCES) {
|
|
524
|
+
const page = await tenantStore.listResources({
|
|
525
|
+
tenantRef,
|
|
526
|
+
visibility: 'shared',
|
|
527
|
+
limit: 1_000,
|
|
528
|
+
...(after !== undefined ? { after } : {}),
|
|
529
|
+
...filter,
|
|
530
|
+
});
|
|
531
|
+
all.push(...page);
|
|
532
|
+
if (page.length < 1_000)
|
|
533
|
+
break;
|
|
534
|
+
after = page.at(-1)?.id;
|
|
535
|
+
}
|
|
536
|
+
return all.slice(0, MAX_SEARCHED_RESOURCES);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* The refusal for an id that names no resource the caller can see. When what was given is not an
|
|
540
|
+
* id at all (a name or a reference, as a model will pass), it names up to three resources that best
|
|
541
|
+
* match it, so the caller can try again with the right id.
|
|
542
|
+
*/
|
|
543
|
+
async function resourceNotFound(tenantStore, tenantRef, given) {
|
|
544
|
+
if (STORE_ID_PATTERN.test(given) || typeof given !== 'string' || given.trim() === '') {
|
|
545
|
+
return new SlotlockAgentOperationError('resource_not_found', 404);
|
|
546
|
+
}
|
|
547
|
+
let words;
|
|
548
|
+
try {
|
|
549
|
+
words = slotlockResourceSearchWords(given);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
return new SlotlockAgentOperationError('resource_not_found', 404);
|
|
553
|
+
}
|
|
554
|
+
const best = slotlockSearchResources(await sharedResources(tenantStore, tenantRef), words).slice(0, 3);
|
|
555
|
+
const details = Object.fromEntries(best.map((match, index) => [
|
|
556
|
+
`candidate_${String(index + 1)}`,
|
|
557
|
+
`${match.resource.id} ${match.resource.name ?? match.resource.externalRef ?? ''}`.trim().slice(0, 200),
|
|
558
|
+
]));
|
|
559
|
+
return new SlotlockAgentOperationError('resource_not_found', 404, details);
|
|
560
|
+
}
|
|
561
|
+
async function requireAgentResource(tenantStore, tenantRef, resourceId) {
|
|
562
|
+
try {
|
|
563
|
+
return await requireResource(tenantStore, tenantRef, resourceId, 'shared');
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
if (error.code !== 'resource_not_found')
|
|
567
|
+
throw error;
|
|
568
|
+
throw await resourceNotFound(tenantStore, tenantRef, resourceId);
|
|
569
|
+
}
|
|
405
570
|
}
|
|
406
571
|
/**
|
|
407
572
|
* The store's change feed for live updates (`subscriptions.changes`): each tenant's changes read
|
|
@@ -418,6 +583,31 @@ export function createSlotlockStoreChangeSource(store, sql) {
|
|
|
418
583
|
};
|
|
419
584
|
}
|
|
420
585
|
/** Bind the protocol server to the production store without giving a request control of tenancy. */
|
|
586
|
+
/**
|
|
587
|
+
* Each resource's open hours within `window` (RFC 0004 B1, B2): its own weekly hours, else what it
|
|
588
|
+
* inherits from its location or tenant, else the server's, with the exceptions that reach it applied.
|
|
589
|
+
* One query reads every resource's exceptions.
|
|
590
|
+
*/
|
|
591
|
+
async function hoursWithin(tenantStore, context, resources, window, options) {
|
|
592
|
+
const exceptions = await tenantStore.availabilityExceptionsFor({
|
|
593
|
+
tenantRef: context.principal.tenantRef,
|
|
594
|
+
resourceIds: resources.map((resource) => resource.id),
|
|
595
|
+
window,
|
|
596
|
+
});
|
|
597
|
+
const open = new Map();
|
|
598
|
+
for (const resource of resources) {
|
|
599
|
+
const fallback = resource.availabilityRules === undefined && resource.inheritedAvailability === undefined
|
|
600
|
+
? await options.availabilityRules(context, resource)
|
|
601
|
+
: [];
|
|
602
|
+
open.set(resource.id, openWindows({
|
|
603
|
+
hours: applicableHours(resource, fallback).rules,
|
|
604
|
+
exceptions: exceptions.get(resource.id) ?? [],
|
|
605
|
+
timezone: resource.timezone,
|
|
606
|
+
window,
|
|
607
|
+
}));
|
|
608
|
+
}
|
|
609
|
+
return open;
|
|
610
|
+
}
|
|
421
611
|
export function createSlotlockStoreAgentBackend(store, options) {
|
|
422
612
|
const maxApprovalMinutes = options.maxApprovalMinutes ?? SLOTLOCK_MAX_APPROVAL_MINUTES;
|
|
423
613
|
if (!(Number.isSafeInteger(maxApprovalMinutes) && maxApprovalMinutes >= 1 && maxApprovalMinutes <= SLOTLOCK_MAX_APPROVAL_MINUTES)) {
|
|
@@ -463,6 +653,11 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
463
653
|
source: 'agent-server',
|
|
464
654
|
// The key's own caps, below the server's (RFC 0004 A5).
|
|
465
655
|
...(holderCaps ? { holderCaps } : {}),
|
|
656
|
+
// Its price is the tenant's (holdPrice), never the agent's own (RFC 0004 C3).
|
|
657
|
+
priceFromRules: true,
|
|
658
|
+
// Booked only once your system records its payment (RFC 0004 C3).
|
|
659
|
+
...(input.payment_required === true ? { paymentRequired: true } : {}),
|
|
660
|
+
...(input.payment !== undefined ? { payment: amountInput(input.payment) } : {}),
|
|
466
661
|
});
|
|
467
662
|
if (result.ok) {
|
|
468
663
|
holds.push({
|
|
@@ -471,6 +666,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
471
666
|
start: result.hold.start.toISOString(),
|
|
472
667
|
end: result.hold.end.toISOString(),
|
|
473
668
|
expires_at: result.hold.expiresAt.toISOString(),
|
|
669
|
+
...(result.hold.paymentRequired ? { payment_required: true, payment_amount: amountOutput(result.hold.paymentAmount) } : {}),
|
|
474
670
|
});
|
|
475
671
|
}
|
|
476
672
|
else {
|
|
@@ -480,6 +676,74 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
480
676
|
return { group_id: groupId, holds, refused };
|
|
481
677
|
});
|
|
482
678
|
},
|
|
679
|
+
async holdTogether(context, rawInput) {
|
|
680
|
+
const input = rawInput;
|
|
681
|
+
const holderRef = agentOwnerRef(context);
|
|
682
|
+
const limits = context.principal.limits;
|
|
683
|
+
const holderCaps = limits?.maxHolds !== undefined || limits?.maxHeldHours !== undefined
|
|
684
|
+
? {
|
|
685
|
+
...(limits.maxHolds !== undefined ? { maxHolds: limits.maxHolds } : {}),
|
|
686
|
+
...(limits.maxHeldHours !== undefined ? { maxHeldHours: limits.maxHeldHours } : {}),
|
|
687
|
+
}
|
|
688
|
+
: undefined;
|
|
689
|
+
// Sorted, so sets over the same resources lock in one order; one group per agent and key.
|
|
690
|
+
const resourceIds = [...new Set(input.resource_ids)].sort();
|
|
691
|
+
if (resourceIds.length < 2)
|
|
692
|
+
throw new SlotlockAgentOperationError('invalid_arguments', 400);
|
|
693
|
+
const groupId = uuidFromDigest(`slotlock-hold-together\0${holderRef}\0${input.idempotency_key}`);
|
|
694
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
695
|
+
for (const resourceId of resourceIds) {
|
|
696
|
+
try {
|
|
697
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
if (!(error instanceof SlotlockAgentOperationError))
|
|
701
|
+
throw error;
|
|
702
|
+
throw new SlotlockAgentOperationError(error.code, error.status, { resource_id: resourceId });
|
|
703
|
+
}
|
|
704
|
+
const result = await tenantStore.acquireHold({
|
|
705
|
+
tenantRef: context.principal.tenantRef,
|
|
706
|
+
principalRef: holderRef,
|
|
707
|
+
holderRef,
|
|
708
|
+
idempotencyKey: agentCommandIdempotencyKey(context, `${input.idempotency_key}\0together\0${resourceId}`),
|
|
709
|
+
groupId,
|
|
710
|
+
resourceId,
|
|
711
|
+
start: new Date(input.start),
|
|
712
|
+
end: new Date(input.end),
|
|
713
|
+
ttlMs: input.ttl_seconds * 1_000,
|
|
714
|
+
source: 'slotlock:together',
|
|
715
|
+
...(holderCaps ? { holderCaps } : {}),
|
|
716
|
+
// Its price is the tenant's (holdPrice), never the agent's own (RFC 0004 C3).
|
|
717
|
+
priceFromRules: true,
|
|
718
|
+
});
|
|
719
|
+
// All or nothing: thrown, so the holds already made roll back with it.
|
|
720
|
+
if (!result.ok) {
|
|
721
|
+
const refusal = operationError(result);
|
|
722
|
+
throw new SlotlockAgentOperationError(refusal.code, refusal.status, { resource_id: resourceId });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// One time to live for the set: each expires when the earliest does.
|
|
726
|
+
const holds = await tenantStore.holdGroup({
|
|
727
|
+
tenantRef: context.principal.tenantRef,
|
|
728
|
+
holderRef,
|
|
729
|
+
groupId,
|
|
730
|
+
alignExpiry: true,
|
|
731
|
+
});
|
|
732
|
+
if (holds.length !== resourceIds.length)
|
|
733
|
+
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
734
|
+
return {
|
|
735
|
+
group_id: groupId,
|
|
736
|
+
holds: holds.map((hold) => ({
|
|
737
|
+
hold_id: hold.holdId,
|
|
738
|
+
resource_id: hold.resourceId,
|
|
739
|
+
start: hold.start.toISOString(),
|
|
740
|
+
end: hold.end.toISOString(),
|
|
741
|
+
expires_at: hold.expiresAt.toISOString(),
|
|
742
|
+
})),
|
|
743
|
+
expires_at: holds[0].expiresAt.toISOString(),
|
|
744
|
+
};
|
|
745
|
+
});
|
|
746
|
+
},
|
|
483
747
|
async bookPool(context, rawInput) {
|
|
484
748
|
const input = rawInput;
|
|
485
749
|
const content = {
|
|
@@ -564,6 +828,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
564
828
|
[window.end.getTime(), 0],
|
|
565
829
|
]);
|
|
566
830
|
let complete = true;
|
|
831
|
+
const enforced = members.filter((member) => member.effectiveBookingRules?.enforceHours === true);
|
|
832
|
+
const memberHours = enforced.length > 0 ? await hoursWithin(tenantStore, context, enforced, window, options) : new Map();
|
|
567
833
|
for (const member of members) {
|
|
568
834
|
const sources = await requiredSources(options, context, member);
|
|
569
835
|
const freeBusy = await readFreeBusy(tenantStore, {
|
|
@@ -590,8 +856,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
590
856
|
? [{ start: search.window.start, end: new Date(Math.min(search.window.end.getTime(), latestStart)) }]
|
|
591
857
|
: [];
|
|
592
858
|
if (rules?.enforceHours === true && open.length > 0) {
|
|
593
|
-
const
|
|
594
|
-
const windows = expandRules(hours, window, member.timezone);
|
|
859
|
+
const windows = memberHours.get(member.id) ?? [];
|
|
595
860
|
windowsLeft -= windows.length;
|
|
596
861
|
if (windowsLeft < 0)
|
|
597
862
|
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
@@ -665,11 +930,25 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
665
930
|
throw new SlotlockAgentOperationError(error.code, error.status, { resource_id: resourceId });
|
|
666
931
|
}
|
|
667
932
|
}
|
|
933
|
+
// A set held together (RFC 0004 B7) is booked from its holds: each must be the agent's, live,
|
|
934
|
+
// and for exactly this time.
|
|
935
|
+
const held = new Map();
|
|
936
|
+
if (input.hold_group_id !== undefined) {
|
|
937
|
+
const holds = await tenantStore.holdGroup({
|
|
938
|
+
tenantRef: context.principal.tenantRef,
|
|
939
|
+
holderRef: agentOwnerRef(context),
|
|
940
|
+
groupId: input.hold_group_id,
|
|
941
|
+
});
|
|
942
|
+
for (const hold of holds)
|
|
943
|
+
held.set(hold.resourceId, hold.holdId);
|
|
944
|
+
}
|
|
668
945
|
const events = [];
|
|
669
946
|
let replayed = true;
|
|
670
947
|
for (const resourceId of resourceIds) {
|
|
671
948
|
const key = `${input.idempotency_key}\0${resourceId}`;
|
|
949
|
+
const holdId = held.get(resourceId);
|
|
672
950
|
const result = await tenantStore.putCalendarEvent({
|
|
951
|
+
...(holdId !== undefined ? { fromHold: { holdId, holderRef: agentOwnerRef(context) } } : {}),
|
|
673
952
|
tenantRef: context.principal.tenantRef,
|
|
674
953
|
ownerRef: agentOwnerRef(context),
|
|
675
954
|
externalRef: agentExternalRef(context, key),
|
|
@@ -815,6 +1094,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
815
1094
|
const labels = input.labels;
|
|
816
1095
|
const location = input.location;
|
|
817
1096
|
const group = input.group;
|
|
1097
|
+
let words;
|
|
1098
|
+
try {
|
|
1099
|
+
words = typeof input.query === 'string' ? slotlockResourceSearchWords(input.query) : undefined;
|
|
1100
|
+
}
|
|
1101
|
+
catch {
|
|
1102
|
+
throw new SlotlockAgentOperationError('invalid_arguments', 400);
|
|
1103
|
+
}
|
|
818
1104
|
// What a resource says about itself, for an agent to show and choose by; its attributes are
|
|
819
1105
|
// the integrator's, and stay on the application API.
|
|
820
1106
|
const output = (resource) => ({
|
|
@@ -845,19 +1131,34 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
845
1131
|
const places = location === undefined
|
|
846
1132
|
? null
|
|
847
1133
|
: locationAndBelow(await tenantStore.listLocations({ tenantRef: context.principal.tenantRef }), location);
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1134
|
+
const members = (await tenantStore.listPoolMembers({
|
|
1135
|
+
tenantRef: context.principal.tenantRef,
|
|
1136
|
+
pool: input.pool,
|
|
1137
|
+
visibility: 'shared',
|
|
1138
|
+
})).filter((resource) => (kind === undefined || (resource.kind ?? 'other') === kind) &&
|
|
1139
|
+
(labels === undefined || slotlockResourceHasLabels(resource.labels, labels)) &&
|
|
1140
|
+
(places === null || (resource.location !== undefined && places.has(resource.location))) &&
|
|
1141
|
+
(group === undefined || resource.groups?.includes(group) === true));
|
|
1142
|
+
// Searched, the best matches first; else in the order slotlock_book_pool tries them.
|
|
1143
|
+
const shown = words === undefined
|
|
1144
|
+
? members
|
|
1145
|
+
: slotlockSearchResources(members, words)
|
|
1146
|
+
.slice(0, input.limit)
|
|
1147
|
+
.map((match) => match.resource);
|
|
1148
|
+
return { resources: shown.map(output), next_cursor: null };
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
if (words !== undefined) {
|
|
1152
|
+
// A search ranks every resource the filters allow (as many as a tenant may have), best first.
|
|
1153
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
1154
|
+
const candidates = await sharedResources(tenantStore, context.principal.tenantRef, {
|
|
1155
|
+
...(kind !== undefined ? { kind } : {}),
|
|
1156
|
+
...(labels !== undefined ? { labels } : {}),
|
|
1157
|
+
...(location !== undefined ? { location } : {}),
|
|
1158
|
+
...(group !== undefined ? { group } : {}),
|
|
1159
|
+
});
|
|
1160
|
+
const best = slotlockSearchResources(candidates, words).slice(0, input.limit);
|
|
1161
|
+
return { resources: best.map((match) => output(match.resource)), next_cursor: null };
|
|
861
1162
|
});
|
|
862
1163
|
}
|
|
863
1164
|
return withTenant(store, context, async (tenantStore) => {
|
|
@@ -921,6 +1222,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
921
1222
|
timezone: resource.timezone,
|
|
922
1223
|
window,
|
|
923
1224
|
now: new Date(),
|
|
1225
|
+
durationMs,
|
|
924
1226
|
});
|
|
925
1227
|
const sources = await requiredSources(options, context, resource);
|
|
926
1228
|
const freeBusy = await readFreeBusy(tenantStore, {
|
|
@@ -935,8 +1237,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
935
1237
|
return { slots: [], certain: false };
|
|
936
1238
|
if (!search)
|
|
937
1239
|
return { slots: [], certain: true };
|
|
938
|
-
|
|
939
|
-
|
|
1240
|
+
// Where the hours hold a hire only at its ends (hoursApply), a slot may run from one local
|
|
1241
|
+
// day to another: each from the range search, judged as a write of it is.
|
|
1242
|
+
if (hoursApplyOf(resource.effectiveBookingRules) === 'ends') {
|
|
1243
|
+
const ranged = await rangedSlots(tenantStore, context, resource, window, search.window.start.getTime(), durationMs, freeBusy.busy, input.limit, options.availabilityRules);
|
|
1244
|
+
return { slots: ranged.map((slot) => ({ start: slot.start.toISOString(), end: slot.end.toISOString() })), certain: true };
|
|
1245
|
+
}
|
|
1246
|
+
const windows = (await hoursWithin(tenantStore, context, [resource], search.window, options)).get(resource.id) ?? [];
|
|
940
1247
|
if (windows.length > MAX_REQUEST_RULE_WINDOWS)
|
|
941
1248
|
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
942
1249
|
const busy = bookingSearchBlocked(freeBusy.busy, search);
|
|
@@ -957,6 +1264,96 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
957
1264
|
return { slots, certain: true };
|
|
958
1265
|
});
|
|
959
1266
|
},
|
|
1267
|
+
async defaultDuration(context, resourceIds) {
|
|
1268
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
1269
|
+
const lengths = new Set();
|
|
1270
|
+
for (const resourceId of resourceIds) {
|
|
1271
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
1272
|
+
lengths.add(resource.effectiveBookingRules?.defaultDurationMinutes);
|
|
1273
|
+
}
|
|
1274
|
+
const [only] = [...lengths];
|
|
1275
|
+
return lengths.size === 1 && only !== undefined ? only : null;
|
|
1276
|
+
});
|
|
1277
|
+
},
|
|
1278
|
+
async findAvailableRanges(context, input) {
|
|
1279
|
+
const tenantRef = context.principal.tenantRef;
|
|
1280
|
+
const pickup = { start: new Date(input.pickup_start), end: new Date(input.pickup_end) };
|
|
1281
|
+
const days = input.days;
|
|
1282
|
+
const back = input.return_start !== undefined
|
|
1283
|
+
? { start: new Date(input.return_start), end: new Date(input.return_end) }
|
|
1284
|
+
: undefined;
|
|
1285
|
+
const limit = input.limit;
|
|
1286
|
+
const charge = busyBudget();
|
|
1287
|
+
const now = new Date();
|
|
1288
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
1289
|
+
let resources;
|
|
1290
|
+
if (typeof input.pool === 'string') {
|
|
1291
|
+
resources = await tenantStore.listPoolMembers({ tenantRef, pool: input.pool, visibility: 'shared', ordered: true });
|
|
1292
|
+
if (resources.length === 0)
|
|
1293
|
+
throw new SlotlockAgentOperationError('pool_not_found', 404);
|
|
1294
|
+
resources = resources.slice(0, 100);
|
|
1295
|
+
}
|
|
1296
|
+
else {
|
|
1297
|
+
resources = [];
|
|
1298
|
+
for (const resourceId of distinctResourceIds(input)) {
|
|
1299
|
+
resources.push(await requireAgentResource(tenantStore, tenantRef, resourceId));
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
// What a range may reach: the pick-up window, the longest it may run, and its buffers.
|
|
1303
|
+
const lastEnd = back ? back.end.getTime() : pickup.end.getTime() + ((days ?? 1) + 1) * 86_400_000;
|
|
1304
|
+
const reach = { start: new Date(pickup.start.getTime() - 86_400_000), end: new Date(lastEnd + 8 * 86_400_000) };
|
|
1305
|
+
const exceptions = await tenantStore.availabilityExceptionsFor({
|
|
1306
|
+
tenantRef,
|
|
1307
|
+
resourceIds: resources.map((resource) => resource.id),
|
|
1308
|
+
window: reach,
|
|
1309
|
+
});
|
|
1310
|
+
const ranges = [];
|
|
1311
|
+
let complete = true;
|
|
1312
|
+
for (const resource of resources) {
|
|
1313
|
+
const sources = await requiredSources(options, context, resource);
|
|
1314
|
+
const freeBusy = await readFreeBusy(tenantStore, {
|
|
1315
|
+
tenantRef,
|
|
1316
|
+
resourceId: resource.id,
|
|
1317
|
+
window: reach,
|
|
1318
|
+
requiredSources: [...sources],
|
|
1319
|
+
...(options.coverageMaxAgeMs !== undefined ? { coverageMaxAgeMs: options.coverageMaxAgeMs } : {}),
|
|
1320
|
+
});
|
|
1321
|
+
charge(freeBusy.busy.length);
|
|
1322
|
+
if (freeBusy.coverage.state !== 'complete') {
|
|
1323
|
+
complete = false;
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
const fallback = resource.availabilityRules === undefined && resource.inheritedAvailability === undefined
|
|
1327
|
+
? await options.availabilityRules(context, resource)
|
|
1328
|
+
: [];
|
|
1329
|
+
const found = findEarliestRange({
|
|
1330
|
+
busy: freeBusy.busy,
|
|
1331
|
+
rules: resource.effectiveBookingRules,
|
|
1332
|
+
timezone: resource.timezone,
|
|
1333
|
+
hours: applicableHours(resource, fallback).rules,
|
|
1334
|
+
exceptions: exceptions.get(resource.id) ?? [],
|
|
1335
|
+
now,
|
|
1336
|
+
pickup,
|
|
1337
|
+
...(back ? { return: back } : {}),
|
|
1338
|
+
...(days !== undefined ? { days } : {}),
|
|
1339
|
+
});
|
|
1340
|
+
if (found)
|
|
1341
|
+
ranges.push({ resource_id: resource.id, start: found.start.toISOString(), end: found.end.toISOString() });
|
|
1342
|
+
}
|
|
1343
|
+
const window = { start: pickup.start, end: new Date(lastEnd) };
|
|
1344
|
+
const answer = {
|
|
1345
|
+
ranges: complete
|
|
1346
|
+
? ranges
|
|
1347
|
+
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start) || a.resource_id.localeCompare(b.resource_id))
|
|
1348
|
+
.slice(0, limit)
|
|
1349
|
+
: [],
|
|
1350
|
+
coverage: coverageProjection(window, complete),
|
|
1351
|
+
};
|
|
1352
|
+
return typeof input.pool === 'string'
|
|
1353
|
+
? { ...answer, selected_resource_ids: resources.map((resource) => resource.id) }
|
|
1354
|
+
: answer;
|
|
1355
|
+
});
|
|
1356
|
+
},
|
|
960
1357
|
async findNextAvailable(context, input) {
|
|
961
1358
|
const window = interval(input);
|
|
962
1359
|
const resourceIds = distinctResourceIds(input);
|
|
@@ -974,6 +1371,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
974
1371
|
timezone: resource.timezone,
|
|
975
1372
|
window,
|
|
976
1373
|
now: new Date(),
|
|
1374
|
+
durationMs,
|
|
977
1375
|
});
|
|
978
1376
|
const sources = await requiredSources(options, context, resource);
|
|
979
1377
|
const freeBusy = await readFreeBusy(tenantStore, {
|
|
@@ -993,17 +1391,23 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
993
1391
|
}
|
|
994
1392
|
if (!search)
|
|
995
1393
|
continue;
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
windows
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1394
|
+
let candidate;
|
|
1395
|
+
if (hoursApplyOf(resource.effectiveBookingRules) === 'ends') {
|
|
1396
|
+
// A slot may run from one local day to another: the range search's earliest.
|
|
1397
|
+
[candidate = null] = await rangedSlots(tenantStore, context, resource, window, search.window.start.getTime(), durationMs, freeBusy.busy, 1, options.availabilityRules);
|
|
1398
|
+
}
|
|
1399
|
+
else {
|
|
1400
|
+
const windows = (await hoursWithin(tenantStore, context, [resource], search.window, options)).get(resource.id) ?? [];
|
|
1401
|
+
windowsLeft -= windows.length;
|
|
1402
|
+
if (windowsLeft < 0)
|
|
1403
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
1404
|
+
candidate = findNextAvailable({
|
|
1405
|
+
busy: bookingSearchBlocked(freeBusy.busy, search),
|
|
1406
|
+
windows,
|
|
1407
|
+
durationMs,
|
|
1408
|
+
...(search.align ? { align: search.align } : {}),
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1007
1411
|
if (candidate &&
|
|
1008
1412
|
(!best ||
|
|
1009
1413
|
candidate.start.getTime() < best.interval.start.getTime() ||
|
|
@@ -1066,6 +1470,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
1066
1470
|
...(typeof input.hold_id === 'string'
|
|
1067
1471
|
? { fromHold: { holdId: input.hold_id, holderRef: agentOwnerRef(context) } }
|
|
1068
1472
|
: {}),
|
|
1473
|
+
// Tell no one about it (RFC 0004 P3).
|
|
1474
|
+
...(input.notify === false ? { notify: false } : {}),
|
|
1069
1475
|
});
|
|
1070
1476
|
if (!result.ok)
|
|
1071
1477
|
throw operationError(result, await overlapDetails(tenantStore, context, result));
|
|
@@ -1237,6 +1643,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
1237
1643
|
...(current ? { expectedResourceId: current.resourceId } : {}),
|
|
1238
1644
|
idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
|
|
1239
1645
|
expectedRevision: input.expected_revision,
|
|
1646
|
+
// An agent is held to the resource's cancel cutoff (RFC 0004 B5).
|
|
1647
|
+
enforceBookingRules: true,
|
|
1240
1648
|
});
|
|
1241
1649
|
if (!result.ok)
|
|
1242
1650
|
throw operationError(result);
|
|
@@ -1254,6 +1662,42 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
1254
1662
|
};
|
|
1255
1663
|
return withApprovalPolicies(store, options, maxApprovalMinutes, backend);
|
|
1256
1664
|
}
|
|
1665
|
+
/** An exception as the API answers it. */
|
|
1666
|
+
function exceptionOutput(exception) {
|
|
1667
|
+
return {
|
|
1668
|
+
id: exception.id,
|
|
1669
|
+
location: exception.location ?? null,
|
|
1670
|
+
resource_id: exception.resourceId ?? null,
|
|
1671
|
+
starts_on: exception.startsOn,
|
|
1672
|
+
ends_on: exception.endsOn,
|
|
1673
|
+
outcome: exception.outcome,
|
|
1674
|
+
hours: exception.hours?.map((window) => ({ start_minutes: window.startMinutes, duration_minutes: window.durationMinutes })) ??
|
|
1675
|
+
null,
|
|
1676
|
+
label: exception.label,
|
|
1677
|
+
source: exception.source,
|
|
1678
|
+
calendar_id: exception.calendarId ?? null,
|
|
1679
|
+
created_at: exception.createdAt.toISOString(),
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
function holidayCalendarOutput(calendar) {
|
|
1683
|
+
return {
|
|
1684
|
+
calendar_id: calendar.id,
|
|
1685
|
+
name: calendar.name,
|
|
1686
|
+
location: calendar.location ?? null,
|
|
1687
|
+
resource_id: calendar.resourceId ?? null,
|
|
1688
|
+
url: calendar.url ?? null,
|
|
1689
|
+
imported_at: calendar.importedAt?.toISOString() ?? null,
|
|
1690
|
+
next_import_at: calendar.nextImportAt?.toISOString() ?? null,
|
|
1691
|
+
last_error: calendar.lastError ?? null,
|
|
1692
|
+
closures: calendar.closures,
|
|
1693
|
+
created_at: calendar.createdAt.toISOString(),
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
/** Days the exports and imports of hours reach: a year on from yesterday. */
|
|
1697
|
+
function exportDays() {
|
|
1698
|
+
const today = Math.floor(Date.now() / 86_400_000);
|
|
1699
|
+
return { from: dayToDate(today - 1), to: dayToDate(today + 366) };
|
|
1700
|
+
}
|
|
1257
1701
|
function storeRefusal(error) {
|
|
1258
1702
|
const code = error?.code;
|
|
1259
1703
|
if (typeof code !== 'string')
|
|
@@ -1280,9 +1724,12 @@ function storeRefusal(error) {
|
|
|
1280
1724
|
code === 'pool_order_limit_reached' ||
|
|
1281
1725
|
code === 'composite_too_deep' ||
|
|
1282
1726
|
code === 'composite_parts_limit' ||
|
|
1283
|
-
code === 'composite_busy'
|
|
1727
|
+
code === 'composite_busy' ||
|
|
1728
|
+
code === 'availability_exceptions_limit_reached') {
|
|
1284
1729
|
return new SlotlockAgentOperationError(code, 409);
|
|
1285
1730
|
}
|
|
1731
|
+
if (code === 'holiday_calendar_unreachable')
|
|
1732
|
+
return new SlotlockAgentOperationError(code, 400);
|
|
1286
1733
|
if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
|
|
1287
1734
|
return new SlotlockAgentOperationError(code, 400);
|
|
1288
1735
|
}
|
|
@@ -1331,6 +1778,7 @@ function approvalPolicyOutput(policy) {
|
|
|
1331
1778
|
outcome: policy.outcome,
|
|
1332
1779
|
approval_ttl_minutes: policy.approvalTtlMinutes,
|
|
1333
1780
|
lead_minutes: policy.leadMinutes,
|
|
1781
|
+
include_application: policy.includeApplication,
|
|
1334
1782
|
created_at: policy.createdAt.toISOString(),
|
|
1335
1783
|
updated_at: policy.updatedAt.toISOString(),
|
|
1336
1784
|
};
|
|
@@ -1370,6 +1818,16 @@ function approvalOutput(approval) {
|
|
|
1370
1818
|
created_at: approval.createdAt.toISOString(),
|
|
1371
1819
|
};
|
|
1372
1820
|
}
|
|
1821
|
+
/** A store refusal as the application API answers it, a broken booking rule by its own code. */
|
|
1822
|
+
function applicationRuleRefusal(result) {
|
|
1823
|
+
return result.code === 'booking_rule_violated' && result.rule
|
|
1824
|
+
? new SlotlockAgentOperationError(`booking_rule_${result.rule}`, 409)
|
|
1825
|
+
: applicationRefusal(result.code);
|
|
1826
|
+
}
|
|
1827
|
+
/** A recorded cancellation fee as the API answers it (RFC 0004 B5). */
|
|
1828
|
+
function feeOutput(fee) {
|
|
1829
|
+
return fee ? { amount_minor: fee.amountMinor, currency: fee.currency } : null;
|
|
1830
|
+
}
|
|
1373
1831
|
function applicationRefusal(code) {
|
|
1374
1832
|
if (code === 'resource_tenant_mismatch') {
|
|
1375
1833
|
return new SlotlockAgentOperationError('resource_not_found', 404);
|
|
@@ -1393,6 +1851,8 @@ function waitlistOutput(entry) {
|
|
|
1393
1851
|
hold_id: entry.holdId,
|
|
1394
1852
|
offered_resource_id: entry.offeredResourceId,
|
|
1395
1853
|
offered_at: entry.offeredAt?.toISOString() ?? null,
|
|
1854
|
+
duration_minutes: entry.durationMs === null ? null : Math.round(entry.durationMs / 60_000),
|
|
1855
|
+
decided_at: entry.decidedAt?.toISOString() ?? null,
|
|
1396
1856
|
created_at: entry.createdAt.toISOString(),
|
|
1397
1857
|
};
|
|
1398
1858
|
}
|
|
@@ -1409,6 +1869,8 @@ function keyApprovalPolicy() {
|
|
|
1409
1869
|
outcome: 'require_approval',
|
|
1410
1870
|
approvalTtlMinutes: SLOTLOCK_DEFAULT_APPROVAL_MINUTES,
|
|
1411
1871
|
leadMinutes: 0,
|
|
1872
|
+
// The key's own approval holds whatever it books, for the tenant too.
|
|
1873
|
+
includeApplication: true,
|
|
1412
1874
|
createdAt: new Date(0),
|
|
1413
1875
|
updatedAt: new Date(0),
|
|
1414
1876
|
};
|
|
@@ -1477,10 +1939,26 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1477
1939
|
/** Whether [start, end) on the resource falls outside its bookable hours. */
|
|
1478
1940
|
async function outsideHours(tenantStore, context, resourceId, start, end) {
|
|
1479
1941
|
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
1480
|
-
const
|
|
1481
|
-
const windows =
|
|
1482
|
-
return !windows
|
|
1942
|
+
const reach = { start: new Date(start.getTime() - 86_400_000), end: new Date(end.getTime() + 86_400_000) };
|
|
1943
|
+
const windows = (await hoursWithin(tenantStore, context, [resource], reach, options)).get(resource.id) ?? [];
|
|
1944
|
+
return !coveredByOpenWindows(windows, { start, end });
|
|
1483
1945
|
}
|
|
1946
|
+
/**
|
|
1947
|
+
* What an update may change without booking any time: everything else (time, resource, zone,
|
|
1948
|
+
* transparency, status, recurrence and its exceptions) is judged by what it would add.
|
|
1949
|
+
*/
|
|
1950
|
+
const EVENT_DETAIL_FIELDS = new Set([
|
|
1951
|
+
'event_id',
|
|
1952
|
+
'expected_revision',
|
|
1953
|
+
'expected_resource_id',
|
|
1954
|
+
'idempotency_key',
|
|
1955
|
+
'title',
|
|
1956
|
+
'description',
|
|
1957
|
+
'location',
|
|
1958
|
+
'organizer',
|
|
1959
|
+
'attendees',
|
|
1960
|
+
'reminders',
|
|
1961
|
+
]);
|
|
1484
1962
|
/** The write's ask and the policies that may cover it, or null when none can. */
|
|
1485
1963
|
async function plan(tenantStore, context, operation, input) {
|
|
1486
1964
|
const tenantRef = context.principal.tenantRef;
|
|
@@ -1538,15 +2016,18 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1538
2016
|
const current = await tenantStore.getCalendarEvent({ tenantRef, ownerRef, externalRef: input.event_id });
|
|
1539
2017
|
if (!current)
|
|
1540
2018
|
return null;
|
|
2019
|
+
// Details alone book nothing. Any other change may: a move, or time the event did not hold
|
|
2020
|
+
// (made opaque or confirmed, given occurrences or fewer exceptions, another zone), judged
|
|
2021
|
+
// below by what the write would add to what the event holds.
|
|
2022
|
+
if (Object.keys(input).every((field) => EVENT_DETAIL_FIELDS.has(field)))
|
|
2023
|
+
return null;
|
|
1541
2024
|
const resourceId = input.resource_id ?? current.resourceId;
|
|
1542
2025
|
const start = input.starts_at ? new Date(input.starts_at) : current.start;
|
|
1543
2026
|
const end = input.ends_at ? new Date(input.ends_at) : current.end;
|
|
1544
|
-
|
|
1545
|
-
if (resourceId === current.resourceId && start.getTime() === current.start.getTime() && end.getTime() === current.end.getTime()) {
|
|
1546
|
-
return null;
|
|
1547
|
-
}
|
|
2027
|
+
const moved = resourceId !== current.resourceId || start.getTime() !== current.start.getTime() || end.getTime() !== current.end.getTime();
|
|
1548
2028
|
asked = {
|
|
1549
|
-
|
|
2029
|
+
// Time added in place is a booking of its own; a move is a move.
|
|
2030
|
+
kind: moved ? 'move' : 'single',
|
|
1550
2031
|
stored: 'slotlock_update_event',
|
|
1551
2032
|
resourceIds: [resourceId],
|
|
1552
2033
|
externalRefs: [current.externalRef],
|
|
@@ -1558,7 +2039,10 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1558
2039
|
}
|
|
1559
2040
|
const minutes = (asked.end.getTime() - asked.start.getTime()) / 60_000;
|
|
1560
2041
|
// 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) =>
|
|
2042
|
+
const policies = (await tenantStore.approvalPoliciesFor({ tenantRef, resourceIds: asked.resourceIds })).filter((policy) =>
|
|
2043
|
+
// The tenant's own bookings are held only by policies that include the application.
|
|
2044
|
+
(!forTenant(context) || policy.includeApplication === true) &&
|
|
2045
|
+
(policy.conditions.kinds === undefined || policy.conditions.kinds.includes(asked.kind)) &&
|
|
1562
2046
|
(policy.conditions.longerThanMinutes === undefined || minutes > policy.conditions.longerThanMinutes));
|
|
1563
2047
|
if (policies.length === 0 && !keyPolicy)
|
|
1564
2048
|
return null;
|
|
@@ -1580,18 +2064,20 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1580
2064
|
});
|
|
1581
2065
|
if (asked.prior) {
|
|
1582
2066
|
const { hold_id: _held, ...request } = input;
|
|
1583
|
-
// Granted: the booking was made with this key, so the write answers it as a replay (and
|
|
1584
|
-
// the store refuses another request with it). Without the hold the approval took over,
|
|
1585
|
-
// as the grant made it.
|
|
1586
|
-
if (asked.prior.status === 'granted')
|
|
1587
|
-
return method.call(this, context, request);
|
|
1588
2067
|
const kept = { ...asked.prior.request };
|
|
1589
2068
|
// A pool booking is kept as the member it would take.
|
|
1590
2069
|
if (operation === 'slotlock_book_pool')
|
|
1591
2070
|
delete kept.resource_id;
|
|
2071
|
+
// The key answers only the request a person was asked about, granted or not: a grant
|
|
2072
|
+
// approves that booking, never another made with its key (a bundle's other resources, say,
|
|
2073
|
+
// which the store would take as new).
|
|
1592
2074
|
if (JSON.stringify(canonical(kept)) !== JSON.stringify(canonical(request))) {
|
|
1593
2075
|
throw new SlotlockAgentOperationError('idempotency_conflict', 409);
|
|
1594
2076
|
}
|
|
2077
|
+
// Granted: the booking was made with this key, so the write answers it as a replay.
|
|
2078
|
+
// Without the hold the approval took over, as the grant made it.
|
|
2079
|
+
if (asked.prior.status === 'granted')
|
|
2080
|
+
return method.call(this, context, request);
|
|
1595
2081
|
throw approvalAnswer(asked.prior);
|
|
1596
2082
|
}
|
|
1597
2083
|
const ask = asked.plan;
|
|
@@ -1615,6 +2101,11 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1615
2101
|
if (probed.replayed === true || occupied.length === 0) {
|
|
1616
2102
|
return context.dryRun ? probed : method.call(this, context, input);
|
|
1617
2103
|
}
|
|
2104
|
+
// Only time the event did not already hold needs a person: an edit or a move within its own
|
|
2105
|
+
// time books nothing new.
|
|
2106
|
+
const holds = uncovered(occupied, ask.before);
|
|
2107
|
+
if (holds.length === 0)
|
|
2108
|
+
return context.dryRun ? probed : method.call(this, context, input);
|
|
1618
2109
|
const written = [...new Set(occupied.map((occupation) => occupation.resourceId))];
|
|
1619
2110
|
const decided = await withTenant(store, { ...context, dryRun: false }, async (tenantStore) => {
|
|
1620
2111
|
// A pool's policies are those of the member the write would take, and its pool.
|
|
@@ -1662,9 +2153,6 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1662
2153
|
expires_at: expiresAt.toISOString(),
|
|
1663
2154
|
});
|
|
1664
2155
|
}
|
|
1665
|
-
const holds = uncovered(occupied, ask.before);
|
|
1666
|
-
if (holds.length === 0)
|
|
1667
|
-
return method.call(this, context, input);
|
|
1668
2156
|
const chosen = ask.pool ? written[0] : undefined;
|
|
1669
2157
|
const { hold_id: _hold, ...request } = input;
|
|
1670
2158
|
const principal = context.principal;
|
|
@@ -1682,6 +2170,8 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1682
2170
|
: {}),
|
|
1683
2171
|
...(context.origin !== undefined ? { origin: context.origin } : {}),
|
|
1684
2172
|
...(ask.pool !== undefined ? { pool: ask.pool } : {}),
|
|
2173
|
+
// Granted, it is made as the tenant's again, never as the key's agent.
|
|
2174
|
+
...(forTenant(context) ? { for: 'tenant' } : {}),
|
|
1685
2175
|
},
|
|
1686
2176
|
requesterRef: principal.subject,
|
|
1687
2177
|
requestKey,
|
|
@@ -1690,8 +2180,14 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1690
2180
|
end: ask.end,
|
|
1691
2181
|
expiresAt,
|
|
1692
2182
|
...(ask.consumeHold ? { consumeHold: ask.consumeHold } : {}),
|
|
2183
|
+
// An agent's approvals hold time against its own caps (RFC 0004 A5); the tenant's own
|
|
2184
|
+
// systems have none.
|
|
2185
|
+
...(forTenant(context) ? {} : { holder: approvalHolder(context) }),
|
|
1693
2186
|
}), WRITE);
|
|
1694
2187
|
if (!result.ok) {
|
|
2188
|
+
if (result.code === 'hold_limit_reached' || result.code === 'held_time_limit_reached') {
|
|
2189
|
+
throw new SlotlockAgentOperationError(result.code, 409, { limit: String(result.limit) });
|
|
2190
|
+
}
|
|
1695
2191
|
if (result.code === 'approval_limit_reached') {
|
|
1696
2192
|
throw new SlotlockAgentOperationError('approval_limit_reached', 429, { limit: String(result.limit ?? '') });
|
|
1697
2193
|
}
|
|
@@ -1751,6 +2247,7 @@ export async function runSlotlockApprovalGrant(agentBackend, approval, grant) {
|
|
|
1751
2247
|
decidedBy: grant.decidedBy,
|
|
1752
2248
|
...(typeof requester.pool === 'string' ? { pool: requester.pool } : {}),
|
|
1753
2249
|
},
|
|
2250
|
+
...(requester.for === 'tenant' ? { [TENANT_ACTOR]: true } : {}),
|
|
1754
2251
|
};
|
|
1755
2252
|
const method = approval.operation === 'slotlock_book_together'
|
|
1756
2253
|
? agentBackend.bookTogether
|
|
@@ -1838,8 +2335,34 @@ function bookingRulesOutput(rules) {
|
|
|
1838
2335
|
...(rules.enforceHours !== undefined ? { enforce_hours: rules.enforceHours } : {}),
|
|
1839
2336
|
...(rules.checkInMinutes !== undefined ? { check_in_minutes: rules.checkInMinutes } : {}),
|
|
1840
2337
|
...(rules.requireCertain !== undefined ? { require_certain: rules.requireCertain } : {}),
|
|
2338
|
+
...(rules.minDurationMinutes !== undefined ? { min_duration_minutes: rules.minDurationMinutes } : {}),
|
|
2339
|
+
...(rules.maxDurationMinutes !== undefined ? { max_duration_minutes: rules.maxDurationMinutes } : {}),
|
|
2340
|
+
...(rules.allowedDurations !== undefined ? { allowed_durations: [...rules.allowedDurations] } : {}),
|
|
2341
|
+
...(rules.defaultDurationMinutes !== undefined ? { default_duration_minutes: rules.defaultDurationMinutes } : {}),
|
|
2342
|
+
...(rules.pickupTimes !== undefined ? { pickup_times: dayWindowsOutput(rules.pickupTimes) } : {}),
|
|
2343
|
+
...(rules.returnTimes !== undefined ? { return_times: dayWindowsOutput(rules.returnTimes) } : {}),
|
|
2344
|
+
...(rules.hoursApply !== undefined ? { hours_apply: rules.hoursApply } : {}),
|
|
2345
|
+
...(rules.cancelCutoffMinutes !== undefined ? { cancel_cutoff_minutes: rules.cancelCutoffMinutes } : {}),
|
|
2346
|
+
...(rules.rescheduleCutoffMinutes !== undefined ? { reschedule_cutoff_minutes: rules.rescheduleCutoffMinutes } : {}),
|
|
2347
|
+
...(rules.cancellationFee !== undefined
|
|
2348
|
+
? {
|
|
2349
|
+
cancellation_fee: {
|
|
2350
|
+
amount_minor: rules.cancellationFee.amountMinor,
|
|
2351
|
+
currency: rules.cancellationFee.currency,
|
|
2352
|
+
...(rules.cancellationFee.withinMinutes !== undefined ? { within_minutes: rules.cancellationFee.withinMinutes } : {}),
|
|
2353
|
+
},
|
|
2354
|
+
}
|
|
2355
|
+
: {}),
|
|
2356
|
+
...(rules.holdPrice !== undefined
|
|
2357
|
+
? { hold_price: { amount_minor: rules.holdPrice.amountMinor, currency: rules.holdPrice.currency } }
|
|
2358
|
+
: {}),
|
|
1841
2359
|
};
|
|
1842
2360
|
}
|
|
2361
|
+
const dayWindowsOutput = (windows) => windows.map((window) => ({ start_minutes: window.startMinutes, duration_minutes: window.durationMinutes }));
|
|
2362
|
+
const dayWindowsInput = (value) => value.map((window) => ({
|
|
2363
|
+
startMinutes: window.start_minutes,
|
|
2364
|
+
durationMinutes: window.duration_minutes,
|
|
2365
|
+
}));
|
|
1843
2366
|
function bookingRulesInput(value) {
|
|
1844
2367
|
if (value === null || value === undefined)
|
|
1845
2368
|
return null;
|
|
@@ -1864,6 +2387,38 @@ function bookingRulesInput(value) {
|
|
|
1864
2387
|
rules.checkInMinutes = input.check_in_minutes;
|
|
1865
2388
|
if (input.require_certain !== undefined)
|
|
1866
2389
|
rules.requireCertain = input.require_certain;
|
|
2390
|
+
if (input.min_duration_minutes !== undefined)
|
|
2391
|
+
rules.minDurationMinutes = input.min_duration_minutes;
|
|
2392
|
+
if (input.max_duration_minutes !== undefined)
|
|
2393
|
+
rules.maxDurationMinutes = input.max_duration_minutes;
|
|
2394
|
+
if (input.allowed_durations !== undefined)
|
|
2395
|
+
rules.allowedDurations = [...input.allowed_durations];
|
|
2396
|
+
if (input.default_duration_minutes !== undefined) {
|
|
2397
|
+
rules.defaultDurationMinutes = input.default_duration_minutes;
|
|
2398
|
+
}
|
|
2399
|
+
if (input.pickup_times !== undefined)
|
|
2400
|
+
rules.pickupTimes = dayWindowsInput(input.pickup_times);
|
|
2401
|
+
if (input.return_times !== undefined)
|
|
2402
|
+
rules.returnTimes = dayWindowsInput(input.return_times);
|
|
2403
|
+
if (input.hours_apply === 'whole' || input.hours_apply === 'ends')
|
|
2404
|
+
rules.hoursApply = input.hours_apply;
|
|
2405
|
+
if (input.cancel_cutoff_minutes !== undefined)
|
|
2406
|
+
rules.cancelCutoffMinutes = input.cancel_cutoff_minutes;
|
|
2407
|
+
if (input.reschedule_cutoff_minutes !== undefined) {
|
|
2408
|
+
rules.rescheduleCutoffMinutes = input.reschedule_cutoff_minutes;
|
|
2409
|
+
}
|
|
2410
|
+
if (input.cancellation_fee !== undefined) {
|
|
2411
|
+
const fee = input.cancellation_fee;
|
|
2412
|
+
rules.cancellationFee = {
|
|
2413
|
+
amountMinor: fee.amount_minor,
|
|
2414
|
+
currency: fee.currency,
|
|
2415
|
+
...(fee.within_minutes !== undefined ? { withinMinutes: fee.within_minutes } : {}),
|
|
2416
|
+
};
|
|
2417
|
+
}
|
|
2418
|
+
if (input.hold_price !== undefined) {
|
|
2419
|
+
const price = input.hold_price;
|
|
2420
|
+
rules.holdPrice = { amountMinor: price.amount_minor, currency: price.currency };
|
|
2421
|
+
}
|
|
1867
2422
|
return rules;
|
|
1868
2423
|
}
|
|
1869
2424
|
function resourceOutput(resource) {
|
|
@@ -1886,6 +2441,13 @@ function resourceOutput(resource) {
|
|
|
1886
2441
|
location: resource.location ?? null,
|
|
1887
2442
|
groups: resource.groups ?? [],
|
|
1888
2443
|
parent_id: resource.parentId ?? null,
|
|
2444
|
+
inherited_availability: resource.inheritedAvailability
|
|
2445
|
+
? {
|
|
2446
|
+
source: resource.inheritedAvailability.source,
|
|
2447
|
+
location: resource.inheritedAvailability.location ?? null,
|
|
2448
|
+
rules: rulesOutput(resource.inheritedAvailability.rules) ?? [],
|
|
2449
|
+
}
|
|
2450
|
+
: null,
|
|
1889
2451
|
part_index: resource.partIndex ?? null,
|
|
1890
2452
|
};
|
|
1891
2453
|
}
|
|
@@ -1941,6 +2503,36 @@ function holdOutput(hold) {
|
|
|
1941
2503
|
end: hold.end.toISOString(),
|
|
1942
2504
|
expires_at: hold.expiresAt.toISOString(),
|
|
1943
2505
|
source: hold.source,
|
|
2506
|
+
...(hold.paymentRequired
|
|
2507
|
+
? {
|
|
2508
|
+
payment_required: true,
|
|
2509
|
+
payment_ref: hold.paymentRef ?? null,
|
|
2510
|
+
paid_at: hold.paidAt?.toISOString() ?? null,
|
|
2511
|
+
payment_amount: amountOutput(hold.paymentAmount),
|
|
2512
|
+
paid_amount: amountOutput(hold.paidAmount),
|
|
2513
|
+
}
|
|
2514
|
+
: {}),
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
function amountOutput(amount) {
|
|
2518
|
+
return amount ? { amount_minor: amount.amountMinor, currency: amount.currency } : null;
|
|
2519
|
+
}
|
|
2520
|
+
function amountInput(value) {
|
|
2521
|
+
const amount = value;
|
|
2522
|
+
return amount ? { amountMinor: amount.amount_minor, currency: amount.currency } : undefined;
|
|
2523
|
+
}
|
|
2524
|
+
function contactOutput(contact) {
|
|
2525
|
+
return {
|
|
2526
|
+
contact_ref: contact.ref,
|
|
2527
|
+
name: contact.name,
|
|
2528
|
+
email: contact.email,
|
|
2529
|
+
phone: contact.phone,
|
|
2530
|
+
whatsapp: contact.whatsapp,
|
|
2531
|
+
consent: contact.consent,
|
|
2532
|
+
consent_at: Object.fromEntries(Object.entries(contact.consentAt).map(([channel, at]) => [channel, at.toISOString()])),
|
|
2533
|
+
locale: contact.locale,
|
|
2534
|
+
created_at: contact.createdAt.toISOString(),
|
|
2535
|
+
updated_at: contact.updatedAt.toISOString(),
|
|
1944
2536
|
};
|
|
1945
2537
|
}
|
|
1946
2538
|
function reservationOutput(reservation) {
|
|
@@ -1954,6 +2546,9 @@ function reservationOutput(reservation) {
|
|
|
1954
2546
|
buffer_after_minutes: Math.ceil(reservation.bufferAfterMs / 60_000),
|
|
1955
2547
|
revision: reservation.revision,
|
|
1956
2548
|
source: reservation.source,
|
|
2549
|
+
...(reservation.contactRef ? { contact_ref: reservation.contactRef } : {}),
|
|
2550
|
+
...(reservation.notify === false ? { notify: false } : {}),
|
|
2551
|
+
...(reservation.reminderMinutes ? { reminder_minutes: reservation.reminderMinutes } : {}),
|
|
1957
2552
|
};
|
|
1958
2553
|
}
|
|
1959
2554
|
/**
|
|
@@ -2288,15 +2883,29 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2288
2883
|
async exportResourceAvailability(context, input) {
|
|
2289
2884
|
return withTenant(store, context, async (tenant) => {
|
|
2290
2885
|
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
2291
|
-
|
|
2886
|
+
const rules = resource.availabilityRules ?? resource.inheritedAvailability?.rules;
|
|
2887
|
+
if (rules === undefined)
|
|
2292
2888
|
return { icalendar: null };
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
}),
|
|
2889
|
+
// The year ahead's closures and special hours, as EXDATE and RDATE (RFC 0004 B1).
|
|
2890
|
+
const span = exportDays();
|
|
2891
|
+
const window = {
|
|
2892
|
+
start: new Date(epochDay(span.from) * 86_400_000),
|
|
2893
|
+
end: new Date((epochDay(span.to) + 1) * 86_400_000),
|
|
2299
2894
|
};
|
|
2895
|
+
const exceptions = await tenant.availabilityExceptionsFor({
|
|
2896
|
+
tenantRef: tenantOf(context),
|
|
2897
|
+
resourceIds: [resource.id],
|
|
2898
|
+
window,
|
|
2899
|
+
});
|
|
2900
|
+
const icalendar = emitVAvailability({
|
|
2901
|
+
rules,
|
|
2902
|
+
timezone: resource.timezone,
|
|
2903
|
+
uid: `${resource.id}-hours`,
|
|
2904
|
+
days: exceptionDays(exceptions.get(resource.id) ?? [], span.from, span.to),
|
|
2905
|
+
});
|
|
2906
|
+
if (icalendar.length > 200_000)
|
|
2907
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
2908
|
+
return { icalendar };
|
|
2300
2909
|
});
|
|
2301
2910
|
},
|
|
2302
2911
|
async importResourceAvailability(context, input) {
|
|
@@ -2326,6 +2935,14 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2326
2935
|
id,
|
|
2327
2936
|
rules: parsed.rules,
|
|
2328
2937
|
});
|
|
2938
|
+
if (resource) {
|
|
2939
|
+
// The days it names replace those an earlier import of its hours made.
|
|
2940
|
+
await tenant.replaceImportedExceptions({
|
|
2941
|
+
tenantRef: tenantOf(context),
|
|
2942
|
+
resourceId: id,
|
|
2943
|
+
ranges: exceptionRanges(parsed.days),
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2329
2946
|
}
|
|
2330
2947
|
catch (error) {
|
|
2331
2948
|
throw storeRefusal(error);
|
|
@@ -2460,11 +3077,142 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2460
3077
|
const rules = rulesInput(input.rules);
|
|
2461
3078
|
return withTenant(store, context, async (tenant) => {
|
|
2462
3079
|
try {
|
|
3080
|
+
const result = await tenant.storeTenantAvailability({
|
|
3081
|
+
tenantRef: tenantOf(context),
|
|
3082
|
+
rules,
|
|
3083
|
+
overrideResources: SLOTLOCK_OVERRIDE_RESOURCES.includes(input.override_resources)
|
|
3084
|
+
? input.override_resources
|
|
3085
|
+
: 'none',
|
|
3086
|
+
});
|
|
3087
|
+
return { resources_updated: result.inheriting, resources_with_own_hours: result.ownHours };
|
|
3088
|
+
}
|
|
3089
|
+
catch (error) {
|
|
3090
|
+
throw storeRefusal(error);
|
|
3091
|
+
}
|
|
3092
|
+
});
|
|
3093
|
+
},
|
|
3094
|
+
async getAvailabilityDefaults(context) {
|
|
3095
|
+
return withTenant(store, context, async (tenant) => {
|
|
3096
|
+
const defaults = await tenant.getAvailabilityDefaults({ tenantRef: tenantOf(context) });
|
|
3097
|
+
return {
|
|
3098
|
+
tenant: rulesOutput(defaults.tenant),
|
|
3099
|
+
locations: defaults.locations.map((entry) => ({
|
|
3100
|
+
location: entry.location,
|
|
3101
|
+
rules: rulesOutput(entry.rules) ?? [],
|
|
3102
|
+
})),
|
|
3103
|
+
};
|
|
3104
|
+
});
|
|
3105
|
+
},
|
|
3106
|
+
async setLocationAvailability(context, input) {
|
|
3107
|
+
const rules = rulesInput(input.rules);
|
|
3108
|
+
const location = input.location;
|
|
3109
|
+
return withTenant(store, context, async (tenant) => {
|
|
3110
|
+
let result;
|
|
3111
|
+
try {
|
|
3112
|
+
result = await tenant.setLocationAvailability({ tenantRef: tenantOf(context), location, rules });
|
|
3113
|
+
}
|
|
3114
|
+
catch (error) {
|
|
3115
|
+
throw storeRefusal(error);
|
|
3116
|
+
}
|
|
3117
|
+
if (!result)
|
|
3118
|
+
throw new SlotlockAgentOperationError('location_not_found', 404);
|
|
3119
|
+
return { location, resources_updated: result.inheriting, resources_with_own_hours: result.ownHours };
|
|
3120
|
+
});
|
|
3121
|
+
},
|
|
3122
|
+
async listAvailabilityExceptions(context, input) {
|
|
3123
|
+
const limit = input.limit ?? 100;
|
|
3124
|
+
return withTenant(store, context, async (tenant) => {
|
|
3125
|
+
let exceptions;
|
|
3126
|
+
try {
|
|
3127
|
+
exceptions = await tenant.listAvailabilityExceptions({
|
|
3128
|
+
tenantRef: tenantOf(context),
|
|
3129
|
+
...(input.resource_id !== undefined ? { resourceId: input.resource_id } : {}),
|
|
3130
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
3131
|
+
...(input.from !== undefined ? { from: input.from } : {}),
|
|
3132
|
+
...(input.to !== undefined ? { to: input.to } : {}),
|
|
3133
|
+
...(input.after !== undefined ? { after: input.after } : {}),
|
|
3134
|
+
limit: limit + 1,
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
3137
|
+
catch (error) {
|
|
3138
|
+
throw storeRefusal(error);
|
|
3139
|
+
}
|
|
3140
|
+
const page = exceptions.slice(0, limit);
|
|
3141
|
+
return {
|
|
3142
|
+
exceptions: page.map(exceptionOutput),
|
|
3143
|
+
next: exceptions.length > limit ? (page.at(-1)?.id ?? null) : null,
|
|
3144
|
+
};
|
|
3145
|
+
});
|
|
3146
|
+
},
|
|
3147
|
+
async createAvailabilityException(context, input) {
|
|
3148
|
+
const hours = input.hours;
|
|
3149
|
+
return withTenant(store, context, async (tenant) => {
|
|
3150
|
+
try {
|
|
3151
|
+
const exception = await tenant.createAvailabilityException({
|
|
3152
|
+
tenantRef: tenantOf(context),
|
|
3153
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
3154
|
+
...(input.resource_id !== undefined ? { resourceId: input.resource_id } : {}),
|
|
3155
|
+
startsOn: input.starts_on,
|
|
3156
|
+
...(input.ends_on !== undefined ? { endsOn: input.ends_on } : {}),
|
|
3157
|
+
outcome: input.outcome,
|
|
3158
|
+
...(hours
|
|
3159
|
+
? { hours: hours.map((window) => ({ startMinutes: window.start_minutes, durationMinutes: window.duration_minutes })) }
|
|
3160
|
+
: {}),
|
|
3161
|
+
label: input.label,
|
|
3162
|
+
});
|
|
3163
|
+
return { exception: exceptionOutput(exception) };
|
|
3164
|
+
}
|
|
3165
|
+
catch (error) {
|
|
3166
|
+
throw storeRefusal(error);
|
|
3167
|
+
}
|
|
3168
|
+
});
|
|
3169
|
+
},
|
|
3170
|
+
async deleteAvailabilityException(context, input) {
|
|
3171
|
+
return withTenant(store, context, async (tenant) => {
|
|
3172
|
+
const deleted = await tenant.deleteAvailabilityException({
|
|
3173
|
+
tenantRef: tenantOf(context),
|
|
3174
|
+
id: input.exception_id,
|
|
3175
|
+
});
|
|
3176
|
+
if (!deleted)
|
|
3177
|
+
throw new SlotlockAgentOperationError('availability_exception_not_found', 404);
|
|
3178
|
+
return { deleted: true };
|
|
3179
|
+
});
|
|
3180
|
+
},
|
|
3181
|
+
async importHolidays(context, input) {
|
|
3182
|
+
if (input.location !== undefined && input.resource_id !== undefined) {
|
|
3183
|
+
throw new SlotlockAgentOperationError('invalid_holiday_calendar', 400, { reason: 'a location or a resource, not both' });
|
|
3184
|
+
}
|
|
3185
|
+
// Read (a URL fetched) before the transaction opens.
|
|
3186
|
+
let read;
|
|
3187
|
+
try {
|
|
3188
|
+
read = await readSlotlockHolidayCalendar({
|
|
3189
|
+
...(input.icalendar !== undefined ? { icalendar: input.icalendar } : {}),
|
|
3190
|
+
...(input.url !== undefined ? { url: input.url } : {}),
|
|
3191
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
3192
|
+
...(input.subscribe !== undefined ? { subscribe: input.subscribe } : {}),
|
|
3193
|
+
...(input.from !== undefined ? { from: input.from } : {}),
|
|
3194
|
+
...(input.to !== undefined ? { to: input.to } : {}),
|
|
3195
|
+
}, options.holidayFetch);
|
|
3196
|
+
}
|
|
3197
|
+
catch (error) {
|
|
3198
|
+
throw storeRefusal(error);
|
|
3199
|
+
}
|
|
3200
|
+
return withTenant(store, context, async (tenant) => {
|
|
3201
|
+
try {
|
|
3202
|
+
const result = await tenant.importHolidayCalendar({
|
|
3203
|
+
tenantRef: tenantOf(context),
|
|
3204
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
3205
|
+
...(input.resource_id !== undefined ? { resourceId: input.resource_id } : {}),
|
|
3206
|
+
name: read.name,
|
|
3207
|
+
...(read.url !== undefined ? { url: read.url } : {}),
|
|
3208
|
+
holidays: read.holidays,
|
|
3209
|
+
span: read.span,
|
|
3210
|
+
});
|
|
2463
3211
|
return {
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
3212
|
+
imported: result.imported,
|
|
3213
|
+
skipped: read.skipped,
|
|
3214
|
+
removed: result.removed,
|
|
3215
|
+
calendar: holidayCalendarOutput(result.calendar),
|
|
2468
3216
|
};
|
|
2469
3217
|
}
|
|
2470
3218
|
catch (error) {
|
|
@@ -2472,6 +3220,17 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2472
3220
|
}
|
|
2473
3221
|
});
|
|
2474
3222
|
},
|
|
3223
|
+
async listHolidayCalendars(context) {
|
|
3224
|
+
return withTenant(store, context, async (tenant) => ({
|
|
3225
|
+
calendars: (await tenant.listHolidayCalendars({ tenantRef: tenantOf(context) })).map(holidayCalendarOutput),
|
|
3226
|
+
}));
|
|
3227
|
+
},
|
|
3228
|
+
async deleteHolidayCalendar(context, input) {
|
|
3229
|
+
return withTenant(store, context, async (tenant) => {
|
|
3230
|
+
const deleted = await tenant.deleteHolidayCalendar({ tenantRef: tenantOf(context), id: input.calendar_id });
|
|
3231
|
+
return { deleted: deleted !== null, closures: deleted?.closures ?? 0 };
|
|
3232
|
+
});
|
|
3233
|
+
},
|
|
2475
3234
|
async acquireHold(context, input) {
|
|
2476
3235
|
return withTenant(store, context, async (tenant) => {
|
|
2477
3236
|
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
@@ -2485,6 +3244,8 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2485
3244
|
...(input.buffer_after_minutes !== undefined
|
|
2486
3245
|
? { bufferAfterMs: input.buffer_after_minutes * 60_000 }
|
|
2487
3246
|
: {}),
|
|
3247
|
+
...(input.payment_required === true ? { paymentRequired: true } : {}),
|
|
3248
|
+
...(input.payment !== undefined ? { payment: amountInput(input.payment) } : {}),
|
|
2488
3249
|
// A retried request with the caller's key answers the hold the first one made.
|
|
2489
3250
|
...(input.idempotency_key !== undefined
|
|
2490
3251
|
? {
|
|
@@ -2534,6 +3295,69 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2534
3295
|
return { reservation: reservationOutput(result.reservation) };
|
|
2535
3296
|
}, WRITE);
|
|
2536
3297
|
},
|
|
3298
|
+
async markHoldPaid(context, input) {
|
|
3299
|
+
const id = input.hold_id;
|
|
3300
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
3301
|
+
throw new SlotlockAgentOperationError('hold_not_found', 404);
|
|
3302
|
+
return withTenant(store, context, async (tenant) => {
|
|
3303
|
+
const result = await tenant.markHoldPaid({
|
|
3304
|
+
tenantRef: tenantOf(context),
|
|
3305
|
+
holdId: id,
|
|
3306
|
+
paymentRef: input.payment_ref,
|
|
3307
|
+
...(input.received !== undefined ? { received: amountInput(input.received) } : {}),
|
|
3308
|
+
});
|
|
3309
|
+
if (!result.ok) {
|
|
3310
|
+
if (result.code === 'payment_conflict') {
|
|
3311
|
+
throw new SlotlockAgentOperationError('payment_conflict', 409, { payment_ref: result.paymentRef });
|
|
3312
|
+
}
|
|
3313
|
+
throw applicationRefusal(result.code);
|
|
3314
|
+
}
|
|
3315
|
+
return { hold: holdOutput(result.hold), replayed: result.idempotent };
|
|
3316
|
+
}, WRITE);
|
|
3317
|
+
},
|
|
3318
|
+
async putContact(context, input) {
|
|
3319
|
+
return withTenant(store, context, async (tenant) => {
|
|
3320
|
+
const result = await tenant.putContact({
|
|
3321
|
+
tenantRef: tenantOf(context),
|
|
3322
|
+
ref: input.contact_ref,
|
|
3323
|
+
name: input.name ?? null,
|
|
3324
|
+
email: input.email ?? null,
|
|
3325
|
+
phone: input.phone ?? null,
|
|
3326
|
+
whatsapp: input.whatsapp ?? null,
|
|
3327
|
+
consent: input.consent ?? {},
|
|
3328
|
+
locale: input.locale ?? null,
|
|
3329
|
+
});
|
|
3330
|
+
if (!result.ok)
|
|
3331
|
+
throw applicationRefusal(result.code);
|
|
3332
|
+
return { contact: contactOutput(result.contact), created: result.created };
|
|
3333
|
+
}, WRITE);
|
|
3334
|
+
},
|
|
3335
|
+
async getContact(context, input) {
|
|
3336
|
+
return withTenant(store, context, async (tenant) => {
|
|
3337
|
+
const contact = await tenant.getContact({ tenantRef: tenantOf(context), ref: input.contact_ref });
|
|
3338
|
+
if (!contact)
|
|
3339
|
+
throw new SlotlockAgentOperationError('contact_not_found', 404);
|
|
3340
|
+
return { contact: contactOutput(contact) };
|
|
3341
|
+
});
|
|
3342
|
+
},
|
|
3343
|
+
async listContacts(context, input) {
|
|
3344
|
+
const limit = input.limit;
|
|
3345
|
+
return withTenant(store, context, async (tenant) => {
|
|
3346
|
+
const contacts = await tenant.listContacts({
|
|
3347
|
+
tenantRef: tenantOf(context),
|
|
3348
|
+
limit: limit + 1,
|
|
3349
|
+
...(input.cursor !== undefined ? { after: input.cursor } : {}),
|
|
3350
|
+
});
|
|
3351
|
+
const shown = contacts.slice(0, limit);
|
|
3352
|
+
return {
|
|
3353
|
+
contacts: shown.map(contactOutput),
|
|
3354
|
+
next_cursor: contacts.length > limit ? (shown.at(-1)?.ref ?? null) : null,
|
|
3355
|
+
};
|
|
3356
|
+
});
|
|
3357
|
+
},
|
|
3358
|
+
async deleteContact(context, input) {
|
|
3359
|
+
return withTenant(store, context, async (tenant) => tenant.deleteContact({ tenantRef: tenantOf(context), ref: input.contact_ref }), WRITE);
|
|
3360
|
+
},
|
|
2537
3361
|
async releaseHold(context, input) {
|
|
2538
3362
|
const id = input.hold_id;
|
|
2539
3363
|
if (!STORE_ID_PATTERN.test(id))
|
|
@@ -2583,12 +3407,17 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2583
3407
|
bufferAfterMs: input.buffer_after_minutes * 60_000,
|
|
2584
3408
|
revision: input.revision,
|
|
2585
3409
|
...(input.source !== undefined ? { source: input.source } : {}),
|
|
3410
|
+
...(input.override_reason !== undefined ? { overrideReason: input.override_reason } : {}),
|
|
3411
|
+
...(input.local_start !== undefined ? { localStart: input.local_start } : {}),
|
|
3412
|
+
...(input.contact_ref !== undefined ? { contactRef: input.contact_ref } : {}),
|
|
3413
|
+
...(input.notify !== undefined ? { notify: input.notify } : {}),
|
|
3414
|
+
...(input.reminder_minutes !== undefined ? { reminderMinutes: input.reminder_minutes } : {}),
|
|
2586
3415
|
};
|
|
2587
3416
|
const result = command.revision === 1
|
|
2588
3417
|
? await tenant.createExternalReservation(command)
|
|
2589
3418
|
: await tenant.rescheduleExternalReservation(command);
|
|
2590
3419
|
if (!result.ok)
|
|
2591
|
-
throw
|
|
3420
|
+
throw applicationRuleRefusal(result);
|
|
2592
3421
|
return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
|
|
2593
3422
|
}, WRITE);
|
|
2594
3423
|
if (input.revision > 1 && !put.replayed) {
|
|
@@ -2602,10 +3431,11 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2602
3431
|
tenantRef: tenantOf(context),
|
|
2603
3432
|
externalRef: input.reservation_ref,
|
|
2604
3433
|
revision: input.revision,
|
|
3434
|
+
...(input.override_reason !== undefined ? { overrideReason: input.override_reason } : {}),
|
|
2605
3435
|
});
|
|
2606
3436
|
if (!result.ok)
|
|
2607
|
-
throw
|
|
2608
|
-
return { cancelled: result.cancelled, revision: result.revision };
|
|
3437
|
+
throw applicationRuleRefusal(result);
|
|
3438
|
+
return { cancelled: result.cancelled, revision: result.revision, cancellation_fee: feeOutput(result.cancellationFee) };
|
|
2609
3439
|
}, WRITE);
|
|
2610
3440
|
if (cancelled.cancelled)
|
|
2611
3441
|
await offerFreedTime(store, context);
|
|
@@ -2633,6 +3463,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2633
3463
|
end: new Date(input.end),
|
|
2634
3464
|
holderRef: input.holder_ref,
|
|
2635
3465
|
...(ttl !== undefined ? { offerTtlMs: ttl * 1000 } : {}),
|
|
3466
|
+
...(input.duration_minutes !== undefined ? { durationMs: input.duration_minutes * 60_000 } : {}),
|
|
2636
3467
|
...(input.idempotency_key !== undefined ? { idempotencyKey: input.idempotency_key } : {}),
|
|
2637
3468
|
});
|
|
2638
3469
|
if (!result.ok)
|
|
@@ -2659,6 +3490,26 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2659
3490
|
await offerFreedTime(store, context);
|
|
2660
3491
|
return left;
|
|
2661
3492
|
},
|
|
3493
|
+
async tenantBookTogether(context, input) {
|
|
3494
|
+
if (!options.agentBackend?.bookTogether)
|
|
3495
|
+
throw new SlotlockAgentOperationError('bundles_unavailable', 409);
|
|
3496
|
+
// Through the agents' bundle (one transaction, the booking rules applied), as the tenant's:
|
|
3497
|
+
// owned by the tenant, not the key, and held only by policies that include the application.
|
|
3498
|
+
return options.agentBackend.bookTogether({ ...context, operation: 'slotlock_book_together', [TENANT_ACTOR]: true }, input);
|
|
3499
|
+
},
|
|
3500
|
+
async tenantBookPool(context, input) {
|
|
3501
|
+
if (!options.agentBackend?.bookPool)
|
|
3502
|
+
throw new SlotlockAgentOperationError('pools_unavailable', 409);
|
|
3503
|
+
return options.agentBackend.bookPool({ ...context, operation: 'slotlock_book_pool', [TENANT_ACTOR]: true }, input);
|
|
3504
|
+
},
|
|
3505
|
+
async declineWaitlistOffer(context, input) {
|
|
3506
|
+
const declined = await withTenant(store, context, (tenant) => tenant.declineWaitlistOffer({ tenantRef: tenantOf(context), id: input.entry_id }));
|
|
3507
|
+
if (!declined)
|
|
3508
|
+
throw new SlotlockAgentOperationError('waitlist_offer_not_found', 404);
|
|
3509
|
+
// The time it let go goes to the next who waits.
|
|
3510
|
+
await offerFreedTime(store, context);
|
|
3511
|
+
return { entry: waitlistOutput(declined) };
|
|
3512
|
+
},
|
|
2662
3513
|
async getCoverage(context, input) {
|
|
2663
3514
|
return withTenant(store, context, async (tenant) => {
|
|
2664
3515
|
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
@@ -2720,6 +3571,45 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2720
3571
|
return { revision: result.revision, replayed: result.idempotent };
|
|
2721
3572
|
}, WRITE);
|
|
2722
3573
|
},
|
|
3574
|
+
async putSourceBusy(context, input) {
|
|
3575
|
+
return withTenant(store, context, async (tenant) => {
|
|
3576
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
3577
|
+
const result = await tenant.replaceSourceBusy({
|
|
3578
|
+
tenantRef: tenantOf(context),
|
|
3579
|
+
resourceId: resource.id,
|
|
3580
|
+
source: input.source,
|
|
3581
|
+
window: interval(input),
|
|
3582
|
+
revision: input.revision,
|
|
3583
|
+
busy: input.busy.map((busy) => ({
|
|
3584
|
+
start: new Date(busy.start),
|
|
3585
|
+
end: new Date(busy.end),
|
|
3586
|
+
})),
|
|
3587
|
+
});
|
|
3588
|
+
if (!result.ok)
|
|
3589
|
+
throw applicationRefusal(result.code);
|
|
3590
|
+
return { revision: result.revision, replayed: result.idempotent, busy: result.busy, conflicts: result.conflicts };
|
|
3591
|
+
}, WRITE);
|
|
3592
|
+
},
|
|
3593
|
+
async reportCoverageConflict(context, input) {
|
|
3594
|
+
return withTenant(store, context, async (tenant) => {
|
|
3595
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
3596
|
+
const result = await tenant.reportCoverageConflict({
|
|
3597
|
+
tenantRef: tenantOf(context),
|
|
3598
|
+
resourceId: resource.id,
|
|
3599
|
+
source: input.source,
|
|
3600
|
+
booking: input.event_id !== undefined
|
|
3601
|
+
? { eventId: input.event_id }
|
|
3602
|
+
: { reservationRef: input.reservation_ref },
|
|
3603
|
+
reason: input.reason,
|
|
3604
|
+
...(input.calendar_start !== undefined
|
|
3605
|
+
? { calendar: interval({ start: input.calendar_start, end: input.calendar_end }) }
|
|
3606
|
+
: {}),
|
|
3607
|
+
});
|
|
3608
|
+
if (!result.ok)
|
|
3609
|
+
throw applicationRefusal(result.code);
|
|
3610
|
+
return { reported: true };
|
|
3611
|
+
}, WRITE);
|
|
3612
|
+
},
|
|
2723
3613
|
async listTenantEvents(context, input) {
|
|
2724
3614
|
const limit = input.limit;
|
|
2725
3615
|
const after = parseEventCursor(input.cursor);
|
|
@@ -2772,14 +3662,16 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
2772
3662
|
idempotencyKey: applicationCommandKey(context, input.idempotency_key),
|
|
2773
3663
|
expectedRevision: input.expected_revision,
|
|
2774
3664
|
...(expectedResourceId !== undefined ? { expectedResourceId } : {}),
|
|
3665
|
+
...(input.override_reason !== undefined ? { overrideReason: input.override_reason } : {}),
|
|
2775
3666
|
});
|
|
2776
3667
|
if (!result.ok)
|
|
2777
|
-
throw
|
|
3668
|
+
throw applicationRuleRefusal(result);
|
|
2778
3669
|
return {
|
|
2779
3670
|
event_id: result.eventId,
|
|
2780
3671
|
revision: result.revision,
|
|
2781
3672
|
cancelled: true,
|
|
2782
3673
|
replayed: result.idempotent,
|
|
3674
|
+
cancellation_fee: feeOutput(result.cancellationFee),
|
|
2783
3675
|
};
|
|
2784
3676
|
}, WRITE);
|
|
2785
3677
|
if (!cancelled.replayed)
|
|
@@ -3056,6 +3948,110 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
3056
3948
|
surfaces: summary.surfaces,
|
|
3057
3949
|
};
|
|
3058
3950
|
},
|
|
3951
|
+
async listAuditEvents(context, input) {
|
|
3952
|
+
const tenantRef = tenantOf(context);
|
|
3953
|
+
const limit = input.limit ?? 50;
|
|
3954
|
+
return withTenant(store, context, async (tenant) => {
|
|
3955
|
+
// As the dashboard's export: where hosted plans apply, the Team plan's.
|
|
3956
|
+
const plan = await tenant.tenantPlan({ tenantRef });
|
|
3957
|
+
if (plan && (plan.plan !== 'team' || plan.status === 'canceled')) {
|
|
3958
|
+
throw new SlotlockAgentOperationError('team_plan_required', 403);
|
|
3959
|
+
}
|
|
3960
|
+
const events = await tenant.listAuditEvents({
|
|
3961
|
+
tenantRef,
|
|
3962
|
+
limit,
|
|
3963
|
+
...(typeof input.cursor === 'string' ? { beforeId: input.cursor } : {}),
|
|
3964
|
+
...(typeof input.action === 'string' ? { action: input.action } : {}),
|
|
3965
|
+
...(typeof input.actor_ref === 'string' ? { actorRef: input.actor_ref } : {}),
|
|
3966
|
+
});
|
|
3967
|
+
return {
|
|
3968
|
+
events: events.map(slotlockAuditEventRecord),
|
|
3969
|
+
// A full page may have more after it; the next then answers none.
|
|
3970
|
+
next_cursor: events.length === limit ? (events.at(-1)?.id ?? null) : null,
|
|
3971
|
+
};
|
|
3972
|
+
});
|
|
3973
|
+
},
|
|
3974
|
+
async verifyAuditLog(context) {
|
|
3975
|
+
const verified = await withTenant(store, context, (tenant) => tenant.verifyAuditChain({ tenantRef: tenantOf(context) }));
|
|
3976
|
+
return { checked: verified.checked, broken_at: verified.brokenAt };
|
|
3977
|
+
},
|
|
3978
|
+
async createTenantExport(context) {
|
|
3979
|
+
const tenantRef = tenantOf(context);
|
|
3980
|
+
const keyId = context.principal.keyId;
|
|
3981
|
+
const actor = typeof keyId === 'string' && /^[0-9a-f-]{36}$/i.test(keyId)
|
|
3982
|
+
? { actorType: 'api_key', actorRef: `api_key:${keyId}` }
|
|
3983
|
+
: { actorType: 'system', actorRef: 'application' };
|
|
3984
|
+
const created = await withTenant(store, context, async (tenant) => {
|
|
3985
|
+
const made = await tenant.createTenantExport({ tenantRef, requestedBy: actor.actorRef }).catch((error) => {
|
|
3986
|
+
const code = error?.code;
|
|
3987
|
+
if (code === 'export_in_progress' || code === 'export_limit_reached') {
|
|
3988
|
+
throw new SlotlockAgentOperationError(code, 409);
|
|
3989
|
+
}
|
|
3990
|
+
throw error;
|
|
3991
|
+
});
|
|
3992
|
+
await tenant.recordAuditEvent({
|
|
3993
|
+
tenantRef,
|
|
3994
|
+
event: { action: 'export.created', ...actor, targetType: 'export', targetRef: made.id },
|
|
3995
|
+
});
|
|
3996
|
+
return made;
|
|
3997
|
+
}, WRITE);
|
|
3998
|
+
// Built after the answer; maintenance builds one a stopped server left pending.
|
|
3999
|
+
void buildSlotlockTenantExport(store, tenantRef, created.id);
|
|
4000
|
+
return { export: tenantExportOutput(created) };
|
|
4001
|
+
},
|
|
4002
|
+
async listTenantExports(context) {
|
|
4003
|
+
const exports = await withTenant(store, context, (tenant) => tenant.listTenantExports({ tenantRef: tenantOf(context) }));
|
|
4004
|
+
return { exports: exports.slice(0, 100).map(tenantExportOutput) };
|
|
4005
|
+
},
|
|
4006
|
+
async getTenantExport(context, input) {
|
|
4007
|
+
const found = await withTenant(store, context, (tenant) => tenant.getTenantExport({ tenantRef: tenantOf(context), exportId: input.export_id }));
|
|
4008
|
+
if (!found)
|
|
4009
|
+
throw new SlotlockAgentOperationError('export_not_found', 404);
|
|
4010
|
+
return { export: tenantExportOutput(found) };
|
|
4011
|
+
},
|
|
4012
|
+
async getTenantExportPart(context, input) {
|
|
4013
|
+
const part = Number(input.part);
|
|
4014
|
+
const read = await withTenant(store, context, (tenant) => tenant.getTenantExportPart({ tenantRef: tenantOf(context), exportId: input.export_id, part }));
|
|
4015
|
+
if (!read.ok)
|
|
4016
|
+
throw new SlotlockAgentOperationError(read.code, read.code === 'export_not_ready' ? 409 : 404);
|
|
4017
|
+
return {
|
|
4018
|
+
export_id: input.export_id,
|
|
4019
|
+
part,
|
|
4020
|
+
table: read.table,
|
|
4021
|
+
rows: read.lines.map((line) => JSON.parse(line)),
|
|
4022
|
+
};
|
|
4023
|
+
},
|
|
4024
|
+
async deleteTenantExport(context, input) {
|
|
4025
|
+
const deleted = await withTenant(store, context, (tenant) => tenant.deleteTenantExport({ tenantRef: tenantOf(context), exportId: input.export_id }), WRITE);
|
|
4026
|
+
return { deleted };
|
|
4027
|
+
},
|
|
4028
|
+
async exportSubject(context, input) {
|
|
4029
|
+
const subject = subjectOf(input);
|
|
4030
|
+
const exported = await withTenant(store, context, (tenant) => tenant.exportSubject({ tenantRef: tenantOf(context), subject, limit: 1_000 })).catch(subjectRefusal);
|
|
4031
|
+
return { records: exported.records, truncated: exported.truncated };
|
|
4032
|
+
},
|
|
4033
|
+
async eraseSubject(context, input) {
|
|
4034
|
+
const subject = subjectOf(input);
|
|
4035
|
+
const tenantRef = tenantOf(context);
|
|
4036
|
+
const keyId = context.principal.keyId;
|
|
4037
|
+
return withTenant(store, context, async (tenant) => {
|
|
4038
|
+
const { erased } = await tenant.eraseSubject({ tenantRef, subject }).catch(subjectRefusal);
|
|
4039
|
+
// Who erased whom is the workspace's to know, but never the person: how they were named,
|
|
4040
|
+
// and what went.
|
|
4041
|
+
await tenant.recordAuditEvent({
|
|
4042
|
+
tenantRef,
|
|
4043
|
+
event: {
|
|
4044
|
+
action: 'subject.erased',
|
|
4045
|
+
...(typeof keyId === 'string' && /^[0-9a-f-]{36}$/i.test(keyId)
|
|
4046
|
+
? { actorType: 'api_key', actorRef: `api_key:${keyId}` }
|
|
4047
|
+
: { actorType: 'system', actorRef: 'application' }),
|
|
4048
|
+
targetType: 'subject',
|
|
4049
|
+
metadata: { by: Object.keys(subject)[0], ...Object.fromEntries(Object.entries(erased).filter(([, rows]) => rows > 0)) },
|
|
4050
|
+
},
|
|
4051
|
+
});
|
|
4052
|
+
return { erased };
|
|
4053
|
+
}, WRITE);
|
|
4054
|
+
},
|
|
3059
4055
|
async enableWebhook(context, input) {
|
|
3060
4056
|
return withTenant(store, context, async (tenant) => {
|
|
3061
4057
|
const enabled = await tenant.enableWebhookEndpoint({
|
|
@@ -3087,6 +4083,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
3087
4083
|
...(input.conditions !== undefined ? { conditions: approvalConditions(input.conditions) } : {}),
|
|
3088
4084
|
...(input.approval_ttl_minutes !== undefined ? { approvalTtlMinutes: input.approval_ttl_minutes } : {}),
|
|
3089
4085
|
...(input.lead_minutes !== undefined ? { leadMinutes: input.lead_minutes } : {}),
|
|
4086
|
+
...(input.include_application !== undefined ? { includeApplication: input.include_application } : {}),
|
|
3090
4087
|
});
|
|
3091
4088
|
}
|
|
3092
4089
|
catch (error) {
|
|
@@ -3214,9 +4211,10 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
3214
4211
|
async runMaintenance(context) {
|
|
3215
4212
|
assertActive(context);
|
|
3216
4213
|
try {
|
|
3217
|
-
const { rollPending: _, zoneShifts, staleSources, noShows, waitlistOffers, approvalsExpired, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
4214
|
+
const { rollPending: _, holidayCalendarsImported: _holidays, zoneShifts, staleSources, noShows, waitlistOffers, approvalsExpired, bookingsCompleted, remindersDue, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
3218
4215
|
batches,
|
|
3219
4216
|
signal: context.signal,
|
|
4217
|
+
...(options.holidayFetch ? { holidayFetch: options.holidayFetch } : {}),
|
|
3220
4218
|
});
|
|
3221
4219
|
return {
|
|
3222
4220
|
...result,
|
|
@@ -3225,6 +4223,8 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
3225
4223
|
no_shows: noShows,
|
|
3226
4224
|
waitlist_offers: waitlistOffers,
|
|
3227
4225
|
approvals_expired: approvalsExpired,
|
|
4226
|
+
bookings_completed: bookingsCompleted,
|
|
4227
|
+
reminders_due: remindersDue,
|
|
3228
4228
|
};
|
|
3229
4229
|
}
|
|
3230
4230
|
catch (error) {
|