Skip to content

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

Package listing

@kody/calendly

src/setup.ts

180 lines · 6.2 KB · TypeScript
import type { CalendlyAuthMode } from './types.ts'
import { requireString } from './types.ts'

export const CALENDLY_API_BASE_URL = 'https://api.calendly.com'
export const CALENDLY_AUTHORIZE_URL = 'https://auth.calendly.com/oauth/authorize'
export const CALENDLY_TOKEN_URL = 'https://auth.calendly.com/oauth/token'
export const CALENDLY_API_HOST = 'api.calendly.com'
export const CALENDLY_AUTH_HOST = 'auth.calendly.com'
export const CALENDLY_REQUIRED_HOSTS = [CALENDLY_API_HOST, CALENDLY_AUTH_HOST] as const
export const CALENDLY_OAUTH_APP_URL = 'https://developer.calendly.com/'
export const CALENDLY_PAT_URL = 'https://calendly.com/integrations/api_webhooks'
export const CALENDLY_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const DEFAULT_INTEGRATION_NAME = 'calendly'
export const DEFAULT_PAT_SECRET = 'calendlyPat'

export const DEFAULT_OAUTH_SCOPES = [
	'users:read',
	'event_types:read',
	'scheduled_events:read',
	'scheduled_events:write',
] as const

export const CALENDLY_SCOPES = {
	'users:read': 'Read the authorizing user (`GET /users/me`).',
	'event_types:read': 'List and read event types and available times.',
	'event_types:write': 'Create or update event types (escape-hatch `./request` only).',
	'scheduled_events:read': 'Read scheduled events and invitees.',
	'scheduled_events:write': 'Create invitees, cancel events, or mark no-shows.',
} as const

export type CalendlyScope = keyof typeof CALENDLY_SCOPES

export type CalendlyOperation =
	| 'users.read'
	| 'eventTypes.read'
	| 'eventTypes.write'
	| 'scheduledEvents.read'
	| 'scheduledEvents.write'
	| 'unknownRead'
	| 'unknownMutation'

export function oauthConnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
	const name = requireString(provider, 'provider')
	const params = new URLSearchParams({
		provider: name,
		authorizeUrl: CALENDLY_AUTHORIZE_URL,
		tokenUrl: CALENDLY_TOKEN_URL,
		apiBaseUrl: CALENDLY_API_BASE_URL,
		scopes: DEFAULT_OAUTH_SCOPES.join(' '),
		flow: 'confidential',
		pkce: 'true',
		allowedHosts: CALENDLY_REQUIRED_HOSTS.join(','),
		dashboardUrl: CALENDLY_OAUTH_APP_URL,
		providerSetupInstructions: [
			'Create an OAuth app at https://developer.calendly.com/.',
			`Register redirect URI exactly ${CALENDLY_CALLBACK_URL}.`,
			'Use Production for live account data (Sandbox is for Calendly test data).',
			'Calendly shows the client secret only once.',
			'Paste the client id and client secret into this Kody form.',
			`Request scopes ${DEFAULT_OAUTH_SCOPES.join(' ')}.`,
			'Do not paste secrets into chat.',
		].join(' '),
	})
	return `https://kody.codes/connect/oauth?${params.toString()}`
}

export function patSetupUrl(secretName: string = DEFAULT_PAT_SECRET): string {
	const name = requireString(secretName, 'secretName')
	const params = new URLSearchParams({
		name,
		description: 'Calendly personal access token (Bearer token for api.calendly.com)',
		allowedHosts: CALENDLY_API_HOST,
		scope: 'user',
	})
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function reconnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
	return `https://kody.codes/connect/oauth?provider=${encodeURIComponent(provider)}`
}

export function scopeForOperation(operation: CalendlyOperation): CalendlyScope {
	switch (operation) {
		case 'users.read':
			return 'users:read'
		case 'eventTypes.read':
			return 'event_types:read'
		case 'eventTypes.write':
			return 'event_types:write'
		case 'scheduledEvents.read':
			return 'scheduled_events:read'
		case 'scheduledEvents.write':
			return 'scheduled_events:write'
		case 'unknownRead':
			return 'users:read'
		case 'unknownMutation':
			return 'scheduled_events:write'
		default: {
			const exhaustive: never = operation
			throw new Error(`Unsupported Calendly operation: ${String(exhaustive)}`)
		}
	}
}

export function nextStepForScope(
	scope: CalendlyScope,
	options: {
		authMode: CalendlyAuthMode
		integrationName: string
		secretName: string
	},
): string {
	switch (options.authMode) {
		case 'oauth':
			return [
				`Reconnect the "${options.integrationName}" OAuth integration and include the "${scope}" scope.`,
				reconnectUrl(options.integrationName),
				`Full BYO connect URL (scopes ${DEFAULT_OAUTH_SCOPES.join(' ')}): ${oauthConnectUrl(options.integrationName)}`,
			].join(' ')
		case 'pat':
			return [
				`Create a Calendly personal access token with the "${scope}" scope, then save it as ${options.secretName}.`,
				CALENDLY_PAT_URL,
				patSetupUrl(options.secretName),
			].join(' ')
		default: {
			const exhaustive: never = options.authMode
			throw new Error(`Unsupported Calendly auth mode: ${String(exhaustive)}`)
		}
	}
}

export function missingCredentialsMessage(options: {
	integrationName: string
	secretName: string
}): string {
	return [
		'Calendly credentials are missing.',
		'OAuth (recommended for shared/multi-account use): create an OAuth app at',
		CALENDLY_OAUTH_APP_URL,
		`with redirect URI ${CALENDLY_CALLBACK_URL}, then connect:`,
		oauthConnectUrl(options.integrationName),
		`Personal access token (fastest for one account): create a PAT at ${CALENDLY_PAT_URL}`,
		'then save it (do not paste the value in chat):',
		patSetupUrl(options.secretName),
		`Required API host: ${CALENDLY_API_HOST}. OAuth also needs ${CALENDLY_AUTH_HOST}.`,
	].join(' ')
}

export function isMutatingMethod(method: string): boolean {
	const normalized = method.toUpperCase()
	return (
		normalized === 'POST' ||
		normalized === 'PUT' ||
		normalized === 'PATCH' ||
		normalized === 'DELETE'
	)
}

export function inferOperationFromPath(
	method: string,
	path: string,
	explicit?: CalendlyOperation,
): CalendlyOperation {
	if (explicit) return explicit
	const mutating = isMutatingMethod(method)
	if (/\/invitees(?:\/|$)/.test(path) || /\/invitee_no_shows(?:\/|$)/.test(path)) {
		return mutating ? 'scheduledEvents.write' : 'scheduledEvents.read'
	}
	if (/\/scheduled_events(?:\/|$)/.test(path)) {
		return mutating ? 'scheduledEvents.write' : 'scheduledEvents.read'
	}
	if (/\/event_types(?:\/|$)/.test(path) || /\/event_type_available_times(?:\/|$)/.test(path)) {
		return mutating ? 'eventTypes.write' : 'eventTypes.read'
	}
	if (/\/users(?:\/|$)/.test(path)) {
		return mutating ? 'unknownMutation' : 'users.read'
	}
	return mutating ? 'unknownMutation' : 'unknownRead'
}