@cosmoops/connect-sdk 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,127 @@
3
3
  `@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
4
4
  versions may carry breaking changes** and patch versions never do.
5
5
 
6
+ ## 0.28.0
7
+
8
+ Connect's webhook bus carried four events, all of them about billing and
9
+ deletion. It now carries the seven a system of record is actually built on —
10
+ calls, appointments and contacts — so the work your customers do inside
11
+ Connect can reach the product they do the rest of their work in.
12
+
13
+ ### Added
14
+
15
+ - Seven webhook event types, with their payloads fully typed on
16
+ `ConnectWebhookEventMap`: `call.completed`, `call.missed`,
17
+ `booking.confirmed`, `booking.cancelled`, `booking.rescheduled`,
18
+ `contact.created` and `contact.updated`. Register handlers for them on
19
+ `createConnectWebhookHandler` exactly as you do the existing four.
20
+ - `ConnectCallDirection` and `ConnectContactSource`, the two unions those
21
+ payloads carry.
22
+
23
+ These are **activity** events rather than lifecycle ones, and the difference
24
+ decides who receives them: they are delivered only to applications the
25
+ workspace is actually subscribed to, so a lapsed customer stops emitting on
26
+ the same boundary that stops their access.
27
+
28
+ Three conventions are worth reading before you write a handler, each
29
+ documented on the types themselves. Phone numbers are E.164. A call event
30
+ identifies its party by `contactPhone` and deliberately carries no
31
+ `contactId` — Connect resolves a caller *by* the number and at the moment a
32
+ call ends it often has not, so the field would be `null` on most deliveries.
33
+ And `booking.rescheduled` carries `previousBookingId` as well as `bookingId`,
34
+ because Connect retires the old booking and writes a new one: reconcile on
35
+ the previous id or you will end up holding two appointments.
36
+
37
+ ### Fixed
38
+
39
+ - `Booking` fixtures in the bundled tests compile again — the type gained a
40
+ required `hostMemberIds` in 0.27.0 and one fixture was missed. No runtime
41
+ change.
42
+
43
+ ### A note on exhaustiveness
44
+
45
+ `ConnectWebhookEventType` is a union that grows, and this release grows it.
46
+ `createConnectWebhookHandler` is unaffected — its `on` map is partial and it
47
+ answers 200 to anything you have not registered. A hand-written `switch` with
48
+ an exhaustive `never` check on that union will now fail to compile until you
49
+ add the new members; that is the documented contract of this union rather than
50
+ a break, and it is the reason the handler exists.
51
+
52
+ ## 0.27.0
53
+
54
+ A minor rather than a patch because `Booking` gains a required field, so a
55
+ hand-written object satisfying the old interface no longer type-checks. Nothing
56
+ you *read* from the API breaks.
57
+
58
+ 0.26.1 was never published; its note below ships as part of this release, so the
59
+ registry goes 0.26.0 → 0.27.0.
60
+
61
+ ### Added
62
+
63
+ - **`connect.scheduling.getBooking()`** - one appointment, by the id you
64
+ stored.
65
+
66
+ ```ts
67
+ const booking = await connect.scheduling.getBooking({
68
+ workspaceId,
69
+ bookingId, // the id you kept on your own record
70
+ });
71
+ ```
72
+
73
+ This is the read to make when a record of yours - a meeting on a deal, an
74
+ appointment on a case - holds a booking id. **`listBookings` cannot serve
75
+ it.** That is a window over `startAt`, and the `startAt` you cached is
76
+ exactly what is wrong once somebody has moved the appointment on the
77
+ workspace's own calendar, so the window you build from it comes back without
78
+ the row - in the one case worth re-reading for.
79
+
80
+ A retired booking still resolves, deliberately. `status: "rescheduled"`
81
+ answers with `rescheduledToId`, so an id stored months ago leads to the
82
+ appointment that exists today; follow the pointer. `cancelled` answers with
83
+ its reason. `not_found` means this workspace never had that booking, which is
84
+ a different fact from "it moved".
85
+
86
+ `GET /scheduling/booking?bookingId=` is the matching browser bridge route -
87
+ singular, and the id in the query string, because the bridge's route table is
88
+ matched by exact string. Same reason `reschedule` and `cancel` take theirs in
89
+ the body.
90
+
91
+ - **`Booking.hostMemberIds`** - the workspace members committed to an
92
+ appointment, so an integration can render who it belongs to.
93
+
94
+ These are the `WorkspaceMember.id` values `workspaces.listMembers` returns,
95
+ so an owner column is a lookup against a roster you already hold rather than
96
+ another round trip. They were withheld as "Connect internals", which
97
+ `listBookings` had already contradicted by *filtering* on `hostMemberId` - a
98
+ partner could narrow a diary by a value it was never allowed to read back.
99
+
100
+ Resolved when the slot was held and frozen there: a round-robin event type
101
+ picks one host, a collective one commits the whole group, and re-pointing the
102
+ event type afterwards does not move an appointment already made.
103
+
104
+ **This is the breaking half.** The field is required, so code constructing a
105
+ `Booking` literal - a test fixture, a mock, a storybook arg - needs
106
+ `hostMemberIds: []` added. Code that only reads bookings from the API is
107
+ unaffected.
108
+
109
+ ## 0.26.1
110
+
111
+ ### Added
112
+
113
+ - **`<ConnectAppShell>` renders a skip link.** The rail is the shell's first
114
+ content, so a keyboard or switch user tabbed every navigation row, the app
115
+ launcher and the account menu again on **every** route change before reaching
116
+ the page they came for - WCAG 2.4.1, and the one bypass a single-rail layout
117
+ cannot do without, because there is no second landmark to jump between. The
118
+ link is the first focusable element in the document, visually hidden until it
119
+ takes focus, and `<main>` now carries `id="connect-main-content"` and
120
+ `tabIndex={-1}` so focus genuinely lands there. Without that `tabIndex` a
121
+ browser scrolls to the anchor and leaves focus where it was, so the next Tab
122
+ returns to the top of the rail and the link appears to do nothing.
123
+
124
+ Nothing to adopt: it is on for every platform. The only visible change is a
125
+ "Skip to content" button that appears at the top-left on the first Tab.
126
+
6
127
  ## 0.26.0
7
128
 
8
129
  A minor rather than a patch because two of these change how existing screens
package/README.md CHANGED
@@ -345,7 +345,7 @@ try {
345
345
  | `contacts` | `lookupByPhone` · `get` |
346
346
  | `channels` | `list` · `get` · `claim` · `setRouting` · `clearRouting` |
347
347
  | `calls` | `list` · `get` |
348
- | `scheduling` | `listEventTypes` · `findSlots` · `listBookings` · `createBooking` · `rescheduleBooking` · `cancelBooking` |
348
+ | `scheduling` | `listEventTypes` · `findSlots` · `listBookings` · `getBooking` · `createBooking` · `rescheduleBooking` · `cancelBooking` |
349
349
  | `ai` | `chat` · `extract` |
350
350
  | `otp` | `send` · `verify` |
351
351
  | `logs` | `write` |
@@ -404,6 +404,26 @@ try {
404
404
  `timezone` is the **business's** zone, not your user's - render the wall time in
405
405
  it, or you will offer an hour they are not open.
406
406
 
407
+ Keeping the booking id on a record of your own - a meeting on a deal, an
408
+ appointment on a case - is how you get back to it later:
409
+
410
+ ```ts
411
+ const booking = await client.scheduling.getBooking({ workspaceId, bookingId });
412
+
413
+ // Someone moved it on the workspace's own calendar. Follow the pointer.
414
+ if (booking.status === "rescheduled") {
415
+ const current = await client.scheduling.getBooking({
416
+ workspaceId,
417
+ bookingId: booking.rescheduledToId!,
418
+ });
419
+ }
420
+ ```
421
+
422
+ Use `getBooking`, not `listBookings`, for that. The list is a window over
423
+ `startAt`, and the `startAt` you cached is exactly what is wrong once the
424
+ appointment has moved - so the window you build from it comes back without the
425
+ row, in the one case worth re-reading for.
426
+
407
427
  ### Attributing a log to a user
408
428
 
409
429
  ```ts
@@ -23,6 +23,15 @@ const use_active_path_js_1 = require("../use-active-path.js");
23
23
  const cn_js_1 = require("../../cn.js");
24
24
  const COLLAPSED_KEY = "connect_sidenav_collapsed";
25
25
  const COLLAPSED_EVENT = "connect:sidenav-collapsed";
26
+ /**
27
+ * The `<main>` element's id, and the skip link's target.
28
+ *
29
+ * Module-local rather than a prop: both ends of the pair live in this file, so
30
+ * there is nothing for a caller to keep in step - and a partner supplying
31
+ * their own id could only get it wrong. Namespaced because it lands in a
32
+ * document the partner also writes.
33
+ */
34
+ const CONTENT_ID = "connect-main-content";
26
35
  /**
27
36
  * The stored rail preference, or `null` when there isn't one (or when reading
28
37
  * it is not allowed).
@@ -189,5 +198,5 @@ function AppShell({ name, icon, homeHref = "/", nav, secondaryNav, activePath, r
189
198
  }), [renderLink, activePath, closeMobile]);
190
199
  const accountRow = signedOut ? (showAuthButtons && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2", children: [(0, jsx_runtime_1.jsx)(auth_buttons_js_1.ConnectLoginButton, { size: "sm", fullWidth: true }), (0, jsx_runtime_1.jsx)(auth_buttons_js_1.ConnectRegisterButton, { size: "sm", fullWidth: true })] }))) : ((0, jsx_runtime_1.jsx)(user_menu_js_1.ConnectUserButton, { variant: "row", theme: theme, onThemeChange: onThemeChange }));
191
200
  const railContent = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebarHeader, { name: name, icon: icon, homeHref: homeHref }), workspaceSwitcher !== false && ((0, jsx_runtime_1.jsx)("div", { className: "shrink-0 px-3 pb-1", children: (0, jsx_runtime_1.jsx)(workspace_switcher_js_1.ConnectWorkspaceSwitcher, {}) })), (0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebarBody, { children: (0, jsx_runtime_1.jsx)(side_nav_js_1.ConnectSidebarNav, { nav: visibleNav }) }), (visibleSecondaryNav || sidebarFooter || showAppsGrid) && ((0, jsx_runtime_1.jsxs)(sidebar_js_1.ConnectSidebarFooter, { separated: false, children: [visibleSecondaryNav && (0, jsx_runtime_1.jsx)(side_nav_js_1.ConnectSidebarNav, { nav: visibleSecondaryNav }), sidebarFooter, showAppsGrid && (0, jsx_runtime_1.jsx)(apps_grid_js_1.ConnectAppsGrid, { variant: "row" })] })), accountRow && (0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebarFooter, { children: accountRow })] }));
192
- return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)("div", { className: (0, cn_js_1.cn)("flex h-screen bg-background", className), children: [(0, jsx_runtime_1.jsx)(shell_context_js_1.ConnectShellProvider, { value: railShell, children: (0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebar, { children: railContent }) }), (0, jsx_runtime_1.jsx)(react_2.Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: (0, jsx_runtime_1.jsx)(react_2.Drawer.Content, { placement: "left", className: "w-[min(20rem,85vw)]", children: (0, jsx_runtime_1.jsx)(react_2.Drawer.Dialog, { "aria-label": "Navigation menu", className: "p-0", children: (0, jsx_runtime_1.jsx)(shell_context_js_1.ConnectShellProvider, { value: drawerShell, children: (0, jsx_runtime_1.jsx)("div", { className: "flex h-full flex-col", children: railContent }) }) }) }) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-1 flex-col", children: [(0, jsx_runtime_1.jsxs)("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border bg-surface px-3 md:hidden", children: [(0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Menu, { className: "size-5" }) }), (0, jsx_runtime_1.jsx)(logo_js_1.ConnectLogo, { name: name, icon: icon })] }), (0, jsx_runtime_1.jsx)("main", { className: "flex-1 overflow-y-auto", children: (0, jsx_runtime_1.jsxs)("div", { className: contentClassName ?? "w-full px-4 py-5 md:px-6 md:py-6", children: [gated ? (0, jsx_runtime_1.jsx)(access_gate_js_1.ConnectAccessGate, { children: children }) : children, footer] }) })] }), askAi && (0, jsx_runtime_1.jsx)(ask_ai_js_1.ConnectAskAi, { ...(askAi === true ? {} : askAi) })] }) }));
201
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)("div", { className: (0, cn_js_1.cn)("flex h-dvh bg-background", className), children: [(0, jsx_runtime_1.jsx)("a", { href: `#${CONTENT_ID}`, className: "sr-only focus:not-sr-only focus:absolute focus:start-3 focus:top-3 focus:z-50 focus:rounded-lg focus:bg-surface focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-lg focus:outline-2 focus:outline-offset-2 focus:outline-focus", children: "Skip to content" }), (0, jsx_runtime_1.jsx)(shell_context_js_1.ConnectShellProvider, { value: railShell, children: (0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebar, { children: railContent }) }), (0, jsx_runtime_1.jsx)(react_2.Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: (0, jsx_runtime_1.jsx)(react_2.Drawer.Content, { placement: "left", className: "w-[min(20rem,85vw)]", children: (0, jsx_runtime_1.jsxs)(react_2.Drawer.Dialog, { "aria-label": "Navigation menu", className: "p-0 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]", children: [(0, jsx_runtime_1.jsx)(react_2.Drawer.CloseTrigger, { className: "end-3 top-[calc(--spacing(3)+env(safe-area-inset-top))]" }), (0, jsx_runtime_1.jsx)(shell_context_js_1.ConnectShellProvider, { value: drawerShell, children: (0, jsx_runtime_1.jsx)("div", { className: "flex h-full flex-col [&>:first-child]:pe-10", children: railContent }) })] }) }) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-1 flex-col", children: [(0, jsx_runtime_1.jsxs)("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border bg-surface px-3 md:hidden", children: [(0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Menu, { className: "size-5" }) }), (0, jsx_runtime_1.jsx)(logo_js_1.ConnectLogo, { name: name, icon: icon })] }), (0, jsx_runtime_1.jsx)("main", { id: CONTENT_ID, tabIndex: -1, className: "flex-1 overflow-y-auto outline-none", children: (0, jsx_runtime_1.jsxs)("div", { className: contentClassName ?? "w-full px-4 py-5 md:px-6 md:py-6", children: [gated ? (0, jsx_runtime_1.jsx)(access_gate_js_1.ConnectAccessGate, { children: children }) : children, footer] }) })] }), askAi && (0, jsx_runtime_1.jsx)(ask_ai_js_1.ConnectAskAi, { ...(askAi === true ? {} : askAi) })] }) }));
193
202
  }
@@ -100,7 +100,23 @@ function ConnectSidebarHeader({ name, icon, homeHref = "/" }) {
100
100
  * the sidebar is `shrink-0`, so this is the only part that ever gives way.
101
101
  */
102
102
  function ConnectSidebarBody({ children }) {
103
- return ((0, jsx_runtime_1.jsx)("div", { className: "flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden px-3 py-2", children: children }));
103
+ return (
104
+ // `touch-pan-y touch-pinch-zoom` is what lets this scroll under a finger
105
+ // inside the mobile drawer. `Drawer.Dialog` carries an inline
106
+ // `touch-action: none` whenever it is dismissable - HeroUI sets it so its
107
+ // own pointer-driven drag-to-dismiss is not fighting the browser's pan -
108
+ // and the declaration meant to hand the scroll back to the scrollable
109
+ // region is missing from the shipped stylesheet: `@heroui/styles`'
110
+ // `drawer.css` carries the comment ("touch-action: pan-y allows vertical
111
+ // scrolling while the dialog captures drag gestures") on a `.drawer__body`
112
+ // rule that never sets the property. A nav longer than the panel was
113
+ // therefore frozen on a phone while scrolling normally under a wheel.
114
+ //
115
+ // `pinch-zoom` alongside `pan-y` rather than `pan-y` on its own: taking
116
+ // zoom away over a region of text is a WCAG 1.4.4 failure, and nothing
117
+ // here needs it suppressed. On the desktop rail, where no ancestor
118
+ // restricts anything, the pair is equivalent to the default.
119
+ (0, jsx_runtime_1.jsx)("div", { className: "flex min-h-0 flex-1 touch-pan-y touch-pinch-zoom flex-col gap-1 overflow-y-auto overflow-x-hidden px-3 py-2", children: children }));
104
120
  }
105
121
  /**
106
122
  * A pinned region at the foot of the rail - the secondary nav, the launcher,
@@ -161,4 +161,4 @@ class ConnectClient {
161
161
  }
162
162
  exports.ConnectClient = ConnectClient;
163
163
  /** Kept in step with `package.json` by `sdk.test.ts`. */
164
- exports.SDK_VERSION = "0.26.0";
164
+ exports.SDK_VERSION = "0.28.0";
@@ -511,6 +511,21 @@ function createConnectHandler(options) {
511
511
  asUser,
512
512
  }));
513
513
  },
514
+ /*
515
+ * Singular, and the id is a query param rather than a path segment - the
516
+ * table above is matched by exact string, which is the same reason
517
+ * reschedule and cancel take theirs in the body. Keying it as
518
+ * `/scheduling/bookings/{id}` would simply never match.
519
+ */
520
+ "GET /scheduling/booking": async (request) => {
521
+ const { workspaceId } = await requireWorkspace(request);
522
+ const asUser = await requireToken(request);
523
+ const bookingId = new URL(request.url).searchParams.get("bookingId");
524
+ if (!bookingId) {
525
+ return json({ error: { code: "validation_error", message: "bookingId is required" } }, 400);
526
+ }
527
+ return json(await client.scheduling.getBooking({ workspaceId, bookingId, asUser }));
528
+ },
514
529
  "GET /scheduling/bookings": async (request) => {
515
530
  const { workspaceId } = await requireWorkspace(request);
516
531
  const asUser = await requireToken(request);
@@ -61,6 +61,29 @@ class SchedulingResource {
61
61
  headers: (0, acting_as_js_1.actingAs)(input.asUser),
62
62
  });
63
63
  }
64
+ /**
65
+ * One appointment, by the id you stored.
66
+ *
67
+ * **This is the read to make when you kept a booking id on a record of your
68
+ * own** - a meeting on a deal, an appointment on a case. `listBookings`
69
+ * cannot serve it: that is a window over `startAt`, and the `startAt` you
70
+ * cached is exactly what is wrong once someone has moved the appointment on
71
+ * the workspace's own calendar, so the window you build from it comes back
72
+ * without the row.
73
+ *
74
+ * A retired booking still resolves. `status: "rescheduled"` answers with
75
+ * `rescheduledToId`, so an id stored months ago leads to the appointment
76
+ * that exists today; `cancelled` answers with its reason. `not_found` means
77
+ * this workspace never had that booking.
78
+ */
79
+ getBooking(input) {
80
+ return (0, http_js_1.request)(this.transport, {
81
+ method: "GET",
82
+ path: `/scheduling/bookings/${encodeURIComponent(input.bookingId)}`,
83
+ query: { workspaceId: input.workspaceId },
84
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
85
+ });
86
+ }
64
87
  /** One page of the workspace's diary. */
65
88
  listBookings(input) {
66
89
  return (0, http_js_1.request)(this.transport, {
@@ -20,6 +20,15 @@ import { useActivePath } from "../use-active-path.js";
20
20
  import { cn } from "../../cn.js";
21
21
  const COLLAPSED_KEY = "connect_sidenav_collapsed";
22
22
  const COLLAPSED_EVENT = "connect:sidenav-collapsed";
23
+ /**
24
+ * The `<main>` element's id, and the skip link's target.
25
+ *
26
+ * Module-local rather than a prop: both ends of the pair live in this file, so
27
+ * there is nothing for a caller to keep in step - and a partner supplying
28
+ * their own id could only get it wrong. Namespaced because it lands in a
29
+ * document the partner also writes.
30
+ */
31
+ const CONTENT_ID = "connect-main-content";
23
32
  /**
24
33
  * The stored rail preference, or `null` when there isn't one (or when reading
25
34
  * it is not allowed).
@@ -186,5 +195,5 @@ export function AppShell({ name, icon, homeHref = "/", nav, secondaryNav, active
186
195
  }), [renderLink, activePath, closeMobile]);
187
196
  const accountRow = signedOut ? (showAuthButtons && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(ConnectLoginButton, { size: "sm", fullWidth: true }), _jsx(ConnectRegisterButton, { size: "sm", fullWidth: true })] }))) : (_jsx(ConnectUserButton, { variant: "row", theme: theme, onThemeChange: onThemeChange }));
188
197
  const railContent = (_jsxs(_Fragment, { children: [_jsx(ConnectSidebarHeader, { name: name, icon: icon, homeHref: homeHref }), workspaceSwitcher !== false && (_jsx("div", { className: "shrink-0 px-3 pb-1", children: _jsx(ConnectWorkspaceSwitcher, {}) })), _jsx(ConnectSidebarBody, { children: _jsx(ConnectSidebarNav, { nav: visibleNav }) }), (visibleSecondaryNav || sidebarFooter || showAppsGrid) && (_jsxs(ConnectSidebarFooter, { separated: false, children: [visibleSecondaryNav && _jsx(ConnectSidebarNav, { nav: visibleSecondaryNav }), sidebarFooter, showAppsGrid && _jsx(ConnectAppsGrid, { variant: "row" })] })), accountRow && _jsx(ConnectSidebarFooter, { children: accountRow })] }));
189
- return (_jsx(MotionProvider, { children: _jsxs("div", { className: cn("flex h-screen bg-background", className), children: [_jsx(ConnectShellProvider, { value: railShell, children: _jsx(ConnectSidebar, { children: railContent }) }), _jsx(Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: _jsx(Drawer.Content, { placement: "left", className: "w-[min(20rem,85vw)]", children: _jsx(Drawer.Dialog, { "aria-label": "Navigation menu", className: "p-0", children: _jsx(ConnectShellProvider, { value: drawerShell, children: _jsx("div", { className: "flex h-full flex-col", children: railContent }) }) }) }) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border bg-surface px-3 md:hidden", children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: _jsx(Menu, { className: "size-5" }) }), _jsx(ConnectLogo, { name: name, icon: icon })] }), _jsx("main", { className: "flex-1 overflow-y-auto", children: _jsxs("div", { className: contentClassName ?? "w-full px-4 py-5 md:px-6 md:py-6", children: [gated ? _jsx(ConnectAccessGate, { children: children }) : children, footer] }) })] }), askAi && _jsx(ConnectAskAi, { ...(askAi === true ? {} : askAi) })] }) }));
198
+ return (_jsx(MotionProvider, { children: _jsxs("div", { className: cn("flex h-dvh bg-background", className), children: [_jsx("a", { href: `#${CONTENT_ID}`, className: "sr-only focus:not-sr-only focus:absolute focus:start-3 focus:top-3 focus:z-50 focus:rounded-lg focus:bg-surface focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-lg focus:outline-2 focus:outline-offset-2 focus:outline-focus", children: "Skip to content" }), _jsx(ConnectShellProvider, { value: railShell, children: _jsx(ConnectSidebar, { children: railContent }) }), _jsx(Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: _jsx(Drawer.Content, { placement: "left", className: "w-[min(20rem,85vw)]", children: _jsxs(Drawer.Dialog, { "aria-label": "Navigation menu", className: "p-0 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]", children: [_jsx(Drawer.CloseTrigger, { className: "end-3 top-[calc(--spacing(3)+env(safe-area-inset-top))]" }), _jsx(ConnectShellProvider, { value: drawerShell, children: _jsx("div", { className: "flex h-full flex-col [&>:first-child]:pe-10", children: railContent }) })] }) }) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border bg-surface px-3 md:hidden", children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: _jsx(Menu, { className: "size-5" }) }), _jsx(ConnectLogo, { name: name, icon: icon })] }), _jsx("main", { id: CONTENT_ID, tabIndex: -1, className: "flex-1 overflow-y-auto outline-none", children: _jsxs("div", { className: contentClassName ?? "w-full px-4 py-5 md:px-6 md:py-6", children: [gated ? _jsx(ConnectAccessGate, { children: children }) : children, footer] }) })] }), askAi && _jsx(ConnectAskAi, { ...(askAi === true ? {} : askAi) })] }) }));
190
199
  }
@@ -93,7 +93,23 @@ export function ConnectSidebarHeader({ name, icon, homeHref = "/" }) {
93
93
  * the sidebar is `shrink-0`, so this is the only part that ever gives way.
94
94
  */
95
95
  export function ConnectSidebarBody({ children }) {
96
- return (_jsx("div", { className: "flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden px-3 py-2", children: children }));
96
+ return (
97
+ // `touch-pan-y touch-pinch-zoom` is what lets this scroll under a finger
98
+ // inside the mobile drawer. `Drawer.Dialog` carries an inline
99
+ // `touch-action: none` whenever it is dismissable - HeroUI sets it so its
100
+ // own pointer-driven drag-to-dismiss is not fighting the browser's pan -
101
+ // and the declaration meant to hand the scroll back to the scrollable
102
+ // region is missing from the shipped stylesheet: `@heroui/styles`'
103
+ // `drawer.css` carries the comment ("touch-action: pan-y allows vertical
104
+ // scrolling while the dialog captures drag gestures") on a `.drawer__body`
105
+ // rule that never sets the property. A nav longer than the panel was
106
+ // therefore frozen on a phone while scrolling normally under a wheel.
107
+ //
108
+ // `pinch-zoom` alongside `pan-y` rather than `pan-y` on its own: taking
109
+ // zoom away over a region of text is a WCAG 1.4.4 failure, and nothing
110
+ // here needs it suppressed. On the desktop rail, where no ancestor
111
+ // restricts anything, the pair is equivalent to the default.
112
+ _jsx("div", { className: "flex min-h-0 flex-1 touch-pan-y touch-pinch-zoom flex-col gap-1 overflow-y-auto overflow-x-hidden px-3 py-2", children: children }));
97
113
  }
98
114
  /**
99
115
  * A pinned region at the foot of the rail - the secondary nav, the launcher,
@@ -157,4 +157,4 @@ export class ConnectClient {
157
157
  }
158
158
  }
159
159
  /** Kept in step with `package.json` by `sdk.test.ts`. */
160
- export const SDK_VERSION = "0.26.0";
160
+ export const SDK_VERSION = "0.28.0";
@@ -508,6 +508,21 @@ export function createConnectHandler(options) {
508
508
  asUser,
509
509
  }));
510
510
  },
511
+ /*
512
+ * Singular, and the id is a query param rather than a path segment - the
513
+ * table above is matched by exact string, which is the same reason
514
+ * reschedule and cancel take theirs in the body. Keying it as
515
+ * `/scheduling/bookings/{id}` would simply never match.
516
+ */
517
+ "GET /scheduling/booking": async (request) => {
518
+ const { workspaceId } = await requireWorkspace(request);
519
+ const asUser = await requireToken(request);
520
+ const bookingId = new URL(request.url).searchParams.get("bookingId");
521
+ if (!bookingId) {
522
+ return json({ error: { code: "validation_error", message: "bookingId is required" } }, 400);
523
+ }
524
+ return json(await client.scheduling.getBooking({ workspaceId, bookingId, asUser }));
525
+ },
511
526
  "GET /scheduling/bookings": async (request) => {
512
527
  const { workspaceId } = await requireWorkspace(request);
513
528
  const asUser = await requireToken(request);
@@ -58,6 +58,29 @@ export class SchedulingResource {
58
58
  headers: actingAs(input.asUser),
59
59
  });
60
60
  }
61
+ /**
62
+ * One appointment, by the id you stored.
63
+ *
64
+ * **This is the read to make when you kept a booking id on a record of your
65
+ * own** - a meeting on a deal, an appointment on a case. `listBookings`
66
+ * cannot serve it: that is a window over `startAt`, and the `startAt` you
67
+ * cached is exactly what is wrong once someone has moved the appointment on
68
+ * the workspace's own calendar, so the window you build from it comes back
69
+ * without the row.
70
+ *
71
+ * A retired booking still resolves. `status: "rescheduled"` answers with
72
+ * `rescheduledToId`, so an id stored months ago leads to the appointment
73
+ * that exists today; `cancelled` answers with its reason. `not_found` means
74
+ * this workspace never had that booking.
75
+ */
76
+ getBooking(input) {
77
+ return request(this.transport, {
78
+ method: "GET",
79
+ path: `/scheduling/bookings/${encodeURIComponent(input.bookingId)}`,
80
+ query: { workspaceId: input.workspaceId },
81
+ headers: actingAs(input.asUser),
82
+ });
83
+ }
61
84
  /** One page of the workspace's diary. */
62
85
  listBookings(input) {
63
86
  return request(this.transport, {
@@ -123,4 +123,4 @@ export declare class ConnectClient {
123
123
  constructor(options: ConnectClientOptions);
124
124
  }
125
125
  /** Kept in step with `package.json` by `sdk.test.ts`. */
126
- export declare const SDK_VERSION = "0.26.0";
126
+ export declare const SDK_VERSION = "0.28.0";
@@ -97,6 +97,25 @@ export declare class SchedulingResource {
97
97
  * days rather than an error.
98
98
  */
99
99
  findSlots(input: FindSlotsInput): Promise<BookingSlots>;
100
+ /**
101
+ * One appointment, by the id you stored.
102
+ *
103
+ * **This is the read to make when you kept a booking id on a record of your
104
+ * own** - a meeting on a deal, an appointment on a case. `listBookings`
105
+ * cannot serve it: that is a window over `startAt`, and the `startAt` you
106
+ * cached is exactly what is wrong once someone has moved the appointment on
107
+ * the workspace's own calendar, so the window you build from it comes back
108
+ * without the row.
109
+ *
110
+ * A retired booking still resolves. `status: "rescheduled"` answers with
111
+ * `rescheduledToId`, so an id stored months ago leads to the appointment
112
+ * that exists today; `cancelled` answers with its reason. `not_found` means
113
+ * this workspace never had that booking.
114
+ */
115
+ getBooking(input: ActingAsOption & {
116
+ workspaceId: string;
117
+ bookingId: string;
118
+ }): Promise<Booking>;
100
119
  /** One page of the workspace's diary. */
101
120
  listBookings(input: ListBookingsInput): Promise<ListBookingsPage>;
102
121
  /**
@@ -775,7 +775,7 @@ export interface EmitEventsResponse {
775
775
  * `createConnectWebhookHandler` simply ignores anything you have not
776
776
  * registered a handler for.
777
777
  */
778
- export type ConnectWebhookEventType = "workspace.deletion_scheduled" | "workspace.deletion_cancelled" | "workspace.deleted" | "subscription.cancelled";
778
+ export type ConnectWebhookEventType = "workspace.deletion_scheduled" | "workspace.deletion_cancelled" | "workspace.deleted" | "subscription.cancelled" | "call.completed" | "call.missed" | "booking.confirmed" | "booking.cancelled" | "booking.rescheduled" | "contact.created" | "contact.updated";
779
779
  /**
780
780
  * A workspace's deletion has been confirmed and the grace window is running.
781
781
  *
@@ -815,12 +815,197 @@ export interface SubscriptionCancelledEvent {
815
815
  subscriptionId: string;
816
816
  applicationId: string;
817
817
  }
818
+ /** Which way a call went. */
819
+ export type ConnectCallDirection = "inbound" | "outbound";
820
+ /**
821
+ * A call ended.
822
+ *
823
+ * The event an external system of record is built on: it is what turns a
824
+ * conversation that happened in Connect's telephony into a row in your own
825
+ * timeline with nobody retyping it. Fires for every completed call, whether an
826
+ * agent answered it or a person did.
827
+ */
828
+ export interface CallCompletedEvent {
829
+ workspaceId: string;
830
+ callId: string;
831
+ channelId: string | null;
832
+ /**
833
+ * E.164, and the only identity a call event carries.
834
+ *
835
+ * There is deliberately no `contactId`: Connect resolves a caller to a
836
+ * contact by number, and at the moment a call ends it may not have done so
837
+ * at all - the field would be `null` on most deliveries, which is a field
838
+ * that silently does nothing. Match on this instead. It is the same
839
+ * canonical form `contacts.lookupByPhone` takes, so resolving it is one call
840
+ * if you want the contact.
841
+ */
842
+ contactPhone: string | null;
843
+ direction: ConnectCallDirection;
844
+ /** How it ended, as Connect's telephony reported it - `"completed"`, `"transferred"`, … */
845
+ outcome: string;
846
+ durationSeconds: number;
847
+ /**
848
+ * What the agent's skills captured during the conversation, keyed by the
849
+ * capture fields the agent declares.
850
+ *
851
+ * Shapeless on purpose - the keys and the value types are whatever that
852
+ * agent's `extractionSchema` declares, which Connect does not constrain.
853
+ * `{}` on most calls, since an unanswered or human-answered call extracts
854
+ * nothing, so treat every key as optional rather than checking the object
855
+ * for presence.
856
+ */
857
+ extraction: Record<string, unknown>;
858
+ /** ISO-8601. */
859
+ occurredAt: string;
860
+ }
861
+ /**
862
+ * A call reached a channel and nothing answered it.
863
+ *
864
+ * Its own event rather than an `outcome` on the one above, because the action
865
+ * differs in kind: a completed call is something to file, a missed one is
866
+ * somebody to ring back. Treating this as a lead is the right thing to do
867
+ * with it.
868
+ */
869
+ export interface CallMissedEvent {
870
+ workspaceId: string;
871
+ callId: string;
872
+ channelId: string | null;
873
+ /** E.164, and the only identity this carries - see `CallCompletedEvent.contactPhone`. */
874
+ contactPhone: string | null;
875
+ direction: ConnectCallDirection;
876
+ /** ISO-8601. */
877
+ occurredAt: string;
878
+ }
879
+ /**
880
+ * An appointment was booked and is holding its slot.
881
+ *
882
+ * Raised however it was booked - the customer's portal, the public booking
883
+ * page, a voice agent mid-call, or your own API call - so you get one shape
884
+ * for all four and never have to reconcile "did we create this, or did they?".
885
+ * Dedupe on `bookingId`, not on the envelope alone.
886
+ */
887
+ export interface BookingConfirmedEvent {
888
+ workspaceId: string;
889
+ bookingId: string;
890
+ eventTypeId: string;
891
+ eventTypeName: string;
892
+ /** ISO-8601, UTC. Render it in `timezone`, which is the workspace's, not the viewer's. */
893
+ startAt: string;
894
+ endAt: string;
895
+ /** IANA zone the appointment was booked in. */
896
+ timezone: string;
897
+ attendeeName: string;
898
+ attendeeEmail: string | null;
899
+ /** E.164. */
900
+ attendeePhone: string | null;
901
+ contactId: string | null;
902
+ /** Connect user ids of the hosts. */
903
+ hostMemberIds: string[];
904
+ /** ISO-8601. */
905
+ occurredAt: string;
906
+ }
907
+ /** An appointment was cancelled. The slot is free and any prep against it is void. */
908
+ export interface BookingCancelledEvent {
909
+ workspaceId: string;
910
+ bookingId: string;
911
+ eventTypeId: string;
912
+ eventTypeName: string;
913
+ /** ISO-8601. The time it *was* going to happen. */
914
+ startAt: string;
915
+ attendeeName: string;
916
+ contactId: string | null;
917
+ /** ISO-8601. */
918
+ occurredAt: string;
919
+ }
920
+ /**
921
+ * An appointment moved.
922
+ *
923
+ * Connect retires the old booking and writes a new one rather than mutating
924
+ * it, so this carries **both ids**: `bookingId` is the row that now exists and
925
+ * `previousBookingId` is the one you are already holding. Reconcile on the
926
+ * previous id or you will end up with two appointments.
927
+ */
928
+ export interface BookingRescheduledEvent {
929
+ workspaceId: string;
930
+ bookingId: string;
931
+ eventTypeId: string;
932
+ eventTypeName: string;
933
+ /** ISO-8601, the new time. */
934
+ startAt: string;
935
+ endAt: string;
936
+ previousBookingId: string;
937
+ /** ISO-8601, the time it was before. */
938
+ previousStartAt: string;
939
+ timezone: string;
940
+ attendeeName: string;
941
+ contactId: string | null;
942
+ /** ISO-8601. */
943
+ occurredAt: string;
944
+ }
945
+ /**
946
+ * How Connect came to know a contact.
947
+ *
948
+ * `extraction` is the interesting one: an agent's skill captured them from
949
+ * what was said, with nobody typing anything. `call` means the number was seen
950
+ * on a call. `manual` and `import` are the two a human stands behind, and
951
+ * Connect lets only those overwrite a field that already has a value.
952
+ */
953
+ export type ConnectContactSource = "call" | "manual" | "import" | "api" | "extraction";
954
+ /**
955
+ * Connect learned about a person.
956
+ *
957
+ * The event with no equivalent anywhere else in this API: most often an agent
958
+ * captured them mid-call through the `contact-capture` skill, so the contact
959
+ * exists in Connect and your product has never heard of them. Without this the
960
+ * capture is invisible where the customer actually works.
961
+ */
962
+ export interface ContactCreatedEvent {
963
+ workspaceId: string;
964
+ contactId: string;
965
+ name: string | null;
966
+ /** E.164. */
967
+ phone: string | null;
968
+ email: string | null;
969
+ company: string | null;
970
+ source: ConnectContactSource;
971
+ /** ISO-8601. */
972
+ occurredAt: string;
973
+ }
974
+ /**
975
+ * A known contact's details changed.
976
+ *
977
+ * `changed` names the fields that actually moved, so you can apply a patch
978
+ * rather than overwriting fields your own users may have curated - a customer
979
+ * who corrected a company name in your product should not have it replaced by
980
+ * what an agent heard over a bad line.
981
+ */
982
+ export interface ContactUpdatedEvent {
983
+ workspaceId: string;
984
+ contactId: string;
985
+ name: string | null;
986
+ /** E.164. */
987
+ phone: string | null;
988
+ email: string | null;
989
+ company: string | null;
990
+ /** The field names that changed on this update. */
991
+ changed: string[];
992
+ source: ConnectContactSource;
993
+ /** ISO-8601. */
994
+ occurredAt: string;
995
+ }
818
996
  /** Maps each event type to the shape of its `data`. */
819
997
  export interface ConnectWebhookEventMap {
820
998
  "workspace.deletion_scheduled": WorkspaceDeletionScheduledEvent;
821
999
  "workspace.deletion_cancelled": WorkspaceDeletionCancelledEvent;
822
1000
  "workspace.deleted": WorkspaceDeletedEvent;
823
1001
  "subscription.cancelled": SubscriptionCancelledEvent;
1002
+ "call.completed": CallCompletedEvent;
1003
+ "call.missed": CallMissedEvent;
1004
+ "booking.confirmed": BookingConfirmedEvent;
1005
+ "booking.cancelled": BookingCancelledEvent;
1006
+ "booking.rescheduled": BookingRescheduledEvent;
1007
+ "contact.created": ContactCreatedEvent;
1008
+ "contact.updated": ContactUpdatedEvent;
824
1009
  }
825
1010
  /** The envelope, as it arrives on the wire. */
826
1011
  export interface ConnectWebhookEnvelope<T extends ConnectWebhookEventType = ConnectWebhookEventType> {
@@ -1283,6 +1468,18 @@ export type BookingSource = "agent-call" | "portal" | "api";
1283
1468
  export interface Booking {
1284
1469
  id: string;
1285
1470
  eventTypeId: string;
1471
+ /**
1472
+ * The workspace members committed to this appointment.
1473
+ *
1474
+ * These are `WorkspaceMember.id` values - the same ids `workspaces.listMembers`
1475
+ * returns and `listBookings`' `hostMemberId` filter takes, so an owner column
1476
+ * is a lookup against a roster you already have rather than another round trip.
1477
+ *
1478
+ * Resolved when the slot was held and frozen there: a round-robin event type
1479
+ * picks one host, a collective one commits the whole group, and re-pointing
1480
+ * the event type afterwards does not move an appointment already made.
1481
+ */
1482
+ hostMemberIds: string[];
1286
1483
  status: BookingStatus;
1287
1484
  /** ISO-8601 instants. */
1288
1485
  startAt: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmoops/connect-sdk",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Official server-side SDK for the CosmoOps Connect platform API - Login with Connect, entitlements, billing and metering.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",