// Google Calendar API Service
import { google } from 'googleapis';
import type { OAuth2Client } from 'google-auth-library';
import type { calendar_v3 } from 'googleapis';
// ─────────────────────────────────────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────────────────────────────────────
export interface EventDateTime {
dateTime?: string; // RFC3339 timestamp for timed events
date?: string; // YYYY-MM-DD for all-day events
timeZone?: string; // IANA timezone
}
export interface EventAttendee {
email: string;
displayName?: string;
optional?: boolean;
responseStatus?: 'needsAction' | 'declined' | 'tentative' | 'accepted';
comment?: string;
}
export interface EventReminder {
method: 'email' | 'popup';
minutes: number; // 0-40320 (up to 4 weeks)
}
export interface CreateEventParams {
calendarId?: string; // Defaults to 'primary'
summary: string;
description?: string;
location?: string;
start: EventDateTime;
end: EventDateTime;
attendees?: EventAttendee[];
recurrence?: string[]; // RRULE strings
reminders?: {
useDefault: boolean;
overrides?: EventReminder[];
};
colorId?: string;
visibility?: 'default' | 'public' | 'private' | 'confidential';
transparency?: 'opaque' | 'transparent'; // opaque = busy, transparent = free
sendUpdates?: 'all' | 'externalOnly' | 'none';
// Google Meet
conferenceDataVersion?: 0 | 1;
createMeetLink?: boolean;
}
export interface UpdateEventParams {
calendarId?: string;
eventId: string;
summary?: string;
description?: string;
location?: string;
start?: EventDateTime;
end?: EventDateTime;
attendees?: EventAttendee[];
recurrence?: string[];
reminders?: {
useDefault: boolean;
overrides?: EventReminder[];
};
colorId?: string;
visibility?: 'default' | 'public' | 'private' | 'confidential';
transparency?: 'opaque' | 'transparent';
sendUpdates?: 'all' | 'externalOnly' | 'none';
}
export interface ListEventsParams {
calendarId?: string;
timeMin?: string; // RFC3339
timeMax?: string; // RFC3339
q?: string; // Search query
maxResults?: number;
pageToken?: string;
singleEvents?: boolean; // Expand recurring events
orderBy?: 'startTime' | 'updated';
showDeleted?: boolean;
timeZone?: string;
}
export interface CalendarEventResult {
id: string;
summary: string;
description?: string;
location?: string;
start: EventDateTime;
end: EventDateTime;
status: string;
htmlLink: string;
hangoutLink?: string;
attendees?: EventAttendee[];
organizer?: { email: string; displayName?: string; self?: boolean };
creator?: { email: string; displayName?: string; self?: boolean };
recurrence?: string[];
recurringEventId?: string;
created?: string;
updated?: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// SERVICE
// ─────────────────────────────────────────────────────────────────────────────
export class CalendarService {
private calendar: calendar_v3.Calendar;
constructor(authClient: OAuth2Client) {
this.calendar = google.calendar({ version: 'v3', auth: authClient });
}
// ───────────────────────────────────────────────────────────────────────────
// EVENTS - CRUD
// ───────────────────────────────────────────────────────────────────────────
/**
* Create a new calendar event
*/
async createEvent(params: CreateEventParams): Promise<CalendarEventResult> {
const {
calendarId = 'primary',
summary,
description,
location,
start,
end,
attendees,
recurrence,
reminders,
colorId,
visibility,
transparency,
sendUpdates = 'none',
conferenceDataVersion,
createMeetLink,
} = params;
// For recurring events, timeZone is required
// If not provided, default to UTC
const startWithTz = recurrence && !start.timeZone
? { ...start, timeZone: start.timeZone || 'UTC' }
: start;
const endWithTz = recurrence && !end.timeZone
? { ...end, timeZone: end.timeZone || 'UTC' }
: end;
// Build event resource
const eventResource: calendar_v3.Schema$Event = {
summary,
description,
location,
start: startWithTz,
end: endWithTz,
attendees: attendees?.map(a => ({
email: a.email,
displayName: a.displayName,
optional: a.optional,
responseStatus: a.responseStatus,
comment: a.comment,
})),
recurrence,
reminders,
colorId,
visibility,
transparency,
};
// Add Google Meet if requested
if (createMeetLink) {
eventResource.conferenceData = {
createRequest: {
requestId: `meet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
conferenceSolutionKey: { type: 'hangoutsMeet' },
},
};
}
const response = await this.calendar.events.insert({
calendarId,
requestBody: eventResource,
sendUpdates,
conferenceDataVersion: createMeetLink ? 1 : (conferenceDataVersion ?? 0),
});
return this.formatEventResult(response.data);
}
/**
* Get a single event by ID
*/
async getEvent(calendarId: string = 'primary', eventId: string): Promise<CalendarEventResult> {
const response = await this.calendar.events.get({
calendarId,
eventId,
});
return this.formatEventResult(response.data);
}
/**
* List events from a calendar
*/
async listEvents(params: ListEventsParams): Promise<{
events: CalendarEventResult[];
nextPageToken?: string;
}> {
const {
calendarId = 'primary',
timeMin,
timeMax,
q,
maxResults = 50,
pageToken,
singleEvents = true,
orderBy = 'startTime',
showDeleted = false,
timeZone,
} = params;
// orderBy=startTime requires singleEvents=true
const effectiveOrderBy = singleEvents ? orderBy : 'updated';
const response = await this.calendar.events.list({
calendarId,
timeMin,
timeMax,
q,
maxResults,
pageToken,
singleEvents,
orderBy: effectiveOrderBy,
showDeleted,
timeZone,
});
const events = (response.data.items || []).map(e => this.formatEventResult(e));
return {
events,
nextPageToken: response.data.nextPageToken || undefined,
};
}
/**
* Update an existing event
*/
async updateEvent(params: UpdateEventParams): Promise<CalendarEventResult> {
const {
calendarId = 'primary',
eventId,
sendUpdates = 'none',
...updates
} = params;
// Get existing event first
const existing = await this.calendar.events.get({ calendarId, eventId });
// Merge updates
const eventResource: calendar_v3.Schema$Event = {
...existing.data,
...updates,
};
// Handle attendees if provided
if (updates.attendees) {
eventResource.attendees = updates.attendees.map(a => ({
email: a.email,
displayName: a.displayName,
optional: a.optional,
responseStatus: a.responseStatus,
comment: a.comment,
}));
}
const response = await this.calendar.events.update({
calendarId,
eventId,
requestBody: eventResource,
sendUpdates,
});
return this.formatEventResult(response.data);
}
/**
* Delete an event
*/
async deleteEvent(
calendarId: string = 'primary',
eventId: string,
sendUpdates: 'all' | 'externalOnly' | 'none' = 'none'
): Promise<{ success: boolean; eventId: string }> {
await this.calendar.events.delete({
calendarId,
eventId,
sendUpdates,
});
return { success: true, eventId };
}
// ───────────────────────────────────────────────────────────────────────────
// EVENTS - ADVANCED
// ───────────────────────────────────────────────────────────────────────────
/**
* Create event from natural language text
*/
async quickAdd(
calendarId: string = 'primary',
text: string,
sendUpdates: 'all' | 'externalOnly' | 'none' = 'none'
): Promise<CalendarEventResult> {
const response = await this.calendar.events.quickAdd({
calendarId,
text,
sendUpdates,
});
return this.formatEventResult(response.data);
}
/**
* Move event to another calendar
*/
async moveEvent(
eventId: string,
sourceCalendarId: string = 'primary',
destinationCalendarId: string,
sendUpdates: 'all' | 'externalOnly' | 'none' = 'none'
): Promise<CalendarEventResult> {
const response = await this.calendar.events.move({
calendarId: sourceCalendarId,
eventId,
destination: destinationCalendarId,
sendUpdates,
});
return this.formatEventResult(response.data);
}
/**
* Get instances of a recurring event
*/
async getEventInstances(
calendarId: string = 'primary',
eventId: string,
timeMin?: string,
timeMax?: string,
maxResults: number = 50
): Promise<{ instances: CalendarEventResult[]; nextPageToken?: string }> {
const response = await this.calendar.events.instances({
calendarId,
eventId,
timeMin,
timeMax,
maxResults,
});
const instances = (response.data.items || []).map(e => this.formatEventResult(e));
return {
instances,
nextPageToken: response.data.nextPageToken || undefined,
};
}
// ───────────────────────────────────────────────────────────────────────────
// CALENDARS
// ───────────────────────────────────────────────────────────────────────────
/**
* List all calendars for the user
*/
async listCalendars(minAccessRole?: 'freeBusyReader' | 'reader' | 'writer' | 'owner'): Promise<{
calendars: Array<{
id: string;
summary: string;
description?: string;
timeZone?: string;
primary?: boolean;
accessRole: string;
backgroundColor?: string;
foregroundColor?: string;
}>;
}> {
const response = await this.calendar.calendarList.list({
minAccessRole,
});
const calendars = (response.data.items || []).map(cal => ({
id: cal.id!,
summary: cal.summary!,
description: cal.description || undefined,
timeZone: cal.timeZone || undefined,
primary: cal.primary || false,
accessRole: cal.accessRole!,
backgroundColor: cal.backgroundColor || undefined,
foregroundColor: cal.foregroundColor || undefined,
}));
return { calendars };
}
/**
* Get a specific calendar
*/
async getCalendar(calendarId: string): Promise<{
id: string;
summary: string;
description?: string;
timeZone?: string;
location?: string;
}> {
const response = await this.calendar.calendars.get({ calendarId });
return {
id: response.data.id!,
summary: response.data.summary!,
description: response.data.description || undefined,
timeZone: response.data.timeZone || undefined,
location: response.data.location || undefined,
};
}
/**
* Create a secondary calendar
*/
async createCalendar(
summary: string,
description?: string,
timeZone?: string,
location?: string
): Promise<{ id: string; summary: string }> {
const response = await this.calendar.calendars.insert({
requestBody: {
summary,
description,
timeZone,
location,
},
});
return {
id: response.data.id!,
summary: response.data.summary!,
};
}
/**
* Update calendar metadata
*/
async updateCalendar(
calendarId: string,
updates: { summary?: string; description?: string; timeZone?: string; location?: string }
): Promise<{ id: string; summary: string }> {
const response = await this.calendar.calendars.patch({
calendarId,
requestBody: updates,
});
return {
id: response.data.id!,
summary: response.data.summary!,
};
}
/**
* Delete a secondary calendar
*/
async deleteCalendar(calendarId: string): Promise<{ success: boolean; calendarId: string }> {
await this.calendar.calendars.delete({ calendarId });
return { success: true, calendarId };
}
// ───────────────────────────────────────────────────────────────────────────
// CALENDAR LIST (User preferences)
// ───────────────────────────────────────────────────────────────────────────
/**
* Add a calendar to user's list
*/
async addCalendarToList(
calendarId: string,
options?: {
colorId?: string;
backgroundColor?: string;
foregroundColor?: string;
hidden?: boolean;
selected?: boolean;
summaryOverride?: string;
}
): Promise<{ id: string; summary: string }> {
const response = await this.calendar.calendarList.insert({
requestBody: {
id: calendarId,
...options,
},
colorRgbFormat: !!(options?.backgroundColor || options?.foregroundColor),
});
return {
id: response.data.id!,
summary: response.data.summary!,
};
}
/**
* Update calendar display preferences
*/
async updateCalendarInList(
calendarId: string,
updates: {
colorId?: string;
backgroundColor?: string;
foregroundColor?: string;
hidden?: boolean;
selected?: boolean;
summaryOverride?: string;
}
): Promise<{ id: string; summary: string }> {
const response = await this.calendar.calendarList.patch({
calendarId,
requestBody: updates,
colorRgbFormat: !!(updates.backgroundColor || updates.foregroundColor),
});
return {
id: response.data.id!,
summary: response.data.summary!,
};
}
/**
* Remove calendar from user's list
*/
async removeCalendarFromList(calendarId: string): Promise<{ success: boolean; calendarId: string }> {
await this.calendar.calendarList.delete({ calendarId });
return { success: true, calendarId };
}
// ───────────────────────────────────────────────────────────────────────────
// ACL (Sharing & Permissions)
// ───────────────────────────────────────────────────────────────────────────
/**
* Share calendar with user/group/domain
*/
async shareCalendar(
calendarId: string,
scope: {
type: 'default' | 'user' | 'group' | 'domain';
value?: string; // email or domain name (not needed for 'default')
},
role: 'none' | 'freeBusyReader' | 'reader' | 'writer' | 'owner',
sendNotifications: boolean = true
): Promise<{ id: string; role: string; scope: { type: string; value?: string } }> {
const response = await this.calendar.acl.insert({
calendarId,
requestBody: {
role,
scope,
},
sendNotifications,
});
return {
id: response.data.id!,
role: response.data.role!,
scope: {
type: response.data.scope?.type!,
value: response.data.scope?.value,
},
};
}
/**
* Get all permissions for a calendar
*/
async getCalendarPermissions(calendarId: string): Promise<{
permissions: Array<{
id: string;
role: string;
scope: { type: string; value?: string };
}>;
}> {
const response = await this.calendar.acl.list({ calendarId });
const permissions = (response.data.items || []).map(rule => ({
id: rule.id!,
role: rule.role!,
scope: {
type: rule.scope?.type!,
value: rule.scope?.value,
},
}));
return { permissions };
}
/**
* Update a permission rule
*/
async updateCalendarPermission(
calendarId: string,
ruleId: string,
role: 'none' | 'freeBusyReader' | 'reader' | 'writer' | 'owner',
sendNotifications: boolean = true
): Promise<{ id: string; role: string }> {
const response = await this.calendar.acl.patch({
calendarId,
ruleId,
requestBody: { role },
sendNotifications,
});
return {
id: response.data.id!,
role: response.data.role!,
};
}
/**
* Remove a permission
*/
async removeCalendarPermission(calendarId: string, ruleId: string): Promise<{ success: boolean; ruleId: string }> {
await this.calendar.acl.delete({ calendarId, ruleId });
return { success: true, ruleId };
}
// ───────────────────────────────────────────────────────────────────────────
// FREEBUSY (Availability)
// ───────────────────────────────────────────────────────────────────────────
/**
* Check availability of calendars
*/
async checkAvailability(
timeMin: string,
timeMax: string,
calendars: string[],
timeZone?: string
): Promise<{
timeMin: string;
timeMax: string;
calendars: Record<string, { busy: Array<{ start: string; end: string }> }>;
}> {
const response = await this.calendar.freebusy.query({
requestBody: {
timeMin,
timeMax,
timeZone,
items: calendars.map(id => ({ id })),
},
});
const result: Record<string, { busy: Array<{ start: string; end: string }> }> = {};
if (response.data.calendars) {
for (const [calId, calData] of Object.entries(response.data.calendars)) {
result[calId] = {
busy: (calData.busy || []).map(b => ({
start: b.start!,
end: b.end!,
})),
};
}
}
return {
timeMin: response.data.timeMin!,
timeMax: response.data.timeMax!,
calendars: result,
};
}
// ───────────────────────────────────────────────────────────────────────────
// COLORS & SETTINGS
// ───────────────────────────────────────────────────────────────────────────
/**
* Get available colors for calendars and events
*/
async getColors(): Promise<{
calendar: Record<string, { background: string; foreground: string }>;
event: Record<string, { background: string; foreground: string }>;
}> {
const response = await this.calendar.colors.get();
const calendar: Record<string, { background: string; foreground: string }> = {};
const event: Record<string, { background: string; foreground: string }> = {};
if (response.data.calendar) {
for (const [id, color] of Object.entries(response.data.calendar)) {
calendar[id] = {
background: color.background!,
foreground: color.foreground!,
};
}
}
if (response.data.event) {
for (const [id, color] of Object.entries(response.data.event)) {
event[id] = {
background: color.background!,
foreground: color.foreground!,
};
}
}
return { calendar, event };
}
/**
* Get user settings
*/
async getSettings(settingId?: string): Promise<{
settings: Array<{ id: string; value: string }>;
}> {
if (settingId) {
const response = await this.calendar.settings.get({ setting: settingId });
return {
settings: [{
id: response.data.id!,
value: response.data.value!,
}],
};
}
const response = await this.calendar.settings.list();
const settings = (response.data.items || []).map(s => ({
id: s.id!,
value: s.value!,
}));
return { settings };
}
// ───────────────────────────────────────────────────────────────────────────
// HELPERS
// ───────────────────────────────────────────────────────────────────────────
private formatEventResult(event: calendar_v3.Schema$Event): CalendarEventResult {
return {
id: event.id!,
summary: event.summary || '(No title)',
description: event.description || undefined,
location: event.location || undefined,
start: {
dateTime: event.start?.dateTime || undefined,
date: event.start?.date || undefined,
timeZone: event.start?.timeZone || undefined,
},
end: {
dateTime: event.end?.dateTime || undefined,
date: event.end?.date || undefined,
timeZone: event.end?.timeZone || undefined,
},
status: event.status || 'confirmed',
htmlLink: event.htmlLink || '',
hangoutLink: event.hangoutLink || undefined,
attendees: event.attendees?.map(a => ({
email: a.email!,
displayName: a.displayName || undefined,
optional: a.optional || false,
responseStatus: a.responseStatus as EventAttendee['responseStatus'],
comment: a.comment || undefined,
})),
organizer: event.organizer ? {
email: event.organizer.email!,
displayName: event.organizer.displayName || undefined,
self: event.organizer.self || false,
} : undefined,
creator: event.creator ? {
email: event.creator.email!,
displayName: event.creator.displayName || undefined,
self: event.creator.self || false,
} : undefined,
recurrence: event.recurrence || undefined,
recurringEventId: event.recurringEventId || undefined,
created: event.created || undefined,
updated: event.updated || undefined,
};
}
}