slotlock 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +281 -0
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +861 -55
- package/application-api.d.ts +19 -1
- package/application-api.js +578 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +64 -9
- package/assistant.js +450 -82
- package/availability-store.d.ts +193 -0
- package/availability-store.js +558 -0
- package/booking-rules.d.ts +92 -5
- package/booking-rules.js +243 -15
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +539 -0
- package/dashboard-markdown.d.ts +2 -0
- package/dashboard-markdown.js +157 -0
- package/dashboard-settings-ui.d.ts +1 -0
- package/dashboard-settings-ui.js +78 -0
- package/dashboard.d.ts +84 -11
- package/dashboard.js +1654 -384
- package/ddl.d.ts +5 -1
- package/ddl.js +760 -9
- package/event-feed.d.ts +1 -1
- package/event-feed.js +18 -0
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +57 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +150 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +14 -3
- package/index.js +10 -2
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +19 -0
- package/maintenance.js +55 -1
- package/metrics.js +3 -0
- package/openapi.json +16237 -7795
- package/package.json +1 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- package/self-host.d.ts +1 -1
- package/self-host.js +221 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +367 -24
- package/store.js +1567 -97
- package/types.d.ts +198 -0
package/hours.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Bookable hours with their exceptions (RFC 0004 B1, B2): the one evaluator search and enforced
|
|
2
|
+
// writes share. A resource's hours are its own, else its location's (nearest up the chain), else its
|
|
3
|
+
// tenant's, else the server's. Exceptions then replace whole local days: a closure empties them, an
|
|
4
|
+
// opening gives them hours of their own. Each applies in the resource's time zone, the tenant's
|
|
5
|
+
// first, then each location's from the outermost in, then the resource's own, so the nearer wins.
|
|
6
|
+
import { mergeIntervals } from './engine.js';
|
|
7
|
+
import { expandRules } from './rules.js';
|
|
8
|
+
import { createTimeZoneConverter } from './timezone.js';
|
|
9
|
+
const DAY_MS = 86_400_000;
|
|
10
|
+
const MINUTE_MS = 60_000;
|
|
11
|
+
const DAY_MINUTES = 1_440;
|
|
12
|
+
/** Exceptions a tenant may keep, at all its scopes together. */
|
|
13
|
+
export const SLOTLOCK_MAX_AVAILABILITY_EXCEPTIONS = 5_000;
|
|
14
|
+
/** The longest one exception may last, in days (both ends counted). */
|
|
15
|
+
export const SLOTLOCK_MAX_EXCEPTION_DAYS = 367;
|
|
16
|
+
/** Windows one open exception may give each day. */
|
|
17
|
+
export const SLOTLOCK_MAX_EXCEPTION_WINDOWS = 4;
|
|
18
|
+
export function invalidException(message) {
|
|
19
|
+
return Object.assign(new Error(message), { code: 'invalid_availability_exception' });
|
|
20
|
+
}
|
|
21
|
+
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
22
|
+
/** Days since 1970-01-01 for a real `YYYY-MM-DD`, or null. */
|
|
23
|
+
export function epochDay(date) {
|
|
24
|
+
const match = DATE.exec(date);
|
|
25
|
+
if (!match)
|
|
26
|
+
return null;
|
|
27
|
+
const [, year, month, day] = match.map(Number);
|
|
28
|
+
const ms = Date.UTC(year, month - 1, day);
|
|
29
|
+
const back = new Date(ms);
|
|
30
|
+
if (year < 1 || back.getUTCFullYear() !== year || back.getUTCMonth() !== month - 1 || back.getUTCDate() !== day) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return Math.round(ms / DAY_MS);
|
|
34
|
+
}
|
|
35
|
+
/** `YYYY-MM-DD` for a day since 1970-01-01. */
|
|
36
|
+
export function dayToDate(day) {
|
|
37
|
+
return new Date(day * DAY_MS).toISOString().slice(0, 10);
|
|
38
|
+
}
|
|
39
|
+
/** An exception's windows from untrusted input, or `invalid_availability_exception`. */
|
|
40
|
+
export function parseExceptionWindows(value) {
|
|
41
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > SLOTLOCK_MAX_EXCEPTION_WINDOWS) {
|
|
42
|
+
throw invalidException(`Open hours must be 1 to ${SLOTLOCK_MAX_EXCEPTION_WINDOWS} windows`);
|
|
43
|
+
}
|
|
44
|
+
return value.map((window, index) => {
|
|
45
|
+
const candidate = window;
|
|
46
|
+
if (typeof candidate !== 'object' ||
|
|
47
|
+
candidate === null ||
|
|
48
|
+
Array.isArray(candidate) ||
|
|
49
|
+
Object.keys(candidate).some((key) => key !== 'startMinutes' && key !== 'durationMinutes') ||
|
|
50
|
+
!Number.isInteger(candidate.startMinutes) ||
|
|
51
|
+
candidate.startMinutes < 0 ||
|
|
52
|
+
candidate.startMinutes >= DAY_MINUTES ||
|
|
53
|
+
!Number.isInteger(candidate.durationMinutes) ||
|
|
54
|
+
candidate.durationMinutes < 1 ||
|
|
55
|
+
candidate.durationMinutes > DAY_MINUTES) {
|
|
56
|
+
throw invalidException(`Window ${index} needs startMinutes (0-1439) and durationMinutes (1-1440)`);
|
|
57
|
+
}
|
|
58
|
+
return { startMinutes: candidate.startMinutes, durationMinutes: candidate.durationMinutes };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* An exception's dates and outcome from untrusted input: real dates, the end not before the start,
|
|
63
|
+
* at most SLOTLOCK_MAX_EXCEPTION_DAYS, and hours exactly when it opens.
|
|
64
|
+
*/
|
|
65
|
+
export function parseExceptionDays(input) {
|
|
66
|
+
const startsOn = typeof input.startsOn === 'string' ? input.startsOn : '';
|
|
67
|
+
const endsOn = input.endsOn === undefined || input.endsOn === null ? startsOn : String(input.endsOn);
|
|
68
|
+
const first = epochDay(startsOn);
|
|
69
|
+
const last = epochDay(endsOn);
|
|
70
|
+
if (first === null || last === null)
|
|
71
|
+
throw invalidException('Dates must be real days, YYYY-MM-DD');
|
|
72
|
+
if (last < first)
|
|
73
|
+
throw invalidException('The last day must not be before the first');
|
|
74
|
+
if (last - first + 1 > SLOTLOCK_MAX_EXCEPTION_DAYS) {
|
|
75
|
+
throw invalidException(`An exception lasts at most ${SLOTLOCK_MAX_EXCEPTION_DAYS} days`);
|
|
76
|
+
}
|
|
77
|
+
if (input.outcome === 'closed') {
|
|
78
|
+
if (input.hours !== undefined && input.hours !== null)
|
|
79
|
+
throw invalidException('A closure has no hours');
|
|
80
|
+
return { startsOn, endsOn, outcome: 'closed' };
|
|
81
|
+
}
|
|
82
|
+
if (input.outcome === 'open') {
|
|
83
|
+
return { startsOn, endsOn, outcome: 'open', hours: parseExceptionWindows(input.hours) };
|
|
84
|
+
}
|
|
85
|
+
throw invalidException('The outcome must be closed or open');
|
|
86
|
+
}
|
|
87
|
+
/** Stored hours that do not read as windows read as none: the day stays closed. */
|
|
88
|
+
export function storedExceptionWindows(value) {
|
|
89
|
+
try {
|
|
90
|
+
return parseExceptionWindows(value);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The open windows of a resource within `window`: its weekly `hours`, with `exceptions` (in rank
|
|
98
|
+
* order) replacing the local days they name in `timezone`. Merged and sorted.
|
|
99
|
+
*/
|
|
100
|
+
export function openWindows(params) {
|
|
101
|
+
const { window, timezone } = params;
|
|
102
|
+
const weekly = expandRules([...params.hours], window, timezone);
|
|
103
|
+
const exceptions = [...(params.exceptions ?? [])].sort((a, b) => a.rank - b.rank);
|
|
104
|
+
if (exceptions.length === 0)
|
|
105
|
+
return weekly;
|
|
106
|
+
const converter = createTimeZoneConverter(timezone);
|
|
107
|
+
const at = (day, minutes) => converter.wallFrameToUtc(day * DAY_MS + minutes * MINUTE_MS, { gap: 'end' }).getTime();
|
|
108
|
+
// The local days the window can touch: a zone is at most fourteen hours from UTC.
|
|
109
|
+
const firstDay = Math.floor(window.start.getTime() / DAY_MS) - 1;
|
|
110
|
+
const lastDay = Math.floor(window.end.getTime() / DAY_MS) + 1;
|
|
111
|
+
let open = weekly;
|
|
112
|
+
for (const exception of exceptions) {
|
|
113
|
+
const from = Math.max(epochDay(exception.startsOn) ?? Number.POSITIVE_INFINITY, firstDay);
|
|
114
|
+
const to = Math.min(epochDay(exception.endsOn) ?? Number.NEGATIVE_INFINITY, lastDay);
|
|
115
|
+
if (from > to)
|
|
116
|
+
continue;
|
|
117
|
+
const removed = { start: at(from, 0), end: at(to + 1, 0) };
|
|
118
|
+
open = open.flatMap((part) => {
|
|
119
|
+
const start = part.start.getTime();
|
|
120
|
+
const end = part.end.getTime();
|
|
121
|
+
if (end <= removed.start || start >= removed.end)
|
|
122
|
+
return [part];
|
|
123
|
+
const kept = [];
|
|
124
|
+
if (start < removed.start)
|
|
125
|
+
kept.push({ start: part.start, end: new Date(removed.start) });
|
|
126
|
+
if (end > removed.end)
|
|
127
|
+
kept.push({ start: new Date(removed.end), end: part.end });
|
|
128
|
+
return kept;
|
|
129
|
+
});
|
|
130
|
+
if (exception.outcome === 'open') {
|
|
131
|
+
for (let day = from; day <= to; day++) {
|
|
132
|
+
for (const hours of exception.hours ?? []) {
|
|
133
|
+
const start = Math.max(at(day, hours.startMinutes), window.start.getTime());
|
|
134
|
+
const end = Math.min(at(day, hours.startMinutes + hours.durationMinutes), window.end.getTime());
|
|
135
|
+
if (end > start)
|
|
136
|
+
open.push({ start: new Date(start), end: new Date(end) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return mergeIntervals(open.filter((part) => part.end.getTime() > part.start.getTime()));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The weekly hours that apply to a resource and where they come from: its own, else what it
|
|
145
|
+
* inherits, else the server's `fallback`.
|
|
146
|
+
*/
|
|
147
|
+
export function applicableHours(resource, fallback) {
|
|
148
|
+
if (resource.availabilityRules !== undefined)
|
|
149
|
+
return { rules: resource.availabilityRules, source: 'resource' };
|
|
150
|
+
const inherited = resource.inheritedAvailability;
|
|
151
|
+
if (inherited !== undefined) {
|
|
152
|
+
return inherited.location !== undefined
|
|
153
|
+
? { rules: inherited.rules, source: inherited.source, location: inherited.location }
|
|
154
|
+
: { rules: inherited.rules, source: inherited.source };
|
|
155
|
+
}
|
|
156
|
+
return { rules: fallback, source: 'server' };
|
|
157
|
+
}
|
|
158
|
+
/** Whether `interval` lies wholly inside the open windows. */
|
|
159
|
+
export function coveredByOpenWindows(open, interval) {
|
|
160
|
+
let covered = interval.start.getTime();
|
|
161
|
+
for (const part of [...open].sort((a, b) => a.start.getTime() - b.start.getTime())) {
|
|
162
|
+
if (part.start.getTime() > covered)
|
|
163
|
+
break;
|
|
164
|
+
covered = Math.max(covered, part.end.getTime());
|
|
165
|
+
if (covered >= interval.end.getTime())
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
return covered >= interval.end.getTime();
|
|
169
|
+
}
|
|
170
|
+
/** The first and last UTC day an exception query for `window` must reach (zones span ±14 hours). */
|
|
171
|
+
export function exceptionQueryDays(window) {
|
|
172
|
+
return {
|
|
173
|
+
from: dayToDate(Math.floor(window.start.getTime() / DAY_MS) - 1),
|
|
174
|
+
to: dayToDate(Math.floor(window.end.getTime() / DAY_MS) + 1),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The days from `from` to `to` (`YYYY-MM-DD`, inclusive) that exceptions replace, each as the
|
|
179
|
+
* nearest exception leaves it, by date.
|
|
180
|
+
*/
|
|
181
|
+
export function exceptionDays(exceptions, from, to) {
|
|
182
|
+
const first = epochDay(from);
|
|
183
|
+
const last = epochDay(to);
|
|
184
|
+
if (first === null || last === null)
|
|
185
|
+
return [];
|
|
186
|
+
const days = new Map();
|
|
187
|
+
for (const exception of [...exceptions].sort((a, b) => a.rank - b.rank)) {
|
|
188
|
+
const start = Math.max(epochDay(exception.startsOn) ?? Number.POSITIVE_INFINITY, first);
|
|
189
|
+
const end = Math.min(epochDay(exception.endsOn) ?? Number.NEGATIVE_INFINITY, last);
|
|
190
|
+
for (let day = start; day <= end; day++) {
|
|
191
|
+
days.set(day, exception.outcome === 'open' ? [...(exception.hours ?? [])] : []);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return [...days.entries()]
|
|
195
|
+
.sort(([a], [b]) => a - b)
|
|
196
|
+
.map(([day, windows]) => ({ date: dayToDate(day), windows }));
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Consecutive days that read alike as exception ranges: no windows a closure, else hours of their
|
|
200
|
+
* own. The inverse of `exceptionDays` for one scope.
|
|
201
|
+
*/
|
|
202
|
+
export function exceptionRanges(days) {
|
|
203
|
+
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
|
204
|
+
const ranges = [];
|
|
205
|
+
const key = (windows) => JSON.stringify([...windows].sort((a, b) => a.startMinutes - b.startMinutes || a.durationMinutes - b.durationMinutes));
|
|
206
|
+
let previous = null;
|
|
207
|
+
for (const day of sorted) {
|
|
208
|
+
const at = epochDay(day.date);
|
|
209
|
+
if (at === null)
|
|
210
|
+
continue;
|
|
211
|
+
const same = key(day.windows);
|
|
212
|
+
const last = ranges.at(-1);
|
|
213
|
+
if (last && previous && previous.day === at - 1 && previous.key === same) {
|
|
214
|
+
last.endsOn = day.date;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
ranges.push(day.windows.length === 0
|
|
218
|
+
? { startsOn: day.date, endsOn: day.date, outcome: 'closed' }
|
|
219
|
+
: { startsOn: day.date, endsOn: day.date, outcome: 'open', hours: JSON.parse(same) });
|
|
220
|
+
}
|
|
221
|
+
previous = { day: at, key: same };
|
|
222
|
+
}
|
|
223
|
+
return ranges;
|
|
224
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -8,7 +8,18 @@ export { type SlotlockMetrics, createSlotlockMetrics } from './metrics.js';
|
|
|
8
8
|
export { expandRules } from './rules.js';
|
|
9
9
|
export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SLOTLOCK_MAX_AVAILABILITY_RULES, weeklyHoursToRules, } from './weekly-hours.js';
|
|
10
10
|
export type { HoursWindow, Weekday, WeeklyHours } from './weekly-hours.js';
|
|
11
|
-
export { emitVAvailability, parseVAvailability, recurrenceRuleFromRRule, SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS, toJSCalendarEvent, } from './interop.js';
|
|
11
|
+
export { emitVAvailability, parseVAvailability, recurrenceRuleFromRRule, SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS, SLOTLOCK_MAX_VAVAILABILITY_DAYS, toJSCalendarEvent, } from './interop.js';
|
|
12
|
+
export { applicableHours, coveredByOpenWindows, exceptionDays, exceptionRanges, openWindows, parseExceptionDays, SLOTLOCK_MAX_AVAILABILITY_EXCEPTIONS, SLOTLOCK_MAX_EXCEPTION_DAYS, SLOTLOCK_MAX_EXCEPTION_WINDOWS, } from './hours.js';
|
|
13
|
+
export type { SlotlockAvailabilityException, SlotlockExceptionDay, SlotlockExceptionOutcome, SlotlockExceptionSource, SlotlockExceptionWindow, SlotlockHoursSource, SlotlockRankedException, } from './hours.js';
|
|
14
|
+
export type { SlotlockAvailabilityDefaults, SlotlockAvailabilityDefaultsResult, SlotlockAvailabilityExceptionInput, SlotlockOverrideResources, SlotlockHolidayCalendar, SlotlockHolidayImport, } from './availability-store.js';
|
|
15
|
+
export { SLOTLOCK_OVERRIDE_RESOURCES, SLOTLOCK_HOLIDAY_REIMPORT_DAYS, SLOTLOCK_MAX_HOLIDAY_CALENDARS, } from './availability-store.js';
|
|
16
|
+
export { type SlotlockHolidayCalendarRead, type SlotlockHolidaySource, readSlotlockHolidayCalendar, } from './holiday-import.js';
|
|
17
|
+
export { fetchHolidayCalendar, parseHolidayCalendar, SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES, SLOTLOCK_MAX_HOLIDAY_DAYS, } from './holidays.js';
|
|
18
|
+
export type { ParsedHolidayCalendar, SlotlockHoliday } from './holidays.js';
|
|
19
|
+
export { SLOTLOCK_BOOKING_ACTIONS, SLOTLOCK_BOOKING_ENDS, SLOTLOCK_BOOKING_STATUSES, SLOTLOCK_BOOKING_TRANSITIONS, slotlockBookingNext, slotlockBookingRefusal, } from './lifecycle.js';
|
|
20
|
+
export type { SlotlockBookingAction, SlotlockBookingStatus, SlotlockEndReason } from './lifecycle.js';
|
|
21
|
+
export { findEarliestRange, sameTimeDaysLater } from './ranges.js';
|
|
22
|
+
export type { SlotlockRangeSearch } from './ranges.js';
|
|
12
23
|
export type { EmitVAvailabilityOptions, JSCalendarAlert, JSCalendarEvent, JSCalendarParticipant, JSCalendarRecurrenceRule, ParsedVAvailability, } from './interop.js';
|
|
13
24
|
export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
|
|
14
25
|
export type { LocalDateTime, TimeZoneConverter, ZonedCalendarDate, ZonedWallTime, } from './timezone.js';
|
|
@@ -44,5 +55,5 @@ export type { ICalendarWorkerPool, ICalendarWorkerPoolOptions } from './ical-wor
|
|
|
44
55
|
export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, recurrenceRuleProblem, storedCalendarEventProblem, } from './sync.js';
|
|
45
56
|
export type { ExternalCalendarChange, ExternalCalendarChangeInput, ExternalCalendarStatus, ICalendarEvent, ICalendarExpansionWindow, ICalendarFeedRead, ICalendarSeriesRefusal, ITipMethod, } from './sync.js';
|
|
46
57
|
export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_DEFINER_FUNCTIONS, SLOTLOCK_DEFINER_TABLES, SLOTLOCK_OPERATOR_FUNCTIONS, SLOTLOCK_SCHEMA_FUNCTIONS, SLOTLOCK_SCHEMA_TABLES, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_TENANT_ADMIN_FUNCTIONS, SLOTLOCK_TENANT_ADMIN_TABLES, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
|
|
47
|
-
export { assertSlotlockSchemaCurrent, calendarEventRollingHorizon, createSlotlockStore, readSlotlockSchemaVersion, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MATERIALIZATION_WINDOW_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, SLOTLOCK_TENANT_WRITE_METHODS, type SlotlockHoldTenantOptions, type SlotlockMigrationOptions, type SlotlockSql, type SlotlockStore, type SlotlockStoreOptions, type SlotlockTenantLimits, type SlotlockTenantStore, } from './store.js';
|
|
48
|
-
export type { AcquireHoldResult, BookingRetentionResult, CalendarAttendee, CalendarAttendeeRole, CalendarCoverageAssessment, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventOccurrence, CalendarEventRetentionResult, CalendarEventStatus, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CalendarOrganizer, CalendarParticipationStatus, CalendarRecurrence, CalendarRecurrenceException, CalendarReminder, CalendarTransparency, ConfirmHoldResult, CancelCalendarEventResult, CancelExternalReservationResult, CreateReservationResult, DeleteResourceResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockAgendaEntry, SlotlockHold, SlotlockCalendarEvent, SlotlockBookingDescription, SlotlockReservation, SlotlockResource, SlotlockTenantErasure, SlotlockBookingRuleDefaults, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, TrustedICalendarEvent, WeeklyAvailabilityRule, } from './types.js';
|
|
58
|
+
export { assertSlotlockSchemaCurrent, calendarEventRollingHorizon, createSlotlockStore, readSlotlockSchemaVersion, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MATERIALIZATION_WINDOW_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, SLOTLOCK_MAX_CONTACTS_PER_TENANT, SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS, SLOTLOCK_TENANT_WRITE_METHODS, type SlotlockHoldTenantOptions, type SlotlockMigrationOptions, type SlotlockSql, type SlotlockStore, type SlotlockStoreOptions, type SlotlockTenantLimits, type SlotlockTenantStore, } from './store.js';
|
|
59
|
+
export type { AcquireHoldResult, BookingRetentionResult, CalendarAttendee, CalendarAttendeeRole, CalendarCoverageAssessment, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventOccurrence, CalendarEventRetentionResult, CalendarEventStatus, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CalendarOrganizer, CalendarParticipationStatus, CalendarRecurrence, CalendarRecurrenceException, CalendarReminder, CalendarTransparency, ConfirmHoldResult, CancelCalendarEventResult, CancelExternalReservationResult, CreateReservationResult, DeleteResourceResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockAgendaEntry, SlotlockHold, MarkHoldPaidResult, SlotlockContact, SlotlockContactConsent, PutContactResult, SourceBusyCommand, SourceBusyResult, SlotlockCalendarEvent, SlotlockBookingDescription, SlotlockReservation, SlotlockResource, SlotlockTenantErasure, SlotlockBookingRuleDefaults, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, TrustedICalendarEvent, SlotlockInheritedAvailability, WeeklyAvailabilityRule, } from './types.js';
|
package/index.js
CHANGED
|
@@ -11,7 +11,15 @@ export { createSlotlockGuardedFetch, createSlotlockWebhookSender, deliverSlotloc
|
|
|
11
11
|
export { createSlotlockMetrics } from './metrics.js';
|
|
12
12
|
export { expandRules } from './rules.js';
|
|
13
13
|
export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SLOTLOCK_MAX_AVAILABILITY_RULES, weeklyHoursToRules, } from './weekly-hours.js';
|
|
14
|
-
export { emitVAvailability, parseVAvailability, recurrenceRuleFromRRule, SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS, toJSCalendarEvent, } from './interop.js';
|
|
14
|
+
export { emitVAvailability, parseVAvailability, recurrenceRuleFromRRule, SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS, SLOTLOCK_MAX_VAVAILABILITY_DAYS, toJSCalendarEvent, } from './interop.js';
|
|
15
|
+
// Stored hours that inherit, and closures and special days (RFC 0004 B1, B2).
|
|
16
|
+
export { applicableHours, coveredByOpenWindows, exceptionDays, exceptionRanges, openWindows, parseExceptionDays, SLOTLOCK_MAX_AVAILABILITY_EXCEPTIONS, SLOTLOCK_MAX_EXCEPTION_DAYS, SLOTLOCK_MAX_EXCEPTION_WINDOWS, } from './hours.js';
|
|
17
|
+
export { SLOTLOCK_OVERRIDE_RESOURCES, SLOTLOCK_HOLIDAY_REIMPORT_DAYS, SLOTLOCK_MAX_HOLIDAY_CALENDARS, } from './availability-store.js';
|
|
18
|
+
export { readSlotlockHolidayCalendar, } from './holiday-import.js';
|
|
19
|
+
export { fetchHolidayCalendar, parseHolidayCalendar, SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES, SLOTLOCK_MAX_HOLIDAY_DAYS, } from './holidays.js';
|
|
20
|
+
// The booking lifecycle, written down (RFC 0004 B4).
|
|
21
|
+
export { SLOTLOCK_BOOKING_ACTIONS, SLOTLOCK_BOOKING_ENDS, SLOTLOCK_BOOKING_STATUSES, SLOTLOCK_BOOKING_TRANSITIONS, slotlockBookingNext, slotlockBookingRefusal, } from './lifecycle.js';
|
|
22
|
+
export { findEarliestRange, sameTimeDaysLater } from './ranges.js';
|
|
15
23
|
export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
|
|
16
24
|
export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_WATCH_SKILL, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MAX_ALTERNATIVES, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
|
|
17
25
|
export { createSlotlockNodeServer } from './node-server.js';
|
|
@@ -30,4 +38,4 @@ export { closeSlotlockPartnerMonth, priceSlotlockPartnerMonth, slotlockClientLab
|
|
|
30
38
|
export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
|
|
31
39
|
export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, recurrenceRuleProblem, storedCalendarEventProblem, } from './sync.js';
|
|
32
40
|
export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_DEFINER_FUNCTIONS, SLOTLOCK_DEFINER_TABLES, SLOTLOCK_OPERATOR_FUNCTIONS, SLOTLOCK_SCHEMA_FUNCTIONS, SLOTLOCK_SCHEMA_TABLES, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_TENANT_ADMIN_FUNCTIONS, SLOTLOCK_TENANT_ADMIN_TABLES, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
|
|
33
|
-
export { assertSlotlockSchemaCurrent, calendarEventRollingHorizon, createSlotlockStore, readSlotlockSchemaVersion, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MATERIALIZATION_WINDOW_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, SLOTLOCK_TENANT_WRITE_METHODS, } from './store.js';
|
|
41
|
+
export { assertSlotlockSchemaCurrent, calendarEventRollingHorizon, createSlotlockStore, readSlotlockSchemaVersion, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MATERIALIZATION_WINDOW_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, SLOTLOCK_MAX_CONTACTS_PER_TENANT, SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS, SLOTLOCK_TENANT_WRITE_METHODS, } from './store.js';
|
package/interop.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SlotlockExceptionDay } from './hours.js';
|
|
1
2
|
import type { SlotlockCalendarEvent, WeeklyAvailabilityRule } from './types.js';
|
|
2
3
|
/** The longest VAVAILABILITY text `parseVAvailability` reads. */
|
|
3
4
|
export declare const SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS = 65536;
|
|
@@ -8,26 +9,43 @@ export interface EmitVAvailabilityOptions {
|
|
|
8
9
|
timezone: string;
|
|
9
10
|
/** The VAVAILABILITY's UID; each AVAILABLE's is this with its index. */
|
|
10
11
|
uid: string;
|
|
12
|
+
/**
|
|
13
|
+
* Days exceptions replace (RFC 0004 B1): each weekly AVAILABLE gets an EXDATE for its window on
|
|
14
|
+
* them, and the hours an open day has instead are AVAILABLEs of their own, one per window with an
|
|
15
|
+
* RDATE for each further day with the same window.
|
|
16
|
+
*/
|
|
17
|
+
days?: readonly SlotlockExceptionDay[];
|
|
11
18
|
}
|
|
19
|
+
/** The most days one VAVAILABILITY's EXDATEs, RDATEs and single AVAILABLEs may name. */
|
|
20
|
+
export declare const SLOTLOCK_MAX_VAVAILABILITY_DAYS = 1000;
|
|
12
21
|
/**
|
|
13
22
|
* A VCALENDAR with one VAVAILABILITY holding an AVAILABLE per rule: DTSTART on the rule's first day
|
|
14
23
|
* in a reference week of January 2024, with the IANA zone as TZID (no VTIMEZONE: RFC 7809 lets the
|
|
15
24
|
* two sides agree on IANA names), DURATION and the rule's RRULE. `[]` is a VAVAILABILITY with no
|
|
16
25
|
* AVAILABLE: never bookable. CRLF line endings, folded at 75 octets.
|
|
17
26
|
*/
|
|
18
|
-
export declare function emitVAvailability({ rules, timezone, uid }: EmitVAvailabilityOptions): string;
|
|
27
|
+
export declare function emitVAvailability({ rules, timezone, uid, days }: EmitVAvailabilityOptions): string;
|
|
19
28
|
export interface ParsedVAvailability {
|
|
20
29
|
/** The TZID every AVAILABLE named, `UTC` for UTC times, or null when they are all floating. */
|
|
21
30
|
timezone: string | null;
|
|
22
31
|
rules: WeeklyAvailabilityRule[];
|
|
32
|
+
/**
|
|
33
|
+
* The days EXDATEs, RDATEs and single AVAILABLEs change, each with the windows it has: none is
|
|
34
|
+
* closed. Empty when the hours are only weekly.
|
|
35
|
+
*/
|
|
36
|
+
days: SlotlockExceptionDay[];
|
|
23
37
|
}
|
|
24
38
|
/**
|
|
25
|
-
* Weekly rules from an iCalendar VAVAILABILITY (RFC 7953): one
|
|
26
|
-
* weekly RRULE with a time of day and a length
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* DTSTART
|
|
39
|
+
* Weekly rules and the days that differ from them, from an iCalendar VAVAILABILITY (RFC 7953): one
|
|
40
|
+
* VAVAILABILITY, each AVAILABLE either a weekly RRULE with a time of day and a length, or a single
|
|
41
|
+
* window. EXDATE takes a weekly window off its day, RDATE adds a window (of the AVAILABLE's length,
|
|
42
|
+
* or a PERIOD's), and a single AVAILABLE adds its own: each day they name becomes a day with hours
|
|
43
|
+
* of its own (`days`), as exceptions keep them (RFC 0004 B1). Refuses (`invalid_availability`) what
|
|
44
|
+
* these cannot say: a VAVAILABILITY bounded by DTSTART or DTEND, more than one, RECURRENCE-ID, a
|
|
45
|
+
* whole-day DTSTART, seconds, AVAILABLEs in different zones, an EXDATE at another time than its
|
|
46
|
+
* window, a day with more than four windows or one over a day, more than
|
|
47
|
+
* SLOTLOCK_MAX_VAVAILABILITY_DAYS such days, and any rule `parseAvailabilityRules` refuses. An
|
|
48
|
+
* RRULE without BYDAY takes DTSTART's day, as RFC 5545 reads it.
|
|
31
49
|
*/
|
|
32
50
|
export declare function parseVAvailability(text: string): ParsedVAvailability;
|
|
33
51
|
/** A JSCalendar Event object (RFC 8984 §5.1): the properties Slotlock fills. */
|
package/interop.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// (RFC 8984). Pure functions: the application API serves them, and an application may call them
|
|
4
4
|
// directly.
|
|
5
5
|
import ICAL from 'ical.js';
|
|
6
|
+
import { epochDay } from './hours.js';
|
|
6
7
|
import { zonedDateTimeFields } from './timezone.js';
|
|
7
8
|
import { invalidAvailability, parseAvailabilityRules } from './weekly-hours.js';
|
|
8
9
|
/** The longest VAVAILABILITY text `parseVAvailability` reads. */
|
|
@@ -40,13 +41,21 @@ function referenceDay(rrule) {
|
|
|
40
41
|
const index = DAYS.findIndex((day) => named.includes(day));
|
|
41
42
|
return new Date(REFERENCE_SUNDAY + Math.max(index, 0) * 86_400_000);
|
|
42
43
|
}
|
|
44
|
+
/** The most days one VAVAILABILITY's EXDATEs, RDATEs and single AVAILABLEs may name. */
|
|
45
|
+
export const SLOTLOCK_MAX_VAVAILABILITY_DAYS = 1_000;
|
|
46
|
+
const compactDate = (date) => date.replaceAll('-', '');
|
|
47
|
+
const wallStamp = (date, minutes) => `${compactDate(date)}T${pad(Math.floor(minutes / 60))}${pad(minutes % 60)}00`;
|
|
48
|
+
/** The weekday codes an RRULE's BYDAY names. */
|
|
49
|
+
const byDays = (rrule) => new Set((/(?:^|;)BYDAY=([^;]*)/.exec(rrule)?.[1] ?? '').split(',').map((day) => day.slice(-2)));
|
|
50
|
+
/** The weekday code of `YYYY-MM-DD`. */
|
|
51
|
+
const weekdayOf = (date) => DAYS[new Date(`${date}T00:00:00Z`).getUTCDay()];
|
|
43
52
|
/**
|
|
44
53
|
* A VCALENDAR with one VAVAILABILITY holding an AVAILABLE per rule: DTSTART on the rule's first day
|
|
45
54
|
* in a reference week of January 2024, with the IANA zone as TZID (no VTIMEZONE: RFC 7809 lets the
|
|
46
55
|
* two sides agree on IANA names), DURATION and the rule's RRULE. `[]` is a VAVAILABILITY with no
|
|
47
56
|
* AVAILABLE: never bookable. CRLF line endings, folded at 75 octets.
|
|
48
57
|
*/
|
|
49
|
-
export function emitVAvailability({ rules, timezone, uid }) {
|
|
58
|
+
export function emitVAvailability({ rules, timezone, uid, days }) {
|
|
50
59
|
const lines = [
|
|
51
60
|
'BEGIN:VCALENDAR',
|
|
52
61
|
'VERSION:2.0',
|
|
@@ -59,7 +68,31 @@ export function emitVAvailability({ rules, timezone, uid }) {
|
|
|
59
68
|
const day = referenceDay(rule.rrule);
|
|
60
69
|
const hour = Math.floor(rule.startMinutes / 60);
|
|
61
70
|
const minute = rule.startMinutes % 60;
|
|
62
|
-
lines.push('BEGIN:AVAILABLE', `UID:${uid}-${index}`, `DTSTAMP:${VAVAILABILITY_DTSTAMP}`, `DTSTART;TZID=${timezone}:${day.getUTCFullYear()}${pad(day.getUTCMonth() + 1)}${pad(day.getUTCDate())}T${pad(hour)}${pad(minute)}00`, `DURATION:PT${rule.durationMinutes}M`, `RRULE:${rule.rrule}
|
|
71
|
+
lines.push('BEGIN:AVAILABLE', `UID:${uid}-${index}`, `DTSTAMP:${VAVAILABILITY_DTSTAMP}`, `DTSTART;TZID=${timezone}:${day.getUTCFullYear()}${pad(day.getUTCMonth() + 1)}${pad(day.getUTCDate())}T${pad(hour)}${pad(minute)}00`, `DURATION:PT${rule.durationMinutes}M`, `RRULE:${rule.rrule}`);
|
|
72
|
+
const weekdays = byDays(rule.rrule);
|
|
73
|
+
const excluded = (days ?? [])
|
|
74
|
+
.filter((day) => weekdays.has(weekdayOf(day.date)))
|
|
75
|
+
.map((day) => wallStamp(day.date, rule.startMinutes));
|
|
76
|
+
if (excluded.length > 0)
|
|
77
|
+
lines.push(`EXDATE;TZID=${timezone}:${excluded.join(',')}`);
|
|
78
|
+
lines.push('END:AVAILABLE');
|
|
79
|
+
});
|
|
80
|
+
// An open day's hours: one AVAILABLE per window, repeated by RDATE on each further day with it.
|
|
81
|
+
const byWindow = new Map();
|
|
82
|
+
for (const day of days ?? []) {
|
|
83
|
+
for (const window of day.windows) {
|
|
84
|
+
const key = `${window.startMinutes}:${window.durationMinutes}`;
|
|
85
|
+
const entry = byWindow.get(key) ?? { window, dates: [] };
|
|
86
|
+
entry.dates.push(day.date);
|
|
87
|
+
byWindow.set(key, entry);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
[...byWindow.values()].forEach(({ window, dates }, index) => {
|
|
91
|
+
const [first, ...rest] = [...dates].sort();
|
|
92
|
+
lines.push('BEGIN:AVAILABLE', `UID:${uid}-day-${index}`, `DTSTAMP:${VAVAILABILITY_DTSTAMP}`, `DTSTART;TZID=${timezone}:${wallStamp(first, window.startMinutes)}`, `DURATION:PT${window.durationMinutes}M`);
|
|
93
|
+
if (rest.length > 0)
|
|
94
|
+
lines.push(`RDATE;TZID=${timezone}:${rest.map((date) => wallStamp(date, window.startMinutes)).join(',')}`);
|
|
95
|
+
lines.push('END:AVAILABLE');
|
|
63
96
|
});
|
|
64
97
|
lines.push('END:VAVAILABILITY', 'END:VCALENDAR');
|
|
65
98
|
return `${lines.map(fold).join(CRLF)}${CRLF}`;
|
|
@@ -72,12 +105,16 @@ function single(component, name) {
|
|
|
72
105
|
return properties[0] ?? null;
|
|
73
106
|
}
|
|
74
107
|
/**
|
|
75
|
-
* Weekly rules from an iCalendar VAVAILABILITY (RFC 7953): one
|
|
76
|
-
* weekly RRULE with a time of day and a length
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* DTSTART
|
|
108
|
+
* Weekly rules and the days that differ from them, from an iCalendar VAVAILABILITY (RFC 7953): one
|
|
109
|
+
* VAVAILABILITY, each AVAILABLE either a weekly RRULE with a time of day and a length, or a single
|
|
110
|
+
* window. EXDATE takes a weekly window off its day, RDATE adds a window (of the AVAILABLE's length,
|
|
111
|
+
* or a PERIOD's), and a single AVAILABLE adds its own: each day they name becomes a day with hours
|
|
112
|
+
* of its own (`days`), as exceptions keep them (RFC 0004 B1). Refuses (`invalid_availability`) what
|
|
113
|
+
* these cannot say: a VAVAILABILITY bounded by DTSTART or DTEND, more than one, RECURRENCE-ID, a
|
|
114
|
+
* whole-day DTSTART, seconds, AVAILABLEs in different zones, an EXDATE at another time than its
|
|
115
|
+
* window, a day with more than four windows or one over a day, more than
|
|
116
|
+
* SLOTLOCK_MAX_VAVAILABILITY_DAYS such days, and any rule `parseAvailabilityRules` refuses. An
|
|
117
|
+
* RRULE without BYDAY takes DTSTART's day, as RFC 5545 reads it.
|
|
81
118
|
*/
|
|
82
119
|
export function parseVAvailability(text) {
|
|
83
120
|
if (text.length > SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS) {
|
|
@@ -99,16 +136,26 @@ export function parseVAvailability(text) {
|
|
|
99
136
|
throw invalidAvailability('A VAVAILABILITY bounded by DTSTART or DTEND cannot be weekly hours');
|
|
100
137
|
}
|
|
101
138
|
const zones = new Set();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
139
|
+
// What EXDATE, RDATE and single AVAILABLEs say, day by day: weekly windows taken away, and
|
|
140
|
+
// windows added.
|
|
141
|
+
const excluded = new Map();
|
|
142
|
+
const added = new Map();
|
|
143
|
+
const named = new Set();
|
|
144
|
+
const nameDay = (date, index) => {
|
|
145
|
+
named.add(date);
|
|
146
|
+
if (named.size > SLOTLOCK_MAX_VAVAILABILITY_DAYS) {
|
|
147
|
+
throw invalidAvailability(`AVAILABLE ${index}: at most ${SLOTLOCK_MAX_VAVAILABILITY_DAYS} days may differ from the week`);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const weekly = [];
|
|
151
|
+
availability.getAllSubcomponents('available').forEach((available, index) => {
|
|
152
|
+
if (available.hasProperty('recurrence-id')) {
|
|
153
|
+
throw invalidAvailability(`AVAILABLE ${index} has RECURRENCE-ID: weekly hours cannot say it`);
|
|
107
154
|
}
|
|
108
155
|
const dtstart = single(available, 'dtstart');
|
|
109
156
|
const rrule = single(available, 'rrule');
|
|
110
|
-
if (!dtstart
|
|
111
|
-
throw invalidAvailability(`AVAILABLE ${index} needs DTSTART
|
|
157
|
+
if (!dtstart)
|
|
158
|
+
throw invalidAvailability(`AVAILABLE ${index} needs DTSTART`);
|
|
112
159
|
const start = dtstart.getFirstValue();
|
|
113
160
|
if (start.isDate)
|
|
114
161
|
throw invalidAvailability(`AVAILABLE ${index} starts on a date, not a time`);
|
|
@@ -139,16 +186,97 @@ export function parseVAvailability(text) {
|
|
|
139
186
|
if (!Number.isInteger(durationMinutes) || durationMinutes < 1) {
|
|
140
187
|
throw invalidAvailability(`AVAILABLE ${index} does not last a whole number of minutes`);
|
|
141
188
|
}
|
|
189
|
+
const startMinutes = start.hour * 60 + start.minute;
|
|
190
|
+
const dateOf = (time) => `${pad(time.year, 4)}-${pad(time.month)}-${pad(time.day)}`;
|
|
191
|
+
// EXDATE and RDATE are read in DTSTART's zone: another would move them.
|
|
192
|
+
const sameZone = (property, name) => {
|
|
193
|
+
if (property.getParameter('tzid') !== dtstart.getParameter('tzid')) {
|
|
194
|
+
throw invalidAvailability(`AVAILABLE ${index} has ${name} in another zone than DTSTART`);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const addWindow = (date, minutes, length) => {
|
|
198
|
+
if (length > 1_440) {
|
|
199
|
+
throw invalidAvailability(`AVAILABLE ${index} adds a window longer than a day on ${date}`);
|
|
200
|
+
}
|
|
201
|
+
nameDay(date, index);
|
|
202
|
+
added.set(date, [...(added.get(date) ?? []), { startMinutes: minutes, durationMinutes: length }]);
|
|
203
|
+
};
|
|
204
|
+
for (const property of available.getAllProperties('rdate')) {
|
|
205
|
+
sameZone(property, 'RDATE');
|
|
206
|
+
for (const value of property.getValues()) {
|
|
207
|
+
if (value instanceof ICAL.Period) {
|
|
208
|
+
const from = value.start;
|
|
209
|
+
const to = value.getEnd();
|
|
210
|
+
const length = (wallSeconds(to) - wallSeconds(from)) / 60;
|
|
211
|
+
if (from.isDate || from.second !== 0 || !Number.isInteger(length) || length < 1) {
|
|
212
|
+
throw invalidAvailability(`AVAILABLE ${index} has an RDATE period that is not whole minutes`);
|
|
213
|
+
}
|
|
214
|
+
addWindow(dateOf(from), from.hour * 60 + from.minute, length);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
const time = value;
|
|
218
|
+
if (time.isDate || time.second !== 0) {
|
|
219
|
+
throw invalidAvailability(`AVAILABLE ${index} has an RDATE that is not a time to the minute`);
|
|
220
|
+
}
|
|
221
|
+
addWindow(dateOf(time), time.hour * 60 + time.minute, durationMinutes);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (!rrule) {
|
|
226
|
+
// A single window on its day.
|
|
227
|
+
addWindow(dateOf(start), startMinutes, durationMinutes);
|
|
228
|
+
if (available.hasProperty('exdate')) {
|
|
229
|
+
throw invalidAvailability(`AVAILABLE ${index} has EXDATE but no RRULE`);
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
142
233
|
let value = String(rrule.getFirstValue());
|
|
143
234
|
if (!/(?:^|;)BYDAY=/.test(value)) {
|
|
144
235
|
value = `${value};BYDAY=${DAYS[start.dayOfWeek() - 1]}`;
|
|
145
236
|
}
|
|
146
|
-
|
|
237
|
+
const position = weekly.length;
|
|
238
|
+
weekly.push({ rule: { rrule: value, startMinutes, durationMinutes }, index });
|
|
239
|
+
for (const property of available.getAllProperties('exdate')) {
|
|
240
|
+
sameZone(property, 'EXDATE');
|
|
241
|
+
for (const time of property.getValues()) {
|
|
242
|
+
if (!time.isDate && time.hour * 60 + time.minute !== startMinutes) {
|
|
243
|
+
throw invalidAvailability(`AVAILABLE ${index} has an EXDATE at another time than its window`);
|
|
244
|
+
}
|
|
245
|
+
const date = dateOf(time);
|
|
246
|
+
nameDay(date, index);
|
|
247
|
+
excluded.set(date, (excluded.get(date) ?? new Set()).add(position));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
147
250
|
});
|
|
148
251
|
if (zones.size > 1)
|
|
149
252
|
throw invalidAvailability('The AVAILABLEs are in different time zones');
|
|
150
253
|
const [timezone = null] = zones;
|
|
151
|
-
|
|
254
|
+
const rules = parseAvailabilityRules(weekly.map(({ rule }) => rule));
|
|
255
|
+
// Each named day as it reads: the weekly windows that fall on it and are not excluded, and those
|
|
256
|
+
// added.
|
|
257
|
+
const days = [...named].sort().map((date) => {
|
|
258
|
+
const weekday = weekdayOf(date);
|
|
259
|
+
const untilOk = (rrule) => {
|
|
260
|
+
const until = /(?:^|;)UNTIL=(\d{8})/.exec(rrule)?.[1];
|
|
261
|
+
return until === undefined || compactDate(date) <= until;
|
|
262
|
+
};
|
|
263
|
+
const windows = [];
|
|
264
|
+
rules.forEach((rule, position) => {
|
|
265
|
+
if (!byDays(rule.rrule).has(weekday) || !untilOk(rule.rrule) || excluded.get(date)?.has(position))
|
|
266
|
+
return;
|
|
267
|
+
if (rule.durationMinutes > 1_440) {
|
|
268
|
+
throw invalidAvailability(`A window longer than a day falls on ${date}, which differs from the week`);
|
|
269
|
+
}
|
|
270
|
+
windows.push({ startMinutes: rule.startMinutes, durationMinutes: rule.durationMinutes });
|
|
271
|
+
});
|
|
272
|
+
windows.push(...(added.get(date) ?? []));
|
|
273
|
+
if (windows.length > 4)
|
|
274
|
+
throw invalidAvailability(`${date} has more than 4 windows`);
|
|
275
|
+
if (epochDay(date) === null)
|
|
276
|
+
throw invalidAvailability(`${date} is not a day`);
|
|
277
|
+
return { date, windows };
|
|
278
|
+
});
|
|
279
|
+
return { timezone, rules, days };
|
|
152
280
|
}
|
|
153
281
|
const wallSeconds = (time) => Date.UTC(time.year, time.month - 1, time.day, time.hour, time.minute, time.second) / 1000;
|
|
154
282
|
/** An instant as a LocalDateTime (RFC 8984 §1.4.4) on the wall clock of `timezone`. */
|
package/lifecycle.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SlotlockChangeType } from './event-feed.js';
|
|
2
|
+
export declare const SLOTLOCK_BOOKING_STATUSES: readonly ["held", "confirmed", "checked_in", "completed", "cancelled", "no_show", "released", "expired", "erased"];
|
|
3
|
+
export type SlotlockBookingStatus = (typeof SLOTLOCK_BOOKING_STATUSES)[number];
|
|
4
|
+
/** What moves a hold or booking, and who does it. */
|
|
5
|
+
export declare const SLOTLOCK_BOOKING_ACTIONS: readonly ["confirm", "release", "expire", "reschedule", "check_in", "cancel", "no_show", "complete", "erase"];
|
|
6
|
+
export type SlotlockBookingAction = (typeof SLOTLOCK_BOOKING_ACTIONS)[number];
|
|
7
|
+
/** Where an end leaves a hold or booking: nothing moves it again, but erasure. */
|
|
8
|
+
export declare const SLOTLOCK_BOOKING_ENDS: ReadonlySet<SlotlockBookingStatus>;
|
|
9
|
+
/** Why a tombstoned reference ended, as `reservation_tombstones.end_reason` keeps it. */
|
|
10
|
+
export type SlotlockEndReason = 'cancelled' | 'no_show' | 'released' | 'expired' | 'erased';
|
|
11
|
+
type Move = {
|
|
12
|
+
to: SlotlockBookingStatus;
|
|
13
|
+
change: SlotlockChangeType | readonly SlotlockChangeType[] | null;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Each allowed move: the status it leads to and the feed change it writes (null: none; a list: all
|
|
17
|
+
* of them, in order). Anything not here is refused with `SLOTLOCK_BOOKING_REFUSALS`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const SLOTLOCK_BOOKING_TRANSITIONS: Readonly<Record<SlotlockBookingStatus, Readonly<Partial<Record<SlotlockBookingAction, Move>>>>>;
|
|
20
|
+
/** The refusal a move gets from a status it is not allowed from, as the store names it. */
|
|
21
|
+
export declare function slotlockBookingRefusal(from: SlotlockBookingStatus, action: SlotlockBookingAction): string;
|
|
22
|
+
/** What `action` does from `from`: the status it leads to and the change it writes, or its refusal. */
|
|
23
|
+
export declare function slotlockBookingNext(from: SlotlockBookingStatus, action: SlotlockBookingAction): {
|
|
24
|
+
ok: true;
|
|
25
|
+
to: SlotlockBookingStatus;
|
|
26
|
+
changes: readonly SlotlockChangeType[];
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
code: string;
|
|
30
|
+
};
|
|
31
|
+
export {};
|