/** * Zoom Sync Service * Orchestrates Zoom Phone + Meetings → PostgreSQL sync * Cross-references calls/meetings with Autotask contacts and companies */ import { getZoomClient } from './zoom-factory'; import { postgresClient } from './postgres-client'; const CALL_WINDOW_DAYS = 90; const MEETING_WINDOW_DAYS = 90; const PARTICIPANT_WINDOW_DAYS = 30; // Zoom API limitation /** * Strip all non-digit characters and return last 10 digits. * Handles country code variations (e.g. +1 prefix). */ function normalizePhone(phone: string | null | undefined): string { if (!phone) return ''; const digits = phone.replace(/\D/g, ''); return digits.slice(-10); } function toIsoDate(d: Date): string { return d.toISOString().split('T')[0]; } export class ZoomSyncService { private syncInProgress = false; isSyncInProgress(): boolean { return this.syncInProgress; } async sync(): Promise<{ usersUpserted: number; callsUpserted: number; callsMatched: number; meetingsUpserted: number; participantsUpserted: number; }> { if (this.syncInProgress) { throw new Error('Zoom sync already in progress'); } this.syncInProgress = true; const startTime = Date.now(); console.log('[ZOOM-SYNC] Starting sync...'); try { const client = getZoomClient(); // ─── Step 1: Sync Zoom users ─────────────────────────────────────── console.log('[ZOOM-SYNC] Fetching Zoom users...'); const zoomUsers = await client.getUsers(); // Load set of resource emails from DB (for filtering) const resourceEmailsResult = await postgresClient.query( `SELECT LOWER(email) as email FROM resources WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL` ); const resourceEmails = new Set( resourceEmailsResult.rows.map((r: { email: string }) => r.email) ); let usersUpserted = 0; const activeZoomUsers = zoomUsers.filter( u => u.email && resourceEmails.has(u.email.toLowerCase()) ); for (const user of activeZoomUsers) { await postgresClient.query( `INSERT INTO zoom_users (zoom_id, email, display_name, is_active, synced_at) VALUES ($1, $2, $3, true, NOW()) ON CONFLICT (zoom_id) DO UPDATE SET email = EXCLUDED.email, display_name = EXCLUDED.display_name, is_active = true, synced_at = NOW()`, [user.id, user.email.toLowerCase(), user.display_name] ); usersUpserted++; } console.log(`[ZOOM-SYNC] Upserted ${usersUpserted} Zoom users (filtered to resource emails)`); // ─── Step 2: Build phone match index from Autotask contacts ─────── console.log('[ZOOM-SYNC] Building phone index from Autotask contacts...'); const contactsResult = await postgresClient.query( `SELECT id, company_id, phone, mobile_phone, alternate_phone FROM contacts WHERE (is_deleted = false OR is_deleted IS NULL)` ); // Map: normalized 10-digit phone → { contactId, companyId } const phoneIndex = new Map(); for (const row of contactsResult.rows) { for (const field of ['phone', 'mobile_phone', 'alternate_phone'] as const) { const norm = normalizePhone(row[field]); if (norm && !phoneIndex.has(norm)) { phoneIndex.set(norm, { contactId: row.id, companyId: row.company_id }); } } } // Also index company phones → companyId (fallback when no contact match) const companiesResult = await postgresClient.query( `SELECT id, phone FROM companies WHERE phone IS NOT NULL` ); const companyPhoneIndex = new Map(); for (const row of companiesResult.rows) { const norm = normalizePhone(row.phone); if (norm && !companyPhoneIndex.has(norm)) { companyPhoneIndex.set(norm, row.id); } } // Internal phone set — to skip contact matching for internal calls const internalNumbers = new Set(); // ─── Step 3: Sync call logs (90-day window) ──────────────────────── const callFrom = toIsoDate(new Date(Date.now() - CALL_WINDOW_DAYS * 24 * 60 * 60 * 1000)); const callTo = toIsoDate(new Date()); let callsUpserted = 0; let callsMatched = 0; for (const user of activeZoomUsers) { console.log(`[ZOOM-SYNC] Fetching call logs for ${user.email}...`); const calls = await client.getUserCallLogs(user.id, callFrom, callTo); for (const call of calls) { // Determine direction and the other-party number const direction = call.direction; let otherPartyNumber: string; let otherPartyName: string; if (direction === 'outbound') { otherPartyNumber = call.callee_number; otherPartyName = call.callee_name || ''; } else if (direction === 'inbound') { otherPartyNumber = call.caller_number; otherPartyName = call.caller_name || ''; } else { // internal — record but skip contact matching otherPartyNumber = call.caller_number || call.callee_number || ''; otherPartyName = call.caller_name || call.callee_name || ''; internalNumbers.add(normalizePhone(otherPartyNumber)); } const callId = call.id || call.call_id; const normOther = normalizePhone(otherPartyNumber); // Map call result → status const result = (call.result || '').toLowerCase(); let callStatus = 'completed'; if (result.includes('missed') || result.includes('no answer')) callStatus = 'missed'; else if (result.includes('voicemail')) callStatus = 'voicemail'; else if (result.includes('busy')) callStatus = 'busy'; else if (result.includes('failed') || result.includes('cancel')) callStatus = 'failed'; // Cross-reference: contact → company let matchedContactId: number | null = null; let matchedCompanyId: number | null = null; if (direction !== 'internal' && normOther) { const contactMatch = phoneIndex.get(normOther); if (contactMatch) { matchedContactId = contactMatch.contactId; matchedCompanyId = contactMatch.companyId; } else { const companyId = companyPhoneIndex.get(normOther); if (companyId) matchedCompanyId = companyId; } } if (matchedContactId || matchedCompanyId) callsMatched++; await postgresClient.query( `INSERT INTO zoom_calls ( zoom_call_id, resource_email, direction, call_status, other_party_number, other_party_name, start_time, duration_seconds, matched_contact_id, matched_company_id, synced_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW()) ON CONFLICT (zoom_call_id) DO UPDATE SET start_time = EXCLUDED.start_time, duration_seconds = EXCLUDED.duration_seconds, call_status = EXCLUDED.call_status, matched_contact_id = EXCLUDED.matched_contact_id, matched_company_id = EXCLUDED.matched_company_id, synced_at = NOW()`, [ callId, user.email.toLowerCase(), direction, callStatus, otherPartyNumber || null, otherPartyName || null, call.date_time ? new Date(call.date_time) : null, call.duration ?? null, matchedContactId, matchedCompanyId, ] ); callsUpserted++; } } console.log(`[ZOOM-SYNC] Calls: ${callsUpserted} upserted, ${callsMatched} matched to contacts/companies`); // ─── Step 4: Sync past meetings (90-day window) ─────────────────── const meetingFrom = toIsoDate(new Date(Date.now() - MEETING_WINDOW_DAYS * 24 * 60 * 60 * 1000)); const meetingTo = toIsoDate(new Date()); // Build map of zoom_id → email for host resolution const zoomIdToEmail = new Map( activeZoomUsers.map(u => [u.id, u.email.toLowerCase()]) ); let meetingsUpserted = 0; // meeting DB id map: zoom_meeting_id → db id (for participant insert) const meetingDbIds = new Map(); for (const user of activeZoomUsers) { console.log(`[ZOOM-SYNC] Fetching past meetings for ${user.email}...`); const meetings = await client.getUserPastMeetings(user.id, meetingFrom, meetingTo); for (const meeting of meetings) { const meetingId = String(meeting.id); const hostEmail = meeting.host_email ?? zoomIdToEmail.get(meeting.host_id) ?? user.email.toLowerCase(); const endTime = meeting.end_time ? new Date(meeting.end_time) : meeting.start_time ? new Date(new Date(meeting.start_time).getTime() + (meeting.duration ?? 0) * 60000) : null; const result = await postgresClient.query( `INSERT INTO zoom_meetings ( zoom_meeting_id, zoom_meeting_uuid, host_email, topic, start_time, end_time, duration_minutes, participant_count, client_participant_count, has_client_attendees, synced_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,0,false,NOW()) ON CONFLICT (zoom_meeting_id) DO UPDATE SET zoom_meeting_uuid = EXCLUDED.zoom_meeting_uuid, host_email = EXCLUDED.host_email, topic = EXCLUDED.topic, start_time = EXCLUDED.start_time, end_time = EXCLUDED.end_time, duration_minutes = EXCLUDED.duration_minutes, participant_count = EXCLUDED.participant_count, synced_at = NOW() RETURNING id`, [ meetingId, meeting.uuid || null, hostEmail, meeting.topic || null, meeting.start_time ? new Date(meeting.start_time) : null, endTime, meeting.duration ?? null, meeting.participants_count ?? 0, ] ); if (result.rows[0]) { meetingDbIds.set(meetingId, result.rows[0].id); meetingsUpserted++; } } } console.log(`[ZOOM-SYNC] Meetings upserted: ${meetingsUpserted}`); // ─── Step 5: Sync meeting participants (30-day window) ──────────── const participantCutoff = new Date(Date.now() - PARTICIPANT_WINDOW_DAYS * 24 * 60 * 60 * 1000); // Build contact email index const contactEmailResult = await postgresClient.query( `SELECT id, company_id, LOWER(email_address) as email1, LOWER(email_address2) as email2, LOWER(email_address3) as email3 FROM contacts WHERE (is_deleted = false OR is_deleted IS NULL)` ); const contactEmailIndex = new Map(); for (const row of contactEmailResult.rows) { for (const e of [row.email1, row.email2, row.email3]) { if (e && !contactEmailIndex.has(e)) { contactEmailIndex.set(e, { contactId: row.id, companyId: row.company_id }); } } } let participantsUpserted = 0; // Process meetings within the 30-day participant window const recentMeetingIds = await postgresClient.query( `SELECT id, zoom_meeting_id FROM zoom_meetings WHERE start_time >= $1`, [participantCutoff] ); for (const meetingRow of recentMeetingIds.rows) { const dbMeetingId: number = meetingRow.id; const zoomMeetingId: string = meetingRow.zoom_meeting_id; // Clear existing participants for this meeting (re-sync) await postgresClient.query( `DELETE FROM zoom_meeting_participants WHERE meeting_id = $1`, [dbMeetingId] ); const participants = await client.getMeetingParticipants(zoomMeetingId); let clientParticipantCount = 0; let hasClientAttendees = false; for (const p of participants) { const email = (p.user_email || '').toLowerCase(); const isInternal = email ? resourceEmails.has(email) : false; let matchedContactId: number | null = null; let matchedCompanyId: number | null = null; if (email && !isInternal) { const match = contactEmailIndex.get(email); if (match) { matchedContactId = match.contactId; matchedCompanyId = match.companyId; clientParticipantCount++; hasClientAttendees = true; } } await postgresClient.query( `INSERT INTO zoom_meeting_participants ( meeting_id, participant_email, participant_name, duration_seconds, matched_contact_id, matched_company_id, is_internal, created_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,NOW())`, [ dbMeetingId, email || null, p.name || null, p.duration ?? null, matchedContactId, matchedCompanyId, isInternal, ] ); participantsUpserted++; } // Update meeting with participant stats await postgresClient.query( `UPDATE zoom_meetings SET client_participant_count = $2, has_client_attendees = $3 WHERE id = $1`, [dbMeetingId, clientParticipantCount, hasClientAttendees] ); } console.log(`[ZOOM-SYNC] Participants upserted: ${participantsUpserted}`); const duration = Date.now() - startTime; console.log(`[ZOOM-SYNC] Done in ${duration}ms`); return { usersUpserted, callsUpserted, callsMatched, meetingsUpserted, participantsUpserted, }; } finally { this.syncInProgress = false; } } } let _instance: ZoomSyncService | null = null; export function getZoomSyncService(): ZoomSyncService { if (!_instance) { _instance = new ZoomSyncService(); } return _instance; }