import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
import { CalendarProvider } from './CalendarProvider';
import {
CalendarEvent,
Calendar,
TimeSlot,
EventSearchCriteria,
FreetimeSearchCriteria,
CreateEventInput,
ApiResponse,
Attendee,
AttendeeResponseStatus,
EventStatus,
CalendarAccessRole
} from '../types';
import { googleConfig } from '../config';
export class GoogleCalendarProvider extends CalendarProvider {
private auth: OAuth2Client;
private calendar: any;
constructor(accessToken: string, refreshToken?: string, tokenExpiry?: Date) {
super(accessToken, refreshToken, tokenExpiry);
this.auth = new OAuth2Client(
googleConfig.clientId,
googleConfig.clientSecret,
googleConfig.redirectUri
);
this.auth.setCredentials({
access_token: accessToken,
refresh_token: refreshToken,
expiry_date: tokenExpiry?.getTime()
});
this.calendar = google.calendar({ version: 'v3', auth: this.auth });
}
async getCalendars(): Promise<ApiResponse<Calendar[]>> {
try {
await this.ensureValidToken();
const response = await this.calendar.calendarList.list({
showHidden: false,
showDeleted: false
});
const calendars: Calendar[] = (response.data.items || []).map((item: any) => ({
id: item.id,
title: item.summary,
description: item.description,
timezone: item.timeZone,
accessRole: this.mapGoogleAccessRole(item.accessRole),
backgroundColor: item.backgroundColor,
foregroundColor: item.foregroundColor
}));
return {
success: true,
data: calendars
};
} catch (error) {
return this.handleError('GET_CALENDARS_FAILED', error);
}
}
async getEvents(
calendarId: string,
startDate: Date,
endDate: Date,
query?: string
): Promise<ApiResponse<CalendarEvent[]>> {
try {
await this.ensureValidToken();
const response = await this.calendar.events.list({
calendarId,
timeMin: startDate.toISOString(),
timeMax: endDate.toISOString(),
q: query,
singleEvents: true,
orderBy: 'startTime',
maxResults: 2500 // Google's max
});
const events: CalendarEvent[] = (response.data.items || []).map((item: any) =>
this.mapGoogleEvent(item, calendarId)
);
return {
success: true,
data: events
};
} catch (error) {
return this.handleError('GET_EVENTS_FAILED', error);
}
}
async getEvent(calendarId: string, eventId: string): Promise<ApiResponse<CalendarEvent>> {
try {
await this.ensureValidToken();
const response = await this.calendar.events.get({
calendarId,
eventId
});
const event = this.mapGoogleEvent(response.data, calendarId);
return {
success: true,
data: event
};
} catch (error) {
return this.handleError('GET_EVENT_FAILED', error);
}
}
async createEvent(
calendarId: string,
eventData: CreateEventInput
): Promise<ApiResponse<CalendarEvent>> {
try {
await this.ensureValidToken();
const googleEvent = {
summary: eventData.title,
description: eventData.description,
location: eventData.location,
start: {
dateTime: new Date(eventData.startTime).toISOString(),
timeZone: 'UTC'
},
end: {
dateTime: new Date(eventData.endTime).toISOString(),
timeZone: 'UTC'
},
attendees: eventData.attendees?.map(attendee => ({
email: attendee.email,
displayName: attendee.displayName,
optional: attendee.optional || false
}))
};
const response = await this.calendar.events.insert({
calendarId,
resource: googleEvent,
sendUpdates: 'all' // Send invitations to attendees
});
const createdEvent = this.mapGoogleEvent(response.data, calendarId);
return {
success: true,
data: createdEvent
};
} catch (error) {
return this.handleError('CREATE_EVENT_FAILED', error);
}
}
async updateEvent(
calendarId: string,
eventId: string,
updates: Partial<CreateEventInput>
): Promise<ApiResponse<CalendarEvent>> {
try {
await this.ensureValidToken();
// First get the current event
const currentResponse = await this.calendar.events.get({
calendarId,
eventId
});
const updatedEvent = {
...currentResponse.data,
summary: updates.title || currentResponse.data.summary,
description: updates.description !== undefined ? updates.description : currentResponse.data.description,
location: updates.location !== undefined ? updates.location : currentResponse.data.location,
start: updates.startTime ? {
dateTime: new Date(updates.startTime).toISOString(),
timeZone: 'UTC'
} : currentResponse.data.start,
end: updates.endTime ? {
dateTime: new Date(updates.endTime).toISOString(),
timeZone: 'UTC'
} : currentResponse.data.end,
attendees: updates.attendees ? updates.attendees.map(attendee => ({
email: attendee.email,
displayName: attendee.displayName,
optional: attendee.optional || false
})) : currentResponse.data.attendees
};
const response = await this.calendar.events.update({
calendarId,
eventId,
resource: updatedEvent,
sendUpdates: 'all'
});
const event = this.mapGoogleEvent(response.data, calendarId);
return {
success: true,
data: event
};
} catch (error) {
return this.handleError('UPDATE_EVENT_FAILED', error);
}
}
async deleteEvent(calendarId: string, eventId: string): Promise<ApiResponse<boolean>> {
try {
await this.ensureValidToken();
await this.calendar.events.delete({
calendarId,
eventId,
sendUpdates: 'all'
});
return {
success: true,
data: true
};
} catch (error) {
return this.handleError('DELETE_EVENT_FAILED', error);
}
}
async findFreeTime(criteria: FreetimeSearchCriteria): Promise<ApiResponse<TimeSlot[]>> {
try {
await this.ensureValidToken();
// Get all events for the specified calendars in the time range
const allEvents: CalendarEvent[] = [];
for (const calendarId of criteria.calendarIds) {
const eventsResponse = await this.getEvents(
calendarId,
criteria.startDate,
criteria.endDate
);
if (eventsResponse.success && eventsResponse.data) {
allEvents.push(...eventsResponse.data);
}
}
// Calculate free slots
const freeSlots = this.calculateFreeSlots(
allEvents,
criteria.startDate,
criteria.endDate,
criteria.durationMinutes,
criteria.constraints
);
// Limit results if specified
const limitedSlots = criteria.maxResults
? freeSlots.slice(0, criteria.maxResults)
: freeSlots;
return {
success: true,
data: limitedSlots
};
} catch (error) {
return this.handleError('FIND_FREE_TIME_FAILED', error);
}
}
async checkAvailability(
emailAddresses: string[],
startTime: Date,
endTime: Date
): Promise<ApiResponse<Record<string, boolean>>> {
try {
await this.ensureValidToken();
const response = await this.calendar.freebusy.query({
resource: {
timeMin: startTime.toISOString(),
timeMax: endTime.toISOString(),
items: emailAddresses.map(email => ({ id: email }))
}
});
const availability: Record<string, boolean> = {};
for (const email of emailAddresses) {
const busyTimes = response.data.calendars?.[email]?.busy || [];
availability[email] = busyTimes.length === 0; // Available if no busy times
}
return {
success: true,
data: availability
};
} catch (error) {
return this.handleError('CHECK_AVAILABILITY_FAILED', error);
}
}
protected async refreshAccessToken(): Promise<void> {
try {
const { credentials } = await this.auth.refreshAccessToken();
this.accessToken = credentials.access_token || '';
this.tokenExpiry = new Date(credentials.expiry_date || Date.now() + 3600000);
if (credentials.refresh_token) {
this.refreshToken = credentials.refresh_token;
}
} catch (error) {
throw new Error(`Failed to refresh Google access token: ${error}`);
}
}
// Helper methods for mapping Google API responses to our types
private mapGoogleEvent(googleEvent: any, calendarId: string): CalendarEvent {
return {
id: googleEvent.id,
calendarId,
title: googleEvent.summary || 'Untitled Event',
description: googleEvent.description,
startTime: new Date(googleEvent.start.dateTime || googleEvent.start.date),
endTime: new Date(googleEvent.end.dateTime || googleEvent.end.date),
location: googleEvent.location,
attendees: (googleEvent.attendees || []).map((attendee: any) => ({
email: attendee.email,
displayName: attendee.displayName,
responseStatus: this.mapGoogleResponseStatus(attendee.responseStatus),
optional: attendee.optional || false
})),
status: this.mapGoogleEventStatus(googleEvent.status),
created: new Date(googleEvent.created),
updated: new Date(googleEvent.updated)
};
}
private mapGoogleResponseStatus(status: string): AttendeeResponseStatus {
switch (status) {
case 'accepted': return AttendeeResponseStatus.ACCEPTED;
case 'declined': return AttendeeResponseStatus.DECLINED;
case 'tentative': return AttendeeResponseStatus.TENTATIVE;
default: return AttendeeResponseStatus.NEEDS_ACTION;
}
}
private mapGoogleEventStatus(status: string): EventStatus {
switch (status) {
case 'confirmed': return EventStatus.CONFIRMED;
case 'tentative': return EventStatus.TENTATIVE;
case 'cancelled': return EventStatus.CANCELLED;
default: return EventStatus.CONFIRMED;
}
}
private mapGoogleAccessRole(role: string): CalendarAccessRole {
switch (role) {
case 'owner': return CalendarAccessRole.OWNER;
case 'reader': return CalendarAccessRole.READER;
case 'writer': return CalendarAccessRole.WRITER;
case 'freeBusyReader': return CalendarAccessRole.FREE_BUSY_READER;
default: return CalendarAccessRole.READER;
}
}
private handleError(code: string, error: any): ApiResponse<any> {
console.error(`Google Calendar API Error [${code}]:`, error);
let message = 'An unknown error occurred';
if (error.response?.data?.error?.message) {
message = error.response.data.error.message;
} else if (error.message) {
message = error.message;
}
return {
success: false,
error: {
code,
message,
details: {
googleError: error.response?.data || error
}
}
};
}
}