Skip to content
← Public packages

@kentcdodds/cal-com

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

src/helpers.js

376 lines · 12.6 KB · JavaScript
export 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 }
}