Skip to content
← Public packages

@kentcdodds/cal-com

Cal.com helpers for booking pages, event types, slots, and webhooks.

src/cal-com.js

536 lines · 14.8 KB · JavaScript
import {
  bookingscontroller_2026_02_25_cancelbooking,
  bookingscontroller_2026_02_25_createbooking,
  bookingscontroller_2026_02_25_getbooking,
  bookingscontroller_2026_02_25_reschedulebooking,
  bookingscontroller_2026_05_01_getbookings,
  eventtypescontroller_2024_06_14_geteventtypebyid,
  eventtypescontroller_2024_06_14_geteventtypes,
  mecontroller_getme,
  rawCalRequest,
  slotscontroller_2024_09_04_getavailableslots,
  webhookscontroller_createwebhook,
  webhookscontroller_getwebhooks,
} from './openapi-client.js'
import {
  applyEventTypeIdToSlotsQuery,
  eventTypeSelectorFromInput,
  firstCatalogEventType,
  flattenEventTypes,
  formatCalErrorDetail,
  formatEventTypeCatalog,
  hasEventTypeLookupSelector,
  isRecord,
  isSmokeInput,
  normalizeCreateBookingBody,
  normalizeCreateWebhookBody,
  planAvailableSlotsQuery,
  resolveEventTypeFromCatalog,
  utcDateOnly,
} from './helpers.js'

const DEFAULT_API_VERSION = '2024-08-13'
const BOOKINGS_LIST_API_VERSION = '2026-05-01'
const BOOKING_API_VERSION = '2026-02-25'
const SLOTS_API_VERSION = '2024-09-04'
const EVENT_TYPES_API_VERSION = '2024-06-14'
const ERROR_DETAIL_MAX = 400

function withoutKeys(record, keys) {
  const blocked = new Set(keys)
  return Object.fromEntries(
    Object.entries(record ?? {})
      .filter(([, value]) => value !== undefined)
      .filter(([key]) => !blocked.has(key)),
  )
}

function queryFromInput(input, ignoredKeys = []) {
  if (isRecord(input?.query)) return withoutKeys(input.query, ['smoke'])
  return withoutKeys(input, [
    'action',
    'apiVersion',
    'body',
    'headers',
    'params',
    'smoke',
    ...ignoredKeys,
  ])
}

function bodyFromInput(input, ignoredKeys = []) {
  if (isRecord(input?.body)) return withoutKeys(input.body, ['smoke'])
  return withoutKeys(input, [
    'action',
    'apiVersion',
    'query',
    'headers',
    'params',
    'smoke',
    ...ignoredKeys,
  ])
}

function bookingUidFromInput(input) {
  const value = input?.bookingUid ?? input?.uid ?? input?.params?.bookingUid
  if (typeof value !== 'string' || value.trim() === '') {
    throw new Error('Cal.com helper requires bookingUid (uid is accepted as an alias).')
  }
  return value
}

function versionHeaders(apiVersion) {
  if (!apiVersion) return {}
  return { 'cal-api-version': apiVersion }
}

async function parseBody(response) {
  const contentType = response.headers.get('content-type') ?? ''
  if (contentType.includes('application/json')) return await response.json()
  const text = await response.text()
  return text ? { text } : null
}

function unwrapData(body) {
  if (isRecord(body) && Object.prototype.hasOwnProperty.call(body, 'data')) return body.data
  return body
}

async function parseOk(response, pathHint) {
  const data = await parseBody(response)
  if (!response.ok) {
    const detail = formatCalErrorDetail(data)
    const suffix = detail ? `: ${detail.slice(0, ERROR_DETAIL_MAX)}` : ''
    const error = new Error(
      `Cal.com API request failed with ${response.status} ${response.statusText}${suffix}`,
    )
    error.status = response.status
    error.statusText = response.statusText
    error.path = pathHint
    error.data = data
    throw error
  }
  return data
}

async function callParsed(fn, input, pathHint) {
  return unwrapData(await parseOk(await fn(input), pathHint))
}

async function eventTypeCatalogSuffix() {
  try {
    return ` ${formatEventTypeCatalog(await listEventTypes())}`
  } catch {
    return ''
  }
}

async function withEventTypeCatalogOn404(work) {
  try {
    return await work()
  } catch (error) {
    if (error?.status !== 404) throw error
    const suffix = await eventTypeCatalogSuffix()
    if (suffix) error.message = `${error.message}${suffix}`
    throw error
  }
}

/**
 * Escape-hatch Cal.com API v2 request. Prefer named helpers when available.
 * Path is relative to https://api.cal.com (include `/v2/...`) or legacy `/me`-style
 * paths which are rewritten to `/v2/...`.
 */
export async function calRequest(input = {}) {
  if (!isRecord(input)) throw new Error('Cal.com request input must be an object.')

  let path = input.path
  if (typeof path !== 'string' || path.trim() === '') {
    throw new Error('Cal.com request requires a non-empty path string.')
  }
  if (/^https?:\/\//i.test(path)) {
    throw new Error('Cal.com request path must be relative, for example /v2/me or /me.')
  }
  path = path.startsWith('/') ? path : `/${path}`
  if (!path.startsWith('/v2/') && path !== '/v2') {
    path = `/v2${path}`
  }

  const method = String(input.method ?? (input.body === undefined ? 'GET' : 'POST')).toUpperCase()
  const headers = {
    ...versionHeaders(input.apiVersion ?? DEFAULT_API_VERSION),
    ...(input.headers ?? {}),
  }
  const response = await rawCalRequest(path, {
    method,
    query: input.query,
    headers,
    body: input.body,
  })
  const data = await parseOk(response, path)
  return { status: response.status, data }
}

export async function getProfile(input = {}) {
  return callParsed(
    () =>
      mecontroller_getme({
        headers: {
          ...versionHeaders(input.apiVersion),
          ...(input.headers ?? {}),
        },
      }),
    input,
    '/v2/me',
  )
}

export async function smokeTest() {
  const profile = await getProfile()
  const summary = {
    ok: true,
    profile: {
      id: profile.id,
      username: profile.username,
      email: profile.email,
      name: profile.name,
      timeZone: profile.timeZone,
      defaultScheduleId: profile.defaultScheduleId,
    },
    eventTypeCount: 0,
    eventTypeId: null,
    checks: {
      profile: true,
      eventType: 'skipped-no-event-types',
      slots: 'skipped-no-event-types',
    },
  }

  const catalog = await listEventTypes()
  const picked = firstCatalogEventType(catalog)
  summary.eventTypeCount = flattenEventTypes(catalog).length

  if (!picked) return summary

  const eventType = await getEventType({ id: picked.id })
  const start = utcDateOnly(0)
  const end = utcDateOnly(7)
  const slots = await getAvailableSlots({
    eventTypeId: picked.id,
    start,
    end,
  })
  summary.eventTypeId = picked.id
  summary.eventTypeTitle = eventType?.title ?? eventType?.name ?? picked.title ?? picked.name ?? null
  summary.checks.eventType = true
  summary.checks.slots = Boolean(slots)
  return summary
}

export async function listBookings(input = {}) {
  return callParsed(
    () =>
      bookingscontroller_2026_05_01_getbookings({
        query: queryFromInput(input),
        headers: {
          ...versionHeaders(input.apiVersion ?? BOOKINGS_LIST_API_VERSION),
          ...(input.headers ?? {}),
        },
      }),
    input,
    '/v2/bookings',
  )
}

export async function getBooking(input = {}) {
  const bookingUid = bookingUidFromInput(input)
  return callParsed(
    () =>
      bookingscontroller_2026_02_25_getbooking({
        params: { bookingUid },
        headers: {
          ...versionHeaders(input.apiVersion ?? BOOKING_API_VERSION),
          ...(input.headers ?? {}),
        },
      }),
    input,
    `/v2/bookings/${bookingUid}`,
  )
}

export async function createBooking(input = {}) {
  const body = normalizeCreateBookingBody(bodyFromInput(input))
  return withEventTypeCatalogOn404(() =>
    callParsed(
      () =>
        bookingscontroller_2026_02_25_createbooking({
          body,
          headers: {
            ...versionHeaders(input.apiVersion ?? BOOKING_API_VERSION),
            ...(input.headers ?? {}),
          },
        }),
      input,
      '/v2/bookings',
    ),
  )
}

export async function cancelBooking(input = {}) {
  const bookingUid = bookingUidFromInput(input)
  return callParsed(
    () =>
      bookingscontroller_2026_02_25_cancelbooking({
        params: { bookingUid },
        body: bodyFromInput(input, ['bookingUid', 'uid']),
        headers: {
          ...versionHeaders(input.apiVersion ?? BOOKING_API_VERSION),
          ...(input.headers ?? {}),
        },
      }),
    input,
    `/v2/bookings/${bookingUid}/cancel`,
  )
}

export async function rescheduleBooking(input = {}) {
  const bookingUid = bookingUidFromInput(input)
  return callParsed(
    () =>
      bookingscontroller_2026_02_25_reschedulebooking({
        params: { bookingUid },
        body: bodyFromInput(input, ['bookingUid', 'uid']),
        headers: {
          ...versionHeaders(input.apiVersion ?? BOOKING_API_VERSION),
          ...(input.headers ?? {}),
        },
      }),
    input,
    `/v2/bookings/${bookingUid}/reschedule`,
  )
}

export async function listEventTypes(input = {}) {
  return callParsed(
    () =>
      eventtypescontroller_2024_06_14_geteventtypes({
        query: queryFromInput(input),
        headers: {
          ...versionHeaders(input.apiVersion ?? EVENT_TYPES_API_VERSION),
          ...(input.headers ?? {}),
        },
      }),
    input,
    '/v2/event-types',
  )
}

async function fetchEventTypeById(id, input = {}) {
  return withEventTypeCatalogOn404(() =>
    callParsed(
      () =>
        eventtypescontroller_2024_06_14_geteventtypebyid({
          params: { eventTypeId: id },
          headers: {
            ...versionHeaders(input.apiVersion ?? EVENT_TYPES_API_VERSION),
            ...(input.headers ?? {}),
          },
        }),
      input,
      `/v2/event-types/${id}`,
    ),
  )
}

export async function getEventType(input = {}) {
  if (isSmokeInput(input)) {
    const payload = await listEventTypes()
    const picked = firstCatalogEventType(payload)
    if (!picked) return payload
    return fetchEventTypeById(picked.id, input)
  }

  const selector = eventTypeSelectorFromInput(input)
  if (!hasEventTypeLookupSelector(selector)) {
    return listEventTypes(input)
  }

  let id = selector.id
  if (id === undefined || id === null || id === '') {
    const payload = await listEventTypes()
    const resolved = resolveEventTypeFromCatalog(payload, selector)
    if (resolved?.id === undefined || resolved?.id === null || resolved?.id === '') {
      throw new Error(
        `Cal.com helper could not resolve that event type. ${formatEventTypeCatalog(payload)}`,
      )
    }
    id = resolved.id
  }

  return fetchEventTypeById(id, input)
}

async function fetchAvailableSlots(query, input = {}) {
  return withEventTypeCatalogOn404(() =>
    callParsed(
      () =>
        slotscontroller_2024_09_04_getavailableslots({
          query,
          headers: {
            ...versionHeaders(input.apiVersion ?? SLOTS_API_VERSION),
            ...(input.headers ?? {}),
          },
        }),
      input,
      '/v2/slots',
    ),
  )
}

/**
 * Available slots for an event type over a date range.
 * Accepts the same id/slug/title aliases as get-event-type. With no selector,
 * returns the event-type list instead of throwing. `{ smoke: true }` fetches
 * slots for a real catalog type instead of using a caller placeholder.
 */
export async function getAvailableSlots(input = {}) {
  if (isSmokeInput(input)) {
    const payload = await listEventTypes()
    const picked = firstCatalogEventType(payload)
    if (!picked) return payload
    const query = applyEventTypeIdToSlotsQuery(queryFromInput(input), picked.id)
    if (!query.start) query.start = utcDateOnly(0)
    if (!query.end) query.end = utcDateOnly(7)
    return fetchAvailableSlots(query, input)
  }

  const plan = planAvailableSlotsQuery(input)
  let query
  switch (plan.kind) {
    case 'list':
      return listEventTypes()
    case 'ready':
      query = plan.query
      break
    case 'resolve': {
      const payload = await listEventTypes()
      const resolved = resolveEventTypeFromCatalog(payload, plan.selector)
      if (resolved?.id === undefined || resolved?.id === null || resolved?.id === '') {
        throw new Error(
          `get-available-slots could not resolve that event type. ${formatEventTypeCatalog(payload)}`,
        )
      }
      query = applyEventTypeIdToSlotsQuery(plan.query, resolved.id)
      break
    }
    default: {
      throw new Error(`Unsupported available-slots plan: ${plan.kind}`)
    }
  }

  return fetchAvailableSlots(query, input)
}

export async function listWebhooks(input = {}) {
  return callParsed(
    () =>
      webhookscontroller_getwebhooks({
        query: queryFromInput(input),
        headers: {
          ...versionHeaders(input.apiVersion),
          ...(input.headers ?? {}),
        },
      }),
    input,
    '/v2/webhooks',
  )
}

export async function createWebhook(input = {}) {
  const body = normalizeCreateWebhookBody(bodyFromInput(input))
  return callParsed(
    () =>
      webhookscontroller_createwebhook({
        body,
        headers: {
          ...versionHeaders(input.apiVersion),
          ...(input.headers ?? {}),
        },
      }),
    input,
    '/v2/webhooks',
  )
}

/**
 * Compact booking-page summary for agents: profile + event types with booking URLs.
 */
export async function summarizeBookingPages(input = {}) {
  const [profile, eventTypes] = await Promise.all([getProfile(input), listEventTypes(input)])
  const username = profile?.username
	const types = Array.isArray(eventTypes)
		? eventTypes
		: (eventTypes?.eventTypeGroups ?? eventTypes?.eventTypes ?? [])
	const pages = (Array.isArray(types) ? types : []).map((eventType) => {
		const slug = eventType?.slug
		return {
			id: eventType?.id,
			title: eventType?.title ?? eventType?.name,
			slug,
			lengthInMinutes: eventType?.lengthInMinutes ?? eventType?.length,
			hidden: eventType?.hidden,
			bookingUrl:
				username && slug ? `https://cal.com/${username}/${slug}` : (eventType?.link ?? null),
		}
	})
  return {
    username,
    name: profile?.name,
    timeZone: profile?.timeZone,
    eventTypeCount: pages.length,
    pages,
  }
}

const actions = {
  request: calRequest,
  'get-profile': getProfile,
  profile: getProfile,
  'smoke-test': smokeTest,
  smoke: smokeTest,
  'list-bookings': listBookings,
  bookings: listBookings,
  'get-booking': getBooking,
  'create-booking': createBooking,
  'cancel-booking': cancelBooking,
  'reschedule-booking': rescheduleBooking,
  'list-event-types': listEventTypes,
  'event-types': listEventTypes,
  'get-event-type': getEventType,
  'get-available-slots': getAvailableSlots,
  slots: getAvailableSlots,
  'list-webhooks': listWebhooks,
  webhooks: listWebhooks,
  'create-webhook': createWebhook,
  'summarize-booking-pages': summarizeBookingPages,
  'booking-pages': summarizeBookingPages,
}

/**
 * Dispatch Cal.com helper actions such as `list-bookings` or `smoke-test`.
 * @param {Object} [input]
 * @param {string} [input.action] Action name. Defaults to `smoke-test`.
 * @returns {Promise<unknown>} Action-specific Cal.com API payload.
 * @example
 * import calCom from 'kody:@kentcdodds/cal-com'
 * const result = await calCom({ action: 'list-bookings', status: 'upcoming' })
 * // => [{ uid: '...', title: '...', ... }]
 */
export default async function calCom(input = {}) {
  const action = input.action ?? 'smoke-test'
  const handler = actions[action]
  if (!handler) {
    throw new Error(`Unsupported Cal.com action: ${action}`)
  }
  return await handler(input)
}