Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/cal-com

src/cal-com.js

539 lines · 14.7 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 { DEFAULT_BOOKING_BASE_URL, setupUrls } from './setup.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'

function isRecord(value) {
	return value !== null && typeof value === 'object' && !Array.isArray(value)
}

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

const AUTH_AND_CONTROL_KEYS = [
	'action',
	'apiVersion',
	'body',
	'headers',
	'params',
	'dryRun',
	'confirm',
	'secretName',
	'integrationName',
	'account',
	'bookingBaseUrl',
]

function queryFromInput(input, ignoredKeys = []) {
	if (isRecord(input?.query)) return input.query
	return withoutKeys(input, [...AUTH_AND_CONTROL_KEYS, ...ignoredKeys])
}

function bodyFromInput(input, ignoredKeys = []) {
	if (isRecord(input?.body)) return input.body
	return withoutKeys(input, [...AUTH_AND_CONTROL_KEYS, 'query', ...ignoredKeys])
}

function authFromInput(input) {
	return {
		secretName: input?.secretName,
		integrationName: input?.integrationName,
		account: input?.account,
	}
}

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 }
}

function requireConfirm(input, action, path, body) {
	if (input?.dryRun) {
		return { dryRun: true, method: 'POST', path, body }
	}
	if (input?.confirm !== true) {
		throw new Error(
			`${action} writes live Cal.com state. Pass dryRun: true to preview, or confirm: true to apply.`,
		)
	}
	return null
}

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 error = new Error(`Cal.com API request failed with ${response.status} ${response.statusText}`)
		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))
}

/**
 * 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 mutating = !['GET', 'HEAD', 'OPTIONS'].includes(method)
	if (mutating) {
		const preview = requireConfirm(input, 'calRequest', path, input.body)
		if (preview) return { ...preview, method, query: input.query ?? null }
	} else if (input.dryRun) {
		return { dryRun: true, method, path, query: input.query ?? null, body: input.body ?? null }
	}

	const headers = {
		...versionHeaders(input.apiVersion ?? DEFAULT_API_VERSION),
		...(input.headers ?? {}),
	}
	const response = await rawCalRequest(path, {
		method,
		query: input.query,
		headers,
		body: input.body,
		...authFromInput(input),
	})
	const data = await parseOk(response, path)
	return { status: response.status, data }
}

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

function trimProfile(profile) {
	return {
		id: profile?.id,
		username: profile?.username,
		email: profile?.email,
		name: profile?.name,
		timeZone: profile?.timeZone,
		defaultScheduleId: profile?.defaultScheduleId,
	}
}

function isAuthSetupFailure(error) {
	const status = error?.status
	if (status === 401 || status === 403) return true
	const message = String(error?.message ?? error)
	return /secret|host approval|not connected|not approved|placeholder|calComApiKey|unresolved/i.test(
		message,
	)
}

/**
 * Verify Cal.com credentials with a trimmed `/me` read.
 * Without credentials, returns setup URLs instead of throwing.
 */
export async function smokeTest(input = {}) {
	const setup = setupUrls()
	if (input?.dryRun) {
		return { ok: true, live: false, dryRun: true, method: 'GET', path: '/v2/me', setup }
	}

	try {
		const profile = await getProfile(input)
		return { ok: true, live: true, profile: trimProfile(profile), setup }
	} catch (error) {
		if (isAuthSetupFailure(error)) {
			return {
				ok: true,
				live: false,
				setup,
				reason: error instanceof Error ? error.message : String(error),
				status: error?.status ?? null,
			}
		}
		throw error
	}
}

export async function listBookings(input = {}) {
	return callParsed(
		() =>
			bookingscontroller_2026_05_01_getbookings({
				...authFromInput(input),
				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({
				...authFromInput(input),
				params: { bookingUid },
				headers: {
					...versionHeaders(input.apiVersion ?? BOOKING_API_VERSION),
					...(input.headers ?? {}),
				},
			}),
		input,
		`/v2/bookings/${bookingUid}`,
	)
}

export async function createBooking(input = {}) {
	const body = bodyFromInput(input)
	if (!body.eventTypeId) {
		throw new Error('createBooking requires eventTypeId.')
	}
	if (!body.start) {
		throw new Error('createBooking requires start (UTC ISO timestamp).')
	}
	if (!isRecord(body.attendee) || !body.attendee.email || !body.attendee.name) {
		throw new Error('createBooking requires attendee.name and attendee.email.')
	}

	const preview = requireConfirm(input, 'createBooking', '/v2/bookings', body)
	if (preview) return preview

	return callParsed(
		() =>
			bookingscontroller_2026_02_25_createbooking({
				...authFromInput(input),
				body,
				headers: {
					...versionHeaders(input.apiVersion ?? BOOKING_API_VERSION),
					...(input.headers ?? {}),
				},
			}),
		input,
		'/v2/bookings',
	)
}

export async function cancelBooking(input = {}) {
	const bookingUid = bookingUidFromInput(input)
	const body = bodyFromInput(input, ['bookingUid', 'uid'])
	const path = `/v2/bookings/${bookingUid}/cancel`
	const preview = requireConfirm(input, 'cancelBooking', path, body)
	if (preview) return preview

	return callParsed(
		() =>
			bookingscontroller_2026_02_25_cancelbooking({
				...authFromInput(input),
				params: { bookingUid },
				body,
				headers: {
					...versionHeaders(input.apiVersion ?? BOOKING_API_VERSION),
					...(input.headers ?? {}),
				},
			}),
		input,
		path,
	)
}

export async function rescheduleBooking(input = {}) {
	const bookingUid = bookingUidFromInput(input)
	const body = bodyFromInput(input, ['bookingUid', 'uid'])
	if (!body.start) {
		throw new Error('rescheduleBooking requires start (UTC ISO timestamp).')
	}
	const path = `/v2/bookings/${bookingUid}/reschedule`
	const preview = requireConfirm(input, 'rescheduleBooking', path, body)
	if (preview) return preview

	return callParsed(
		() =>
			bookingscontroller_2026_02_25_reschedulebooking({
				...authFromInput(input),
				params: { bookingUid },
				body,
				headers: {
					...versionHeaders(input.apiVersion ?? BOOKING_API_VERSION),
					...(input.headers ?? {}),
				},
			}),
		input,
		path,
	)
}

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

export async function getEventType(input = {}) {
	const id = input?.id ?? input?.eventTypeId ?? input?.params?.eventTypeId
	if (id === undefined || id === null || id === '') {
		throw new Error('Cal.com helper requires id or eventTypeId.')
	}

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

/**
 * Available slots for an event type over a date range.
 * Returns the unwrapped Cal.com slots payload (date → slot list).
 */
export async function getAvailableSlots(input = {}) {
	const query = queryFromInput(input)
	if (query.eventTypeId === undefined || query.eventTypeId === null || query.eventTypeId === '') {
		throw new Error('getAvailableSlots requires eventTypeId.')
	}
	if (!query.start || !query.end) {
		throw new Error('getAvailableSlots requires start and end dates.')
	}

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

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

export async function createWebhook(input = {}) {
	const body = bodyFromInput(input)
	if (typeof body.subscriberUrl !== 'string' || body.subscriberUrl.trim() === '') {
		throw new Error('createWebhook requires subscriberUrl.')
	}

	const preview = requireConfirm(input, 'createWebhook', '/v2/webhooks', body)
	if (preview) return preview

	return callParsed(
		() =>
			webhookscontroller_createwebhook({
				...authFromInput(input),
				body,
				headers: {
					...versionHeaders(input.apiVersion),
					...(input.headers ?? {}),
				},
			}),
		input,
		'/v2/webhooks',
	)
}

function bookingUrlForEventType(eventType, username, bookingBaseUrl) {
	const explicit = eventType?.link ?? eventType?.bookingUrl
	if (typeof explicit === 'string' && /^https?:\/\//i.test(explicit)) return explicit

	const slug = eventType?.slug
	if (username && slug) return `${bookingBaseUrl}/${username}/${slug}`
	return null
}

function eventTypeListFromPayload(eventTypes) {
	if (Array.isArray(eventTypes)) return eventTypes
	if (Array.isArray(eventTypes?.eventTypeGroups)) {
		return eventTypes.eventTypeGroups.flatMap((group) =>
			Array.isArray(group?.eventTypes) ? group.eventTypes : [],
		)
	}
	if (Array.isArray(eventTypes?.eventTypes)) return eventTypes.eventTypes
	return []
}

/**
 * Compact booking-page summary: authenticated profile + event types with booking URLs.
 * URLs come from each event type's `link` when present, otherwise
 * `{bookingBaseUrl}/{username}/{slug}` using the connected account username.
 * No personal booking paths are hard-coded.
 */
export async function summarizeBookingPages(input = {}) {
	const [profile, eventTypes] = await Promise.all([getProfile(input), listEventTypes(input)])
	const username = profile?.username
	const bookingBaseUrl = String(input.bookingBaseUrl ?? DEFAULT_BOOKING_BASE_URL).replace(/\/$/, '')
	const pages = eventTypeListFromPayload(eventTypes).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: bookingUrlForEventType(eventType, username, bookingBaseUrl),
		}
	})
	return {
		username,
		name: profile?.name,
		timeZone: profile?.timeZone,
		bookingBaseUrl,
		eventTypeCount: pages.length,
		pages,
	}
}

const actions = {
	request: calRequest,
	'setup': () => setupUrls(),
	'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-event-types` or `smoke-test`.
 * @param {Object} [input]
 * @param {string} [input.action] Action name. Defaults to `smoke-test`.
 * @param {boolean} [input.dryRun] Preview mutating calls without writing.
 * @returns {Promise<unknown>} Action-specific Cal.com API payload.
 * @example
 * import calCom from 'kody:@kody/cal-com'
 * const result = await calCom({ action: 'list-event-types' })
 * // => [{ id: 123, title: '30 Min Meeting', ... }]
 */
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)
}