@tryvitris/website-admin-contracts 0.1.0-alpha.1 → 1.0.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/LICENSE +21 -21
- package/README.md +3 -3
- package/dist/billing.d.ts +117 -0
- package/dist/billing.js +7 -0
- package/dist/client-onboarding.d.ts +69 -0
- package/dist/client-onboarding.js +35 -0
- package/dist/client-portfolio.d.ts +64 -0
- package/dist/client-portfolio.js +9 -0
- package/dist/index.d.ts +163 -1
- package/dist/index.js +75 -8
- package/dist/scheduling.d.ts +410 -0
- package/dist/scheduling.js +216 -0
- package/dist/stripe-billing.d.ts +103 -0
- package/dist/stripe-billing.js +0 -0
- package/package.json +36 -32
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/** Versioned scheduling DTOs contain no deployment or provider credentials. */
|
|
2
|
+
export declare const schedulingContractVersion = 1;
|
|
3
|
+
export declare const schedulingPermissions: readonly ["appointments:read", "appointments:write", "availability:write", "scheduling:configure", "calendar-connections:manage"];
|
|
4
|
+
export type SchedulingPermission = typeof schedulingPermissions[number];
|
|
5
|
+
export type BookingMode = 'instant' | 'approval';
|
|
6
|
+
export type SchedulingMode = 'disabled' | 'test';
|
|
7
|
+
export type CalendarProvider = 'google' | 'microsoft';
|
|
8
|
+
export type CalendarConnectionHealth = 'disconnected' | 'connecting' | 'healthy' | 'stale' | 'reauthorization_required' | 'error';
|
|
9
|
+
export type AppointmentStatus = 'pending_approval' | 'confirmed' | 'declined' | 'expired' | 'cancelled' | 'completed' | 'no_show';
|
|
10
|
+
export declare const appointmentStatuses: readonly AppointmentStatus[];
|
|
11
|
+
export interface SchedulingLocation {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
timezone: string;
|
|
15
|
+
active: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface SchedulingResource {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
kind: 'provider' | 'room' | 'bay' | 'equipment';
|
|
21
|
+
locationIds: string[];
|
|
22
|
+
active: boolean;
|
|
23
|
+
userId?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SchedulingService {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
active: boolean;
|
|
29
|
+
durationMinutes: number;
|
|
30
|
+
bufferBeforeMinutes: number;
|
|
31
|
+
bufferAfterMinutes: number;
|
|
32
|
+
bookingMode: BookingMode | 'inherit';
|
|
33
|
+
locationMode: 'business' | 'phone' | 'video' | 'customer';
|
|
34
|
+
locationIds: string[];
|
|
35
|
+
providerIds: string[];
|
|
36
|
+
resourceIds: string[];
|
|
37
|
+
price: {
|
|
38
|
+
amountMinor: number | null;
|
|
39
|
+
currency: string;
|
|
40
|
+
label: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Overnight hours are represented by two windows on adjacent weekdays. */
|
|
44
|
+
export interface SchedulingWeeklyHours {
|
|
45
|
+
locationId: string;
|
|
46
|
+
resourceId?: string;
|
|
47
|
+
weekday: number;
|
|
48
|
+
startMinute: number;
|
|
49
|
+
endMinute: number;
|
|
50
|
+
}
|
|
51
|
+
export interface SchedulingException {
|
|
52
|
+
id: string;
|
|
53
|
+
locationId: string;
|
|
54
|
+
resourceId?: string;
|
|
55
|
+
startsAt: string;
|
|
56
|
+
endsAt: string;
|
|
57
|
+
kind: 'open' | 'closed';
|
|
58
|
+
}
|
|
59
|
+
export interface SchedulingGrant {
|
|
60
|
+
userId: string;
|
|
61
|
+
scope: 'all' | 'own';
|
|
62
|
+
permissions: SchedulingPermission[];
|
|
63
|
+
}
|
|
64
|
+
export interface SchedulingConfig {
|
|
65
|
+
schemaVersion: 1;
|
|
66
|
+
mode: SchedulingMode;
|
|
67
|
+
defaultBookingMode: BookingMode;
|
|
68
|
+
minNoticeMinutes: number;
|
|
69
|
+
horizonDays: number;
|
|
70
|
+
slotStepMinutes: number;
|
|
71
|
+
approvalTtlMinutes: number;
|
|
72
|
+
locations: SchedulingLocation[];
|
|
73
|
+
resources: SchedulingResource[];
|
|
74
|
+
services: SchedulingService[];
|
|
75
|
+
weeklyHours: SchedulingWeeklyHours[];
|
|
76
|
+
exceptions: SchedulingException[];
|
|
77
|
+
grants: SchedulingGrant[];
|
|
78
|
+
notifications?: {
|
|
79
|
+
enabled: boolean;
|
|
80
|
+
mode: 'capture' | 'send';
|
|
81
|
+
reminders?: {
|
|
82
|
+
enabled: boolean;
|
|
83
|
+
offsetsMinutes: number[];
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
customerManagement?: {
|
|
87
|
+
enabled: boolean;
|
|
88
|
+
cancellationCutoffMinutes: number;
|
|
89
|
+
rescheduleCutoffMinutes: number;
|
|
90
|
+
};
|
|
91
|
+
publicBooking?: {
|
|
92
|
+
enabled: boolean;
|
|
93
|
+
serviceIds: string[];
|
|
94
|
+
allowProviderChoice: boolean;
|
|
95
|
+
policyText: string;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export interface SchedulingConfigurationResponse {
|
|
99
|
+
schemaVersion: 1;
|
|
100
|
+
revision: number;
|
|
101
|
+
config: SchedulingConfig;
|
|
102
|
+
}
|
|
103
|
+
export interface SchedulingCustomer {
|
|
104
|
+
name: string;
|
|
105
|
+
email: string;
|
|
106
|
+
phone: string;
|
|
107
|
+
}
|
|
108
|
+
export interface CreateAppointmentRequest {
|
|
109
|
+
idempotencyKey: string;
|
|
110
|
+
revision: number;
|
|
111
|
+
serviceId: string;
|
|
112
|
+
locationId: string;
|
|
113
|
+
providerId: string;
|
|
114
|
+
startsAt: string;
|
|
115
|
+
customer: SchedulingCustomer;
|
|
116
|
+
}
|
|
117
|
+
export interface RescheduleAppointmentRequest {
|
|
118
|
+
idempotencyKey: string;
|
|
119
|
+
revision: number;
|
|
120
|
+
appointmentRevision: number;
|
|
121
|
+
locationId: string;
|
|
122
|
+
providerId: string;
|
|
123
|
+
startsAt: string;
|
|
124
|
+
}
|
|
125
|
+
export interface AppointmentStatusRequest {
|
|
126
|
+
idempotencyKey: string;
|
|
127
|
+
revision: number;
|
|
128
|
+
appointmentRevision: number;
|
|
129
|
+
status: AppointmentStatus;
|
|
130
|
+
}
|
|
131
|
+
export interface SchedulingMutationResponse {
|
|
132
|
+
ok: true;
|
|
133
|
+
id: string;
|
|
134
|
+
revision: number;
|
|
135
|
+
scheduleRevision: number;
|
|
136
|
+
}
|
|
137
|
+
export interface AppointmentSnapshot {
|
|
138
|
+
service: SchedulingService;
|
|
139
|
+
location: SchedulingLocation;
|
|
140
|
+
resourceIds: string[];
|
|
141
|
+
bookingMode: BookingMode;
|
|
142
|
+
approvalTtlMinutes: number;
|
|
143
|
+
customerManagement?: SchedulingConfig['customerManagement'];
|
|
144
|
+
}
|
|
145
|
+
export interface SchedulingAppointment {
|
|
146
|
+
id: string;
|
|
147
|
+
title: string;
|
|
148
|
+
serviceId: string;
|
|
149
|
+
locationId: string;
|
|
150
|
+
providerId: string;
|
|
151
|
+
startsAt: string;
|
|
152
|
+
endsAt: string;
|
|
153
|
+
occupiedStartsAt: string;
|
|
154
|
+
occupiedEndsAt: string;
|
|
155
|
+
status: AppointmentStatus;
|
|
156
|
+
expiresAt: string | null;
|
|
157
|
+
customer: SchedulingCustomer;
|
|
158
|
+
revision: number;
|
|
159
|
+
snapshot: AppointmentSnapshot;
|
|
160
|
+
mode: 'test';
|
|
161
|
+
source?: 'website' | 'staff' | 'unknown';
|
|
162
|
+
acceptedPolicy?: string;
|
|
163
|
+
}
|
|
164
|
+
export interface SchedulingSummary {
|
|
165
|
+
schemaVersion: 1;
|
|
166
|
+
mode: SchedulingMode;
|
|
167
|
+
revision: number;
|
|
168
|
+
generatedAt: string;
|
|
169
|
+
through: string;
|
|
170
|
+
upcoming: number;
|
|
171
|
+
pendingApproval: number;
|
|
172
|
+
next: SchedulingAppointment[];
|
|
173
|
+
}
|
|
174
|
+
export interface BookingReadiness {
|
|
175
|
+
ready: boolean;
|
|
176
|
+
mode: SchedulingMode;
|
|
177
|
+
simulated: boolean;
|
|
178
|
+
checks: {
|
|
179
|
+
id: string;
|
|
180
|
+
ready: boolean;
|
|
181
|
+
detail: string;
|
|
182
|
+
}[];
|
|
183
|
+
connection: {
|
|
184
|
+
version: 1;
|
|
185
|
+
enabled: true;
|
|
186
|
+
apiOrigin: string;
|
|
187
|
+
siteId: string;
|
|
188
|
+
installationId: string;
|
|
189
|
+
route: string;
|
|
190
|
+
};
|
|
191
|
+
siteOrigin: string;
|
|
192
|
+
}
|
|
193
|
+
export interface SchedulingAvailabilityQuery {
|
|
194
|
+
serviceId: string;
|
|
195
|
+
locationId: string;
|
|
196
|
+
providerId?: string;
|
|
197
|
+
appointmentId?: string;
|
|
198
|
+
startsAt: string;
|
|
199
|
+
endsAt: string;
|
|
200
|
+
}
|
|
201
|
+
export interface SchedulingSlot {
|
|
202
|
+
startsAt: string;
|
|
203
|
+
endsAt: string;
|
|
204
|
+
providerId: string;
|
|
205
|
+
timezone: string;
|
|
206
|
+
}
|
|
207
|
+
export interface SchedulingAvailabilityResponse {
|
|
208
|
+
schemaVersion: 1;
|
|
209
|
+
revision: number;
|
|
210
|
+
slots: SchedulingSlot[];
|
|
211
|
+
truncated: boolean;
|
|
212
|
+
terms?: Pick<SchedulingService, 'durationMinutes' | 'bufferBeforeMinutes' | 'bufferAfterMinutes' | 'price'> & {
|
|
213
|
+
bookingMode: BookingMode;
|
|
214
|
+
};
|
|
215
|
+
location?: SchedulingLocation;
|
|
216
|
+
}
|
|
217
|
+
export interface SchedulingStatusResponse {
|
|
218
|
+
schemaVersion: 1;
|
|
219
|
+
revision: number;
|
|
220
|
+
mode: SchedulingMode;
|
|
221
|
+
publicBooking: boolean;
|
|
222
|
+
calendarSync: boolean;
|
|
223
|
+
deliveries: 'captured' | 'email';
|
|
224
|
+
scope: 'all' | 'own';
|
|
225
|
+
permissions: SchedulingPermission[];
|
|
226
|
+
}
|
|
227
|
+
export interface SchedulingCatalog {
|
|
228
|
+
locations: SchedulingLocation[];
|
|
229
|
+
resources: Omit<SchedulingResource, 'userId'>[];
|
|
230
|
+
services: SchedulingService[];
|
|
231
|
+
defaultBookingMode: BookingMode;
|
|
232
|
+
}
|
|
233
|
+
export interface SchedulingWorkspaceResponse {
|
|
234
|
+
status: SchedulingStatusResponse;
|
|
235
|
+
catalog: SchedulingCatalog;
|
|
236
|
+
configuration?: SchedulingConfigurationResponse;
|
|
237
|
+
accounts?: {
|
|
238
|
+
id: string;
|
|
239
|
+
email: string;
|
|
240
|
+
active: number;
|
|
241
|
+
}[];
|
|
242
|
+
accountsTruncated?: boolean;
|
|
243
|
+
}
|
|
244
|
+
export interface CalendarConnection {
|
|
245
|
+
id: string;
|
|
246
|
+
resourceId: string;
|
|
247
|
+
provider: CalendarProvider;
|
|
248
|
+
accountLabel: string;
|
|
249
|
+
enabled: boolean;
|
|
250
|
+
health: CalendarConnectionHealth;
|
|
251
|
+
revision: number;
|
|
252
|
+
blocking: string[];
|
|
253
|
+
destination: string | null;
|
|
254
|
+
lastSyncedAt: number | null;
|
|
255
|
+
errorCode: string | null;
|
|
256
|
+
pendingJobs: number;
|
|
257
|
+
conflicts: number;
|
|
258
|
+
}
|
|
259
|
+
export interface CalendarConnectionsResponse {
|
|
260
|
+
simulated: boolean;
|
|
261
|
+
enabled: boolean;
|
|
262
|
+
schemaReady: boolean;
|
|
263
|
+
providers: {
|
|
264
|
+
id: CalendarProvider;
|
|
265
|
+
configured: boolean;
|
|
266
|
+
callbackUrl: string;
|
|
267
|
+
}[];
|
|
268
|
+
connections: CalendarConnection[];
|
|
269
|
+
conflicts: {
|
|
270
|
+
connectionId: string;
|
|
271
|
+
appointmentId: string;
|
|
272
|
+
reason: string;
|
|
273
|
+
}[];
|
|
274
|
+
}
|
|
275
|
+
export interface SchedulingAppointmentList {
|
|
276
|
+
rows: SchedulingAppointment[];
|
|
277
|
+
revision: number;
|
|
278
|
+
truncated: boolean;
|
|
279
|
+
message?: string;
|
|
280
|
+
}
|
|
281
|
+
export interface SchedulingResourceAvailability {
|
|
282
|
+
schemaVersion: 1;
|
|
283
|
+
revision: number;
|
|
284
|
+
resourceId: string;
|
|
285
|
+
weeklyHours: SchedulingWeeklyHours[];
|
|
286
|
+
exceptions: SchedulingException[];
|
|
287
|
+
}
|
|
288
|
+
export interface SchedulingHistoryResponse {
|
|
289
|
+
rows: {
|
|
290
|
+
action: string;
|
|
291
|
+
actor_id: string;
|
|
292
|
+
revision: number;
|
|
293
|
+
created_at: number;
|
|
294
|
+
}[];
|
|
295
|
+
}
|
|
296
|
+
export declare class SchedulingValidationError extends Error {
|
|
297
|
+
}
|
|
298
|
+
export declare function schedulingId(value: unknown): string;
|
|
299
|
+
export declare function schedulingInstant(value: unknown): string;
|
|
300
|
+
export declare function schedulingRevision(value: unknown): number;
|
|
301
|
+
export declare function emptySchedulingConfig(): SchedulingConfig;
|
|
302
|
+
export declare function parseSchedulingHours(value: unknown): SchedulingWeeklyHours;
|
|
303
|
+
export declare function parseSchedulingException(value: unknown): SchedulingException;
|
|
304
|
+
export declare function parseSchedulingConfig(value: unknown): SchedulingConfig;
|
|
305
|
+
export declare function parseCreateAppointment(value: unknown): CreateAppointmentRequest;
|
|
306
|
+
export declare function parseRescheduleAppointment(value: unknown): RescheduleAppointmentRequest;
|
|
307
|
+
export declare function parseAppointmentStatus(value: unknown): AppointmentStatusRequest;
|
|
308
|
+
export declare function parseSchedulingAvailability(value: unknown): SchedulingAvailabilityQuery;
|
|
309
|
+
export interface PublicBookingCatalog {
|
|
310
|
+
schemaVersion: 1;
|
|
311
|
+
siteId: string;
|
|
312
|
+
installationId: string;
|
|
313
|
+
siteOrigin: string;
|
|
314
|
+
businessName: string;
|
|
315
|
+
mode: 'test';
|
|
316
|
+
revision: number;
|
|
317
|
+
horizonDays: number;
|
|
318
|
+
minNoticeMinutes: number;
|
|
319
|
+
policyText: string;
|
|
320
|
+
allowProviderChoice: boolean;
|
|
321
|
+
emailMode?: 'capture' | 'send' | 'disabled';
|
|
322
|
+
customerManagement?: {
|
|
323
|
+
cancellationCutoffMinutes: number;
|
|
324
|
+
rescheduleCutoffMinutes: number;
|
|
325
|
+
};
|
|
326
|
+
challenge: {
|
|
327
|
+
kind: 'turnstile' | 'local-fixture';
|
|
328
|
+
siteKey?: string;
|
|
329
|
+
};
|
|
330
|
+
services: {
|
|
331
|
+
id: string;
|
|
332
|
+
name: string;
|
|
333
|
+
durationMinutes: number;
|
|
334
|
+
price: SchedulingService['price'];
|
|
335
|
+
bookingMode: BookingMode;
|
|
336
|
+
locationMode: SchedulingService['locationMode'];
|
|
337
|
+
locationIds: string[];
|
|
338
|
+
providerIds: string[];
|
|
339
|
+
}[];
|
|
340
|
+
locations: SchedulingLocation[];
|
|
341
|
+
providers: {
|
|
342
|
+
id: string;
|
|
343
|
+
name: string;
|
|
344
|
+
locationIds: string[];
|
|
345
|
+
}[];
|
|
346
|
+
}
|
|
347
|
+
export interface PublicBookingRequest extends CreateAppointmentRequest {
|
|
348
|
+
acceptedPolicy: true;
|
|
349
|
+
challengeToken: string;
|
|
350
|
+
company: string;
|
|
351
|
+
}
|
|
352
|
+
export interface PublicBookingReceipt {
|
|
353
|
+
schemaVersion: 1;
|
|
354
|
+
siteId: string;
|
|
355
|
+
mode: 'test';
|
|
356
|
+
reference: string;
|
|
357
|
+
status: 'confirmed' | 'pending_approval';
|
|
358
|
+
serviceName: string;
|
|
359
|
+
startsAt: string;
|
|
360
|
+
endsAt: string;
|
|
361
|
+
providerName: string;
|
|
362
|
+
locationName: string;
|
|
363
|
+
timezone: string;
|
|
364
|
+
price: SchedulingService['price'];
|
|
365
|
+
policyText: string;
|
|
366
|
+
expiresAt: string | null;
|
|
367
|
+
delivery: 'captured' | 'queued';
|
|
368
|
+
calendar: {
|
|
369
|
+
filename: string;
|
|
370
|
+
contentType: string;
|
|
371
|
+
content: string;
|
|
372
|
+
};
|
|
373
|
+
emailStatus?: string;
|
|
374
|
+
}
|
|
375
|
+
export declare function parsePublicBooking(value: unknown): PublicBookingRequest;
|
|
376
|
+
export declare function parsePublicAvailability(value: unknown): SchedulingAvailabilityQuery;
|
|
377
|
+
export interface CustomerAppointment {
|
|
378
|
+
schemaVersion: 1;
|
|
379
|
+
reference: string;
|
|
380
|
+
status: AppointmentStatus;
|
|
381
|
+
revision: number;
|
|
382
|
+
scheduleRevision: number;
|
|
383
|
+
serviceName: string;
|
|
384
|
+
startsAt: string;
|
|
385
|
+
endsAt: string;
|
|
386
|
+
timezone: string;
|
|
387
|
+
locationName: string;
|
|
388
|
+
providerName: string;
|
|
389
|
+
horizonDays: number;
|
|
390
|
+
expiresAt: string | null;
|
|
391
|
+
emailStatus?: string;
|
|
392
|
+
price: SchedulingService['price'];
|
|
393
|
+
policyText: string;
|
|
394
|
+
canCancel: boolean;
|
|
395
|
+
canReschedule: boolean;
|
|
396
|
+
cancellationDeadline: string | null;
|
|
397
|
+
rescheduleDeadline: string | null;
|
|
398
|
+
calendar: {
|
|
399
|
+
filename: string;
|
|
400
|
+
contentType: string;
|
|
401
|
+
content: string;
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
export interface CustomerCommand {
|
|
405
|
+
idempotencyKey: string;
|
|
406
|
+
revision: number;
|
|
407
|
+
appointmentRevision: number;
|
|
408
|
+
startsAt?: string;
|
|
409
|
+
}
|
|
410
|
+
export declare function parseCustomerCommand(value: unknown, reschedule?: boolean): CustomerCommand;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// packages/contracts/src/scheduling.ts
|
|
2
|
+
var schedulingContractVersion = 1;
|
|
3
|
+
var schedulingPermissions = ["appointments:read", "appointments:write", "availability:write", "scheduling:configure", "calendar-connections:manage"];
|
|
4
|
+
var appointmentStatuses = ["pending_approval", "confirmed", "declined", "expired", "cancelled", "completed", "no_show"];
|
|
5
|
+
var SchedulingValidationError = class extends Error {
|
|
6
|
+
};
|
|
7
|
+
function invalid(message) {
|
|
8
|
+
throw new SchedulingValidationError(message);
|
|
9
|
+
}
|
|
10
|
+
function object(value, keys) {
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return invalid("Expected an object");
|
|
12
|
+
const record = value;
|
|
13
|
+
if (Object.keys(record).some((key) => !keys.includes(key))) return invalid("Unknown scheduling field");
|
|
14
|
+
return record;
|
|
15
|
+
}
|
|
16
|
+
function text(value, name, limit, empty = false) {
|
|
17
|
+
if (typeof value !== "string" || value.length > limit || !empty && !value.trim() || /[\u0000-\u001f\u007f]/.test(value)) return invalid(`Invalid ${name}`);
|
|
18
|
+
return value.trim();
|
|
19
|
+
}
|
|
20
|
+
function integer(value, name, min, max) {
|
|
21
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) return invalid(`Invalid ${name}`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function choice(value, values, name) {
|
|
25
|
+
if (typeof value !== "string" || !values.includes(value)) return invalid(`Invalid ${name}`);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function boolean(value) {
|
|
29
|
+
if (typeof value !== "boolean") return invalid("Expected a boolean");
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function schedulingId(value) {
|
|
33
|
+
const result = text(value, "ID", 64);
|
|
34
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(result)) return invalid("Invalid ID");
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
function list(value, max, parse) {
|
|
38
|
+
if (!Array.isArray(value) || value.length > max) return invalid("Invalid scheduling list");
|
|
39
|
+
return value.map(parse);
|
|
40
|
+
}
|
|
41
|
+
function ids(value, max, required = false) {
|
|
42
|
+
const result = list(value, max, schedulingId);
|
|
43
|
+
if (required && !result.length || new Set(result).size !== result.length) return invalid("Missing or duplicate IDs");
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
function unique(items, id) {
|
|
47
|
+
if (new Set(items.map(id)).size !== items.length) return invalid("Duplicate scheduling record");
|
|
48
|
+
return items;
|
|
49
|
+
}
|
|
50
|
+
function schedulingInstant(value) {
|
|
51
|
+
const result = text(value, "UTC minute timestamp", 24);
|
|
52
|
+
if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d:00\.000Z$/.test(result) || !Number.isFinite(Date.parse(result)) || new Date(result).toISOString() !== result) return invalid("Use an ISO UTC timestamp at a whole minute");
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
function schedulingRevision(value) {
|
|
56
|
+
return integer(value, "revision", 0, Number.MAX_SAFE_INTEGER - 1);
|
|
57
|
+
}
|
|
58
|
+
function command(value) {
|
|
59
|
+
return { idempotencyKey: schedulingId(value.idempotencyKey), revision: schedulingRevision(value.revision) };
|
|
60
|
+
}
|
|
61
|
+
function emptySchedulingConfig() {
|
|
62
|
+
return { schemaVersion: 1, mode: "disabled", defaultBookingMode: "instant", minNoticeMinutes: 0, horizonDays: 90, slotStepMinutes: 15, approvalTtlMinutes: 1440, locations: [], resources: [], services: [], weeklyHours: [], exceptions: [], grants: [] };
|
|
63
|
+
}
|
|
64
|
+
function parseSchedulingHours(value) {
|
|
65
|
+
const v = object(value, ["locationId", "resourceId", "weekday", "startMinute", "endMinute"]);
|
|
66
|
+
const startMinute = integer(v.startMinute, "start minute", 0, 1439), endMinute = integer(v.endMinute, "end minute", 1, 1440);
|
|
67
|
+
if (endMinute <= startMinute) return invalid("Split overnight hours into adjacent weekdays");
|
|
68
|
+
return { locationId: schedulingId(v.locationId), ...v.resourceId !== void 0 ? { resourceId: schedulingId(v.resourceId) } : {}, weekday: integer(v.weekday, "weekday", 0, 6), startMinute, endMinute };
|
|
69
|
+
}
|
|
70
|
+
function parseSchedulingException(value) {
|
|
71
|
+
const v = object(value, ["id", "locationId", "resourceId", "startsAt", "endsAt", "kind"]);
|
|
72
|
+
const startsAt = schedulingInstant(v.startsAt), endsAt = schedulingInstant(v.endsAt);
|
|
73
|
+
if (endsAt <= startsAt || Date.parse(endsAt) - Date.parse(startsAt) > 31 * 864e5) return invalid("An exception must be between one minute and 31 days");
|
|
74
|
+
return { id: schedulingId(v.id), locationId: schedulingId(v.locationId), ...v.resourceId !== void 0 ? { resourceId: schedulingId(v.resourceId) } : {}, startsAt, endsAt, kind: choice(v.kind, ["open", "closed"], "exception kind") };
|
|
75
|
+
}
|
|
76
|
+
function parseSchedulingConfig(value) {
|
|
77
|
+
const v = object(value, ["schemaVersion", "mode", "defaultBookingMode", "minNoticeMinutes", "horizonDays", "slotStepMinutes", "approvalTtlMinutes", "locations", "resources", "services", "weeklyHours", "exceptions", "grants", "publicBooking", "notifications", "customerManagement"]);
|
|
78
|
+
if (v.schemaVersion !== 1) return invalid("Unsupported scheduling contract version");
|
|
79
|
+
const locations = unique(list(v.locations, 20, (item) => {
|
|
80
|
+
const l = object(item, ["id", "name", "timezone", "active"]);
|
|
81
|
+
const timezone = text(l.timezone, "timezone", 100);
|
|
82
|
+
try {
|
|
83
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
|
|
84
|
+
} catch {
|
|
85
|
+
return invalid("Invalid IANA timezone");
|
|
86
|
+
}
|
|
87
|
+
if (/^[+-]/.test(timezone)) return invalid("Use an IANA timezone, not a fixed offset");
|
|
88
|
+
return { id: schedulingId(l.id), name: text(l.name, "location name", 120), timezone, active: boolean(l.active) };
|
|
89
|
+
}), (l) => l.id);
|
|
90
|
+
const resources = unique(list(v.resources, 50, (item) => {
|
|
91
|
+
const r = object(item, ["id", "name", "kind", "locationIds", "active", "userId"]);
|
|
92
|
+
const kind = choice(r.kind, ["provider", "room", "bay", "equipment"], "resource kind");
|
|
93
|
+
if (r.userId !== void 0 && kind !== "provider") return invalid("Only providers can link to accounts");
|
|
94
|
+
return { id: schedulingId(r.id), name: text(r.name, "resource name", 120), kind, locationIds: ids(r.locationIds, 20, true), active: boolean(r.active), ...r.userId !== void 0 ? { userId: schedulingId(r.userId) } : {} };
|
|
95
|
+
}), (r) => r.id);
|
|
96
|
+
const services = unique(list(v.services, 100, (item) => {
|
|
97
|
+
const s = object(item, ["id", "name", "active", "durationMinutes", "bufferBeforeMinutes", "bufferAfterMinutes", "bookingMode", "locationMode", "locationIds", "providerIds", "resourceIds", "price"]);
|
|
98
|
+
const p = object(s.price, ["amountMinor", "currency", "label"]);
|
|
99
|
+
const currency = text(p.currency, "currency", 3);
|
|
100
|
+
if (!/^[A-Z]{3}$/.test(currency)) return invalid("Use a three-letter uppercase currency");
|
|
101
|
+
return { id: schedulingId(s.id), name: text(s.name, "service name", 160), active: boolean(s.active), durationMinutes: integer(s.durationMinutes, "duration", 1, 720), bufferBeforeMinutes: integer(s.bufferBeforeMinutes, "buffer", 0, 240), bufferAfterMinutes: integer(s.bufferAfterMinutes, "buffer", 0, 240), bookingMode: choice(s.bookingMode, ["inherit", "instant", "approval"], "booking mode"), locationMode: choice(s.locationMode, ["business", "phone", "video", "customer"], "location mode"), locationIds: ids(s.locationIds, 20, true), providerIds: ids(s.providerIds, 50, true), resourceIds: ids(s.resourceIds, 7), price: { amountMinor: p.amountMinor === null ? null : integer(p.amountMinor, "price", 0, 1e8), currency, label: text(p.label, "pricing label", 120, true) } };
|
|
102
|
+
}), (s) => s.id);
|
|
103
|
+
const weeklyHours = list(v.weeklyHours, 1e3, parseSchedulingHours);
|
|
104
|
+
const exceptions = unique(list(v.exceptions, 200, parseSchedulingException), (e) => e.id);
|
|
105
|
+
const grants = unique(list(v.grants, 100, (item) => {
|
|
106
|
+
const g = object(item, ["userId", "scope", "permissions"]);
|
|
107
|
+
const scope = choice(g.scope, ["all", "own"], "access scope");
|
|
108
|
+
const permissions = unique(list(g.permissions, 5, (p) => choice(p, schedulingPermissions, "scheduling permission")), (p) => p);
|
|
109
|
+
if (!permissions.includes("appointments:read")) return invalid("Scheduling grants require appointment read access");
|
|
110
|
+
if (scope === "own" && permissions.includes("scheduling:configure")) return invalid("Own scope cannot configure the installation");
|
|
111
|
+
return { userId: schedulingId(g.userId), scope, permissions };
|
|
112
|
+
}), (g) => g.userId);
|
|
113
|
+
const locationById = new Map(locations.map((l) => [l.id, l]));
|
|
114
|
+
const resourceById = new Map(resources.map((r) => [r.id, r]));
|
|
115
|
+
const locationRefs = (values) => {
|
|
116
|
+
if (values.some((id) => !locationById.has(id))) return invalid("Unknown scheduling location");
|
|
117
|
+
};
|
|
118
|
+
for (const r of resources) locationRefs(r.locationIds);
|
|
119
|
+
for (const s of services) {
|
|
120
|
+
locationRefs(s.locationIds);
|
|
121
|
+
for (const id of s.providerIds) if (resourceById.get(id)?.kind !== "provider") return invalid("Unknown eligible provider");
|
|
122
|
+
for (const id of s.resourceIds) if (!resourceById.has(id) || resourceById.get(id)?.kind === "provider") return invalid("Invalid required resource");
|
|
123
|
+
for (const locationId of s.locationIds) {
|
|
124
|
+
if (!s.providerIds.some((id) => resourceById.get(id).locationIds.includes(locationId))) return invalid("Service location has no eligible provider");
|
|
125
|
+
if (s.resourceIds.some((id) => !resourceById.get(id).locationIds.includes(locationId))) return invalid("Required resource is unavailable at a service location");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const h of [...weeklyHours, ...exceptions]) {
|
|
129
|
+
locationRefs([h.locationId]);
|
|
130
|
+
if (h.resourceId && !resourceById.get(h.resourceId)?.locationIds.includes(h.locationId)) return invalid("Availability resource does not belong to this location");
|
|
131
|
+
}
|
|
132
|
+
for (const g of grants) if (g.scope === "own" && !resources.some((r) => r.kind === "provider" && r.userId === g.userId)) return invalid("Own access needs a linked provider");
|
|
133
|
+
const result = { schemaVersion: 1, mode: choice(v.mode, ["disabled", "test"], "scheduling mode (live booking is not available yet)"), defaultBookingMode: choice(v.defaultBookingMode, ["instant", "approval"], "booking mode"), minNoticeMinutes: integer(v.minNoticeMinutes, "minimum notice", 0, 43200), horizonDays: integer(v.horizonDays, "booking horizon", 1, 365), slotStepMinutes: integer(v.slotStepMinutes, "start increment", 1, 60), approvalTtlMinutes: integer(v.approvalTtlMinutes, "approval expiry", 1, 10080), locations, resources, services, weeklyHours, exceptions, grants };
|
|
134
|
+
if (v.notifications !== void 0) {
|
|
135
|
+
const n = object(v.notifications, ["enabled", "mode", "reminders"]);
|
|
136
|
+
result.notifications = { enabled: boolean(n.enabled), mode: choice(n.mode, ["capture", "send"], "email mode") };
|
|
137
|
+
if (n.reminders !== void 0) {
|
|
138
|
+
const r = object(n.reminders, ["enabled", "offsetsMinutes"]);
|
|
139
|
+
if (!Array.isArray(r.offsetsMinutes) || r.offsetsMinutes.length < 1 || r.offsetsMinutes.length > 3) throw new SchedulingValidationError("Choose one to three reminder times.");
|
|
140
|
+
const offsetsMinutes = r.offsetsMinutes.map((value2) => integer(value2, "reminder lead time", 1, 43200));
|
|
141
|
+
if (new Set(offsetsMinutes).size !== offsetsMinutes.length) throw new SchedulingValidationError("Reminder times must be different.");
|
|
142
|
+
result.notifications.reminders = { enabled: boolean(r.enabled), offsetsMinutes: offsetsMinutes.sort((a, b) => b - a) };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (v.customerManagement !== void 0) {
|
|
146
|
+
const m = object(v.customerManagement, ["enabled", "cancellationCutoffMinutes", "rescheduleCutoffMinutes"]);
|
|
147
|
+
result.customerManagement = { enabled: boolean(m.enabled), cancellationCutoffMinutes: integer(m.cancellationCutoffMinutes, "cancellation notice", 0, 43200), rescheduleCutoffMinutes: integer(m.rescheduleCutoffMinutes, "rescheduling notice", 0, 43200) };
|
|
148
|
+
}
|
|
149
|
+
if (v.publicBooking !== void 0) {
|
|
150
|
+
const p = object(v.publicBooking, ["enabled", "serviceIds", "allowProviderChoice", "policyText"]);
|
|
151
|
+
const serviceIds = ids(p.serviceIds, 128);
|
|
152
|
+
if (serviceIds.some((id) => !services.some((s) => s.id === id))) return invalid("Unknown published service");
|
|
153
|
+
if (typeof p.policyText !== "string" || p.policyText.length > 2e3 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(p.policyText)) return invalid("Invalid booking policy");
|
|
154
|
+
result.publicBooking = { enabled: boolean(p.enabled), serviceIds, allowProviderChoice: boolean(p.allowProviderChoice), policyText: p.policyText.replace(/\r\n?/g, "\n").trim() };
|
|
155
|
+
if (result.publicBooking.enabled && (!serviceIds.length || !result.publicBooking.policyText)) return invalid("Publish at least one service and provide a booking policy");
|
|
156
|
+
}
|
|
157
|
+
if (JSON.stringify(result).length > 1e5) return invalid("Scheduling configuration is too large");
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
function parseCreateAppointment(value) {
|
|
161
|
+
const v = object(value, ["idempotencyKey", "revision", "serviceId", "locationId", "providerId", "startsAt", "customer"]);
|
|
162
|
+
const c = object(v.customer, ["name", "email", "phone"]);
|
|
163
|
+
const email = text(c.email, "customer email", 254).toLowerCase();
|
|
164
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return invalid("Invalid customer email");
|
|
165
|
+
return { ...command(v), serviceId: schedulingId(v.serviceId), locationId: schedulingId(v.locationId), providerId: schedulingId(v.providerId), startsAt: schedulingInstant(v.startsAt), customer: { name: text(c.name, "customer name", 160), email, phone: text(c.phone, "customer phone", 40, true) } };
|
|
166
|
+
}
|
|
167
|
+
function parseRescheduleAppointment(value) {
|
|
168
|
+
const v = object(value, ["idempotencyKey", "revision", "appointmentRevision", "locationId", "providerId", "startsAt"]);
|
|
169
|
+
return { ...command(v), appointmentRevision: schedulingRevision(v.appointmentRevision), locationId: schedulingId(v.locationId), providerId: schedulingId(v.providerId), startsAt: schedulingInstant(v.startsAt) };
|
|
170
|
+
}
|
|
171
|
+
function parseAppointmentStatus(value) {
|
|
172
|
+
const v = object(value, ["idempotencyKey", "revision", "appointmentRevision", "status"]);
|
|
173
|
+
return { ...command(v), appointmentRevision: schedulingRevision(v.appointmentRevision), status: choice(v.status, appointmentStatuses, "appointment status") };
|
|
174
|
+
}
|
|
175
|
+
function parseSchedulingAvailability(value) {
|
|
176
|
+
const v = object(value, ["serviceId", "locationId", "providerId", "appointmentId", "startsAt", "endsAt"]);
|
|
177
|
+
const startsAt = schedulingInstant(v.startsAt), endsAt = schedulingInstant(v.endsAt);
|
|
178
|
+
if (endsAt <= startsAt || Date.parse(endsAt) - Date.parse(startsAt) > 7 * 864e5) return invalid("Search between one minute and seven days");
|
|
179
|
+
return { serviceId: schedulingId(v.serviceId), locationId: schedulingId(v.locationId), ...v.providerId !== void 0 ? { providerId: schedulingId(v.providerId) } : {}, ...v.appointmentId !== void 0 ? { appointmentId: schedulingId(v.appointmentId) } : {}, startsAt, endsAt };
|
|
180
|
+
}
|
|
181
|
+
function parsePublicBooking(value) {
|
|
182
|
+
const v = object(value, ["idempotencyKey", "revision", "serviceId", "locationId", "providerId", "startsAt", "customer", "acceptedPolicy", "challengeToken", "company"]);
|
|
183
|
+
if (typeof v.idempotencyKey !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v.idempotencyKey)) return invalid("Use a unique booking attempt identifier");
|
|
184
|
+
if (v.acceptedPolicy !== true) return invalid("Accept the booking policy before continuing");
|
|
185
|
+
const request = parseCreateAppointment(Object.fromEntries(["idempotencyKey", "revision", "serviceId", "locationId", "providerId", "startsAt", "customer"].map((key) => [key, v[key]])));
|
|
186
|
+
return { ...request, acceptedPolicy: true, challengeToken: text(v.challengeToken, "security check", 2048, true), company: text(v.company, "company", 160, true) };
|
|
187
|
+
}
|
|
188
|
+
function parsePublicAvailability(value) {
|
|
189
|
+
const v = object(value, ["serviceId", "locationId", "providerId", "startsAt", "endsAt"]);
|
|
190
|
+
return parseSchedulingAvailability(v);
|
|
191
|
+
}
|
|
192
|
+
function parseCustomerCommand(value, reschedule = false) {
|
|
193
|
+
const v = object(value, reschedule ? ["idempotencyKey", "revision", "appointmentRevision", "startsAt"] : ["idempotencyKey", "revision", "appointmentRevision"]);
|
|
194
|
+
if (typeof v.idempotencyKey !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v.idempotencyKey)) return invalid("Use a unique appointment command identifier");
|
|
195
|
+
return { ...command(v), appointmentRevision: schedulingRevision(v.appointmentRevision), ...reschedule ? { startsAt: schedulingInstant(v.startsAt) } : {} };
|
|
196
|
+
}
|
|
197
|
+
export {
|
|
198
|
+
SchedulingValidationError,
|
|
199
|
+
appointmentStatuses,
|
|
200
|
+
emptySchedulingConfig,
|
|
201
|
+
parseAppointmentStatus,
|
|
202
|
+
parseCreateAppointment,
|
|
203
|
+
parseCustomerCommand,
|
|
204
|
+
parsePublicAvailability,
|
|
205
|
+
parsePublicBooking,
|
|
206
|
+
parseRescheduleAppointment,
|
|
207
|
+
parseSchedulingAvailability,
|
|
208
|
+
parseSchedulingConfig,
|
|
209
|
+
parseSchedulingException,
|
|
210
|
+
parseSchedulingHours,
|
|
211
|
+
schedulingContractVersion,
|
|
212
|
+
schedulingId,
|
|
213
|
+
schedulingInstant,
|
|
214
|
+
schedulingPermissions,
|
|
215
|
+
schedulingRevision
|
|
216
|
+
};
|