From ba6a87b69967b990657ce6d23722f56faf0b3c03 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Mon, 26 Jan 2026 14:33:41 +0100 Subject: [PATCH] refactor(companion): split calcom.ts into modular service files (#27206) * refactor(companion): split calcom.ts into modular service files Split the monolithic 1692-line calcom.ts file into focused modules: - auth.ts: Authentication configuration and token management - bookings.ts: Booking CRUD operations and actions - conferencing.ts: Conferencing options - event-types.ts: Event type CRUD operations - private-links.ts: Private link management for event types - request.ts: Core HTTP request functionality - schedules.ts: Schedule CRUD operations - user.ts: User profile management - utils.ts: JSON parsing and payload sanitization utilities - webhooks.ts: Webhook management (global and event type specific) - index.ts: Re-exports all functions and maintains backward compatibility This improves code organization, maintainability, and makes it easier to understand and modify specific functionality. Co-Authored-By: peer@cal.com * fix(companion): avoid logging sensitive booking response data Replace logging of full responseText (which may contain PII like attendee emails and names) with responseLength for safer debugging. Addresses Cubic AI review feedback (confidence 9/10). Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- companion/services/calcom.ts | 1691 -------------------- companion/services/calcom/auth.ts | 91 ++ companion/services/calcom/bookings.ts | 588 +++++++ companion/services/calcom/conferencing.ts | 28 + companion/services/calcom/event-types.ts | 173 ++ companion/services/calcom/index.ts | 172 ++ companion/services/calcom/private-links.ts | 108 ++ companion/services/calcom/request.ts | 133 ++ companion/services/calcom/schedules.ts | 211 +++ companion/services/calcom/user.ts | 116 ++ companion/services/calcom/utils.ts | 149 ++ companion/services/calcom/webhooks.ts | 196 +++ 12 files changed, 1965 insertions(+), 1691 deletions(-) delete mode 100644 companion/services/calcom.ts create mode 100644 companion/services/calcom/auth.ts create mode 100644 companion/services/calcom/bookings.ts create mode 100644 companion/services/calcom/conferencing.ts create mode 100644 companion/services/calcom/event-types.ts create mode 100644 companion/services/calcom/index.ts create mode 100644 companion/services/calcom/private-links.ts create mode 100644 companion/services/calcom/request.ts create mode 100644 companion/services/calcom/schedules.ts create mode 100644 companion/services/calcom/user.ts create mode 100644 companion/services/calcom/utils.ts create mode 100644 companion/services/calcom/webhooks.ts diff --git a/companion/services/calcom.ts b/companion/services/calcom.ts deleted file mode 100644 index 422233c13a..0000000000 --- a/companion/services/calcom.ts +++ /dev/null @@ -1,1691 +0,0 @@ -import { fetchWithTimeout } from "@/utils/network"; -import { safeLogError, safeLogInfo } from "@/utils/safeLogger"; - -import type { - AddGuestInput, - AddGuestsResponse, - Booking, - BookingLimitsCount, - BookingLimitsDuration, - BookingParticipationResult, - BookingRecording, - BookingTranscript, - ConferencingOption, - ConferencingSession, - ConfirmationPolicy, - CreateEventTypeInput, - CreatePrivateLinkInput, - CreateWebhookInput, - EventType, - GetConferencingSessionsResponse, - GetRecordingsResponse, - GetTranscriptsResponse, - MarkAbsentRequest, - MarkAbsentResponse, - PrivateLink, - Schedule, - UpdateLocationResponse, - UpdatePrivateLinkInput, - UpdateWebhookInput, - UserProfile, - Webhook, -} from "./types"; - -const API_BASE_URL = "https://api.cal.com/v2"; - -const REQUEST_TIMEOUT_MS = 30000; - -/** - * Safely parse JSON response with structure validation. - * Validates that the input is a non-null object before returning. - * This prevents prototype pollution and ensures type safety. - */ -function safeParseJson(jsonString: string): Record | null { - if (typeof jsonString !== "string" || !jsonString.trim()) { - return null; - } - - try { - const parsed: unknown = JSON.parse(jsonString); - - // Validate it's a plain object (not null, array, or primitive) - if ( - parsed !== null && - typeof parsed === "object" && - !Array.isArray(parsed) && - Object.getPrototypeOf(parsed) === Object.prototype - ) { - return parsed as Record; - } - - // Also accept arrays as valid JSON responses - if (Array.isArray(parsed)) { - return { data: parsed } as Record; - } - - return null; - } catch { - return null; - } -} - -/** - * Safely parse JSON error response with structure validation. - * Validates the expected error response structure before returning. - */ -function safeParseErrorJson( - jsonString: string -): { error?: { message?: string }; message?: string } | null { - if (typeof jsonString !== "string" || !jsonString.trim()) { - return null; - } - - try { - const parsed: unknown = JSON.parse(jsonString); - - // Validate it's a plain object - if ( - parsed === null || - typeof parsed !== "object" || - Array.isArray(parsed) || - Object.getPrototypeOf(parsed) !== Object.prototype - ) { - return null; - } - - const obj = parsed as Record; - - // Validate expected error structure - must have error or message property with correct types - const hasValidErrorProp = - obj.error === undefined || - (typeof obj.error === "object" && - obj.error !== null && - ((obj.error as Record).message === undefined || - typeof (obj.error as Record).message === "string")); - - const hasValidMessageProp = obj.message === undefined || typeof obj.message === "string"; - - if (hasValidErrorProp && hasValidMessageProp) { - return obj as { error?: { message?: string }; message?: string }; - } - - return null; - } catch { - return null; - } -} - -// Authentication configuration -interface AuthConfig { - accessToken?: string; - refreshToken?: string; -} - -// Global auth state -const authConfig: AuthConfig = {}; - -// Token refresh callback - will be set by AuthContext -let tokenRefreshCallback: ((accessToken: string, refreshToken?: string) => Promise) | null = - null; - -// Refresh token function - will be set by AuthContext -let refreshTokenFunction: - | ((refreshToken: string) => Promise<{ accessToken: string; refreshToken?: string }>) - | null = null; - -// Re-export types for backward compatibility -export type { - EventType, - CreateEventTypeInput, - Booking, - BookingParticipationResult, - Schedule, - UserProfile, - ConferencingOption, - Webhook, - CreateWebhookInput, - UpdateWebhookInput, - PrivateLink, - CreatePrivateLinkInput, - UpdatePrivateLinkInput, - BookingLimitsCount, - BookingLimitsDuration, - ConfirmationPolicy, -}; - -export const getBookingParticipation = ( - booking: Booking, - userId?: number, - userEmail?: string -): BookingParticipationResult => { - const normalizedUserEmail = userEmail?.toLowerCase(); - const emailEq = (a?: string, b?: string) => a?.toLowerCase() === b?.toLowerCase(); - const idEq = (a?: string | number, b?: string | number) => - a !== undefined && b !== undefined && String(a) === String(b); - - const isOrganizer = - !!booking.user && - (idEq(booking.user.id, userId) || emailEq(booking.user.email, normalizedUserEmail)); - - const isHost = - Array.isArray(booking.hosts) && - booking.hosts.some((host) => idEq(host.id, userId) || emailEq(host.email, normalizedUserEmail)); - - const isAttendee = - Array.isArray(booking.attendees) && - booking.attendees.some( - (attendee) => idEq(attendee.id, userId) || emailEq(attendee.email, normalizedUserEmail) - ); - - const isParticipating = isOrganizer || isHost || isAttendee; - - return { isOrganizer, isHost, isAttendee, isParticipating }; -}; - -// Module-level state (previously private static) -let _userProfile: UserProfile | null = null; -// In-flight promise to prevent concurrent /me API calls -let _userProfilePromise: Promise | null = null; - -/** - * Set OAuth access token for authentication - */ -function setAccessToken(accessToken: string, refreshToken?: string): void { - authConfig.accessToken = accessToken; - if (refreshToken) { - authConfig.refreshToken = refreshToken; - } -} - -/** - * Set refresh token function for automatic token refresh - */ -function setRefreshTokenFunction( - refreshFn: (refreshToken: string) => Promise<{ accessToken: string; refreshToken?: string }> -): void { - refreshTokenFunction = refreshFn; -} - -/** - * Clear all authentication - */ -function clearAuth(): void { - authConfig.accessToken = undefined; - authConfig.refreshToken = undefined; - tokenRefreshCallback = null; - refreshTokenFunction = null; -} - -/** - * Set token refresh callback for OAuth token refresh - */ -function setTokenRefreshCallback( - callback: (accessToken: string, refreshToken?: string) => Promise -): void { - tokenRefreshCallback = callback; -} - -/** - * Get current authentication header - */ -function getAuthHeader(): string { - if (authConfig.accessToken) { - return `Bearer ${authConfig.accessToken}`; - } else { - throw new Error("No authentication configured. Please sign in with OAuth."); - } -} - -// Get current user profile -async function getCurrentUser(): Promise { - const response = await makeRequest<{ status: string; data: UserProfile }>( - "/me", - { - headers: { - "cal-api-version": "2024-06-11", - }, - }, - "2024-06-11" - ); - return response.data; -} - -// Update current user profile -async function updateUserProfile(updates: { - email?: string; - name?: string; - timeFormat?: number; - defaultScheduleId?: number; - weekStart?: string; - timeZone?: string; - locale?: string; - avatarUrl?: string; - bio?: string; - metadata?: Record; -}): Promise { - try { - const response = await makeRequest<{ status: string; data: UserProfile }>( - "/me", - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-06-11", - }, - body: JSON.stringify(updates), - }, - "2024-06-11" - ); - - if (response?.data) { - // Update cached profile - _userProfile = response.data; - return response.data; - } - - throw new Error("Invalid response from update user profile API"); - } catch (error) { - console.error("updateUserProfile error"); - throw error; - } -} - -// Get and cache user profile with in-flight deduplication -// This prevents multiple concurrent callers from each making a /me API call -async function getUserProfile(): Promise { - // Return cached profile if available - if (_userProfile) { - return _userProfile; - } - - // If there's already an in-flight request, wait for it instead of making a new one - if (_userProfilePromise) { - return _userProfilePromise; - } - - // Create a new request and cache the promise to deduplicate concurrent calls - _userProfilePromise = getCurrentUser() - .then((profile) => { - _userProfile = profile; - _userProfilePromise = null; - return profile; - }) - .catch((error) => { - _userProfilePromise = null; - throw error; - }); - - return _userProfilePromise; -} - -// Clear cached profile (useful for logout) -function clearUserProfile(): void { - _userProfile = null; - _userProfilePromise = null; -} - -// Test function for bookings API specifically -async function testRawBookingsAPI(): Promise { - try { - const url = `${API_BASE_URL}/bookings?status=upcoming&status=unconfirmed&limit=50`; - - const response = await fetchWithTimeout( - url, - { - headers: { - Authorization: getAuthHeader(), - "Content-Type": "application/json", - "cal-api-version": "2024-08-13", - }, - }, - REQUEST_TIMEOUT_MS - ); - - const responseText = await response.text(); - - if (responseText?.trim()) { - const _responseJson = safeParseJson(responseText); - if (!_responseJson) { - safeLogError("[CalComAPIService] Failed to parse bookings response", { - responseText, - }); - } - } - } catch (_error) { - safeLogError("[CalComAPIService] testRawBookingsAPI failed", { - error: _error, - }); - } -} - -async function makeRequest( - endpoint: string, - options: RequestInit = {}, - apiVersion: string = "2024-08-13", - isRetry: boolean = false -): Promise { - const url = `${API_BASE_URL}${endpoint}`; - - const response = await fetchWithTimeout( - url, - { - ...options, - headers: { - Authorization: getAuthHeader(), - "Content-Type": "application/json", - "cal-api-version": apiVersion, - ...options.headers, - }, - }, - REQUEST_TIMEOUT_MS - ); - - if (!response.ok) { - const errorBody = await response.text(); - - // Parse error for better user messages - let errorMessage = response.statusText; - - const errorJson = safeParseErrorJson(errorBody); - if (errorJson) { - errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText; - } else { - // If JSON parsing fails, use the raw error body - errorMessage = errorBody || response.statusText; - } - - // Handle specific error cases - if (response.status === 401) { - if (!isRetry && authConfig.refreshToken && refreshTokenFunction && tokenRefreshCallback) { - try { - const newTokens = await refreshTokenFunction(authConfig.refreshToken); - - authConfig.accessToken = newTokens.accessToken; - if (newTokens.refreshToken) { - authConfig.refreshToken = newTokens.refreshToken; - } - - // Notify AuthContext to update stored tokens - await tokenRefreshCallback(newTokens.accessToken, newTokens.refreshToken); - - // Retry the original request with the new token - return makeRequest(endpoint, options, apiVersion, true); - } catch (refreshError) { - safeLogError("Token refresh failed:", refreshError); - clearAuth(); - throw new Error("Authentication failed. Please sign in again."); - } - } - - if (errorMessage.includes("expired")) { - throw new Error("Your authentication has expired. Please sign in again."); - } - throw new Error("Authentication failed. Please check your credentials."); - } - - // Include status code in error message for graceful error handling downstream - throw new Error(`API Error: ${response.status} ${errorMessage}`); - } - - return response.json(); -} - -async function deleteEventType(eventTypeId: number): Promise { - try { - await makeRequest( - `/event-types/${eventTypeId}`, - { - method: "DELETE", - }, - "2024-06-14" - ); - } catch (error) { - safeLogError("Delete API error", { error, eventTypeId }); - throw error; - } -} - -async function createEventType(input: CreateEventTypeInput): Promise { - try { - const sanitizedInput = sanitizePayload(input as unknown as Record); - - const response = await makeRequest<{ status: string; data: EventType }>( - "/event-types", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-06-14", - }, - body: JSON.stringify(sanitizedInput), - }, - "2024-06-14" - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from create event type API"); - } catch (error) { - console.error("createEventType error"); - throw error; - } -} - -// Cancel a booking -async function cancelBooking(bookingUid: string, cancellationReason?: string): Promise { - const body: { cancellationReason?: string } = {}; - if (cancellationReason) { - body.cancellationReason = cancellationReason; - } - - await makeRequest( - `/bookings/${bookingUid}/cancel`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-08-13", - }, - body: JSON.stringify(body), - }, - "2024-08-13" - ); -} - -async function rescheduleBooking( - bookingUid: string, - input: { - start: string; - reschedulingReason?: string; - } -): Promise { - try { - safeLogInfo("[CalComAPIService] rescheduleBooking request:", { - bookingUid, - input, - }); - - const response = await makeRequest<{ status: string; data: Booking }>( - `/bookings/${bookingUid}/reschedule`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-08-13", - }, - body: JSON.stringify(input), - }, - "2024-08-13" - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from reschedule booking API"); - } catch (error) { - safeLogError("[CalComAPIService] rescheduleBooking error:", error); - if (error instanceof Error) { - safeLogError("[CalComAPIService] Error message:", error.message); - safeLogError("[CalComAPIService] Error stack:", error.stack); - } - throw error; - } -} - -async function confirmBooking(bookingUid: string): Promise { - try { - const response = await makeRequest<{ status: string; data: Booking }>( - `/bookings/${bookingUid}/confirm`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-08-13", - }, - }, - "2024-08-13" - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from confirm booking API"); - } catch (error) { - safeLogError("confirmBooking error", { error, bookingUid }); - throw error; - } -} - -async function declineBooking(bookingUid: string, reason?: string): Promise { - try { - const body: { reason?: string } = {}; - if (reason) { - body.reason = reason; - } - - const response = await makeRequest<{ status: string; data: Booking }>( - `/bookings/${bookingUid}/decline`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-08-13", - }, - body: JSON.stringify(body), - }, - "2024-08-13" - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from decline booking API"); - } catch (error) { - console.error("declineBooking error"); - throw error; - } -} - -/** - * Mark an attendee as absent (no-show) for a booking - * @param bookingUid - The unique identifier of the booking - * @param attendeeEmail - The email of the attendee to mark as absent - * @param absent - Whether to mark as absent (true) or undo (false) - */ -async function markAbsent( - bookingUid: string, - attendeeEmail: string, - absent: boolean = true -): Promise { - try { - const body: MarkAbsentRequest = { - attendees: [{ email: attendeeEmail, absent }], - }; - - const response = await makeRequest( - `/bookings/${bookingUid}/mark-absent`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-08-13", - }, - body: JSON.stringify(body), - }, - "2024-08-13" - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from mark absent API"); - } catch (error) { - console.error("markAbsent error"); - throw error; - } -} - -// ============================================================================ -// Booking Action API Methods (API v2 2024-08-13) -// ============================================================================ - -/** - * Add guests to a booking - * @param bookingUid - The unique identifier of the booking - * @param guests - Array of guests to add (email required, name optional) - * @returns Updated booking with new guests - */ -async function addGuests(bookingUid: string, guests: AddGuestInput[]): Promise { - try { - const response = await makeRequest( - `/bookings/${bookingUid}/guests`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-08-13", - }, - body: JSON.stringify({ guests }), - }, - "2024-08-13" - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from add guests API"); - } catch (error) { - console.error("addGuests error"); - throw error; - } -} - -/** - * Update the location of a booking (legacy - sends location as string) - * Note: This updates the booking location but may not update the calendar event - * @param bookingUid - The unique identifier of the booking - * @param location - The new location string - * @returns Updated booking with new location - * @deprecated Use updateLocationV2 instead for proper typed location updates - */ -async function updateLocation(bookingUid: string, location: string): Promise { - try { - const response = await makeRequest( - `/bookings/${bookingUid}/location`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-08-13", - }, - body: JSON.stringify({ location }), - }, - "2024-08-13" - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from update location API"); - } catch (error) { - console.error("updateLocation error"); - throw error; - } -} - -/** - * Update the location of a booking with typed location object - * Supports: address, link, phone, attendeePhone, attendeeAddress, attendeeDefined - * Note: Integration-based locations (Cal Video, Google Meet, Zoom) are NOT supported - * @param bookingUid - The unique identifier of the booking - * @param location - The location object with type and value - * @returns Updated booking with new location - */ -async function updateLocationV2( - bookingUid: string, - location: { type: string; [key: string]: string } -): Promise { - try { - const response = await makeRequest( - `/bookings/${bookingUid}/location`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-08-13", - }, - body: JSON.stringify({ location }), - }, - "2024-08-13" - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from update location API"); - } catch (error) { - safeLogError("[CalComAPIService] updateLocationV2 error:", error); - throw error; - } -} - -/** - * Get recordings for a booking (Cal Video only) - * Note: Only available for past Cal Video bookings - * @param bookingUid - The unique identifier of the booking - * @returns Array of recording objects with download URLs - */ -async function getRecordings(bookingUid: string): Promise { - try { - const response = await makeRequest( - `/bookings/${bookingUid}/recordings`, - { - headers: { - "cal-api-version": "2024-08-13", - }, - }, - "2024-08-13" - ); - - if (response?.data) { - return response.data; - } - - return []; - } catch (error) { - // Handle 401/403 gracefully - endpoint may require auth in future - if (error instanceof Error) { - const statusMatch = error.message.match(/API Error: (\d+)/); - if (statusMatch && (statusMatch[1] === "401" || statusMatch[1] === "403")) { - console.warn(`Recordings access denied for booking ${bookingUid}`); - return []; - } - if (statusMatch && statusMatch[1] === "404") { - console.warn(`No recordings found for booking ${bookingUid}`); - return []; - } - } - console.error("getRecordings error"); - throw error; - } -} - -/** - * Get conferencing session details for a booking (Cal Video only) - * Note: This endpoint is PBAC-guarded and requires booking.readRecordings permission - * @param bookingUid - The unique identifier of the booking - * @returns Array of conferencing session objects with participant info - */ -async function getConferencingSessions(bookingUid: string): Promise { - try { - const response = await makeRequest( - `/bookings/${bookingUid}/conferencing-sessions`, - { - headers: { - "cal-api-version": "2024-08-13", - }, - }, - "2024-08-13" - ); - - if (response?.data) { - return response.data; - } - - return []; - } catch (error) { - // Handle 401/403 gracefully - endpoint is PBAC-guarded - if (error instanceof Error) { - const statusMatch = error.message.match(/API Error: (\d+)/); - if (statusMatch && (statusMatch[1] === "401" || statusMatch[1] === "403")) { - console.warn(`Conferencing sessions access denied for booking ${bookingUid}`); - return []; - } - if (statusMatch && statusMatch[1] === "404") { - console.warn(`No conferencing sessions found for booking ${bookingUid}`); - return []; - } - } - console.error("getConferencingSessions error"); - throw error; - } -} - -/** - * Get transcripts for a booking (Cal Video only) - * Note: This endpoint may require authentication in future releases - * @param bookingUid - The unique identifier of the booking - * @returns Array of transcript objects with download URLs - */ -async function getTranscripts(bookingUid: string): Promise { - try { - const response = await makeRequest( - `/bookings/${bookingUid}/transcripts`, - { - headers: { - "cal-api-version": "2024-08-13", - }, - }, - "2024-08-13" - ); - - if (response?.data) { - return response.data; - } - - return []; - } catch (error) { - // Handle 401/403 gracefully - endpoint may require auth in future - if (error instanceof Error) { - const statusMatch = error.message.match(/API Error: (\d+)/); - if (statusMatch && (statusMatch[1] === "401" || statusMatch[1] === "403")) { - console.warn(`Transcripts access denied for booking ${bookingUid}`); - return []; - } - if (statusMatch && statusMatch[1] === "404") { - console.warn(`No transcripts found for booking ${bookingUid}`); - return []; - } - } - console.error("getTranscripts error"); - throw error; - } -} - -async function getEventTypes(): Promise { - // For authenticated users, no username/orgSlug params needed - API uses auth token - // This also ensures hidden event types are returned (they're filtered out when username is provided) - // Sort by creation date descending (newer first) to match main codebase behavior - const endpoint = `/event-types?sortCreatedAt=desc`; - - const response = await makeRequest(endpoint, {}, "2024-06-14"); - - // Handle different possible response structures - let eventTypesArray: EventType[] = []; - - if (Array.isArray(response)) { - eventTypesArray = response as EventType[]; - } else if (response && typeof response === "object") { - const resp = response as Record; - if (resp.data && Array.isArray(resp.data)) { - eventTypesArray = resp.data as EventType[]; - } else if (resp.eventTypes && Array.isArray(resp.eventTypes)) { - eventTypesArray = resp.eventTypes as EventType[]; - } else if (resp.items && Array.isArray(resp.items)) { - eventTypesArray = resp.items as EventType[]; - } else if (resp.data && typeof resp.data === "object") { - const dataObj = resp.data as Record; - if (dataObj.eventTypes && Array.isArray(dataObj.eventTypes)) { - eventTypesArray = dataObj.eventTypes as EventType[]; - } else if (dataObj.items && Array.isArray(dataObj.items)) { - eventTypesArray = dataObj.items as EventType[]; - } else { - const keys = Object.keys(dataObj); - if (keys.length > 0) { - eventTypesArray = Object.values(dataObj).filter((item): item is EventType => - Boolean(item && typeof item === "object" && "id" in item) - ); - } - } - } else { - const possibleArrays = Object.values(resp).filter((val) => Array.isArray(val)); - if (possibleArrays.length > 0) { - eventTypesArray = possibleArrays[0] as EventType[]; - } - } - } - - return eventTypesArray; -} - -async function getBookingByUid(bookingUid: string): Promise { - try { - const response = await makeRequest<{ status: string; data: Booking }>( - `/bookings/${bookingUid}`, - { - headers: { - "cal-api-version": "2024-08-13", - }, - }, - "2024-08-13" - ); - if (response?.data) { - return response.data; - } - throw new Error("Invalid response from get booking API"); - } catch (error) { - console.error("getBookingByUid error:", error); - throw error; - } -} - -async function getBookings(filters?: { - status?: string[]; - fromDate?: string; - toDate?: string; - eventTypeId?: number; - limit?: number; - offset?: number; -}): Promise { - // Build query parameters - const params = new URLSearchParams(); - - if (filters?.status?.length) { - for (const status of filters.status) { - params.append("status", status); - } - } - if (filters?.fromDate) { - params.append("fromDate", filters.fromDate); - } - if (filters?.toDate) { - params.append("toDate", filters.toDate); - } - if (filters?.eventTypeId) { - params.append("eventTypeId", filters.eventTypeId.toString()); - } - if (filters?.limit) { - params.append("limit", filters.limit.toString()); - } - if (filters?.offset) { - params.append("offset", filters.offset.toString()); - } - - const queryString = params.toString(); - const endpoint = `/bookings${queryString ? `?${queryString}` : ""}`; - - const response = await makeRequest(endpoint); - - // Handle different possible response structures (same logic as event types) - let bookingsArray: Booking[] = []; - - if (Array.isArray(response)) { - bookingsArray = response as Booking[]; - } else if (response && typeof response === "object") { - const resp = response as Record; - if (resp.data && Array.isArray(resp.data)) { - bookingsArray = resp.data as Booking[]; - } else if (resp.bookings && Array.isArray(resp.bookings)) { - bookingsArray = resp.bookings as Booking[]; - } else if (resp.items && Array.isArray(resp.items)) { - bookingsArray = resp.items as Booking[]; - } else if (resp.data && typeof resp.data === "object") { - const dataObj = resp.data as Record; - if (dataObj.bookings && Array.isArray(dataObj.bookings)) { - bookingsArray = dataObj.bookings as Booking[]; - } else if (dataObj.items && Array.isArray(dataObj.items)) { - bookingsArray = dataObj.items as Booking[]; - } else { - // Convert object values to array as last resort - const keys = Object.keys(dataObj); - if (keys.length > 0) { - bookingsArray = Object.values(dataObj).filter((item): item is Booking => - Boolean(item && typeof item === "object" && ("id" in item || "uid" in item)) - ); - } - } - } else { - // Try to extract any arrays from the response - const possibleArrays = Object.values(resp).filter((val) => Array.isArray(val)); - if (possibleArrays.length > 0) { - bookingsArray = possibleArrays[0] as Booking[]; - } - } - } - - // Get cached user profile to filter bookings (uses in-flight deduplication) - let userProfile: UserProfile | undefined; - try { - userProfile = await getUserProfile(); - } catch (_error) { - return bookingsArray; - } - - // Extract user info from response - let userId: number | undefined; - let userEmail: string | undefined; - - if (userProfile) { - userId = userProfile.id; - userEmail = userProfile.email; - } - - // Filter bookings to only show ones where the current user is participating - const userBookings = bookingsArray.filter((booking) => { - const { - isOrganizer: _isOrganizer, - isHost: _isHost, - isAttendee: _isAttendee, - isParticipating, - } = getBookingParticipation(booking, userId, userEmail); - - return isParticipating; - }); - - return userBookings; -} - -// Get all schedules -async function getSchedules(): Promise { - const response = await makeRequest<{ status: string; data: Schedule[] }>( - "/schedules", - { - headers: { - "cal-api-version": "2024-06-11", // Override version for schedules - }, - }, - "2024-06-11" - ); - - let schedulesArray: Schedule[] = []; - - // Handle response structure: { status: "success", data: [...] } - if (response && response.status === "success" && response.data && Array.isArray(response.data)) { - schedulesArray = response.data; - } else if (Array.isArray(response)) { - // Fallback: response might be array directly - schedulesArray = response; - } else if (response?.data && Array.isArray(response.data)) { - // Fallback: response.data might be array - schedulesArray = response.data; - } - - return schedulesArray; -} - -// Create a new schedule -async function createSchedule(input: { - name: string; - timeZone: string; - isDefault?: boolean; - availability?: Array<{ - days: string[]; // e.g., ["Monday", "Tuesday"] - startTime: string; // e.g., "09:00" - endTime: string; // e.g., "17:00" - }>; - overrides?: Array<{ - date: string; // e.g., "2024-05-20" - startTime: string; // e.g., "12:00" - endTime: string; // e.g., "14:00" - }>; -}): Promise { - try { - const sanitizedInput = sanitizePayload(input as Record); - - const response = await makeRequest<{ status: string; data: Schedule }>( - "/schedules", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-06-11", - }, - body: JSON.stringify(sanitizedInput), - }, - "2024-06-11" - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from create schedule API"); - } catch (error) { - console.error("createSchedule error"); - throw error; - } -} - -async function getScheduleById(scheduleId: number): Promise { - try { - const response = await makeRequest( - `/schedules/${scheduleId}`, - { - headers: { - "cal-api-version": "2024-06-11", - }, - }, - "2024-06-11" - ); - - if (response && typeof response === "object") { - const resp = response as Record; - if (resp.data && typeof resp.data === "object") { - return resp.data as Schedule; - } - if (resp.id) { - return response as Schedule; - } - } - - return null; - } catch (error) { - console.error("getScheduleById error"); - throw error; - } -} - -// Get conferencing options -async function getConferencingOptions(): Promise { - try { - const response = await makeRequest<{ - status: string; - data: ConferencingOption[]; - }>("/conferencing"); - - if (response?.data && Array.isArray(response.data)) { - return response.data; - } - - return []; - } catch (error) { - console.error("getConferencingOptions error"); - throw error; - } -} - -// Get a single event type by ID -async function getEventTypeById(eventTypeId: number): Promise { - try { - const response = await makeRequest<{ status: string; data: EventType }>( - `/event-types/${eventTypeId}`, - {}, - "2024-06-14" - ); - - if (response?.data) { - return response.data; - } - - return null; - } catch (error) { - // Handle 404 errors gracefully - resource doesn't exist or user doesn't have access - if (error instanceof Error) { - // Check if error message contains 404 status code - const statusMatch = error.message.match(/API Error: (\d+)/); - if (statusMatch && statusMatch[1] === "404") { - console.warn(`Event type ${eventTypeId} not found`); - return null; - } - } - console.error("getEventTypeById error"); - throw error; - } -} - -// Update an event type -/** - * Sanitizes a payload before sending to the API. - * - Removes keys with null values for array fields (API expects arrays or field to be omitted) - * - Removes keys with undefined values - * - Recursively sanitizes nested objects - */ -function sanitizePayload(payload: Record): Record { - const sanitized: Record = {}; - - // Fields that should NEVER be sent as null - API expects array or omit entirely - const arrayFields = [ - "lengthInMinutesOptions", - "multipleDuration", - "locations", - "bookingFields", - "hosts", - "children", - "customInputs", - ]; - - // Fields that can be null (to clear the value) - const nullableFields = [ - "description", - "successRedirectUrl", - "slotInterval", - "eventName", - "timeZone", - "interfaceLanguage", - ]; - - for (const [key, value] of Object.entries(payload)) { - // Skip undefined values - if (value === undefined) continue; - - // Handle null values - if (value === null) { - // For array fields, skip entirely (don't send null) - if (arrayFields.includes(key)) { - console.warn(`Skipping null value for array field: ${key}`); - continue; - } - // For nullable fields, allow null - if (nullableFields.includes(key)) { - sanitized[key] = null; - continue; - } - // For other fields, skip null to be safe - console.warn(`Skipping null value for field: ${key}`); - continue; - } - - // Recursively sanitize nested objects (but not arrays) - if (typeof value === "object" && !Array.isArray(value)) { - const sanitizedNested = sanitizePayload(value as Record); - // Only include if the nested object has values - if (Object.keys(sanitizedNested).length > 0) { - sanitized[key] = sanitizedNested; - } - } else { - sanitized[key] = value; - } - } - - return sanitized; -} - -async function updateEventType( - eventTypeId: number, - updates: Partial -): Promise { - try { - const sanitizedUpdates = sanitizePayload(updates as Record); - - const response = await makeRequest<{ status: string; data: EventType }>( - `/event-types/${eventTypeId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-06-14", - }, - body: JSON.stringify(sanitizedUpdates), - }, - "2024-06-14" - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from update event type API"); - } catch (error) { - console.error("updateEventType error"); - throw error; - } -} - -// Update a schedule -async function updateSchedule( - scheduleId: number, - updates: { - isDefault?: boolean; - name?: string; - timeZone?: string; - availability?: Array<{ - days: string[]; // Day names like "Monday", "Tuesday" - startTime: string; // Format: "09:00" - endTime: string; // Format: "10:00" - }>; - overrides?: Array<{ - date: string; // Format: "2024-05-20" - startTime: string; // Format: "12:00" - endTime: string; // Format: "14:00" - }>; - } -): Promise { - try { - // Sanitize the updates to remove null values - const sanitizedUpdates = sanitizePayload(updates as Record); - const response = await makeRequest<{ status: string; data: Schedule }>( - `/schedules/${scheduleId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "cal-api-version": "2024-06-11", - }, - body: JSON.stringify(sanitizedUpdates), - }, - "2024-06-11" - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from update schedule API"); - } catch (error) { - console.error("updateSchedule error"); - throw error; - } -} - -// Duplicate a schedule -async function duplicateSchedule(scheduleId: number): Promise { - try { - const response = await makeRequest<{ status: string; data: Schedule }>( - `/atoms/schedules/${scheduleId}/duplicate`, - { - method: "POST", - headers: { - "cal-api-version": "2024-06-11", - }, - }, - "2024-06-11" - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from duplicate schedule API"); - } catch (error) { - console.error("duplicateSchedule error"); - throw error; - } -} - -// Delete a schedule -async function deleteSchedule(scheduleId: number): Promise { - try { - await makeRequest<{ status: string }>( - `/schedules/${scheduleId}`, - { - method: "DELETE", - headers: { - "cal-api-version": "2024-06-11", - }, - }, - "2024-06-11" - ); - } catch (error) { - console.error("deleteSchedule error"); - throw error; - } -} - -// ============================================ -// WEBHOOKS -// ============================================ - -// Get all global webhooks -async function getWebhooks(): Promise { - try { - const response = await makeRequest<{ status: string; data: Webhook[] }>("/webhooks"); - - if (response?.data && Array.isArray(response.data)) { - return response.data; - } - - return []; - } catch (error) { - console.error("getWebhooks error"); - throw error; - } -} - -// Create a global webhook -async function createWebhook(input: CreateWebhookInput): Promise { - try { - const sanitizedInput = sanitizePayload(input as unknown as Record); - const response = await makeRequest<{ status: string; data: Webhook }>("/webhooks", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(sanitizedInput), - }); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from create webhook API"); - } catch (error) { - console.error("createWebhook error"); - throw error; - } -} - -// Update a global webhook -async function updateWebhook(webhookId: string, updates: UpdateWebhookInput): Promise { - try { - const sanitizedUpdates = sanitizePayload(updates as Record); - const response = await makeRequest<{ status: string; data: Webhook }>( - `/webhooks/${webhookId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(sanitizedUpdates), - } - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from update webhook API"); - } catch (error) { - console.error("updateWebhook error"); - throw error; - } -} - -// Delete a global webhook -async function deleteWebhook(webhookId: string): Promise { - try { - await makeRequest(`/webhooks/${webhookId}`, { - method: "DELETE", - }); - } catch (error) { - console.error("deleteWebhook error"); - throw error; - } -} - -// Get webhooks for a specific event type -async function getEventTypeWebhooks(eventTypeId: number): Promise { - try { - const response = await makeRequest<{ status: string; data: Webhook[] }>( - `/event-types/${eventTypeId}/webhooks` - ); - - if (response?.data && Array.isArray(response.data)) { - return response.data; - } - - return []; - } catch (error) { - console.error("getEventTypeWebhooks error"); - throw error; - } -} - -// Create a webhook for a specific event type -async function createEventTypeWebhook( - eventTypeId: number, - input: CreateWebhookInput -): Promise { - try { - const sanitizedInput = sanitizePayload(input as unknown as Record); - const response = await makeRequest<{ status: string; data: Webhook }>( - `/event-types/${eventTypeId}/webhooks`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(sanitizedInput), - } - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from create event type webhook API"); - } catch (error) { - console.error("createEventTypeWebhook error"); - throw error; - } -} - -// Update an event type webhook -async function updateEventTypeWebhook( - eventTypeId: number, - webhookId: string, - updates: UpdateWebhookInput -): Promise { - try { - const sanitizedUpdates = sanitizePayload(updates as Record); - const response = await makeRequest<{ status: string; data: Webhook }>( - `/event-types/${eventTypeId}/webhooks/${webhookId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(sanitizedUpdates), - } - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from update event type webhook API"); - } catch (error) { - console.error("updateEventTypeWebhook error"); - throw error; - } -} - -// Delete an event type webhook -async function deleteEventTypeWebhook(eventTypeId: number, webhookId: string): Promise { - try { - await makeRequest(`/event-types/${eventTypeId}/webhooks/${webhookId}`, { - method: "DELETE", - }); - } catch (error) { - console.error("deleteEventTypeWebhook error"); - throw error; - } -} - -// ============================================ -// PRIVATE LINKS -// ============================================ - -// Get all private links for an event type -async function getEventTypePrivateLinks(eventTypeId: number): Promise { - try { - const response = await makeRequest<{ status: string; data: PrivateLink[] }>( - `/event-types/${eventTypeId}/private-links` - ); - - if (response?.data && Array.isArray(response.data)) { - return response.data; - } - - return []; - } catch (error) { - console.error("getEventTypePrivateLinks error"); - throw error; - } -} - -// Create a private link for an event type -async function createEventTypePrivateLink( - eventTypeId: number, - input: CreatePrivateLinkInput = {} -): Promise { - try { - const sanitizedInput = sanitizePayload(input as Record); - const response = await makeRequest<{ status: string; data: PrivateLink }>( - `/event-types/${eventTypeId}/private-links`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(sanitizedInput), - } - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from create private link API"); - } catch (error) { - console.error("createEventTypePrivateLink error"); - throw error; - } -} - -// Update a private link -async function updateEventTypePrivateLink( - eventTypeId: number, - linkId: number, - updates: UpdatePrivateLinkInput -): Promise { - try { - const sanitizedUpdates = sanitizePayload(updates as Record); - const response = await makeRequest<{ status: string; data: PrivateLink }>( - `/event-types/${eventTypeId}/private-links/${linkId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(sanitizedUpdates), - } - ); - - if (response?.data) { - return response.data; - } - - throw new Error("Invalid response from update private link API"); - } catch (error) { - console.error("updateEventTypePrivateLink error"); - throw error; - } -} - -// Delete a private link -async function deleteEventTypePrivateLink(eventTypeId: number, linkId: number): Promise { - try { - await makeRequest(`/event-types/${eventTypeId}/private-links/${linkId}`, { - method: "DELETE", - }); - } catch (error) { - console.error("deleteEventTypePrivateLink error"); - throw error; - } -} - -// Helper to get username -async function getUsername(): Promise { - const profile = await getUserProfile(); - return profile.username; -} - -// Export as object to satisfy noStaticOnlyClass rule -export const CalComAPIService = { - setAccessToken, - setRefreshTokenFunction, - clearAuth, - setTokenRefreshCallback, - getCurrentUser, - updateUserProfile, - getUserProfile, - getUsername, - clearUserProfile, - testRawBookingsAPI, - deleteEventType, - createEventType, - cancelBooking, - rescheduleBooking, - confirmBooking, - declineBooking, - markAbsent, - addGuests, - updateLocation, - updateLocationV2, - getRecordings, - getConferencingSessions, - getTranscripts, - getEventTypes, - getBookingByUid, - getBookings, - getSchedules, - createSchedule, - getScheduleById, - getConferencingOptions, - getEventTypeById, - updateEventType, - updateSchedule, - duplicateSchedule, - deleteSchedule, - getWebhooks, - createWebhook, - updateWebhook, - deleteWebhook, - getEventTypeWebhooks, - createEventTypeWebhook, - updateEventTypeWebhook, - deleteEventTypeWebhook, - getEventTypePrivateLinks, - createEventTypePrivateLink, - updateEventTypePrivateLink, - deleteEventTypePrivateLink, -}; diff --git a/companion/services/calcom/auth.ts b/companion/services/calcom/auth.ts new file mode 100644 index 0000000000..013247effb --- /dev/null +++ b/companion/services/calcom/auth.ts @@ -0,0 +1,91 @@ +/** + * Authentication configuration and functions for Cal.com API + */ + +// Authentication configuration +interface AuthConfig { + accessToken?: string; + refreshToken?: string; +} + +// Global auth state +const authConfig: AuthConfig = {}; + +// Token refresh callback - will be set by AuthContext +let tokenRefreshCallback: ((accessToken: string, refreshToken?: string) => Promise) | null = + null; + +// Refresh token function - will be set by AuthContext +let refreshTokenFunction: + | ((refreshToken: string) => Promise<{ accessToken: string; refreshToken?: string }>) + | null = null; + +/** + * Set OAuth access token for authentication + */ +export function setAccessToken(accessToken: string, refreshToken?: string): void { + authConfig.accessToken = accessToken; + if (refreshToken) { + authConfig.refreshToken = refreshToken; + } +} + +/** + * Set refresh token function for automatic token refresh + */ +export function setRefreshTokenFunction( + refreshFn: (refreshToken: string) => Promise<{ accessToken: string; refreshToken?: string }> +): void { + refreshTokenFunction = refreshFn; +} + +/** + * Clear all authentication + */ +export function clearAuth(): void { + authConfig.accessToken = undefined; + authConfig.refreshToken = undefined; + tokenRefreshCallback = null; + refreshTokenFunction = null; +} + +/** + * Set token refresh callback for OAuth token refresh + */ +export function setTokenRefreshCallback( + callback: (accessToken: string, refreshToken?: string) => Promise +): void { + tokenRefreshCallback = callback; +} + +/** + * Get current authentication header + */ +export function getAuthHeader(): string { + if (authConfig.accessToken) { + return `Bearer ${authConfig.accessToken}`; + } else { + throw new Error("No authentication configured. Please sign in with OAuth."); + } +} + +/** + * Get the current auth config (for internal use by request module) + */ +export function getAuthConfig(): AuthConfig { + return authConfig; +} + +/** + * Get the token refresh callback (for internal use by request module) + */ +export function getTokenRefreshCallback(): typeof tokenRefreshCallback { + return tokenRefreshCallback; +} + +/** + * Get the refresh token function (for internal use by request module) + */ +export function getRefreshTokenFunction(): typeof refreshTokenFunction { + return refreshTokenFunction; +} diff --git a/companion/services/calcom/bookings.ts b/companion/services/calcom/bookings.ts new file mode 100644 index 0000000000..ef9253b98c --- /dev/null +++ b/companion/services/calcom/bookings.ts @@ -0,0 +1,588 @@ +/** + * Booking-related functions for Cal.com API + */ + +import { safeLogError, safeLogInfo } from "@/utils/safeLogger"; + +import type { + AddGuestInput, + AddGuestsResponse, + Booking, + BookingParticipationResult, + BookingRecording, + BookingTranscript, + ConferencingSession, + GetConferencingSessionsResponse, + GetRecordingsResponse, + GetTranscriptsResponse, + MarkAbsentRequest, + MarkAbsentResponse, + UpdateLocationResponse, + UserProfile, +} from "../types"; + +import { makeRequest } from "./request"; +import { getUserProfile } from "./user"; + +/** + * Determine user's participation role in a booking + */ +export const getBookingParticipation = ( + booking: Booking, + userId?: number, + userEmail?: string +): BookingParticipationResult => { + const normalizedUserEmail = userEmail?.toLowerCase(); + const emailEq = (a?: string, b?: string) => a?.toLowerCase() === b?.toLowerCase(); + const idEq = (a?: string | number, b?: string | number) => + a !== undefined && b !== undefined && String(a) === String(b); + + const isOrganizer = + !!booking.user && + (idEq(booking.user.id, userId) || emailEq(booking.user.email, normalizedUserEmail)); + + const isHost = + Array.isArray(booking.hosts) && + booking.hosts.some((host) => idEq(host.id, userId) || emailEq(host.email, normalizedUserEmail)); + + const isAttendee = + Array.isArray(booking.attendees) && + booking.attendees.some( + (attendee) => idEq(attendee.id, userId) || emailEq(attendee.email, normalizedUserEmail) + ); + + const isParticipating = isOrganizer || isHost || isAttendee; + + return { isOrganizer, isHost, isAttendee, isParticipating }; +}; + +/** + * Get a single booking by UID + */ +export async function getBookingByUid(bookingUid: string): Promise { + try { + const response = await makeRequest<{ status: string; data: Booking }>( + `/bookings/${bookingUid}`, + { + headers: { + "cal-api-version": "2024-08-13", + }, + }, + "2024-08-13" + ); + if (response?.data) { + return response.data; + } + throw new Error("Invalid response from get booking API"); + } catch (error) { + console.error("getBookingByUid error:", error); + throw error; + } +} + +/** + * Get bookings with optional filters + */ +export async function getBookings(filters?: { + status?: string[]; + fromDate?: string; + toDate?: string; + eventTypeId?: number; + limit?: number; + offset?: number; +}): Promise { + // Build query parameters + const params = new URLSearchParams(); + + if (filters?.status?.length) { + for (const status of filters.status) { + params.append("status", status); + } + } + if (filters?.fromDate) { + params.append("fromDate", filters.fromDate); + } + if (filters?.toDate) { + params.append("toDate", filters.toDate); + } + if (filters?.eventTypeId) { + params.append("eventTypeId", filters.eventTypeId.toString()); + } + if (filters?.limit) { + params.append("limit", filters.limit.toString()); + } + if (filters?.offset) { + params.append("offset", filters.offset.toString()); + } + + const queryString = params.toString(); + const endpoint = `/bookings${queryString ? `?${queryString}` : ""}`; + + const response = await makeRequest(endpoint); + + // Handle different possible response structures (same logic as event types) + let bookingsArray: Booking[] = []; + + if (Array.isArray(response)) { + bookingsArray = response as Booking[]; + } else if (response && typeof response === "object") { + const resp = response as Record; + if (resp.data && Array.isArray(resp.data)) { + bookingsArray = resp.data as Booking[]; + } else if (resp.bookings && Array.isArray(resp.bookings)) { + bookingsArray = resp.bookings as Booking[]; + } else if (resp.items && Array.isArray(resp.items)) { + bookingsArray = resp.items as Booking[]; + } else if (resp.data && typeof resp.data === "object") { + const dataObj = resp.data as Record; + if (dataObj.bookings && Array.isArray(dataObj.bookings)) { + bookingsArray = dataObj.bookings as Booking[]; + } else if (dataObj.items && Array.isArray(dataObj.items)) { + bookingsArray = dataObj.items as Booking[]; + } else { + // Convert object values to array as last resort + const keys = Object.keys(dataObj); + if (keys.length > 0) { + bookingsArray = Object.values(dataObj).filter((item): item is Booking => + Boolean(item && typeof item === "object" && ("id" in item || "uid" in item)) + ); + } + } + } else { + // Try to extract any arrays from the response + const possibleArrays = Object.values(resp).filter((val) => Array.isArray(val)); + if (possibleArrays.length > 0) { + bookingsArray = possibleArrays[0] as Booking[]; + } + } + } + + // Get cached user profile to filter bookings (uses in-flight deduplication) + let userProfile: UserProfile | undefined; + try { + userProfile = await getUserProfile(); + } catch (_error) { + return bookingsArray; + } + + // Extract user info from response + let userId: number | undefined; + let userEmail: string | undefined; + + if (userProfile) { + userId = userProfile.id; + userEmail = userProfile.email; + } + + // Filter bookings to only show ones where the current user is participating + const userBookings = bookingsArray.filter((booking) => { + const { + isOrganizer: _isOrganizer, + isHost: _isHost, + isAttendee: _isAttendee, + isParticipating, + } = getBookingParticipation(booking, userId, userEmail); + + return isParticipating; + }); + + return userBookings; +} + +/** + * Cancel a booking + */ +export async function cancelBooking( + bookingUid: string, + cancellationReason?: string +): Promise { + const body: { cancellationReason?: string } = {}; + if (cancellationReason) { + body.cancellationReason = cancellationReason; + } + + await makeRequest( + `/bookings/${bookingUid}/cancel`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + body: JSON.stringify(body), + }, + "2024-08-13" + ); +} + +/** + * Reschedule a booking + */ +export async function rescheduleBooking( + bookingUid: string, + input: { + start: string; + reschedulingReason?: string; + } +): Promise { + try { + safeLogInfo("[CalComAPIService] rescheduleBooking request:", { + bookingUid, + input, + }); + + const response = await makeRequest<{ status: string; data: Booking }>( + `/bookings/${bookingUid}/reschedule`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + body: JSON.stringify(input), + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from reschedule booking API"); + } catch (error) { + safeLogError("[CalComAPIService] rescheduleBooking error:", error); + if (error instanceof Error) { + safeLogError("[CalComAPIService] Error message:", error.message); + safeLogError("[CalComAPIService] Error stack:", error.stack); + } + throw error; + } +} + +/** + * Confirm a pending booking + */ +export async function confirmBooking(bookingUid: string): Promise { + try { + const response = await makeRequest<{ status: string; data: Booking }>( + `/bookings/${bookingUid}/confirm`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from confirm booking API"); + } catch (error) { + safeLogError("confirmBooking error", { error, bookingUid }); + throw error; + } +} + +/** + * Decline a pending booking + */ +export async function declineBooking(bookingUid: string, reason?: string): Promise { + try { + const body: { reason?: string } = {}; + if (reason) { + body.reason = reason; + } + + const response = await makeRequest<{ status: string; data: Booking }>( + `/bookings/${bookingUid}/decline`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + body: JSON.stringify(body), + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from decline booking API"); + } catch (error) { + console.error("declineBooking error"); + throw error; + } +} + +/** + * Mark an attendee as absent (no-show) for a booking + * @param bookingUid - The unique identifier of the booking + * @param attendeeEmail - The email of the attendee to mark as absent + * @param absent - Whether to mark as absent (true) or undo (false) + */ +export async function markAbsent( + bookingUid: string, + attendeeEmail: string, + absent: boolean = true +): Promise { + try { + const body: MarkAbsentRequest = { + attendees: [{ email: attendeeEmail, absent }], + }; + + const response = await makeRequest( + `/bookings/${bookingUid}/mark-absent`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + body: JSON.stringify(body), + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from mark absent API"); + } catch (error) { + console.error("markAbsent error"); + throw error; + } +} + +/** + * Add guests to a booking + * @param bookingUid - The unique identifier of the booking + * @param guests - Array of guests to add (email required, name optional) + * @returns Updated booking with new guests + */ +export async function addGuests(bookingUid: string, guests: AddGuestInput[]): Promise { + try { + const response = await makeRequest( + `/bookings/${bookingUid}/guests`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + body: JSON.stringify({ guests }), + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from add guests API"); + } catch (error) { + console.error("addGuests error"); + throw error; + } +} + +/** + * Update the location of a booking (legacy - sends location as string) + * Note: This updates the booking location but may not update the calendar event + * @param bookingUid - The unique identifier of the booking + * @param location - The new location string + * @returns Updated booking with new location + * @deprecated Use updateLocationV2 instead for proper typed location updates + */ +export async function updateLocation(bookingUid: string, location: string): Promise { + try { + const response = await makeRequest( + `/bookings/${bookingUid}/location`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + body: JSON.stringify({ location }), + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from update location API"); + } catch (error) { + console.error("updateLocation error"); + throw error; + } +} + +/** + * Update the location of a booking with typed location object + * Supports: address, link, phone, attendeePhone, attendeeAddress, attendeeDefined + * Note: Integration-based locations (Cal Video, Google Meet, Zoom) are NOT supported + * @param bookingUid - The unique identifier of the booking + * @param location - The location object with type and value + * @returns Updated booking with new location + */ +export async function updateLocationV2( + bookingUid: string, + location: { type: string; [key: string]: string } +): Promise { + try { + const response = await makeRequest( + `/bookings/${bookingUid}/location`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + body: JSON.stringify({ location }), + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from update location API"); + } catch (error) { + safeLogError("[CalComAPIService] updateLocationV2 error:", error); + throw error; + } +} + +/** + * Get recordings for a booking (Cal Video only) + * Note: Only available for past Cal Video bookings + * @param bookingUid - The unique identifier of the booking + * @returns Array of recording objects with download URLs + */ +export async function getRecordings(bookingUid: string): Promise { + try { + const response = await makeRequest( + `/bookings/${bookingUid}/recordings`, + { + headers: { + "cal-api-version": "2024-08-13", + }, + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + return []; + } catch (error) { + // Handle 401/403 gracefully - endpoint may require auth in future + if (error instanceof Error) { + const statusMatch = error.message.match(/API Error: (\d+)/); + if (statusMatch && (statusMatch[1] === "401" || statusMatch[1] === "403")) { + console.warn(`Recordings access denied for booking ${bookingUid}`); + return []; + } + if (statusMatch && statusMatch[1] === "404") { + console.warn(`No recordings found for booking ${bookingUid}`); + return []; + } + } + console.error("getRecordings error"); + throw error; + } +} + +/** + * Get conferencing session details for a booking (Cal Video only) + * Note: This endpoint is PBAC-guarded and requires booking.readRecordings permission + * @param bookingUid - The unique identifier of the booking + * @returns Array of conferencing session objects with participant info + */ +export async function getConferencingSessions(bookingUid: string): Promise { + try { + const response = await makeRequest( + `/bookings/${bookingUid}/conferencing-sessions`, + { + headers: { + "cal-api-version": "2024-08-13", + }, + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + return []; + } catch (error) { + // Handle 401/403 gracefully - endpoint is PBAC-guarded + if (error instanceof Error) { + const statusMatch = error.message.match(/API Error: (\d+)/); + if (statusMatch && (statusMatch[1] === "401" || statusMatch[1] === "403")) { + console.warn(`Conferencing sessions access denied for booking ${bookingUid}`); + return []; + } + if (statusMatch && statusMatch[1] === "404") { + console.warn(`No conferencing sessions found for booking ${bookingUid}`); + return []; + } + } + console.error("getConferencingSessions error"); + throw error; + } +} + +/** + * Get transcripts for a booking (Cal Video only) + * Note: This endpoint may require authentication in future releases + * @param bookingUid - The unique identifier of the booking + * @returns Array of transcript objects with download URLs + */ +export async function getTranscripts(bookingUid: string): Promise { + try { + const response = await makeRequest( + `/bookings/${bookingUid}/transcripts`, + { + headers: { + "cal-api-version": "2024-08-13", + }, + }, + "2024-08-13" + ); + + if (response?.data) { + return response.data; + } + + return []; + } catch (error) { + // Handle 401/403 gracefully - endpoint may require auth in future + if (error instanceof Error) { + const statusMatch = error.message.match(/API Error: (\d+)/); + if (statusMatch && (statusMatch[1] === "401" || statusMatch[1] === "403")) { + console.warn(`Transcripts access denied for booking ${bookingUid}`); + return []; + } + if (statusMatch && statusMatch[1] === "404") { + console.warn(`No transcripts found for booking ${bookingUid}`); + return []; + } + } + console.error("getTranscripts error"); + throw error; + } +} diff --git a/companion/services/calcom/conferencing.ts b/companion/services/calcom/conferencing.ts new file mode 100644 index 0000000000..94c00e4f8a --- /dev/null +++ b/companion/services/calcom/conferencing.ts @@ -0,0 +1,28 @@ +/** + * Conferencing functions for Cal.com API + */ + +import type { ConferencingOption } from "../types"; + +import { makeRequest } from "./request"; + +/** + * Get conferencing options for the authenticated user + */ +export async function getConferencingOptions(): Promise { + try { + const response = await makeRequest<{ + status: string; + data: ConferencingOption[]; + }>("/conferencing"); + + if (response?.data && Array.isArray(response.data)) { + return response.data; + } + + return []; + } catch (error) { + console.error("getConferencingOptions error"); + throw error; + } +} diff --git a/companion/services/calcom/event-types.ts b/companion/services/calcom/event-types.ts new file mode 100644 index 0000000000..28aa60911a --- /dev/null +++ b/companion/services/calcom/event-types.ts @@ -0,0 +1,173 @@ +/** + * Event type functions for Cal.com API + */ + +import { safeLogError } from "@/utils/safeLogger"; + +import type { CreateEventTypeInput, EventType } from "../types"; + +import { makeRequest } from "./request"; +import { sanitizePayload } from "./utils"; + +/** + * Get all event types for the authenticated user + */ +export async function getEventTypes(): Promise { + // For authenticated users, no username/orgSlug params needed - API uses auth token + // This also ensures hidden event types are returned (they're filtered out when username is provided) + // Sort by creation date descending (newer first) to match main codebase behavior + const endpoint = `/event-types?sortCreatedAt=desc`; + + const response = await makeRequest(endpoint, {}, "2024-06-14"); + + // Handle different possible response structures + let eventTypesArray: EventType[] = []; + + if (Array.isArray(response)) { + eventTypesArray = response as EventType[]; + } else if (response && typeof response === "object") { + const resp = response as Record; + if (resp.data && Array.isArray(resp.data)) { + eventTypesArray = resp.data as EventType[]; + } else if (resp.eventTypes && Array.isArray(resp.eventTypes)) { + eventTypesArray = resp.eventTypes as EventType[]; + } else if (resp.items && Array.isArray(resp.items)) { + eventTypesArray = resp.items as EventType[]; + } else if (resp.data && typeof resp.data === "object") { + const dataObj = resp.data as Record; + if (dataObj.eventTypes && Array.isArray(dataObj.eventTypes)) { + eventTypesArray = dataObj.eventTypes as EventType[]; + } else if (dataObj.items && Array.isArray(dataObj.items)) { + eventTypesArray = dataObj.items as EventType[]; + } else { + const keys = Object.keys(dataObj); + if (keys.length > 0) { + eventTypesArray = Object.values(dataObj).filter((item): item is EventType => + Boolean(item && typeof item === "object" && "id" in item) + ); + } + } + } else { + const possibleArrays = Object.values(resp).filter((val) => Array.isArray(val)); + if (possibleArrays.length > 0) { + eventTypesArray = possibleArrays[0] as EventType[]; + } + } + } + + return eventTypesArray; +} + +/** + * Get a single event type by ID + */ +export async function getEventTypeById(eventTypeId: number): Promise { + try { + const response = await makeRequest<{ status: string; data: EventType }>( + `/event-types/${eventTypeId}`, + {}, + "2024-06-14" + ); + + if (response?.data) { + return response.data; + } + + return null; + } catch (error) { + // Handle 404 errors gracefully - resource doesn't exist or user doesn't have access + if (error instanceof Error) { + // Check if error message contains 404 status code + const statusMatch = error.message.match(/API Error: (\d+)/); + if (statusMatch && statusMatch[1] === "404") { + console.warn(`Event type ${eventTypeId} not found`); + return null; + } + } + console.error("getEventTypeById error"); + throw error; + } +} + +/** + * Create a new event type + */ +export async function createEventType(input: CreateEventTypeInput): Promise { + try { + const sanitizedInput = sanitizePayload(input as unknown as Record); + + const response = await makeRequest<{ status: string; data: EventType }>( + "/event-types", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-06-14", + }, + body: JSON.stringify(sanitizedInput), + }, + "2024-06-14" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from create event type API"); + } catch (error) { + console.error("createEventType error"); + throw error; + } +} + +/** + * Update an event type + */ +export async function updateEventType( + eventTypeId: number, + updates: Partial +): Promise { + try { + const sanitizedUpdates = sanitizePayload(updates as Record); + + const response = await makeRequest<{ status: string; data: EventType }>( + `/event-types/${eventTypeId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-06-14", + }, + body: JSON.stringify(sanitizedUpdates), + }, + "2024-06-14" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from update event type API"); + } catch (error) { + console.error("updateEventType error"); + throw error; + } +} + +/** + * Delete an event type + */ +export async function deleteEventType(eventTypeId: number): Promise { + try { + await makeRequest( + `/event-types/${eventTypeId}`, + { + method: "DELETE", + }, + "2024-06-14" + ); + } catch (error) { + safeLogError("Delete API error", { error, eventTypeId }); + throw error; + } +} diff --git a/companion/services/calcom/index.ts b/companion/services/calcom/index.ts new file mode 100644 index 0000000000..4d4abe89ec --- /dev/null +++ b/companion/services/calcom/index.ts @@ -0,0 +1,172 @@ +/** + * Cal.com API Service + * + * This module provides a unified interface for interacting with the Cal.com API v2. + * It is organized into logical submodules for better maintainability: + * + * - auth: Authentication configuration and token management + * - bookings: Booking CRUD operations and actions + * - conferencing: Conferencing options + * - event-types: Event type CRUD operations + * - private-links: Private link management for event types + * - request: Core HTTP request functionality + * - schedules: Schedule CRUD operations + * - user: User profile management + * - utils: JSON parsing and payload sanitization utilities + * - webhooks: Webhook management (global and event type specific) + */ + +// Re-export types for backward compatibility +export type { + EventType, + CreateEventTypeInput, + Booking, + BookingParticipationResult, + Schedule, + UserProfile, + ConferencingOption, + Webhook, + CreateWebhookInput, + UpdateWebhookInput, + PrivateLink, + CreatePrivateLinkInput, + UpdatePrivateLinkInput, + BookingLimitsCount, + BookingLimitsDuration, + ConfirmationPolicy, +} from "../types"; + +// Import all functions from submodules +import { + clearAuth, + setAccessToken, + setRefreshTokenFunction, + setTokenRefreshCallback, +} from "./auth"; +import { + addGuests, + cancelBooking, + confirmBooking, + declineBooking, + getBookingByUid, + getBookingParticipation, + getBookings, + getConferencingSessions, + getRecordings, + getTranscripts, + markAbsent, + rescheduleBooking, + updateLocation, + updateLocationV2, +} from "./bookings"; +import { getConferencingOptions } from "./conferencing"; +import { + createEventType, + deleteEventType, + getEventTypeById, + getEventTypes, + updateEventType, +} from "./event-types"; +import { + createEventTypePrivateLink, + deleteEventTypePrivateLink, + getEventTypePrivateLinks, + updateEventTypePrivateLink, +} from "./private-links"; +import { testRawBookingsAPI } from "./request"; +import { + createSchedule, + deleteSchedule, + duplicateSchedule, + getScheduleById, + getSchedules, + updateSchedule, +} from "./schedules"; +import { + clearUserProfile, + getCurrentUser, + getUsername, + getUserProfile, + updateUserProfile, +} from "./user"; +import { + createEventTypeWebhook, + createWebhook, + deleteEventTypeWebhook, + deleteWebhook, + getEventTypeWebhooks, + getWebhooks, + updateEventTypeWebhook, + updateWebhook, +} from "./webhooks"; + +// Re-export the booking participation helper for direct use +export { getBookingParticipation }; + +// Export the unified CalComAPIService object for backward compatibility +export const CalComAPIService = { + // Auth + setAccessToken, + setRefreshTokenFunction, + clearAuth, + setTokenRefreshCallback, + + // User + getCurrentUser, + updateUserProfile, + getUserProfile, + getUsername, + clearUserProfile, + + // Debug + testRawBookingsAPI, + + // Event Types + deleteEventType, + createEventType, + getEventTypes, + getEventTypeById, + updateEventType, + + // Bookings + cancelBooking, + rescheduleBooking, + confirmBooking, + declineBooking, + markAbsent, + addGuests, + updateLocation, + updateLocationV2, + getRecordings, + getConferencingSessions, + getTranscripts, + getBookingByUid, + getBookings, + + // Schedules + getSchedules, + createSchedule, + getScheduleById, + updateSchedule, + duplicateSchedule, + deleteSchedule, + + // Conferencing + getConferencingOptions, + + // Webhooks + getWebhooks, + createWebhook, + updateWebhook, + deleteWebhook, + getEventTypeWebhooks, + createEventTypeWebhook, + updateEventTypeWebhook, + deleteEventTypeWebhook, + + // Private Links + getEventTypePrivateLinks, + createEventTypePrivateLink, + updateEventTypePrivateLink, + deleteEventTypePrivateLink, +}; diff --git a/companion/services/calcom/private-links.ts b/companion/services/calcom/private-links.ts new file mode 100644 index 0000000000..e4cc8b5ccc --- /dev/null +++ b/companion/services/calcom/private-links.ts @@ -0,0 +1,108 @@ +/** + * Private links functions for Cal.com API + */ + +import type { CreatePrivateLinkInput, PrivateLink, UpdatePrivateLinkInput } from "../types"; + +import { makeRequest } from "./request"; +import { sanitizePayload } from "./utils"; + +/** + * Get all private links for an event type + */ +export async function getEventTypePrivateLinks(eventTypeId: number): Promise { + try { + const response = await makeRequest<{ status: string; data: PrivateLink[] }>( + `/event-types/${eventTypeId}/private-links` + ); + + if (response?.data && Array.isArray(response.data)) { + return response.data; + } + + return []; + } catch (error) { + console.error("getEventTypePrivateLinks error"); + throw error; + } +} + +/** + * Create a private link for an event type + */ +export async function createEventTypePrivateLink( + eventTypeId: number, + input: CreatePrivateLinkInput = {} +): Promise { + try { + const sanitizedInput = sanitizePayload(input as Record); + const response = await makeRequest<{ status: string; data: PrivateLink }>( + `/event-types/${eventTypeId}/private-links`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(sanitizedInput), + } + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from create private link API"); + } catch (error) { + console.error("createEventTypePrivateLink error"); + throw error; + } +} + +/** + * Update a private link + */ +export async function updateEventTypePrivateLink( + eventTypeId: number, + linkId: number, + updates: UpdatePrivateLinkInput +): Promise { + try { + const sanitizedUpdates = sanitizePayload(updates as Record); + const response = await makeRequest<{ status: string; data: PrivateLink }>( + `/event-types/${eventTypeId}/private-links/${linkId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(sanitizedUpdates), + } + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from update private link API"); + } catch (error) { + console.error("updateEventTypePrivateLink error"); + throw error; + } +} + +/** + * Delete a private link + */ +export async function deleteEventTypePrivateLink( + eventTypeId: number, + linkId: number +): Promise { + try { + await makeRequest(`/event-types/${eventTypeId}/private-links/${linkId}`, { + method: "DELETE", + }); + } catch (error) { + console.error("deleteEventTypePrivateLink error"); + throw error; + } +} diff --git a/companion/services/calcom/request.ts b/companion/services/calcom/request.ts new file mode 100644 index 0000000000..e78cdcd657 --- /dev/null +++ b/companion/services/calcom/request.ts @@ -0,0 +1,133 @@ +/** + * Core HTTP request functionality for Cal.com API + */ + +import { fetchWithTimeout } from "@/utils/network"; +import { safeLogError } from "@/utils/safeLogger"; + +import { + clearAuth, + getAuthConfig, + getAuthHeader, + getRefreshTokenFunction, + getTokenRefreshCallback, +} from "./auth"; +import { safeParseErrorJson, safeParseJson } from "./utils"; + +export const API_BASE_URL = "https://api.cal.com/v2"; +export const REQUEST_TIMEOUT_MS = 30000; + +/** + * Test function for bookings API specifically + */ +export async function testRawBookingsAPI(): Promise { + try { + const url = `${API_BASE_URL}/bookings?status=upcoming&status=unconfirmed&limit=50`; + + const response = await fetchWithTimeout( + url, + { + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + }, + REQUEST_TIMEOUT_MS + ); + + const responseText = await response.text(); + + if (responseText?.trim()) { + const _responseJson = safeParseJson(responseText); + if (!_responseJson) { + safeLogError("[CalComAPIService] Failed to parse bookings response", { + responseLength: responseText.length, + }); + } + } + } catch (_error) { + safeLogError("[CalComAPIService] testRawBookingsAPI failed", { + error: _error, + }); + } +} + +/** + * Make an authenticated request to the Cal.com API + */ +export async function makeRequest( + endpoint: string, + options: RequestInit = {}, + apiVersion: string = "2024-08-13", + isRetry: boolean = false +): Promise { + const url = `${API_BASE_URL}${endpoint}`; + + const response = await fetchWithTimeout( + url, + { + ...options, + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + "cal-api-version": apiVersion, + ...options.headers, + }, + }, + REQUEST_TIMEOUT_MS + ); + + if (!response.ok) { + const errorBody = await response.text(); + + // Parse error for better user messages + let errorMessage = response.statusText; + + const errorJson = safeParseErrorJson(errorBody); + if (errorJson) { + errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText; + } else { + // If JSON parsing fails, use the raw error body + errorMessage = errorBody || response.statusText; + } + + // Handle specific error cases + if (response.status === 401) { + const authConfig = getAuthConfig(); + const refreshTokenFunction = getRefreshTokenFunction(); + const tokenRefreshCallback = getTokenRefreshCallback(); + + if (!isRetry && authConfig.refreshToken && refreshTokenFunction && tokenRefreshCallback) { + try { + const newTokens = await refreshTokenFunction(authConfig.refreshToken); + + authConfig.accessToken = newTokens.accessToken; + if (newTokens.refreshToken) { + authConfig.refreshToken = newTokens.refreshToken; + } + + // Notify AuthContext to update stored tokens + await tokenRefreshCallback(newTokens.accessToken, newTokens.refreshToken); + + // Retry the original request with the new token + return makeRequest(endpoint, options, apiVersion, true); + } catch (refreshError) { + safeLogError("Token refresh failed:", refreshError); + clearAuth(); + throw new Error("Authentication failed. Please sign in again."); + } + } + + if (errorMessage.includes("expired")) { + throw new Error("Your authentication has expired. Please sign in again."); + } + throw new Error("Authentication failed. Please check your credentials."); + } + + // Include status code in error message for graceful error handling downstream + throw new Error(`API Error: ${response.status} ${errorMessage}`); + } + + return response.json(); +} diff --git a/companion/services/calcom/schedules.ts b/companion/services/calcom/schedules.ts new file mode 100644 index 0000000000..4912d4e90e --- /dev/null +++ b/companion/services/calcom/schedules.ts @@ -0,0 +1,211 @@ +/** + * Schedule functions for Cal.com API + */ + +import type { Schedule } from "../types"; + +import { makeRequest } from "./request"; +import { sanitizePayload } from "./utils"; + +/** + * Get all schedules for the authenticated user + */ +export async function getSchedules(): Promise { + const response = await makeRequest<{ status: string; data: Schedule[] }>( + "/schedules", + { + headers: { + "cal-api-version": "2024-06-11", + }, + }, + "2024-06-11" + ); + + let schedulesArray: Schedule[] = []; + + // Handle response structure: { status: "success", data: [...] } + if (response && response.status === "success" && response.data && Array.isArray(response.data)) { + schedulesArray = response.data; + } else if (Array.isArray(response)) { + // Fallback: response might be array directly + schedulesArray = response; + } else if (response?.data && Array.isArray(response.data)) { + // Fallback: response.data might be array + schedulesArray = response.data; + } + + return schedulesArray; +} + +/** + * Get a schedule by ID + */ +export async function getScheduleById(scheduleId: number): Promise { + try { + const response = await makeRequest( + `/schedules/${scheduleId}`, + { + headers: { + "cal-api-version": "2024-06-11", + }, + }, + "2024-06-11" + ); + + if (response && typeof response === "object") { + const resp = response as Record; + if (resp.data && typeof resp.data === "object") { + return resp.data as Schedule; + } + if (resp.id) { + return response as Schedule; + } + } + + return null; + } catch (error) { + console.error("getScheduleById error"); + throw error; + } +} + +/** + * Create a new schedule + */ +export async function createSchedule(input: { + name: string; + timeZone: string; + isDefault?: boolean; + availability?: Array<{ + days: string[]; + startTime: string; + endTime: string; + }>; + overrides?: Array<{ + date: string; + startTime: string; + endTime: string; + }>; +}): Promise { + try { + const sanitizedInput = sanitizePayload(input as Record); + + const response = await makeRequest<{ status: string; data: Schedule }>( + "/schedules", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-06-11", + }, + body: JSON.stringify(sanitizedInput), + }, + "2024-06-11" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from create schedule API"); + } catch (error) { + console.error("createSchedule error"); + throw error; + } +} + +/** + * Update a schedule + */ +export async function updateSchedule( + scheduleId: number, + updates: { + isDefault?: boolean; + name?: string; + timeZone?: string; + availability?: Array<{ + days: string[]; + startTime: string; + endTime: string; + }>; + overrides?: Array<{ + date: string; + startTime: string; + endTime: string; + }>; + } +): Promise { + try { + // Sanitize the updates to remove null values + const sanitizedUpdates = sanitizePayload(updates as Record); + const response = await makeRequest<{ status: string; data: Schedule }>( + `/schedules/${scheduleId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-06-11", + }, + body: JSON.stringify(sanitizedUpdates), + }, + "2024-06-11" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from update schedule API"); + } catch (error) { + console.error("updateSchedule error"); + throw error; + } +} + +/** + * Duplicate a schedule + */ +export async function duplicateSchedule(scheduleId: number): Promise { + try { + const response = await makeRequest<{ status: string; data: Schedule }>( + `/atoms/schedules/${scheduleId}/duplicate`, + { + method: "POST", + headers: { + "cal-api-version": "2024-06-11", + }, + }, + "2024-06-11" + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from duplicate schedule API"); + } catch (error) { + console.error("duplicateSchedule error"); + throw error; + } +} + +/** + * Delete a schedule + */ +export async function deleteSchedule(scheduleId: number): Promise { + try { + await makeRequest<{ status: string }>( + `/schedules/${scheduleId}`, + { + method: "DELETE", + headers: { + "cal-api-version": "2024-06-11", + }, + }, + "2024-06-11" + ); + } catch (error) { + console.error("deleteSchedule error"); + throw error; + } +} diff --git a/companion/services/calcom/user.ts b/companion/services/calcom/user.ts new file mode 100644 index 0000000000..73eea4b62c --- /dev/null +++ b/companion/services/calcom/user.ts @@ -0,0 +1,116 @@ +/** + * User profile functions for Cal.com API + */ + +import type { UserProfile } from "../types"; + +import { makeRequest } from "./request"; + +// Module-level state for user profile caching +let _userProfile: UserProfile | null = null; +// In-flight promise to prevent concurrent /me API calls +let _userProfilePromise: Promise | null = null; + +/** + * Get current user profile from API + */ +export async function getCurrentUser(): Promise { + const response = await makeRequest<{ status: string; data: UserProfile }>( + "/me", + { + headers: { + "cal-api-version": "2024-06-11", + }, + }, + "2024-06-11" + ); + return response.data; +} + +/** + * Update current user profile + */ +export async function updateUserProfile(updates: { + email?: string; + name?: string; + timeFormat?: number; + defaultScheduleId?: number; + weekStart?: string; + timeZone?: string; + locale?: string; + avatarUrl?: string; + bio?: string; + metadata?: Record; +}): Promise { + try { + const response = await makeRequest<{ status: string; data: UserProfile }>( + "/me", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-06-11", + }, + body: JSON.stringify(updates), + }, + "2024-06-11" + ); + + if (response?.data) { + // Update cached profile + _userProfile = response.data; + return response.data; + } + + throw new Error("Invalid response from update user profile API"); + } catch (error) { + console.error("updateUserProfile error"); + throw error; + } +} + +/** + * Get and cache user profile with in-flight deduplication + * This prevents multiple concurrent callers from each making a /me API call + */ +export async function getUserProfile(): Promise { + // Return cached profile if available + if (_userProfile) { + return _userProfile; + } + + // If there's already an in-flight request, wait for it instead of making a new one + if (_userProfilePromise) { + return _userProfilePromise; + } + + // Create a new request and cache the promise to deduplicate concurrent calls + _userProfilePromise = getCurrentUser() + .then((profile) => { + _userProfile = profile; + _userProfilePromise = null; + return profile; + }) + .catch((error) => { + _userProfilePromise = null; + throw error; + }); + + return _userProfilePromise; +} + +/** + * Clear cached profile (useful for logout) + */ +export function clearUserProfile(): void { + _userProfile = null; + _userProfilePromise = null; +} + +/** + * Helper to get username from user profile + */ +export async function getUsername(): Promise { + const profile = await getUserProfile(); + return profile.username; +} diff --git a/companion/services/calcom/utils.ts b/companion/services/calcom/utils.ts new file mode 100644 index 0000000000..3bc9aac034 --- /dev/null +++ b/companion/services/calcom/utils.ts @@ -0,0 +1,149 @@ +/** + * Utility functions for JSON parsing and payload sanitization + */ + +/** + * Safely parse JSON response with structure validation. + * Validates that the input is a non-null object before returning. + * This prevents prototype pollution and ensures type safety. + */ +export function safeParseJson(jsonString: string): Record | null { + if (typeof jsonString !== "string" || !jsonString.trim()) { + return null; + } + + try { + const parsed: unknown = JSON.parse(jsonString); + + // Validate it's a plain object (not null, array, or primitive) + if ( + parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) && + Object.getPrototypeOf(parsed) === Object.prototype + ) { + return parsed as Record; + } + + // Also accept arrays as valid JSON responses + if (Array.isArray(parsed)) { + return { data: parsed } as Record; + } + + return null; + } catch { + return null; + } +} + +/** + * Safely parse JSON error response with structure validation. + * Validates the expected error response structure before returning. + */ +export function safeParseErrorJson( + jsonString: string +): { error?: { message?: string }; message?: string } | null { + if (typeof jsonString !== "string" || !jsonString.trim()) { + return null; + } + + try { + const parsed: unknown = JSON.parse(jsonString); + + // Validate it's a plain object + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + Object.getPrototypeOf(parsed) !== Object.prototype + ) { + return null; + } + + const obj = parsed as Record; + + // Validate expected error structure - must have error or message property with correct types + const hasValidErrorProp = + obj.error === undefined || + (typeof obj.error === "object" && + obj.error !== null && + ((obj.error as Record).message === undefined || + typeof (obj.error as Record).message === "string")); + + const hasValidMessageProp = obj.message === undefined || typeof obj.message === "string"; + + if (hasValidErrorProp && hasValidMessageProp) { + return obj as { error?: { message?: string }; message?: string }; + } + + return null; + } catch { + return null; + } +} + +/** + * Sanitizes a payload before sending to the API. + * - Removes keys with null values for array fields (API expects arrays or field to be omitted) + * - Removes keys with undefined values + * - Recursively sanitizes nested objects + */ +export function sanitizePayload(payload: Record): Record { + const sanitized: Record = {}; + + // Fields that should NEVER be sent as null - API expects array or omit entirely + const arrayFields = [ + "lengthInMinutesOptions", + "multipleDuration", + "locations", + "bookingFields", + "hosts", + "children", + "customInputs", + ]; + + // Fields that can be null (to clear the value) + const nullableFields = [ + "description", + "successRedirectUrl", + "slotInterval", + "eventName", + "timeZone", + "interfaceLanguage", + ]; + + for (const [key, value] of Object.entries(payload)) { + // Skip undefined values + if (value === undefined) continue; + + // Handle null values + if (value === null) { + // For array fields, skip entirely (don't send null) + if (arrayFields.includes(key)) { + console.warn(`Skipping null value for array field: ${key}`); + continue; + } + // For nullable fields, allow null + if (nullableFields.includes(key)) { + sanitized[key] = null; + continue; + } + // For other fields, skip null to be safe + console.warn(`Skipping null value for field: ${key}`); + continue; + } + + // Recursively sanitize nested objects (but not arrays) + if (typeof value === "object" && !Array.isArray(value)) { + const sanitizedNested = sanitizePayload(value as Record); + // Only include if the nested object has values + if (Object.keys(sanitizedNested).length > 0) { + sanitized[key] = sanitizedNested; + } + } else { + sanitized[key] = value; + } + } + + return sanitized; +} diff --git a/companion/services/calcom/webhooks.ts b/companion/services/calcom/webhooks.ts new file mode 100644 index 0000000000..1a587addaa --- /dev/null +++ b/companion/services/calcom/webhooks.ts @@ -0,0 +1,196 @@ +/** + * Webhook functions for Cal.com API + */ + +import type { CreateWebhookInput, UpdateWebhookInput, Webhook } from "../types"; + +import { makeRequest } from "./request"; +import { sanitizePayload } from "./utils"; + +/** + * Get all global webhooks + */ +export async function getWebhooks(): Promise { + try { + const response = await makeRequest<{ status: string; data: Webhook[] }>("/webhooks"); + + if (response?.data && Array.isArray(response.data)) { + return response.data; + } + + return []; + } catch (error) { + console.error("getWebhooks error"); + throw error; + } +} + +/** + * Create a global webhook + */ +export async function createWebhook(input: CreateWebhookInput): Promise { + try { + const sanitizedInput = sanitizePayload(input as unknown as Record); + const response = await makeRequest<{ status: string; data: Webhook }>("/webhooks", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(sanitizedInput), + }); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from create webhook API"); + } catch (error) { + console.error("createWebhook error"); + throw error; + } +} + +/** + * Update a global webhook + */ +export async function updateWebhook( + webhookId: string, + updates: UpdateWebhookInput +): Promise { + try { + const sanitizedUpdates = sanitizePayload(updates as Record); + const response = await makeRequest<{ status: string; data: Webhook }>( + `/webhooks/${webhookId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(sanitizedUpdates), + } + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from update webhook API"); + } catch (error) { + console.error("updateWebhook error"); + throw error; + } +} + +/** + * Delete a global webhook + */ +export async function deleteWebhook(webhookId: string): Promise { + try { + await makeRequest(`/webhooks/${webhookId}`, { + method: "DELETE", + }); + } catch (error) { + console.error("deleteWebhook error"); + throw error; + } +} + +/** + * Get webhooks for a specific event type + */ +export async function getEventTypeWebhooks(eventTypeId: number): Promise { + try { + const response = await makeRequest<{ status: string; data: Webhook[] }>( + `/event-types/${eventTypeId}/webhooks` + ); + + if (response?.data && Array.isArray(response.data)) { + return response.data; + } + + return []; + } catch (error) { + console.error("getEventTypeWebhooks error"); + throw error; + } +} + +/** + * Create a webhook for a specific event type + */ +export async function createEventTypeWebhook( + eventTypeId: number, + input: CreateWebhookInput +): Promise { + try { + const sanitizedInput = sanitizePayload(input as unknown as Record); + const response = await makeRequest<{ status: string; data: Webhook }>( + `/event-types/${eventTypeId}/webhooks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(sanitizedInput), + } + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from create event type webhook API"); + } catch (error) { + console.error("createEventTypeWebhook error"); + throw error; + } +} + +/** + * Update an event type webhook + */ +export async function updateEventTypeWebhook( + eventTypeId: number, + webhookId: string, + updates: UpdateWebhookInput +): Promise { + try { + const sanitizedUpdates = sanitizePayload(updates as Record); + const response = await makeRequest<{ status: string; data: Webhook }>( + `/event-types/${eventTypeId}/webhooks/${webhookId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(sanitizedUpdates), + } + ); + + if (response?.data) { + return response.data; + } + + throw new Error("Invalid response from update event type webhook API"); + } catch (error) { + console.error("updateEventTypeWebhook error"); + throw error; + } +} + +/** + * Delete an event type webhook + */ +export async function deleteEventTypeWebhook( + eventTypeId: number, + webhookId: string +): Promise { + try { + await makeRequest(`/event-types/${eventTypeId}/webhooks/${webhookId}`, { + method: "DELETE", + }); + } catch (error) { + console.error("deleteEventTypeWebhook error"); + throw error; + } +}