diff --git a/.changeset/fix-calendar-timezone.md b/.changeset/fix-calendar-timezone.md new file mode 100644 index 0000000000..32b4b61c6b --- /dev/null +++ b/.changeset/fix-calendar-timezone.md @@ -0,0 +1,11 @@ +--- +--- + +fix: Strip Z suffix from Google Calendar dateTime to fix timezone + +Applied the same timezone fix to Google Calendar events that was applied to +Zoom meetings. The Z suffix was causing Google Calendar to interpret times +as UTC, resulting in meetings showing 5 hours earlier than intended for ET. + +For example, "1 PM ET" was showing as "8 AM ET" because the Z suffix told +Google Calendar the time was 13:00 UTC (which is 8 AM ET). diff --git a/.changeset/fix-meeting-id-lookup.md b/.changeset/fix-meeting-id-lookup.md new file mode 100644 index 0000000000..40bbb03b93 --- /dev/null +++ b/.changeset/fix-meeting-id-lookup.md @@ -0,0 +1,12 @@ +--- +--- + +fix: Accept both UUID and Zoom meeting IDs in meeting tools + +Updated meeting tools to accept both internal UUIDs and Zoom meeting IDs: +- Show meeting ID in list_upcoming_meetings output +- get_meeting_details, cancel_meeting, rsvp_to_meeting, add_meeting_attendee + now try UUID first, then fall back to Zoom meeting ID lookup + +This fixes the issue where Claude would see Zoom meeting IDs in meeting links +but the tools only accepted internal UUIDs. diff --git a/server/src/addie/mcp/meeting-tools.ts b/server/src/addie/mcp/meeting-tools.ts index caae338d51..7df8b9a81b 100644 --- a/server/src/addie/mcp/meeting-tools.ts +++ b/server/src/addie/mcp/meeting-tools.ts @@ -649,6 +649,7 @@ export function createMeetingToolHandlers( for (const meeting of meetings) { response += `📅 **${meeting.title}**\n`; + response += ` ID: ${meeting.id}\n`; response += ` ${formatDate(meeting.start_time)} at ${formatTime(meeting.start_time, meeting.timezone)}\n`; if (!groupName) { response += ` Group: ${meeting.working_group_name}\n`; @@ -707,14 +708,22 @@ export function createMeetingToolHandlers( // Get meeting details handlers.set('get_meeting_details', async (input) => { - const meetingId = input.meeting_id as string; + const inputId = input.meeting_id as string; - const meeting = await meetingsDb.getMeetingWithGroup(meetingId); + // Try to find by our UUID first, then by Zoom meeting ID + let meeting = await meetingsDb.getMeetingWithGroup(inputId); + if (!meeting) { + // Maybe it's a Zoom meeting ID - look that up first + const meetingByZoom = await meetingsDb.getMeetingByZoomId(inputId); + if (meetingByZoom) { + meeting = await meetingsDb.getMeetingWithGroup(meetingByZoom.id); + } + } if (!meeting) { - return `❌ Meeting not found: "${meetingId}"`; + return `❌ Meeting not found: "${inputId}". Use list_upcoming_meetings to find the correct meeting ID.`; } - const attendees = await meetingsDb.getAttendeesForMeeting(meetingId); + const attendees = await meetingsDb.getAttendeesForMeeting(meeting.id); const accepted = attendees.filter(a => a.rsvp_status === 'accepted'); const declined = attendees.filter(a => a.rsvp_status === 'declined'); const pending = attendees.filter(a => a.rsvp_status === 'pending'); @@ -759,21 +768,25 @@ export function createMeetingToolHandlers( return '❌ Unable to identify you. Please make sure you\'re logged in.'; } - const meetingId = input.meeting_id as string; + const inputId = input.meeting_id as string; const response = input.response as 'accepted' | 'declined' | 'tentative'; const note = input.note as string | undefined; - const meeting = await meetingsDb.getMeetingById(meetingId); + // Try to find by our UUID first, then by Zoom meeting ID + let meeting = await meetingsDb.getMeetingById(inputId); + if (!meeting) { + meeting = await meetingsDb.getMeetingByZoomId(inputId); + } if (!meeting) { - return `❌ Meeting not found: "${meetingId}"`; + return `❌ Meeting not found: "${inputId}". Use list_upcoming_meetings to find the correct meeting ID.`; } // Check if user is already an attendee - let attendee = await meetingsDb.getAttendee(meetingId, userId); + let attendee = await meetingsDb.getAttendee(meeting.id, userId); if (attendee) { // Update existing RSVP - attendee = await meetingsDb.updateAttendee(meetingId, userId, { + attendee = await meetingsDb.updateAttendee(meeting.id, userId, { rsvp_status: response, rsvp_note: note, }); @@ -785,7 +798,7 @@ export function createMeetingToolHandlers( : userEmail; attendee = await meetingsDb.addAttendee({ - meeting_id: meetingId, + meeting_id: meeting.id, workos_user_id: userId, email: userEmail, name: userName, @@ -810,9 +823,14 @@ export function createMeetingToolHandlers( const meetingId = input.meeting_id as string; - const meeting = await meetingsDb.getMeetingById(meetingId); + // Try to find by our UUID first, then by Zoom meeting ID + let meeting = await meetingsDb.getMeetingById(meetingId); + if (!meeting) { + // Maybe it's a Zoom meeting ID instead of our UUID + meeting = await meetingsDb.getMeetingByZoomId(meetingId); + } if (!meeting) { - return `❌ Meeting not found: "${meetingId}"`; + return `❌ Meeting not found: "${meetingId}". Use list_upcoming_meetings to find the correct meeting ID.`; } if (meeting.status === 'cancelled') { @@ -820,7 +838,8 @@ export function createMeetingToolHandlers( } try { - const result = await meetingService.cancelMeeting(meetingId); + // Use meeting.id (our UUID) not the input meetingId (might be Zoom ID) + const result = await meetingService.cancelMeeting(meeting.id); let response = `✅ Cancelled: **${meeting.title}**\n`; response += `Cancellation notices have been sent to attendees.`; @@ -830,7 +849,7 @@ export function createMeetingToolHandlers( response += result.errors.map(e => `• ${e}`).join('\n'); } - logger.info({ meetingId, cancelledBy: getUserId() }, 'Meeting cancelled via Addie'); + logger.info({ meetingId: meeting.id, cancelledBy: getUserId() }, 'Meeting cancelled via Addie'); return response; } catch (error) { @@ -844,17 +863,22 @@ export function createMeetingToolHandlers( const permCheck = await checkSchedulePermission(); if (permCheck) return permCheck; - const meetingId = input.meeting_id as string; + const inputId = input.meeting_id as string; const email = input.email as string; const name = input.name as string | undefined; - const meeting = await meetingsDb.getMeetingById(meetingId); + // Try to find by our UUID first, then by Zoom meeting ID + let meeting = await meetingsDb.getMeetingById(inputId); + if (!meeting) { + meeting = await meetingsDb.getMeetingByZoomId(inputId); + } if (!meeting) { - return `❌ Meeting not found: "${meetingId}"`; + return `❌ Meeting not found: "${inputId}". Use list_upcoming_meetings to find the correct meeting ID.`; } try { - const result = await meetingService.addAttendeesToMeeting(meetingId, [ + // Use meeting.id (our UUID) not the inputId (might be Zoom ID) + const result = await meetingService.addAttendeesToMeeting(meeting.id, [ { email, name }, ]); diff --git a/server/src/services/meeting-service.ts b/server/src/services/meeting-service.ts index a6a26c906f..05b2faf068 100644 --- a/server/src/services/meeting-service.ts +++ b/server/src/services/meeting-service.ts @@ -33,17 +33,17 @@ const ZOOM_HOST_EMAIL = process.env.ZOOM_HOST_EMAIL || 'addie@agenticadvertising /** * Format a Date as ISO string WITHOUT the Z suffix. - * This is needed for Zoom API which interprets times with Z as UTC, - * but times without Z in the specified timezone parameter. + * This is needed for both Zoom and Google Calendar APIs which interpret + * times with Z as UTC, but times without Z in the specified timezone parameter. * - * When Claude sends "2026-01-15T11:00:00" for 11 AM ET, JavaScript parses it - * as 11:00 UTC (server local time). By stripping the Z suffix, we tell Zoom - * to interpret "2026-01-15T11:00:00" in the timezone we specify (America/New_York), - * resulting in the correct 11:00 AM ET meeting time. + * When Claude sends "2026-01-15T13:00:00" for 1 PM, JavaScript parses it + * as 13:00 server local time (UTC on Fly.io). By stripping the Z suffix, + * we tell the APIs to interpret "2026-01-15T13:00:00" in the timezone we + * specify (America/New_York), resulting in the correct 1 PM ET meeting time. */ -function formatDateForZoom(date: Date): string { - // toISOString() returns "2026-01-15T11:00:00.000Z" - // We strip the milliseconds and Z to get "2026-01-15T11:00:00" +function formatDateWithoutZ(date: Date): string { + // toISOString() returns "2026-01-15T13:00:00.000Z" + // We strip the milliseconds and Z to get "2026-01-15T13:00:00" return date.toISOString().replace(/\.\d{3}Z$/, ''); } @@ -103,7 +103,7 @@ export async function scheduleMeeting(options: ScheduleMeetingOptions): Promise< zoomMeeting = await zoom.createMeeting(ZOOM_HOST_EMAIL, { topic: `${workingGroup.name}: ${options.title}`, // Format without Z suffix so Zoom interprets in the specified timezone - start_time: formatDateForZoom(options.startTime), + start_time: formatDateWithoutZ(options.startTime), duration: durationMinutes, timezone, agenda: options.agenda || options.description, @@ -172,15 +172,16 @@ export async function scheduleMeeting(options: ScheduleMeetingOptions): Promise< })); // Build calendar event + // Use formatDateWithoutZ so Google interprets in the specified timezone const eventInput: calendar.CreateCalendarEventInput = { summary: `${workingGroup.name}: ${options.title}`, description: buildCalendarDescription(options.description, options.agenda, zoomMeeting), start: { - dateTime: options.startTime.toISOString(), + dateTime: formatDateWithoutZ(options.startTime), timeZone: timezone, }, end: { - dateTime: endTime.toISOString(), + dateTime: formatDateWithoutZ(endTime), timeZone: timezone, }, attendees: attendeeEmails,