From f8813bf9e97d451f45559b05cbb1c6c38ebb1578 Mon Sep 17 00:00:00 2001 From: oskarcode Date: Fri, 13 Mar 2026 16:41:27 -0400 Subject: [PATCH 1/4] feat(calendar): add calendar management tools (createCalendar, createRecurringEvent, setEventReminders) --- workspace-server/src/index.ts | 107 +++++++ .../src/services/CalendarService.ts | 301 +++++++++++------- 2 files changed, 301 insertions(+), 107 deletions(-) diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 674d678..3b1e4cd 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -638,6 +638,113 @@ async function main() { calendarService.createEvent, ); + server.registerTool( + 'calendar.createCalendar', + { + description: 'Creates a new calendar.', + inputSchema: { + summary: z.string().describe('Calendar name/title.'), + description: z + .string() + .optional() + .describe('Optional calendar description.'), + timeZone: z + .string() + .optional() + .describe('Optional IANA timezone (e.g., America/New_York).'), + }, + }, + calendarService.createCalendar, + ); + + server.registerTool( + 'calendar.createRecurringEvent', + { + description: 'Creates a recurring event in a calendar.', + inputSchema: { + calendarId: z + .string() + .optional() + .describe( + 'The ID of the calendar to create the recurring event in. Defaults to primary.', + ), + summary: z.string().describe('The summary or title of the event.'), + description: z + .string() + .optional() + .describe('The description of the event.'), + start: z.object({ + dateTime: z + .string() + .describe( + 'The start time in strict ISO 8601 format with seconds and timezone.', + ), + }), + end: z.object({ + dateTime: z + .string() + .describe( + 'The end time in strict ISO 8601 format with seconds and timezone.', + ), + }), + attendees: z + .array(z.string()) + .optional() + .describe('The email addresses of the attendees.'), + recurrence: z + .array(z.string()) + .min(1) + .describe( + 'Recurrence rules (e.g., ["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10"]).', + ), + reminders: z + .object({ + useDefault: z.boolean().optional(), + overrides: z + .array( + z.object({ + method: z.enum(['email', 'popup']), + minutes: z.number().int().min(0), + }), + ) + .optional(), + }) + .optional() + .describe('Optional reminder configuration for the recurring event.'), + }, + }, + calendarService.createRecurringEvent, + ); + + server.registerTool( + 'calendar.setEventReminders', + { + description: + 'Sets custom reminders for an existing calendar event (or resets to default reminders).', + inputSchema: { + eventId: z.string().describe('The ID of the event to update reminders for.'), + calendarId: z + .string() + .optional() + .describe('The ID of the calendar containing the event.'), + useDefault: z + .boolean() + .optional() + .describe('Whether to use default calendar reminders (default: true).'), + overrides: z + .array( + z.object({ + method: z.enum(['email', 'popup']), + minutes: z.number().int().min(0), + }), + ) + .optional() + .describe('Custom reminder overrides.'), + }, + }, + calendarService.setEventReminders, + ); + server.registerTool( 'calendar.listEvents', { diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index 980c725..a0936e5 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -4,33 +4,52 @@ * SPDX-License-Identifier: Apache-2.0 */ -import crypto from 'node:crypto'; import { calendar_v3, google } from 'googleapis'; import { logToFile } from '../utils/logger'; import { gaxiosOptions } from '../utils/GaxiosConfig'; import { iso8601DateTimeSchema, emailArraySchema } from '../utils/validation'; import { z } from 'zod'; -/** - * Google Drive file attachment for calendar events. - * Attachments are fully replaced (not appended) when provided. - */ -interface EventAttachment { - fileUrl: string; - title?: string; - mimeType?: string; +export interface CreateEventInput { + calendarId?: string; + summary: string; + description?: string; + start: { dateTime: string }; + end: { dateTime: string }; + attendees?: string[]; } -export interface CreateEventInput { +export interface CreateRecurringEventInput { calendarId?: string; summary: string; description?: string; start: { dateTime: string }; end: { dateTime: string }; attendees?: string[]; - sendUpdates?: 'all' | 'externalOnly' | 'none'; - addGoogleMeet?: boolean; - attachments?: EventAttachment[]; + recurrence: string[]; + reminders?: { + useDefault?: boolean; + overrides?: Array<{ + method: 'email' | 'popup'; + minutes: number; + }>; + }; +} + +export interface CreateCalendarInput { + summary: string; + description?: string; + timeZone?: string; +} + +export interface SetEventRemindersInput { + eventId: string; + calendarId?: string; + useDefault?: boolean; + overrides?: Array<{ + method: 'email' | 'popup'; + minutes: number; + }>; } export interface ListEventsInput { @@ -58,8 +77,6 @@ export interface UpdateEventInput { start?: { dateTime: string }; end?: { dateTime: string }; attendees?: string[]; - addGoogleMeet?: boolean; - attachments?: EventAttachment[]; } export interface RespondToEventInput { @@ -82,37 +99,6 @@ export class CalendarService { constructor(private authManager: any) {} - /** - * Adds conferenceData and attachments to an event body and its API params. - * - * IMPORTANT: Attachments are fully REPLACED, not appended. When attachments - * are provided, any existing attachments on the event will be removed. - */ - private applyMeetAndAttachments( - event: calendar_v3.Schema$Event, - params: { conferenceDataVersion?: number; supportsAttachments?: boolean }, - addGoogleMeet?: boolean, - attachments?: EventAttachment[], - ): void { - if (addGoogleMeet) { - event.conferenceData = { - createRequest: { - requestId: crypto.randomUUID(), - conferenceSolutionKey: { type: 'hangoutsMeet' }, - }, - }; - params.conferenceDataVersion = 1; - } - if (attachments && attachments.length > 0) { - event.attachments = attachments.map((a) => ({ - fileUrl: a.fileUrl, - title: a.title, - mimeType: a.mimeType, - })); - params.supportsAttachments = true; - } - } - private createValidationErrorResponse(error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Validation failed'; @@ -143,6 +129,160 @@ export class CalendarService { }; } + createRecurringEvent = async (input: CreateRecurringEventInput) => { + const { + calendarId, + summary, + description, + start, + end, + attendees, + recurrence, + reminders, + } = input; + + try { + iso8601DateTimeSchema.parse(start.dateTime); + iso8601DateTimeSchema.parse(end.dateTime); + if (attendees) { + emailArraySchema.parse(attendees); + } + if (!recurrence || recurrence.length === 0) { + throw new Error('recurrence must contain at least one RRULE string'); + } + } catch (error) { + return this.createValidationErrorResponse(error); + } + + const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); + logToFile(`Creating recurring event in calendar: ${finalCalendarId}`); + + try { + const event: calendar_v3.Schema$Event = { + summary, + description, + start, + end, + attendees: attendees?.map((email) => ({ email })), + recurrence, + }; + + if (reminders) { + event.reminders = { + useDefault: reminders.useDefault, + overrides: reminders.overrides, + }; + } + + const calendar = await this.getCalendar(); + const res = await calendar.events.insert({ + calendarId: finalCalendarId, + requestBody: event, + }); + + logToFile(`Successfully created recurring event: ${res.data.id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(res.data), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during calendar.createRecurringEvent: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + createCalendar = async (input: CreateCalendarInput) => { + const { summary, description, timeZone } = input; + logToFile(`Creating calendar with summary: ${summary}`); + + try { + const calendar = await this.getCalendar(); + const res = await calendar.calendars.insert({ + requestBody: { + summary, + description, + timeZone, + }, + }); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(res.data), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during calendar.createCalendar: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + setEventReminders = async (input: SetEventRemindersInput) => { + const { eventId, calendarId, useDefault = true, overrides } = input; + + const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); + logToFile(`Setting reminders for event ${eventId} in ${finalCalendarId}`); + + try { + const calendar = await this.getCalendar(); + const res = await calendar.events.patch({ + calendarId: finalCalendarId, + eventId, + requestBody: { + reminders: { + useDefault, + overrides, + }, + }, + }); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(res.data), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during calendar.setEventReminders: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + private async getCalendar(): Promise { logToFile('Getting authenticated client for calendar...'); const auth = await this.authManager.getAuthenticatedClient(); @@ -206,17 +346,7 @@ export class CalendarService { }; createEvent = async (input: CreateEventInput) => { - const { - calendarId, - summary, - description, - start, - end, - attendees, - sendUpdates, - addGoogleMeet, - attachments, - } = input; + const { calendarId, summary, description, start, end, attendees } = input; // Validate datetime formats try { @@ -236,42 +366,19 @@ export class CalendarService { logToFile(`Event start: ${start.dateTime}`); logToFile(`Event end: ${end.dateTime}`); logToFile(`Event attendees: ${attendees?.join(', ')}`); - if (addGoogleMeet) logToFile('Adding Google Meet link'); - if (attachments?.length) - logToFile(`Attachments: ${attachments.length} file(s)`); - - // Determine sendUpdates value - let finalSendUpdates = sendUpdates; - if (finalSendUpdates === undefined) { - finalSendUpdates = attendees?.length ? 'all' : 'none'; - } - if (finalSendUpdates) { - logToFile(`Sending updates: ${finalSendUpdates}`); - } - try { - const event: calendar_v3.Schema$Event = { + const event = { summary, description, start, end, attendees: attendees?.map((email) => ({ email })), }; - const calendar = await this.getCalendar(); - const insertParams: calendar_v3.Params$Resource$Events$Insert = { + const res = await calendar.events.insert({ calendarId: finalCalendarId, requestBody: event, - sendUpdates: finalSendUpdates, - }; - this.applyMeetAndAttachments( - event, - insertParams, - addGoogleMeet, - attachments, - ); - - const res = await calendar.events.insert(insertParams); + }); logToFile(`Successfully created event: ${res.data.id}`); return { content: [ @@ -440,17 +547,8 @@ export class CalendarService { }; updateEvent = async (input: UpdateEventInput) => { - const { - eventId, - calendarId, - summary, - description, - start, - end, - attendees, - addGoogleMeet, - attachments, - } = input; + const { eventId, calendarId, summary, description, start, end, attendees } = + input; // Validate datetime formats if provided try { @@ -469,9 +567,6 @@ export class CalendarService { const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); logToFile(`Updating event ${eventId} in calendar: ${finalCalendarId}`); - if (addGoogleMeet) logToFile('Adding Google Meet link'); - if (attachments?.length) - logToFile(`Attachments: ${attachments.length} file(s)`); try { const calendar = await this.getCalendar(); @@ -485,19 +580,11 @@ export class CalendarService { if (attendees) requestBody.attendees = attendees.map((email) => ({ email })); - const updateParams: calendar_v3.Params$Resource$Events$Update = { + const res = await calendar.events.update({ calendarId: finalCalendarId, eventId, requestBody, - }; - this.applyMeetAndAttachments( - requestBody, - updateParams, - addGoogleMeet, - attachments, - ); - - const res = await calendar.events.update(updateParams); + }); logToFile(`Successfully updated event: ${res.data.id}`); return { From 929a38cb10e3d89ef5fa3717d63d16dbe47e9beb Mon Sep 17 00:00:00 2001 From: oskarcode Date: Fri, 13 Mar 2026 17:27:47 -0400 Subject: [PATCH 2/4] fix: surgically add new calendar tools without removing existing functionality --- workspace-server/src/index.ts | 226 +++++----- .../src/services/CalendarService.ts | 385 +++++++++--------- 2 files changed, 304 insertions(+), 307 deletions(-) diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 3b1e4cd..6f4ca38 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -638,113 +638,6 @@ async function main() { calendarService.createEvent, ); - server.registerTool( - 'calendar.createCalendar', - { - description: 'Creates a new calendar.', - inputSchema: { - summary: z.string().describe('Calendar name/title.'), - description: z - .string() - .optional() - .describe('Optional calendar description.'), - timeZone: z - .string() - .optional() - .describe('Optional IANA timezone (e.g., America/New_York).'), - }, - }, - calendarService.createCalendar, - ); - - server.registerTool( - 'calendar.createRecurringEvent', - { - description: 'Creates a recurring event in a calendar.', - inputSchema: { - calendarId: z - .string() - .optional() - .describe( - 'The ID of the calendar to create the recurring event in. Defaults to primary.', - ), - summary: z.string().describe('The summary or title of the event.'), - description: z - .string() - .optional() - .describe('The description of the event.'), - start: z.object({ - dateTime: z - .string() - .describe( - 'The start time in strict ISO 8601 format with seconds and timezone.', - ), - }), - end: z.object({ - dateTime: z - .string() - .describe( - 'The end time in strict ISO 8601 format with seconds and timezone.', - ), - }), - attendees: z - .array(z.string()) - .optional() - .describe('The email addresses of the attendees.'), - recurrence: z - .array(z.string()) - .min(1) - .describe( - 'Recurrence rules (e.g., ["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10"]).', - ), - reminders: z - .object({ - useDefault: z.boolean().optional(), - overrides: z - .array( - z.object({ - method: z.enum(['email', 'popup']), - minutes: z.number().int().min(0), - }), - ) - .optional(), - }) - .optional() - .describe('Optional reminder configuration for the recurring event.'), - }, - }, - calendarService.createRecurringEvent, - ); - - server.registerTool( - 'calendar.setEventReminders', - { - description: - 'Sets custom reminders for an existing calendar event (or resets to default reminders).', - inputSchema: { - eventId: z.string().describe('The ID of the event to update reminders for.'), - calendarId: z - .string() - .optional() - .describe('The ID of the calendar containing the event.'), - useDefault: z - .boolean() - .optional() - .describe('Whether to use default calendar reminders (default: true).'), - overrides: z - .array( - z.object({ - method: z.enum(['email', 'popup']), - minutes: z.number().int().min(0), - }), - ) - .optional() - .describe('Custom reminder overrides.'), - }, - }, - calendarService.setEventReminders, - ); - server.registerTool( 'calendar.listEvents', { @@ -907,12 +800,119 @@ async function main() { 'The ID of the calendar to delete the event from. Defaults to the primary calendar.', ), }, - }, - calendarService.deleteEvent, - ); - - server.registerTool( - 'chat.listSpaces', + }, + calendarService.deleteEvent, + ); + + server.registerTool( + 'calendar.createCalendar', + { + description: 'Creates a new calendar.', + inputSchema: { + summary: z.string().describe('Calendar name/title.'), + description: z + .string() + .optional() + .describe('Optional calendar description.'), + timeZone: z + .string() + .optional() + .describe('Optional IANA timezone (e.g., America/New_York).'), + }, + }, + calendarService.createCalendar, + ); + + server.registerTool( + 'calendar.createRecurringEvent', + { + description: 'Creates a recurring event in a calendar.', + inputSchema: { + calendarId: z + .string() + .optional() + .describe( + 'The ID of the calendar to create the recurring event in. Defaults to primary.', + ), + summary: z.string().describe('The summary or title of the event.'), + description: z + .string() + .optional() + .describe('The description of the event.'), + start: z.object({ + dateTime: z + .string() + .describe( + 'The start time in strict ISO 8601 format with seconds and timezone.', + ), + }), + end: z.object({ + dateTime: z + .string() + .describe( + 'The end time in strict ISO 8601 format with seconds and timezone.', + ), + }), + attendees: z + .array(z.string()) + .optional() + .describe('The email addresses of the attendees.'), + recurrence: z + .array(z.string()) + .min(1) + .describe( + 'Recurrence rules (e.g., ["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10"]).', + ), + reminders: z + .object({ + useDefault: z.boolean().optional(), + overrides: z + .array( + z.object({ + method: z.enum(['email', 'popup']), + minutes: z.number().min(0), + }), + ) + .optional(), + }) + .optional() + .describe('Optional reminder configuration for the recurring event.'), + }, + }, + calendarService.createRecurringEvent, + ); + + server.registerTool( + 'calendar.setEventReminders', + { + description: + 'Sets custom reminders for an existing calendar event (or resets to default reminders).', + inputSchema: { + eventId: z.string().describe('The ID of the event to update reminders for.'), + calendarId: z + .string() + .optional() + .describe('The ID of the calendar containing the event.'), + useDefault: z + .boolean() + .optional() + .describe('Whether to use default calendar reminders (default: true).'), + overrides: z + .array( + z.object({ + method: z.enum(['email', 'popup']), + minutes: z.number().min(0), + }), + ) + .optional() + .describe('Custom reminder overrides.'), + }, + }, + calendarService.setEventReminders, + ); + + server.registerTool( + 'chat.listSpaces', { description: 'Lists the spaces the user is a member of.', inputSchema: {}, diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index a0936e5..1e3e8df 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -4,52 +4,33 @@ * SPDX-License-Identifier: Apache-2.0 */ +import crypto from 'node:crypto'; import { calendar_v3, google } from 'googleapis'; import { logToFile } from '../utils/logger'; import { gaxiosOptions } from '../utils/GaxiosConfig'; import { iso8601DateTimeSchema, emailArraySchema } from '../utils/validation'; import { z } from 'zod'; -export interface CreateEventInput { - calendarId?: string; - summary: string; - description?: string; - start: { dateTime: string }; - end: { dateTime: string }; - attendees?: string[]; +/** + * Google Drive file attachment for calendar events. + * Attachments are fully replaced (not appended) when provided. + */ +interface EventAttachment { + fileUrl: string; + title?: string; + mimeType?: string; } -export interface CreateRecurringEventInput { +export interface CreateEventInput { calendarId?: string; summary: string; description?: string; start: { dateTime: string }; end: { dateTime: string }; attendees?: string[]; - recurrence: string[]; - reminders?: { - useDefault?: boolean; - overrides?: Array<{ - method: 'email' | 'popup'; - minutes: number; - }>; - }; -} - -export interface CreateCalendarInput { - summary: string; - description?: string; - timeZone?: string; -} - -export interface SetEventRemindersInput { - eventId: string; - calendarId?: string; - useDefault?: boolean; - overrides?: Array<{ - method: 'email' | 'popup'; - minutes: number; - }>; + sendUpdates?: 'all' | 'externalOnly' | 'none'; + addGoogleMeet?: boolean; + attachments?: EventAttachment[]; } export interface ListEventsInput { @@ -77,6 +58,8 @@ export interface UpdateEventInput { start?: { dateTime: string }; end?: { dateTime: string }; attendees?: string[]; + addGoogleMeet?: boolean; + attachments?: EventAttachment[]; } export interface RespondToEventInput { @@ -94,11 +77,69 @@ export interface FindFreeTimeInput { duration: number; } +export interface CreateRecurringEventInput { + calendarId?: string; + summary: string; + description?: string; + start: { dateTime: string }; + end: { dateTime: string }; + attendees?: string[]; + recurrence: string[]; + reminders?: { + useDefault?: boolean; + overrides?: Array<{ method: 'email' | 'popup'; minutes: number }>; + }; +} + +export interface CreateCalendarInput { + summary: string; + description?: string; + timeZone?: string; +} + +export interface SetEventRemindersInput { + eventId: string; + calendarId?: string; + useDefault?: boolean; + overrides?: Array<{ method: 'email' | 'popup'; minutes: number }>; +} + export class CalendarService { private primaryCalendarId: string | null = null; constructor(private authManager: any) {} + /** + * Adds conferenceData and attachments to an event body and its API params. + * + * IMPORTANT: Attachments are fully REPLACED, not appended. When attachments + * are provided, any existing attachments on the event will be removed. + */ + private applyMeetAndAttachments( + event: calendar_v3.Schema$Event, + params: { conferenceDataVersion?: number; supportsAttachments?: boolean }, + addGoogleMeet?: boolean, + attachments?: EventAttachment[], + ): void { + if (addGoogleMeet) { + event.conferenceData = { + createRequest: { + requestId: crypto.randomUUID(), + conferenceSolutionKey: { type: 'hangoutsMeet' }, + }, + }; + params.conferenceDataVersion = 1; + } + if (attachments && attachments.length > 0) { + event.attachments = attachments.map((a) => ({ + fileUrl: a.fileUrl, + title: a.title, + mimeType: a.mimeType, + })); + params.supportsAttachments = true; + } + } + private createValidationErrorResponse(error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Validation failed'; @@ -129,160 +170,6 @@ export class CalendarService { }; } - createRecurringEvent = async (input: CreateRecurringEventInput) => { - const { - calendarId, - summary, - description, - start, - end, - attendees, - recurrence, - reminders, - } = input; - - try { - iso8601DateTimeSchema.parse(start.dateTime); - iso8601DateTimeSchema.parse(end.dateTime); - if (attendees) { - emailArraySchema.parse(attendees); - } - if (!recurrence || recurrence.length === 0) { - throw new Error('recurrence must contain at least one RRULE string'); - } - } catch (error) { - return this.createValidationErrorResponse(error); - } - - const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); - logToFile(`Creating recurring event in calendar: ${finalCalendarId}`); - - try { - const event: calendar_v3.Schema$Event = { - summary, - description, - start, - end, - attendees: attendees?.map((email) => ({ email })), - recurrence, - }; - - if (reminders) { - event.reminders = { - useDefault: reminders.useDefault, - overrides: reminders.overrides, - }; - } - - const calendar = await this.getCalendar(); - const res = await calendar.events.insert({ - calendarId: finalCalendarId, - requestBody: event, - }); - - logToFile(`Successfully created recurring event: ${res.data.id}`); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify(res.data), - }, - ], - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logToFile(`Error during calendar.createRecurringEvent: ${errorMessage}`); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ error: errorMessage }), - }, - ], - }; - } - }; - - createCalendar = async (input: CreateCalendarInput) => { - const { summary, description, timeZone } = input; - logToFile(`Creating calendar with summary: ${summary}`); - - try { - const calendar = await this.getCalendar(); - const res = await calendar.calendars.insert({ - requestBody: { - summary, - description, - timeZone, - }, - }); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify(res.data), - }, - ], - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logToFile(`Error during calendar.createCalendar: ${errorMessage}`); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ error: errorMessage }), - }, - ], - }; - } - }; - - setEventReminders = async (input: SetEventRemindersInput) => { - const { eventId, calendarId, useDefault = true, overrides } = input; - - const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); - logToFile(`Setting reminders for event ${eventId} in ${finalCalendarId}`); - - try { - const calendar = await this.getCalendar(); - const res = await calendar.events.patch({ - calendarId: finalCalendarId, - eventId, - requestBody: { - reminders: { - useDefault, - overrides, - }, - }, - }); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify(res.data), - }, - ], - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logToFile(`Error during calendar.setEventReminders: ${errorMessage}`); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ error: errorMessage }), - }, - ], - }; - } - }; - private async getCalendar(): Promise { logToFile('Getting authenticated client for calendar...'); const auth = await this.authManager.getAuthenticatedClient(); @@ -346,7 +233,17 @@ export class CalendarService { }; createEvent = async (input: CreateEventInput) => { - const { calendarId, summary, description, start, end, attendees } = input; + const { + calendarId, + summary, + description, + start, + end, + attendees, + sendUpdates, + addGoogleMeet, + attachments, + } = input; // Validate datetime formats try { @@ -366,19 +263,42 @@ export class CalendarService { logToFile(`Event start: ${start.dateTime}`); logToFile(`Event end: ${end.dateTime}`); logToFile(`Event attendees: ${attendees?.join(', ')}`); + if (addGoogleMeet) logToFile('Adding Google Meet link'); + if (attachments?.length) + logToFile(`Attachments: ${attachments.length} file(s)`); + + // Determine sendUpdates value + let finalSendUpdates = sendUpdates; + if (finalSendUpdates === undefined) { + finalSendUpdates = attendees?.length ? 'all' : 'none'; + } + if (finalSendUpdates) { + logToFile(`Sending updates: ${finalSendUpdates}`); + } + try { - const event = { + const event: calendar_v3.Schema$Event = { summary, description, start, end, attendees: attendees?.map((email) => ({ email })), }; + const calendar = await this.getCalendar(); - const res = await calendar.events.insert({ + const insertParams: calendar_v3.Params$Resource$Events$Insert = { calendarId: finalCalendarId, requestBody: event, - }); + sendUpdates: finalSendUpdates, + }; + this.applyMeetAndAttachments( + event, + insertParams, + addGoogleMeet, + attachments, + ); + + const res = await calendar.events.insert(insertParams); logToFile(`Successfully created event: ${res.data.id}`); return { content: [ @@ -403,6 +323,63 @@ export class CalendarService { } }; + createCalendar = async (input: CreateCalendarInput) => { + const { summary, description, timeZone } = input; + logToFile(`Creating calendar with summary: ${summary}`); + try { + const calendar = await this.getCalendar(); + const res = await calendar.calendars.insert({ + requestBody: { summary, description, timeZone }, + }); + return { content: [{ type: 'text' as const, text: JSON.stringify(res.data) }] }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logToFile(`Error during calendar.createCalendar: ${errorMessage}`); + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: errorMessage }) }] }; + } + }; + + createRecurringEvent = async (input: CreateRecurringEventInput) => { + const { calendarId, summary, description, start, end, attendees, recurrence, reminders } = input; + try { + iso8601DateTimeSchema.parse(start.dateTime); + iso8601DateTimeSchema.parse(end.dateTime); + if (attendees) emailArraySchema.parse(attendees); + if (!recurrence || recurrence.length === 0) throw new Error('recurrence must contain at least one RRULE string'); + } catch (error) { + return this.createValidationErrorResponse(error); + } + const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); + logToFile(`Creating recurring event in calendar: ${finalCalendarId}`); + try { + const event: calendar_v3.Schema$Event = { summary, description, start, end, attendees: attendees?.map((email) => ({ email })), recurrence }; + if (reminders) event.reminders = { useDefault: reminders.useDefault, overrides: reminders.overrides }; + const calendar = await this.getCalendar(); + const res = await calendar.events.insert({ calendarId: finalCalendarId, requestBody: event }); + logToFile(`Successfully created recurring event: ${res.data.id}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(res.data) }] }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logToFile(`Error during calendar.createRecurringEvent: ${errorMessage}`); + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: errorMessage }) }] }; + } + }; + + setEventReminders = async (input: SetEventRemindersInput) => { + const { eventId, calendarId, useDefault = true, overrides } = input; + const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); + logToFile(`Setting reminders for event ${eventId} in ${finalCalendarId}`); + try { + const calendar = await this.getCalendar(); + const res = await calendar.events.patch({ calendarId: finalCalendarId, eventId, requestBody: { reminders: { useDefault, overrides } } }); + return { content: [{ type: 'text' as const, text: JSON.stringify(res.data) }] }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logToFile(`Error during calendar.setEventReminders: ${errorMessage}`); + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: errorMessage }) }] }; + } + }; + listEvents = async (input: ListEventsInput) => { const { calendarId, @@ -547,8 +524,17 @@ export class CalendarService { }; updateEvent = async (input: UpdateEventInput) => { - const { eventId, calendarId, summary, description, start, end, attendees } = - input; + const { + eventId, + calendarId, + summary, + description, + start, + end, + attendees, + addGoogleMeet, + attachments, + } = input; // Validate datetime formats if provided try { @@ -567,6 +553,9 @@ export class CalendarService { const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); logToFile(`Updating event ${eventId} in calendar: ${finalCalendarId}`); + if (addGoogleMeet) logToFile('Adding Google Meet link'); + if (attachments?.length) + logToFile(`Attachments: ${attachments.length} file(s)`); try { const calendar = await this.getCalendar(); @@ -580,11 +569,19 @@ export class CalendarService { if (attendees) requestBody.attendees = attendees.map((email) => ({ email })); - const res = await calendar.events.update({ + const updateParams: calendar_v3.Params$Resource$Events$Update = { calendarId: finalCalendarId, eventId, requestBody, - }); + }; + this.applyMeetAndAttachments( + requestBody, + updateParams, + addGoogleMeet, + attachments, + ); + + const res = await calendar.events.update(updateParams); logToFile(`Successfully updated event: ${res.data.id}`); return { From aae9763e163b5a6c9df4ecaa472a724fbb503a25 Mon Sep 17 00:00:00 2001 From: oskarcode Date: Fri, 13 Mar 2026 17:34:57 -0400 Subject: [PATCH 3/4] fix: restore sendUpdates notifications for createRecurringEvent and updateEvent - Add sendUpdates parameter to CreateRecurringEventInput interface - Add sendUpdates parameter to UpdateEventInput interface - Implement sendUpdates logic in createRecurringEvent method - Implement sendUpdates logic in updateEvent method - Default to 'all' when attendees present, 'none' when no attendees - Ensures attendees receive notifications for recurring events and updates Fixes critical issue where attendees were not notified of recurring events or event changes. --- .../src/services/CalendarService.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index 1e3e8df..b65ff81 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -58,6 +58,7 @@ export interface UpdateEventInput { start?: { dateTime: string }; end?: { dateTime: string }; attendees?: string[]; + sendUpdates?: 'all' | 'externalOnly' | 'none'; addGoogleMeet?: boolean; attachments?: EventAttachment[]; } @@ -85,6 +86,7 @@ export interface CreateRecurringEventInput { end: { dateTime: string }; attendees?: string[]; recurrence: string[]; + sendUpdates?: 'all' | 'externalOnly' | 'none'; reminders?: { useDefault?: boolean; overrides?: Array<{ method: 'email' | 'popup'; minutes: number }>; @@ -340,7 +342,7 @@ export class CalendarService { }; createRecurringEvent = async (input: CreateRecurringEventInput) => { - const { calendarId, summary, description, start, end, attendees, recurrence, reminders } = input; + const { calendarId, summary, description, start, end, attendees, recurrence, sendUpdates, reminders } = input; try { iso8601DateTimeSchema.parse(start.dateTime); iso8601DateTimeSchema.parse(end.dateTime); @@ -351,11 +353,21 @@ export class CalendarService { } const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); logToFile(`Creating recurring event in calendar: ${finalCalendarId}`); + + // Determine sendUpdates value + let finalSendUpdates = sendUpdates; + if (finalSendUpdates === undefined) { + finalSendUpdates = attendees?.length ? 'all' : 'none'; + } + if (finalSendUpdates) { + logToFile(`Sending updates: ${finalSendUpdates}`); + } + try { const event: calendar_v3.Schema$Event = { summary, description, start, end, attendees: attendees?.map((email) => ({ email })), recurrence }; if (reminders) event.reminders = { useDefault: reminders.useDefault, overrides: reminders.overrides }; const calendar = await this.getCalendar(); - const res = await calendar.events.insert({ calendarId: finalCalendarId, requestBody: event }); + const res = await calendar.events.insert({ calendarId: finalCalendarId, requestBody: event, sendUpdates: finalSendUpdates }); logToFile(`Successfully created recurring event: ${res.data.id}`); return { content: [{ type: 'text' as const, text: JSON.stringify(res.data) }] }; } catch (error) { @@ -532,6 +544,7 @@ export class CalendarService { start, end, attendees, + sendUpdates, addGoogleMeet, attachments, } = input; @@ -557,6 +570,15 @@ export class CalendarService { if (attachments?.length) logToFile(`Attachments: ${attachments.length} file(s)`); + // Determine sendUpdates value + let finalSendUpdates = sendUpdates; + if (finalSendUpdates === undefined) { + finalSendUpdates = attendees?.length ? 'all' : 'none'; + } + if (finalSendUpdates) { + logToFile(`Sending updates: ${finalSendUpdates}`); + } + try { const calendar = await this.getCalendar(); @@ -573,6 +595,7 @@ export class CalendarService { calendarId: finalCalendarId, eventId, requestBody, + sendUpdates: finalSendUpdates, }; this.applyMeetAndAttachments( requestBody, From 9450324378119e32550d887df7a44200d58fb404 Mon Sep 17 00:00:00 2001 From: oskarcode Date: Fri, 13 Mar 2026 17:43:33 -0400 Subject: [PATCH 4/4] feat(calendar): standardize error handling with getErrorMessage helper - Add getErrorMessage() helper method for consistent error message extraction - Replace 12 duplicate error handling patterns with helper method call - Addresses code review feedback about duplicate logic in PR #279 - Maintains existing error handling behavior with cleaner implementation --- .../src/services/CalendarService.ts | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index b65ff81..b2062b9 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -142,9 +142,17 @@ export class CalendarService { } } + /** + * Standardized error message extraction helper. + * Converts any error to a string message safely. + */ + private getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + private createValidationErrorResponse(error: unknown) { const errorMessage = - error instanceof Error ? error.message : 'Validation failed'; + error instanceof Error ? this.getErrorMessage(error) : 'Validation failed'; let helpMessage = 'Please use strict ISO 8601 format with seconds and timezone. Examples: 2024-01-15T10:30:00Z (UTC) or 2024-01-15T10:30:00-05:00 (EST)'; @@ -220,8 +228,7 @@ export class CalendarService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.list: ${errorMessage}`); return { content: [ @@ -311,8 +318,7 @@ export class CalendarService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.createEvent: ${errorMessage}`); return { content: [ @@ -335,7 +341,7 @@ export class CalendarService { }); return { content: [{ type: 'text' as const, text: JSON.stringify(res.data) }] }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.createCalendar: ${errorMessage}`); return { content: [{ type: 'text' as const, text: JSON.stringify({ error: errorMessage }) }] }; } @@ -371,7 +377,7 @@ export class CalendarService { logToFile(`Successfully created recurring event: ${res.data.id}`); return { content: [{ type: 'text' as const, text: JSON.stringify(res.data) }] }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.createRecurringEvent: ${errorMessage}`); return { content: [{ type: 'text' as const, text: JSON.stringify({ error: errorMessage }) }] }; } @@ -386,7 +392,7 @@ export class CalendarService { const res = await calendar.events.patch({ calendarId: finalCalendarId, eventId, requestBody: { reminders: { useDefault, overrides } } }); return { content: [{ type: 'text' as const, text: JSON.stringify(res.data) }] }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.setEventReminders: ${errorMessage}`); return { content: [{ type: 'text' as const, text: JSON.stringify({ error: errorMessage }) }] }; } @@ -447,8 +453,7 @@ export class CalendarService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.listEvents: ${errorMessage}`); return { content: [ @@ -483,7 +488,7 @@ export class CalendarService { } catch (error) { const errorMessage = (error as any).response?.data?.error?.message || - (error instanceof Error ? error.message : String(error)); + this.getErrorMessage(error); logToFile(`Error during calendar.getEvent: ${errorMessage}`); return { content: [ @@ -522,7 +527,7 @@ export class CalendarService { } catch (error) { const errorMessage = (error as any).response?.data?.error?.message || - (error instanceof Error ? error.message : String(error)); + this.getErrorMessage(error); logToFile(`Error during calendar.deleteEvent: ${errorMessage}`); return { content: [ @@ -616,8 +621,7 @@ export class CalendarService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.updateEvent: ${errorMessage}`); return { content: [ @@ -718,8 +722,7 @@ export class CalendarService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.respondToEvent: ${errorMessage}`); return { content: [ @@ -910,8 +913,7 @@ export class CalendarService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.findFreeTime: ${errorMessage}`); return { content: [