@cosmoops/connect-sdk 0.38.0 → 0.41.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 +86 -0
- package/dist/cjs/client/components/availability-form.js +52 -24
- package/dist/cjs/client/components/call-button.js +398 -0
- package/dist/cjs/client/components/create-workspace-form.js +66 -0
- package/dist/cjs/client/components/event-type-form.js +99 -25
- package/dist/cjs/client/components/workspace-switcher.js +42 -9
- package/dist/cjs/client/index.js +17 -2
- package/dist/cjs/client/portal-links.js +1 -1
- package/dist/cjs/client/use-scheduling.js +37 -0
- package/dist/cjs/client/use-workspaces.js +41 -0
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +176 -2
- package/dist/cjs/server/resources/calls.js +58 -0
- package/dist/cjs/server/resources/scheduling.js +67 -0
- package/dist/cjs/server/resources/workspaces.js +30 -11
- package/dist/esm/client/components/availability-form.js +53 -25
- package/dist/esm/client/components/call-button.js +358 -0
- package/dist/esm/client/components/create-workspace-form.js +63 -0
- package/dist/esm/client/components/event-type-form.js +100 -26
- package/dist/esm/client/components/workspace-switcher.js +42 -9
- package/dist/esm/client/index.js +8 -2
- package/dist/esm/client/portal-links.js +1 -1
- package/dist/esm/client/use-scheduling.js +34 -0
- package/dist/esm/client/use-workspaces.js +38 -0
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +176 -2
- package/dist/esm/server/resources/calls.js +58 -0
- package/dist/esm/server/resources/scheduling.js +67 -0
- package/dist/esm/server/resources/workspaces.js +30 -11
- package/dist/types/client/components/availability-form.d.ts +23 -12
- package/dist/types/client/components/call-button.d.ts +132 -0
- package/dist/types/client/components/create-workspace-form.d.ts +32 -0
- package/dist/types/client/components/event-type-form.d.ts +30 -8
- package/dist/types/client/components/workspace-switcher.d.ts +9 -9
- package/dist/types/client/index.d.ts +7 -1
- package/dist/types/client/portal-links.d.ts +1 -1
- package/dist/types/client/use-scheduling.d.ts +46 -2
- package/dist/types/client/use-workspaces.d.ts +27 -0
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/resources/calls.d.ts +32 -1
- package/dist/types/server/resources/scheduling.d.ts +66 -2
- package/dist/types/server/resources/workspaces.d.ts +28 -0
- package/dist/types/types.d.ts +143 -1
- package/package.json +4 -1
|
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|
|
4
4
|
import { Button, Dropdown, ListBox, Separator } from "@heroui/react";
|
|
5
5
|
import { ChevronsUpDown, Plus } from "lucide-react";
|
|
6
6
|
import { useConnect, useConnectUser, useConnectWorkspaces } from "../provider.js";
|
|
7
|
+
import { ConnectCreateWorkspaceModal } from "./create-workspace-form.js";
|
|
7
8
|
import { m } from "./motion.js";
|
|
8
9
|
import { SidebarLabel, sidebarRowClass, useConnectShell } from "./shell-context.js";
|
|
9
10
|
/** `Acme Logistics` -> `AL`. The collapsed rail's whole label for a workspace. */
|
|
@@ -11,9 +12,6 @@ function workspaceInitials(name) {
|
|
|
11
12
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
12
13
|
return (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
|
|
13
14
|
}
|
|
14
|
-
function openExternal(url) {
|
|
15
|
-
window.open(url, "_blank", "noopener,noreferrer");
|
|
16
|
-
}
|
|
17
15
|
/**
|
|
18
16
|
* Switches which workspace this application is looking at.
|
|
19
17
|
*
|
|
@@ -28,9 +26,9 @@ function openExternal(url) {
|
|
|
28
26
|
* a workspace is *created*, so hiding it at one workspace would guarantee
|
|
29
27
|
* nobody ever reached two. `alwaysShow` is the escape hatch for a partner
|
|
30
28
|
* application that wants that same discoverability without becoming Connect:
|
|
31
|
-
* it keeps the dropdown open below two workspaces and adds a row that
|
|
32
|
-
*
|
|
33
|
-
* that
|
|
29
|
+
* it keeps the dropdown open below two workspaces and adds a row that opens
|
|
30
|
+
* `<ConnectCreateWorkspaceModal>` inline, auto-switching into the result when
|
|
31
|
+
* that is possible - see `handleCreated` below.
|
|
34
32
|
*
|
|
35
33
|
* **Authorization is the data, not a prop.** The list comes from the caller's
|
|
36
34
|
* own session (`GET /workspaces` takes no user id), so a workspace the user
|
|
@@ -50,12 +48,13 @@ function openExternal(url) {
|
|
|
50
48
|
* `onSwitched` defaults to.
|
|
51
49
|
*/
|
|
52
50
|
export function ConnectWorkspaceSwitcher({ onSwitched, row = true, className, alwaysShow = false, }) {
|
|
53
|
-
const { mutateResource
|
|
51
|
+
const { mutateResource } = useConnect();
|
|
54
52
|
const { collapsed } = useConnectShell();
|
|
55
53
|
const user = useConnectUser();
|
|
56
|
-
const { data, isLoading } = useConnectWorkspaces();
|
|
54
|
+
const { data, isLoading, refetch } = useConnectWorkspaces();
|
|
57
55
|
const [switching, setSwitching] = useState(null);
|
|
58
56
|
const [error, setError] = useState(null);
|
|
57
|
+
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
59
58
|
const workspaces = data?.workspaces ?? [];
|
|
60
59
|
// Nothing loaded yet. Deliberately renders `null` rather than a skeleton:
|
|
61
60
|
// this sits in persistent chrome, and a placeholder that resolves to
|
|
@@ -90,11 +89,45 @@ export function ConnectWorkspaceSwitcher({ onSwitched, row = true, className, al
|
|
|
90
89
|
setSwitching(null);
|
|
91
90
|
}
|
|
92
91
|
};
|
|
92
|
+
/**
|
|
93
|
+
* After a successful create: bring the new row into this list (it is
|
|
94
|
+
* created, not switched to - see `ConnectCreateWorkspaceModal`'s doc
|
|
95
|
+
* comment), then move into it when that is actually possible.
|
|
96
|
+
*
|
|
97
|
+
* Deliberately **not** `handleSwitch` - that function's `selectable()`
|
|
98
|
+
* guard requires `subscribed`, which a workspace this young never is, so
|
|
99
|
+
* calling it here would silently do nothing. `SwitchWorkspaceUseCase`
|
|
100
|
+
* itself has no subscription gate, only membership/MFA/audience, so this
|
|
101
|
+
* calls the bridge directly and only withholds on `inAudience` - a
|
|
102
|
+
* workspace of a type this application is not sold to, which `switch`
|
|
103
|
+
* really does refuse.
|
|
104
|
+
*/
|
|
105
|
+
const handleCreated = async (workspace) => {
|
|
106
|
+
setIsCreateOpen(false);
|
|
107
|
+
refetch();
|
|
108
|
+
if (!workspace.inAudience)
|
|
109
|
+
return;
|
|
110
|
+
setSwitching(workspace.workspaceId);
|
|
111
|
+
setError(null);
|
|
112
|
+
try {
|
|
113
|
+
await mutateResource("/workspaces/switch", { workspaceId: workspace.workspaceId });
|
|
114
|
+
if (onSwitched) {
|
|
115
|
+
onSwitched(workspace);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
window.location.reload();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (cause) {
|
|
122
|
+
setError(cause instanceof Error ? cause.message : "Workspace created, but could not switch to it");
|
|
123
|
+
setSwitching(null);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
93
126
|
return (_jsxs("div", { className: row ? "" : className, children: [_jsxs(Dropdown, { children: [row ? (
|
|
94
127
|
// A 32px tile rather than a 20px glyph, so the initials land on the
|
|
95
128
|
// same optical centre the avatar below them uses - see
|
|
96
129
|
// `SIDEBAR_ROW_TILE`. Collapsed, this tile *is* the switcher.
|
|
97
|
-
_jsxs(Button, { variant: "tertiary", "aria-label": `Workspace: ${label}. Switch workspace`, className: sidebarRowClass({ tile: true, className }), children: [_jsx("span", { className: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-xs font-semibold text-accent", children: workspaceInitials(label) }), _jsx(SidebarLabel, { className: "text-sm font-medium text-foreground", children: label }), _jsx(m.span, { className: "shrink-0 text-muted", animate: { opacity: collapsed ? 0 : 1 }, transition: collapsed ? { duration: 0.1 } : { duration: 0.18, delay: 0.1 }, "aria-hidden": true, children: _jsx(ChevronsUpDown, { className: "size-4" }) })] })) : (_jsxs(Button, { variant: "secondary", size: "sm", className: "w-full justify-between", children: [_jsx("span", { className: "truncate", children: label }), _jsx(ChevronsUpDown, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" })] })), _jsx(Dropdown.Popover, { children: _jsxs(ListBox, { children: [workspaces.map((workspace) => (_jsxs(ListBox.Item, { id: workspace.workspaceId, textValue: workspace.name, isDisabled: !selectable(workspace) || switching !== null, onAction: () => void handleSwitch(workspace), children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-sm", children: workspace.name }), selectable(workspace) ? null : (_jsx("p", { className: "truncate text-xs text-muted", children: reasonUnavailable(workspace) }))] }), _jsx(ListBox.ItemIndicator, {})] }, workspace.workspaceId))), alwaysShow ? (_jsxs(_Fragment, { children: [workspaces.length > 0 && _jsx(Separator, {}), _jsxs(ListBox.Item, { id: "__create_workspace", textValue: "Create workspace", onAction: () =>
|
|
130
|
+
_jsxs(Button, { variant: "tertiary", "aria-label": `Workspace: ${label}. Switch workspace`, className: sidebarRowClass({ tile: true, className }), children: [_jsx("span", { className: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-xs font-semibold text-accent", children: workspaceInitials(label) }), _jsx(SidebarLabel, { className: "text-sm font-medium text-foreground", children: label }), _jsx(m.span, { className: "shrink-0 text-muted", animate: { opacity: collapsed ? 0 : 1 }, transition: collapsed ? { duration: 0.1 } : { duration: 0.18, delay: 0.1 }, "aria-hidden": true, children: _jsx(ChevronsUpDown, { className: "size-4" }) })] })) : (_jsxs(Button, { variant: "secondary", size: "sm", className: "w-full justify-between", children: [_jsx("span", { className: "truncate", children: label }), _jsx(ChevronsUpDown, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" })] })), _jsx(Dropdown.Popover, { children: _jsxs(ListBox, { children: [workspaces.map((workspace) => (_jsxs(ListBox.Item, { id: workspace.workspaceId, textValue: workspace.name, isDisabled: !selectable(workspace) || switching !== null, onAction: () => void handleSwitch(workspace), children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-sm", children: workspace.name }), selectable(workspace) ? null : (_jsx("p", { className: "truncate text-xs text-muted", children: reasonUnavailable(workspace) }))] }), _jsx(ListBox.ItemIndicator, {})] }, workspace.workspaceId))), alwaysShow ? (_jsxs(_Fragment, { children: [workspaces.length > 0 && _jsx(Separator, {}), _jsxs(ListBox.Item, { id: "__create_workspace", textValue: "Create workspace", onAction: () => setIsCreateOpen(true), children: [_jsx(Plus, { className: "size-4 shrink-0 text-muted", "aria-hidden": true }), _jsx("p", { className: "truncate text-sm", children: "Create workspace" })] })] })) : null] }) })] }), error && !collapsed ? _jsx("p", { className: "mt-1 px-2 text-xs text-danger", children: error }) : null, alwaysShow ? (_jsx(ConnectCreateWorkspaceModal, { isOpen: isCreateOpen, onOpenChange: setIsCreateOpen, onCreated: (workspace) => void handleCreated(workspace) })) : null] }));
|
|
98
131
|
}
|
|
99
132
|
/** Belongs to the user, and usable here. The same two facts `switch` enforces. */
|
|
100
133
|
function selectable(workspace) {
|
package/dist/esm/client/index.js
CHANGED
|
@@ -31,7 +31,7 @@ ConnectUserMenu, ConnectManageSubscriptionButton, ConnectInvoicesButton, Connect
|
|
|
31
31
|
// Built, compiled into `dist/`, and reachable from no entry point until now -
|
|
32
32
|
// `ConnectUserMenu` was the only thing that could render it.
|
|
33
33
|
export { ConnectAccountSessions } from "./components/account-sessions.js";
|
|
34
|
-
// A link to *any* Connect portal page that offers a "Back to <your app>" way
|
|
34
|
+
// A link to *any* Connect portal page that offers a `"Back to <your app>"` way
|
|
35
35
|
// home - what `ConnectProfileButton` builds for `/profile` internally, made
|
|
36
36
|
// reusable for a host linking out to a different Connect page (e.g.
|
|
37
37
|
// `/calendar/settings`) on its own.
|
|
@@ -147,6 +147,12 @@ isFreePlan,
|
|
|
147
147
|
groupPlansByTier, resolvePlanGroup, } from "./components/plan-pricing.js";
|
|
148
148
|
export { ConnectWorkspaceSwitcher } from "./components/workspace-switcher.js";
|
|
149
149
|
export { useConnectWorkspaces, useConnectWorkspaceMembers } from "./provider.js";
|
|
150
|
+
// The write half of the workspace switcher - opening another workspace under
|
|
151
|
+
// the signed-in user's own tenant, from inside a host application rather
|
|
152
|
+
// than a link-out to Connect's own "Create workspace" button.
|
|
153
|
+
export { ConnectCreateWorkspaceModal } from "./components/create-workspace-form.js";
|
|
154
|
+
export { useCreateWorkspace } from "./use-workspaces.js";
|
|
155
|
+
export { ConnectCallProvider, ConnectCallButton, ConnectCallingStatus, useConnectCall, } from "./components/call-button.js";
|
|
150
156
|
export { ConnectBookingCalendar } from "./components/booking-calendar.js";
|
|
151
157
|
export { ConnectBookingDialog, ConnectBookingDetail } from "./components/booking-dialogs.js";
|
|
152
158
|
// The write half of scheduling - a workspace's own first-time setup, not a
|
|
@@ -155,5 +161,5 @@ export { ConnectBookingDialog, ConnectBookingDetail } from "./components/booking
|
|
|
155
161
|
// step instead of a link-out to Connect's own Calendar settings.
|
|
156
162
|
export { ConnectAvailabilityFormModal } from "./components/availability-form.js";
|
|
157
163
|
export { ConnectEventTypeFormModal } from "./components/event-type-form.js";
|
|
158
|
-
export { useBookableEventTypes, useAvailabilitySchedules, useCreateAvailabilitySchedule, useCreateEventType, useAvailableSlots, useBookings, useBookingActions, } from "./use-scheduling.js";
|
|
164
|
+
export { useBookableEventTypes, useAvailabilitySchedules, useCreateAvailabilitySchedule, useUpdateAvailabilitySchedule, useManageableEventTypes, useCreateEventType, useUpdateEventType, useAvailableSlots, useBookings, useBookingActions, } from "./use-scheduling.js";
|
|
159
165
|
export { layoutBookings, dayColumns, zonedInstant, } from "./components/booking-grid.js";
|
|
@@ -6,7 +6,7 @@ import { useConnect } from "./provider.js";
|
|
|
6
6
|
* `connectPortalUrl(appUrl, clientId, "/calendar/settings")`, and so on.
|
|
7
7
|
*
|
|
8
8
|
* Connect answers any of these paths for a signed-in user on the bare URL -
|
|
9
|
-
* these two params only add the "Back to <your app>" banner
|
|
9
|
+
* these two params only add the `"Back to <your app>"` banner
|
|
10
10
|
* (`ApplicationReturnBar` on Connect's side). It validates `return_to`
|
|
11
11
|
* against this `client_id`'s registered redirect origins and silently drops
|
|
12
12
|
* the banner if they don't match, so a wrong or stale `appUrl`/`clientId`
|
|
@@ -25,6 +25,16 @@ export function useAvailabilitySchedules() {
|
|
|
25
25
|
const { data, isLoading, error } = useConnectResource("/scheduling/schedules");
|
|
26
26
|
return { schedules: data?.schedules ?? [], isLoading, error };
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* The richer read `ConnectEventTypeFormModal`'s edit mode needs - everything
|
|
30
|
+
* `useBookableEventTypes`'s booking catalogue deliberately withholds. See
|
|
31
|
+
* the bridge route's own doc comment for why this is a separate read rather
|
|
32
|
+
* than a wider response on that one. Includes inactive rows.
|
|
33
|
+
*/
|
|
34
|
+
export function useManageableEventTypes() {
|
|
35
|
+
const { data, isLoading, error, refetch } = useConnectResource("/scheduling/event-types/manage");
|
|
36
|
+
return { eventTypes: data?.eventTypes ?? [], isLoading, error, refetch };
|
|
37
|
+
}
|
|
28
38
|
/**
|
|
29
39
|
* Gives a workspace its first working hours.
|
|
30
40
|
*
|
|
@@ -41,6 +51,16 @@ export function useCreateAvailabilitySchedule() {
|
|
|
41
51
|
createSchedule: (input) => run("/scheduling/schedules", input),
|
|
42
52
|
};
|
|
43
53
|
}
|
|
54
|
+
/** Edits an existing availability schedule. Every field optional - send only what changed. */
|
|
55
|
+
export function useUpdateAvailabilitySchedule() {
|
|
56
|
+
const { run, isPending, error, clearError } = useResourceAction();
|
|
57
|
+
return {
|
|
58
|
+
isPending,
|
|
59
|
+
error,
|
|
60
|
+
clearError,
|
|
61
|
+
updateSchedule: (input) => run("/scheduling/schedules/update", input),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
44
64
|
/**
|
|
45
65
|
* Creates the first thing a workspace can be booked for.
|
|
46
66
|
*
|
|
@@ -57,6 +77,20 @@ export function useCreateEventType() {
|
|
|
57
77
|
createEventType: (input) => run("/scheduling/event-types", input),
|
|
58
78
|
};
|
|
59
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Edits an existing appointment type. Every field optional - send only what
|
|
82
|
+
* changed. `hostMemberId` omitted leaves the host untouched, including a
|
|
83
|
+
* *group* host this surface cannot represent.
|
|
84
|
+
*/
|
|
85
|
+
export function useUpdateEventType() {
|
|
86
|
+
const { run, isPending, error, clearError } = useResourceAction();
|
|
87
|
+
return {
|
|
88
|
+
isPending,
|
|
89
|
+
error,
|
|
90
|
+
clearError,
|
|
91
|
+
updateEventType: (input) => run("/scheduling/event-types/update", input),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
60
94
|
/**
|
|
61
95
|
* Free times for one appointment type.
|
|
62
96
|
*
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useState } from "react";
|
|
3
|
+
import { useConnect } from "./provider.js";
|
|
4
|
+
/**
|
|
5
|
+
* Opens another workspace under the tenant the signed-in user already owns.
|
|
6
|
+
*
|
|
7
|
+
* **Not optimistic**, same posture as every write in `use-scheduling.ts`:
|
|
8
|
+
* whether the tenant's plan still has room for another workspace is a
|
|
9
|
+
* question only the server can answer, and a modal that closes before that
|
|
10
|
+
* answer comes back is the lie a two-phase write exists to avoid.
|
|
11
|
+
*
|
|
12
|
+
* The result is a `UserWorkspace` row with `isActive`/`subscribed` both
|
|
13
|
+
* `false` - creating does not switch the session to it, and the workspace has
|
|
14
|
+
* no subscription yet. Pair with `<ConnectWorkspaceSwitcher>`'s own
|
|
15
|
+
* `mutateResource("/workspaces/switch", ...)` call to move into it, guarded
|
|
16
|
+
* on `inAudience` the same way that component's `selectable()` already is.
|
|
17
|
+
*/
|
|
18
|
+
export function useCreateWorkspace() {
|
|
19
|
+
const { mutateResource } = useConnect();
|
|
20
|
+
const [isPending, setPending] = useState(false);
|
|
21
|
+
const [error, setError] = useState(null);
|
|
22
|
+
const createWorkspace = useCallback(async (input) => {
|
|
23
|
+
setPending(true);
|
|
24
|
+
setError(null);
|
|
25
|
+
try {
|
|
26
|
+
const result = await mutateResource("/workspaces", input);
|
|
27
|
+
return result.workspace;
|
|
28
|
+
}
|
|
29
|
+
catch (cause) {
|
|
30
|
+
setError(cause instanceof Error ? cause.message : "Could not create that workspace.");
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
setPending(false);
|
|
35
|
+
}
|
|
36
|
+
}, [mutateResource]);
|
|
37
|
+
return { isPending, error, clearError: () => setError(null), createWorkspace };
|
|
38
|
+
}
|
|
@@ -738,8 +738,8 @@ export function createConnectHandler(options) {
|
|
|
738
738
|
*/
|
|
739
739
|
"STORE": async (request) => storeRoute(request),
|
|
740
740
|
/**
|
|
741
|
-
* The workspace switcher's
|
|
742
|
-
* session token.
|
|
741
|
+
* The workspace switcher's reads and writes, forwarded with the caller's
|
|
742
|
+
* own session token.
|
|
743
743
|
*
|
|
744
744
|
* `POST /workspaces/switch` is the only bridge route that *changes the
|
|
745
745
|
* caller's session*, so it writes the new token back through the same
|
|
@@ -751,6 +751,14 @@ export function createConnectHandler(options) {
|
|
|
751
751
|
* A partner with no `session.write` cannot re-persist a rotated token, so
|
|
752
752
|
* switching is simply absent for them rather than half-working - the same
|
|
753
753
|
* treatment `/signin` and `/callback` already get.
|
|
754
|
+
*
|
|
755
|
+
* `POST /workspaces` opens another workspace under the caller's own
|
|
756
|
+
* tenant and does not rotate anything - it does not switch the session,
|
|
757
|
+
* same as Connect's own "Create workspace" button. Every guard
|
|
758
|
+
* (ownership, the plan's workspace allowance, the business-email-domain
|
|
759
|
+
* rule) lives server-side in `CreateWorkspaceUseCase`; this route is a
|
|
760
|
+
* plain forward with the caller's session token, exactly like the reads
|
|
761
|
+
* beside it.
|
|
754
762
|
*/
|
|
755
763
|
/*
|
|
756
764
|
* Push notifications - the browser half, and deliberately only that half.
|
|
@@ -807,6 +815,20 @@ export function createConnectHandler(options) {
|
|
|
807
815
|
return json(await client.push.revokeDevice(body.id, { asUser: token }));
|
|
808
816
|
},
|
|
809
817
|
"GET /workspaces": async (request) => json({ workspaces: await client.workspaces.list({ asUser: await requireToken(request) }) }),
|
|
818
|
+
"POST /workspaces": async (request) => {
|
|
819
|
+
const token = await requireToken(request);
|
|
820
|
+
const body = (await request.json().catch(() => null));
|
|
821
|
+
if (typeof body?.name !== "string" || (body.type !== "individual" && body.type !== "business")) {
|
|
822
|
+
throw new ConnectError({
|
|
823
|
+
status: 400,
|
|
824
|
+
code: "validation_error",
|
|
825
|
+
message: "name and type are required",
|
|
826
|
+
endpoint: "POST /workspaces",
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
const workspace = await client.workspaces.create({ name: body.name, type: body.type }, { asUser: token });
|
|
830
|
+
return json({ workspace });
|
|
831
|
+
},
|
|
810
832
|
"GET /workspaces/members": async (request) => json({ members: await client.workspaces.members({ asUser: await requireToken(request) }) }),
|
|
811
833
|
"POST /workspaces/switch": async (request) => {
|
|
812
834
|
const token = await requireToken(request);
|
|
@@ -890,6 +912,58 @@ export function createConnectHandler(options) {
|
|
|
890
912
|
asUser,
|
|
891
913
|
}), 201);
|
|
892
914
|
},
|
|
915
|
+
/*
|
|
916
|
+
* The richer read `ConnectEventTypeFormModal`'s edit mode needs - see
|
|
917
|
+
* `listManageableEventTypes`'s own doc comment for why this is a separate
|
|
918
|
+
* route from the catalogue above rather than a wider response on it.
|
|
919
|
+
*/
|
|
920
|
+
"GET /scheduling/event-types/manage": async (request) => {
|
|
921
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
922
|
+
const asUser = await requireToken(request);
|
|
923
|
+
return json({
|
|
924
|
+
eventTypes: await client.scheduling.listManageableEventTypes(workspaceId, { asUser }),
|
|
925
|
+
});
|
|
926
|
+
},
|
|
927
|
+
/*
|
|
928
|
+
* A fixed path with the id in the body, not `/scheduling/event-types/{id}` -
|
|
929
|
+
* this table is matched by exact string, the same reason the booking
|
|
930
|
+
* actions below take their id this way rather than as a path segment.
|
|
931
|
+
*/
|
|
932
|
+
"POST /scheduling/event-types/update": async (request) => {
|
|
933
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
934
|
+
const asUser = await requireToken(request);
|
|
935
|
+
const body = (await request.json().catch(() => null));
|
|
936
|
+
if (typeof body?.eventTypeId !== "string") {
|
|
937
|
+
throw new ConnectError({
|
|
938
|
+
status: 400,
|
|
939
|
+
code: "validation_error",
|
|
940
|
+
message: "eventTypeId is required",
|
|
941
|
+
endpoint: "/scheduling/event-types/update",
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
return json(await client.scheduling.updateEventType({
|
|
945
|
+
workspaceId,
|
|
946
|
+
eventTypeId: body.eventTypeId,
|
|
947
|
+
name: typeof body.name === "string" ? body.name : undefined,
|
|
948
|
+
description: typeof body.description === "string" || body.description === null
|
|
949
|
+
? body.description
|
|
950
|
+
: undefined,
|
|
951
|
+
durationMinutes: typeof body.durationMinutes === "number" ? body.durationMinutes : undefined,
|
|
952
|
+
bufferBeforeMinutes: typeof body.bufferBeforeMinutes === "number" ? body.bufferBeforeMinutes : undefined,
|
|
953
|
+
bufferAfterMinutes: typeof body.bufferAfterMinutes === "number" ? body.bufferAfterMinutes : undefined,
|
|
954
|
+
hostMemberId: typeof body.hostMemberId === "string" ? body.hostMemberId : undefined,
|
|
955
|
+
scheduleId: typeof body.scheduleId === "string" ? body.scheduleId : undefined,
|
|
956
|
+
location: body.location && typeof body.location.kind === "string"
|
|
957
|
+
? {
|
|
958
|
+
kind: body.location.kind,
|
|
959
|
+
value: typeof body.location.value === "string" ? body.location.value : null,
|
|
960
|
+
}
|
|
961
|
+
: undefined,
|
|
962
|
+
publicBookingEnabled: typeof body.publicBookingEnabled === "boolean" ? body.publicBookingEnabled : undefined,
|
|
963
|
+
active: typeof body.active === "boolean" ? body.active : undefined,
|
|
964
|
+
asUser,
|
|
965
|
+
}));
|
|
966
|
+
},
|
|
893
967
|
/*
|
|
894
968
|
* The working-hours templates an appointment type can point at - read
|
|
895
969
|
* before a setup UI offers to create one, so it can tell "none yet" from
|
|
@@ -928,6 +1002,33 @@ export function createConnectHandler(options) {
|
|
|
928
1002
|
asUser,
|
|
929
1003
|
}), 201);
|
|
930
1004
|
},
|
|
1005
|
+
/*
|
|
1006
|
+
* A fixed path with the id in the body, same reasoning as the event
|
|
1007
|
+
* type's own `update` route above.
|
|
1008
|
+
*/
|
|
1009
|
+
"POST /scheduling/schedules/update": async (request) => {
|
|
1010
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
1011
|
+
const asUser = await requireToken(request);
|
|
1012
|
+
const body = (await request.json().catch(() => null));
|
|
1013
|
+
if (typeof body?.scheduleId !== "string") {
|
|
1014
|
+
throw new ConnectError({
|
|
1015
|
+
status: 400,
|
|
1016
|
+
code: "validation_error",
|
|
1017
|
+
message: "scheduleId is required",
|
|
1018
|
+
endpoint: "/scheduling/schedules/update",
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
return json(await client.scheduling.updateSchedule({
|
|
1022
|
+
workspaceId,
|
|
1023
|
+
scheduleId: body.scheduleId,
|
|
1024
|
+
name: typeof body.name === "string" ? body.name : undefined,
|
|
1025
|
+
timezone: typeof body.timezone === "string" ? body.timezone : undefined,
|
|
1026
|
+
weeklyHours: typeof body.weeklyHours === "object" && body.weeklyHours !== null
|
|
1027
|
+
? body.weeklyHours
|
|
1028
|
+
: undefined,
|
|
1029
|
+
asUser,
|
|
1030
|
+
}));
|
|
1031
|
+
},
|
|
931
1032
|
"GET /scheduling/slots": async (request) => {
|
|
932
1033
|
const { workspaceId } = await requireWorkspace(request);
|
|
933
1034
|
const asUser = await requireToken(request);
|
|
@@ -1109,6 +1210,67 @@ export function createConnectHandler(options) {
|
|
|
1109
1210
|
const items = await client.sessions.list(await requireToken(request));
|
|
1110
1211
|
return json({ items });
|
|
1111
1212
|
},
|
|
1213
|
+
/*
|
|
1214
|
+
* Browser calling - `<ConnectCallButton>`. Three fixed writes and no read:
|
|
1215
|
+
* how a call went is your *server's* question (`client.calls.get`), never
|
|
1216
|
+
* the browser's, so a call log can't be written from what a page claims.
|
|
1217
|
+
*
|
|
1218
|
+
* The workspace and the member both come from the session, as everywhere
|
|
1219
|
+
* here; the browser names only the number.
|
|
1220
|
+
*/
|
|
1221
|
+
/*
|
|
1222
|
+
* The one calling *read*, and it is about the member, not about a call:
|
|
1223
|
+
* can they ring anybody from here. A refusal means they lack "Make calls"
|
|
1224
|
+
* - which is an answer, not a failure, so it is returned as one and the
|
|
1225
|
+
* button simply is not offered.
|
|
1226
|
+
*/
|
|
1227
|
+
"GET /calls/availability": async (request) => {
|
|
1228
|
+
await requireWorkspace(request);
|
|
1229
|
+
const asUser = await requireToken(request);
|
|
1230
|
+
try {
|
|
1231
|
+
return json(await client.calls.getCallingAvailability({ asUser }));
|
|
1232
|
+
}
|
|
1233
|
+
catch (error) {
|
|
1234
|
+
if (error instanceof ConnectError && error.status === 403) {
|
|
1235
|
+
return json(NO_CALLING_PERMISSION);
|
|
1236
|
+
}
|
|
1237
|
+
throw error;
|
|
1238
|
+
}
|
|
1239
|
+
},
|
|
1240
|
+
"POST /calls/browser-token": async (request) => {
|
|
1241
|
+
await requireWorkspace(request);
|
|
1242
|
+
const asUser = await requireToken(request);
|
|
1243
|
+
return json(await client.calls.getBrowserCallingToken({ asUser }));
|
|
1244
|
+
},
|
|
1245
|
+
"POST /calls/start": async (request) => {
|
|
1246
|
+
await requireWorkspace(request);
|
|
1247
|
+
const asUser = await requireToken(request);
|
|
1248
|
+
const body = (await request.json().catch(() => null));
|
|
1249
|
+
if (typeof body?.phone !== "string") {
|
|
1250
|
+
throw new ConnectError({
|
|
1251
|
+
status: 400,
|
|
1252
|
+
code: "validation_error",
|
|
1253
|
+
message: "phone is required",
|
|
1254
|
+
endpoint: "/calls/start",
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
return json(await client.calls.startBrowserCall({ phone: body.phone, asUser }), 201);
|
|
1258
|
+
},
|
|
1259
|
+
"POST /calls/cancel": async (request) => {
|
|
1260
|
+
await requireWorkspace(request);
|
|
1261
|
+
const asUser = await requireToken(request);
|
|
1262
|
+
const body = (await request.json().catch(() => null));
|
|
1263
|
+
if (typeof body?.callId !== "string") {
|
|
1264
|
+
throw new ConnectError({
|
|
1265
|
+
status: 400,
|
|
1266
|
+
code: "validation_error",
|
|
1267
|
+
message: "callId is required",
|
|
1268
|
+
endpoint: "/calls/cancel",
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
await client.calls.cancelBrowserCall({ callId: body.callId, asUser });
|
|
1272
|
+
return json({ cancelled: true });
|
|
1273
|
+
},
|
|
1112
1274
|
"POST /sessions/revoke": async (request) => {
|
|
1113
1275
|
const body = (await request.json().catch(() => null));
|
|
1114
1276
|
if (!body?.sessionId) {
|
|
@@ -1394,6 +1556,18 @@ export function createConnectHandler(options) {
|
|
|
1394
1556
|
}
|
|
1395
1557
|
};
|
|
1396
1558
|
}
|
|
1559
|
+
/** What a member without "Make calls" is told - see `GET /calls/availability`. */
|
|
1560
|
+
const NO_CALLING_PERMISSION = {
|
|
1561
|
+
status: "no_permission",
|
|
1562
|
+
number: null,
|
|
1563
|
+
available: 0,
|
|
1564
|
+
availableMinutes: 0,
|
|
1565
|
+
ratePaisePerMinute: 0,
|
|
1566
|
+
lowBalance: false,
|
|
1567
|
+
canTopUp: false,
|
|
1568
|
+
canManageNumber: false,
|
|
1569
|
+
offer: null,
|
|
1570
|
+
};
|
|
1397
1571
|
function json(body, status = 200) {
|
|
1398
1572
|
return new Response(JSON.stringify(body), {
|
|
1399
1573
|
status,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { request } from "../http.js";
|
|
2
|
+
import { actingAs } from "./acting-as.js";
|
|
2
3
|
/**
|
|
3
4
|
* Every call that arrived on a channel assigned to your application.
|
|
4
5
|
*
|
|
@@ -50,4 +51,61 @@ export class CallsResource {
|
|
|
50
51
|
path: `/calls/${encodeURIComponent(callId)}`,
|
|
51
52
|
});
|
|
52
53
|
}
|
|
54
|
+
/*
|
|
55
|
+
* Browser calling - what `<ConnectCallButton>` runs on, through your bridge.
|
|
56
|
+
*
|
|
57
|
+
* All three need the `calls:place` scope **and** a signed-in member
|
|
58
|
+
* (`asUser`), whose own "Make calls" permission Connect checks. None works
|
|
59
|
+
* server-to-server: a call is placed by a person, from the workspace's own
|
|
60
|
+
* number, and is not recorded. Read how one went with `get(callId)`, which
|
|
61
|
+
* needs `calls:read` - a call your application placed is readable by it.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* Whether the member can call, and if not why - what `<ConnectCallButton>`
|
|
65
|
+
* renders from. Throws `ConnectError` 403 for a member without "Make
|
|
66
|
+
* calls"; the bridge turns that into `status: "no_permission"`.
|
|
67
|
+
*/
|
|
68
|
+
getCallingAvailability(input) {
|
|
69
|
+
return request(this.transport, {
|
|
70
|
+
method: "GET",
|
|
71
|
+
path: "/calls/availability",
|
|
72
|
+
headers: actingAs(input.asUser),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** A carrier token the member's browser signs in to the phone network with. */
|
|
76
|
+
getBrowserCallingToken(input) {
|
|
77
|
+
return request(this.transport, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
path: "/calls/browser-token",
|
|
80
|
+
json: {},
|
|
81
|
+
headers: actingAs(input.asUser),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Approves one call from the member to `phone` (E.164) and records it.
|
|
86
|
+
*
|
|
87
|
+
* Throws `ConnectError` with status `412` when the workspace has no active
|
|
88
|
+
* number, is not allowed to place calls, or the member is already on one -
|
|
89
|
+
* the message is written to be shown to them.
|
|
90
|
+
*/
|
|
91
|
+
startBrowserCall(input) {
|
|
92
|
+
return request(this.transport, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
path: "/calls/start",
|
|
95
|
+
json: { phone: input.phone },
|
|
96
|
+
headers: actingAs(input.asUser),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Closes a call the browser never managed to place - recorded as
|
|
101
|
+
* `unanswered`/`cancelled`. A no-op once the carrier has it.
|
|
102
|
+
*/
|
|
103
|
+
async cancelBrowserCall(input) {
|
|
104
|
+
await request(this.transport, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
path: "/calls/cancel",
|
|
107
|
+
json: { callId: input.callId },
|
|
108
|
+
headers: actingAs(input.asUser),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
53
111
|
}
|
|
@@ -73,6 +73,9 @@ export class SchedulingResource {
|
|
|
73
73
|
* Needs a schedule - create or pick one with `listSchedules`/`createSchedule`
|
|
74
74
|
* first. The slug for a future public booking link is derived from `name`
|
|
75
75
|
* server-side; you cannot choose one.
|
|
76
|
+
*
|
|
77
|
+
* Answers the richer *manageable* shape, not `BookableEventType` - you just
|
|
78
|
+
* created this row and are about to manage it, not book against it.
|
|
76
79
|
*/
|
|
77
80
|
createEventType(input) {
|
|
78
81
|
return request(this.transport, {
|
|
@@ -93,6 +96,70 @@ export class SchedulingResource {
|
|
|
93
96
|
headers: actingAs(input.asUser),
|
|
94
97
|
});
|
|
95
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* The richer read `createEventType`/`updateEventType`'s edit mode needs -
|
|
101
|
+
* everything `listEventTypes`'s booking catalogue deliberately withholds,
|
|
102
|
+
* because this is the opposite audience: a member managing their own
|
|
103
|
+
* calendar, not a widget rendering someone else's. Includes inactive rows,
|
|
104
|
+
* since an edit screen is exactly where one gets reactivated.
|
|
105
|
+
*/
|
|
106
|
+
async listManageableEventTypes(workspaceId, options = {}) {
|
|
107
|
+
const result = await request(this.transport, {
|
|
108
|
+
method: "GET",
|
|
109
|
+
path: "/scheduling/event-types/manage",
|
|
110
|
+
query: { workspaceId },
|
|
111
|
+
headers: actingAs(options.asUser),
|
|
112
|
+
});
|
|
113
|
+
return result.eventTypes ?? [];
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Edits an existing availability schedule.
|
|
117
|
+
*
|
|
118
|
+
* Narrower than what the workspace's own Calendar settings can do, the
|
|
119
|
+
* same way `createSchedule` is: `ownerMemberId` and date overrides stay
|
|
120
|
+
* untouched, since this surface never sets either.
|
|
121
|
+
*/
|
|
122
|
+
updateSchedule(input) {
|
|
123
|
+
return request(this.transport, {
|
|
124
|
+
method: "PUT",
|
|
125
|
+
path: `/scheduling/schedules/${encodeURIComponent(input.scheduleId)}`,
|
|
126
|
+
json: {
|
|
127
|
+
workspaceId: input.workspaceId,
|
|
128
|
+
name: input.name,
|
|
129
|
+
timezone: input.timezone,
|
|
130
|
+
weeklyHours: input.weeklyHours,
|
|
131
|
+
},
|
|
132
|
+
headers: actingAs(input.asUser),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Edits an existing appointment type.
|
|
137
|
+
*
|
|
138
|
+
* `hostMemberId` omitted leaves the host untouched, including a *group*
|
|
139
|
+
* host this surface cannot represent - so an edit that says nothing about
|
|
140
|
+
* the host can never accidentally overwrite a team assignment made in
|
|
141
|
+
* Connect.
|
|
142
|
+
*/
|
|
143
|
+
updateEventType(input) {
|
|
144
|
+
return request(this.transport, {
|
|
145
|
+
method: "PUT",
|
|
146
|
+
path: `/scheduling/event-types/${encodeURIComponent(input.eventTypeId)}`,
|
|
147
|
+
json: {
|
|
148
|
+
workspaceId: input.workspaceId,
|
|
149
|
+
name: input.name,
|
|
150
|
+
description: input.description,
|
|
151
|
+
durationMinutes: input.durationMinutes,
|
|
152
|
+
bufferBeforeMinutes: input.bufferBeforeMinutes,
|
|
153
|
+
bufferAfterMinutes: input.bufferAfterMinutes,
|
|
154
|
+
hostMemberId: input.hostMemberId,
|
|
155
|
+
scheduleId: input.scheduleId,
|
|
156
|
+
location: input.location,
|
|
157
|
+
publicBookingEnabled: input.publicBookingEnabled,
|
|
158
|
+
active: input.active,
|
|
159
|
+
},
|
|
160
|
+
headers: actingAs(input.asUser),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
96
163
|
/**
|
|
97
164
|
* Free times for one appointment type over one window.
|
|
98
165
|
*
|