Skip to main content
Glama
zoom-create-meeting.ts11.8 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { getExtensionValue } from '@medplum/core'; import type { BotEvent, MedplumClient } from '@medplum/core'; import type { Appointment, Extension } from '@medplum/fhirtypes'; // Zoom extension URLs const ZOOM_MEETING_EXTENSION_URL = 'https://medplum.com/zoom'; const ZOOM_MEETING_ID_URL = 'meeting-id'; const ZOOM_MEETING_PASSWORD_URL = 'meeting-password'; const ZOOM_MEETING_START_URL = 'meeting-start-url'; const ZOOM_MEETING_JOIN_URL = 'meeting-join-url'; interface ZoomMeetingInput { topic?: string; duration?: number; startTime?: string; timezone?: string; password?: boolean; settings?: { host_video?: boolean; participant_video?: boolean; join_before_host?: boolean; mute_upon_entry?: boolean; waiting_room?: boolean; }; } interface ZoomMeetingResponse { meetingUrl: string; startUrl: string; password?: string; joinUrl: string; meetingId: string; } interface ZoomTokenResponse { access_token: string; } interface ZoomMeetingApiResponse { id: string | number; join_url: string; start_url: string; password?: string; } /** * Gets a Zoom access token using the Server-to-Server OAuth flow * @param accountId - Zoom account ID * @param clientId - Zoom client ID * @param clientSecret - Zoom client secret * @returns Access token */ async function getZoomAccessToken(accountId: string, clientId: string, clientSecret: string): Promise<string> { const tokenEndpoint = 'https://zoom.us/oauth/token'; const authHeader = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; const body = new URLSearchParams({ grant_type: 'account_credentials', account_id: accountId, }).toString(); console.log('Requesting Zoom access token with:', { endpoint: tokenEndpoint, accountId, clientId: clientId.substring(0, 4) + '...', // Log partial client ID for security body, }); const response = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: authHeader, }, body, }); if (!response.ok) { const errorData = await response.text(); console.error('Zoom token request failed:', { status: response.status, statusText: response.statusText, errorData, headers: Object.fromEntries(response.headers.entries()), }); throw new Error(`Failed to get Zoom access token: ${response.statusText}. Details: ${errorData}`); } const data = (await response.json()) as ZoomTokenResponse; console.log('Successfully obtained Zoom access token'); return data.access_token; } /** * Creates a Zoom meeting using the Zoom API * @param accessToken - Zoom access token * @param input - Meeting configuration * @param userEmail - Zoom user email * @returns Meeting details */ async function createZoomMeeting( accessToken: string, input: ZoomMeetingInput, userEmail: string ): Promise<ZoomMeetingResponse> { const url = `https://api.zoom.us/v2/users/${userEmail}/meetings`; const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }; console.log('Creating Zoom meeting with:', { url, userEmail, body: { ...input, settings: input.settings, }, }); const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ topic: input.topic || 'Medplum Meeting', type: 2, //2 for scheduled duration: input.duration || 30, start_time: input.startTime, timezone: input.timezone, password: crypto.randomUUID().slice(0, 8), settings: { host_video: input.settings?.host_video ?? true, participant_video: input.settings?.participant_video ?? true, join_before_host: input.settings?.join_before_host ?? false, mute_upon_entry: input.settings?.mute_upon_entry ?? true, waiting_room: input.settings?.waiting_room ?? true, }, }), }); if (!response.ok) { const errorData = await response.text(); console.error('Zoom API Error:', { status: response.status, statusText: response.statusText, errorData, headers: Object.fromEntries(response.headers.entries()), }); throw new Error(`Failed to create Zoom meeting: ${response.statusText}. Details: ${errorData}`); } const meeting = (await response.json()) as ZoomMeetingApiResponse; return { meetingUrl: meeting.join_url, startUrl: meeting.start_url, password: meeting.password, joinUrl: meeting.join_url, meetingId: meeting.id.toString(), }; } /** * Updates an existing Zoom meeting using the Zoom API * @param accessToken - Zoom access token * @param meetingId - Zoom meeting ID * @param input - Meeting configuration * @returns Meeting details */ async function updateZoomMeeting( accessToken: string, meetingId: string, input: ZoomMeetingInput ): Promise<ZoomMeetingResponse> { const url = `https://api.zoom.us/v2/meetings/${meetingId}`; const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }; console.log('Updating Zoom meeting with:', { url, body: { ...input, settings: input.settings, }, }); const response = await fetch(url, { method: 'PATCH', headers, body: JSON.stringify({ topic: input.topic || 'Medplum Meeting', type: 2, // 2 for scheduled duration: input.duration || 30, start_time: input.startTime, timezone: input.timezone, settings: { host_video: input.settings?.host_video ?? true, participant_video: input.settings?.participant_video ?? true, join_before_host: input.settings?.join_before_host ?? false, mute_upon_entry: input.settings?.mute_upon_entry ?? true, waiting_room: input.settings?.waiting_room ?? true, }, }), }); if (!response.ok) { const errorData = await response.text(); console.error('Zoom API Error:', { status: response.status, statusText: response.statusText, errorData, headers: Object.fromEntries(response.headers.entries()), }); throw new Error(`Failed to update Zoom meeting: ${response.statusText}. Details: ${errorData}`); } // After updating, get the latest meeting details const getResponse = await fetch(url, { method: 'GET', headers, }); if (!getResponse.ok) { const errorData = await getResponse.text(); throw new Error(`Failed to get updated meeting details: ${getResponse.statusText}. Details: ${errorData}`); } const meeting = (await getResponse.json()) as ZoomMeetingApiResponse; return { meetingUrl: meeting.join_url, startUrl: meeting.start_url, password: meeting.password, joinUrl: meeting.join_url, meetingId: meeting.id.toString(), }; } /** * Deletes a Zoom meeting using the Zoom API * @param accessToken - Zoom access token * @param meetingId - Zoom meeting ID */ async function deleteZoomMeeting(accessToken: string, meetingId: string): Promise<void> { const url = `https://api.zoom.us/v2/meetings/${meetingId}`; const headers = { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }; console.log('Deleting Zoom meeting: ', url); const response = await fetch(url, { method: 'DELETE', headers, }); if (!response.ok) { const errorData = await response.text(); console.error('Zoom API Error:', { status: response.status, statusText: response.statusText, errorData, headers: Object.fromEntries(response.headers.entries()), }); throw new Error(`Failed to delete Zoom meeting: ${response.statusText}. Details: ${errorData}`); } } /** * Removes Zoom meeting extensions from an appointment * @param appointment - Appointment resource * @returns Appointment with Zoom extensions removed */ function removeZoomExtensions(appointment: Appointment): Appointment { return { ...appointment, extension: appointment.extension?.filter((ext) => ext.url !== ZOOM_MEETING_EXTENSION_URL), }; } /** * Updates an Appointment resource with Zoom meeting details * @param medplum - Medplum client * @param appointment - Appointment resource * @param meetingDetails - Zoom meeting details * @returns Updated Appointment resource */ async function updateAppointmentWithZoomDetails( medplum: MedplumClient, appointment: Appointment, meetingDetails: ZoomMeetingResponse ): Promise<Appointment> { // Add Zoom meeting details as extension // Remove any existing Zoom extensions const extensions: Extension[] = (appointment.extension || []).filter((ext) => ext.url !== ZOOM_MEETING_EXTENSION_URL); extensions.push({ url: ZOOM_MEETING_EXTENSION_URL, extension: [ { url: ZOOM_MEETING_ID_URL, valueString: meetingDetails.meetingId, }, { url: ZOOM_MEETING_PASSWORD_URL, valueString: meetingDetails.password || '', }, { url: ZOOM_MEETING_START_URL, valueString: meetingDetails.startUrl, }, { url: ZOOM_MEETING_JOIN_URL, valueString: meetingDetails.joinUrl, }, ], }); appointment.extension = extensions; return medplum.updateResource(appointment); } /** * Medplum Bot handler for creating Zoom meetings * @param medplum - Medplum client * @param event - Bot event * @returns Zoom meeting details */ export async function handler(medplum: MedplumClient, event: BotEvent<Appointment>): Promise<Appointment> { // Get Zoom credentials from bot secrets const accountId = event.secrets['ZOOM_ACCOUNT_ID']?.valueString; const clientId = event.secrets['ZOOM_CLIENT_ID']?.valueString; const clientSecret = event.secrets['ZOOM_CLIENT_SECRET']?.valueString; const userEmail = event.secrets['ZOOM_USER_EMAIL']?.valueString; if (!accountId || !clientId || !clientSecret || !userEmail) { throw new Error('Missing Zoom credentials in bot secrets'); } // Get access token const accessToken = await getZoomAccessToken(accountId, clientId, clientSecret); const appointment = event.input as Appointment; // Check if this is a deletion (status is 'cancelled' or 'noshow') const isDeletion = appointment.status === 'cancelled' || appointment.status === 'noshow'; // Get existing meeting ID if any const existingMeetingId = getExtensionValue(appointment, 'https://medplum.com/zoom', 'meeting-id') as string; if (isDeletion && existingMeetingId) { // Delete the Zoom meeting await deleteZoomMeeting(accessToken, existingMeetingId); // Remove Zoom extensions from the appointment and update in Medplum const cancelledAppointment = removeZoomExtensions(appointment); return medplum.updateResource(cancelledAppointment); } // If not a deletion, proceed with normal meeting creation/update if (!appointment.start) { throw new Error('Appointment start time is required'); } const meetingInput = { topic: appointment.description || 'Medical Appointment', startTime: appointment.start, duration: appointment.minutesDuration || 30, timezone: 'UTC', }; let meetingDetails: ZoomMeetingResponse; if (existingMeetingId) { // Update existing meeting meetingDetails = await updateZoomMeeting(accessToken, existingMeetingId, meetingInput); } else { // Create new meeting meetingDetails = await createZoomMeeting(accessToken, meetingInput, userEmail); } const updatedAppointment = await updateAppointmentWithZoomDetails(medplum, appointment, meetingDetails); return updatedAppointment; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server