From 79440fd0ff09223b405bf661fdb42ac6642176e6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Jan 2026 15:33:58 -0500 Subject: [PATCH] feat: Add meeting management improvements - Add update_meeting tool to Addie for rescheduling/editing meetings - Fix meetings not displaying on committee detail pages - Add edit functionality to admin meetings page - Fix timezone handling when editing meetings in admin UI Co-Authored-By: Claude Opus 4.5 --- .changeset/add-update-meeting-tool.md | 13 ++ .changeset/dirty-hoops-eat.md | 2 + .changeset/fix-meetings-section-display.md | 11 ++ server/public/admin-meetings.html | 84 +++++++-- server/public/working-groups/detail.html | 6 +- server/src/addie/mcp/meeting-tools.ts | 201 +++++++++++++++++++++ 6 files changed, 302 insertions(+), 15 deletions(-) create mode 100644 .changeset/add-update-meeting-tool.md create mode 100644 .changeset/dirty-hoops-eat.md create mode 100644 .changeset/fix-meetings-section-display.md diff --git a/.changeset/add-update-meeting-tool.md b/.changeset/add-update-meeting-tool.md new file mode 100644 index 0000000000..9710cb748f --- /dev/null +++ b/.changeset/add-update-meeting-tool.md @@ -0,0 +1,13 @@ +--- +--- + +feat: Add update_meeting tool to Addie + +Added a new tool that allows updating existing meetings through conversation: +- Change title, description, or agenda +- Reschedule to a new time (properly handles timezone conversion) +- Update duration + +Updates are synchronized to Zoom and Google Calendar when those integrations +are configured. This enables fixing meeting times or details without having +to cancel and recreate. diff --git a/.changeset/dirty-hoops-eat.md b/.changeset/dirty-hoops-eat.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/dirty-hoops-eat.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/fix-meetings-section-display.md b/.changeset/fix-meetings-section-display.md new file mode 100644 index 0000000000..68ca5f8610 --- /dev/null +++ b/.changeset/fix-meetings-section-display.md @@ -0,0 +1,11 @@ +--- +--- + +fix: Display meetings section on committee pages and add admin edit + +Fixed a bug where the meetings section wasn't showing on committee detail pages. +The API returns `{ meetings: [...] }` but the code was calling `.filter()` directly +on the response object instead of `response.meetings`. + +Also added edit functionality to the admin meetings page - admins can now click +"Edit" to reschedule or update meeting details (title, description, time). diff --git a/server/public/admin-meetings.html b/server/public/admin-meetings.html index f6aa4e2009..88c10ad2f3 100644 --- a/server/public/admin-meetings.html +++ b/server/public/admin-meetings.html @@ -396,6 +396,7 @@

Meeting Details

@@ -633,56 +634,112 @@

${escapeHtml(s.title)}

document.getElementById('meetingModal').style.display = 'flex'; } + // Edit existing meeting + function editMeeting() { + if (!viewingMeeting) return; + + const m = viewingMeeting; + const date = new Date(m.start_time); + const tz = m.timezone || 'America/New_York'; + + // Format date/time in the meeting's timezone for editing + const dateFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }); + const timeFormatter = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false }); + const formattedDate = dateFormatter.format(date); // YYYY-MM-DD format + const formattedTime = timeFormatter.format(date).replace(/^24:/, '00:'); // HH:MM format + + document.getElementById('modalTitle').textContent = 'Edit Meeting'; + document.getElementById('meetingId').value = m.id; + document.getElementById('workingGroupId').value = m.working_group_id; + document.getElementById('title').value = m.title; + document.getElementById('meetingDate').value = formattedDate; + document.getElementById('meetingTime').value = formattedTime; + document.getElementById('timezone').value = tz; + document.getElementById('description').value = m.description || m.agenda || ''; + + // Calculate duration from start_time and end_time + if (m.end_time) { + const endDate = new Date(m.end_time); + const durationMs = endDate.getTime() - date.getTime(); + document.getElementById('duration').value = Math.round(durationMs / 60000); + } else { + document.getElementById('duration').value = 60; + } + + // Hide invite mode for edits (can't change invites after creation) + document.getElementById('inviteMode').parentElement.style.display = 'none'; + + document.getElementById('saveBtn').textContent = 'Save Changes'; + closeDetailsModal(); + document.getElementById('meetingModal').style.display = 'flex'; + } + function closeModal() { document.getElementById('meetingModal').style.display = 'none'; + // Reset invite mode visibility + document.getElementById('inviteMode').parentElement.style.display = 'block'; + document.getElementById('saveBtn').textContent = 'Schedule'; } - // Save meeting + // Save meeting (create or update) async function saveMeeting(event) { event.preventDefault(); + const meetingId = document.getElementById('meetingId').value; + const isUpdate = !!meetingId; + const date = document.getElementById('meetingDate').value; const time = document.getElementById('meetingTime').value; const timezone = document.getElementById('timezone').value; + const durationMinutes = parseInt(document.getElementById('duration').value) || 60; // Construct ISO datetime const startTime = new Date(`${date}T${time}`); + const endTime = new Date(startTime.getTime() + durationMinutes * 60000); const data = { - working_group_id: document.getElementById('workingGroupId').value, title: document.getElementById('title').value.trim(), description: document.getElementById('description').value.trim() || null, start_time: startTime.toISOString(), - duration_minutes: parseInt(document.getElementById('duration').value) || 60, + end_time: endTime.toISOString(), timezone: timezone, - invite_mode: document.getElementById('inviteMode').value, }; + // Only include these fields for new meetings + if (!isUpdate) { + data.working_group_id = document.getElementById('workingGroupId').value; + data.duration_minutes = durationMinutes; + data.invite_mode = document.getElementById('inviteMode').value; + } + const saveBtn = document.getElementById('saveBtn'); saveBtn.disabled = true; - saveBtn.textContent = 'Scheduling...'; + saveBtn.textContent = isUpdate ? 'Saving...' : 'Scheduling...'; try { - const response = await fetch('/api/admin/meetings', { - method: 'POST', + const url = isUpdate ? `/api/admin/meetings/${meetingId}` : '/api/admin/meetings'; + const method = isUpdate ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!response.ok) { const error = await response.json(); - throw new Error(error.message || 'Failed to schedule meeting'); + throw new Error(error.message || `Failed to ${isUpdate ? 'update' : 'schedule'} meeting`); } closeModal(); loadMeetings(); } catch (error) { - console.error('Error scheduling meeting:', error); - alert('Failed to schedule meeting: ' + error.message); + console.error(`Error ${isUpdate ? 'updating' : 'scheduling'} meeting:`, error); + alert(`Failed to ${isUpdate ? 'update' : 'schedule'} meeting: ` + error.message); } saveBtn.disabled = false; - saveBtn.textContent = 'Schedule'; + saveBtn.textContent = isUpdate ? 'Save Changes' : 'Schedule'; } // View meeting details @@ -731,7 +788,10 @@

${escapeHtml(s.title)}

`; - document.getElementById('cancelMeetingBtn').style.display = m.status === 'scheduled' ? 'block' : 'none'; + // Only show Edit and Cancel buttons for scheduled meetings + const isScheduled = m.status === 'scheduled'; + document.getElementById('editMeetingBtn').style.display = isScheduled ? 'inline-block' : 'none'; + document.getElementById('cancelMeetingBtn').style.display = isScheduled ? 'inline-block' : 'none'; document.getElementById('detailsModal').style.display = 'flex'; } catch (error) { console.error('Error loading meeting:', error); diff --git a/server/public/working-groups/detail.html b/server/public/working-groups/detail.html index 2bc76e2327..a263930244 100644 --- a/server/public/working-groups/detail.html +++ b/server/public/working-groups/detail.html @@ -1674,10 +1674,10 @@

Recent Events

const response = await fetch(`/api/meetings?working_group_id=${currentGroup.id}`); if (!response.ok) return; - const meetings = await response.json(); - // Filter to only upcoming meetings + const data = await response.json(); + // API returns { meetings: [...] } - filter to scheduled upcoming meetings const now = new Date(); - const upcoming = meetings.filter(m => new Date(m.start_time) > now && m.status === 'scheduled'); + const upcoming = (data.meetings || []).filter(m => new Date(m.start_time) > now && m.status === 'scheduled'); const meetingsSection = document.getElementById('meetingsSection'); const meetingsList = document.getElementById('meetingsList'); diff --git a/server/src/addie/mcp/meeting-tools.ts b/server/src/addie/mcp/meeting-tools.ts index c4046fbc72..ae3915d1db 100644 --- a/server/src/addie/mcp/meeting-tools.ts +++ b/server/src/addie/mcp/meeting-tools.ts @@ -384,6 +384,48 @@ Example prompts this handles: required: ['meeting_id'], }, }, + { + name: 'update_meeting', + description: `Update an existing meeting's details. Use this when someone wants to change the time, title, description, or agenda of a scheduled meeting. + +This will update the meeting in the database, Zoom (if configured), and Google Calendar. + +IMPORTANT: For start_time, provide the time in the user's timezone WITHOUT a Z suffix (same as schedule_meeting).`, + input_schema: { + type: 'object' as const, + properties: { + meeting_id: { + type: 'string', + description: 'Meeting ID (UUID or Zoom meeting ID)', + }, + title: { + type: 'string', + description: 'New meeting title', + }, + description: { + type: 'string', + description: 'New meeting description', + }, + agenda: { + type: 'string', + description: 'New meeting agenda (markdown supported)', + }, + start_time: { + type: 'string', + description: 'New start time in ISO 8601 format WITHOUT timezone suffix (e.g., "2026-01-15T14:00:00" for 2pm). Use timezone parameter to specify the timezone.', + }, + duration_minutes: { + type: 'number', + description: 'New duration in minutes', + }, + timezone: { + type: 'string', + description: 'Timezone for start_time (default: America/New_York)', + }, + }, + required: ['meeting_id'], + }, + }, { name: 'add_meeting_attendee', description: `Add someone to a meeting. Use this when someone asks to add a specific person to a scheduled meeting.`, @@ -929,6 +971,165 @@ export function createMeetingToolHandlers( } }); + // Update meeting + handlers.set('update_meeting', async (input) => { + const permCheck = await checkSchedulePermission(); + if (permCheck) return permCheck; + + const meetingId = input.meeting_id as string; + + // Try to find by our UUID first, then by Zoom meeting ID + let meeting = await meetingsDb.getMeetingById(meetingId); + if (!meeting) { + meeting = await meetingsDb.getMeetingByZoomId(meetingId); + } + if (!meeting) { + return `❌ Meeting not found: "${meetingId}". Use list_upcoming_meetings to find the correct meeting ID.`; + } + + if (meeting.status === 'cancelled') { + return `❌ Cannot update a cancelled meeting.`; + } + + // Build updates object + const updates: Record = {}; + const changes: string[] = []; + + if (input.title) { + updates.title = input.title as string; + changes.push(`Title → "${input.title}"`); + } + + if (input.description !== undefined) { + updates.description = input.description as string; + changes.push('Description updated'); + } + + if (input.agenda !== undefined) { + updates.agenda = input.agenda as string; + changes.push('Agenda updated'); + } + + // Handle time updates + const startTimeStr = input.start_time as string | undefined; + const durationMinutes = input.duration_minutes as number | undefined; + const timezone = (input.timezone as string) || meeting.timezone || 'America/New_York'; + + // Calculate current duration from start_time and end_time + const currentDuration = meeting.start_time && meeting.end_time + ? Math.round((meeting.end_time.getTime() - meeting.start_time.getTime()) / 60000) + : 60; + + if (startTimeStr) { + const startTime = parseDateInTimezone(startTimeStr, timezone); + if (isNaN(startTime.getTime())) { + return `❌ Invalid start time format. Please use ISO 8601 format (e.g., "2026-01-15T14:00:00").`; + } + updates.start_time = startTime; + updates.timezone = timezone; + + const duration = durationMinutes || currentDuration; + updates.end_time = new Date(startTime.getTime() + duration * 60 * 1000); + + changes.push(`Time → ${formatDate(startTime)} at ${formatTime(startTime, timezone)}`); + } else if (durationMinutes && meeting.start_time) { + // Just updating duration, keep existing start time + updates.end_time = new Date(meeting.start_time.getTime() + durationMinutes * 60 * 1000); + changes.push(`Duration → ${durationMinutes} minutes`); + } + + if (changes.length === 0) { + return `No changes specified. You can update: title, description, agenda, start_time, duration_minutes, timezone.`; + } + + try { + // Update in database + const updatedMeeting = await meetingsDb.updateMeeting(meeting.id, updates); + if (!updatedMeeting) { + return `❌ Failed to update meeting in database.`; + } + + const errors: string[] = []; + + // Update Zoom meeting if time changed and Zoom meeting exists + if (updates.start_time && meeting.zoom_meeting_id && zoom.isZoomConfigured()) { + try { + const startTime = updates.start_time as Date; + await zoom.updateMeeting(meeting.zoom_meeting_id, { + start_time: startTime.toISOString(), + duration: durationMinutes || currentDuration, + timezone, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + errors.push(`Zoom update failed: ${msg}`); + logger.error({ err: error, meetingId: meeting.id }, 'Failed to update Zoom meeting'); + } + } + + // Update Google Calendar event if it exists + if (meeting.google_calendar_event_id && calendar.isGoogleCalendarConfigured()) { + try { + const startTime = (updates.start_time as Date) || meeting.start_time; + const title = (updates.title as string) || meeting.title; + + // Calculate end time - use updated value, or compute from start + duration + let endTime = updates.end_time as Date | undefined; + if (!endTime) { + if (meeting.end_time) { + endTime = meeting.end_time; + } else { + // Fallback: compute from start time + current duration + endTime = new Date(startTime.getTime() + currentDuration * 60 * 1000); + } + } + + // Get working group for calendar event summary + const workingGroup = await workingGroupDb.getWorkingGroupById(meeting.working_group_id); + const summary = workingGroup ? `${workingGroup.name}: ${title}` : title; + + await calendar.updateEvent(meeting.google_calendar_event_id, { + summary, + description: (updates.description as string) || meeting.description || undefined, + start: { + dateTime: startTime.toISOString(), + timeZone: timezone, + }, + end: { + dateTime: endTime.toISOString(), + timeZone: timezone, + }, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + errors.push(`Calendar update failed: ${msg}`); + logger.error({ err: error, meetingId: meeting.id }, 'Failed to update calendar event'); + } + } + + let response = `✅ Updated: **${updatedMeeting.title}**\n\n`; + response += `**Changes:**\n${changes.map(c => `• ${c}`).join('\n')}\n`; + + if (errors.length > 0) { + response += `\n⚠️ Some integrations had issues:\n${errors.map(e => `• ${e}`).join('\n')}`; + } else if (meeting.google_calendar_event_id) { + response += `\n📧 Calendar invites have been updated.`; + } + + logger.info({ + meetingId: meeting.id, + changes, + updatedBy: getUserId(), + }, 'Meeting updated via Addie'); + + return response; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ err: error, meetingId: meeting.id }, 'Failed to update meeting via Addie'); + return `❌ Failed to update meeting: ${msg}`; + } + }); + // Add attendee handlers.set('add_meeting_attendee', async (input) => { const permCheck = await checkSchedulePermission();