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/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';
|
|
@@ -26,8 +34,14 @@ export { createSlotlockPartnerKeyAuthenticator, createSlotlockPartnerStore, pars
|
|
|
26
34
|
export { createSlotlockDashboardTeams, createSlotlockOrganizationCheck } from './teams.js';
|
|
27
35
|
export { reportSlotlockOverage, SLOTLOCK_DEFAULT_OVERAGE_STOP_PERCENT } from './overage.js';
|
|
28
36
|
export { createSlotlockOtlpExporter, readSlotlockOtlpConfig, slotlockCallSpan } from './otel.js';
|
|
37
|
+
export { createSlotlockInvitationEmailSender, SlotlockInvitationEmailError, slotlockInvitationEmail, } from './invitation-email.js';
|
|
38
|
+
export { createSlotlockDashboardWorkspaces, createSlotlockMemoryDashboardWorkspaces, createSlotlockWorkspaceCheck, } from './workspaces.js';
|
|
39
|
+
export { SLOTLOCK_DASHBOARD_PAGE_CAPABILITIES, SLOTLOCK_DASHBOARD_POST_CAPABILITIES, SLOTLOCK_FRESH_CAPABILITIES, SLOTLOCK_ROLE_CAPABILITIES, SLOTLOCK_SCOPED_CAPABILITIES, SLOTLOCK_SCOPED_DASHBOARD_PAGES, SLOTLOCK_SCOPED_DASHBOARD_POSTS, SLOTLOCK_WORKSPACE_CAPABILITIES, SLOTLOCK_WORKSPACE_ROLE_NAMES, SLOTLOCK_WORKSPACE_ROLES, isSlotlockWorkspaceRole, slotlockMemberCan, slotlockRoleCan, slotlockRoleKeyScopes, slotlockRoleMayGrant, } from './workspace-roles.js';
|
|
40
|
+
// For resource adapters of one's own that keep to a limited member's scope (`keepsToScope`).
|
|
41
|
+
export { slotlockDashboardRequestScope } from './dashboard-scope.js';
|
|
29
42
|
export { closeSlotlockPartnerMonth, priceSlotlockPartnerMonth, slotlockClientLabel, slotlockPartnerMonthChecksum, } from './partner-billing.js';
|
|
30
43
|
export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
|
|
31
44
|
export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, recurrenceRuleProblem, storedCalendarEventProblem, } from './sync.js';
|
|
32
|
-
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';
|
|
45
|
+
export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_APPEND_ONLY_TABLES, SLOTLOCK_AUDIT_RETENTION_DAYS, SLOTLOCK_CHANGE_RETENTION_BY_PLAN, 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';
|
|
46
|
+
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, SLOTLOCK_TENANT_EXPORT_HOURS, SLOTLOCK_TENANT_EXPORT_MAX_BYTES, SLOTLOCK_TENANT_EXPORT_PART_ROWS, SLOTLOCK_MAX_TENANT_EXPORTS, buildSlotlockTenantExport, slotlockAuditEventRecord, } from './store.js';
|
|
47
|
+
export { SLOTLOCK_SUBJECT_TABLES, SLOTLOCK_TENANT_EXPORT_LEFT_OUT, SLOTLOCK_TENANT_EXPORT_TABLES, } from './types.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`. */
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SlotlockInvitationSender } from './dashboard.js';
|
|
2
|
+
/** Where invitations are sent from (SLOTLOCK_INVITATION_EMAIL, _KEY, _FROM). */
|
|
3
|
+
export interface SlotlockInvitationEmailConfig {
|
|
4
|
+
provider: 'resend' | 'postmark';
|
|
5
|
+
/** Resend's API key, or Postmark's server token. */
|
|
6
|
+
key: string;
|
|
7
|
+
/** The sender, as the provider has it verified: `Slotlock <team@example.com>` or an address. */
|
|
8
|
+
from: string;
|
|
9
|
+
}
|
|
10
|
+
type Invitation = Parameters<SlotlockInvitationSender['send']>[0];
|
|
11
|
+
/** A provider's refusal: its name and HTTP status alone (its text can echo the address). */
|
|
12
|
+
export declare class SlotlockInvitationEmailError extends Error {
|
|
13
|
+
name: string;
|
|
14
|
+
}
|
|
15
|
+
/** The message an invitation sends: subject, text and HTML saying the same, with no marketing. */
|
|
16
|
+
export declare function slotlockInvitationEmail(invitation: Invitation, brandName?: string): {
|
|
17
|
+
subject: string;
|
|
18
|
+
text: string;
|
|
19
|
+
html: string;
|
|
20
|
+
};
|
|
21
|
+
/** Sends each invitation once through the provider; throws on any answer but success. */
|
|
22
|
+
export declare function createSlotlockInvitationEmailSender(config: SlotlockInvitationEmailConfig, options?: {
|
|
23
|
+
fetch?: typeof fetch;
|
|
24
|
+
brandName?: string;
|
|
25
|
+
}): SlotlockInvitationSender;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Team invitations by email (dashboard.ts SlotlockInvitationSender): a plain transactional message
|
|
2
|
+
// with the link, sent through Resend or Postmark with the deployment's own key. Nothing else is
|
|
3
|
+
// sent, and no address is kept by this module.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
/** A provider's refusal: its name and HTTP status alone (its text can echo the address). */
|
|
6
|
+
export class SlotlockInvitationEmailError extends Error {
|
|
7
|
+
name = 'SlotlockInvitationEmailError';
|
|
8
|
+
}
|
|
9
|
+
/** Resend refuses a request without a User-Agent (403), and a Worker's fetch sends none. */
|
|
10
|
+
const USER_AGENT = 'Slotlock (+https://slotlock.pylota.io)';
|
|
11
|
+
const escape = (value) => value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
|
|
12
|
+
/** The message an invitation sends: subject, text and HTML saying the same, with no marketing. */
|
|
13
|
+
export function slotlockInvitationEmail(invitation, brandName = 'Slotlock') {
|
|
14
|
+
const until = `${invitation.expiresAt.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
15
|
+
const subject = `${invitation.inviter} invited you to ${invitation.workspace} on ${brandName}`;
|
|
16
|
+
const lines = [
|
|
17
|
+
`${invitation.inviter} invited you to join ${invitation.workspace} on ${brandName} as ${invitation.role}.`,
|
|
18
|
+
`Join: ${invitation.link}`,
|
|
19
|
+
`The link works once, for this address, until ${until}. If you did not expect it, ignore this email: nothing happens unless you use the link.`,
|
|
20
|
+
];
|
|
21
|
+
const html = `<!doctype html><html><body style="font-family:system-ui,sans-serif;line-height:1.5;color:#111"><p>${escape(lines[0])}</p><p><a href="${escape(invitation.link)}">Join ${escape(invitation.workspace)}</a></p><p style="color:#555;font-size:14px">${escape(lines[2])}</p></body></html>`;
|
|
22
|
+
return { subject, text: `${lines.join('\n\n')}\n`, html };
|
|
23
|
+
}
|
|
24
|
+
/** Sends each invitation once through the provider; throws on any answer but success. */
|
|
25
|
+
export function createSlotlockInvitationEmailSender(config, options = {}) {
|
|
26
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
27
|
+
return {
|
|
28
|
+
async send(invitation) {
|
|
29
|
+
const message = slotlockInvitationEmail(invitation, options.brandName);
|
|
30
|
+
const request = config.provider === 'resend'
|
|
31
|
+
? {
|
|
32
|
+
url: 'https://api.resend.com/emails',
|
|
33
|
+
headers: {
|
|
34
|
+
authorization: `Bearer ${config.key}`,
|
|
35
|
+
'content-type': 'application/json',
|
|
36
|
+
'user-agent': USER_AGENT,
|
|
37
|
+
// One send per link, however often the person inviting submits: keyed by the link,
|
|
38
|
+
// as a digest, so the key names no address.
|
|
39
|
+
'idempotency-key': createHash('sha256').update(invitation.link, 'utf8').digest('hex'),
|
|
40
|
+
},
|
|
41
|
+
body: { from: config.from, to: [invitation.to], subject: message.subject, text: message.text, html: message.html },
|
|
42
|
+
}
|
|
43
|
+
: {
|
|
44
|
+
url: 'https://api.postmarkapp.com/email',
|
|
45
|
+
headers: {
|
|
46
|
+
'x-postmark-server-token': config.key,
|
|
47
|
+
'content-type': 'application/json',
|
|
48
|
+
accept: 'application/json',
|
|
49
|
+
'user-agent': USER_AGENT,
|
|
50
|
+
},
|
|
51
|
+
body: {
|
|
52
|
+
From: config.from,
|
|
53
|
+
To: invitation.to,
|
|
54
|
+
Subject: message.subject,
|
|
55
|
+
TextBody: message.text,
|
|
56
|
+
HtmlBody: message.html,
|
|
57
|
+
MessageStream: 'outbound',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const response = await fetchImpl(request.url, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: request.headers,
|
|
63
|
+
body: JSON.stringify(request.body),
|
|
64
|
+
redirect: 'manual',
|
|
65
|
+
signal: AbortSignal.timeout(10_000),
|
|
66
|
+
});
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
// The status alone: a provider's error text can echo the address.
|
|
69
|
+
throw new SlotlockInvitationEmailError(`${config.provider === 'resend' ? 'Resend' : 'Postmark'} answered HTTP ${response.status}`);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
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 {};
|
package/lifecycle.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export const SLOTLOCK_BOOKING_STATUSES = Object.freeze([
|
|
2
|
+
'held',
|
|
3
|
+
'confirmed',
|
|
4
|
+
'checked_in',
|
|
5
|
+
'completed',
|
|
6
|
+
'cancelled',
|
|
7
|
+
'no_show',
|
|
8
|
+
'released',
|
|
9
|
+
'expired',
|
|
10
|
+
'erased',
|
|
11
|
+
]);
|
|
12
|
+
/** What moves a hold or booking, and who does it. */
|
|
13
|
+
export const SLOTLOCK_BOOKING_ACTIONS = Object.freeze([
|
|
14
|
+
/** The holder books the held time (confirmHold, slotlock_confirm_slot). */
|
|
15
|
+
'confirm',
|
|
16
|
+
/** The holder lets the hold go (releaseHold, slotlock_release_slots). */
|
|
17
|
+
'release',
|
|
18
|
+
/** Its time to live runs out; the next writer in its way reaps it. */
|
|
19
|
+
'expire',
|
|
20
|
+
/** A new time or resource, at the next revision (rescheduleExternalReservation). */
|
|
21
|
+
'reschedule',
|
|
22
|
+
/** Claimed in person (checkInReservation), from an hour before the start until the end. */
|
|
23
|
+
'check_in',
|
|
24
|
+
/** Cancelled at the next revision (cancelExternalReservation). */
|
|
25
|
+
'cancel',
|
|
26
|
+
/** Not checked in within the resource's checkInMinutes: maintenance releases it. */
|
|
27
|
+
'no_show',
|
|
28
|
+
/** Its end passes: maintenance says so on the feed. */
|
|
29
|
+
'complete',
|
|
30
|
+
/** The tenant is erased. */
|
|
31
|
+
'erase',
|
|
32
|
+
]);
|
|
33
|
+
/** Where an end leaves a hold or booking: nothing moves it again, but erasure. */
|
|
34
|
+
export const SLOTLOCK_BOOKING_ENDS = new Set([
|
|
35
|
+
'cancelled',
|
|
36
|
+
'no_show',
|
|
37
|
+
'released',
|
|
38
|
+
'expired',
|
|
39
|
+
'erased',
|
|
40
|
+
]);
|
|
41
|
+
/**
|
|
42
|
+
* Each allowed move: the status it leads to and the feed change it writes (null: none; a list: all
|
|
43
|
+
* of them, in order). Anything not here is refused with `SLOTLOCK_BOOKING_REFUSALS`.
|
|
44
|
+
*/
|
|
45
|
+
export const SLOTLOCK_BOOKING_TRANSITIONS = Object.freeze({
|
|
46
|
+
held: {
|
|
47
|
+
confirm: { to: 'confirmed', change: 'hold.confirmed' },
|
|
48
|
+
release: { to: 'released', change: 'hold.released' },
|
|
49
|
+
expire: { to: 'expired', change: 'hold.expired' },
|
|
50
|
+
reschedule: { to: 'held', change: 'hold.updated' },
|
|
51
|
+
erase: { to: 'erased', change: null },
|
|
52
|
+
},
|
|
53
|
+
confirmed: {
|
|
54
|
+
reschedule: { to: 'confirmed', change: ['reservation.updated', 'reservation.rescheduled'] },
|
|
55
|
+
check_in: { to: 'checked_in', change: 'reservation.checked_in' },
|
|
56
|
+
cancel: { to: 'cancelled', change: 'reservation.cancelled' },
|
|
57
|
+
no_show: { to: 'no_show', change: 'reservation.no_show' },
|
|
58
|
+
complete: { to: 'completed', change: 'reservation.completed' },
|
|
59
|
+
erase: { to: 'erased', change: null },
|
|
60
|
+
},
|
|
61
|
+
checked_in: {
|
|
62
|
+
reschedule: { to: 'checked_in', change: ['reservation.updated', 'reservation.rescheduled'] },
|
|
63
|
+
// Checking in again answers the first check-in.
|
|
64
|
+
check_in: { to: 'checked_in', change: null },
|
|
65
|
+
cancel: { to: 'cancelled', change: 'reservation.cancelled' },
|
|
66
|
+
complete: { to: 'completed', change: 'reservation.completed' },
|
|
67
|
+
erase: { to: 'erased', change: null },
|
|
68
|
+
},
|
|
69
|
+
completed: {
|
|
70
|
+
// A system of record may correct or void a booking after it ended.
|
|
71
|
+
reschedule: { to: 'completed', change: ['reservation.updated', 'reservation.rescheduled'] },
|
|
72
|
+
cancel: { to: 'cancelled', change: 'reservation.cancelled' },
|
|
73
|
+
erase: { to: 'erased', change: null },
|
|
74
|
+
},
|
|
75
|
+
cancelled: { erase: { to: 'erased', change: null } },
|
|
76
|
+
no_show: { erase: { to: 'erased', change: null } },
|
|
77
|
+
released: {},
|
|
78
|
+
expired: {},
|
|
79
|
+
erased: {},
|
|
80
|
+
});
|
|
81
|
+
/** The refusal a move gets from a status it is not allowed from, as the store names it. */
|
|
82
|
+
export function slotlockBookingRefusal(from, action) {
|
|
83
|
+
const holdAction = action === 'confirm' || action === 'release' || action === 'expire';
|
|
84
|
+
switch (from) {
|
|
85
|
+
case 'held':
|
|
86
|
+
// A hold is not a booking: booking operations do not find it.
|
|
87
|
+
return 'reservation_not_found';
|
|
88
|
+
case 'confirmed':
|
|
89
|
+
case 'checked_in':
|
|
90
|
+
if (holdAction)
|
|
91
|
+
return 'hold_not_found';
|
|
92
|
+
// Only maintenance releases no-shows, and never one checked in.
|
|
93
|
+
return 'reservation_checked_in';
|
|
94
|
+
case 'completed':
|
|
95
|
+
return holdAction ? 'hold_not_found' : 'reservation_ended';
|
|
96
|
+
case 'cancelled':
|
|
97
|
+
case 'no_show':
|
|
98
|
+
// The reference is retired: writing it again is refused, checking it in finds nothing.
|
|
99
|
+
return action === 'check_in' ? 'reservation_not_found' : holdAction ? 'hold_not_found' : 'reservation_cancelled';
|
|
100
|
+
case 'released':
|
|
101
|
+
return action === 'confirm' ? 'hold_not_found' : 'not_found';
|
|
102
|
+
case 'expired':
|
|
103
|
+
return action === 'confirm' ? 'hold_expired' : 'not_found';
|
|
104
|
+
case 'erased':
|
|
105
|
+
return 'not_found';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** What `action` does from `from`: the status it leads to and the change it writes, or its refusal. */
|
|
109
|
+
export function slotlockBookingNext(from, action) {
|
|
110
|
+
const move = SLOTLOCK_BOOKING_TRANSITIONS[from][action];
|
|
111
|
+
if (!move)
|
|
112
|
+
return { ok: false, code: slotlockBookingRefusal(from, action) };
|
|
113
|
+
const changes = move.change === null ? [] : typeof move.change === 'string' ? [move.change] : move.change;
|
|
114
|
+
return { ok: true, to: move.to, changes };
|
|
115
|
+
}
|
package/maintenance.d.ts
CHANGED
|
@@ -9,8 +9,8 @@ export interface SlotlockMaintenanceResult {
|
|
|
9
9
|
refused: number;
|
|
10
10
|
/**
|
|
11
11
|
* Rows deleted: expired holds and their keyed answers; past retention, event idempotency answers,
|
|
12
|
-
* agent event cancellations, stray booking retirements and feed changes;
|
|
13
|
-
* conversations. A cancelled booking's tombstone stays.
|
|
12
|
+
* agent event cancellations, stray booking retirements and feed changes; expired assistant
|
|
13
|
+
* conversations; and audit events past retention. A cancelled booking's tombstone stays.
|
|
14
14
|
*/
|
|
15
15
|
pruned: number;
|
|
16
16
|
/**
|
|
@@ -26,6 +26,18 @@ export interface SlotlockMaintenanceResult {
|
|
|
26
26
|
waitlistOffers: number;
|
|
27
27
|
/** Approvals no one decided in time, their held time freed (`approval.expired`). */
|
|
28
28
|
approvalsExpired: number;
|
|
29
|
+
/** Bookings whose end has passed in the last week, now said on the feed (`reservation.completed`). */
|
|
30
|
+
bookingsCompleted: number;
|
|
31
|
+
/**
|
|
32
|
+
* Event reminders that came due, each now on the feed once (`reminder.due`) for a sender to send
|
|
33
|
+
* (RFC 0004 P3).
|
|
34
|
+
*/
|
|
35
|
+
remindersDue: number;
|
|
36
|
+
/**
|
|
37
|
+
* Holiday calendar subscriptions imported again (RFC 0004 B1), with `holidayFetch`; one that
|
|
38
|
+
* could not be read keeps its closures and is tried again a day later.
|
|
39
|
+
*/
|
|
40
|
+
holidayCalendarsImported: number;
|
|
29
41
|
/** More was left than one pass does: run another. */
|
|
30
42
|
capped: boolean;
|
|
31
43
|
/**
|
|
@@ -49,6 +61,11 @@ export declare function maintainSlotlockTenant(store: SlotlockStore, tenantRef:
|
|
|
49
61
|
* (SLOTLOCK_USAGE=on).
|
|
50
62
|
*/
|
|
51
63
|
usage?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Fetches holiday calendar subscriptions due to be imported again (the guarded fetch under
|
|
66
|
+
* `serve`); without it, none is.
|
|
67
|
+
*/
|
|
68
|
+
holidayFetch?: typeof fetch;
|
|
52
69
|
}): Promise<SlotlockMaintenanceResult>;
|
|
53
70
|
/**
|
|
54
71
|
* Run `work` holding the tenant's maintenance lock on a connection of its own, or return
|
|
@@ -83,6 +100,13 @@ export interface SlotlockMaintenanceSchedulerOptions {
|
|
|
83
100
|
batches?: number;
|
|
84
101
|
/** Roll each tenant's bookings and holds up into its usage, first (maintainSlotlockTenant `usage`). */
|
|
85
102
|
usage?: boolean;
|
|
103
|
+
/** Fetches holiday calendar subscriptions due to be imported again (maintainSlotlockTenant). */
|
|
104
|
+
holidayFetch?: typeof fetch;
|
|
105
|
+
/**
|
|
106
|
+
* Team workspaces' verified domains looked up again (workspace-domains.ts runSlotlockDomainChecks):
|
|
107
|
+
* run with each sweep, at most every quarter hour. What it throws goes to onError.
|
|
108
|
+
*/
|
|
109
|
+
domainChecks?: () => Promise<unknown>;
|
|
86
110
|
/** A pass that ran: counts only, never the tenant. Not awaited; what it throws is ignored. */
|
|
87
111
|
onPass?: (result: SlotlockMaintenanceResult) => void;
|
|
88
112
|
/**
|