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.
Files changed (86) hide show
  1. package/CHANGELOG.md +542 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-server.d.ts +25 -5
  5. package/agent-server.js +152 -7
  6. package/agent-store-backend.d.ts +6 -1
  7. package/agent-store-backend.js +1068 -68
  8. package/application-api.d.ts +28 -1
  9. package/application-api.js +789 -21
  10. package/approvals.d.ts +2 -0
  11. package/approvals.js +12 -5
  12. package/assistant.d.ts +34 -8
  13. package/assistant.js +81 -26
  14. package/availability-store.d.ts +193 -0
  15. package/availability-store.js +566 -0
  16. package/booking-rules.d.ts +103 -5
  17. package/booking-rules.js +263 -15
  18. package/config.d.ts +50 -0
  19. package/config.js +107 -0
  20. package/dashboard-assistant-ui.d.ts +1 -1
  21. package/dashboard-assistant-ui.js +109 -8
  22. package/dashboard-scope.d.ts +7 -0
  23. package/dashboard-scope.js +21 -0
  24. package/dashboard.d.ts +152 -8
  25. package/dashboard.js +3868 -399
  26. package/ddl-identity.d.ts +25 -0
  27. package/ddl-identity.js +881 -0
  28. package/ddl-scim.d.ts +5 -0
  29. package/ddl-scim.js +740 -0
  30. package/ddl-sso.d.ts +15 -0
  31. package/ddl-sso.js +1033 -0
  32. package/ddl.d.ts +44 -9
  33. package/ddl.js +2872 -47
  34. package/domains.d.ts +117 -0
  35. package/domains.js +340 -0
  36. package/event-feed.d.ts +2 -2
  37. package/event-feed.js +24 -34
  38. package/holiday-import.d.ts +38 -0
  39. package/holiday-import.js +56 -0
  40. package/holidays.d.ts +38 -0
  41. package/holidays.js +209 -0
  42. package/hours.d.ts +125 -0
  43. package/hours.js +224 -0
  44. package/index.d.ts +24 -5
  45. package/index.js +17 -3
  46. package/interop.d.ts +25 -7
  47. package/interop.js +145 -17
  48. package/invitation-email.d.ts +26 -0
  49. package/invitation-email.js +73 -0
  50. package/lifecycle.d.ts +31 -0
  51. package/lifecycle.js +115 -0
  52. package/maintenance.d.ts +26 -2
  53. package/maintenance.js +70 -2
  54. package/metrics.js +3 -0
  55. package/oidc.d.ts +135 -0
  56. package/oidc.js +452 -0
  57. package/openapi.json +20172 -9850
  58. package/package.json +2 -1
  59. package/ranges.d.ts +28 -0
  60. package/ranges.js +135 -0
  61. package/resource-search.d.ts +19 -0
  62. package/resource-search.js +153 -0
  63. package/rest-api.js +7 -0
  64. package/rules.d.ts +2 -0
  65. package/rules.js +2 -0
  66. package/scim-server.d.ts +19 -0
  67. package/scim-server.js +243 -0
  68. package/scim.d.ts +180 -0
  69. package/scim.js +416 -0
  70. package/self-host.d.ts +19 -6
  71. package/self-host.js +483 -28
  72. package/slotlock.manifest.json +1 -1
  73. package/store.d.ts +637 -23
  74. package/store.js +2529 -147
  75. package/types.d.ts +230 -0
  76. package/types.js +68 -1
  77. package/workspace-domains.d.ts +146 -0
  78. package/workspace-domains.js +249 -0
  79. package/workspace-roles.d.ts +47 -0
  80. package/workspace-roles.js +304 -0
  81. package/workspace-scim.d.ts +89 -0
  82. package/workspace-scim.js +157 -0
  83. package/workspace-sso.d.ts +131 -0
  84. package/workspace-sso.js +180 -0
  85. package/workspaces.d.ts +240 -0
  86. package/workspaces.js +433 -0
@@ -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-1,440.
28
- * Bookings made before the rule, or imported, keep what they have.
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
- * The rule a booking of `interval` made at `now` breaks, or null. `hours` (the resource's own, or
87
- * the default) are read only when enforced.
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 { expandRules } from './rules.js';
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
- * The rule a booking of `interval` made at `now` breaks, or null. `hours` (the resource's own, or
143
- * the default) are read only when enforced.
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 = expandRules([...params.hours], interval, params.timezone);
163
- let covered = interval.start.getTime();
164
- for (const window of open.sort((a, b) => a.start.getTime() - b.start.getTime())) {
165
- if (window.start.getTime() > covered)
166
- break;
167
- covered = Math.max(covered, window.end.getTime());
168
- if (covered >= interval.end.getTime())
169
- break;
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 (covered < interval.end.getTime())
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 toStep = step === undefined ? undefined : (from) => nextStepStart(from, step, params.timezone, offset);
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;