- 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
387 lines
14 KiB
TypeScript
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;
|
|
}
|