@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/cjs/client/components/availability-form.js +52 -24
  3. package/dist/cjs/client/components/call-button.js +398 -0
  4. package/dist/cjs/client/components/create-workspace-form.js +66 -0
  5. package/dist/cjs/client/components/event-type-form.js +99 -25
  6. package/dist/cjs/client/components/workspace-switcher.js +42 -9
  7. package/dist/cjs/client/index.js +17 -2
  8. package/dist/cjs/client/portal-links.js +1 -1
  9. package/dist/cjs/client/use-scheduling.js +37 -0
  10. package/dist/cjs/client/use-workspaces.js +41 -0
  11. package/dist/cjs/server/client.js +1 -1
  12. package/dist/cjs/server/handler.js +176 -2
  13. package/dist/cjs/server/resources/calls.js +58 -0
  14. package/dist/cjs/server/resources/scheduling.js +67 -0
  15. package/dist/cjs/server/resources/workspaces.js +30 -11
  16. package/dist/esm/client/components/availability-form.js +53 -25
  17. package/dist/esm/client/components/call-button.js +358 -0
  18. package/dist/esm/client/components/create-workspace-form.js +63 -0
  19. package/dist/esm/client/components/event-type-form.js +100 -26
  20. package/dist/esm/client/components/workspace-switcher.js +42 -9
  21. package/dist/esm/client/index.js +8 -2
  22. package/dist/esm/client/portal-links.js +1 -1
  23. package/dist/esm/client/use-scheduling.js +34 -0
  24. package/dist/esm/client/use-workspaces.js +38 -0
  25. package/dist/esm/server/client.js +1 -1
  26. package/dist/esm/server/handler.js +176 -2
  27. package/dist/esm/server/resources/calls.js +58 -0
  28. package/dist/esm/server/resources/scheduling.js +67 -0
  29. package/dist/esm/server/resources/workspaces.js +30 -11
  30. package/dist/types/client/components/availability-form.d.ts +23 -12
  31. package/dist/types/client/components/call-button.d.ts +132 -0
  32. package/dist/types/client/components/create-workspace-form.d.ts +32 -0
  33. package/dist/types/client/components/event-type-form.d.ts +30 -8
  34. package/dist/types/client/components/workspace-switcher.d.ts +9 -9
  35. package/dist/types/client/index.d.ts +7 -1
  36. package/dist/types/client/portal-links.d.ts +1 -1
  37. package/dist/types/client/use-scheduling.d.ts +46 -2
  38. package/dist/types/client/use-workspaces.d.ts +27 -0
  39. package/dist/types/server/client.d.ts +1 -1
  40. package/dist/types/server/resources/calls.d.ts +32 -1
  41. package/dist/types/server/resources/scheduling.d.ts +66 -2
  42. package/dist/types/server/resources/workspaces.d.ts +28 -0
  43. package/dist/types/types.d.ts +143 -1
  44. 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 sends
32
- * the member to Connect to actually create one, since there is nothing here
33
- * that can.
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, appUrl } = useConnect();
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: () => openExternal(appUrl), 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] }));
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) {
@@ -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
+ }
@@ -175,4 +175,4 @@ export class ConnectClient {
175
175
  }
176
176
  }
177
177
  /** Kept in step with `package.json` by `sdk.test.ts`. */
178
- export const SDK_VERSION = "0.38.0";
178
+ export const SDK_VERSION = "0.41.0";
@@ -738,8 +738,8 @@ export function createConnectHandler(options) {
738
738
  */
739
739
  "STORE": async (request) => storeRoute(request),
740
740
  /**
741
- * The workspace switcher's three reads, forwarded with the caller's own
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
  *