← Public packages
@kentcdodds/cal-com
Cal.com helpers for booking pages, event types, slots, and webhooks.
src/helpers.js
376 lines · 12.6 KB · JavaScriptexport function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function collectConstraintMessages(errors, into = []) {
if (!Array.isArray(errors)) return into
for (const item of errors) {
if (!isRecord(item)) continue
if (isRecord(item.constraints)) {
for (const value of Object.values(item.constraints)) {
if (typeof value === 'string' && value.trim()) into.push(value.trim())
}
}
if (Array.isArray(item.children) && item.children.length) {
collectConstraintMessages(item.children, into)
}
}
return into
}
/**
* Pull a short human-readable reason from a Cal.com error JSON body.
* Returns null when nothing useful is present.
*/
export function formatCalErrorDetail(data) {
if (!isRecord(data)) return null
const err = isRecord(data.error) ? data.error : data
const generic = err.message === 'Bad Request Exception'
if (typeof err.message === 'string' && err.message.trim() && !generic) {
return err.message.trim()
}
const details = isRecord(err.details) ? err.details : null
if (details) {
const constraints = collectConstraintMessages(details.errors)
if (constraints.length) return constraints.join('; ')
if (typeof details.message === 'string' && details.message.trim()) {
return details.message.trim()
}
}
if (typeof err.message === 'string' && err.message.trim()) return err.message.trim()
return null
}
export function normalizeCreateBookingBody(body) {
if (!isRecord(body)) {
throw new Error('create-booking requires an object with eventTypeId, start, and attendee')
}
const attendeeIn = isRecord(body.attendee) ? body.attendee : null
if (!attendeeIn) {
throw new Error('create-booking requires attendee: { name, email, timeZone }')
}
const name = typeof attendeeIn.name === 'string' ? attendeeIn.name.trim() : ''
const email = typeof attendeeIn.email === 'string' ? attendeeIn.email.trim() : ''
if (!name) throw new Error('create-booking requires attendee.name')
if (!email) throw new Error('create-booking requires attendee.email')
const timeZone =
(typeof attendeeIn.timeZone === 'string' && attendeeIn.timeZone.trim()) ||
(typeof body.timeZone === 'string' && body.timeZone.trim()) ||
'UTC'
const start = typeof body.start === 'string' ? body.start.trim() : ''
if (!start) {
throw new Error('create-booking requires start (UTC ISO 8601 date-time)')
}
if (!hasEventTypeSelector(body)) {
throw new Error('create-booking requires eventTypeId (or eventTypeSlug + username/teamSlug)')
}
const next = {
...body,
start,
attendee: { ...attendeeIn, name, email, timeZone },
}
delete next.timeZone
return next
}
/**
* Normalize a Cal.com event-type list payload into a flat array of records.
* Accepts a bare array, `{ eventTypes }`, or `{ eventTypeGroups: [{ eventTypes }] }`.
*/
export function flattenEventTypes(payload) {
if (Array.isArray(payload)) return payload.filter(isRecord)
if (!isRecord(payload)) return []
if (Array.isArray(payload.eventTypes)) return payload.eventTypes.filter(isRecord)
if (Array.isArray(payload.eventTypeGroups)) {
return payload.eventTypeGroups.flatMap((group) =>
isRecord(group) && Array.isArray(group.eventTypes)
? group.eventTypes.filter(isRecord)
: [],
)
}
return []
}
/**
* Short catalog of event type ids/titles/slugs for unknown-id errors.
* Does not inspect or follow text from Cal.com error bodies.
*/
export function formatEventTypeCatalog(payload, { limit = 8 } = {}) {
const types = flattenEventTypes(payload)
if (types.length === 0) {
return 'No event types found on this Cal.com account. Create one, then retry with that id.'
}
const items = types.slice(0, limit).map((eventType) => {
const id = eventType.id ?? '?'
const title =
typeof eventType.title === 'string'
? eventType.title
: typeof eventType.name === 'string'
? eventType.name
: 'untitled'
const slug = typeof eventType.slug === 'string' && eventType.slug ? ` (${eventType.slug})` : ''
return `${id} "${title}"${slug}`
})
const more = types.length > limit ? `; +${types.length - limit} more` : ''
return `Available event types: ${items.join('; ')}${more}. Call list-event-types or summarize-booking-pages to pick a real id.`
}
export function hasEventTypeSelector(record) {
if (!isRecord(record)) return false
const hasEventTypeId =
record.eventTypeId !== undefined && record.eventTypeId !== null && record.eventTypeId !== ''
const slug = typeof record.eventTypeSlug === 'string' ? record.eventTypeSlug.trim() : ''
const username = typeof record.username === 'string' ? record.username.trim() : ''
const teamSlug = typeof record.teamSlug === 'string' ? record.teamSlug.trim() : ''
return Boolean(hasEventTypeId || (slug && (username || teamSlug)))
}
export function isNumericEventTypeId(value) {
if (typeof value === 'number') return Number.isInteger(value) && value > 0
if (typeof value !== 'string') return false
return /^\d+$/.test(value.trim())
}
function firstNonEmptyString(...values) {
for (const value of values) {
if (typeof value === 'string' && value.trim()) return value.trim()
}
return ''
}
function eventTypeDisplayName(eventType) {
if (typeof eventType?.title === 'string' && eventType.title.trim()) return eventType.title
if (typeof eventType?.name === 'string' && eventType.name.trim()) return eventType.name
return ''
}
/**
* Read id/slug/title aliases from a get-event-type input.
* Non-numeric `id` / `eventTypeId` values are treated as slug or title.
*/
export function eventTypeSelectorFromInput(input) {
if (!isRecord(input)) return { id: undefined, slug: '', title: '' }
const params = isRecord(input.params) ? input.params : {}
const rawId = input.id ?? input.eventTypeId ?? params.eventTypeId ?? params.id
const slug = firstNonEmptyString(
input.slug,
input.eventTypeSlug,
params.slug,
params.eventTypeSlug,
)
const title = firstNonEmptyString(input.title, input.name, params.title, params.name)
if (rawId !== undefined && rawId !== null && rawId !== '') {
if (isNumericEventTypeId(rawId)) {
return {
id: typeof rawId === 'number' ? rawId : rawId.trim(),
slug,
title,
}
}
if (typeof rawId === 'string' && rawId.trim()) {
const token = rawId.trim()
return { id: undefined, slug: slug || token, title: title || token }
}
}
return { id: undefined, slug, title }
}
export function hasEventTypeLookupSelector(selector) {
if (!isRecord(selector)) return false
const hasId = selector.id !== undefined && selector.id !== null && selector.id !== ''
const slug = typeof selector.slug === 'string' ? selector.slug.trim() : ''
const title = typeof selector.title === 'string' ? selector.title.trim() : ''
return Boolean(hasId || slug || title)
}
/**
* Resolve one event type from a list/catalog payload.
* Returns null when nothing uniquely matches.
*/
export function resolveEventTypeFromCatalog(payload, selector = {}) {
const types = flattenEventTypes(payload)
if (!isRecord(selector) || types.length === 0) return null
const id = selector.id
if (id !== undefined && id !== null && id !== '') {
const byId = types.find((eventType) => String(eventType.id) === String(id))
if (byId) return byId
}
const slug = typeof selector.slug === 'string' ? selector.slug.trim().toLowerCase() : ''
if (slug) {
const bySlug = types.filter(
(eventType) => typeof eventType.slug === 'string' && eventType.slug.toLowerCase() === slug,
)
if (bySlug.length === 1) return bySlug[0]
}
const title = typeof selector.title === 'string' ? selector.title.trim().toLowerCase() : ''
if (title) {
const byTitle = types.filter((eventType) => eventTypeDisplayName(eventType).toLowerCase() === title)
if (byTitle.length === 1) return byTitle[0]
}
return null
}
const SLOTS_INPUT_META_KEYS = ['action', 'apiVersion', 'body', 'headers', 'params', 'smoke']
const SLOTS_ALIAS_KEYS = ['id', 'slug', 'title', 'name']
export function isSmokeInput(input) {
return isRecord(input) && input.smoke === true
}
export function firstCatalogEventType(payload) {
return (
flattenEventTypes(payload).find((eventType) => {
return eventType.id !== undefined && eventType.id !== null && eventType.id !== ''
}) ?? null
)
}
export function utcDateOnly(offsetDays = 0, now = new Date()) {
const date = new Date(now.getTime())
date.setUTCDate(date.getUTCDate() + offsetDays)
return date.toISOString().slice(0, 10)
}
function omitKeys(record, keys) {
const blocked = new Set(keys)
return Object.fromEntries(
Object.entries(record ?? {})
.filter(([, value]) => value !== undefined)
.filter(([key]) => !blocked.has(key)),
)
}
export function applyEventTypeIdToSlotsQuery(query, eventTypeId) {
const next = isRecord(query) ? { ...query, eventTypeId } : { eventTypeId }
return omitKeys(next, SLOTS_ALIAS_KEYS)
}
/**
* Decide how get-available-slots should proceed before calling Cal.com.
* - ready: query already has eventTypeId or slug+username/teamSlug, or a numeric id alias
* - list: no selector at all — return list-event-types instead of throwing
* - resolve: slug/title alias needs the local catalog
*/
export function planAvailableSlotsQuery(input = {}) {
const query = isRecord(input?.query)
? omitKeys(input.query, [])
: omitKeys(input, SLOTS_INPUT_META_KEYS)
if (hasEventTypeSelector(query)) {
return { kind: 'ready', query }
}
const selector = eventTypeSelectorFromInput(input)
if (!hasEventTypeLookupSelector(selector)) {
return { kind: 'list' }
}
if (selector.id !== undefined && selector.id !== null && selector.id !== '') {
return { kind: 'ready', query: applyEventTypeIdToSlotsQuery(query, selector.id) }
}
return { kind: 'resolve', query, selector }
}
/**
* Official Cal.com API v2 webhook trigger enum from CreateWebhookInputDto
* (https://cal.com/docs/api-reference/v2/webhooks/create-a-webhook).
* Docs examples sometimes list unofficial aliases such as BOOKING_CONFIRMED;
* those are not accepted by POST /v2/webhooks.
*/
export const WEBHOOK_TRIGGERS = Object.freeze([
'BOOKING_CREATED',
'BOOKING_PAYMENT_INITIATED',
'BOOKING_PAID',
'BOOKING_RESCHEDULED',
'BOOKING_REQUESTED',
'BOOKING_CANCELLED',
'BOOKING_REJECTED',
'BOOKING_NO_SHOW_UPDATED',
'BOOKING_LOCATION_UPDATED',
'BOOKING_REASSIGNED',
'FORM_SUBMITTED',
'MEETING_ENDED',
'MEETING_STARTED',
'RECORDING_READY',
'INSTANT_MEETING',
'INSTANT_MEETING_ACCEPTED',
'RECORDING_TRANSCRIPTION_GENERATED',
'OOO_CREATED',
'AFTER_HOSTS_CAL_VIDEO_NO_SHOW',
'AFTER_GUESTS_CAL_VIDEO_NO_SHOW',
'FORM_SUBMITTED_NO_EVENT',
'ROUTING_FORM_FALLBACK_HIT',
'DELEGATION_CREDENTIAL_ERROR',
'WRONG_ASSIGNMENT_REPORT',
'DELEGATION_CREDENTIAL_SECRET_ROTATION_FAILED',
'DELEGATION_CREDENTIAL_ROTATION_REQUIRED',
'DELEGATION_CREDENTIAL_SECRET_ROTATED',
'CALENDAR_ENTRY_REJECTED',
])
const WEBHOOK_TRIGGER_SET = new Set(WEBHOOK_TRIGGERS)
export function canonicalizeWebhookTrigger(value) {
return String(value)
.trim()
.replace(/[\s.\-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.toUpperCase()
}
function webhookTriggerList(value) {
if (Array.isArray(value)) return value
if (typeof value === 'string') return [value]
return []
}
export function normalizeCreateWebhookBody(body) {
if (!isRecord(body)) {
throw new Error('create-webhook requires an object with subscriberUrl and triggers')
}
const subscriberUrl = typeof body.subscriberUrl === 'string' ? body.subscriberUrl.trim() : ''
if (!subscriberUrl) {
throw new Error('create-webhook requires subscriberUrl')
}
const rawTriggers = webhookTriggerList(body.triggers).filter(
(value) => typeof value === 'string' && value.trim(),
)
if (rawTriggers.length === 0) {
throw new Error('create-webhook requires triggers (e.g. ["BOOKING_CREATED"])')
}
const seen = new Set()
const triggers = []
const unknown = []
for (const value of rawTriggers) {
const canonical = canonicalizeWebhookTrigger(value)
if (!WEBHOOK_TRIGGER_SET.has(canonical)) {
unknown.push(value)
continue
}
if (seen.has(canonical)) continue
seen.add(canonical)
triggers.push(canonical)
}
if (unknown.length) {
const listed = unknown.map((value) => JSON.stringify(value)).join(', ')
throw new Error(
`create-webhook trigger${unknown.length === 1 ? '' : 's'} ${listed} ${unknown.length === 1 ? 'is' : 'are'} not supported. Allowed: ${WEBHOOK_TRIGGERS.join(', ')}.`,
)
}
const active = typeof body.active === 'boolean' ? body.active : true
return { ...body, subscriberUrl, triggers, active }
}