import { calendlyFetch, resolveCalendlyAuth, type ResolvedCalendlyAuth } from './auth.ts'
import type { CalendlyAuthInput, CalendlyPageInfo, JsonRecord } from './types.ts'
import { requireString } from './types.ts'
import {
CALENDLY_API_BASE_URL,
CALENDLY_API_HOST,
inferOperationFromPath,
isMutatingMethod,
nextStepForScope,
scopeForOperation,
type CalendlyOperation,
} from './setup.ts'
export class CalendlyApiError extends Error {
readonly status: number
readonly operation: CalendlyOperation
readonly details: unknown
readonly missingScope: string | null
constructor(
message: string,
options: {
status: number
operation: CalendlyOperation
details: unknown
missingScope: string | null
},
) {
super(message)
this.name = 'CalendlyApiError'
this.status = options.status
this.operation = options.operation
this.details = options.details
this.missingScope = options.missingScope
}
}
export type CalendlyRequestInput = CalendlyAuthInput & {
path: string
method?: string
query?: JsonRecord
body?: JsonRecord
operation?: CalendlyOperation
}
export type CalendlyResponse<T> = {
data: T
pageInfo: CalendlyPageInfo
status: number
}
export function uuidFromUri(value: string): string {
const trimmed = requireString(value, 'uri')
const parts = trimmed.replace(/\/+$/, '').split('/')
const last = parts[parts.length - 1]
if (!last) throw new Error('Calendly URI is missing a uuid segment.')
return last
}
export function calendlyResourceUri(kind: string, idOrUri: string): string {
const trimmed = requireString(idOrUri, kind)
if (trimmed.startsWith('https://')) {
const url = new URL(trimmed)
if (url.host !== CALENDLY_API_HOST) {
throw new Error(`Calendly resource URIs must use host ${CALENDLY_API_HOST}. Got ${url.host}.`)
}
return `${url.origin}${url.pathname}`.replace(/\/+$/, '')
}
return `${CALENDLY_API_BASE_URL}/${kind}/${trimmed}`
}
export function normalizeCalendlyPath(path: string): string {
const trimmed = requireString(path, 'path')
if (trimmed.startsWith('https://')) {
const url = new URL(trimmed)
if (url.host !== CALENDLY_API_HOST) {
throw new Error(`Calendly requests must use host ${CALENDLY_API_HOST}. Got ${url.host}.`)
}
return `${url.pathname}${url.search}`
}
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
}
export function calendlyUrl(path: string, query?: JsonRecord): string {
const normalized = normalizeCalendlyPath(path)
const url = new URL(normalized, CALENDLY_API_BASE_URL)
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue
if (typeof value === 'boolean' || typeof value === 'number') {
url.searchParams.set(key, String(value))
continue
}
if (typeof value === 'string') {
url.searchParams.set(key, value)
continue
}
throw new Error(`query.${key} must be a string, number, or boolean.`)
}
}
return url.toString()
}
function calendlyMessage(body: unknown, fallback: string): string {
if (!body || typeof body !== 'object') return fallback
const record = body as { title?: unknown; message?: unknown; details?: unknown }
const title = typeof record.title === 'string' ? record.title : ''
const message = typeof record.message === 'string' ? record.message : ''
const details = Array.isArray(record.details)
? record.details
.map((item) => {
if (!item || typeof item !== 'object') return ''
const detail = item as { parameter?: unknown; message?: unknown }
const parameter = typeof detail.parameter === 'string' ? detail.parameter : ''
const detailMessage = typeof detail.message === 'string' ? detail.message : ''
return [parameter, detailMessage].filter(Boolean).join(': ')
})
.filter(Boolean)
.join('; ')
: ''
return [title, message, details].filter(Boolean).join(' — ') || fallback
}
function looksLikeInsufficientScope(status: number, message: string): boolean {
if (status === 401 || status === 403) return true
const lower = message.toLowerCase()
return (
lower.includes('insufficient') ||
lower.includes('missing scope') ||
lower.includes('not authorized') ||
lower.includes('forbidden') ||
lower.includes('permission')
)
}
function pageInfoFromBody(body: unknown): CalendlyPageInfo {
const pagination =
body && typeof body === 'object' && (body as { pagination?: unknown }).pagination
? ((body as { pagination: JsonRecord }).pagination ?? {})
: {}
return {
count: typeof pagination.count === 'number' ? pagination.count : null,
nextPageToken: typeof pagination.next_page_token === 'string' ? pagination.next_page_token : null,
previousPageToken:
typeof pagination.previous_page_token === 'string' ? pagination.previous_page_token : null,
nextPage: typeof pagination.next_page === 'string' ? pagination.next_page : null,
previousPage: typeof pagination.previous_page === 'string' ? pagination.previous_page : null,
}
}
function dataFromBody(body: unknown): unknown {
if (!body || typeof body !== 'object') return body
const record = body as JsonRecord
if (Object.prototype.hasOwnProperty.call(record, 'collection')) return record.collection
if (Object.prototype.hasOwnProperty.call(record, 'resource')) return record.resource
return body
}
export async function calendlyRequest<T = unknown>(
input: CalendlyRequestInput,
): Promise<CalendlyResponse<T>> {
const method = (input.method ?? 'GET').toUpperCase()
const path = requireString(input.path, 'path')
const operation = inferOperationFromPath(method, normalizeCalendlyPath(path), input.operation)
const auth = await resolveCalendlyAuth(input)
return calendlyRequestWithAuth<T>(auth, {
method,
path,
query: input.query,
body: input.body,
operation,
})
}
export async function calendlyRequestWithAuth<T = unknown>(
auth: ResolvedCalendlyAuth,
input: {
method: string
path: string
query?: JsonRecord
body?: JsonRecord
operation: CalendlyOperation
},
): Promise<CalendlyResponse<T>> {
const url = calendlyUrl(input.path, input.query)
const headers = new Headers({
Accept: 'application/json',
'User-Agent': 'kody-calendly/1.0',
})
const init: RequestInit = { method: input.method, headers }
if (input.body !== undefined) {
headers.set('Content-Type', 'application/json')
init.body = JSON.stringify(input.body)
}
const response = await calendlyFetch(auth, url, init)
const text = await response.text()
let parsed: unknown = text
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = text
}
const message = calendlyMessage(parsed, response.statusText)
if (!response.ok || looksLikeInsufficientScope(response.status, message)) {
const missingScope = scopeForOperation(input.operation)
throw new CalendlyApiError(
[
`Calendly ${input.operation} failed (${response.status}): ${message}.`,
`This call needs the "${missingScope}" permission.`,
nextStepForScope(missingScope, {
authMode: auth.mode,
integrationName: auth.integrationName,
secretName: auth.secretName,
}),
].join(' '),
{
status: response.status,
operation: input.operation,
details: parsed,
missingScope,
},
)
}
return {
data: dataFromBody(parsed) as T,
pageInfo: pageInfoFromBody(parsed),
status: response.status,
}
}
export { isMutatingMethod, inferOperationFromPath }