* feat: companion expo and chrome * fix: api-v2-fetch-bookings (#25023) * migrated to app expo router * feat(companion): added availability screen and fix event type fetch (#25025) * fix(companion): add host check in bookings (#25036) * using liquid glass bottom nav experimental * added action sheet * added icons to event type tabs * added action bar for event-type details * long press for edit event types * added limits * added more limits * added more limits * added username to event types * feat(companion): config to set and show all the available meeting options a user has stalled (#25081) * feat: Add tailwind support using nativewind. (#25028) * init nativewind * replace styles with tailwind classes * changes * feat(companion): all locations options, icons, and apply nativewind to remaining files (#25143) * feat: all locations options and icons and apply nativwind to reamaining files * icon bug fix * cal video icon * other location frontend * other location api * address cubics comment about correctly detects SVGs * feat(companion): edit availability (#25149) * feat: companion-edit-availability * date override and use correct config for patch api endpoint * Android Alert supports a maximum of 3 buttons * feat: companion-ui-release-1 (#25150) * feat(companion): Add event type & schedule creation flows with API expansion (#25187) * feat(companion): event schedule creation and apis * profile section * limits, advanced, recurring tab * Clean Folder Structure * addressed cubics comments * fix cubiics comments * replaced text with cal.com svg logo * added correct more button for event-types * added copy link button * added extension popup * added chrome extension sidebar * achieved liquid glass buttons * nit * more progress * fixed ios bug, added icon * added cal icon to gmail * nit * nit * added dialog for one-off meeting * added one-time dialog * feat(companion): gmail plugin (#25327) * Update Cal.com Companion to version 1.7.0 with enhanced background and content scripts. The background script now handles fetching event types from the Cal.com API and improved error handling. The content script has been updated to manage sidebar visibility and respond to messages more effectively. Removed deprecated dev background script and updated manifest permissions for better functionality. * chore: update companion .gitignore to exclude build outputs and env files * feat: insert Cal.com event link directly at cursor position in Gmail compose - Add insertTextAtCursor function to insert booking link where user is typing - Automatically positions cursor after inserted link for seamless typing - Fallback to clipboard copy if compose field not found - Improved UX: no need to manually paste the link * feat(companion): improve notification UI and add event types caching - Update notification to Cal.com brand style (black bg, check icon, smooth fade) - Change notification text to 'Link inserted' for subtlety - Move notification to bottom-right position (matching Cal.com toasts) - Add event types caching (5min duration) to reduce API calls - Cache automatically refreshes on page reload - Fix TypeScript error with composeBody.focus() type assertion * Notification toast border * conditional description * conditional description * feat(companion): Enhance UI with action buttons, tooltips, and Gmail extension improvements (#25353) * feat: companion-and-gmail-ui-upgrade * feat: companion-and-gmail-ui-upgrade * feat: companion-and-gmail-ui-upgrade * feat: companion-and-gmail-ui-upgrade * feat(companion): one-off links (#25355) * added one off prototype * resolve merge conflicts --------- Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com> * fixed sidebar iframe * fix: enable pointer events on sidebar while maintaining click-through on transparent area - Added pointerEvents: 'auto' to sidebar View component in _layout.tsx - Changed iframe width from 100% to 400px to match sidebar width - Changed iframe pointerEvents from 'none' to 'auto' to enable clicking - This allows sidebar items to be clickable while transparent area remains click-through * feat(companion): center all modals on full screen in browser extension (#25380) * fix: companion-full-width-iframe * center the popups on extension view * feat(companion): center all modals on full screen in browser extension - Created FullScreenModal component that expands iframe to full width - Updated content script to handle instant iframe expansion/collapse - Converted 17 modals across 6 files to use FullScreenModal - All modals now appear centered on full browser viewport - Removed animations for instant modal appearance - Mobile app behavior remains unchanged Files updated: - companion/components/FullScreenModal.tsx (new) - companion/extension/entrypoints/content.ts - companion/app/(tabs)/event-types.tsx (5 modals) - companion/app/(tabs)/bookings.tsx (2 modals) - companion/app/(tabs)/availability.tsx (3 modals) - companion/app/booking-detail.tsx (2 modals) - companion/app/availability-detail.tsx (4 modals) - companion/components/Header.tsx (1 modal) * security(companion): validate postMessage origin in content script Add origin and source validation to postMessage listener to prevent malicious scripts on host pages from manipulating the companion iframe. - Verify event.source matches iframe.contentWindow - Verify event.origin matches iframe origin (localhost:8081) - Reject all unauthorized messages before processing This prevents external scripts from: - Forcing full-screen overlays - Intercepting pointer events - Manipulating the companion UI * fix(companion): center 'Add new event type' modal on full screen Replace KeyboardAvoidingView with TouchableOpacity pattern in the create event type modal to ensure it appears centered on the full screen like all other modals. This was the last remaining modal that wasn't properly centered. * fix(companion): resolve modal transition timing issue Fixed the 'Add new event type' modal not centering properly by: - Added 100ms delay when transitioning between modals - Prevents conflicting expand/collapse messages - Removed redundant postMessage calls from modal handlers - Added max-height constraint to modal content - Cleaned up debug console logs The issue was that when clicking 'New Event Type' from the 'New' menu, both modals were transitioning simultaneously, causing the create modal to collapse immediately. The delay ensures proper sequencing. All 17 modals now work correctly and appear centered on full screen. * added working date range * removed all of one-off event code * nit * feat(companion): Suggest `Cal.com` links and Embed using Gemini Ai in Gmail (#25406) Co-authored-by: Peer Richelsen <peeroke@gmail.com> * add companion build on CI * feat: oauth companion (#25526) * first implemenation of oauth * fix logout for web * revert app name * remove isUsingOAuth * clean up log out button * remove logs * clean up authcontext * simplify code generation * code clean up * clean up oauthService.ts * oauth browser extension * add redirect uri to .env.example * remove api key support * clean up _layout file * add identity * fix + clean up * improvements to oauth flow * code clean up * remove comments * clean up comments * add log out dialog * add confirm modal --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com> --------- Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Co-authored-by: Abhijeet Singh <asingh9829@gmail.com> Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com> Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com> Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: CarinaWolli <wollencarina@gmail.com>
1147 lines
33 KiB
TypeScript
1147 lines
33 KiB
TypeScript
import type {
|
|
EventType,
|
|
CreateEventTypeInput,
|
|
GetEventTypesResponse,
|
|
Booking,
|
|
GetBookingsResponse,
|
|
BookingParticipationResult,
|
|
Schedule,
|
|
GetSchedulesResponse,
|
|
GetScheduleResponse,
|
|
UserProfile,
|
|
ConferencingOption,
|
|
Webhook,
|
|
CreateWebhookInput,
|
|
UpdateWebhookInput,
|
|
PrivateLink,
|
|
CreatePrivateLinkInput,
|
|
UpdatePrivateLinkInput,
|
|
} from "./types";
|
|
|
|
const API_BASE_URL = "https://api.cal.com/v2";
|
|
|
|
// 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<void>) | 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,
|
|
};
|
|
|
|
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 };
|
|
};
|
|
|
|
export class CalComAPIService {
|
|
private static userProfile: UserProfile | null = null;
|
|
|
|
/**
|
|
* Set OAuth access token for authentication
|
|
*/
|
|
static setAccessToken(accessToken: string, refreshToken?: string): void {
|
|
authConfig.accessToken = accessToken;
|
|
if (refreshToken) {
|
|
authConfig.refreshToken = refreshToken;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set refresh token function for automatic token refresh
|
|
*/
|
|
static setRefreshTokenFunction(
|
|
refreshFn: (refreshToken: string) => Promise<{ accessToken: string; refreshToken?: string }>
|
|
): void {
|
|
refreshTokenFunction = refreshFn;
|
|
}
|
|
|
|
/**
|
|
* Clear all authentication
|
|
*/
|
|
static clearAuth(): void {
|
|
authConfig.accessToken = undefined;
|
|
authConfig.refreshToken = undefined;
|
|
tokenRefreshCallback = null;
|
|
refreshTokenFunction = null;
|
|
}
|
|
|
|
/**
|
|
* Set token refresh callback for OAuth token refresh
|
|
*/
|
|
static setTokenRefreshCallback(
|
|
callback: (accessToken: string, refreshToken?: string) => Promise<void>
|
|
): void {
|
|
tokenRefreshCallback = callback;
|
|
}
|
|
|
|
/**
|
|
* Get current authentication header
|
|
*/
|
|
private static 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
|
|
static async getCurrentUser(): Promise<UserProfile> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: UserProfile }>(
|
|
"/me",
|
|
{
|
|
headers: {
|
|
"cal-api-version": "2024-06-11",
|
|
},
|
|
},
|
|
"2024-06-11"
|
|
);
|
|
return response.data;
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Update current user profile
|
|
static async updateUserProfile(updates: {
|
|
email?: string;
|
|
name?: string;
|
|
timeFormat?: number;
|
|
defaultScheduleId?: number;
|
|
weekStart?: string;
|
|
timeZone?: string;
|
|
locale?: string;
|
|
avatarUrl?: string;
|
|
bio?: string;
|
|
metadata?: Record<string, any>;
|
|
}): Promise<UserProfile> {
|
|
try {
|
|
const response = await this.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 && response.data) {
|
|
// Update cached profile
|
|
this.userProfile = response.data;
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from update user profile API");
|
|
} catch (error) {
|
|
console.error("updateUserProfile error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Get and cache user profile
|
|
static async getUserProfile(): Promise<UserProfile> {
|
|
if (this.userProfile) {
|
|
return this.userProfile;
|
|
}
|
|
|
|
this.userProfile = await this.getCurrentUser();
|
|
return this.userProfile;
|
|
}
|
|
|
|
// Get cached username or fetch if not available
|
|
static async getUsername(): Promise<string> {
|
|
const profile = await this.getUserProfile();
|
|
return profile.username;
|
|
}
|
|
|
|
// Build shareable link for event type
|
|
static async buildEventTypeLink(eventTypeSlug: string): Promise<string> {
|
|
const username = await this.getUsername();
|
|
return `https://cal.com/${username}/${eventTypeSlug}`;
|
|
}
|
|
|
|
// Clear cached profile (useful for logout)
|
|
static clearUserProfile(): void {
|
|
this.userProfile = null;
|
|
}
|
|
|
|
// Test function for bookings API specifically
|
|
static async testRawBookingsAPI(): Promise<void> {
|
|
try {
|
|
const url = `${API_BASE_URL}/bookings?status=upcoming&status=unconfirmed&limit=50`;
|
|
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
Authorization: this.getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
"cal-api-version": "2024-08-13",
|
|
},
|
|
});
|
|
|
|
const responseText = await response.text();
|
|
|
|
try {
|
|
const responseJson = JSON.parse(responseText);
|
|
} catch (parseError) {}
|
|
} catch (error) {}
|
|
}
|
|
|
|
private static async makeRequest<T>(
|
|
endpoint: string,
|
|
options: RequestInit = {},
|
|
apiVersion: string = "2024-08-13",
|
|
isRetry: boolean = false
|
|
): Promise<T> {
|
|
const url = `${API_BASE_URL}${endpoint}`;
|
|
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
Authorization: this.getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
"cal-api-version": apiVersion,
|
|
...options.headers,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text();
|
|
|
|
// Parse error for better user messages
|
|
let errorMessage = response.statusText;
|
|
|
|
try {
|
|
const errorJson = JSON.parse(errorBody);
|
|
errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText;
|
|
} catch (parseError) {
|
|
// 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 this.makeRequest<T>(endpoint, options, apiVersion, true);
|
|
} catch (refreshError) {
|
|
console.error("Token refresh failed:", refreshError);
|
|
this.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.");
|
|
}
|
|
|
|
throw new Error(`API Error: ${errorMessage}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
// Delete an event type
|
|
static async deleteEventType(eventTypeId: number): Promise<void> {
|
|
try {
|
|
console.log(`Deleting event type with ID: ${eventTypeId}`);
|
|
await this.makeRequest(
|
|
`/event-types/${eventTypeId}`,
|
|
{
|
|
method: "DELETE",
|
|
},
|
|
"2024-06-14"
|
|
);
|
|
console.log("Delete completed");
|
|
} catch (error) {
|
|
console.error("Delete API error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Create an event type
|
|
static async createEventType(input: CreateEventTypeInput): Promise<EventType> {
|
|
try {
|
|
console.log("Creating event type with input:", JSON.stringify(input, null, 2));
|
|
|
|
const response = await this.makeRequest<{ status: string; data: EventType }>(
|
|
"/event-types",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"cal-api-version": "2024-06-14",
|
|
},
|
|
body: JSON.stringify(input),
|
|
},
|
|
"2024-06-14"
|
|
);
|
|
|
|
if (response && response.data) {
|
|
console.log("Event type created successfully:", response.data);
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from create event type API");
|
|
} catch (error) {
|
|
console.error("createEventType error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Cancel a booking
|
|
static async cancelBooking(bookingUid: string, reason?: string): Promise<void> {
|
|
try {
|
|
const body: { reason?: string } = {};
|
|
if (reason) {
|
|
body.reason = reason;
|
|
}
|
|
|
|
await this.makeRequest(`/bookings/${bookingUid}/cancel`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Reschedule a booking
|
|
static async rescheduleBooking(
|
|
bookingUid: string,
|
|
input: {
|
|
start: string; // ISO 8601 datetime string
|
|
reschedulingReason?: string;
|
|
}
|
|
): Promise<Booking> {
|
|
try {
|
|
console.log(`Rescheduling booking ${bookingUid} to:`, input.start);
|
|
|
|
const response = await this.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 && response.data) {
|
|
console.log("Booking rescheduled successfully:", response.data);
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from reschedule booking API");
|
|
} catch (error) {
|
|
console.error("rescheduleBooking error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
static async getEventTypes(): Promise<EventType[]> {
|
|
try {
|
|
// Get current user to extract username
|
|
let username: string | undefined;
|
|
try {
|
|
const currentUser = await this.getCurrentUser();
|
|
// Extract username from response
|
|
if (currentUser?.username) {
|
|
username = currentUser.username;
|
|
}
|
|
} catch (error) {}
|
|
|
|
// Build query string with username if available
|
|
const params = new URLSearchParams();
|
|
if (username) {
|
|
params.append("username", username);
|
|
}
|
|
|
|
const queryString = params.toString();
|
|
const endpoint = `/event-types${queryString ? `?${queryString}` : ""}`;
|
|
|
|
const response = await this.makeRequest<any>(endpoint, {}, "2024-06-14");
|
|
|
|
// Handle different possible response structures
|
|
let eventTypesArray: EventType[] = [];
|
|
|
|
if (Array.isArray(response)) {
|
|
eventTypesArray = response;
|
|
} else if (response && typeof response === "object") {
|
|
if (response.data && Array.isArray(response.data)) {
|
|
eventTypesArray = response.data;
|
|
} else if (response.eventTypes && Array.isArray(response.eventTypes)) {
|
|
eventTypesArray = response.eventTypes;
|
|
} else if (response.items && Array.isArray(response.items)) {
|
|
eventTypesArray = response.items;
|
|
} else if (response.data && typeof response.data === "object") {
|
|
if (response.data.eventTypes && Array.isArray(response.data.eventTypes)) {
|
|
eventTypesArray = response.data.eventTypes;
|
|
} else if (response.data.items && Array.isArray(response.data.items)) {
|
|
eventTypesArray = response.data.items;
|
|
} else {
|
|
const keys = Object.keys(response.data);
|
|
if (keys.length > 0) {
|
|
eventTypesArray = Object.values(response.data).filter(
|
|
(item): item is EventType => item && typeof item === "object" && "id" in item
|
|
) as EventType[];
|
|
}
|
|
}
|
|
} else {
|
|
const possibleArrays = Object.values(response).filter((val) => Array.isArray(val));
|
|
if (possibleArrays.length > 0) {
|
|
eventTypesArray = possibleArrays[0] as EventType[];
|
|
}
|
|
}
|
|
}
|
|
|
|
return eventTypesArray;
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
static async getBookingByUid(bookingUid: string): Promise<Booking> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: Booking }>(
|
|
`/bookings/${bookingUid}`,
|
|
{
|
|
headers: {
|
|
"cal-api-version": "2024-08-13",
|
|
},
|
|
},
|
|
"2024-08-13"
|
|
);
|
|
console.log("getBookingByUid raw response:", JSON.stringify(response, null, 2));
|
|
if (response && response.data) {
|
|
console.log("getBookingByUid booking data:", JSON.stringify(response.data, null, 2));
|
|
console.log("getBookingByUid user field:", response.data.user);
|
|
console.log("getBookingByUid hosts field:", response.data.hosts);
|
|
console.log("getBookingByUid attendees field:", response.data.attendees);
|
|
return response.data;
|
|
}
|
|
throw new Error("Invalid response from get booking API");
|
|
} catch (error) {
|
|
console.error("getBookingByUid error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
static async getBookings(filters?: {
|
|
status?: string[];
|
|
fromDate?: string;
|
|
toDate?: string;
|
|
eventTypeId?: number;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<Booking[]> {
|
|
try {
|
|
// Build query parameters
|
|
const params = new URLSearchParams();
|
|
|
|
if (filters?.status?.length) {
|
|
filters.status.forEach((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 this.makeRequest<any>(endpoint);
|
|
|
|
// Handle different possible response structures (same logic as event types)
|
|
let bookingsArray: Booking[] = [];
|
|
|
|
if (Array.isArray(response)) {
|
|
bookingsArray = response;
|
|
} else if (response && typeof response === "object") {
|
|
if (response.data && Array.isArray(response.data)) {
|
|
bookingsArray = response.data;
|
|
} else if (response.bookings && Array.isArray(response.bookings)) {
|
|
bookingsArray = response.bookings;
|
|
} else if (response.items && Array.isArray(response.items)) {
|
|
bookingsArray = response.items;
|
|
} else if (response.data && typeof response.data === "object") {
|
|
if (response.data.bookings && Array.isArray(response.data.bookings)) {
|
|
bookingsArray = response.data.bookings;
|
|
} else if (response.data.items && Array.isArray(response.data.items)) {
|
|
bookingsArray = response.data.items;
|
|
} else {
|
|
// Convert object values to array as last resort
|
|
const keys = Object.keys(response.data);
|
|
if (keys.length > 0) {
|
|
bookingsArray = Object.values(response.data).filter(
|
|
(item): item is Booking =>
|
|
item && typeof item === "object" && ("id" in item || "uid" in item)
|
|
) as Booking[];
|
|
}
|
|
}
|
|
} else {
|
|
// Try to extract any arrays from the response
|
|
const possibleArrays = Object.values(response).filter((val) => Array.isArray(val));
|
|
if (possibleArrays.length > 0) {
|
|
bookingsArray = possibleArrays[0] as Booking[];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get current user to filter bookings
|
|
let currentUser;
|
|
try {
|
|
currentUser = await this.getCurrentUser();
|
|
} catch (error) {
|
|
return bookingsArray;
|
|
}
|
|
|
|
// Extract user info from response
|
|
let userId: number | undefined;
|
|
let userEmail: string | undefined;
|
|
|
|
if (currentUser) {
|
|
userId = currentUser.id;
|
|
userEmail = currentUser.email;
|
|
}
|
|
|
|
// Filter bookings to only show ones where the current user is participating
|
|
const userBookings = bookingsArray.filter((booking) => {
|
|
const { isOrganizer, isHost, isAttendee, isParticipating } = getBookingParticipation(
|
|
booking,
|
|
userId,
|
|
userEmail
|
|
);
|
|
|
|
return isParticipating;
|
|
});
|
|
|
|
return userBookings;
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Get all schedules
|
|
static async getSchedules(): Promise<Schedule[]> {
|
|
try {
|
|
const response = await this.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 && response.data && Array.isArray(response.data)) {
|
|
// Fallback: response.data might be array
|
|
schedulesArray = response.data;
|
|
}
|
|
|
|
return schedulesArray;
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Create a new schedule
|
|
static async 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<Schedule> {
|
|
try {
|
|
console.log("Creating schedule with input:", JSON.stringify(input, null, 2));
|
|
|
|
const response = await this.makeRequest<{ status: string; data: Schedule }>(
|
|
"/schedules",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"cal-api-version": "2024-06-11",
|
|
},
|
|
body: JSON.stringify(input),
|
|
},
|
|
"2024-06-11"
|
|
);
|
|
|
|
if (response && response.data) {
|
|
console.log("Schedule created successfully:", response.data);
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from create schedule API");
|
|
} catch (error) {
|
|
console.error("createSchedule error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Get specific schedule by ID
|
|
static async getScheduleById(scheduleId: number): Promise<Schedule | null> {
|
|
try {
|
|
const response = await this.makeRequest<any>(
|
|
`/schedules/${scheduleId}`,
|
|
{
|
|
headers: {
|
|
"cal-api-version": "2024-06-11", // Override version for schedules
|
|
},
|
|
},
|
|
"2024-06-11"
|
|
);
|
|
|
|
console.log("getScheduleById raw response:", JSON.stringify(response, null, 2));
|
|
|
|
if (response && response.data) {
|
|
console.log("Returning schedule data:", response.data);
|
|
return response.data;
|
|
}
|
|
|
|
// Sometimes the response might be the schedule directly
|
|
if (response && response.id) {
|
|
console.log("Returning schedule directly:", response);
|
|
return response;
|
|
}
|
|
|
|
console.log("No schedule data found in response");
|
|
return null;
|
|
} catch (error) {
|
|
console.error("getScheduleById error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Get conferencing options
|
|
static async getConferencingOptions(): Promise<ConferencingOption[]> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: ConferencingOption[] }>(
|
|
"/conferencing"
|
|
);
|
|
|
|
if (response && response.data && Array.isArray(response.data)) {
|
|
return response.data;
|
|
}
|
|
|
|
return [];
|
|
} catch (error) {
|
|
console.error("getConferencingOptions error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Get a single event type by ID
|
|
static async getEventTypeById(eventTypeId: number): Promise<EventType | null> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: EventType }>(
|
|
`/event-types/${eventTypeId}`,
|
|
{},
|
|
"2024-06-14"
|
|
);
|
|
|
|
if (response && 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:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Update an event type
|
|
static async updateEventType(
|
|
eventTypeId: number,
|
|
updates: Partial<CreateEventTypeInput>
|
|
): Promise<EventType> {
|
|
try {
|
|
console.log(`Updating event type ${eventTypeId} with:`, JSON.stringify(updates, null, 2));
|
|
|
|
const response = await this.makeRequest<{ status: string; data: EventType }>(
|
|
`/event-types/${eventTypeId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"cal-api-version": "2024-06-14",
|
|
},
|
|
body: JSON.stringify(updates),
|
|
},
|
|
"2024-06-14"
|
|
);
|
|
|
|
if (response && response.data) {
|
|
console.log("Event type updated successfully:", response.data);
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from update event type API");
|
|
} catch (error) {
|
|
console.error("updateEventType error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Update a schedule
|
|
static async 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<Schedule> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: Schedule }>(
|
|
`/schedules/${scheduleId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"cal-api-version": "2024-06-11",
|
|
},
|
|
body: JSON.stringify(updates),
|
|
},
|
|
"2024-06-11"
|
|
);
|
|
|
|
if (response && response.data) {
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from update schedule API");
|
|
} catch (error) {
|
|
console.error("updateSchedule error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Duplicate a schedule
|
|
static async duplicateSchedule(scheduleId: number): Promise<Schedule> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: Schedule }>(
|
|
`/atoms/schedules/${scheduleId}/duplicate`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"cal-api-version": "2024-06-11",
|
|
},
|
|
},
|
|
"2024-06-11"
|
|
);
|
|
|
|
if (response && response.data) {
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from duplicate schedule API");
|
|
} catch (error) {
|
|
console.error("duplicateSchedule error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Delete a schedule
|
|
static async deleteSchedule(scheduleId: number): Promise<void> {
|
|
try {
|
|
await this.makeRequest<{ status: string }>(
|
|
`/schedules/${scheduleId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
"cal-api-version": "2024-06-11",
|
|
},
|
|
},
|
|
"2024-06-11"
|
|
);
|
|
} catch (error) {
|
|
console.error("deleteSchedule error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// WEBHOOKS
|
|
// ============================================
|
|
|
|
// Get all global webhooks
|
|
static async getWebhooks(): Promise<Webhook[]> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: Webhook[] }>("/webhooks");
|
|
|
|
if (response && response.data && Array.isArray(response.data)) {
|
|
return response.data;
|
|
}
|
|
|
|
return [];
|
|
} catch (error) {
|
|
console.error("getWebhooks error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Create a global webhook
|
|
static async createWebhook(input: CreateWebhookInput): Promise<Webhook> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: Webhook }>("/webhooks", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(input),
|
|
});
|
|
|
|
if (response && response.data) {
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from create webhook API");
|
|
} catch (error) {
|
|
console.error("createWebhook error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Update a global webhook
|
|
static async updateWebhook(webhookId: string, updates: UpdateWebhookInput): Promise<Webhook> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: Webhook }>(
|
|
`/webhooks/${webhookId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(updates),
|
|
}
|
|
);
|
|
|
|
if (response && response.data) {
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from update webhook API");
|
|
} catch (error) {
|
|
console.error("updateWebhook error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Delete a global webhook
|
|
static async deleteWebhook(webhookId: string): Promise<void> {
|
|
try {
|
|
await this.makeRequest(`/webhooks/${webhookId}`, {
|
|
method: "DELETE",
|
|
});
|
|
} catch (error) {
|
|
console.error("deleteWebhook error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Get webhooks for a specific event type
|
|
static async getEventTypeWebhooks(eventTypeId: number): Promise<Webhook[]> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: Webhook[] }>(
|
|
`/event-types/${eventTypeId}/webhooks`
|
|
);
|
|
|
|
if (response && response.data && Array.isArray(response.data)) {
|
|
return response.data;
|
|
}
|
|
|
|
return [];
|
|
} catch (error) {
|
|
console.error("getEventTypeWebhooks error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Create a webhook for a specific event type
|
|
static async createEventTypeWebhook(
|
|
eventTypeId: number,
|
|
input: CreateWebhookInput
|
|
): Promise<Webhook> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: Webhook }>(
|
|
`/event-types/${eventTypeId}/webhooks`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(input),
|
|
}
|
|
);
|
|
|
|
if (response && response.data) {
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from create event type webhook API");
|
|
} catch (error) {
|
|
console.error("createEventTypeWebhook error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Update an event type webhook
|
|
static async updateEventTypeWebhook(
|
|
eventTypeId: number,
|
|
webhookId: string,
|
|
updates: UpdateWebhookInput
|
|
): Promise<Webhook> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: Webhook }>(
|
|
`/event-types/${eventTypeId}/webhooks/${webhookId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(updates),
|
|
}
|
|
);
|
|
|
|
if (response && response.data) {
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from update event type webhook API");
|
|
} catch (error) {
|
|
console.error("updateEventTypeWebhook error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Delete an event type webhook
|
|
static async deleteEventTypeWebhook(eventTypeId: number, webhookId: string): Promise<void> {
|
|
try {
|
|
await this.makeRequest(`/event-types/${eventTypeId}/webhooks/${webhookId}`, {
|
|
method: "DELETE",
|
|
});
|
|
} catch (error) {
|
|
console.error("deleteEventTypeWebhook error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// PRIVATE LINKS
|
|
// ============================================
|
|
|
|
// Get all private links for an event type
|
|
static async getEventTypePrivateLinks(eventTypeId: number): Promise<PrivateLink[]> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: PrivateLink[] }>(
|
|
`/event-types/${eventTypeId}/private-links`
|
|
);
|
|
|
|
if (response && response.data && Array.isArray(response.data)) {
|
|
return response.data;
|
|
}
|
|
|
|
return [];
|
|
} catch (error) {
|
|
console.error("getEventTypePrivateLinks error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Create a private link for an event type
|
|
static async createEventTypePrivateLink(
|
|
eventTypeId: number,
|
|
input: CreatePrivateLinkInput = {}
|
|
): Promise<PrivateLink> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: PrivateLink }>(
|
|
`/event-types/${eventTypeId}/private-links`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(input),
|
|
}
|
|
);
|
|
|
|
if (response && response.data) {
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from create private link API");
|
|
} catch (error) {
|
|
console.error("createEventTypePrivateLink error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Update a private link
|
|
static async updateEventTypePrivateLink(
|
|
eventTypeId: number,
|
|
linkId: number,
|
|
updates: UpdatePrivateLinkInput
|
|
): Promise<PrivateLink> {
|
|
try {
|
|
const response = await this.makeRequest<{ status: string; data: PrivateLink }>(
|
|
`/event-types/${eventTypeId}/private-links/${linkId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(updates),
|
|
}
|
|
);
|
|
|
|
if (response && response.data) {
|
|
return response.data;
|
|
}
|
|
|
|
throw new Error("Invalid response from update private link API");
|
|
} catch (error) {
|
|
console.error("updateEventTypePrivateLink error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Delete a private link
|
|
static async deleteEventTypePrivateLink(eventTypeId: number, linkId: number): Promise<void> {
|
|
try {
|
|
await this.makeRequest(`/event-types/${eventTypeId}/private-links/${linkId}`, {
|
|
method: "DELETE",
|
|
});
|
|
} catch (error) {
|
|
console.error("deleteEventTypePrivateLink error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|