wulf-pulse/lib/services/zoom-sync-service.ts
lorentz c518eefdb2 feat: Morning NOC Summary adaptive card for Teams
- Add MorningSummaryService with Zabbix aggregation and adaptive card builder
- Add webhook delivery system with Teams incoming webhooks
- Add admin UI at /admin/morning-summary for webhook/config management
- Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history
- Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM)
- Add outages_only filter (Unavailable triggers only)
- Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts
- Fix resolved events: event.get value:1 scoped to window with r_eventid filter
- Remove emojis from fact rows and section headers in card
- Remove Open Zabbix button (duplicate of View Problems)
- Add migrations: morning_summary_config + morning_summaries tables
- Add outages_only column to morning_summary_config
2026-03-11 09:34:51 -04:00

387 lines
14 KiB
TypeScript

/**
* 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<string>(
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<string, { contactId: number; companyId: number | null }>();
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<string, number>();
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<string>();
// ─── 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<string, string>(
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<string, number>();
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<string, { contactId: number; companyId: number | null }>();
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;
}