diff --git a/.changeset/nice-kings-add.md b/.changeset/nice-kings-add.md index 794035520f..80516bcb48 100644 --- a/.changeset/nice-kings-add.md +++ b/.changeset/nice-kings-add.md @@ -1,4 +1,14 @@ --- --- -Exclude Zoom webhook from JSON body parser to enable signature verification +### Zoom Meeting Webhooks & User Timezone + +**Zoom Webhook Integration:** +- Handle `meeting.started` and `meeting.ended` webhooks to update meeting status +- Handle `recording.completed` webhook to store transcripts and Zoom AI Companion summaries +- Send Slack notifications to working group channels when meetings start/end +- Fix JSON body parsing to allow webhook signature verification + +**User Timezone:** +- Add timezone column to users table for scheduling preferences +- Add helper functions for timezone management (getUserTimezone, updateUserTimezone, etc.) diff --git a/server/src/db/meetings-db.ts b/server/src/db/meetings-db.ts index c2ef08b425..42997d6dda 100644 --- a/server/src/db/meetings-db.ts +++ b/server/src/db/meetings-db.ts @@ -201,6 +201,17 @@ export class MeetingsDatabase { return result.rows[0] || null; } + /** + * Get meeting by Zoom meeting ID + */ + async getMeetingByZoomId(zoomMeetingId: string): Promise { + const result = await query( + 'SELECT * FROM meetings WHERE zoom_meeting_id = $1', + [zoomMeetingId] + ); + return result.rows[0] || null; + } + /** * Get meeting with working group info */ diff --git a/server/src/db/migrations/171_user_timezone.sql b/server/src/db/migrations/171_user_timezone.sql new file mode 100644 index 0000000000..a800b14849 --- /dev/null +++ b/server/src/db/migrations/171_user_timezone.sql @@ -0,0 +1,11 @@ +-- Add timezone column to users table +-- This stores the user's preferred timezone for scheduling meetings and notifications +-- Standard IANA timezone format (e.g., 'America/New_York', 'Europe/London') + +ALTER TABLE users +ADD COLUMN IF NOT EXISTS timezone VARCHAR(100); + +-- Index for finding users by timezone (useful for scheduling) +CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone) WHERE timezone IS NOT NULL; + +COMMENT ON COLUMN users.timezone IS 'User preferred timezone in IANA format (e.g., America/New_York)'; diff --git a/server/src/db/users-db.ts b/server/src/db/users-db.ts index 9ba7b3ae52..c5cb0cdee0 100644 --- a/server/src/db/users-db.ts +++ b/server/src/db/users-db.ts @@ -17,6 +17,7 @@ export interface User { country?: string; location_source?: string; location_updated_at?: Date; + timezone?: string; primary_slack_user_id?: string; primary_organization_id?: string; created_at: Date; @@ -121,4 +122,57 @@ export class UsersDatabase { ); return result.rows; } + + /** + * Get user's timezone + */ + async getUserTimezone(workosUserId: string): Promise { + const result = await query<{ timezone: string }>( + `SELECT timezone FROM users WHERE workos_user_id = $1`, + [workosUserId] + ); + return result.rows[0]?.timezone || null; + } + + /** + * Update user's timezone + */ + async updateUserTimezone(workosUserId: string, timezone: string): Promise { + const result = await query( + `UPDATE users + SET timezone = $2, + updated_at = NOW() + WHERE workos_user_id = $1 + RETURNING *`, + [workosUserId, timezone] + ); + return result.rows[0] || null; + } + + /** + * Find users by timezone + */ + async findUsersByTimezone(timezone: string): Promise { + const result = await query( + `SELECT * FROM users + WHERE timezone = $1 + ORDER BY engagement_score DESC`, + [timezone] + ); + return result.rows; + } + + /** + * Get users without timezone set (useful for prompting them to set it) + */ + async findUsersWithoutTimezone(limit = 100): Promise { + const result = await query( + `SELECT * FROM users + WHERE timezone IS NULL + ORDER BY engagement_score DESC + LIMIT $1`, + [limit] + ); + return result.rows; + } } diff --git a/server/src/integrations/zoom.ts b/server/src/integrations/zoom.ts index 8104a5986b..0b297a284f 100644 --- a/server/src/integrations/zoom.ts +++ b/server/src/integrations/zoom.ts @@ -329,6 +329,104 @@ export function parseVttToText(vttContent: string): string { return textLines.join(' '); } +/** + * Zoom AI Companion meeting summary response + */ +export interface ZoomMeetingSummary { + meeting_host_id: string; + meeting_host_email: string; + meeting_uuid: string; + meeting_id: number; + meeting_topic: string; + meeting_start_time: string; + meeting_end_time: string; + summary_start_time: string; + summary_end_time: string; + summary_overview: string; + summary_details: Array<{ + summary_title?: string; + summary_content?: string; + }>; + next_steps?: string[]; + edited_summary?: { + summary_overview?: string; + summary_details?: Array<{ + summary_title?: string; + summary_content?: string; + }>; + next_steps?: string[]; + }; +} + +/** + * Get meeting summary from Zoom AI Companion + * Requires Meeting Summary with AI Companion feature enabled + */ +export async function getMeetingSummary(meetingId: string | number): Promise { + try { + // Double-encode UUID if it contains slashes (Zoom API quirk) + const encodedId = String(meetingId).includes('/') + ? encodeURIComponent(encodeURIComponent(String(meetingId))) + : String(meetingId); + + const summary = await zoomRequest( + 'GET', + `/meetings/${encodedId}/meeting_summary` + ); + + logger.info({ meetingId, hasSummary: !!summary.summary_overview }, 'Retrieved Zoom meeting summary'); + return summary; + } catch (error) { + // 404 means no summary available (meeting hasn't ended, AI Companion not enabled, etc.) + if (error instanceof Error && error.message.includes('404')) { + logger.info({ meetingId }, 'No Zoom meeting summary available'); + return null; + } + logger.error({ err: error, meetingId }, 'Error getting Zoom meeting summary'); + return null; + } +} + +/** + * Format Zoom meeting summary to markdown + */ +export function formatMeetingSummaryAsMarkdown(summary: ZoomMeetingSummary): string { + const parts: string[] = []; + + // Use edited summary if available, otherwise use original + const overview = summary.edited_summary?.summary_overview || summary.summary_overview; + const details = summary.edited_summary?.summary_details || summary.summary_details; + const nextSteps = summary.edited_summary?.next_steps || summary.next_steps; + + if (overview) { + parts.push('## Overview'); + parts.push(overview); + parts.push(''); + } + + if (details && details.length > 0) { + for (const detail of details) { + if (detail.summary_title) { + parts.push(`## ${detail.summary_title}`); + } + if (detail.summary_content) { + parts.push(detail.summary_content); + } + parts.push(''); + } + } + + if (nextSteps && nextSteps.length > 0) { + parts.push('## Next Steps'); + for (const step of nextSteps) { + parts.push(`- ${step}`); + } + parts.push(''); + } + + return parts.join('\n').trim(); +} + /** * Zoom webhook event types we handle */ diff --git a/server/src/notifications/slack.ts b/server/src/notifications/slack.ts index b29126cfb0..57dcda3ce0 100644 --- a/server/src/notifications/slack.ts +++ b/server/src/notifications/slack.ts @@ -516,3 +516,138 @@ export async function notifyPublishedPost(data: { category: data.category, }); } + +/** + * Notify a working group's Slack channel when a meeting starts + */ +export async function notifyMeetingStarted(data: { + slackChannelId: string; + meetingTitle: string; + workingGroupName: string; + zoomJoinUrl?: string; +}): Promise { + if (!isSlackConfigured()) { + logger.debug('Slack not configured, skipping meeting started notification'); + return false; + } + + const message: SlackBlockMessage = { + text: `📹 Meeting starting now: ${data.meetingTitle}`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text' as const, + text: '📹 Meeting Starting Now', + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn' as const, + text: `*${data.meetingTitle}*\n${data.workingGroupName}`, + }, + }, + ...(data.zoomJoinUrl ? [{ + type: 'section', + text: { + type: 'mrkdwn' as const, + text: `<${data.zoomJoinUrl}|Join Zoom Meeting>`, + }, + }] : []), + ], + }; + + try { + const result = await sendChannelMessage(data.slackChannelId, message); + if (result.ok) { + logger.info( + { channelId: data.slackChannelId, meetingTitle: data.meetingTitle }, + 'Sent meeting started notification to Slack channel' + ); + return true; + } else { + logger.warn( + { channelId: data.slackChannelId, error: result.error }, + 'Failed to send meeting started notification' + ); + return false; + } + } catch (error) { + logger.error({ error, channelId: data.slackChannelId }, 'Error sending meeting started notification'); + return false; + } +} + +/** + * Notify a working group's Slack channel when a meeting ends + */ +export async function notifyMeetingEnded(data: { + slackChannelId: string; + meetingTitle: string; + workingGroupName: string; + durationMinutes?: number; +}): Promise { + if (!isSlackConfigured()) { + logger.debug('Slack not configured, skipping meeting ended notification'); + return false; + } + + const durationText = data.durationMinutes + ? `Duration: ${Math.floor(data.durationMinutes / 60)}h ${data.durationMinutes % 60}m` + : ''; + + const message: SlackBlockMessage = { + text: `✅ Meeting ended: ${data.meetingTitle}`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text' as const, + text: '✅ Meeting Ended', + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn' as const, + text: `*${data.meetingTitle}*\n${data.workingGroupName}${durationText ? `\n${durationText}` : ''}`, + }, + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: { + type: 'mrkdwn' as const, + text: 'Recording and transcript will be available soon.', + }, + }, + ], + }, + ], + }; + + try { + const result = await sendChannelMessage(data.slackChannelId, message); + if (result.ok) { + logger.info( + { channelId: data.slackChannelId, meetingTitle: data.meetingTitle }, + 'Sent meeting ended notification to Slack channel' + ); + return true; + } else { + logger.warn( + { channelId: data.slackChannelId, error: result.error }, + 'Failed to send meeting ended notification' + ); + return false; + } + } catch (error) { + logger.error({ error, channelId: data.slackChannelId }, 'Error sending meeting ended notification'); + return false; + } +} diff --git a/server/src/routes/webhooks.ts b/server/src/routes/webhooks.ts index b37c4cbeb8..bf9bef9899 100644 --- a/server/src/routes/webhooks.ts +++ b/server/src/routes/webhooks.ts @@ -13,7 +13,11 @@ import { createLogger } from '../logger.js'; import { getPool } from '../db/client.js'; import { ModelConfig } from '../config/models.js'; import { verifyWebhookSignature as verifyZoomSignature } from '../integrations/zoom.js'; -import { handleRecordingCompleted } from '../services/meeting-service.js'; +import { + handleRecordingCompleted, + handleMeetingStarted, + handleMeetingEnded, +} from '../services/meeting-service.js'; import { getFeedByEmailSlug, createEmailPerspective, @@ -1344,21 +1348,31 @@ export function createWebhooksRouter(): Router { case 'recording.completed': case 'recording.transcript_completed': { const meetingUuid = body.payload?.object?.uuid; + const meetingId = body.payload?.object?.id; if (meetingUuid && typeof meetingUuid === 'string' && meetingUuid.length < 256) { - await handleRecordingCompleted(meetingUuid); + // Pass both UUID and numeric ID - we store the numeric ID in our DB + await handleRecordingCompleted(meetingUuid, meetingId?.toString()); } else if (meetingUuid) { logger.warn({ meetingUuidType: typeof meetingUuid }, 'Invalid meetingUuid format'); } break; } - case 'meeting.started': - logger.info({ meetingId: body.payload?.object?.id }, 'Meeting started'); + case 'meeting.started': { + const startedMeetingId = body.payload?.object?.id; + if (startedMeetingId) { + await handleMeetingStarted(startedMeetingId.toString()); + } break; + } - case 'meeting.ended': - logger.info({ meetingId: body.payload?.object?.id }, 'Meeting ended'); + case 'meeting.ended': { + const endedMeetingId = body.payload?.object?.id; + if (endedMeetingId) { + await handleMeetingEnded(endedMeetingId.toString()); + } break; + } default: logger.debug({ event: body.event }, 'Unhandled Zoom webhook event'); diff --git a/server/src/services/meeting-service.ts b/server/src/services/meeting-service.ts index ad7ec349bb..e226fa44b2 100644 --- a/server/src/services/meeting-service.ts +++ b/server/src/services/meeting-service.ts @@ -13,6 +13,10 @@ import { MeetingsDatabase } from '../db/meetings-db.js'; import { WorkingGroupDatabase } from '../db/working-group-db.js'; import * as zoom from '../integrations/zoom.js'; import * as calendar from '../integrations/google-calendar.js'; +import { + notifyMeetingStarted, + notifyMeetingEnded, +} from '../notifications/slack.js'; import type { CreateMeetingInput, UpdateMeetingInput, @@ -163,6 +167,8 @@ export async function scheduleMeeting(options: ScheduleMeetingOptions): Promise< timeZone: timezone, }, attendees: attendeeEmails, + // Set location to Zoom join URL so it appears in calendar invite + location: zoomMeeting?.join_url, }; // Add Zoom link as conference data @@ -291,31 +297,51 @@ export async function addAttendeesToMeeting( /** * Handle Zoom recording completed webhook - * Stores transcript and generates summary + * Stores transcript and fetches Zoom AI Companion summary */ -export async function handleRecordingCompleted(meetingUuid: string): Promise { - logger.info({ meetingUuid }, 'Processing recording completed'); +export async function handleRecordingCompleted(meetingUuid: string, zoomMeetingId?: string): Promise { + logger.info({ meetingUuid, zoomMeetingId }, 'Processing recording completed'); - // Find meeting in database by Zoom meeting ID - // Note: We store the numeric ID, but webhooks use UUID - // For now, we'd need to query by zoom_meeting_id + // Find meeting in database - try zoom_meeting_id first (numeric ID), fall back to UUID lookup + let meeting: Meeting | null = null; + if (zoomMeetingId) { + meeting = await meetingsDb.getMeetingByZoomId(zoomMeetingId); + } - // Get transcript - const transcriptText = await zoom.getTranscriptText(meetingUuid); - if (!transcriptText) { - logger.info({ meetingUuid }, 'No transcript available'); + if (!meeting) { + logger.warn({ meetingUuid, zoomMeetingId }, 'Meeting not found in database - transcript will not be stored'); return; } - // Parse VTT to plain text - const plainText = zoom.parseVttToText(transcriptText); + // Get transcript + const transcriptText = await zoom.getTranscriptText(meetingUuid); + if (transcriptText) { + // Parse VTT to plain text and store + const plainText = zoom.parseVttToText(transcriptText); + logger.info({ meetingId: meeting.id, transcriptLength: plainText.length }, 'Transcript retrieved'); - // TODO: Find the meeting in our database by zoom_meeting_id - // TODO: Store transcript_text - // TODO: Generate AI summary using Claude - // TODO: Store summary + await meetingsDb.updateMeeting(meeting.id, { + transcript_text: plainText, + }); + } else { + logger.info({ meetingUuid, meetingId: meeting.id }, 'No transcript available'); + } + + // Fetch Zoom AI Companion meeting summary + try { + const zoomSummary = await zoom.getMeetingSummary(meetingUuid); + if (zoomSummary) { + const summary = zoom.formatMeetingSummaryAsMarkdown(zoomSummary); + await meetingsDb.updateMeeting(meeting.id, { summary }); + logger.info({ meetingId: meeting.id }, 'Zoom AI Companion summary stored'); + } else { + logger.info({ meetingId: meeting.id }, 'No Zoom AI Companion summary available'); + } + } catch (error) { + logger.error({ err: error, meetingId: meeting.id }, 'Failed to fetch Zoom meeting summary'); + } - logger.info({ meetingUuid, transcriptLength: plainText.length }, 'Transcript processed'); + logger.info({ meetingUuid, meetingId: meeting.id }, 'Recording processing completed'); } /** @@ -496,3 +522,85 @@ function getNextOccurrence(from: Date, rule: RecurrenceRule): Date { return next; } + +/** + * Handle Zoom meeting started webhook + * Updates meeting status and sends Slack notification + */ +export async function handleMeetingStarted(zoomMeetingId: string): Promise { + logger.info({ zoomMeetingId }, 'Processing meeting started'); + + const meeting = await meetingsDb.getMeetingByZoomId(zoomMeetingId); + if (!meeting) { + logger.warn({ zoomMeetingId }, 'Meeting not found in database - skipping started notification'); + return; + } + + // Update meeting status + await meetingsDb.updateMeeting(meeting.id, { status: 'in_progress' }); + + // Get working group for Slack channel + const workingGroup = await workingGroupDb.getWorkingGroupById(meeting.working_group_id); + if (!workingGroup) { + logger.warn({ meetingId: meeting.id }, 'Working group not found - skipping Slack notification'); + return; + } + + // Send Slack notification if channel is configured + if (workingGroup.slack_channel_id) { + await notifyMeetingStarted({ + slackChannelId: workingGroup.slack_channel_id, + meetingTitle: meeting.title, + workingGroupName: workingGroup.name, + zoomJoinUrl: meeting.zoom_join_url, + }); + } + + logger.info({ meetingId: meeting.id, zoomMeetingId }, 'Meeting started processed'); +} + +/** + * Handle Zoom meeting ended webhook + * Updates meeting status and sends Slack notification + */ +export async function handleMeetingEnded(zoomMeetingId: string): Promise { + logger.info({ zoomMeetingId }, 'Processing meeting ended'); + + const meeting = await meetingsDb.getMeetingByZoomId(zoomMeetingId); + if (!meeting) { + logger.warn({ zoomMeetingId }, 'Meeting not found in database - skipping ended notification'); + return; + } + + // Calculate duration if we have start time + let durationMinutes: number | undefined; + if (meeting.start_time) { + const now = new Date(); + durationMinutes = Math.round((now.getTime() - meeting.start_time.getTime()) / 60000); + } + + // Update meeting status + await meetingsDb.updateMeeting(meeting.id, { + status: 'completed', + end_time: new Date(), + }); + + // Get working group for Slack channel + const workingGroup = await workingGroupDb.getWorkingGroupById(meeting.working_group_id); + if (!workingGroup) { + logger.warn({ meetingId: meeting.id }, 'Working group not found - skipping Slack notification'); + return; + } + + // Send Slack notification if channel is configured + if (workingGroup.slack_channel_id) { + await notifyMeetingEnded({ + slackChannelId: workingGroup.slack_channel_id, + meetingTitle: meeting.title, + workingGroupName: workingGroup.name, + durationMinutes, + }); + } + + logger.info({ meetingId: meeting.id, zoomMeetingId, durationMinutes }, 'Meeting ended processed'); +} diff --git a/server/src/types.ts b/server/src/types.ts index 2a5c660af7..d313231c77 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -323,6 +323,7 @@ export type LocationSource = 'manual' | 'outreach' | 'inferred'; export interface UserLocation { city?: string; country?: string; + timezone?: string; location_source?: LocationSource; location_updated_at?: Date; }