Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .changeset/nice-kings-add.md
Original file line number Diff line number Diff line change
@@ -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.)
11 changes: 11 additions & 0 deletions server/src/db/meetings-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,17 @@ export class MeetingsDatabase {
return result.rows[0] || null;
}

/**
* Get meeting by Zoom meeting ID
*/
async getMeetingByZoomId(zoomMeetingId: string): Promise<Meeting | null> {
const result = await query<Meeting>(
'SELECT * FROM meetings WHERE zoom_meeting_id = $1',
[zoomMeetingId]
);
return result.rows[0] || null;
}

/**
* Get meeting with working group info
*/
Expand Down
11 changes: 11 additions & 0 deletions server/src/db/migrations/171_user_timezone.sql
Original file line number Diff line number Diff line change
@@ -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)';
54 changes: 54 additions & 0 deletions server/src/db/users-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -121,4 +122,57 @@ export class UsersDatabase {
);
return result.rows;
}

/**
* Get user's timezone
*/
async getUserTimezone(workosUserId: string): Promise<string | null> {
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<User | null> {
const result = await query<User>(
`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<User[]> {
const result = await query<User>(
`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<User[]> {
const result = await query<User>(
`SELECT * FROM users
WHERE timezone IS NULL
ORDER BY engagement_score DESC
LIMIT $1`,
[limit]
);
return result.rows;
}
}
98 changes: 98 additions & 0 deletions server/src/integrations/zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ZoomMeetingSummary | null> {
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<ZoomMeetingSummary>(
'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
*/
Expand Down
135 changes: 135 additions & 0 deletions server/src/notifications/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<boolean> {
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;
}
}
Loading