@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 +121 -0
- package/README.md +21 -1
- package/dist/cjs/client/components/app-shell.js +10 -1
- package/dist/cjs/client/components/sidebar.js +17 -1
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +15 -0
- package/dist/cjs/server/resources/scheduling.js +23 -0
- package/dist/esm/client/components/app-shell.js +10 -1
- package/dist/esm/client/components/sidebar.js +17 -1
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +15 -0
- package/dist/esm/server/resources/scheduling.js +23 -0
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/resources/scheduling.d.ts +19 -0
- package/dist/types/types.d.ts +198 -1
- package/package.json +1 -1
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-
|
|
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 (
|
|
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,
|
|
@@ -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-
|
|
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 (
|
|
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,
|
|
@@ -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, {
|
|
@@ -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
|
/**
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
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",
|