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/booking-rules.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SlotlockRankedException } from './hours.js';
|
|
1
2
|
import type { Interval, WeeklyAvailabilityRule } from './types.js';
|
|
2
3
|
/**
|
|
3
4
|
* A resource's booking rules: what a booking made through Slotlock (hold, reservation, agent event)
|
|
@@ -24,10 +25,52 @@ export interface SlotlockBookingRules {
|
|
|
24
25
|
*/
|
|
25
26
|
bufferBeforeMinutes?: number;
|
|
26
27
|
/**
|
|
27
|
-
* Minutes kept free after each booking made under the rule, as its trailing buffer: 0-
|
|
28
|
-
* Bookings made before the rule, or imported,
|
|
28
|
+
* Minutes kept free after each booking made under the rule, as its trailing buffer: 0-10,080 (a
|
|
29
|
+
* week, for a vehicle's service after a long hire). Bookings made before the rule, or imported,
|
|
30
|
+
* keep what they have.
|
|
29
31
|
*/
|
|
30
32
|
turnaroundMinutes?: number;
|
|
33
|
+
/** A booking lasts at least this many minutes: 1-525,600 (a year). */
|
|
34
|
+
minDurationMinutes?: number;
|
|
35
|
+
/** A booking lasts at most this many minutes: 1-525,600. */
|
|
36
|
+
maxDurationMinutes?: number;
|
|
37
|
+
/** A booking lasts exactly one of these many minutes: 1-20 lengths, each 1-525,600. */
|
|
38
|
+
allowedDurations?: number[];
|
|
39
|
+
/** The length a search offers when it is asked for none: 1-525,600, and allowed by the others. */
|
|
40
|
+
defaultDurationMinutes?: number;
|
|
41
|
+
/**
|
|
42
|
+
* A booking from one local day to another (a hire) starts inside one of these windows of the day,
|
|
43
|
+
* in the resource's time zone: 1-4 windows. A booking within one day is not held to them.
|
|
44
|
+
*/
|
|
45
|
+
pickupTimes?: SlotlockDayWindow[];
|
|
46
|
+
/** Such a booking ends inside one of these windows of the day, alike. */
|
|
47
|
+
returnTimes?: SlotlockDayWindow[];
|
|
48
|
+
/**
|
|
49
|
+
* How the bookable hours hold a booking from one local day to another (a hire): `whole`, every
|
|
50
|
+
* minute of it inside them; `ends`, only its start and end, the resource being out between. A
|
|
51
|
+
* booking within one day lies inside them either way. By default `ends` where `pickupTimes` or
|
|
52
|
+
* `returnTimes` are given, else `whole`. Writes are held to it under `enforceHours`; searches
|
|
53
|
+
* (slots and ranges) follow it always.
|
|
54
|
+
*/
|
|
55
|
+
hoursApply?: SlotlockHoursApply;
|
|
56
|
+
/**
|
|
57
|
+
* A booking is cancelled at least this many minutes before it starts (RFC 0004 B5): 0-525,600.
|
|
58
|
+
* Later, cancelling it is refused (`cancel_cutoff`) unless an admin overrides with a reason.
|
|
59
|
+
*/
|
|
60
|
+
cancelCutoffMinutes?: number;
|
|
61
|
+
/** A booking is moved at least this many minutes before it starts, alike (`reschedule_cutoff`). */
|
|
62
|
+
rescheduleCutoffMinutes?: number;
|
|
63
|
+
/**
|
|
64
|
+
* What a cancellation costs, recorded on it for the integrator to charge: Slotlock never moves
|
|
65
|
+
* money. With `withinMinutes`, only a cancellation that late before the start.
|
|
66
|
+
*/
|
|
67
|
+
cancellationFee?: SlotlockCancellationFee;
|
|
68
|
+
/**
|
|
69
|
+
* What holding the resource costs, set by the tenant (RFC 0004 C3): an agent's hold of it waits for
|
|
70
|
+
* this payment (at most 30 minutes) whatever the agent asks, an agent names no other price, and an
|
|
71
|
+
* agent books it only from such a hold, once paid. The tenant's own systems set their own.
|
|
72
|
+
*/
|
|
73
|
+
holdPrice?: SlotlockHoldPrice;
|
|
31
74
|
/** A booking lies inside the resource's bookable hours. */
|
|
32
75
|
enforceHours?: boolean;
|
|
33
76
|
/**
|
|
@@ -43,8 +86,25 @@ export interface SlotlockBookingRules {
|
|
|
43
86
|
*/
|
|
44
87
|
requireCertain?: boolean;
|
|
45
88
|
}
|
|
89
|
+
/** A cancellation's fee: an amount in the currency's minor unit (cents), ISO 4217 currency. */
|
|
90
|
+
export interface SlotlockCancellationFee {
|
|
91
|
+
amountMinor: number;
|
|
92
|
+
currency: string;
|
|
93
|
+
/** Only a cancellation this many minutes or fewer before the start: 0-525,600. */
|
|
94
|
+
withinMinutes?: number;
|
|
95
|
+
}
|
|
96
|
+
/** A hold's price: an amount in the currency's minor unit (cents), ISO 4217 currency. */
|
|
97
|
+
export interface SlotlockHoldPrice {
|
|
98
|
+
amountMinor: number;
|
|
99
|
+
currency: string;
|
|
100
|
+
}
|
|
101
|
+
/** A window of a day: minutes after local midnight, and how long (it may run past midnight). */
|
|
102
|
+
export interface SlotlockDayWindow {
|
|
103
|
+
startMinutes: number;
|
|
104
|
+
durationMinutes: number;
|
|
105
|
+
}
|
|
46
106
|
/** Which rule a write broke, as its refusal names it. */
|
|
47
|
-
export type SlotlockBookingRule = 'min_notice' | 'max_advance' | 'slot_step' | 'outside_hours' | 'uncertain';
|
|
107
|
+
export type SlotlockBookingRule = 'min_notice' | 'max_advance' | 'slot_step' | 'outside_hours' | 'uncertain' | 'min_duration' | 'max_duration' | 'duration_not_allowed' | 'pickup_time' | 'return_time' | 'cancel_cutoff' | 'reschedule_cutoff';
|
|
48
108
|
/** Each numeric rule's least and greatest value. */
|
|
49
109
|
export declare const SLOTLOCK_BOOKING_RULE_LIMITS: Readonly<{
|
|
50
110
|
readonly minNoticeMinutes: readonly [0, 525600];
|
|
@@ -54,7 +114,29 @@ export declare const SLOTLOCK_BOOKING_RULE_LIMITS: Readonly<{
|
|
|
54
114
|
readonly bufferBeforeMinutes: readonly [0, number];
|
|
55
115
|
readonly turnaroundMinutes: readonly [0, number];
|
|
56
116
|
readonly checkInMinutes: readonly [1, number];
|
|
117
|
+
readonly minDurationMinutes: readonly [1, 525600];
|
|
118
|
+
readonly maxDurationMinutes: readonly [1, 525600];
|
|
119
|
+
readonly defaultDurationMinutes: readonly [1, 525600];
|
|
120
|
+
readonly cancelCutoffMinutes: readonly [0, 525600];
|
|
121
|
+
readonly rescheduleCutoffMinutes: readonly [0, 525600];
|
|
57
122
|
}>;
|
|
123
|
+
/** Lengths `allowedDurations` may list. */
|
|
124
|
+
export declare const SLOTLOCK_MAX_ALLOWED_DURATIONS = 20;
|
|
125
|
+
/** Windows `pickupTimes` and `returnTimes` may each hold. */
|
|
126
|
+
export declare const SLOTLOCK_MAX_DAY_WINDOWS = 4;
|
|
127
|
+
/** How the hours hold a hire (`hoursApply`). */
|
|
128
|
+
export type SlotlockHoursApply = 'whole' | 'ends';
|
|
129
|
+
/** The `hoursApply` a resource's rules mean: as given, else `ends` with pick-up or return times. */
|
|
130
|
+
export declare function hoursApplyOf(rules: SlotlockBookingRules | undefined): SlotlockHoursApply;
|
|
131
|
+
/**
|
|
132
|
+
* The cutoff rule a cancellation or move of a booking starting at `start`, made at `now`, breaks,
|
|
133
|
+
* or null (RFC 0004 B5).
|
|
134
|
+
*/
|
|
135
|
+
export declare function cutoffRuleBroken(rules: SlotlockBookingRules | undefined, kind: 'cancel' | 'reschedule', start: Date, now: Date): 'cancel_cutoff' | 'reschedule_cutoff' | null;
|
|
136
|
+
/** The fee a cancellation of a booking starting at `start`, made at `now`, records, or null. */
|
|
137
|
+
export declare function cancellationFeeDue(rules: SlotlockBookingRules | undefined, start: Date, now: Date): SlotlockCancellationFee | null;
|
|
138
|
+
/** The duration rule a booking of `minutes` breaks, or null. */
|
|
139
|
+
export declare function durationRuleBroken(rules: SlotlockBookingRules | undefined, minutes: number): 'min_duration' | 'max_duration' | 'duration_not_allowed' | null;
|
|
58
140
|
/** Booking rules from untrusted input, or a TypeError naming the field that is wrong. */
|
|
59
141
|
export declare function parseBookingRules(value: unknown): SlotlockBookingRules;
|
|
60
142
|
/**
|
|
@@ -83,13 +165,27 @@ export declare function bookingBufferBeforeMs(rules: SlotlockBookingRules | unde
|
|
|
83
165
|
*/
|
|
84
166
|
export declare function nextStepStart(from: Date, stepMinutes: number, timezone: string, offsetMinutes?: number): Date;
|
|
85
167
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
168
|
+
* Whether a booking runs from one local day into another (a hire): its last moment falls on a later
|
|
169
|
+
* local day than its start. One that ends at midnight stays on its day.
|
|
170
|
+
*/
|
|
171
|
+
export declare function isMultiDay(interval: Interval, timezone: string): boolean;
|
|
172
|
+
/**
|
|
173
|
+
* Whether `instant` lies in one of the windows of its local day: `[start, start + length)`, or with
|
|
174
|
+
* `closed` (a return) up to and including the window's end.
|
|
175
|
+
*/
|
|
176
|
+
export declare function withinDayWindows(instant: Date, windows: readonly SlotlockDayWindow[], timezone: string, closed?: boolean): boolean;
|
|
177
|
+
/**
|
|
178
|
+
* The rule a booking of `interval` made at `now` breaks, or null. `hours` (the resource's own, else
|
|
179
|
+
* inherited, else the default) and `exceptions` are read only when enforced. A hire (a booking from
|
|
180
|
+
* one local day to another) under `pickupTimes` or `returnTimes` starts and ends in them, and under
|
|
181
|
+
* `enforceHours` lies inside the hours as `hoursApply` says: wholly, or only at its start and end
|
|
182
|
+
* (the resource being out between).
|
|
88
183
|
*/
|
|
89
184
|
export declare function bookingRuleBroken(params: {
|
|
90
185
|
rules: SlotlockBookingRules | undefined;
|
|
91
186
|
timezone: string;
|
|
92
187
|
hours: readonly WeeklyAvailabilityRule[];
|
|
188
|
+
exceptions?: readonly SlotlockRankedException[];
|
|
93
189
|
interval: Interval;
|
|
94
190
|
now: Date;
|
|
95
191
|
}): SlotlockBookingRule | null;
|
|
@@ -114,6 +210,8 @@ export declare function bookingSearch(params: {
|
|
|
114
210
|
window: Interval;
|
|
115
211
|
now: Date;
|
|
116
212
|
bufferAfterMs?: number;
|
|
213
|
+
/** The length searched for: a length the duration rules refuse finds nothing. */
|
|
214
|
+
durationMs?: number;
|
|
117
215
|
}): SlotlockBookingSearch | null;
|
|
118
216
|
/** The time a search must read as free or busy: its window widened by a slot's buffers. */
|
|
119
217
|
export declare function bookingSearchReach(search: SlotlockBookingSearch): Interval;
|
package/booking-rules.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { coveredByOpenWindows, openWindows } from './hours.js';
|
|
2
2
|
import { zonedOffsetMinutes } from './timezone.js';
|
|
3
3
|
const MINUTE_MS = 60_000;
|
|
4
4
|
const DAY_MINUTES = 24 * 60;
|
|
@@ -9,10 +9,147 @@ export const SLOTLOCK_BOOKING_RULE_LIMITS = Object.freeze({
|
|
|
9
9
|
slotStepMinutes: [1, DAY_MINUTES],
|
|
10
10
|
stepOffsetMinutes: [0, DAY_MINUTES - 1],
|
|
11
11
|
bufferBeforeMinutes: [0, DAY_MINUTES],
|
|
12
|
-
turnaroundMinutes: [0, DAY_MINUTES],
|
|
12
|
+
turnaroundMinutes: [0, 7 * DAY_MINUTES],
|
|
13
13
|
checkInMinutes: [1, DAY_MINUTES],
|
|
14
|
+
minDurationMinutes: [1, 525_600],
|
|
15
|
+
maxDurationMinutes: [1, 525_600],
|
|
16
|
+
defaultDurationMinutes: [1, 525_600],
|
|
17
|
+
cancelCutoffMinutes: [0, 525_600],
|
|
18
|
+
rescheduleCutoffMinutes: [0, 525_600],
|
|
14
19
|
});
|
|
15
20
|
const LIMITS = SLOTLOCK_BOOKING_RULE_LIMITS;
|
|
21
|
+
/** Lengths `allowedDurations` may list. */
|
|
22
|
+
export const SLOTLOCK_MAX_ALLOWED_DURATIONS = 20;
|
|
23
|
+
/** Windows `pickupTimes` and `returnTimes` may each hold. */
|
|
24
|
+
export const SLOTLOCK_MAX_DAY_WINDOWS = 4;
|
|
25
|
+
const LIST_RULES = new Set(['allowedDurations', 'pickupTimes', 'returnTimes', 'cancellationFee', 'holdPrice']);
|
|
26
|
+
/** The `hoursApply` a resource's rules mean: as given, else `ends` with pick-up or return times. */
|
|
27
|
+
export function hoursApplyOf(rules) {
|
|
28
|
+
if (rules?.hoursApply !== undefined)
|
|
29
|
+
return rules.hoursApply;
|
|
30
|
+
return rules?.pickupTimes !== undefined || rules?.returnTimes !== undefined ? 'ends' : 'whole';
|
|
31
|
+
}
|
|
32
|
+
function parseHoursApply(value) {
|
|
33
|
+
if (value !== 'whole' && value !== 'ends')
|
|
34
|
+
throw new TypeError("Slotlock booking rule hoursApply must be 'whole' or 'ends'");
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
/** `holdPrice` from untrusted input, or a TypeError. */
|
|
38
|
+
function parseHoldPrice(value) {
|
|
39
|
+
const price = value;
|
|
40
|
+
if (typeof price !== 'object' ||
|
|
41
|
+
price === null ||
|
|
42
|
+
Array.isArray(price) ||
|
|
43
|
+
Object.keys(price).some((key) => !['amountMinor', 'currency'].includes(key)) ||
|
|
44
|
+
!Number.isSafeInteger(price.amountMinor) ||
|
|
45
|
+
price.amountMinor < 1 ||
|
|
46
|
+
price.amountMinor > 1_000_000_000_000 ||
|
|
47
|
+
typeof price.currency !== 'string' ||
|
|
48
|
+
!/^[A-Z]{3}$/.test(price.currency)) {
|
|
49
|
+
throw new TypeError('Slotlock booking rule holdPrice needs amountMinor (a whole 1-1,000,000,000,000) and currency (ISO 4217, like EUR)');
|
|
50
|
+
}
|
|
51
|
+
return { amountMinor: price.amountMinor, currency: price.currency };
|
|
52
|
+
}
|
|
53
|
+
/** `cancellationFee` from untrusted input, or a TypeError. */
|
|
54
|
+
function parseCancellationFee(value) {
|
|
55
|
+
const fee = value;
|
|
56
|
+
if (typeof fee !== 'object' ||
|
|
57
|
+
fee === null ||
|
|
58
|
+
Array.isArray(fee) ||
|
|
59
|
+
Object.keys(fee).some((key) => !['amountMinor', 'currency', 'withinMinutes'].includes(key)) ||
|
|
60
|
+
!Number.isSafeInteger(fee.amountMinor) ||
|
|
61
|
+
fee.amountMinor < 0 ||
|
|
62
|
+
fee.amountMinor > 1_000_000_000_000 ||
|
|
63
|
+
typeof fee.currency !== 'string' ||
|
|
64
|
+
!/^[A-Z]{3}$/.test(fee.currency) ||
|
|
65
|
+
(fee.withinMinutes !== undefined &&
|
|
66
|
+
(!Number.isSafeInteger(fee.withinMinutes) || fee.withinMinutes < 0 || fee.withinMinutes > 525_600))) {
|
|
67
|
+
throw new TypeError('Slotlock booking rule cancellationFee needs amountMinor (a whole 0-1,000,000,000,000), currency (ISO 4217, like EUR) and optionally withinMinutes (0-525600)');
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
amountMinor: fee.amountMinor,
|
|
71
|
+
currency: fee.currency,
|
|
72
|
+
...(fee.withinMinutes !== undefined ? { withinMinutes: fee.withinMinutes } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The cutoff rule a cancellation or move of a booking starting at `start`, made at `now`, breaks,
|
|
77
|
+
* or null (RFC 0004 B5).
|
|
78
|
+
*/
|
|
79
|
+
export function cutoffRuleBroken(rules, kind, start, now) {
|
|
80
|
+
const minutes = kind === 'cancel' ? rules?.cancelCutoffMinutes : rules?.rescheduleCutoffMinutes;
|
|
81
|
+
if (minutes === undefined)
|
|
82
|
+
return null;
|
|
83
|
+
return now.getTime() > start.getTime() - minutes * MINUTE_MS ? (kind === 'cancel' ? 'cancel_cutoff' : 'reschedule_cutoff') : null;
|
|
84
|
+
}
|
|
85
|
+
/** The fee a cancellation of a booking starting at `start`, made at `now`, records, or null. */
|
|
86
|
+
export function cancellationFeeDue(rules, start, now) {
|
|
87
|
+
const fee = rules?.cancellationFee;
|
|
88
|
+
if (!fee)
|
|
89
|
+
return null;
|
|
90
|
+
if (fee.withinMinutes !== undefined && now.getTime() < start.getTime() - fee.withinMinutes * MINUTE_MS)
|
|
91
|
+
return null;
|
|
92
|
+
return fee;
|
|
93
|
+
}
|
|
94
|
+
/** `allowedDurations` from untrusted input, or a TypeError. */
|
|
95
|
+
function parseAllowedDurations(value) {
|
|
96
|
+
if (!Array.isArray(value) ||
|
|
97
|
+
value.length < 1 ||
|
|
98
|
+
value.length > SLOTLOCK_MAX_ALLOWED_DURATIONS ||
|
|
99
|
+
!value.every((length) => Number.isSafeInteger(length) && length >= 1 && length <= 525_600) ||
|
|
100
|
+
new Set(value).size !== value.length) {
|
|
101
|
+
throw new TypeError(`Slotlock booking rule allowedDurations must be 1-${SLOTLOCK_MAX_ALLOWED_DURATIONS} different whole minutes, each 1-525600`);
|
|
102
|
+
}
|
|
103
|
+
return [...value].sort((a, b) => a - b);
|
|
104
|
+
}
|
|
105
|
+
/** `pickupTimes` or `returnTimes` from untrusted input, or a TypeError. */
|
|
106
|
+
function parseDayWindows(key, value) {
|
|
107
|
+
const valid = Array.isArray(value) &&
|
|
108
|
+
value.length >= 1 &&
|
|
109
|
+
value.length <= SLOTLOCK_MAX_DAY_WINDOWS &&
|
|
110
|
+
value.every((window) => typeof window === 'object' &&
|
|
111
|
+
window !== null &&
|
|
112
|
+
!Array.isArray(window) &&
|
|
113
|
+
Object.keys(window).every((name) => name === 'startMinutes' || name === 'durationMinutes') &&
|
|
114
|
+
Number.isSafeInteger(window.startMinutes) &&
|
|
115
|
+
window.startMinutes >= 0 &&
|
|
116
|
+
window.startMinutes < DAY_MINUTES &&
|
|
117
|
+
Number.isSafeInteger(window.durationMinutes) &&
|
|
118
|
+
window.durationMinutes >= 1 &&
|
|
119
|
+
window.durationMinutes <= DAY_MINUTES);
|
|
120
|
+
if (!valid) {
|
|
121
|
+
throw new TypeError(`Slotlock booking rule ${key} must be 1-${SLOTLOCK_MAX_DAY_WINDOWS} windows of startMinutes (0-1439) and durationMinutes (1-1440)`);
|
|
122
|
+
}
|
|
123
|
+
return value.map((window) => ({
|
|
124
|
+
startMinutes: window.startMinutes,
|
|
125
|
+
durationMinutes: window.durationMinutes,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
/** Refuse durations that leave no length a booking may have (a TypeError naming them). */
|
|
129
|
+
function checkDurations(rules) {
|
|
130
|
+
const min = rules.minDurationMinutes ?? 1;
|
|
131
|
+
const max = rules.maxDurationMinutes ?? 525_600;
|
|
132
|
+
if (min > max)
|
|
133
|
+
throw new TypeError('Slotlock booking rule minDurationMinutes must not exceed maxDurationMinutes');
|
|
134
|
+
if (rules.allowedDurations && !rules.allowedDurations.some((length) => length >= min && length <= max)) {
|
|
135
|
+
throw new TypeError('Slotlock booking rule allowedDurations has no length within the minimum and maximum');
|
|
136
|
+
}
|
|
137
|
+
if (rules.defaultDurationMinutes !== undefined && durationRuleBroken(rules, rules.defaultDurationMinutes) !== null) {
|
|
138
|
+
throw new TypeError('Slotlock booking rule defaultDurationMinutes must be a length the other rules allow');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** The duration rule a booking of `minutes` breaks, or null. */
|
|
142
|
+
export function durationRuleBroken(rules, minutes) {
|
|
143
|
+
if (!rules)
|
|
144
|
+
return null;
|
|
145
|
+
if (rules.minDurationMinutes !== undefined && minutes < rules.minDurationMinutes)
|
|
146
|
+
return 'min_duration';
|
|
147
|
+
if (rules.maxDurationMinutes !== undefined && minutes > rules.maxDurationMinutes)
|
|
148
|
+
return 'max_duration';
|
|
149
|
+
if (rules.allowedDurations !== undefined && !rules.allowedDurations.includes(minutes))
|
|
150
|
+
return 'duration_not_allowed';
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
16
153
|
/** Booking rules from untrusted input, or a TypeError naming the field that is wrong. */
|
|
17
154
|
export function parseBookingRules(value) {
|
|
18
155
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
@@ -21,10 +158,22 @@ export function parseBookingRules(value) {
|
|
|
21
158
|
const input = value;
|
|
22
159
|
const rules = {};
|
|
23
160
|
for (const key of Object.keys(input)) {
|
|
24
|
-
if (!(key in LIMITS) && key !== 'enforceHours' && key !== 'requireCertain') {
|
|
161
|
+
if (!(key in LIMITS) && key !== 'enforceHours' && key !== 'requireCertain' && key !== 'hoursApply' && !LIST_RULES.has(key)) {
|
|
25
162
|
throw new TypeError(`Slotlock booking rules have no ${key}`);
|
|
26
163
|
}
|
|
27
164
|
}
|
|
165
|
+
if (input.hoursApply !== undefined)
|
|
166
|
+
rules.hoursApply = parseHoursApply(input.hoursApply);
|
|
167
|
+
if (input.allowedDurations !== undefined)
|
|
168
|
+
rules.allowedDurations = parseAllowedDurations(input.allowedDurations);
|
|
169
|
+
if (input.cancellationFee !== undefined)
|
|
170
|
+
rules.cancellationFee = parseCancellationFee(input.cancellationFee);
|
|
171
|
+
if (input.holdPrice !== undefined)
|
|
172
|
+
rules.holdPrice = parseHoldPrice(input.holdPrice);
|
|
173
|
+
if (input.pickupTimes !== undefined)
|
|
174
|
+
rules.pickupTimes = parseDayWindows('pickupTimes', input.pickupTimes);
|
|
175
|
+
if (input.returnTimes !== undefined)
|
|
176
|
+
rules.returnTimes = parseDayWindows('returnTimes', input.returnTimes);
|
|
28
177
|
for (const [key, [min, max]] of Object.entries(LIMITS)) {
|
|
29
178
|
const field = input[key];
|
|
30
179
|
if (field === undefined)
|
|
@@ -51,6 +200,7 @@ export function parseBookingRules(value) {
|
|
|
51
200
|
rules.stepOffsetMinutes >= rules.slotStepMinutes) {
|
|
52
201
|
throw new TypeError('Slotlock booking rule stepOffsetMinutes must be less than slotStepMinutes');
|
|
53
202
|
}
|
|
203
|
+
checkDurations(rules);
|
|
54
204
|
return rules;
|
|
55
205
|
}
|
|
56
206
|
/**
|
|
@@ -73,6 +223,29 @@ export function parseEffectiveBookingRules(value) {
|
|
|
73
223
|
dropped.push(key);
|
|
74
224
|
continue;
|
|
75
225
|
}
|
|
226
|
+
if (key === 'hoursApply') {
|
|
227
|
+
if (field === 'whole' || field === 'ends')
|
|
228
|
+
rules.hoursApply = field;
|
|
229
|
+
else
|
|
230
|
+
dropped.push(key);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (LIST_RULES.has(key)) {
|
|
234
|
+
try {
|
|
235
|
+
if (key === 'allowedDurations')
|
|
236
|
+
rules.allowedDurations = parseAllowedDurations(field);
|
|
237
|
+
else if (key === 'cancellationFee')
|
|
238
|
+
rules.cancellationFee = parseCancellationFee(field);
|
|
239
|
+
else if (key === 'holdPrice')
|
|
240
|
+
rules.holdPrice = parseHoldPrice(field);
|
|
241
|
+
else
|
|
242
|
+
rules[key] = parseDayWindows(key, field);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
dropped.push(key);
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
76
249
|
const limits = LIMITS[key];
|
|
77
250
|
if (limits === undefined ||
|
|
78
251
|
typeof field !== 'number' ||
|
|
@@ -84,6 +257,13 @@ export function parseEffectiveBookingRules(value) {
|
|
|
84
257
|
}
|
|
85
258
|
rules[key] = field;
|
|
86
259
|
}
|
|
260
|
+
// Levels are validated alone: a combination that leaves no length refuses every booking.
|
|
261
|
+
try {
|
|
262
|
+
checkDurations(rules);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
dropped.push('durations');
|
|
266
|
+
}
|
|
87
267
|
return { rules, dropped };
|
|
88
268
|
}
|
|
89
269
|
/**
|
|
@@ -138,9 +318,35 @@ export function nextStepStart(from, stepMinutes, timezone, offsetMinutes = 0) {
|
|
|
138
318
|
}
|
|
139
319
|
return candidate;
|
|
140
320
|
}
|
|
321
|
+
/** The local day an instant falls on in `timezone`, as days since 1970-01-01. */
|
|
322
|
+
function localDay(instant, timezone) {
|
|
323
|
+
const local = instant.getTime() + zonedOffsetMinutes(instant, timezone) * MINUTE_MS;
|
|
324
|
+
return Math.floor(local / (DAY_MINUTES * MINUTE_MS));
|
|
325
|
+
}
|
|
141
326
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
327
|
+
* Whether a booking runs from one local day into another (a hire): its last moment falls on a later
|
|
328
|
+
* local day than its start. One that ends at midnight stays on its day.
|
|
329
|
+
*/
|
|
330
|
+
export function isMultiDay(interval, timezone) {
|
|
331
|
+
return localDay(new Date(interval.end.getTime() - 1), timezone) > localDay(interval.start, timezone);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Whether `instant` lies in one of the windows of its local day: `[start, start + length)`, or with
|
|
335
|
+
* `closed` (a return) up to and including the window's end.
|
|
336
|
+
*/
|
|
337
|
+
export function withinDayWindows(instant, windows, timezone, closed = false) {
|
|
338
|
+
const minutes = localMinutes(instant, timezone);
|
|
339
|
+
return windows.some((window) => {
|
|
340
|
+
const into = (((minutes - window.startMinutes) % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES;
|
|
341
|
+
return closed ? into <= window.durationMinutes : into < window.durationMinutes;
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The rule a booking of `interval` made at `now` breaks, or null. `hours` (the resource's own, else
|
|
346
|
+
* inherited, else the default) and `exceptions` are read only when enforced. A hire (a booking from
|
|
347
|
+
* one local day to another) under `pickupTimes` or `returnTimes` starts and ends in them, and under
|
|
348
|
+
* `enforceHours` lies inside the hours as `hoursApply` says: wholly, or only at its start and end
|
|
349
|
+
* (the resource being out between).
|
|
144
350
|
*/
|
|
145
351
|
export function bookingRuleBroken(params) {
|
|
146
352
|
const { rules, interval, now } = params;
|
|
@@ -158,18 +364,37 @@ export function bookingRuleBroken(params) {
|
|
|
158
364
|
!onStep(interval.start, rules.slotStepMinutes, params.timezone, stepOffset(rules))) {
|
|
159
365
|
return 'slot_step';
|
|
160
366
|
}
|
|
367
|
+
const duration = durationRuleBroken(rules, (interval.end.getTime() - start) / MINUTE_MS);
|
|
368
|
+
if (duration)
|
|
369
|
+
return duration;
|
|
370
|
+
const multiDay = isMultiDay(interval, params.timezone);
|
|
371
|
+
if (multiDay && rules.pickupTimes && !withinDayWindows(interval.start, rules.pickupTimes, params.timezone)) {
|
|
372
|
+
return 'pickup_time';
|
|
373
|
+
}
|
|
374
|
+
if (multiDay && rules.returnTimes && !withinDayWindows(interval.end, rules.returnTimes, params.timezone, true)) {
|
|
375
|
+
return 'return_time';
|
|
376
|
+
}
|
|
377
|
+
// A hire the hours hold only at its ends.
|
|
378
|
+
const hire = multiDay && hoursApplyOf(rules) === 'ends';
|
|
161
379
|
if (rules.enforceHours === true) {
|
|
162
|
-
const open =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
380
|
+
const open = (window) => openWindows({
|
|
381
|
+
hours: params.hours,
|
|
382
|
+
...(params.exceptions !== undefined ? { exceptions: params.exceptions } : {}),
|
|
383
|
+
timezone: params.timezone,
|
|
384
|
+
window,
|
|
385
|
+
});
|
|
386
|
+
if (hire) {
|
|
387
|
+
// Open at the start, and open up to the end: the hours a hire is collected and returned in.
|
|
388
|
+
const minute = MINUTE_MS;
|
|
389
|
+
const atStart = { start: interval.start, end: new Date(start + minute) };
|
|
390
|
+
const atEnd = { start: new Date(interval.end.getTime() - minute), end: interval.end };
|
|
391
|
+
if (!coveredByOpenWindows(open(atStart), atStart) || !coveredByOpenWindows(open(atEnd), atEnd)) {
|
|
392
|
+
return 'outside_hours';
|
|
393
|
+
}
|
|
170
394
|
}
|
|
171
|
-
if (
|
|
395
|
+
else if (!coveredByOpenWindows(open(interval), interval)) {
|
|
172
396
|
return 'outside_hours';
|
|
397
|
+
}
|
|
173
398
|
}
|
|
174
399
|
return null;
|
|
175
400
|
}
|
|
@@ -179,6 +404,9 @@ export function bookingRuleBroken(params) {
|
|
|
179
404
|
*/
|
|
180
405
|
export function bookingSearch(params) {
|
|
181
406
|
const { rules, window, now } = params;
|
|
407
|
+
if (params.durationMs !== undefined && durationRuleBroken(rules, params.durationMs / MINUTE_MS) !== null) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
182
410
|
let start = window.start.getTime();
|
|
183
411
|
const end = window.end.getTime();
|
|
184
412
|
if (rules?.minNoticeMinutes !== undefined) {
|
|
@@ -188,7 +416,27 @@ export function bookingSearch(params) {
|
|
|
188
416
|
const bufferBeforeMs = bookingBufferBeforeMs(rules);
|
|
189
417
|
const step = rules?.slotStepMinutes;
|
|
190
418
|
const offset = stepOffset(rules);
|
|
191
|
-
const
|
|
419
|
+
const stepped = step === undefined ? undefined : (from) => nextStepStart(from, step, params.timezone, offset);
|
|
420
|
+
// A slot that runs from one local day into another starts and ends in the pick-up and return
|
|
421
|
+
// times: candidates move on a minute (or a step) at a time, for at most two days.
|
|
422
|
+
const durationMs = params.durationMs;
|
|
423
|
+
const hireRules = rules && (rules.pickupTimes !== undefined || rules.returnTimes !== undefined) ? rules : undefined;
|
|
424
|
+
const toStep = hireRules === undefined || durationMs === undefined
|
|
425
|
+
? stepped
|
|
426
|
+
: (from) => {
|
|
427
|
+
let candidate = stepped ? stepped(from) : new Date(Math.ceil(from.getTime() / MINUTE_MS) * MINUTE_MS);
|
|
428
|
+
for (let tries = 0; tries <= 2 * DAY_MINUTES; tries++) {
|
|
429
|
+
const slot = { start: candidate, end: new Date(candidate.getTime() + durationMs) };
|
|
430
|
+
if (!isMultiDay(slot, params.timezone) ||
|
|
431
|
+
((!hireRules.pickupTimes || withinDayWindows(slot.start, hireRules.pickupTimes, params.timezone)) &&
|
|
432
|
+
(!hireRules.returnTimes || withinDayWindows(slot.end, hireRules.returnTimes, params.timezone, true)))) {
|
|
433
|
+
return candidate;
|
|
434
|
+
}
|
|
435
|
+
const next = new Date(Math.floor(candidate.getTime() / MINUTE_MS) * MINUTE_MS + MINUTE_MS);
|
|
436
|
+
candidate = stepped ? stepped(next) : next;
|
|
437
|
+
}
|
|
438
|
+
return new Date(end + 1);
|
|
439
|
+
};
|
|
192
440
|
const searched = { start: new Date(start), end: new Date(end) };
|
|
193
441
|
if (rules?.maxAdvanceDays !== undefined) {
|
|
194
442
|
const latestStart = now.getTime() + rules.maxAdvanceDays * DAY_MINUTES * MINUTE_MS;
|
package/config.d.ts
CHANGED
|
@@ -42,6 +42,41 @@ export interface SlotlockServeDashboardConfig {
|
|
|
42
42
|
* `'enabled'`: those an owner turned on from the dashboard (Teams on Cloud, teams.ts).
|
|
43
43
|
*/
|
|
44
44
|
organizations?: string[] | 'enabled';
|
|
45
|
+
/**
|
|
46
|
+
* Team workspaces (SLOTLOCK_DASHBOARD_TEAM_WORKSPACES=on): anyone signed in creates one and invites
|
|
47
|
+
* people to it by email, each with a role (workspaces.ts).
|
|
48
|
+
*/
|
|
49
|
+
teamWorkspaces?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Team invitations sent by email (SLOTLOCK_INVITATION_EMAIL resend or postmark, with
|
|
52
|
+
* SLOTLOCK_INVITATION_EMAIL_KEY and SLOTLOCK_INVITATION_EMAIL_FROM); without it, the person inviting
|
|
53
|
+
* sends the link.
|
|
54
|
+
*/
|
|
55
|
+
invitationEmail?: {
|
|
56
|
+
provider: 'resend' | 'postmark';
|
|
57
|
+
key: string;
|
|
58
|
+
from: string;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Verified domains (SLOTLOCK_VERIFIED_DOMAINS=on, with team workspaces): a team's Owner proves a
|
|
62
|
+
* domain with a DNS record, and the people whose verified address is at it may ask to join. Looked
|
|
63
|
+
* up over DNS-over-HTTPS, or with the system's resolver (SLOTLOCK_DNS_RESOLVER=system, a server only).
|
|
64
|
+
*/
|
|
65
|
+
verifiedDomains?: {
|
|
66
|
+
resolver: 'doh' | 'system';
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Single sign-on through each team's own identity provider (SLOTLOCK_TEAM_SSO=on, with verified
|
|
70
|
+
* domains): its client secrets sealed under SLOTLOCK_SSO_SECRET.
|
|
71
|
+
*/
|
|
72
|
+
teamSso?: {
|
|
73
|
+
secret: string;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* SCIM provisioning (SLOTLOCK_TEAM_SCIM=on, with verified domains): each team's identity provider
|
|
77
|
+
* adds, deactivates and groups its people at the dashboard's /scim/v2.
|
|
78
|
+
*/
|
|
79
|
+
teamScim?: boolean;
|
|
45
80
|
/** What the deployment is called in the dashboard (SLOTLOCK_DASHBOARD_NAME). */
|
|
46
81
|
name?: string;
|
|
47
82
|
/**
|
|
@@ -85,6 +120,21 @@ export declare function readDashboardConfig(env: SlotlockEnv): SlotlockServeDash
|
|
|
85
120
|
* organizations' keys, and only theirs.
|
|
86
121
|
*/
|
|
87
122
|
export declare function readDashboardOrganizations(env: SlotlockEnv): string[] | 'enabled' | undefined;
|
|
123
|
+
/**
|
|
124
|
+
* SLOTLOCK_VERIFIED_DOMAINS: on or off (the default), only with team workspaces; and
|
|
125
|
+
* SLOTLOCK_DNS_RESOLVER, how a domain's record is looked up: doh (the default: Cloudflare's
|
|
126
|
+
* DNS-over-HTTPS, cross-checked by Google's) or system (the server's own resolver, which follows
|
|
127
|
+
* split-horizon DNS). Read on its own as well: the server that runs maintenance checks the domains
|
|
128
|
+
* each day, wherever the dashboard runs.
|
|
129
|
+
*/
|
|
130
|
+
export declare function readVerifiedDomains(env: SlotlockEnv): {
|
|
131
|
+
resolver: 'doh' | 'system';
|
|
132
|
+
} | undefined;
|
|
133
|
+
/**
|
|
134
|
+
* SLOTLOCK_DASHBOARD_TEAM_WORKSPACES: on or off (the default). Read on its own as well: a server whose
|
|
135
|
+
* dashboard runs elsewhere (on Cloudflare Workers) takes team workspaces' keys only with it on.
|
|
136
|
+
*/
|
|
137
|
+
export declare function readDashboardTeamWorkspaces(env: SlotlockEnv): boolean;
|
|
88
138
|
/** A token bucket: `perSecond` tokens come back each second, and it holds at most `burst`. */
|
|
89
139
|
export interface SlotlockRate {
|
|
90
140
|
perSecond: number;
|