@cosmoops/connect-sdk 0.17.0 → 0.19.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 (31) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +20 -1
  3. package/dist/cjs/client/components/booking-calendar.js +170 -0
  4. package/dist/cjs/client/components/booking-dialogs.js +124 -0
  5. package/dist/cjs/client/components/booking-grid.js +206 -0
  6. package/dist/cjs/client/index.js +15 -1
  7. package/dist/cjs/client/use-scheduling.js +113 -0
  8. package/dist/cjs/server/client.js +5 -1
  9. package/dist/cjs/server/handler.js +136 -0
  10. package/dist/cjs/server/resources/contacts.js +66 -0
  11. package/dist/cjs/server/resources/scheduling.js +32 -1
  12. package/dist/esm/client/components/booking-calendar.js +167 -0
  13. package/dist/esm/client/components/booking-dialogs.js +120 -0
  14. package/dist/esm/client/components/booking-grid.js +199 -0
  15. package/dist/esm/client/index.js +4 -0
  16. package/dist/esm/client/use-scheduling.js +107 -0
  17. package/dist/esm/server/client.js +5 -1
  18. package/dist/esm/server/handler.js +136 -0
  19. package/dist/esm/server/resources/contacts.js +62 -0
  20. package/dist/esm/server/resources/scheduling.js +32 -1
  21. package/dist/types/client/components/booking-calendar.d.ts +38 -0
  22. package/dist/types/client/components/booking-dialogs.d.ts +33 -0
  23. package/dist/types/client/components/booking-grid.d.ts +102 -0
  24. package/dist/types/client/index.d.ts +5 -0
  25. package/dist/types/client/use-scheduling.d.ts +93 -0
  26. package/dist/types/server/client.d.ts +4 -1
  27. package/dist/types/server/index.d.ts +2 -1
  28. package/dist/types/server/resources/contacts.d.ts +60 -0
  29. package/dist/types/server/resources/scheduling.d.ts +51 -6
  30. package/dist/types/types.d.ts +58 -0
  31. package/package.json +1 -1
@@ -412,6 +412,142 @@ export function createConnectHandler(options) {
412
412
  await session.write?.(request, response, result.sessionToken);
413
413
  return response;
414
414
  },
415
+ /*
416
+ * Scheduling - the workspace's own calendar, in a browser.
417
+ *
418
+ * Every one of these forwards the caller's session token, which is what
419
+ * makes them safe to expose: Connect derives the workspace from that token
420
+ * and checks the member's own `calendar:read` / `calendar:manage`. The
421
+ * browser names no tenant and the partner's key does not, by itself,
422
+ * authorize a write - without that second half, shipping a booking
423
+ * component would let every end user of every partner app take slots on a
424
+ * real business calendar.
425
+ *
426
+ * `workspaceId` is still passed because the resource requires it, and is
427
+ * still ignored by Connect whenever a token is present. `requireWorkspace`
428
+ * supplies the session's own, so the two can never disagree.
429
+ */
430
+ "GET /scheduling/event-types": async (request) => {
431
+ const { workspaceId } = await requireWorkspace(request);
432
+ const asUser = await requireToken(request);
433
+ return json({ eventTypes: await client.scheduling.listEventTypes(workspaceId, { asUser }) });
434
+ },
435
+ "GET /scheduling/slots": async (request) => {
436
+ const { workspaceId } = await requireWorkspace(request);
437
+ const asUser = await requireToken(request);
438
+ const url = new URL(request.url);
439
+ const eventTypeId = url.searchParams.get("eventTypeId");
440
+ const from = url.searchParams.get("from");
441
+ const to = url.searchParams.get("to");
442
+ if (!eventTypeId || !from || !to) {
443
+ throw new ConnectError({
444
+ status: 400,
445
+ code: "validation_error",
446
+ message: "eventTypeId, from and to are required",
447
+ endpoint: "/scheduling/slots",
448
+ });
449
+ }
450
+ return json(await client.scheduling.findSlots({
451
+ workspaceId,
452
+ eventTypeId,
453
+ from,
454
+ to,
455
+ // Set when offering times to move an existing appointment to, or the
456
+ // booking blocks its own replacement and the list comes back empty.
457
+ excludeBookingId: url.searchParams.get("excludeBookingId") ?? undefined,
458
+ asUser,
459
+ }));
460
+ },
461
+ "GET /scheduling/bookings": async (request) => {
462
+ const { workspaceId } = await requireWorkspace(request);
463
+ const asUser = await requireToken(request);
464
+ const url = new URL(request.url);
465
+ const limit = Number(url.searchParams.get("limit") ?? 25);
466
+ return json(await client.scheduling.listBookings({
467
+ workspaceId,
468
+ status: url.searchParams.get("status") ?? undefined,
469
+ hostMemberId: url.searchParams.get("hostMemberId") ?? undefined,
470
+ from: url.searchParams.get("from") ?? undefined,
471
+ to: url.searchParams.get("to") ?? undefined,
472
+ order: url.searchParams.get("order") === "asc" ? "asc" : "desc",
473
+ cursor: url.searchParams.get("cursor") ?? undefined,
474
+ limit: Number.isInteger(limit) && limit >= 1 && limit <= 100 ? limit : 25,
475
+ asUser,
476
+ }));
477
+ },
478
+ "POST /scheduling/bookings": async (request) => {
479
+ const { workspaceId } = await requireWorkspace(request);
480
+ const asUser = await requireToken(request);
481
+ const body = (await request.json().catch(() => null));
482
+ if (typeof body?.eventTypeId !== "string" ||
483
+ typeof body.startAt !== "string" ||
484
+ typeof body.attendee?.name !== "string" ||
485
+ typeof body.attendee?.phone !== "string") {
486
+ throw new ConnectError({
487
+ status: 400,
488
+ code: "validation_error",
489
+ message: "eventTypeId, startAt and an attendee name and phone are required",
490
+ endpoint: "/scheduling/bookings",
491
+ });
492
+ }
493
+ return json(await client.scheduling.createBooking({
494
+ workspaceId,
495
+ eventTypeId: body.eventTypeId,
496
+ startAt: body.startAt,
497
+ attendee: {
498
+ name: body.attendee.name,
499
+ phone: body.attendee.phone,
500
+ email: typeof body.attendee.email === "string" ? body.attendee.email : null,
501
+ },
502
+ notes: typeof body.notes === "string" ? body.notes : null,
503
+ asUser,
504
+ }));
505
+ },
506
+ /*
507
+ * Reschedule and cancel take the booking id in the body rather than the
508
+ * path, matching `POST /sessions/revoke`. The bridge's route table is
509
+ * exact-match on `METHOD /path`, so a path parameter would mean a second
510
+ * matching strategy for two routes.
511
+ */
512
+ "POST /scheduling/bookings/reschedule": async (request) => {
513
+ const { workspaceId } = await requireWorkspace(request);
514
+ const asUser = await requireToken(request);
515
+ const body = (await request.json().catch(() => null));
516
+ if (typeof body?.bookingId !== "string" || typeof body.startAt !== "string") {
517
+ throw new ConnectError({
518
+ status: 400,
519
+ code: "validation_error",
520
+ message: "bookingId and startAt are required",
521
+ endpoint: "/scheduling/bookings/reschedule",
522
+ });
523
+ }
524
+ return json(await client.scheduling.rescheduleBooking({
525
+ workspaceId,
526
+ bookingId: body.bookingId,
527
+ startAt: body.startAt,
528
+ notes: typeof body.notes === "string" ? body.notes : undefined,
529
+ asUser,
530
+ }));
531
+ },
532
+ "POST /scheduling/bookings/cancel": async (request) => {
533
+ const { workspaceId } = await requireWorkspace(request);
534
+ const asUser = await requireToken(request);
535
+ const body = (await request.json().catch(() => null));
536
+ if (typeof body?.bookingId !== "string") {
537
+ throw new ConnectError({
538
+ status: 400,
539
+ code: "validation_error",
540
+ message: "bookingId is required",
541
+ endpoint: "/scheduling/bookings/cancel",
542
+ });
543
+ }
544
+ return json(await client.scheduling.cancelBooking({
545
+ workspaceId,
546
+ bookingId: body.bookingId,
547
+ reason: typeof body.reason === "string" ? body.reason : null,
548
+ asUser,
549
+ }));
550
+ },
415
551
  "GET /integrations": async (request) => {
416
552
  const { workspaceId } = await requireWorkspace(request);
417
553
  return json({ toolkits: await client.integrations.list(workspaceId) });
@@ -0,0 +1,62 @@
1
+ import { request } from "../http.js";
2
+ import { actingAs } from "./acting-as.js";
3
+ /**
4
+ * Who a workspace already knows how to reach.
5
+ *
6
+ * **This is the telephony identity, not a CRM record.** A contact is keyed by
7
+ * `primaryPhone` - the one fact every inbound call carries - and it is written
8
+ * automatically: a voice agent creates one on the first call from a new number
9
+ * and fills fields in as it learns them. `source` says which kind you are
10
+ * holding, and it is worth rendering: `call` means an agent guessed and nobody
11
+ * has confirmed, `manual` means a person typed it.
12
+ *
13
+ * **Reference it, do not copy it.** If your application keeps its own customer
14
+ * records, store this contact's `id` on yours rather than duplicating the
15
+ * fields. Two writable copies of a person's name with no single owner drift,
16
+ * and Connect's is the one an agent keeps updating from calls. The id is also
17
+ * an exact join to anything else keyed by it - `Booking.contactId`, most
18
+ * usefully - so "which of my records is this appointment for" becomes a lookup
19
+ * instead of a phone match.
20
+ *
21
+ * Read-only for now. Writing into a store with a per-field provenance chain
22
+ * means declaring where your facts came from and how they merge with a
23
+ * human's, and nothing needs that yet.
24
+ */
25
+ export class ContactsResource {
26
+ transport;
27
+ constructor(transport) {
28
+ this.transport = transport;
29
+ }
30
+ /**
31
+ * "Do we already know this number?" - one point read, cheap enough for a
32
+ * screen pop on every inbound call.
33
+ *
34
+ * Resolves to `null` for a number nobody has called from before. That is the
35
+ * ordinary case for a first-time caller, not an error, which is why it is a
36
+ * null rather than a thrown `ConnectError`.
37
+ */
38
+ async lookupByPhone(input) {
39
+ const result = await request(this.transport, {
40
+ method: "GET",
41
+ path: "/contacts",
42
+ query: { workspaceId: input.workspaceId, phone: input.phone },
43
+ headers: actingAs(input.asUser),
44
+ });
45
+ return result.contact ?? null;
46
+ }
47
+ /**
48
+ * The record behind an id you already hold.
49
+ *
50
+ * Throws `ConnectError` with `code: "not_found"` for an id that does not
51
+ * exist *or* belongs to another workspace - deliberately the same answer,
52
+ * since telling those apart would confirm a contact exists somewhere else.
53
+ */
54
+ get(contactId, options) {
55
+ return request(this.transport, {
56
+ method: "GET",
57
+ path: `/contacts/${encodeURIComponent(contactId)}`,
58
+ query: { workspaceId: options.workspaceId },
59
+ headers: actingAs(options.asUser),
60
+ });
61
+ }
62
+ }
@@ -1,4 +1,5 @@
1
1
  import { request } from "../http.js";
2
+ import { actingAs } from "./acting-as.js";
2
3
  /**
3
4
  * A subscribing workspace's own calendar.
4
5
  *
@@ -18,11 +19,12 @@ export class SchedulingResource {
18
19
  this.transport = transport;
19
20
  }
20
21
  /** What this workspace offers as bookable. Active appointment types only. */
21
- async listEventTypes(workspaceId) {
22
+ async listEventTypes(workspaceId, options = {}) {
22
23
  const result = await request(this.transport, {
23
24
  method: "GET",
24
25
  path: "/scheduling/event-types",
25
26
  query: { workspaceId },
27
+ headers: actingAs(options.asUser),
26
28
  });
27
29
  // `?? []` for the same reason `apps.list` has it - a collection every
28
30
  // caller immediately maps, and a body without the key would hand them
@@ -51,7 +53,9 @@ export class SchedulingResource {
51
53
  eventTypeId: input.eventTypeId,
52
54
  from: toInstant(input.from),
53
55
  to: toInstant(input.to),
56
+ excludeBookingId: input.excludeBookingId,
54
57
  },
58
+ headers: actingAs(input.asUser),
55
59
  });
56
60
  }
57
61
  /** One page of the workspace's diary. */
@@ -69,6 +73,7 @@ export class SchedulingResource {
69
73
  cursor: input.cursor,
70
74
  limit: input.limit === undefined ? undefined : String(input.limit),
71
75
  },
76
+ headers: actingAs(input.asUser),
72
77
  });
73
78
  }
74
79
  /**
@@ -90,6 +95,31 @@ export class SchedulingResource {
90
95
  attendee: input.attendee,
91
96
  notes: input.notes ?? null,
92
97
  },
98
+ headers: actingAs(input.asUser),
99
+ });
100
+ }
101
+ /**
102
+ * Moves an appointment to a different time.
103
+ *
104
+ * **The original is retired, not edited.** It stays on the diary as
105
+ * `rescheduled` with `rescheduledToId` pointing at the replacement, and both
106
+ * come back here - so a booking id you cached still resolves, and still says
107
+ * where the appointment went.
108
+ *
109
+ * A refused move changes nothing: the new slot is claimed before the old one
110
+ * is released, so a `conflict` leaves the customer with the appointment they
111
+ * already had. Re-read the slots (with `excludeBookingId`) and offer another.
112
+ */
113
+ rescheduleBooking(input) {
114
+ return request(this.transport, {
115
+ method: "POST",
116
+ path: `/scheduling/bookings/${encodeURIComponent(input.bookingId)}/reschedule`,
117
+ json: {
118
+ workspaceId: input.workspaceId,
119
+ startAt: toInstant(input.startAt),
120
+ notes: input.notes ?? null,
121
+ },
122
+ headers: actingAs(input.asUser),
93
123
  });
94
124
  }
95
125
  /**
@@ -104,6 +134,7 @@ export class SchedulingResource {
104
134
  method: "POST",
105
135
  path: `/scheduling/bookings/${encodeURIComponent(input.bookingId)}/cancel`,
106
136
  json: { workspaceId: input.workspaceId, reason: input.reason ?? null },
137
+ headers: actingAs(input.asUser),
107
138
  });
108
139
  }
109
140
  }
@@ -0,0 +1,38 @@
1
+ import { type GridWindow } from "./booking-grid.js";
2
+ export interface ConnectBookingCalendarProps {
3
+ /**
4
+ * The business's IANA timezone. **Every instant on the grid is read against
5
+ * it, never the viewer's** - a workspace's working day must not shift
6
+ * because a member is travelling.
7
+ *
8
+ * Defaults to the host's zone, which is right for the common case where
9
+ * staff and business share one. Pass it explicitly when they might not.
10
+ */
11
+ timeZone?: string;
12
+ /** Only appointments this member hosts. Omit for the whole workspace's diary. */
13
+ hostMemberId?: string;
14
+ /** Narrow the visible band - see `DEFAULT_WINDOW`. */
15
+ window?: GridWindow;
16
+ /** Hide the "New appointment" action for a read-only view. */
17
+ canBook?: boolean;
18
+ className?: string;
19
+ }
20
+ /**
21
+ * A week of the workspace's calendar, with booking, rescheduling and
22
+ * cancelling.
23
+ *
24
+ * **Two views, and which one you get is a viewport decision, not a
25
+ * preference.** A seven-column time grid needs roughly 640px before a column
26
+ * is wide enough to hold a name; below that it degrades into unreadable
27
+ * slivers. So the grid is `sm:` and up, and the same week renders as a grouped
28
+ * agenda underneath - not a cut-down grid, a different and better shape for a
29
+ * phone. Both read the same data and open the same dialogs.
30
+ *
31
+ * Everything it does goes through the bridge as the signed-in member, so
32
+ * Connect checks *their* `calendar:read` / `calendar:manage` rather than the
33
+ * host application's key. A member who may look but not book gets the grid and
34
+ * a refusal on the write, which is the correct shape - see `useBookingActions`.
35
+ */
36
+ export declare function ConnectBookingCalendar({ timeZone, hostMemberId, window: visibleWindow, canBook, className, }: Readonly<ConnectBookingCalendarProps>): import("react").JSX.Element;
37
+ declare function timeLabel(instant: Date, zone: string): string;
38
+ export { timeLabel as connectBookingTimeLabel };
@@ -0,0 +1,33 @@
1
+ import type { BookableEventType, Booking } from "../../types.js";
2
+ /**
3
+ * Picking a time, and then who it is for.
4
+ *
5
+ * The slot list is read fresh every time this opens and is **never cached
6
+ * across an open**: it is a snapshot of contention, and offering a time
7
+ * somebody took ninety seconds ago is how two people end up in the same slot.
8
+ * The server re-checks inside its transaction regardless, so a stale list
9
+ * costs a refusal rather than a double booking - this only makes the refusal
10
+ * rare enough to be surprising when it happens.
11
+ */
12
+ export declare function ConnectBookingDialog({ isOpen, onOpenChange, eventTypes, initialStartAt, timeZone, onBooked, }: Readonly<{
13
+ isOpen: boolean;
14
+ onOpenChange(open: boolean): void;
15
+ eventTypes: BookableEventType[];
16
+ initialStartAt: Date;
17
+ timeZone: string;
18
+ onBooked(booking: Booking): void;
19
+ }>): import("react").JSX.Element;
20
+ /**
21
+ * One appointment, and the two things anyone opens it to do.
22
+ *
23
+ * Rescheduling re-reads the slots with `excludeBookingId` set to this booking.
24
+ * Without that the appointment blocks its own replacement, and moving it by
25
+ * half an hour - the only move most people ever make - offers nothing.
26
+ */
27
+ export declare function ConnectBookingDetail({ booking, eventType, timeZone, onOpenChange, onChanged, }: Readonly<{
28
+ booking: Booking;
29
+ eventType: BookableEventType | null;
30
+ timeZone: string;
31
+ onOpenChange(open: boolean): void;
32
+ onChanged(): void;
33
+ }>): import("react").JSX.Element;
@@ -0,0 +1,102 @@
1
+ import type { Booking } from "../../types.js";
2
+ /**
3
+ * The arithmetic behind the week grid, with no React and no fetching - the
4
+ * same split `slot-computation.ts` uses server-side, and for the same reason:
5
+ * every timezone and overlap rule is then testable without rendering
6
+ * anything, and a layout bug is a failing assertion rather than a screenshot
7
+ * somebody has to notice.
8
+ *
9
+ * **Every instant is read against the business's timezone**, never the
10
+ * viewer's. A workspace in Kolkata whose staff are travelling must not see
11
+ * their own working day shift; the grid is a picture of when *the business* is
12
+ * open. That is why every function here takes an explicit `timeZone` rather
13
+ * than falling back to the host's.
14
+ */
15
+ export interface ZonedInstant {
16
+ /** `YYYY-MM-DD` in `timeZone` - the local calendar day this instant falls on. */
17
+ dateKey: string;
18
+ /** Minutes since local midnight, `0`-`1439`. */
19
+ minutes: number;
20
+ }
21
+ /**
22
+ * An instant, as the local day and minute it lands on in `timeZone`.
23
+ *
24
+ * Built from `formatToParts` rather than by adding a fixed offset. An offset
25
+ * is only correct until a DST boundary, and "add 5.5 hours" is wrong the
26
+ * moment the same code is pointed at a zone that observes one - which is
27
+ * exactly the kind of bug that appears twice a year and never in a test.
28
+ */
29
+ export declare function zonedInstant(instant: Date, timeZone: string): ZonedInstant;
30
+ export interface DayColumn {
31
+ /** `YYYY-MM-DD` in the grid's timezone. */
32
+ dateKey: string;
33
+ /** "Mon 8" - the column heading. */
34
+ label: string;
35
+ /** "Sep" etc, for the first column and any month change. */
36
+ monthLabel: string;
37
+ /** Midnight of this day, as an instant. Used to build slot query windows. */
38
+ startsAt: Date;
39
+ isToday: boolean;
40
+ }
41
+ /**
42
+ * `count` consecutive days starting at the local day `anchor` falls on.
43
+ *
44
+ * Days are stepped by adding 24 hours and re-reading the zone rather than by
45
+ * incrementing a date field, so a day that is 23 or 25 hours long still
46
+ * produces the right sequence of `dateKey`s - the midday anchor is what keeps
47
+ * a DST shift from landing the step on the previous or next day.
48
+ */
49
+ export declare function dayColumns(anchor: Date, timeZone: string, count: number, now: Date): DayColumn[];
50
+ /**
51
+ * Local midnight of `dateKey` in `timeZone`, as an instant.
52
+ *
53
+ * Solved by probing rather than by arithmetic: guess UTC midnight, read what
54
+ * local time that actually is, and subtract the difference. One correction is
55
+ * always enough because a zone's offset cannot change within the few hours the
56
+ * correction spans - and unlike a fixed-offset formula it is right across DST
57
+ * and for the half-hour zones (`Asia/Kolkata`, `Australia/Adelaide`) that a
58
+ * naive implementation quietly breaks on.
59
+ */
60
+ export declare function dayStartInstant(dateKey: string, timeZone: string): Date;
61
+ export interface PositionedBooking {
62
+ booking: Booking;
63
+ dateKey: string;
64
+ /** Fraction of the visible day, `0`-`1`, from the top of the grid. */
65
+ top: number;
66
+ /** Fraction of the visible day. Never `0` - see `MIN_VISIBLE_FRACTION`. */
67
+ height: number;
68
+ /** Column index within its overlap cluster, and how many columns that cluster has. */
69
+ column: number;
70
+ columns: number;
71
+ /** True when the appointment extends past the grid's visible window. */
72
+ clipped: boolean;
73
+ }
74
+ export interface GridWindow {
75
+ /** Minutes since local midnight the grid starts at - `480` is 08:00. */
76
+ startMinute: number;
77
+ endMinute: number;
78
+ }
79
+ /**
80
+ * Places bookings on the grid, side by side where they collide.
81
+ *
82
+ * Overlap handling is the part worth stating. Two appointments at the same
83
+ * time are not an error - a workspace with two hosts has them constantly - and
84
+ * stacking them would hide one entirely. So overlapping bookings are grouped
85
+ * into *clusters* and each cluster is split into equal columns. The cluster is
86
+ * built transitively: A overlapping B and B overlapping C puts all three in one
87
+ * cluster even when A and C do not touch, because anything else lets two of
88
+ * them land on the same column.
89
+ */
90
+ export declare function layoutBookings(bookings: Booking[], timeZone: string, window: GridWindow): PositionedBooking[];
91
+ /**
92
+ * The hour labels down the left edge, inclusive of both ends.
93
+ *
94
+ * Takes no timezone, deliberately: these label *wall-clock hours on the grid*,
95
+ * not instants. The grid is already drawn in the business's zone, so "10 am"
96
+ * means the business's ten - running the label through a zone conversion would
97
+ * shift it away from the row it sits on.
98
+ */
99
+ export declare function hourMarks(window: GridWindow): {
100
+ minute: number;
101
+ label: string;
102
+ }[];
@@ -87,3 +87,8 @@ export type { PlanAction, PlanTier, ComparablePlan } from "./components/plan-pri
87
87
  export { ConnectWorkspaceSwitcher } from "./components/workspace-switcher.js";
88
88
  export type { ConnectWorkspaceSwitcherProps } from "./components/workspace-switcher.js";
89
89
  export { useConnectWorkspaces, useConnectWorkspaceMembers } from "./provider.js";
90
+ export { ConnectBookingCalendar } from "./components/booking-calendar.js";
91
+ export type { ConnectBookingCalendarProps } from "./components/booking-calendar.js";
92
+ export { ConnectBookingDialog, ConnectBookingDetail } from "./components/booking-dialogs.js";
93
+ export { useBookableEventTypes, useAvailableSlots, useBookings, useBookingActions, type SlotQuery, type BookingsQuery, } from "./use-scheduling.js";
94
+ export { layoutBookings, dayColumns, zonedInstant, type GridWindow, type PositionedBooking, } from "./components/booking-grid.js";
@@ -0,0 +1,93 @@
1
+ import type { BookableEventType, Booking, BookingAttendee } from "../types.js";
2
+ /**
3
+ * The bridge's scheduling surface, as hooks.
4
+ *
5
+ * Every path here is one of the six the bridge exposes, and every one of them
6
+ * is resolved *for the signed-in member* - Connect takes the workspace from
7
+ * their session and checks their own `calendar:read` / `calendar:manage`.
8
+ * Nothing in this file names a workspace, and nothing should: a browser that
9
+ * can name a tenant is a browser that can read someone else's diary.
10
+ */
11
+ /** What this workspace offers as bookable. Active types only. */
12
+ export declare function useBookableEventTypes(): {
13
+ eventTypes: BookableEventType[];
14
+ isLoading: boolean;
15
+ error: string | null;
16
+ };
17
+ export interface SlotQuery {
18
+ eventTypeId: string;
19
+ from: Date;
20
+ to: Date;
21
+ /**
22
+ * Set to the booking being moved. Without it the appointment counts as
23
+ * blocking its own replacement and a reschedule picker comes back empty for
24
+ * the one adjustment people actually make.
25
+ */
26
+ excludeBookingId?: string;
27
+ }
28
+ /**
29
+ * Free times for one appointment type.
30
+ *
31
+ * **Deliberately re-read rather than cached across a session.** A slot list is
32
+ * a snapshot of contention: anyone else on that calendar - a colleague, a
33
+ * voice agent mid-call, someone on the public booking link - can take one
34
+ * between the read and the click. Showing a stale one is how two people are
35
+ * offered the same minute. The booking call re-checks inside a transaction
36
+ * anyway, so a stale list costs a refusal rather than a double booking; this
37
+ * just means the refusal is rare.
38
+ */
39
+ export declare function useAvailableSlots(query: SlotQuery | null): {
40
+ slots: import("./index.js").BookingSlot[];
41
+ timezone: string | null;
42
+ durationMinutes: number | null;
43
+ isLoading: boolean;
44
+ error: string | null;
45
+ refetch: () => void;
46
+ };
47
+ export interface BookingsQuery {
48
+ from: Date;
49
+ to: Date;
50
+ /** Only appointments this member is hosting. Omit for the whole workspace. */
51
+ hostMemberId?: string;
52
+ limit?: number;
53
+ }
54
+ /** One page of the diary, ascending - a forward view, not a history. */
55
+ export declare function useBookings(query: BookingsQuery): {
56
+ bookings: Booking[];
57
+ hasMore: boolean;
58
+ isLoading: boolean;
59
+ error: string | null;
60
+ path: string;
61
+ refetch: () => void;
62
+ };
63
+ export interface BookingActionsState {
64
+ isPending: boolean;
65
+ /** The last refusal, written to be shown to the person who clicked. */
66
+ error: string | null;
67
+ clearError(): void;
68
+ }
69
+ /**
70
+ * Book, move and cancel.
71
+ *
72
+ * **None of these is optimistic**, and that is not an oversight. Whether a
73
+ * slot is still free is a question only the server can answer, inside the
74
+ * transaction that decides it - two people can be holding the same minute
75
+ * right now. Painting a booking as made and withdrawing it is exactly the lie
76
+ * the two-phase hold exists to prevent.
77
+ */
78
+ export declare function useBookingActions(): BookingActionsState & {
79
+ createBooking(input: {
80
+ eventTypeId: string;
81
+ startAt: string;
82
+ attendee: BookingAttendee;
83
+ notes?: string | null;
84
+ }): Promise<Booking | null>;
85
+ rescheduleBooking(input: {
86
+ bookingId: string;
87
+ startAt: string;
88
+ }): Promise<Booking | null>;
89
+ cancelBooking(input: {
90
+ bookingId: string;
91
+ reason?: string | null;
92
+ }): Promise<Booking | null>;
93
+ };
@@ -5,6 +5,7 @@ import { AiResource } from "./resources/ai.js";
5
5
  import { DataResource } from "./resources/data.js";
6
6
  import { WorkspacesResource } from "./resources/workspaces.js";
7
7
  import { SchedulingResource } from "./resources/scheduling.js";
8
+ import { ContactsResource } from "./resources/contacts.js";
8
9
  import { KvResource } from "./resources/kv.js";
9
10
  import { BillingResource } from "./resources/billing.js";
10
11
  import { DriveResource } from "./resources/drive.js";
@@ -78,6 +79,8 @@ export declare class ConnectClient {
78
79
  readonly drive: DriveResource;
79
80
  /** A subscribing workspace's own calendar - what is bookable, free, and booked. */
80
81
  readonly scheduling: SchedulingResource;
82
+ /** Who a workspace already knows how to reach, by phone or by id. */
83
+ readonly contacts: ContactsResource;
81
84
  /** RAG chat and structured extraction. */
82
85
  readonly ai: AiResource;
83
86
  /** Your application's own document store - see `DataResource`. */
@@ -112,4 +115,4 @@ export declare class ConnectClient {
112
115
  constructor(options: ConnectClientOptions);
113
116
  }
114
117
  /** Kept in step with `package.json` by `sdk.test.ts`. */
115
- export declare const SDK_VERSION = "0.17.0";
118
+ export declare const SDK_VERSION = "0.19.0";
@@ -27,7 +27,8 @@ export type { AnalyticsResource } from "./resources/analytics.js";
27
27
  export type { SessionsResource } from "./resources/sessions.js";
28
28
  export type { ExecuteIntegrationActionInput, IntegrationsResource } from "./resources/integrations.js";
29
29
  export type { PlatformResource } from "./resources/platform.js";
30
- export type { SchedulingResource, FindSlotsInput, ListBookingsInput, CreateBookingInput, } from "./resources/scheduling.js";
30
+ export type { SchedulingResource, FindSlotsInput, ListBookingsInput, CreateBookingInput, RescheduleBookingInput, } from "./resources/scheduling.js";
31
+ export type { ContactsResource, LookupContactInput } from "./resources/contacts.js";
31
32
  /**
32
33
  * Webhook verification and a ready-made POST route. Server-only for the
33
34
  * obvious reason: the signing secret must never reach a browser bundle.
@@ -0,0 +1,60 @@
1
+ import { type TransportOptions } from "../http.js";
2
+ import type { ConnectContact } from "../../types.js";
3
+ export interface LookupContactInput {
4
+ workspaceId: string;
5
+ /**
6
+ * Any reasonable spelling. `+919000000001`, `919000000001` and
7
+ * `+91 90000 00001` all resolve to the same contact - the number is
8
+ * normalized to E.164 before the lookup, because comparing unnormalized is
9
+ * how a contact that exists reads as absent.
10
+ */
11
+ phone: string;
12
+ /** See `ActingAsOption` on the scheduling resource - same contract. */
13
+ asUser?: string;
14
+ }
15
+ /**
16
+ * Who a workspace already knows how to reach.
17
+ *
18
+ * **This is the telephony identity, not a CRM record.** A contact is keyed by
19
+ * `primaryPhone` - the one fact every inbound call carries - and it is written
20
+ * automatically: a voice agent creates one on the first call from a new number
21
+ * and fills fields in as it learns them. `source` says which kind you are
22
+ * holding, and it is worth rendering: `call` means an agent guessed and nobody
23
+ * has confirmed, `manual` means a person typed it.
24
+ *
25
+ * **Reference it, do not copy it.** If your application keeps its own customer
26
+ * records, store this contact's `id` on yours rather than duplicating the
27
+ * fields. Two writable copies of a person's name with no single owner drift,
28
+ * and Connect's is the one an agent keeps updating from calls. The id is also
29
+ * an exact join to anything else keyed by it - `Booking.contactId`, most
30
+ * usefully - so "which of my records is this appointment for" becomes a lookup
31
+ * instead of a phone match.
32
+ *
33
+ * Read-only for now. Writing into a store with a per-field provenance chain
34
+ * means declaring where your facts came from and how they merge with a
35
+ * human's, and nothing needs that yet.
36
+ */
37
+ export declare class ContactsResource {
38
+ private readonly transport;
39
+ constructor(transport: TransportOptions);
40
+ /**
41
+ * "Do we already know this number?" - one point read, cheap enough for a
42
+ * screen pop on every inbound call.
43
+ *
44
+ * Resolves to `null` for a number nobody has called from before. That is the
45
+ * ordinary case for a first-time caller, not an error, which is why it is a
46
+ * null rather than a thrown `ConnectError`.
47
+ */
48
+ lookupByPhone(input: LookupContactInput): Promise<ConnectContact | null>;
49
+ /**
50
+ * The record behind an id you already hold.
51
+ *
52
+ * Throws `ConnectError` with `code: "not_found"` for an id that does not
53
+ * exist *or* belongs to another workspace - deliberately the same answer,
54
+ * since telling those apart would confirm a contact exists somewhere else.
55
+ */
56
+ get(contactId: string, options: {
57
+ workspaceId: string;
58
+ asUser?: string;
59
+ }): Promise<ConnectContact>;
60
+ }