@cosmoops/connect-sdk 0.17.0 → 0.18.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.
@@ -0,0 +1,120 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button, Input, Label, Modal, TextField } from "@heroui/react";
4
+ import { CalendarX2, Clock } from "lucide-react";
5
+ import { useMemo, useState } from "react";
6
+ import { useAvailableSlots, useBookingActions } from "../use-scheduling.js";
7
+ const SEARCH_DAYS = 14;
8
+ /**
9
+ * Picking a time, and then who it is for.
10
+ *
11
+ * The slot list is read fresh every time this opens and is **never cached
12
+ * across an open**: it is a snapshot of contention, and offering a time
13
+ * somebody took ninety seconds ago is how two people end up in the same slot.
14
+ * The server re-checks inside its transaction regardless, so a stale list
15
+ * costs a refusal rather than a double booking - this only makes the refusal
16
+ * rare enough to be surprising when it happens.
17
+ */
18
+ export function ConnectBookingDialog({ isOpen, onOpenChange, eventTypes, initialStartAt, timeZone, onBooked, }) {
19
+ const [eventTypeId, setEventTypeId] = useState(eventTypes[0]?.id ?? "");
20
+ const [chosen, setChosen] = useState(null);
21
+ const [name, setName] = useState("");
22
+ const [phone, setPhone] = useState("");
23
+ const window = useMemo(() => ({
24
+ // From the start of the day that was clicked, so the times on offer are
25
+ // the ones near where the person was looking.
26
+ from: new Date(initialStartAt.getTime() - 12 * 60 * 60_000),
27
+ to: new Date(initialStartAt.getTime() + SEARCH_DAYS * 24 * 60 * 60_000),
28
+ }), [initialStartAt]);
29
+ const { slots, isLoading } = useAvailableSlots(eventTypeId ? { eventTypeId, from: window.from, to: window.to } : null);
30
+ const { createBooking, isPending, error, clearError } = useBookingActions();
31
+ const canSubmit = Boolean(chosen && name.trim() && phone.trim()) && !isPending;
32
+ return (_jsx(Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.Header, { children: "New appointment" }), _jsxs(Modal.Body, { className: "flex flex-col gap-4", children: [eventTypes.length > 1 && (_jsx("div", { className: "flex flex-wrap gap-2", children: eventTypes.map((eventType) => (_jsx(Button, { size: "sm", variant: eventType.id === eventTypeId ? "primary" : "secondary", onPress: () => {
33
+ setEventTypeId(eventType.id);
34
+ // The old pick belongs to the old type's grid and is
35
+ // almost never a legal slot on the new one.
36
+ setChosen(null);
37
+ }, children: eventType.name }, eventType.id))) })), _jsx(SlotPicker, { slots: slots, isLoading: isLoading, chosen: chosen, onChoose: setChosen, timeZone: timeZone }), _jsxs(TextField, { isRequired: true, variant: "secondary", children: [_jsx(Label, { children: "Name" }), _jsx(Input, { value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending })] }), _jsxs(TextField, { isRequired: true, variant: "secondary", children: [_jsx(Label, { children: "Phone" }), _jsx(Input, { type: "tel", value: phone, onChange: (e) => setPhone(e.currentTarget.value), disabled: isPending })] }), error && _jsx("p", { className: "text-sm text-danger", children: error }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", onPress: () => onOpenChange(false), isDisabled: isPending, children: "Cancel" }), _jsx(Button, { variant: "primary", isDisabled: !canSubmit, onPress: async () => {
38
+ clearError();
39
+ const booked = await createBooking({
40
+ eventTypeId,
41
+ startAt: chosen,
42
+ attendee: { name: name.trim(), phone: phone.trim(), email: null },
43
+ });
44
+ if (booked)
45
+ onBooked(booked);
46
+ }, children: isPending ? "Booking…" : "Book" })] })] })] }) }) }) }));
47
+ }
48
+ /**
49
+ * One appointment, and the two things anyone opens it to do.
50
+ *
51
+ * Rescheduling re-reads the slots with `excludeBookingId` set to this booking.
52
+ * Without that the appointment blocks its own replacement, and moving it by
53
+ * half an hour - the only move most people ever make - offers nothing.
54
+ */
55
+ export function ConnectBookingDetail({ booking, eventType, timeZone, onOpenChange, onChanged, }) {
56
+ const [isMoving, setMoving] = useState(false);
57
+ const [chosen, setChosen] = useState(null);
58
+ const { rescheduleBooking, cancelBooking, isPending, error, clearError } = useBookingActions();
59
+ const start = new Date(booking.startAt);
60
+ const window = useMemo(() => ({
61
+ from: new Date(start.getTime() - 12 * 60 * 60_000),
62
+ to: new Date(start.getTime() + SEARCH_DAYS * 24 * 60 * 60_000),
63
+ }), [start]);
64
+ const { slots, isLoading } = useAvailableSlots(isMoving ? { eventTypeId: booking.eventTypeId, ...window, excludeBookingId: booking.id } : null);
65
+ return (_jsx(Modal, { isOpen: true, onOpenChange: onOpenChange, children: _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.Header, { children: booking.attendee.name }), _jsxs(Modal.Body, { className: "flex flex-col gap-4", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsxs("p", { className: "flex items-center gap-2 text-sm", children: [_jsx(Clock, { className: "size-4 text-muted", "aria-hidden": true }), longLabel(start, timeZone)] }), eventType && _jsx("p", { className: "text-sm text-muted", children: eventType.name }), _jsx("p", { className: "text-sm text-muted", children: booking.attendee.phone }), booking.notes && _jsx("p", { className: "pt-1 text-sm", children: booking.notes })] }), isMoving && (_jsx(SlotPicker, { slots: slots, isLoading: isLoading, chosen: chosen, onChoose: setChosen, timeZone: timeZone })), error && _jsx("p", { className: "text-sm text-danger", children: error }), _jsx("div", { className: "flex flex-wrap justify-end gap-2", children: isMoving ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onPress: () => setMoving(false), isDisabled: isPending, children: "Back" }), _jsx(Button, { variant: "primary", isDisabled: !chosen || isPending, onPress: async () => {
66
+ clearError();
67
+ const moved = await rescheduleBooking({
68
+ bookingId: booking.id,
69
+ startAt: chosen,
70
+ });
71
+ if (moved)
72
+ onChanged();
73
+ }, children: isPending ? "Moving…" : "Move appointment" })] })) : (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "danger", isDisabled: isPending, onPress: async () => {
74
+ clearError();
75
+ const cancelled = await cancelBooking({ bookingId: booking.id });
76
+ if (cancelled)
77
+ onChanged();
78
+ }, children: isPending ? "Cancelling…" : "Cancel appointment" }), _jsx(Button, { variant: "secondary", onPress: () => setMoving(true), children: "Reschedule" })] })) })] })] }) }) }) }));
79
+ }
80
+ function SlotPicker({ slots, isLoading, chosen, onChoose, timeZone, }) {
81
+ const byDay = useMemo(() => {
82
+ const groups = new Map();
83
+ const dayFormat = new Intl.DateTimeFormat("en-IN", {
84
+ timeZone,
85
+ weekday: "short",
86
+ day: "numeric",
87
+ month: "short",
88
+ });
89
+ for (const slot of slots) {
90
+ const label = dayFormat.format(new Date(slot.startAt));
91
+ groups.set(label, [...(groups.get(label) ?? []), slot]);
92
+ }
93
+ return [...groups.entries()];
94
+ }, [slots, timeZone]);
95
+ if (isLoading)
96
+ return _jsx("p", { className: "text-sm text-muted", children: "Finding free times\u2026" });
97
+ if (byDay.length === 0) {
98
+ return (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-dashed border-separator p-4", children: [_jsx(CalendarX2, { className: "size-4 text-muted", "aria-hidden": true }), _jsx("p", { className: "text-sm text-muted", children: "Nothing free in the next two weeks." })] }));
99
+ }
100
+ return (_jsx("div", { className: "flex max-h-56 flex-col gap-3 overflow-y-auto", children: byDay.map(([day, daySlots]) => (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("p", { className: "text-xs font-medium text-muted", children: day }), _jsx("div", { className: "grid grid-cols-2 gap-1.5 sm:grid-cols-4", children: daySlots.map((slot) => (_jsx(Button, { size: "sm", className: "w-full", variant: slot.startAt === chosen ? "primary" : "secondary", onPress: () => onChoose(slot.startAt), children: shortTime(new Date(slot.startAt), timeZone) }, slot.startAt))) })] }, day))) }));
101
+ }
102
+ function shortTime(instant, timeZone) {
103
+ return new Intl.DateTimeFormat("en-IN", {
104
+ timeZone,
105
+ hour: "numeric",
106
+ minute: "2-digit",
107
+ hour12: true,
108
+ }).format(instant);
109
+ }
110
+ function longLabel(instant, timeZone) {
111
+ return new Intl.DateTimeFormat("en-IN", {
112
+ timeZone,
113
+ weekday: "long",
114
+ day: "numeric",
115
+ month: "long",
116
+ hour: "numeric",
117
+ minute: "2-digit",
118
+ hour12: true,
119
+ }).format(instant);
120
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * An instant, as the local day and minute it lands on in `timeZone`.
3
+ *
4
+ * Built from `formatToParts` rather than by adding a fixed offset. An offset
5
+ * is only correct until a DST boundary, and "add 5.5 hours" is wrong the
6
+ * moment the same code is pointed at a zone that observes one - which is
7
+ * exactly the kind of bug that appears twice a year and never in a test.
8
+ */
9
+ export function zonedInstant(instant, timeZone) {
10
+ const parts = partsFormatter(timeZone).formatToParts(instant);
11
+ const read = (type) => parts.find((part) => part.type === type)?.value ?? "00";
12
+ // `hourCycle: "h23"` so midnight is 00 rather than 24 - `hour12: false`
13
+ // alone still yields 24 in some engines, and a 24 would push midnight onto
14
+ // the wrong day.
15
+ return {
16
+ dateKey: `${read("year")}-${read("month")}-${read("day")}`,
17
+ minutes: Number(read("hour")) * 60 + Number(read("minute")),
18
+ };
19
+ }
20
+ const formatterCache = new Map();
21
+ function partsFormatter(timeZone) {
22
+ let formatter = formatterCache.get(timeZone);
23
+ if (!formatter) {
24
+ // Cached because a grid calls this once per booking per render, and
25
+ // constructing an `Intl.DateTimeFormat` is the expensive part by a wide
26
+ // margin.
27
+ formatter = new Intl.DateTimeFormat("en-CA", {
28
+ timeZone,
29
+ year: "numeric",
30
+ month: "2-digit",
31
+ day: "2-digit",
32
+ hour: "2-digit",
33
+ minute: "2-digit",
34
+ hourCycle: "h23",
35
+ });
36
+ formatterCache.set(timeZone, formatter);
37
+ }
38
+ return formatter;
39
+ }
40
+ /**
41
+ * `count` consecutive days starting at the local day `anchor` falls on.
42
+ *
43
+ * Days are stepped by adding 24 hours and re-reading the zone rather than by
44
+ * incrementing a date field, so a day that is 23 or 25 hours long still
45
+ * produces the right sequence of `dateKey`s - the midday anchor is what keeps
46
+ * a DST shift from landing the step on the previous or next day.
47
+ */
48
+ export function dayColumns(anchor, timeZone, count, now) {
49
+ const todayKey = zonedInstant(now, timeZone).dateKey;
50
+ const columns = [];
51
+ for (let index = 0; index < count; index += 1) {
52
+ const probe = new Date(anchor.getTime() + index * 24 * 60 * 60_000);
53
+ const { dateKey } = zonedInstant(probe, timeZone);
54
+ const startsAt = dayStartInstant(dateKey, timeZone);
55
+ columns.push({
56
+ dateKey,
57
+ label: new Intl.DateTimeFormat("en-IN", {
58
+ timeZone,
59
+ weekday: "short",
60
+ day: "numeric",
61
+ }).format(startsAt),
62
+ monthLabel: new Intl.DateTimeFormat("en-IN", { timeZone, month: "short" }).format(startsAt),
63
+ startsAt,
64
+ isToday: dateKey === todayKey,
65
+ });
66
+ }
67
+ return columns;
68
+ }
69
+ /**
70
+ * Local midnight of `dateKey` in `timeZone`, as an instant.
71
+ *
72
+ * Solved by probing rather than by arithmetic: guess UTC midnight, read what
73
+ * local time that actually is, and subtract the difference. One correction is
74
+ * always enough because a zone's offset cannot change within the few hours the
75
+ * correction spans - and unlike a fixed-offset formula it is right across DST
76
+ * and for the half-hour zones (`Asia/Kolkata`, `Australia/Adelaide`) that a
77
+ * naive implementation quietly breaks on.
78
+ */
79
+ export function dayStartInstant(dateKey, timeZone) {
80
+ const utcGuess = new Date(`${dateKey}T00:00:00.000Z`);
81
+ const { dateKey: landedOn, minutes } = zonedInstant(utcGuess, timeZone);
82
+ // Whether the guess landed on the day before, the day itself, or the day
83
+ // after decides which way to correct.
84
+ const dayDelta = compareDateKeys(landedOn, dateKey);
85
+ return new Date(utcGuess.getTime() - (minutes + dayDelta * 1440) * 60_000);
86
+ }
87
+ function compareDateKeys(a, b) {
88
+ if (a === b)
89
+ return 0;
90
+ return a < b ? -1 : 1;
91
+ }
92
+ /**
93
+ * A fifteen-minute appointment on a twelve-hour grid is two percent tall -
94
+ * unreadable and unclickable. Short appointments are drawn at a floor and
95
+ * allowed to overhang, which is what every calendar does and what makes a
96
+ * back-to-back pair still look like two things.
97
+ */
98
+ const MIN_VISIBLE_FRACTION = 0.02;
99
+ /**
100
+ * Places bookings on the grid, side by side where they collide.
101
+ *
102
+ * Overlap handling is the part worth stating. Two appointments at the same
103
+ * time are not an error - a workspace with two hosts has them constantly - and
104
+ * stacking them would hide one entirely. So overlapping bookings are grouped
105
+ * into *clusters* and each cluster is split into equal columns. The cluster is
106
+ * built transitively: A overlapping B and B overlapping C puts all three in one
107
+ * cluster even when A and C do not touch, because anything else lets two of
108
+ * them land on the same column.
109
+ */
110
+ export function layoutBookings(bookings, timeZone, window) {
111
+ const span = window.endMinute - window.startMinute;
112
+ if (span <= 0)
113
+ return [];
114
+ const byDay = new Map();
115
+ for (const booking of bookings) {
116
+ const start = zonedInstant(new Date(booking.startAt), timeZone);
117
+ const end = zonedInstant(new Date(booking.endAt), timeZone);
118
+ // An appointment that crosses local midnight is clamped to the end of the
119
+ // day it started on. Splitting it across two columns would be more correct
120
+ // and is not worth the complexity for a booking grid, where the longest
121
+ // event type is capped at eight hours server-side.
122
+ const endMinutes = end.dateKey === start.dateKey ? end.minutes : 1440;
123
+ const entries = byDay.get(start.dateKey) ?? [];
124
+ entries.push({ booking, start: start.minutes, end: Math.max(endMinutes, start.minutes + 1) });
125
+ byDay.set(start.dateKey, entries);
126
+ }
127
+ const positioned = [];
128
+ for (const [dateKey, entries] of byDay) {
129
+ entries.sort((a, b) => a.start - b.start || a.end - b.end);
130
+ for (const cluster of clusterOverlapping(entries)) {
131
+ cluster.forEach((entry, column) => {
132
+ const clampedStart = Math.max(entry.start, window.startMinute);
133
+ const clampedEnd = Math.min(entry.end, window.endMinute);
134
+ positioned.push({
135
+ booking: entry.booking,
136
+ dateKey,
137
+ top: (clampedStart - window.startMinute) / span,
138
+ height: Math.max((clampedEnd - clampedStart) / span, MIN_VISIBLE_FRACTION),
139
+ column,
140
+ columns: cluster.length,
141
+ clipped: entry.start < window.startMinute || entry.end > window.endMinute,
142
+ });
143
+ });
144
+ }
145
+ }
146
+ return positioned;
147
+ }
148
+ /**
149
+ * Transitive overlap grouping - see `layoutBookings`.
150
+ *
151
+ * Entries arrive sorted by start. A cluster stays open while the next entry
152
+ * begins before the *furthest* end seen so far, not before the previous
153
+ * entry's end: a long appointment that swallows several short ones has to keep
154
+ * the whole run together.
155
+ */
156
+ function clusterOverlapping(entries) {
157
+ const clusters = [];
158
+ let current = [];
159
+ let clusterEnd = -Infinity;
160
+ for (const entry of entries) {
161
+ if (current.length > 0 && entry.start >= clusterEnd) {
162
+ clusters.push(current);
163
+ current = [];
164
+ clusterEnd = -Infinity;
165
+ }
166
+ current.push(entry);
167
+ clusterEnd = Math.max(clusterEnd, entry.end);
168
+ }
169
+ if (current.length > 0)
170
+ clusters.push(current);
171
+ return clusters;
172
+ }
173
+ /**
174
+ * The hour labels down the left edge, inclusive of both ends.
175
+ *
176
+ * Takes no timezone, deliberately: these label *wall-clock hours on the grid*,
177
+ * not instants. The grid is already drawn in the business's zone, so "10 am"
178
+ * means the business's ten - running the label through a zone conversion would
179
+ * shift it away from the row it sits on.
180
+ */
181
+ export function hourMarks(window) {
182
+ const marks = [];
183
+ const formatter = new Intl.DateTimeFormat("en-IN", {
184
+ timeZone: "UTC",
185
+ hour: "numeric",
186
+ hour12: true,
187
+ });
188
+ const firstHour = Math.ceil(window.startMinute / 60);
189
+ const lastHour = Math.floor(window.endMinute / 60);
190
+ for (let hour = firstHour; hour <= lastHour; hour += 1) {
191
+ marks.push({
192
+ minute: hour * 60,
193
+ // A synthetic UTC instant formatted in UTC - the round trip exists only
194
+ // to borrow `Intl`'s "10 am" / "12 pm" spelling, not to convert anything.
195
+ label: formatter.format(new Date(Date.UTC(2000, 0, 1, hour % 24))),
196
+ });
197
+ }
198
+ return marks;
199
+ }
@@ -113,3 +113,7 @@ export { formatPrice, monthlyOf, resolveAction, annualSaving, discountLabel, ACT
113
113
  groupPlansByTier, resolvePlanGroup, } from "./components/plan-pricing.js";
114
114
  export { ConnectWorkspaceSwitcher } from "./components/workspace-switcher.js";
115
115
  export { useConnectWorkspaces, useConnectWorkspaceMembers } from "./provider.js";
116
+ export { ConnectBookingCalendar } from "./components/booking-calendar.js";
117
+ export { ConnectBookingDialog, ConnectBookingDetail } from "./components/booking-dialogs.js";
118
+ export { useBookableEventTypes, useAvailableSlots, useBookings, useBookingActions, } from "./use-scheduling.js";
119
+ export { layoutBookings, dayColumns, zonedInstant, } from "./components/booking-grid.js";
@@ -0,0 +1,107 @@
1
+ "use client";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import { useConnect, useConnectResource } from "./provider.js";
4
+ /**
5
+ * The bridge's scheduling surface, as hooks.
6
+ *
7
+ * Every path here is one of the six the bridge exposes, and every one of them
8
+ * is resolved *for the signed-in member* - Connect takes the workspace from
9
+ * their session and checks their own `calendar:read` / `calendar:manage`.
10
+ * Nothing in this file names a workspace, and nothing should: a browser that
11
+ * can name a tenant is a browser that can read someone else's diary.
12
+ */
13
+ /** What this workspace offers as bookable. Active types only. */
14
+ export function useBookableEventTypes() {
15
+ const { data, isLoading, error } = useConnectResource("/scheduling/event-types");
16
+ return { eventTypes: data?.eventTypes ?? [], isLoading, error };
17
+ }
18
+ /**
19
+ * Free times for one appointment type.
20
+ *
21
+ * **Deliberately re-read rather than cached across a session.** A slot list is
22
+ * a snapshot of contention: anyone else on that calendar - a colleague, a
23
+ * voice agent mid-call, someone on the public booking link - can take one
24
+ * between the read and the click. Showing a stale one is how two people are
25
+ * offered the same minute. The booking call re-checks inside a transaction
26
+ * anyway, so a stale list costs a refusal rather than a double booking; this
27
+ * just means the refusal is rare.
28
+ */
29
+ export function useAvailableSlots(query) {
30
+ const path = query
31
+ ? `/scheduling/slots?${new URLSearchParams({
32
+ eventTypeId: query.eventTypeId,
33
+ from: query.from.toISOString(),
34
+ to: query.to.toISOString(),
35
+ ...(query.excludeBookingId ? { excludeBookingId: query.excludeBookingId } : {}),
36
+ }).toString()}`
37
+ : null;
38
+ // `useConnectResource` needs a path; an empty one never resolves, so the
39
+ // hook is called unconditionally (rules of hooks) and simply idles.
40
+ const { data, isLoading, error, refetch } = useConnectResource(path ?? "");
41
+ return {
42
+ slots: path ? (data?.slots ?? []) : [],
43
+ timezone: data?.timezone ?? null,
44
+ durationMinutes: data?.durationMinutes ?? null,
45
+ isLoading: path ? isLoading : false,
46
+ error: path ? error : null,
47
+ refetch,
48
+ };
49
+ }
50
+ /** One page of the diary, ascending - a forward view, not a history. */
51
+ export function useBookings(query) {
52
+ const path = useMemo(() => `/scheduling/bookings?${new URLSearchParams({
53
+ from: query.from.toISOString(),
54
+ to: query.to.toISOString(),
55
+ order: "asc",
56
+ status: "confirmed",
57
+ limit: String(query.limit ?? 100),
58
+ ...(query.hostMemberId ? { hostMemberId: query.hostMemberId } : {}),
59
+ }).toString()}`, [query.from, query.to, query.hostMemberId, query.limit]);
60
+ const { data, isLoading, error, refetch } = useConnectResource(path);
61
+ return {
62
+ bookings: data?.bookings ?? [],
63
+ hasMore: data?.hasMore ?? false,
64
+ isLoading,
65
+ error,
66
+ path,
67
+ refetch,
68
+ };
69
+ }
70
+ /**
71
+ * Book, move and cancel.
72
+ *
73
+ * **None of these is optimistic**, and that is not an oversight. Whether a
74
+ * slot is still free is a question only the server can answer, inside the
75
+ * transaction that decides it - two people can be holding the same minute
76
+ * right now. Painting a booking as made and withdrawing it is exactly the lie
77
+ * the two-phase hold exists to prevent.
78
+ */
79
+ export function useBookingActions() {
80
+ const { mutateResource } = useConnect();
81
+ const [isPending, setPending] = useState(false);
82
+ const [error, setError] = useState(null);
83
+ const run = useCallback(async (path, body) => {
84
+ setPending(true);
85
+ setError(null);
86
+ try {
87
+ return await mutateResource(path, body);
88
+ }
89
+ catch (cause) {
90
+ // The server writes these as sentences - "that time was just taken" -
91
+ // precisely so they can be shown rather than mapped to a code.
92
+ setError(cause instanceof Error ? cause.message : "Something went wrong.");
93
+ return null;
94
+ }
95
+ finally {
96
+ setPending(false);
97
+ }
98
+ }, [mutateResource]);
99
+ return {
100
+ isPending,
101
+ error,
102
+ clearError: () => setError(null),
103
+ createBooking: (input) => run("/scheduling/bookings", input),
104
+ rescheduleBooking: (input) => run("/scheduling/bookings/reschedule", input).then((result) => result?.booking ?? null),
105
+ cancelBooking: (input) => run("/scheduling/bookings/cancel", input),
106
+ };
107
+ }
@@ -142,4 +142,4 @@ export class ConnectClient {
142
142
  }
143
143
  }
144
144
  /** Kept in step with `package.json` by `sdk.test.ts`. */
145
- export const SDK_VERSION = "0.17.0";
145
+ export const SDK_VERSION = "0.18.0";
@@ -412,6 +412,142 @@ export function createConnectHandler(options) {
412
412
  await session.write?.(request, response, result.sessionToken);
413
413
  return response;
414
414
  },
415
+ /*
416
+ * Scheduling - the workspace's own calendar, in a browser.
417
+ *
418
+ * Every one of these forwards the caller's session token, which is what
419
+ * makes them safe to expose: Connect derives the workspace from that token
420
+ * and checks the member's own `calendar:read` / `calendar:manage`. The
421
+ * browser names no tenant and the partner's key does not, by itself,
422
+ * authorize a write - without that second half, shipping a booking
423
+ * component would let every end user of every partner app take slots on a
424
+ * real business calendar.
425
+ *
426
+ * `workspaceId` is still passed because the resource requires it, and is
427
+ * still ignored by Connect whenever a token is present. `requireWorkspace`
428
+ * supplies the session's own, so the two can never disagree.
429
+ */
430
+ "GET /scheduling/event-types": async (request) => {
431
+ const { workspaceId } = await requireWorkspace(request);
432
+ const asUser = await requireToken(request);
433
+ return json({ eventTypes: await client.scheduling.listEventTypes(workspaceId, { asUser }) });
434
+ },
435
+ "GET /scheduling/slots": async (request) => {
436
+ const { workspaceId } = await requireWorkspace(request);
437
+ const asUser = await requireToken(request);
438
+ const url = new URL(request.url);
439
+ const eventTypeId = url.searchParams.get("eventTypeId");
440
+ const from = url.searchParams.get("from");
441
+ const to = url.searchParams.get("to");
442
+ if (!eventTypeId || !from || !to) {
443
+ throw new ConnectError({
444
+ status: 400,
445
+ code: "validation_error",
446
+ message: "eventTypeId, from and to are required",
447
+ endpoint: "/scheduling/slots",
448
+ });
449
+ }
450
+ return json(await client.scheduling.findSlots({
451
+ workspaceId,
452
+ eventTypeId,
453
+ from,
454
+ to,
455
+ // Set when offering times to move an existing appointment to, or the
456
+ // booking blocks its own replacement and the list comes back empty.
457
+ excludeBookingId: url.searchParams.get("excludeBookingId") ?? undefined,
458
+ asUser,
459
+ }));
460
+ },
461
+ "GET /scheduling/bookings": async (request) => {
462
+ const { workspaceId } = await requireWorkspace(request);
463
+ const asUser = await requireToken(request);
464
+ const url = new URL(request.url);
465
+ const limit = Number(url.searchParams.get("limit") ?? 25);
466
+ return json(await client.scheduling.listBookings({
467
+ workspaceId,
468
+ status: url.searchParams.get("status") ?? undefined,
469
+ hostMemberId: url.searchParams.get("hostMemberId") ?? undefined,
470
+ from: url.searchParams.get("from") ?? undefined,
471
+ to: url.searchParams.get("to") ?? undefined,
472
+ order: url.searchParams.get("order") === "asc" ? "asc" : "desc",
473
+ cursor: url.searchParams.get("cursor") ?? undefined,
474
+ limit: Number.isInteger(limit) && limit >= 1 && limit <= 100 ? limit : 25,
475
+ asUser,
476
+ }));
477
+ },
478
+ "POST /scheduling/bookings": async (request) => {
479
+ const { workspaceId } = await requireWorkspace(request);
480
+ const asUser = await requireToken(request);
481
+ const body = (await request.json().catch(() => null));
482
+ if (typeof body?.eventTypeId !== "string" ||
483
+ typeof body.startAt !== "string" ||
484
+ typeof body.attendee?.name !== "string" ||
485
+ typeof body.attendee?.phone !== "string") {
486
+ throw new ConnectError({
487
+ status: 400,
488
+ code: "validation_error",
489
+ message: "eventTypeId, startAt and an attendee name and phone are required",
490
+ endpoint: "/scheduling/bookings",
491
+ });
492
+ }
493
+ return json(await client.scheduling.createBooking({
494
+ workspaceId,
495
+ eventTypeId: body.eventTypeId,
496
+ startAt: body.startAt,
497
+ attendee: {
498
+ name: body.attendee.name,
499
+ phone: body.attendee.phone,
500
+ email: typeof body.attendee.email === "string" ? body.attendee.email : null,
501
+ },
502
+ notes: typeof body.notes === "string" ? body.notes : null,
503
+ asUser,
504
+ }));
505
+ },
506
+ /*
507
+ * Reschedule and cancel take the booking id in the body rather than the
508
+ * path, matching `POST /sessions/revoke`. The bridge's route table is
509
+ * exact-match on `METHOD /path`, so a path parameter would mean a second
510
+ * matching strategy for two routes.
511
+ */
512
+ "POST /scheduling/bookings/reschedule": async (request) => {
513
+ const { workspaceId } = await requireWorkspace(request);
514
+ const asUser = await requireToken(request);
515
+ const body = (await request.json().catch(() => null));
516
+ if (typeof body?.bookingId !== "string" || typeof body.startAt !== "string") {
517
+ throw new ConnectError({
518
+ status: 400,
519
+ code: "validation_error",
520
+ message: "bookingId and startAt are required",
521
+ endpoint: "/scheduling/bookings/reschedule",
522
+ });
523
+ }
524
+ return json(await client.scheduling.rescheduleBooking({
525
+ workspaceId,
526
+ bookingId: body.bookingId,
527
+ startAt: body.startAt,
528
+ notes: typeof body.notes === "string" ? body.notes : undefined,
529
+ asUser,
530
+ }));
531
+ },
532
+ "POST /scheduling/bookings/cancel": async (request) => {
533
+ const { workspaceId } = await requireWorkspace(request);
534
+ const asUser = await requireToken(request);
535
+ const body = (await request.json().catch(() => null));
536
+ if (typeof body?.bookingId !== "string") {
537
+ throw new ConnectError({
538
+ status: 400,
539
+ code: "validation_error",
540
+ message: "bookingId is required",
541
+ endpoint: "/scheduling/bookings/cancel",
542
+ });
543
+ }
544
+ return json(await client.scheduling.cancelBooking({
545
+ workspaceId,
546
+ bookingId: body.bookingId,
547
+ reason: typeof body.reason === "string" ? body.reason : null,
548
+ asUser,
549
+ }));
550
+ },
415
551
  "GET /integrations": async (request) => {
416
552
  const { workspaceId } = await requireWorkspace(request);
417
553
  return json({ toolkits: await client.integrations.list(workspaceId) });
@@ -1,4 +1,5 @@
1
1
  import { request } from "../http.js";
2
+ import { actingAs } from "./acting-as.js";
2
3
  /**
3
4
  * A subscribing workspace's own calendar.
4
5
  *
@@ -18,11 +19,12 @@ export class SchedulingResource {
18
19
  this.transport = transport;
19
20
  }
20
21
  /** What this workspace offers as bookable. Active appointment types only. */
21
- async listEventTypes(workspaceId) {
22
+ async listEventTypes(workspaceId, options = {}) {
22
23
  const result = await request(this.transport, {
23
24
  method: "GET",
24
25
  path: "/scheduling/event-types",
25
26
  query: { workspaceId },
27
+ headers: actingAs(options.asUser),
26
28
  });
27
29
  // `?? []` for the same reason `apps.list` has it - a collection every
28
30
  // caller immediately maps, and a body without the key would hand them
@@ -51,7 +53,9 @@ export class SchedulingResource {
51
53
  eventTypeId: input.eventTypeId,
52
54
  from: toInstant(input.from),
53
55
  to: toInstant(input.to),
56
+ excludeBookingId: input.excludeBookingId,
54
57
  },
58
+ headers: actingAs(input.asUser),
55
59
  });
56
60
  }
57
61
  /** One page of the workspace's diary. */
@@ -69,6 +73,7 @@ export class SchedulingResource {
69
73
  cursor: input.cursor,
70
74
  limit: input.limit === undefined ? undefined : String(input.limit),
71
75
  },
76
+ headers: actingAs(input.asUser),
72
77
  });
73
78
  }
74
79
  /**
@@ -90,6 +95,31 @@ export class SchedulingResource {
90
95
  attendee: input.attendee,
91
96
  notes: input.notes ?? null,
92
97
  },
98
+ headers: actingAs(input.asUser),
99
+ });
100
+ }
101
+ /**
102
+ * Moves an appointment to a different time.
103
+ *
104
+ * **The original is retired, not edited.** It stays on the diary as
105
+ * `rescheduled` with `rescheduledToId` pointing at the replacement, and both
106
+ * come back here - so a booking id you cached still resolves, and still says
107
+ * where the appointment went.
108
+ *
109
+ * A refused move changes nothing: the new slot is claimed before the old one
110
+ * is released, so a `conflict` leaves the customer with the appointment they
111
+ * already had. Re-read the slots (with `excludeBookingId`) and offer another.
112
+ */
113
+ rescheduleBooking(input) {
114
+ return request(this.transport, {
115
+ method: "POST",
116
+ path: `/scheduling/bookings/${encodeURIComponent(input.bookingId)}/reschedule`,
117
+ json: {
118
+ workspaceId: input.workspaceId,
119
+ startAt: toInstant(input.startAt),
120
+ notes: input.notes ?? null,
121
+ },
122
+ headers: actingAs(input.asUser),
93
123
  });
94
124
  }
95
125
  /**
@@ -104,6 +134,7 @@ export class SchedulingResource {
104
134
  method: "POST",
105
135
  path: `/scheduling/bookings/${encodeURIComponent(input.bookingId)}/cancel`,
106
136
  json: { workspaceId: input.workspaceId, reason: input.reason ?? null },
137
+ headers: actingAs(input.asUser),
107
138
  });
108
139
  }
109
140
  }