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; + } +}