@cosmoops/connect-sdk 0.37.0 → 0.40.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 +37 -0
- package/dist/cjs/client/components/availability-form.js +103 -0
- package/dist/cjs/client/components/create-workspace-form.js +66 -0
- package/dist/cjs/client/components/event-type-form.js +156 -0
- package/dist/cjs/client/components/user-menu.js +3 -24
- package/dist/cjs/client/components/workspace-switcher.js +42 -9
- package/dist/cjs/client/index.js +31 -3
- package/dist/cjs/client/portal-links.js +38 -0
- package/dist/cjs/client/use-scheduling.js +104 -8
- package/dist/cjs/client/use-workspaces.js +41 -0
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +181 -2
- package/dist/cjs/server/http.js +13 -1
- package/dist/cjs/server/resources/ai.js +6 -0
- package/dist/cjs/server/resources/scheduling.js +129 -0
- package/dist/cjs/server/resources/visitor.js +6 -0
- package/dist/cjs/server/resources/workspaces.js +30 -11
- package/dist/esm/client/components/availability-form.js +100 -0
- package/dist/esm/client/components/create-workspace-form.js +63 -0
- package/dist/esm/client/components/event-type-form.js +153 -0
- package/dist/esm/client/components/user-menu.js +3 -24
- package/dist/esm/client/components/workspace-switcher.js +42 -9
- package/dist/esm/client/index.js +17 -1
- package/dist/esm/client/portal-links.js +34 -0
- package/dist/esm/client/use-scheduling.js +98 -8
- package/dist/esm/client/use-workspaces.js +38 -0
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +181 -2
- package/dist/esm/server/http.js +12 -1
- package/dist/esm/server/resources/ai.js +7 -1
- package/dist/esm/server/resources/scheduling.js +129 -0
- package/dist/esm/server/resources/visitor.js +7 -1
- package/dist/esm/server/resources/workspaces.js +30 -11
- package/dist/types/client/components/availability-form.d.ts +33 -0
- package/dist/types/client/components/create-workspace-form.d.ts +32 -0
- package/dist/types/client/components/event-type-form.d.ts +44 -0
- package/dist/types/client/components/workspace-switcher.d.ts +9 -9
- package/dist/types/client/index.d.ts +10 -1
- package/dist/types/client/portal-links.d.ts +22 -0
- package/dist/types/client/use-scheduling.d.ts +93 -1
- package/dist/types/client/use-workspaces.d.ts +27 -0
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/http.d.ts +35 -0
- package/dist/types/server/resources/scheduling.d.ts +131 -1
- package/dist/types/server/resources/workspaces.d.ts +28 -0
- package/dist/types/types.d.ts +60 -0
- package/package.json +1 -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,6 +31,11 @@ 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
|
|
35
|
+
// home - what `ConnectProfileButton` builds for `/profile` internally, made
|
|
36
|
+
// reusable for a host linking out to a different Connect page (e.g.
|
|
37
|
+
// `/calendar/settings`) on its own.
|
|
38
|
+
export { connectPortalUrl, useConnectPortalUrl } from "./portal-links.js";
|
|
34
39
|
// The frame the rest of these components live inside. Exported next to the
|
|
35
40
|
// pieces it composes rather than in a section of its own - an integration
|
|
36
41
|
// reaching for a sidebar wants to find the launcher and the account menu in
|
|
@@ -142,7 +147,18 @@ isFreePlan,
|
|
|
142
147
|
groupPlansByTier, resolvePlanGroup, } from "./components/plan-pricing.js";
|
|
143
148
|
export { ConnectWorkspaceSwitcher } from "./components/workspace-switcher.js";
|
|
144
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";
|
|
145
155
|
export { ConnectBookingCalendar } from "./components/booking-calendar.js";
|
|
146
156
|
export { ConnectBookingDialog, ConnectBookingDetail } from "./components/booking-dialogs.js";
|
|
147
|
-
|
|
157
|
+
// The write half of scheduling - a workspace's own first-time setup, not a
|
|
158
|
+
// partner integration's booking flow. `ConnectAvailabilityFormModal` and
|
|
159
|
+
// `ConnectEventTypeFormModal` give a host application a real inline setup
|
|
160
|
+
// step instead of a link-out to Connect's own Calendar settings.
|
|
161
|
+
export { ConnectAvailabilityFormModal } from "./components/availability-form.js";
|
|
162
|
+
export { ConnectEventTypeFormModal } from "./components/event-type-form.js";
|
|
163
|
+
export { useBookableEventTypes, useAvailabilitySchedules, useCreateAvailabilitySchedule, useUpdateAvailabilitySchedule, useManageableEventTypes, useCreateEventType, useUpdateEventType, useAvailableSlots, useBookings, useBookingActions, } from "./use-scheduling.js";
|
|
148
164
|
export { layoutBookings, dayColumns, zonedInstant, } from "./components/booking-grid.js";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useConnect } from "./provider.js";
|
|
3
|
+
/**
|
|
4
|
+
* Any Connect portal page, with the context it needs to offer a way back
|
|
5
|
+
* here - `connectPortalUrl(appUrl, clientId, "/profile")`,
|
|
6
|
+
* `connectPortalUrl(appUrl, clientId, "/calendar/settings")`, and so on.
|
|
7
|
+
*
|
|
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
|
|
10
|
+
* (`ApplicationReturnBar` on Connect's side). It validates `return_to`
|
|
11
|
+
* against this `client_id`'s registered redirect origins and silently drops
|
|
12
|
+
* the banner if they don't match, so a wrong or stale `appUrl`/`clientId`
|
|
13
|
+
* pairing degrades to what linking there did before rather than erroring.
|
|
14
|
+
*
|
|
15
|
+
* `window.location.href` rather than a configured home URL: the point is to
|
|
16
|
+
* come back to the page they left, and only the browser knows which one that
|
|
17
|
+
* is.
|
|
18
|
+
*/
|
|
19
|
+
export function connectPortalUrl(appUrl, clientId, path) {
|
|
20
|
+
const url = new URL(path, appUrl);
|
|
21
|
+
url.searchParams.set("client_id", clientId);
|
|
22
|
+
if (typeof window !== "undefined") {
|
|
23
|
+
url.searchParams.set("return_to", window.location.href);
|
|
24
|
+
}
|
|
25
|
+
return url.toString();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* `connectPortalUrl`, with `appUrl`/`clientId` read from the provider instead
|
|
29
|
+
* of threaded through by every caller.
|
|
30
|
+
*/
|
|
31
|
+
export function useConnectPortalUrl(path) {
|
|
32
|
+
const { appUrl, clientId } = useConnect();
|
|
33
|
+
return connectPortalUrl(appUrl, clientId, path);
|
|
34
|
+
}
|
|
@@ -15,6 +15,82 @@ export function useBookableEventTypes() {
|
|
|
15
15
|
const { data, isLoading, error } = useConnectResource("/scheduling/event-types");
|
|
16
16
|
return { eventTypes: data?.eventTypes ?? [], isLoading, error };
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* The working-hours templates an appointment type can point at.
|
|
20
|
+
*
|
|
21
|
+
* Read this before offering a "set up availability" step - a workspace that
|
|
22
|
+
* already has one does not need a second "Working hours" from your UI.
|
|
23
|
+
*/
|
|
24
|
+
export function useAvailabilitySchedules() {
|
|
25
|
+
const { data, isLoading, error } = useConnectResource("/scheduling/schedules");
|
|
26
|
+
return { schedules: data?.schedules ?? [], isLoading, error };
|
|
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
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Gives a workspace its first working hours.
|
|
40
|
+
*
|
|
41
|
+
* **Not optimistic** - same posture as `useBookingActions`: this is one of
|
|
42
|
+
* the two writes a host's first-run setup makes, and a modal that closes on
|
|
43
|
+
* a failed create is worse than one that stays open with the reason shown.
|
|
44
|
+
*/
|
|
45
|
+
export function useCreateAvailabilitySchedule() {
|
|
46
|
+
const { run, isPending, error, clearError } = useResourceAction();
|
|
47
|
+
return {
|
|
48
|
+
isPending,
|
|
49
|
+
error,
|
|
50
|
+
clearError,
|
|
51
|
+
createSchedule: (input) => run("/scheduling/schedules", input),
|
|
52
|
+
};
|
|
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
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Creates the first thing a workspace can be booked for.
|
|
66
|
+
*
|
|
67
|
+
* Host is a specific member, never a team - there is no groups read in this
|
|
68
|
+
* bridge, so a round-robin team host stays a Console-only capability
|
|
69
|
+
* reachable by following "Manage in Connect".
|
|
70
|
+
*/
|
|
71
|
+
export function useCreateEventType() {
|
|
72
|
+
const { run, isPending, error, clearError } = useResourceAction();
|
|
73
|
+
return {
|
|
74
|
+
isPending,
|
|
75
|
+
error,
|
|
76
|
+
clearError,
|
|
77
|
+
createEventType: (input) => run("/scheduling/event-types", input),
|
|
78
|
+
};
|
|
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
|
+
}
|
|
18
94
|
/**
|
|
19
95
|
* Free times for one appointment type.
|
|
20
96
|
*
|
|
@@ -68,15 +144,16 @@ export function useBookings(query) {
|
|
|
68
144
|
};
|
|
69
145
|
}
|
|
70
146
|
/**
|
|
71
|
-
*
|
|
147
|
+
* The isPending/error bookkeeping every write in this file shares, factored
|
|
148
|
+
* out once `useCreateAvailabilitySchedule` and `useCreateEventType` needed
|
|
149
|
+
* the identical shape `useBookingActions` already had.
|
|
72
150
|
*
|
|
73
|
-
* **
|
|
74
|
-
* slot is
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* the two-phase hold exists to prevent.
|
|
151
|
+
* **Never optimistic**, and that is not an oversight for any caller of this:
|
|
152
|
+
* whether a slot is free, or a schedule name collides, is a question only the
|
|
153
|
+
* server can answer. Painting the result before it comes back is exactly the
|
|
154
|
+
* lie a two-phase hold (or a modal that closes early) would tell.
|
|
78
155
|
*/
|
|
79
|
-
|
|
156
|
+
function useResourceAction() {
|
|
80
157
|
const { mutateResource } = useConnect();
|
|
81
158
|
const [isPending, setPending] = useState(false);
|
|
82
159
|
const [error, setError] = useState(null);
|
|
@@ -96,10 +173,23 @@ export function useBookingActions() {
|
|
|
96
173
|
setPending(false);
|
|
97
174
|
}
|
|
98
175
|
}, [mutateResource]);
|
|
176
|
+
return { isPending, error, clearError: () => setError(null), run };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Book, move and cancel.
|
|
180
|
+
*
|
|
181
|
+
* **None of these is optimistic**, and that is not an oversight. Whether a
|
|
182
|
+
* slot is still free is a question only the server can answer, inside the
|
|
183
|
+
* transaction that decides it - two people can be holding the same minute
|
|
184
|
+
* right now. Painting a booking as made and withdrawing it is exactly the lie
|
|
185
|
+
* the two-phase hold exists to prevent.
|
|
186
|
+
*/
|
|
187
|
+
export function useBookingActions() {
|
|
188
|
+
const { run, isPending, error, clearError } = useResourceAction();
|
|
99
189
|
return {
|
|
100
190
|
isPending,
|
|
101
191
|
error,
|
|
102
|
-
clearError
|
|
192
|
+
clearError,
|
|
103
193
|
createBooking: (input) => run("/scheduling/bookings", input),
|
|
104
194
|
rescheduleBooking: (input) => run("/scheduling/bookings/reschedule", input).then((result) => result?.booking ?? null),
|
|
105
195
|
cancelBooking: (input) => run("/scheduling/bookings/cancel", input),
|
|
@@ -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);
|
|
@@ -850,6 +872,163 @@ export function createConnectHandler(options) {
|
|
|
850
872
|
const asUser = await requireToken(request);
|
|
851
873
|
return json({ eventTypes: await client.scheduling.listEventTypes(workspaceId, { asUser }) });
|
|
852
874
|
},
|
|
875
|
+
/*
|
|
876
|
+
* Creates the first thing a workspace can be booked for - a browser-side
|
|
877
|
+
* counterpart to `POST /scheduling/event-types` above, for a host's own
|
|
878
|
+
* inline setup UI (`ConnectEventTypeFormModal`). Host is a specific
|
|
879
|
+
* member, never a team - there is no groups read in this bridge, so a
|
|
880
|
+
* round-robin team host stays a Console-only capability.
|
|
881
|
+
*/
|
|
882
|
+
"POST /scheduling/event-types": async (request) => {
|
|
883
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
884
|
+
const asUser = await requireToken(request);
|
|
885
|
+
const body = (await request.json().catch(() => null));
|
|
886
|
+
if (typeof body?.name !== "string" ||
|
|
887
|
+
typeof body.durationMinutes !== "number" ||
|
|
888
|
+
typeof body.hostMemberId !== "string" ||
|
|
889
|
+
typeof body.scheduleId !== "string" ||
|
|
890
|
+
typeof body.location?.kind !== "string") {
|
|
891
|
+
throw new ConnectError({
|
|
892
|
+
status: 400,
|
|
893
|
+
code: "validation_error",
|
|
894
|
+
message: "name, durationMinutes, hostMemberId, scheduleId and location are required",
|
|
895
|
+
endpoint: "/scheduling/event-types",
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return json(await client.scheduling.createEventType({
|
|
899
|
+
workspaceId,
|
|
900
|
+
name: body.name,
|
|
901
|
+
description: typeof body.description === "string" ? body.description : null,
|
|
902
|
+
durationMinutes: body.durationMinutes,
|
|
903
|
+
bufferBeforeMinutes: typeof body.bufferBeforeMinutes === "number" ? body.bufferBeforeMinutes : undefined,
|
|
904
|
+
bufferAfterMinutes: typeof body.bufferAfterMinutes === "number" ? body.bufferAfterMinutes : undefined,
|
|
905
|
+
hostMemberId: body.hostMemberId,
|
|
906
|
+
scheduleId: body.scheduleId,
|
|
907
|
+
location: {
|
|
908
|
+
kind: body.location.kind,
|
|
909
|
+
value: typeof body.location.value === "string" ? body.location.value : null,
|
|
910
|
+
},
|
|
911
|
+
publicBookingEnabled: typeof body.publicBookingEnabled === "boolean" ? body.publicBookingEnabled : undefined,
|
|
912
|
+
asUser,
|
|
913
|
+
}), 201);
|
|
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
|
+
},
|
|
967
|
+
/*
|
|
968
|
+
* The working-hours templates an appointment type can point at - read
|
|
969
|
+
* before a setup UI offers to create one, so it can tell "none yet" from
|
|
970
|
+
* "already set up".
|
|
971
|
+
*/
|
|
972
|
+
"GET /scheduling/schedules": async (request) => {
|
|
973
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
974
|
+
const asUser = await requireToken(request);
|
|
975
|
+
return json({ schedules: await client.scheduling.listSchedules(workspaceId, { asUser }) });
|
|
976
|
+
},
|
|
977
|
+
/**
|
|
978
|
+
* Gives a workspace its first working hours - the browser-side
|
|
979
|
+
* counterpart to `POST /scheduling/schedules` above, for
|
|
980
|
+
* `ConnectAvailabilityFormModal`.
|
|
981
|
+
*/
|
|
982
|
+
"POST /scheduling/schedules": async (request) => {
|
|
983
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
984
|
+
const asUser = await requireToken(request);
|
|
985
|
+
const body = (await request.json().catch(() => null));
|
|
986
|
+
if (typeof body?.name !== "string" ||
|
|
987
|
+
typeof body.timezone !== "string" ||
|
|
988
|
+
typeof body.weeklyHours !== "object" ||
|
|
989
|
+
body.weeklyHours === null) {
|
|
990
|
+
throw new ConnectError({
|
|
991
|
+
status: 400,
|
|
992
|
+
code: "validation_error",
|
|
993
|
+
message: "name, timezone and weeklyHours are required",
|
|
994
|
+
endpoint: "/scheduling/schedules",
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
return json(await client.scheduling.createSchedule({
|
|
998
|
+
workspaceId,
|
|
999
|
+
name: body.name,
|
|
1000
|
+
timezone: body.timezone,
|
|
1001
|
+
weeklyHours: body.weeklyHours,
|
|
1002
|
+
asUser,
|
|
1003
|
+
}), 201);
|
|
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
|
+
},
|
|
853
1032
|
"GET /scheduling/slots": async (request) => {
|
|
854
1033
|
const { workspaceId } = await requireWorkspace(request);
|
|
855
1034
|
const asUser = await requireToken(request);
|
package/dist/esm/server/http.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import { ConnectError } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* How long a *generating* endpoint may take to send its headers.
|
|
4
|
+
*
|
|
5
|
+
* One number, named once, and it is not a guess: it is what
|
|
6
|
+
* `POST /visitor/chat` and `POST /ai/chat` declare as their `maxDuration` on
|
|
7
|
+
* the far side. A client bound shorter than the route's own ceiling cannot be
|
|
8
|
+
* right - it can only abort work Connect is still allowed to be doing - and
|
|
9
|
+
* the two numbers drifting apart is exactly how a 20s client came to be
|
|
10
|
+
* fronting a 60s route.
|
|
11
|
+
*/
|
|
12
|
+
export const GENERATION_TIMEOUT_MS = 60_000;
|
|
2
13
|
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
|
|
3
14
|
/**
|
|
4
15
|
* One id, safe to interpolate into a request path.
|
|
@@ -145,7 +156,7 @@ export async function request(options, spec) {
|
|
|
145
156
|
// that has actually started to run as long as it needs. A caller that
|
|
146
157
|
// wants to bound the stream itself passes `spec.signal`.
|
|
147
158
|
const timeoutController = new AbortController();
|
|
148
|
-
const timer = setTimeout(() => timeoutController.abort(), options.timeoutMs);
|
|
159
|
+
const timer = setTimeout(() => timeoutController.abort(), spec.timeoutMs ?? options.timeoutMs);
|
|
149
160
|
const signal = spec.signal
|
|
150
161
|
? AbortSignal.any([spec.signal, timeoutController.signal])
|
|
151
162
|
: timeoutController.signal;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { actingAs } from "./acting-as.js";
|
|
2
|
-
import { request } from "../http.js";
|
|
2
|
+
import { GENERATION_TIMEOUT_MS, request } from "../http.js";
|
|
3
3
|
/**
|
|
4
4
|
* RAG chat and structured extraction against a workspace's data.
|
|
5
5
|
*
|
|
@@ -40,6 +40,12 @@ export class AiResource {
|
|
|
40
40
|
...(input.screenContext ? { screenContext: input.screenContext } : {}),
|
|
41
41
|
},
|
|
42
42
|
headers: actingAs(input.asUser),
|
|
43
|
+
/*
|
|
44
|
+
* Matches the route's own `maxDuration`. This endpoint does not send
|
|
45
|
+
* headers until the model's first token, so the default 20s bound was
|
|
46
|
+
* aborting any turn that opened with a tool call - see `RequestSpec.timeoutMs`.
|
|
47
|
+
*/
|
|
48
|
+
timeoutMs: GENERATION_TIMEOUT_MS,
|
|
43
49
|
raw: true,
|
|
44
50
|
signal: options.signal,
|
|
45
51
|
});
|