@tryvitris/website-admin-contracts 0.1.0-alpha.1 → 0.1.0-alpha.2
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/dist/index.d.ts +104 -1
- package/dist/index.js +58 -7
- package/dist/scheduling.d.ts +410 -0
- package/dist/scheduling.js +216 -0
- package/package.json +36 -32
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
export * from './scheduling.js';
|
|
1
2
|
export declare const contractVersion = 1;
|
|
2
3
|
export declare const coreSections: readonly ["overview", "leads", "analytics", "settings", "access", "activity"];
|
|
3
|
-
export declare const permissions: readonly ["leads:read", "leads:write", "analytics:read", "settings:write", "access:read", "activity:read", "tasks:read", "tasks:write"];
|
|
4
|
+
export declare const permissions: readonly ["leads:read", "leads:write", "analytics:read", "settings:write", "access:read", "activity:read", "tasks:read", "tasks:write", "appointments:read", "appointments:write", "availability:write", "scheduling:configure", "calendar-connections:manage"];
|
|
4
5
|
export type Permission = typeof permissions[number];
|
|
5
6
|
export type Role = 'owner' | 'administrator' | 'editor' | 'analyst';
|
|
6
7
|
export declare const roles: Record<Role, readonly Permission[]>;
|
|
@@ -24,7 +25,12 @@ export interface AdminManifest {
|
|
|
24
25
|
origin: string;
|
|
25
26
|
tenantId: string;
|
|
26
27
|
};
|
|
28
|
+
theme?: WebsiteTheme;
|
|
27
29
|
}
|
|
30
|
+
export declare const websiteColorKeys: readonly ["colorBg", "colorText", "colorMuted", "colorAccent", "colorAccentText", "colorSurface", "colorSurface2", "colorBorder", "colorFocus"];
|
|
31
|
+
export type WebsiteTheme = Partial<Record<typeof websiteColorKeys[number], string>>;
|
|
32
|
+
export declare function isThemeColor(value: unknown): value is string;
|
|
33
|
+
export declare function isWebsiteTheme(value: unknown): value is WebsiteTheme;
|
|
28
34
|
export interface ModuleDefinition {
|
|
29
35
|
id: string;
|
|
30
36
|
version: number;
|
|
@@ -32,6 +38,103 @@ export interface ModuleDefinition {
|
|
|
32
38
|
permission: Permission;
|
|
33
39
|
description: string;
|
|
34
40
|
}
|
|
41
|
+
export interface User {
|
|
42
|
+
id: string;
|
|
43
|
+
email: string;
|
|
44
|
+
role: Role;
|
|
45
|
+
}
|
|
46
|
+
export interface BootstrapResponse {
|
|
47
|
+
manifest: AdminManifest;
|
|
48
|
+
user: User | null;
|
|
49
|
+
passwordLogin: boolean;
|
|
50
|
+
localDemo: boolean;
|
|
51
|
+
connected: boolean;
|
|
52
|
+
modules: ModuleDefinition[];
|
|
53
|
+
capabilities: string[];
|
|
54
|
+
theme?: WebsiteTheme | null;
|
|
55
|
+
themeStatus?: 'website' | 'installation' | 'unavailable' | 'default';
|
|
56
|
+
}
|
|
57
|
+
export type DataSource = 'synthetic' | 'connected' | 'not-connected';
|
|
58
|
+
export interface LeadRow {
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
email: string;
|
|
62
|
+
message: string;
|
|
63
|
+
status: string;
|
|
64
|
+
created_at: string;
|
|
65
|
+
source?: string | null;
|
|
66
|
+
phone?: string | null;
|
|
67
|
+
fields_json?: string | null;
|
|
68
|
+
notification_policy?: string | null;
|
|
69
|
+
notification_status?: string | null;
|
|
70
|
+
notification_error?: string | null;
|
|
71
|
+
}
|
|
72
|
+
export declare const analyticsMetricKeys: readonly ["sessions", "bounces", "engaged", "duration_ms", "leave_count", "scroll_25", "scroll_50", "scroll_75", "scroll_100", "converts_form", "converts_tel", "converts_mailto", "converts_maps", "engage_nav", "engage_btn", "engage_img", "engage_carousel"];
|
|
73
|
+
export declare const analyticsBreakdownKeys: readonly ["top_paths_json", "top_refs_json", "channels_json", "devices_json", "hours_json", "top_engage_json"];
|
|
74
|
+
export type AnalyticsMetric = 'views' | 'visitors' | 'enquiries' | typeof analyticsMetricKeys[number];
|
|
75
|
+
export type AnalyticsBreakdown = typeof analyticsBreakdownKeys[number];
|
|
76
|
+
export interface AnalyticsItem {
|
|
77
|
+
key: string;
|
|
78
|
+
n: number;
|
|
79
|
+
}
|
|
80
|
+
export type AnalyticsRow = {
|
|
81
|
+
day: string;
|
|
82
|
+
views: number;
|
|
83
|
+
visitors: number;
|
|
84
|
+
enquiries: number;
|
|
85
|
+
} & Partial<Record<typeof analyticsMetricKeys[number], number>> & Partial<Record<AnalyticsBreakdown, string>>;
|
|
86
|
+
export interface LeadsResponse {
|
|
87
|
+
rows: LeadRow[];
|
|
88
|
+
source: DataSource;
|
|
89
|
+
writable?: boolean;
|
|
90
|
+
message?: string;
|
|
91
|
+
mode?: string;
|
|
92
|
+
}
|
|
93
|
+
export interface AnalyticsResponse {
|
|
94
|
+
rows: AnalyticsRow[];
|
|
95
|
+
previousRows?: AnalyticsRow[];
|
|
96
|
+
source: DataSource;
|
|
97
|
+
timezone: string;
|
|
98
|
+
days: number;
|
|
99
|
+
generatedAt?: string;
|
|
100
|
+
}
|
|
101
|
+
export interface SettingsResponse {
|
|
102
|
+
notification_email: string;
|
|
103
|
+
revision: number;
|
|
104
|
+
}
|
|
105
|
+
export interface AccessRow extends User {
|
|
106
|
+
active: number;
|
|
107
|
+
}
|
|
108
|
+
export interface ActivityRow {
|
|
109
|
+
id: string;
|
|
110
|
+
actor: string;
|
|
111
|
+
action: string;
|
|
112
|
+
created_at: string;
|
|
113
|
+
}
|
|
114
|
+
export interface TaskRow {
|
|
115
|
+
id: string;
|
|
116
|
+
title: string;
|
|
117
|
+
done: number;
|
|
118
|
+
}
|
|
119
|
+
export interface RowsResponse<T> {
|
|
120
|
+
rows: T[];
|
|
121
|
+
message?: string;
|
|
122
|
+
}
|
|
123
|
+
export interface ModuleRow {
|
|
124
|
+
id?: string;
|
|
125
|
+
title?: string;
|
|
126
|
+
name?: string;
|
|
127
|
+
}
|
|
128
|
+
export interface MutationResponse {
|
|
129
|
+
ok: true;
|
|
130
|
+
}
|
|
131
|
+
export interface AuthRequestResponse {
|
|
132
|
+
ok: true;
|
|
133
|
+
message: string;
|
|
134
|
+
}
|
|
135
|
+
export declare function isObject(value: unknown): value is Record<string, unknown>;
|
|
136
|
+
export declare function isLeadRow(value: unknown): value is LeadRow;
|
|
137
|
+
export declare function isAnalyticsRow(value: unknown): value is AnalyticsRow;
|
|
35
138
|
export declare const workQueueModule: ModuleDefinition;
|
|
36
139
|
export declare function isLoopback(origin: string): boolean;
|
|
37
140
|
export declare function parseManifest(value: unknown): AdminManifest;
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { schedulingPermissions } from "./scheduling.js";
|
|
2
|
+
export * from "./scheduling.js";
|
|
3
|
+
const contractVersion = 1;
|
|
4
|
+
const coreSections = ["overview", "leads", "analytics", "settings", "access", "activity"];
|
|
5
|
+
const permissions = ["leads:read", "leads:write", "analytics:read", "settings:write", "access:read", "activity:read", "tasks:read", "tasks:write", ...schedulingPermissions];
|
|
6
|
+
const roles = {
|
|
6
7
|
owner: permissions,
|
|
7
8
|
administrator: permissions,
|
|
8
9
|
editor: ["leads:read", "leads:write", "activity:read", "tasks:read", "tasks:write"],
|
|
@@ -11,7 +12,47 @@ var roles = {
|
|
|
11
12
|
function can(role, permission) {
|
|
12
13
|
return roles[role]?.includes(permission) ?? false;
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
const websiteColorKeys = ["colorBg", "colorText", "colorMuted", "colorAccent", "colorAccentText", "colorSurface", "colorSurface2", "colorBorder", "colorFocus"];
|
|
16
|
+
function isThemeColor(value) {
|
|
17
|
+
if (typeof value !== "string" || value.length > 100) return false;
|
|
18
|
+
if (/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value)) return true;
|
|
19
|
+
const match = value.match(/^(rgb|rgba|hsl|hsla)\(([^()]+)\)$/i);
|
|
20
|
+
if (!match) return false;
|
|
21
|
+
const parts = match[2].trim().split(/[\s,/]+/);
|
|
22
|
+
if (parts.length < 3 || parts.length > 4) return false;
|
|
23
|
+
const numeric = (part, max) => /^\d+(?:\.\d+)?%?$/.test(part) && parseFloat(part) >= 0 && parseFloat(part) <= (part.endsWith("%") ? 100 : max);
|
|
24
|
+
const components = match[1].toLowerCase().startsWith("rgb") ? parts.slice(0, 3).every((p) => numeric(p, 255)) : /^[+-]?\d+(?:\.\d+)?(?:deg)?$/.test(parts[0]) && parts.slice(1, 3).every((p) => p.endsWith("%") && numeric(p, 100));
|
|
25
|
+
return components && (parts.length === 3 || numeric(parts[3], 1));
|
|
26
|
+
}
|
|
27
|
+
function isWebsiteTheme(value) {
|
|
28
|
+
return isObject(value) && Object.keys(value).length > 0 && Object.entries(value).every(([key, color]) => websiteColorKeys.includes(key) && isThemeColor(color));
|
|
29
|
+
}
|
|
30
|
+
const analyticsMetricKeys = ["sessions", "bounces", "engaged", "duration_ms", "leave_count", "scroll_25", "scroll_50", "scroll_75", "scroll_100", "converts_form", "converts_tel", "converts_mailto", "converts_maps", "engage_nav", "engage_btn", "engage_img", "engage_carousel"];
|
|
31
|
+
const analyticsBreakdownKeys = ["top_paths_json", "top_refs_json", "channels_json", "devices_json", "hours_json", "top_engage_json"];
|
|
32
|
+
function isObject(value) {
|
|
33
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
function isLeadRow(value) {
|
|
36
|
+
if (!isObject(value)) return false;
|
|
37
|
+
return ["id", "name", "email", "message", "status", "created_at"].every((key) => typeof value[key] === "string") && ["source", "phone", "fields_json", "notification_policy", "notification_status", "notification_error"].every((key) => value[key] === void 0 || value[key] === null || typeof value[key] === "string");
|
|
38
|
+
}
|
|
39
|
+
function isAnalyticsRow(value) {
|
|
40
|
+
if (!isObject(value) || typeof value.day !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value.day)) return false;
|
|
41
|
+
const count = (n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
42
|
+
if (!["views", "visitors", "enquiries"].every((key) => count(value[key])) || !analyticsMetricKeys.every((key) => value[key] === void 0 || count(value[key]))) return false;
|
|
43
|
+
return analyticsBreakdownKeys.every((key) => {
|
|
44
|
+
const raw = value[key];
|
|
45
|
+
if (raw === void 0) return true;
|
|
46
|
+
if (typeof raw !== "string" || raw.length > 1e5) return false;
|
|
47
|
+
try {
|
|
48
|
+
const items = JSON.parse(raw);
|
|
49
|
+
return Array.isArray(items) && items.length <= 200 && items.every((item) => isObject(item) && typeof item.key === "string" && item.key.length <= 300 && count(item.n) && (key !== "hours_json" || /^(?:[0-9]|1[0-9]|2[0-3])$/.test(item.key)));
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const workQueueModule = {
|
|
15
56
|
id: "work-queue",
|
|
16
57
|
version: 1,
|
|
17
58
|
label: "Work queue",
|
|
@@ -49,6 +90,7 @@ function parseManifest(value) {
|
|
|
49
90
|
if (!Array.isArray(v.modules) || v.modules.length > 20) throw new Error("Invalid modules");
|
|
50
91
|
const modules = v.modules.map((x) => id(x, "module ID"));
|
|
51
92
|
if (new Set(modules).size !== modules.length) throw new Error("Duplicate module");
|
|
93
|
+
if (v.theme !== void 0 && !isWebsiteTheme(v.theme)) throw new Error("Invalid website theme colors");
|
|
52
94
|
let backend;
|
|
53
95
|
if (v.backend !== void 0) {
|
|
54
96
|
const b = object(v.backend);
|
|
@@ -65,7 +107,8 @@ function parseManifest(value) {
|
|
|
65
107
|
site: { id: id(site.id, "site ID"), name: text(site.name, "site name"), origin: origin(site.origin, "website origin") },
|
|
66
108
|
adminOrigin: origin(v.adminOrigin, "admin origin"),
|
|
67
109
|
modules,
|
|
68
|
-
...backend ? { backend } : {}
|
|
110
|
+
...backend ? { backend } : {},
|
|
111
|
+
...v.theme ? { theme: v.theme } : {}
|
|
69
112
|
};
|
|
70
113
|
}
|
|
71
114
|
function validateModules(manifest, modules) {
|
|
@@ -79,13 +122,21 @@ function validateModules(manifest, modules) {
|
|
|
79
122
|
for (const enabled of manifest.modules) if (!ids.has(enabled)) throw new Error(`Module ${enabled} is not installed`);
|
|
80
123
|
}
|
|
81
124
|
export {
|
|
125
|
+
analyticsBreakdownKeys,
|
|
126
|
+
analyticsMetricKeys,
|
|
82
127
|
can,
|
|
83
128
|
contractVersion,
|
|
84
129
|
coreSections,
|
|
130
|
+
isAnalyticsRow,
|
|
131
|
+
isLeadRow,
|
|
85
132
|
isLoopback,
|
|
133
|
+
isObject,
|
|
134
|
+
isThemeColor,
|
|
135
|
+
isWebsiteTheme,
|
|
86
136
|
parseManifest,
|
|
87
137
|
permissions,
|
|
88
138
|
roles,
|
|
89
139
|
validateModules,
|
|
140
|
+
websiteColorKeys,
|
|
90
141
|
workQueueModule
|
|
91
142
|
};
|
|
@@ -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
|
+
};
|
package/package.json
CHANGED
|
@@ -1,32 +1,36 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@tryvitris/website-admin-contracts",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"description": "Validated installation and module contracts for Cloudflare business admin apps",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": {
|
|
9
|
-
"types": "./dist/index.d.ts",
|
|
10
|
-
"default": "./dist/index.js"
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
}
|
|
32
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@tryvitris/website-admin-contracts",
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Validated installation and module contracts for Cloudflare business admin apps",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./scheduling": {
|
|
13
|
+
"types": "./dist/scheduling.d.ts",
|
|
14
|
+
"default": "./dist/scheduling.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"tag": "alpha"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=24"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/jackperrotta/website-admin-harness.git",
|
|
30
|
+
"directory": "packages/contracts"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/jackperrotta/website-admin-harness#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/jackperrotta/website-admin-harness/issues"
|
|
35
|
+
}
|
|
36
|
+
}
|