/** * Zoom Server-to-Server OAuth API Client * Requires: ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET */ export interface ZoomUser { id: string; email: string; display_name: string; status: string; } export interface ZoomCallLog { id: string; call_id: string; caller_number: string; caller_name: string; callee_number: string; callee_name: string; direction: 'inbound' | 'outbound' | 'internal'; result: string; // 'Call connected', 'Missed', 'Voicemail', etc. date_time: string; // ISO 8601 — Zoom Phone API uses date_time, not start_time duration: number; // seconds } export interface ZoomMeeting { id: string | number; uuid: string; host_id: string; host_email?: string; topic: string; start_time: string; end_time?: string; duration: number; // minutes participants_count?: number; } export interface ZoomMeetingParticipant { id?: string; user_id?: string; name: string; user_email: string; duration: number; // seconds join_time: string; leave_time: string; } export interface ZoomClientConfig { accountId: string; clientId: string; clientSecret: string; } export class ZoomClient { private config: ZoomClientConfig; private accessToken: string | null = null; private tokenExpiry: number = 0; private readonly baseUrl = 'https://api.zoom.us/v2'; constructor(config: ZoomClientConfig) { this.config = config; } private async getToken(): Promise { // Refresh 5 minutes before expiry if (this.accessToken && Date.now() < this.tokenExpiry - 300000) { return this.accessToken; } const credentials = Buffer.from( `${this.config.clientId}:${this.config.clientSecret}` ).toString('base64'); const url = `https://zoom.us/oauth/token?grant_type=account_credentials&account_id=${encodeURIComponent(this.config.accountId)}`; const res = await fetch(url, { method: 'POST', headers: { Authorization: `Basic ${credentials}`, 'Content-Type': 'application/x-www-form-urlencoded', }, }); if (!res.ok) { const text = await res.text(); throw new Error(`Zoom token request failed: ${res.status} ${text}`); } const data = await res.json(); this.accessToken = data.access_token; this.tokenExpiry = Date.now() + data.expires_in * 1000; return this.accessToken!; } private async fetchJson(path: string, retries = 0): Promise { const token = await this.getToken(); const res = await fetch(`${this.baseUrl}${path}`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', }, }); if (res.status === 429) { if (retries >= 5) { throw new Error(`Zoom rate limit hit after ${retries} retries: ${path}`); } const delay = Math.pow(2, retries) * 1000; console.warn(`[ZOOM] Rate limited on ${path}, retrying in ${delay}ms`); await new Promise(resolve => setTimeout(resolve, delay)); return this.fetchJson(path, retries + 1); } if (!res.ok) { const text = await res.text(); throw new Error(`Zoom API error ${res.status} for ${path}: ${text}`); } return res.json(); } /** * Get all active Zoom users (paginated via next_page_token) */ async getUsers(): Promise { const users: ZoomUser[] = []; let nextPageToken = ''; do { const params = new URLSearchParams({ status: 'active', page_size: '300', }); if (nextPageToken) params.set('next_page_token', nextPageToken); const data = await this.fetchJson<{ users: ZoomUser[]; next_page_token?: string; }>(`/users?${params}`); users.push(...(data.users ?? [])); nextPageToken = data.next_page_token ?? ''; } while (nextPageToken); return users; } /** * Get phone call logs for a user in a date range (paginated) * from/to: ISO date strings 'YYYY-MM-DD' */ async getUserCallLogs( zoomUserId: string, from: string, to: string ): Promise { const calls: ZoomCallLog[] = []; let nextPageToken = ''; do { const params = new URLSearchParams({ from, to, page_size: '300', type: 'all', }); if (nextPageToken) params.set('next_page_token', nextPageToken); let data: { call_logs?: ZoomCallLog[]; next_page_token?: string }; try { data = await this.fetchJson<{ call_logs?: ZoomCallLog[]; next_page_token?: string; }>(`/phone/users/${encodeURIComponent(zoomUserId)}/call_logs?${params}`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); // 400 = user has no Zoom Phone license — skip silently if (msg.includes('400') || msg.includes('404')) { break; } throw err; } calls.push(...(data.call_logs ?? [])); nextPageToken = data.next_page_token ?? ''; } while (nextPageToken); return calls; } /** * Get past meetings for a user in a date range (paginated) * Uses the Reports API — requires role or admin scope */ async getUserPastMeetings( zoomUserId: string, from: string, to: string ): Promise { const meetings: ZoomMeeting[] = []; let nextPageToken = ''; do { const params = new URLSearchParams({ type: 'past', from, to, page_size: '300', }); if (nextPageToken) params.set('next_page_token', nextPageToken); let data: { meetings?: ZoomMeeting[]; next_page_token?: string }; try { data = await this.fetchJson<{ meetings?: ZoomMeeting[]; next_page_token?: string; }>(`/report/users/${encodeURIComponent(zoomUserId)}/meetings?${params}`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('400') || msg.includes('404')) { break; } throw err; } meetings.push(...(data.meetings ?? [])); nextPageToken = data.next_page_token ?? ''; } while (nextPageToken); return meetings; } /** * Get participants for a past meeting (Zoom limitation: last 30 days only) */ async getMeetingParticipants(meetingId: string): Promise { const participants: ZoomMeetingParticipant[] = []; let nextPageToken = ''; do { const params = new URLSearchParams({ page_size: '300' }); if (nextPageToken) params.set('next_page_token', nextPageToken); let data: { participants?: ZoomMeetingParticipant[]; next_page_token?: string }; try { data = await this.fetchJson<{ participants?: ZoomMeetingParticipant[]; next_page_token?: string; }>(`/past_meetings/${encodeURIComponent(meetingId)}/participants?${params}`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('400') || msg.includes('404') || msg.includes('3001')) { break; } throw err; } participants.push(...(data.participants ?? [])); nextPageToken = data.next_page_token ?? ''; } while (nextPageToken); return participants; } }