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
This commit is contained in:
parent
19605f82aa
commit
c518eefdb2
61 changed files with 11236 additions and 237 deletions
424
lib/services/engagement-sync-service.ts
Normal file
424
lib/services/engagement-sync-service.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
/**
|
||||
* Engagement Sync Service
|
||||
* Orchestrates Microsoft Graph → PostgreSQL sync for employee engagement data
|
||||
*/
|
||||
|
||||
import { getMsgraphClient } from './msgraph-factory';
|
||||
import type { CalendarEvent, UserMessage } from './msgraph-client';
|
||||
import { postgresClient } from './postgres-client';
|
||||
|
||||
const PERIODS = ['D7', 'D30', 'D90'] as const;
|
||||
type Period = typeof PERIODS[number];
|
||||
|
||||
const PERIOD_DAYS: Record<Period, number> = { D7: 7, D30: 30, D90: 90 };
|
||||
|
||||
interface CalendarBucket {
|
||||
meetingCount: number;
|
||||
durationSeconds: number;
|
||||
externalCount: number;
|
||||
}
|
||||
|
||||
function computeCalendarBuckets(
|
||||
events: CalendarEvent[],
|
||||
userEmail: string,
|
||||
internalDomains: Set<string>,
|
||||
now: Date
|
||||
): Record<Period, CalendarBucket> {
|
||||
const cutoffs: Record<Period, number> = {
|
||||
D7: now.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||||
D30: now.getTime() - 30 * 24 * 60 * 60 * 1000,
|
||||
D90: now.getTime() - 90 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const result: Record<Period, CalendarBucket> = {
|
||||
D7: { meetingCount: 0, durationSeconds: 0, externalCount: 0 },
|
||||
D30: { meetingCount: 0, durationSeconds: 0, externalCount: 0 },
|
||||
D90: { meetingCount: 0, durationSeconds: 0, externalCount: 0 },
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
const eventTime = new Date(event.start.dateTime).getTime();
|
||||
const durationSec = Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(new Date(event.end.dateTime).getTime() - new Date(event.start.dateTime).getTime()) / 1000
|
||||
)
|
||||
);
|
||||
|
||||
// An attendee is external if their domain is not in the org's verified domains
|
||||
// (filter out the user themselves to avoid false positives)
|
||||
const hasExternal = event.attendees.some(a => {
|
||||
const email = (a.emailAddress?.address ?? '').toLowerCase();
|
||||
if (email === userEmail.toLowerCase()) return false;
|
||||
const domain = email.split('@')[1];
|
||||
return domain && !internalDomains.has(domain);
|
||||
});
|
||||
|
||||
for (const period of PERIODS) {
|
||||
if (eventTime >= cutoffs[period]) {
|
||||
result[period].meetingCount++;
|
||||
result[period].durationSeconds += durationSec;
|
||||
if (hasExternal) result[period].externalCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class EngagementSyncService {
|
||||
private syncInProgress = false;
|
||||
|
||||
isSyncInProgress(): boolean {
|
||||
return this.syncInProgress;
|
||||
}
|
||||
|
||||
async sync(): Promise<{ usersUpserted: number; snapshotsUpserted: number }> {
|
||||
if (this.syncInProgress) {
|
||||
throw new Error('Engagement sync already in progress');
|
||||
}
|
||||
|
||||
this.syncInProgress = true;
|
||||
const startTime = Date.now();
|
||||
console.log('[ENGAGEMENT-SYNC] Starting sync...');
|
||||
|
||||
try {
|
||||
const client = getMsgraphClient();
|
||||
|
||||
// 1. Fetch org's verified domains (to identify external attendees)
|
||||
const orgDomains = await client.getOrganizationDomains();
|
||||
const internalDomains = new Set(orgDomains);
|
||||
console.log(`[ENGAGEMENT-SYNC] Internal domains: ${[...internalDomains].join(', ')}`);
|
||||
|
||||
// 2. Sync users
|
||||
console.log('[ENGAGEMENT-SYNC] Fetching Graph users...');
|
||||
const graphUsers = await client.getUsers();
|
||||
const licencedUsers = graphUsers.filter(u => u.mail || u.userPrincipalName);
|
||||
|
||||
for (const user of licencedUsers) {
|
||||
const email = (user.mail || user.userPrincipalName || '').toLowerCase();
|
||||
if (!email) continue;
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO graph_users (id, display_name, email, job_title, department, account_enabled, synced_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
email = EXCLUDED.email,
|
||||
job_title = EXCLUDED.job_title,
|
||||
department = EXCLUDED.department,
|
||||
account_enabled = EXCLUDED.account_enabled,
|
||||
synced_at = NOW()`,
|
||||
[user.id, user.displayName, email, user.jobTitle, user.department, user.accountEnabled ?? true]
|
||||
);
|
||||
}
|
||||
console.log(`[ENGAGEMENT-SYNC] Upserted ${licencedUsers.length} graph users`);
|
||||
|
||||
// 3. Fetch Teams + Email activity reports for each period
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const now = new Date();
|
||||
|
||||
// Build snapshot data map: email → period → data
|
||||
type SnapshotData = {
|
||||
teamsChatMessages: number;
|
||||
teamsPrivateMessages: number;
|
||||
teamsCalls: number;
|
||||
teamsMeetingsAttended: number;
|
||||
teamsMeetingsOrganized: number;
|
||||
audioDurationSeconds: number;
|
||||
emailsSent: number;
|
||||
emailsReceived: number;
|
||||
emailsRead: number;
|
||||
meetingDurationSeconds: number;
|
||||
meetingsWithExternal: number;
|
||||
lastActivityDate: string | null;
|
||||
afterHoursMessages: number;
|
||||
};
|
||||
|
||||
const snapshotMap = new Map<string, Map<Period, SnapshotData>>();
|
||||
|
||||
for (const period of PERIODS) {
|
||||
console.log(`[ENGAGEMENT-SYNC] Fetching activity for period ${period}...`);
|
||||
|
||||
const [teamsRows, emailRows] = await Promise.all([
|
||||
client.getTeamsActivity(period).catch(err => {
|
||||
console.warn(`[ENGAGEMENT-SYNC] Teams activity failed for ${period}:`, err.message);
|
||||
return [];
|
||||
}),
|
||||
client.getEmailActivity(period).catch(err => {
|
||||
console.warn(`[ENGAGEMENT-SYNC] Email activity failed for ${period}:`, err.message);
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
|
||||
const teamsMap = new Map(teamsRows.map(r => [r.userPrincipalName.toLowerCase(), r]));
|
||||
const emailMap = new Map(emailRows.map(r => [r.userPrincipalName.toLowerCase(), r]));
|
||||
const allEmails = new Set([
|
||||
...teamsRows.map(r => r.userPrincipalName.toLowerCase()),
|
||||
...emailRows.map(r => r.userPrincipalName.toLowerCase()),
|
||||
]);
|
||||
|
||||
for (const email of allEmails) {
|
||||
const teams = teamsMap.get(email);
|
||||
const mail = emailMap.get(email);
|
||||
|
||||
const dates = [teams?.lastActivityDate, mail?.lastActivityDate].filter(Boolean) as string[];
|
||||
const lastActivity = dates.length > 0 ? dates.sort().reverse()[0] : null;
|
||||
|
||||
if (!snapshotMap.has(email)) snapshotMap.set(email, new Map());
|
||||
snapshotMap.get(email)!.set(period, {
|
||||
teamsChatMessages: teams?.teamChatMessageCount ?? 0,
|
||||
teamsPrivateMessages: teams?.privateChatMessageCount ?? 0,
|
||||
teamsCalls: teams?.callCount ?? 0,
|
||||
teamsMeetingsAttended: teams?.meetingsAttendedCount ?? 0,
|
||||
teamsMeetingsOrganized: teams?.meetingsOrganizedCount ?? 0,
|
||||
audioDurationSeconds: teams?.audioDurationSeconds ?? 0,
|
||||
emailsSent: mail?.sendCount ?? 0,
|
||||
emailsReceived: mail?.receiveCount ?? 0,
|
||||
emailsRead: mail?.readCount ?? 0,
|
||||
meetingDurationSeconds: 0,
|
||||
meetingsWithExternal: 0,
|
||||
lastActivityDate: lastActivity,
|
||||
afterHoursMessages: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fetch calendar events per user (90 days, aggregate into all period buckets)
|
||||
console.log('[ENGAGEMENT-SYNC] Fetching calendar events...');
|
||||
const activeUsers = licencedUsers.filter(u => {
|
||||
const email = (u.mail || u.userPrincipalName || '').toLowerCase();
|
||||
return snapshotMap.has(email);
|
||||
});
|
||||
|
||||
// Build contact email → { contactId, companyId } index for attendee matching
|
||||
const contactRows = await postgresClient.query(
|
||||
`SELECT id, company_id, LOWER(email_address) as e1,
|
||||
LOWER(email_address2) as e2, LOWER(email_address3) as e3
|
||||
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 contactRows.rows) {
|
||||
for (const e of [row.e1, row.e2, row.e3]) {
|
||||
if (e && !contactEmailIndex.has(e)) {
|
||||
contactEmailIndex.set(e, { contactId: row.id, companyId: row.company_id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const calStart = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
let calFetched = 0;
|
||||
let calSkipped = 0;
|
||||
|
||||
for (const user of activeUsers) {
|
||||
const email = (user.mail || user.userPrincipalName || '').toLowerCase();
|
||||
try {
|
||||
const events = await client.getUserCalendarEvents(user.id, calStart, now);
|
||||
if (events.length > 0) {
|
||||
const buckets = computeCalendarBuckets(events, email, internalDomains, now);
|
||||
for (const period of PERIODS) {
|
||||
const snap = snapshotMap.get(email)?.get(period);
|
||||
if (snap) {
|
||||
snap.meetingDurationSeconds = buckets[period].durationSeconds;
|
||||
snap.meetingsWithExternal = buckets[period].externalCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist individual meeting records
|
||||
for (const event of events) {
|
||||
if (!event.id) continue;
|
||||
try {
|
||||
const startTime = new Date(event.start.dateTime);
|
||||
const endTime = new Date(event.end.dateTime);
|
||||
const durationMinutes = Math.max(
|
||||
0,
|
||||
Math.round((endTime.getTime() - startTime.getTime()) / 60000)
|
||||
);
|
||||
const externalAttendees = event.attendees.filter(a => {
|
||||
const aEmail = (a.emailAddress?.address ?? '').toLowerCase();
|
||||
if (aEmail === email) return false;
|
||||
const domain = aEmail.split('@')[1];
|
||||
return domain && !internalDomains.has(domain);
|
||||
});
|
||||
const attendeeCount = event.attendees.length;
|
||||
|
||||
const meetingResult = await postgresClient.query(
|
||||
`INSERT INTO teams_meetings
|
||||
(graph_event_id, user_email, subject, start_time, end_time,
|
||||
duration_minutes, is_online_meeting, attendee_count, synced_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||
ON CONFLICT (user_email, graph_event_id) DO UPDATE SET
|
||||
subject = EXCLUDED.subject,
|
||||
start_time = EXCLUDED.start_time,
|
||||
end_time = EXCLUDED.end_time,
|
||||
duration_minutes = EXCLUDED.duration_minutes,
|
||||
is_online_meeting = EXCLUDED.is_online_meeting,
|
||||
attendee_count = EXCLUDED.attendee_count,
|
||||
synced_at = NOW()
|
||||
RETURNING id`,
|
||||
[event.id, email, event.subject, startTime, endTime,
|
||||
durationMinutes, event.isOnlineMeeting, attendeeCount]
|
||||
);
|
||||
const meetingId = meetingResult.rows[0]?.id;
|
||||
if (!meetingId) continue;
|
||||
|
||||
// Re-sync attendees clean
|
||||
await postgresClient.query(
|
||||
`DELETE FROM teams_meeting_attendees WHERE meeting_id = $1`,
|
||||
[meetingId]
|
||||
);
|
||||
|
||||
let clientCount = 0;
|
||||
for (const att of externalAttendees) {
|
||||
const attEmail = (att.emailAddress?.address ?? '').toLowerCase();
|
||||
const attName = att.emailAddress?.name ?? null;
|
||||
const match = attEmail ? contactEmailIndex.get(attEmail) : undefined;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO teams_meeting_attendees
|
||||
(meeting_id, attendee_email, attendee_name, matched_contact_id, matched_company_id)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[meetingId, attEmail || null, attName,
|
||||
match?.contactId ?? null, match?.companyId ?? null]
|
||||
);
|
||||
if (match?.companyId) clientCount++;
|
||||
}
|
||||
|
||||
await postgresClient.query(
|
||||
`UPDATE teams_meetings
|
||||
SET client_attendee_count = $1, has_client_attendees = $2
|
||||
WHERE id = $3`,
|
||||
[clientCount, clientCount > 0, meetingId]
|
||||
);
|
||||
} catch (meetingErr) {
|
||||
const msg = meetingErr instanceof Error ? meetingErr.message : String(meetingErr);
|
||||
console.warn(`[ENGAGEMENT-SYNC] Meeting persist failed for event ${event.id}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
calFetched++;
|
||||
} catch (err) {
|
||||
calSkipped++;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[ENGAGEMENT-SYNC] Calendar fetch failed for ${email}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[ENGAGEMENT-SYNC] Calendar: ${calFetched} fetched, ${calSkipped} skipped`);
|
||||
|
||||
// 5. Count after-hours messages per user (requires Chat.Read.All permission)
|
||||
// After-hours window: 5:30 PM – 7:00 AM America/New_York
|
||||
const AFTER_HOURS_START_MIN = 17 * 60 + 30; // 1050 — 5:30 PM local
|
||||
const AFTER_HOURS_END_MIN = 7 * 60; // 420 — 7:00 AM local
|
||||
|
||||
// Returns minutes since midnight in America/New_York (handles EST/EDT automatically)
|
||||
function toEasternMinutes(dt: Date): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/New_York',
|
||||
hour: 'numeric', minute: 'numeric', hour12: false,
|
||||
}).formatToParts(dt);
|
||||
const h = parseInt(parts.find(p => p.type === 'hour')?.value ?? '0');
|
||||
const m = parseInt(parts.find(p => p.type === 'minute')?.value ?? '0');
|
||||
return h * 60 + m;
|
||||
}
|
||||
const periodCutoffs: Record<Period, number> = {
|
||||
D7: now.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||||
D30: now.getTime() - 30 * 24 * 60 * 60 * 1000,
|
||||
D90: now.getTime() - 90 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
let msgFetched = 0;
|
||||
let msgSkipped = 0;
|
||||
|
||||
console.log(`[ENGAGEMENT-SYNC] Fetching after-hours messages for ${activeUsers.length} users...`);
|
||||
for (const user of activeUsers) {
|
||||
const email = (user.mail || user.userPrincipalName || '').toLowerCase();
|
||||
if (!snapshotMap.has(email)) continue;
|
||||
try {
|
||||
const messages: UserMessage[] = await client.getUserMessages(user.id, calStart, now);
|
||||
const afterD90 = messages.filter(msg => {
|
||||
const estMin = toEasternMinutes(new Date(msg.createdDateTime));
|
||||
return estMin >= AFTER_HOURS_START_MIN || estMin < AFTER_HOURS_END_MIN;
|
||||
}).length;
|
||||
console.log(`[ENGAGEMENT-SYNC] ${email}: ${messages.length} msgs total, ${afterD90} after-hours`);
|
||||
// Bucket by period
|
||||
for (const period of PERIODS) {
|
||||
const snap = snapshotMap.get(email)?.get(period);
|
||||
if (!snap) continue;
|
||||
snap.afterHoursMessages = messages.filter(msg => {
|
||||
const msgMs = new Date(msg.createdDateTime).getTime();
|
||||
if (msgMs < periodCutoffs[period]) return false;
|
||||
const estMin = toEasternMinutes(new Date(msg.createdDateTime));
|
||||
return estMin >= AFTER_HOURS_START_MIN || estMin < AFTER_HOURS_END_MIN;
|
||||
}).length;
|
||||
}
|
||||
msgFetched++;
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[ENGAGEMENT-SYNC] Message fetch failed for ${email}: ${errMsg.slice(0, 200)}`);
|
||||
msgSkipped++;
|
||||
}
|
||||
// Small pause between users to stay under Graph API rate limits (10 req/10 sec per app)
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
console.log(`[ENGAGEMENT-SYNC] After-hours messages: ${msgFetched} fetched, ${msgSkipped} skipped`);
|
||||
|
||||
// 6. Upsert all snapshots
|
||||
let totalSnapshots = 0;
|
||||
for (const [email, periodMap] of snapshotMap) {
|
||||
for (const [period, snap] of periodMap) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO engagement_snapshots (
|
||||
user_email, period_type, period_end,
|
||||
teams_chat_messages, teams_private_messages, teams_calls,
|
||||
teams_meetings_attended, teams_meetings_organized,
|
||||
emails_sent, emails_received, emails_read,
|
||||
audio_duration_seconds, meeting_duration_seconds, meetings_with_external,
|
||||
after_hours_messages, last_activity_date, synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
|
||||
ON CONFLICT (user_email, period_type, period_end) DO UPDATE SET
|
||||
teams_chat_messages = EXCLUDED.teams_chat_messages,
|
||||
teams_private_messages = EXCLUDED.teams_private_messages,
|
||||
teams_calls = EXCLUDED.teams_calls,
|
||||
teams_meetings_attended = EXCLUDED.teams_meetings_attended,
|
||||
teams_meetings_organized = EXCLUDED.teams_meetings_organized,
|
||||
emails_sent = EXCLUDED.emails_sent,
|
||||
emails_received = EXCLUDED.emails_received,
|
||||
emails_read = EXCLUDED.emails_read,
|
||||
audio_duration_seconds = EXCLUDED.audio_duration_seconds,
|
||||
meeting_duration_seconds = EXCLUDED.meeting_duration_seconds,
|
||||
meetings_with_external = EXCLUDED.meetings_with_external,
|
||||
after_hours_messages = EXCLUDED.after_hours_messages,
|
||||
last_activity_date = EXCLUDED.last_activity_date,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
email, period, today,
|
||||
snap.teamsChatMessages, snap.teamsPrivateMessages, snap.teamsCalls,
|
||||
snap.teamsMeetingsAttended, snap.teamsMeetingsOrganized,
|
||||
snap.emailsSent, snap.emailsReceived, snap.emailsRead,
|
||||
snap.audioDurationSeconds, snap.meetingDurationSeconds, snap.meetingsWithExternal,
|
||||
snap.afterHoursMessages, snap.lastActivityDate,
|
||||
]
|
||||
);
|
||||
totalSnapshots++;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`[ENGAGEMENT-SYNC] Done in ${duration}ms. Users: ${licencedUsers.length}, Snapshots: ${totalSnapshots}`);
|
||||
|
||||
return { usersUpserted: licencedUsers.length, snapshotsUpserted: totalSnapshots };
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: EngagementSyncService | null = null;
|
||||
|
||||
export function getEngagementSyncService(): EngagementSyncService {
|
||||
if (!_instance) {
|
||||
_instance = new EngagementSyncService();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
buildActiveFilter,
|
||||
buildDateRangeFilter,
|
||||
buildContractsFilter,
|
||||
buildContractServicesFilter,
|
||||
buildProjectsFilter,
|
||||
buildTimeEntriesFilter,
|
||||
buildBillingItemsFilter,
|
||||
|
|
@ -125,6 +126,9 @@ export class EntitySyncService {
|
|||
if (entity === EntityType.CONTRACTS) {
|
||||
filters.push(...buildContractsFilter());
|
||||
entityLogger.info('Full sync with status filter for active contracts');
|
||||
} else if (entity === EntityType.CONTRACT_SERVICES) {
|
||||
filters.push(...buildContractServicesFilter());
|
||||
entityLogger.info('Full sync of all contract services');
|
||||
} else if (entity === EntityType.PROJECTS) {
|
||||
filters.push(...buildProjectsFilter());
|
||||
entityLogger.info('Full sync with status filter for non-completed projects');
|
||||
|
|
@ -365,6 +369,21 @@ export class EntitySyncService {
|
|||
|
||||
entityLogger.info('Upserted records to PostgreSQL', { upsertedCount });
|
||||
|
||||
// Post-sync enrichment: populate service_name from autotask_services lookup
|
||||
if (entity === EntityType.CONTRACT_SERVICES) {
|
||||
try {
|
||||
const enrichResult = await postgresClient.query(`
|
||||
UPDATE contract_services cs
|
||||
SET service_name = s.name
|
||||
FROM autotask_services s
|
||||
WHERE cs.service_id = s.id AND cs.service_name IS NULL AND s.name IS NOT NULL
|
||||
`);
|
||||
entityLogger.info('Enriched contract_services with service names', { rowCount: enrichResult.rowCount });
|
||||
} catch (err) {
|
||||
entityLogger.warn('service_name enrichment skipped (autotask_services may not be synced yet)');
|
||||
}
|
||||
}
|
||||
|
||||
// For full sync, soft delete records not in the fetched set
|
||||
// IMPORTANT: Skip soft deletes if ANY filters were applied (date, status, active, etc.)
|
||||
// because we cannot know what records exist outside the filter criteria
|
||||
|
|
|
|||
543
lib/services/morning-summary-service.ts
Normal file
543
lib/services/morning-summary-service.ts
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
import { postgresClient } from './postgres-client';
|
||||
import { ZabbixClient } from './zabbix-client';
|
||||
|
||||
export interface MorningSummaryProblem {
|
||||
eventid: string;
|
||||
hostid: string;
|
||||
hostName: string;
|
||||
clientName: string;
|
||||
triggerName: string;
|
||||
severity: number;
|
||||
severityLabel: string;
|
||||
startedAt: Date;
|
||||
durationLabel: string;
|
||||
acknowledged: boolean;
|
||||
needsAttention: boolean;
|
||||
}
|
||||
|
||||
export interface MorningSummaryResolved {
|
||||
eventid: string;
|
||||
hostName: string;
|
||||
clientName: string;
|
||||
triggerName: string;
|
||||
startedAt: Date;
|
||||
resolvedAt: Date;
|
||||
durationLabel: string;
|
||||
}
|
||||
|
||||
export interface MorningSummary {
|
||||
generatedAt: Date;
|
||||
windowFrom: Date;
|
||||
windowTo: Date;
|
||||
isWeekendWindow: boolean;
|
||||
openProblems: MorningSummaryProblem[];
|
||||
resolvedOvernight: MorningSummaryResolved[];
|
||||
openCount: number;
|
||||
resolvedCount: number;
|
||||
mttrMinutes: number | null;
|
||||
clientsAffected: string[];
|
||||
}
|
||||
|
||||
export interface WebhookConfig {
|
||||
id: number;
|
||||
label: string;
|
||||
webhook_url: string;
|
||||
enabled: boolean;
|
||||
last_delivered_at: string | null;
|
||||
last_status: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SummaryConfig {
|
||||
weekend_suppression: boolean;
|
||||
monday_extended_window: boolean;
|
||||
severity_filter: number;
|
||||
outages_only: boolean;
|
||||
}
|
||||
|
||||
export interface DeliveryResult {
|
||||
webhookId: number;
|
||||
label: string;
|
||||
success: boolean;
|
||||
httpStatus?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const SEVERITY_LABELS: Record<number, string> = {
|
||||
0: 'Not classified',
|
||||
1: 'Information',
|
||||
2: 'Warning',
|
||||
3: 'Average',
|
||||
4: 'High',
|
||||
5: 'Disaster',
|
||||
};
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalMinutes = Math.floor(ms / 60000);
|
||||
if (totalMinutes < 1) return '<1m';
|
||||
if (totalMinutes < 60) return `${totalMinutes}m`;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const mins = totalMinutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function makeZabbix(): ZabbixClient {
|
||||
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||
throw new Error('Zabbix not configured: ZABBIX_API_URL and ZABBIX_API_TOKEN required');
|
||||
}
|
||||
return new ZabbixClient({
|
||||
apiUrl: process.env.ZABBIX_API_URL,
|
||||
apiToken: process.env.ZABBIX_API_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
function getWindowStart(now: Date, config: SummaryConfig): { from: Date; isWeekend: boolean } {
|
||||
const day = now.getDay(); // 0=Sun,1=Mon
|
||||
if (day === 1 && config.monday_extended_window) {
|
||||
const friday = new Date(now);
|
||||
friday.setDate(friday.getDate() - 3);
|
||||
friday.setHours(18, 0, 0, 0);
|
||||
return { from: friday, isWeekend: true };
|
||||
}
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
yesterday.setHours(18, 0, 0, 0);
|
||||
return { from: yesterday, isWeekend: false };
|
||||
}
|
||||
|
||||
function buildAdaptiveCard(summary: MorningSummary): object {
|
||||
const headerText = `☀️ Morning NOC Summary${summary.isWeekendWindow ? ' — Weekend Coverage' : ''}`;
|
||||
|
||||
const mttrText = summary.mttrMinutes != null ? formatDuration(summary.mttrMinutes * 60000) : '—';
|
||||
|
||||
const statsColumns = {
|
||||
type: 'ColumnSet',
|
||||
columns: [
|
||||
{
|
||||
type: 'Column', width: 'stretch',
|
||||
items: [{
|
||||
type: 'TextBlock',
|
||||
text: `${summary.openCount} Open`,
|
||||
weight: 'Bolder', color: summary.openCount > 0 ? 'Attention' : 'Default',
|
||||
wrap: true,
|
||||
}],
|
||||
},
|
||||
{
|
||||
type: 'Column', width: 'stretch',
|
||||
items: [{
|
||||
type: 'TextBlock',
|
||||
text: `${summary.resolvedCount} Resolved`,
|
||||
weight: 'Bolder', color: summary.resolvedCount > 0 ? 'Good' : 'Default',
|
||||
wrap: true,
|
||||
}],
|
||||
},
|
||||
{
|
||||
type: 'Column', width: 'stretch',
|
||||
items: [{
|
||||
type: 'TextBlock',
|
||||
text: `${mttrText} MTTR`,
|
||||
weight: 'Bolder', wrap: true,
|
||||
}],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const bodyItems: object[] = [
|
||||
{ type: 'TextBlock', text: headerText, weight: 'Bolder', size: 'Large', wrap: true },
|
||||
statsColumns,
|
||||
{ type: 'TextBlock', text: ' ', spacing: 'None' },
|
||||
];
|
||||
|
||||
// Open issues section
|
||||
if (summary.openProblems.length > 0) {
|
||||
const facts = summary.openProblems.map(p => ({
|
||||
title: `${p.clientName} / ${p.hostName}`,
|
||||
value: `${p.triggerName} — ${p.durationLabel}`,
|
||||
}));
|
||||
|
||||
bodyItems.push({
|
||||
type: 'Container',
|
||||
style: 'attention',
|
||||
items: [
|
||||
{ type: 'TextBlock', text: 'Open Issues', weight: 'Bolder', color: 'Attention', wrap: true },
|
||||
{ type: 'FactSet', facts },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
bodyItems.push({
|
||||
type: 'Container',
|
||||
style: 'good',
|
||||
items: [{ type: 'TextBlock', text: 'All Clear — No open issues', weight: 'Bolder', color: 'Good', wrap: true }],
|
||||
});
|
||||
}
|
||||
|
||||
// Resolved overnight section
|
||||
if (summary.resolvedOvernight.length > 0) {
|
||||
const shown = summary.resolvedOvernight.slice(0, 5);
|
||||
const extra = summary.resolvedOvernight.length - 5;
|
||||
const facts = shown.map(r => ({
|
||||
title: `${r.clientName} / ${r.hostName}`,
|
||||
value: `${r.triggerName} — ${r.durationLabel}`,
|
||||
}));
|
||||
if (extra > 0) {
|
||||
facts.push({ title: '', value: `+${extra} more — all resolved` });
|
||||
}
|
||||
|
||||
bodyItems.push({
|
||||
type: 'Container',
|
||||
style: 'good',
|
||||
items: [
|
||||
{ type: 'TextBlock', text: 'Resolved', weight: 'Bolder', color: 'Good', wrap: true },
|
||||
{ type: 'FactSet', facts },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Window info footer
|
||||
const windowFrom = summary.windowFrom.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
const windowTo = summary.windowTo.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
bodyItems.push({
|
||||
type: 'TextBlock',
|
||||
text: `Window: ${windowFrom} → ${windowTo}`,
|
||||
size: 'Small', color: 'Default', isSubtle: true, wrap: true, spacing: 'Small',
|
||||
});
|
||||
|
||||
return {
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.4',
|
||||
body: bodyItems,
|
||||
actions: [
|
||||
{ type: 'Action.OpenUrl', title: 'Open Pulse', url: 'https://pulse.wulfconsulting.cloud' },
|
||||
{ type: 'Action.OpenUrl', title: 'View Problems', url: 'https://zabbix.wulfconsulting.cloud/zabbix.php?action=problem.view' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export class MorningSummaryService {
|
||||
async getConfig(): Promise<SummaryConfig> {
|
||||
const result = await postgresClient.query('SELECT * FROM morning_summary_config WHERE id = 1');
|
||||
if (result.rows.length === 0) {
|
||||
return { weekend_suppression: true, monday_extended_window: true, severity_filter: 2, outages_only: false };
|
||||
}
|
||||
return result.rows[0] as SummaryConfig;
|
||||
}
|
||||
|
||||
async updateConfig(updates: Partial<SummaryConfig>): Promise<SummaryConfig> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (updates.weekend_suppression !== undefined) { fields.push(`weekend_suppression = $${idx++}`); values.push(updates.weekend_suppression); }
|
||||
if (updates.monday_extended_window !== undefined) { fields.push(`monday_extended_window = $${idx++}`); values.push(updates.monday_extended_window); }
|
||||
if (updates.severity_filter !== undefined) { fields.push(`severity_filter = $${idx++}`); values.push(updates.severity_filter); }
|
||||
if (updates.outages_only !== undefined) { fields.push(`outages_only = $${idx++}`); values.push(updates.outages_only); }
|
||||
|
||||
if (fields.length === 0) return this.getConfig();
|
||||
fields.push('updated_at = NOW()');
|
||||
values.push(1);
|
||||
|
||||
const result = await postgresClient.query(
|
||||
`UPDATE morning_summary_config SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows[0] as SummaryConfig;
|
||||
}
|
||||
|
||||
async getWebhooks(): Promise<WebhookConfig[]> {
|
||||
const result = await postgresClient.query('SELECT * FROM morning_summary_webhooks ORDER BY id');
|
||||
return result.rows as WebhookConfig[];
|
||||
}
|
||||
|
||||
async createWebhook(label: string, webhookUrl: string): Promise<WebhookConfig> {
|
||||
const result = await postgresClient.query(
|
||||
'INSERT INTO morning_summary_webhooks (label, webhook_url, enabled) VALUES ($1, $2, true) RETURNING *',
|
||||
[label, webhookUrl]
|
||||
);
|
||||
return result.rows[0] as WebhookConfig;
|
||||
}
|
||||
|
||||
async updateWebhook(id: number, updates: { label?: string; webhook_url?: string; enabled?: boolean }): Promise<WebhookConfig> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (updates.label !== undefined) { fields.push(`label = $${idx++}`); values.push(updates.label); }
|
||||
if (updates.webhook_url !== undefined) { fields.push(`webhook_url = $${idx++}`); values.push(updates.webhook_url); }
|
||||
if (updates.enabled !== undefined) { fields.push(`enabled = $${idx++}`); values.push(updates.enabled); }
|
||||
|
||||
if (fields.length === 0) throw new Error('No fields to update');
|
||||
values.push(id);
|
||||
|
||||
const result = await postgresClient.query(
|
||||
`UPDATE morning_summary_webhooks SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows[0] as WebhookConfig;
|
||||
}
|
||||
|
||||
async deleteWebhook(id: number): Promise<void> {
|
||||
await postgresClient.query('DELETE FROM morning_summary_webhooks WHERE id = $1', [id]);
|
||||
}
|
||||
|
||||
async getLatestSummaryRow(): Promise<{
|
||||
id: number;
|
||||
generated_at: string;
|
||||
window_from: string;
|
||||
window_to: string;
|
||||
open_count: number;
|
||||
resolved_count: number;
|
||||
mttr_minutes: number | null;
|
||||
clients_affected: string[];
|
||||
is_weekend_window: boolean;
|
||||
card_payload: object | null;
|
||||
delivery_status: object;
|
||||
} | null> {
|
||||
const result = await postgresClient.query('SELECT * FROM morning_summaries ORDER BY generated_at DESC LIMIT 1');
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
}
|
||||
|
||||
async getSummaryHistory(limit = 10): Promise<Array<{
|
||||
id: number;
|
||||
generated_at: string;
|
||||
open_count: number;
|
||||
resolved_count: number;
|
||||
mttr_minutes: number | null;
|
||||
is_weekend_window: boolean;
|
||||
delivery_status: object;
|
||||
}>> {
|
||||
const result = await postgresClient.query(
|
||||
'SELECT id, generated_at, open_count, resolved_count, mttr_minutes, is_weekend_window, delivery_status FROM morning_summaries ORDER BY generated_at DESC LIMIT $1',
|
||||
[limit]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull data from Zabbix and build the MorningSummary + Adaptive Card.
|
||||
*/
|
||||
async aggregate(overrideWindowFrom?: Date): Promise<{ summary: MorningSummary; card: object }> {
|
||||
const now = new Date();
|
||||
const config = await this.getConfig();
|
||||
const { from: windowFrom, isWeekend } = overrideWindowFrom
|
||||
? { from: overrideWindowFrom, isWeekend: false }
|
||||
: getWindowStart(now, config);
|
||||
|
||||
const zabbix = makeZabbix();
|
||||
|
||||
// Fetch open problems and resolved events in parallel
|
||||
const [rawProblems, rawResolved] = await Promise.all([
|
||||
zabbix.getOpenProblems(config.severity_filter),
|
||||
zabbix.getResolvedEvents(windowFrom, now),
|
||||
]);
|
||||
|
||||
// Build the set of unique triggerids from both result sets
|
||||
const allTriggerIds = [
|
||||
...new Set([
|
||||
...rawProblems.map(p => p.objectid),
|
||||
...rawResolved.map(e => e.objectid),
|
||||
]),
|
||||
];
|
||||
|
||||
// Resolve triggerid → { hostid, hostName } for ENABLED hosts only.
|
||||
// Triggerids tied solely to disabled hosts will be absent from this map.
|
||||
const triggerHostMap = await zabbix.getTriggerEnabledHosts(allTriggerIds);
|
||||
|
||||
// Fetch groups for every enabled hostid so we can derive clientName
|
||||
const enabledHostIds = [...new Set([...triggerHostMap.values()].map(h => h.hostid))];
|
||||
const groupMap = await zabbix.getHostGroupMap(enabledHostIds);
|
||||
|
||||
function resolveClient(triggerid: string): { hostid: string; hostName: string; clientName: string } {
|
||||
const host = triggerHostMap.get(triggerid);
|
||||
if (!host) return { hostid: '', hostName: 'Unknown', clientName: 'Unknown' };
|
||||
const groups = groupMap.get(host.hostid) ?? [];
|
||||
const clientGroup = groups.find((g: string) => g.startsWith('Clients/'));
|
||||
const clientName = clientGroup ? clientGroup.replace('Clients/', '').trim() : host.hostName;
|
||||
return { hostid: host.hostid, hostName: host.hostName, clientName };
|
||||
}
|
||||
|
||||
// Only include problems whose trigger maps to an enabled host; optionally filter to outages only
|
||||
const openProblems: MorningSummaryProblem[] = rawProblems
|
||||
.filter(p => triggerHostMap.has(p.objectid))
|
||||
.filter(p => !config.outages_only || p.name.toLowerCase().includes('unavailable'))
|
||||
.map(p => {
|
||||
const { hostid, hostName, clientName } = resolveClient(p.objectid);
|
||||
const severity = parseInt(p.severity, 10);
|
||||
const startedAt = new Date(parseInt(p.clock, 10) * 1000);
|
||||
const durationMs = now.getTime() - startedAt.getTime();
|
||||
const ackArray = Array.isArray(p.acknowledges) ? p.acknowledges : [];
|
||||
const acknowledged = ackArray.length > 0;
|
||||
const needsAttention = !acknowledged && durationMs > 4 * 60 * 60 * 1000;
|
||||
|
||||
return {
|
||||
eventid: p.eventid,
|
||||
hostid,
|
||||
hostName,
|
||||
clientName,
|
||||
triggerName: p.name,
|
||||
severity,
|
||||
severityLabel: SEVERITY_LABELS[severity] ?? 'Unknown',
|
||||
startedAt,
|
||||
durationLabel: formatDuration(durationMs),
|
||||
acknowledged,
|
||||
needsAttention,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort: needs attention first, then by severity desc, then by duration desc
|
||||
openProblems.sort((a, b) => {
|
||||
if (a.needsAttention !== b.needsAttention) return a.needsAttention ? -1 : 1;
|
||||
if (b.severity !== a.severity) return b.severity - a.severity;
|
||||
return b.startedAt.getTime() - a.startedAt.getTime();
|
||||
});
|
||||
|
||||
// Only include resolved events whose trigger maps to an enabled host; optionally filter to outages only
|
||||
const resolvedOvernight: MorningSummaryResolved[] = rawResolved
|
||||
.filter(ev => triggerHostMap.has(ev.objectid))
|
||||
.filter(ev => !config.outages_only || ev.name.toLowerCase().includes('unavailable'))
|
||||
.map(ev => {
|
||||
const { hostName, clientName } = resolveClient(ev.objectid);
|
||||
const startedAt = new Date(parseInt(ev.clock, 10) * 1000);
|
||||
const resolvedAt = ev.r_clock ? new Date(parseInt(ev.r_clock, 10) * 1000) : now;
|
||||
const durationMs = resolvedAt.getTime() - startedAt.getTime();
|
||||
|
||||
return {
|
||||
eventid: ev.eventid,
|
||||
hostName,
|
||||
clientName,
|
||||
triggerName: ev.name,
|
||||
startedAt,
|
||||
resolvedAt,
|
||||
durationLabel: formatDuration(durationMs),
|
||||
};
|
||||
});
|
||||
|
||||
// Stats
|
||||
const mttrMinutes = resolvedOvernight.length > 0
|
||||
? Math.round(resolvedOvernight.reduce((sum, r) => {
|
||||
return sum + (r.resolvedAt.getTime() - r.startedAt.getTime()) / 60000;
|
||||
}, 0) / resolvedOvernight.length)
|
||||
: null;
|
||||
|
||||
const clientsAffected = [...new Set(openProblems.map(p => p.clientName))].sort();
|
||||
|
||||
const summary: MorningSummary = {
|
||||
generatedAt: now,
|
||||
windowFrom,
|
||||
windowTo: now,
|
||||
isWeekendWindow: isWeekend,
|
||||
openProblems,
|
||||
resolvedOvernight,
|
||||
openCount: openProblems.length,
|
||||
resolvedCount: resolvedOvernight.length,
|
||||
mttrMinutes,
|
||||
clientsAffected,
|
||||
};
|
||||
|
||||
const card = buildAdaptiveCard(summary);
|
||||
|
||||
// Persist
|
||||
await postgresClient.query(
|
||||
`INSERT INTO morning_summaries
|
||||
(generated_at, window_from, window_to, open_count, resolved_count, mttr_minutes,
|
||||
clients_affected, is_weekend_window, card_payload, delivery_status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
now, windowFrom, now,
|
||||
summary.openCount, summary.resolvedCount, summary.mttrMinutes,
|
||||
summary.clientsAffected, summary.isWeekendWindow,
|
||||
JSON.stringify(card), JSON.stringify({}),
|
||||
]
|
||||
);
|
||||
|
||||
return { summary, card };
|
||||
}
|
||||
|
||||
/**
|
||||
* Post an Adaptive Card to one or more webhook URLs.
|
||||
* Returns per-webhook results and updates DB delivery_status.
|
||||
*/
|
||||
async deliver(card: object, webhookIds?: number[]): Promise<DeliveryResult[]> {
|
||||
const all = await this.getWebhooks();
|
||||
const targets = webhookIds
|
||||
? all.filter(w => webhookIds.includes(w.id))
|
||||
: all.filter(w => w.enabled);
|
||||
|
||||
const envelope = {
|
||||
type: 'message',
|
||||
attachments: [{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
contentUrl: null,
|
||||
content: card,
|
||||
}],
|
||||
};
|
||||
|
||||
const results: DeliveryResult[] = await Promise.all(
|
||||
targets.map(async (webhook): Promise<DeliveryResult> => {
|
||||
try {
|
||||
const res = await fetch(webhook.webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(envelope),
|
||||
});
|
||||
|
||||
const success = res.ok;
|
||||
const httpStatus = res.status;
|
||||
|
||||
await postgresClient.query(
|
||||
`UPDATE morning_summary_webhooks
|
||||
SET last_delivered_at = NOW(), last_status = $1 WHERE id = $2`,
|
||||
[success ? 'success' : 'failed', webhook.id]
|
||||
);
|
||||
|
||||
return { webhookId: webhook.id, label: webhook.label, success, httpStatus };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
await postgresClient.query(
|
||||
`UPDATE morning_summary_webhooks SET last_delivered_at = NOW(), last_status = 'failed' WHERE id = $1`,
|
||||
[webhook.id]
|
||||
);
|
||||
return { webhookId: webhook.id, label: webhook.label, success: false, error };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Update delivery_status on the latest summary row
|
||||
const statusMap: Record<number, object> = {};
|
||||
for (const r of results) {
|
||||
statusMap[r.webhookId] = { success: r.success, httpStatus: r.httpStatus, error: r.error };
|
||||
}
|
||||
await postgresClient.query(
|
||||
`UPDATE morning_summaries SET delivery_status = $1
|
||||
WHERE id = (SELECT id FROM morning_summaries ORDER BY generated_at DESC LIMIT 1)`,
|
||||
[JSON.stringify(statusMap)]
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full run: aggregate + deliver. Used by scheduler and /send endpoint.
|
||||
*/
|
||||
async run(webhookIds?: number[]): Promise<{ summary: MorningSummary; results: DeliveryResult[] }> {
|
||||
const { summary, card } = await this.aggregate();
|
||||
const results = await this.deliver(card, webhookIds);
|
||||
console.log(`[MORNING-SUMMARY] Open: ${summary.openCount}, Resolved: ${summary.resolvedCount}, Webhooks: ${results.length}`);
|
||||
return { summary, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Test send: aggregate current data and send to a single webhook only.
|
||||
*/
|
||||
async testSend(webhookId: number): Promise<DeliveryResult> {
|
||||
const { card } = await this.aggregate();
|
||||
const results = await this.deliver(card, [webhookId]);
|
||||
return results[0] ?? { webhookId, label: '', success: false, error: 'Webhook not found' };
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: MorningSummaryService | null = null;
|
||||
export function getMorningSummaryService(): MorningSummaryService {
|
||||
if (!_instance) _instance = new MorningSummaryService();
|
||||
return _instance;
|
||||
}
|
||||
372
lib/services/msgraph-client.ts
Normal file
372
lib/services/msgraph-client.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
/**
|
||||
* Microsoft Graph API Client
|
||||
* Client credentials flow for application permissions
|
||||
* Reports.Read.All, User.Read.All, Calendars.Read
|
||||
*/
|
||||
|
||||
export interface GraphUser {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
mail: string | null;
|
||||
userPrincipalName: string | null;
|
||||
jobTitle: string | null;
|
||||
department: string | null;
|
||||
accountEnabled: boolean | null;
|
||||
}
|
||||
|
||||
export interface TeamsActivityRow {
|
||||
userPrincipalName: string;
|
||||
lastActivityDate: string;
|
||||
teamChatMessageCount: number;
|
||||
privateChatMessageCount: number;
|
||||
callCount: number;
|
||||
meetingCount: number;
|
||||
meetingsOrganizedCount: number;
|
||||
meetingsAttendedCount: number;
|
||||
audioDurationSeconds: number;
|
||||
}
|
||||
|
||||
export interface EmailActivityRow {
|
||||
userPrincipalName: string;
|
||||
lastActivityDate: string;
|
||||
sendCount: number;
|
||||
receiveCount: number;
|
||||
readCount: number;
|
||||
}
|
||||
|
||||
export interface UserMessage {
|
||||
createdDateTime: string;
|
||||
fromUserId: string | null;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
subject: string;
|
||||
start: { dateTime: string; timeZone: string };
|
||||
end: { dateTime: string; timeZone: string };
|
||||
attendees: Array<{
|
||||
emailAddress: { address: string; name: string };
|
||||
type: string;
|
||||
}>;
|
||||
isOnlineMeeting: boolean;
|
||||
isCancelled: boolean;
|
||||
}
|
||||
|
||||
export interface MsGraphClientConfig {
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export class MsGraphClient {
|
||||
private config: MsGraphClientConfig;
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
|
||||
constructor(config: MsGraphClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
const url = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
scope: 'https://graph.microsoft.com/.default',
|
||||
});
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Graph 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<T>(path: string, retryCount = 0): Promise<T> {
|
||||
const token = await this.getToken();
|
||||
const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Retry on 429 rate limit — respect Retry-After header (minimum 30s)
|
||||
if (res.status === 429 && retryCount < 4) {
|
||||
const retryAfter = Math.max(30, parseInt(res.headers.get('Retry-After') || '30', 10));
|
||||
await new Promise(r => setTimeout(r, retryAfter * 1000));
|
||||
return this.fetchJson(path, retryCount + 1);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Graph API error ${res.status} for ${path}: ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async fetchCsv(path: string): Promise<string> {
|
||||
const token = await this.getToken();
|
||||
const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'text/csv',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Graph API error ${res.status} for ${path}: ${text}`);
|
||||
}
|
||||
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSV — handles BOM prefix on first header column
|
||||
*/
|
||||
private parseCsv(csv: string): Record<string, string>[] {
|
||||
const lines = csv.split('\n').filter(l => l.trim());
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
// Strip BOM from first header if present
|
||||
const rawHeaders = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, '').replace(/^\uFEFF/, ''));
|
||||
const rows: Record<string, string>[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = lines[i].split(',').map(v => v.trim().replace(/^"|"$/g, ''));
|
||||
const row: Record<string, string> = {};
|
||||
rawHeaders.forEach((h, idx) => {
|
||||
row[h] = values[idx] ?? '';
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private parseInt0(v: string): number {
|
||||
const n = parseInt(v);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users in the tenant (paginated)
|
||||
*/
|
||||
async getUsers(): Promise<GraphUser[]> {
|
||||
const users: GraphUser[] = [];
|
||||
let url: string | null = '/users?$select=id,displayName,mail,userPrincipalName,jobTitle,department,accountEnabled&$top=999';
|
||||
|
||||
while (url) {
|
||||
const data: { value: GraphUser[]; '@odata.nextLink'?: string } = await this.fetchJson(url);
|
||||
users.push(...data.value);
|
||||
if (data['@odata.nextLink']) {
|
||||
url = data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '');
|
||||
} else {
|
||||
url = null;
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tenant's verified email domains (used to identify external attendees)
|
||||
*/
|
||||
async getOrganizationDomains(): Promise<string[]> {
|
||||
try {
|
||||
const data = await this.fetchJson<{
|
||||
value: Array<{ verifiedDomains: Array<{ name: string; isDefault: boolean }> }>;
|
||||
}>('/organization?$select=verifiedDomains');
|
||||
return (data.value[0]?.verifiedDomains ?? []).map(d => d.name.toLowerCase());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Teams user activity report — includes audio duration in seconds
|
||||
* period: 'D7' | 'D30' | 'D90'
|
||||
*/
|
||||
async getTeamsActivity(period: string): Promise<TeamsActivityRow[]> {
|
||||
const csv = await this.fetchCsv(`/reports/getTeamsUserActivityUserDetail(period='${period}')`);
|
||||
const rows = this.parseCsv(csv);
|
||||
|
||||
return rows
|
||||
.filter(r => r['User Principal Name'])
|
||||
.map(r => ({
|
||||
userPrincipalName: r['User Principal Name'] || '',
|
||||
lastActivityDate: r['Last Activity Date'] || '',
|
||||
teamChatMessageCount: this.parseInt0(r['Team Chat Message Count']),
|
||||
privateChatMessageCount: this.parseInt0(r['Private Chat Message Count']),
|
||||
callCount: this.parseInt0(r['Call Count']),
|
||||
meetingCount: this.parseInt0(r['Meeting Count']),
|
||||
meetingsOrganizedCount: this.parseInt0(r['Meetings Organized Count']),
|
||||
meetingsAttendedCount: this.parseInt0(r['Meetings Attended Count']),
|
||||
// "Audio Duration In Seconds" is the pre-computed seconds column
|
||||
audioDurationSeconds: this.parseInt0(r['Audio Duration In Seconds']),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Email activity report
|
||||
* period: 'D7' | 'D30' | 'D90'
|
||||
*/
|
||||
async getEmailActivity(period: string): Promise<EmailActivityRow[]> {
|
||||
const csv = await this.fetchCsv(`/reports/getEmailActivityUserDetail(period='${period}')`);
|
||||
const rows = this.parseCsv(csv);
|
||||
|
||||
return rows
|
||||
.filter(r => r['User Principal Name'])
|
||||
.map(r => ({
|
||||
userPrincipalName: r['User Principal Name'] || '',
|
||||
lastActivityDate: r['Last Activity Date'] || '',
|
||||
sendCount: this.parseInt0(r['Send Count']),
|
||||
receiveCount: this.parseInt0(r['Receive Count']),
|
||||
readCount: this.parseInt0(r['Read Count']),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages sent by a user in a date range, across all their chats.
|
||||
* Requires Chat.Read.All application permission.
|
||||
*
|
||||
* Strategy (based on Graph API docs):
|
||||
* 1. GET /users/{id}/chats with $expand=lastMessagePreview to find recently active chats.
|
||||
* Stop paging when the chat's last message preview is older than startDate.
|
||||
* 2. For each recent chat, GET /chats/{id}/messages with:
|
||||
* $filter=lastModifiedDateTime gt {start} and lastModifiedDateTime lt {end}
|
||||
* $orderby=lastModifiedDateTime desc (newest first)
|
||||
*
|
||||
* Returns only messages sent by this user (from.user.id match).
|
||||
*/
|
||||
async getUserMessages(
|
||||
userId: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<UserMessage[]> {
|
||||
const messages: UserMessage[] = [];
|
||||
const startIso = startDate.toISOString().slice(0, 19) + 'Z';
|
||||
const endIso = endDate.toISOString().slice(0, 19) + 'Z';
|
||||
const startMs = startDate.getTime();
|
||||
|
||||
// Step 1: get chats sorted by most recent activity, stop when too old
|
||||
const chatIds: string[] = [];
|
||||
let chatUrl: string | null =
|
||||
`/users/${encodeURIComponent(userId)}/chats` +
|
||||
`?$expand=lastMessagePreview&$orderby=lastMessagePreview/createdDateTime desc&$top=50`;
|
||||
|
||||
while (chatUrl && chatIds.length < 200) {
|
||||
const data: {
|
||||
value: Array<{ id: string; lastMessagePreview?: { createdDateTime?: string } }>;
|
||||
'@odata.nextLink'?: string;
|
||||
} = await this.fetchJson(chatUrl);
|
||||
|
||||
let hitOldChat = false;
|
||||
for (const chat of data.value) {
|
||||
const lastMsgTime = chat.lastMessagePreview?.createdDateTime
|
||||
? new Date(chat.lastMessagePreview.createdDateTime).getTime()
|
||||
: null;
|
||||
if (lastMsgTime !== null && lastMsgTime < startMs) {
|
||||
hitOldChat = true;
|
||||
break;
|
||||
}
|
||||
chatIds.push(chat.id);
|
||||
}
|
||||
|
||||
chatUrl = hitOldChat
|
||||
? null
|
||||
: data['@odata.nextLink']
|
||||
? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
|
||||
: null;
|
||||
}
|
||||
|
||||
// Step 2: for each recent chat, get messages in the date range
|
||||
for (const chatId of chatIds) {
|
||||
let msgUrl: string | null =
|
||||
`/chats/${encodeURIComponent(chatId)}/messages` +
|
||||
`?$filter=lastModifiedDateTime gt ${startIso} and lastModifiedDateTime lt ${endIso}` +
|
||||
`&$orderby=lastModifiedDateTime desc&$top=50`;
|
||||
|
||||
let pageCount = 0;
|
||||
try {
|
||||
while (msgUrl && pageCount < 20) {
|
||||
const data: {
|
||||
value: Array<{ createdDateTime: string; from?: { user?: { id: string } } }>;
|
||||
'@odata.nextLink'?: string;
|
||||
} = await this.fetchJson(msgUrl);
|
||||
|
||||
pageCount++;
|
||||
for (const msg of data.value) {
|
||||
if (msg.from?.user?.id !== userId) continue;
|
||||
messages.push({ createdDateTime: msg.createdDateTime, fromUserId: userId });
|
||||
}
|
||||
|
||||
msgUrl = data['@odata.nextLink']
|
||||
? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
|
||||
: null;
|
||||
}
|
||||
} catch {
|
||||
// Skip inaccessible chats (403 on meeting threads, 429 exhausted, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get calendar events for a user in a date range (paginated).
|
||||
* Returns empty array and logs if the mailbox is not Exchange Online (graceful degradation).
|
||||
*/
|
||||
async getUserCalendarEvents(
|
||||
userId: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<CalendarEvent[]> {
|
||||
const start = startDate.toISOString();
|
||||
const end = endDate.toISOString();
|
||||
const events: CalendarEvent[] = [];
|
||||
|
||||
let url: string | null =
|
||||
`/users/${encodeURIComponent(userId)}/calendarView` +
|
||||
`?startDateTime=${start}&endDateTime=${end}` +
|
||||
`&$select=id,subject,start,end,attendees,isOnlineMeeting,isCancelled` +
|
||||
`&$top=100`;
|
||||
|
||||
while (url) {
|
||||
try {
|
||||
const data: { value: CalendarEvent[]; '@odata.nextLink'?: string } =
|
||||
await this.fetchJson(url);
|
||||
events.push(...data.value.filter(e => !e.isCancelled));
|
||||
url = data['@odata.nextLink']
|
||||
? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
|
||||
: null;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Guests and on-prem mailboxes don't support REST API — skip silently
|
||||
if (msg.includes('MailboxNotEnabledForRESTAPI') || msg.includes('404')) {
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
}
|
||||
42
lib/services/msgraph-factory.ts
Normal file
42
lib/services/msgraph-factory.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { MsGraphClient, MsGraphClientConfig } from './msgraph-client';
|
||||
|
||||
let msGraphClientInstance: MsGraphClient | null = null;
|
||||
|
||||
/**
|
||||
* Check if Microsoft Graph credentials are configured
|
||||
*/
|
||||
export function isMsgraphConfigured(): boolean {
|
||||
return !!(
|
||||
process.env.MSGRAPH_CLIENT_ID &&
|
||||
process.env.MSGRAPH_CLIENT_SECRET &&
|
||||
process.env.MSGRAPH_TENANT_ID
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create Microsoft Graph client singleton
|
||||
*/
|
||||
export function getMsgraphClient(): MsGraphClient {
|
||||
if (!msGraphClientInstance) {
|
||||
const config: MsGraphClientConfig = {
|
||||
tenantId: process.env.MSGRAPH_TENANT_ID || '',
|
||||
clientId: process.env.MSGRAPH_CLIENT_ID || '',
|
||||
clientSecret: process.env.MSGRAPH_CLIENT_SECRET || '',
|
||||
};
|
||||
|
||||
if (!config.tenantId || !config.clientId || !config.clientSecret) {
|
||||
throw new Error(
|
||||
'Microsoft Graph credentials missing. Set MSGRAPH_CLIENT_ID, MSGRAPH_CLIENT_SECRET, and MSGRAPH_TENANT_ID.'
|
||||
);
|
||||
}
|
||||
|
||||
msGraphClientInstance = new MsGraphClient(config);
|
||||
console.log('[MSGRAPH] Client initialized');
|
||||
}
|
||||
|
||||
return msGraphClientInstance;
|
||||
}
|
||||
|
||||
export function resetMsgraphClient(): void {
|
||||
msGraphClientInstance = null;
|
||||
}
|
||||
|
|
@ -7,15 +7,21 @@ import cron, { ScheduledTask } from 'node-cron';
|
|||
import { SyncService, createSyncService } from './sync-service';
|
||||
import { postgresClient } from './postgres-client';
|
||||
import { AutotaskClient } from './autotask-client';
|
||||
import { EntityType, SyncType } from '../types/sync';
|
||||
import { VeeamSyncService } from './veeam-sync-service';
|
||||
import { VeeamRpoService } from './veeam-rpo-service';
|
||||
import { EngagementSyncService } from './engagement-sync-service';
|
||||
import { isMsgraphConfigured } from './msgraph-factory';
|
||||
import { ZoomSyncService } from './zoom-sync-service';
|
||||
import { isZoomConfigured } from './zoom-factory';
|
||||
import { MorningSummaryService } from './morning-summary-service';
|
||||
|
||||
export interface ScheduleConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
cron_expression: string;
|
||||
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check';
|
||||
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary';
|
||||
years_back?: number;
|
||||
is_enabled: boolean;
|
||||
last_run?: Date;
|
||||
|
|
@ -40,6 +46,8 @@ class SyncScheduler {
|
|||
private syncService: SyncService;
|
||||
private _veeamSyncService: VeeamSyncService | null = null;
|
||||
private _veeamRpoService: VeeamRpoService | null = null;
|
||||
private _engagementSyncService: EngagementSyncService | null = null;
|
||||
private _zoomSyncService: ZoomSyncService | null = null;
|
||||
|
||||
private getVeeamSyncService(): VeeamSyncService {
|
||||
if (!this._veeamSyncService) {
|
||||
|
|
@ -55,6 +63,28 @@ class SyncScheduler {
|
|||
return this._veeamRpoService;
|
||||
}
|
||||
|
||||
private getEngagementSyncService(): EngagementSyncService {
|
||||
if (!this._engagementSyncService) {
|
||||
this._engagementSyncService = new EngagementSyncService();
|
||||
}
|
||||
return this._engagementSyncService;
|
||||
}
|
||||
|
||||
private _morningSummaryService?: MorningSummaryService;
|
||||
private getMorningSummaryService(): MorningSummaryService {
|
||||
if (!this._morningSummaryService) {
|
||||
this._morningSummaryService = new MorningSummaryService();
|
||||
}
|
||||
return this._morningSummaryService;
|
||||
}
|
||||
|
||||
private getZoomSyncService(): ZoomSyncService {
|
||||
if (!this._zoomSyncService) {
|
||||
this._zoomSyncService = new ZoomSyncService();
|
||||
}
|
||||
return this._zoomSyncService;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Create sync service instance
|
||||
const autotaskClient = new AutotaskClient({
|
||||
|
|
@ -105,7 +135,7 @@ class SyncScheduler {
|
|||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
cron_expression VARCHAR(50) NOT NULL,
|
||||
sync_type VARCHAR(30) NOT NULL CHECK (sync_type IN ('incremental', 'full', 'veeam-incremental', 'veeam-full')),
|
||||
sync_type VARCHAR(30) NOT NULL,
|
||||
years_back INTEGER DEFAULT 2,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
last_run TIMESTAMP,
|
||||
|
|
@ -180,6 +210,38 @@ class SyncScheduler {
|
|||
sync_type: 'veeam-rpo-check',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'contract-services',
|
||||
name: 'Contract Services Sync',
|
||||
description: 'Syncs Autotask contract service lines (service catalog items per contract) daily at 4 AM',
|
||||
cron_expression: '0 4 * * *',
|
||||
sync_type: 'contract-services',
|
||||
is_enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'engagement-daily',
|
||||
name: 'Engagement Daily Sync',
|
||||
description: 'Syncs Microsoft Graph Teams and email activity for employee engagement dashboard daily at 6 AM',
|
||||
cron_expression: '0 6 * * *',
|
||||
sync_type: 'engagement-daily',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'zoom-daily',
|
||||
name: 'Zoom Daily Sync',
|
||||
description: 'Syncs Zoom Phone call logs and meeting data daily at 6 AM',
|
||||
cron_expression: '0 6 * * *',
|
||||
sync_type: 'zoom-daily',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'morning-summary',
|
||||
name: 'Morning NOC Summary',
|
||||
description: 'Posts a Zabbix overnight summary Adaptive Card to configured Teams channel webhooks at 6:30 AM Mon–Fri',
|
||||
cron_expression: '30 6 * * 1-5',
|
||||
sync_type: 'morning-summary',
|
||||
is_enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const schedule of defaultSchedules) {
|
||||
|
|
@ -289,6 +351,22 @@ class SyncScheduler {
|
|||
await this.getVeeamSyncService().fullSync('scheduled');
|
||||
} else if (config.sync_type === 'veeam-rpo-check') {
|
||||
await this.getVeeamRpoService().runCheck();
|
||||
} else if (config.sync_type === 'contract-services') {
|
||||
await this.syncService.syncEntities([EntityType.AUTOTASK_SERVICES, EntityType.CONTRACT_SERVICES], SyncType.ENTITY_SPECIFIC, 'scheduled');
|
||||
} else if (config.sync_type === 'engagement-daily') {
|
||||
if (isMsgraphConfigured()) {
|
||||
await this.getEngagementSyncService().sync();
|
||||
} else {
|
||||
console.log('[SCHEDULER] Skipping engagement sync — Microsoft Graph not configured');
|
||||
}
|
||||
} else if (config.sync_type === 'zoom-daily') {
|
||||
if (isZoomConfigured()) {
|
||||
await this.getZoomSyncService().sync();
|
||||
} else {
|
||||
console.log('[SCHEDULER] Skipping Zoom sync — Zoom credentials not configured');
|
||||
}
|
||||
} else if (config.sync_type === 'morning-summary') {
|
||||
await this.getMorningSummaryService().run();
|
||||
} else if (config.sync_type === 'incremental') {
|
||||
await this.syncService.incrementalSync('scheduled');
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import {
|
|||
ZabbixHostCreateParams,
|
||||
ZabbixHostUpdateParams,
|
||||
ZabbixRpcResponse,
|
||||
ZabbixProblem,
|
||||
ZabbixEvent,
|
||||
} from '@/lib/types/zabbix';
|
||||
|
||||
export type { ZabbixHostTag } from '@/lib/types/zabbix';
|
||||
|
|
@ -116,6 +118,119 @@ export class ZabbixClient {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all hosts with full detail: interfaces, tags, macros, groups.
|
||||
*/
|
||||
async getHosts(): Promise<ZabbixHost[]> {
|
||||
return this.rpc<ZabbixHost[]>('host.get', {
|
||||
output: 'extend',
|
||||
selectInterfaces: 'extend',
|
||||
selectTags: 'extend',
|
||||
selectMacros: 'extend',
|
||||
selectGroups: 'extend',
|
||||
selectParentTemplates: ['templateid', 'host', 'name'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one or more hosts by their hostids.
|
||||
*/
|
||||
async deleteHosts(hostids: string[]): Promise<void> {
|
||||
await this.rpc<{ hostids: string[] }>('host.delete', hostids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get open problems with hosts inline.
|
||||
* Only returns problems whose trigger is linked to at least one enabled host.
|
||||
* severities: 2=Warning,3=Average,4=High,5=Disaster
|
||||
*/
|
||||
async getOpenProblems(minSeverity = 2): Promise<ZabbixProblem[]> {
|
||||
return this.rpc<ZabbixProblem[]>('problem.get', {
|
||||
output: 'extend',
|
||||
selectAcknowledges: 'extend',
|
||||
selectSuppressionData: 'extend',
|
||||
severities: [2, 3, 4, 5].filter(s => s >= minSeverity),
|
||||
recent: true,
|
||||
sortfield: 'eventid',
|
||||
sortorder: 'DESC',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get problems that were resolved within the given window.
|
||||
* Uses problem.get with time_from/time_till (filters on problem clock) and
|
||||
* r_eventid IS SET (meaning the problem has a recovery event = resolved).
|
||||
* Returns problems only — r_clock is the resolution timestamp.
|
||||
*/
|
||||
async getResolvedEvents(from: Date, to: Date): Promise<ZabbixEvent[]> {
|
||||
const fromSec = Math.floor(from.getTime() / 1000);
|
||||
const toSec = Math.floor(to.getTime() / 1000);
|
||||
|
||||
// event.get with value:1 returns PROBLEM trigger events.
|
||||
// time_from/time_till filter on event creation (problem start) time.
|
||||
// We then keep only those that have an r_clock (= resolved) within the window.
|
||||
const events = await this.rpc<ZabbixEvent[]>('event.get', {
|
||||
output: 'extend',
|
||||
source: 0,
|
||||
object: 0,
|
||||
value: 1,
|
||||
time_from: fromSec,
|
||||
time_till: toSec,
|
||||
severities: [2, 3, 4, 5],
|
||||
sortfield: 'eventid',
|
||||
sortorder: 'DESC',
|
||||
limit: 500,
|
||||
});
|
||||
|
||||
return events.filter(e => e.r_eventid && e.r_eventid !== '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch group names for a specific list of hostids.
|
||||
* Returns a Map<hostid, groupName[]> for client name resolution.
|
||||
*/
|
||||
async getHostGroupMap(hostids: string[]): Promise<Map<string, string[]>> {
|
||||
if (hostids.length === 0) return new Map();
|
||||
const hosts = await this.rpc<Array<{
|
||||
hostid: string;
|
||||
groups: Array<{ groupid: string; name: string }>;
|
||||
}>>('host.get', {
|
||||
output: ['hostid'],
|
||||
hostids,
|
||||
selectGroups: ['groupid', 'name'],
|
||||
});
|
||||
|
||||
const map = new Map<string, string[]>();
|
||||
for (const h of hosts) {
|
||||
map.set(h.hostid, (h.groups ?? []).map((g: { name: string }) => g.name));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a list of triggerids, return a map of triggerid → { hostid, hostName }
|
||||
* filtered to only enabled hosts (status '0').
|
||||
* Triggerids with no enabled host are omitted from the map.
|
||||
*/
|
||||
async getTriggerEnabledHosts(triggerids: string[]): Promise<Map<string, { hostid: string; hostName: string }>> {
|
||||
if (triggerids.length === 0) return new Map();
|
||||
const triggers = await this.rpc<Array<{
|
||||
triggerid: string;
|
||||
hosts: Array<{ hostid: string; name: string; status: string }>;
|
||||
}>>('trigger.get', {
|
||||
output: ['triggerid'],
|
||||
triggerids,
|
||||
selectHosts: ['hostid', 'name', 'status'],
|
||||
});
|
||||
|
||||
const map = new Map<string, { hostid: string; hostName: string }>();
|
||||
for (const t of triggers) {
|
||||
const enabled = (t.hosts ?? []).find(h => h.status === '0');
|
||||
if (enabled) map.set(t.triggerid, { hostid: enabled.hostid, hostName: enabled.name });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a Zabbix host. Idempotent — looks up by host name first.
|
||||
* Returns the hostid and whether the host was created or updated.
|
||||
|
|
|
|||
206
lib/services/zabbix-wan-utils.ts
Normal file
206
lib/services/zabbix-wan-utils.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* Shared utilities for Zabbix WAN host management.
|
||||
* Used by both the RMM discovery sync and the manual host creation endpoints.
|
||||
*/
|
||||
|
||||
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||
import { ZabbixHostMacro, ZabbixHostTag, ZabbixHostCreateParams } from '@/lib/types/zabbix';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ISP info type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IspInfo {
|
||||
isp: string; // "Comcast Cable Communications, LLC"
|
||||
asn: string; // "AS7922"
|
||||
city: string;
|
||||
region: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zabbix host technical name sanitization
|
||||
// Zabbix rejects: + ' , . & ( ) and other special chars in the `host` field.
|
||||
// We sanitize to alphanumeric, spaces, hyphens, underscores only.
|
||||
// The display `name` field is left as-is (accepts any UTF-8).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function sanitizeHostname(name: string): string {
|
||||
return name
|
||||
.replace(/[^a-zA-Z0-9 \-_]/g, '') // strip disallowed chars
|
||||
.replace(/\s+/g, ' ') // collapse multiple spaces
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ISP lookup via ipinfo.io (free, no key required for basic fields)
|
||||
// Results are cached within the module lifetime to avoid duplicate lookups.
|
||||
// Call clearIspCache() at the start of each request if needed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ispCache = new Map<string, IspInfo | null>();
|
||||
|
||||
export function clearIspCache(): void {
|
||||
ispCache.clear();
|
||||
}
|
||||
|
||||
export async function lookupIsp(ip: string): Promise<IspInfo | null> {
|
||||
if (ispCache.has(ip)) return ispCache.get(ip)!;
|
||||
|
||||
try {
|
||||
const token = process.env.IPINFO_TOKEN;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`https://ipinfo.io/${ip}/json`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
if (!res.ok) { ispCache.set(ip, null); return null; }
|
||||
|
||||
const data = await res.json();
|
||||
// org field format: "AS7922 Comcast Cable Communications, LLC"
|
||||
const org: string = data.org ?? '';
|
||||
const m = org.match(/^(AS\d+)\s+(.+)$/);
|
||||
|
||||
const info: IspInfo = {
|
||||
isp: m ? m[2] : org,
|
||||
asn: m ? m[1] : '',
|
||||
city: data.city ?? '',
|
||||
region: data.region ?? '',
|
||||
country: data.country ?? '',
|
||||
};
|
||||
ispCache.set(ip, info);
|
||||
return info;
|
||||
} catch {
|
||||
ispCache.set(ip, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the full Zabbix host upsert params from common inputs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BuildHostParamsInput {
|
||||
siteName: string;
|
||||
wanIp: string;
|
||||
companyId?: number;
|
||||
companyName?: string;
|
||||
rmmSiteUid?: string;
|
||||
ispInfo: IspInfo | null;
|
||||
multiWan?: boolean;
|
||||
allIps?: string[];
|
||||
singleDeviceFallback?: boolean;
|
||||
onlineDeviceCount?: number;
|
||||
source?: string; // tag value for "source" (default "datto-rmm")
|
||||
icmpTemplateId: string | null;
|
||||
globalGroupId: string;
|
||||
zabbix: ZabbixClient;
|
||||
}
|
||||
|
||||
export async function buildHostParams(input: BuildHostParamsInput): Promise<ZabbixHostCreateParams> {
|
||||
const {
|
||||
siteName, wanIp, companyId, companyName, rmmSiteUid,
|
||||
ispInfo, multiWan, allIps, singleDeviceFallback,
|
||||
onlineDeviceCount, source, icmpTemplateId, globalGroupId, zabbix,
|
||||
} = input;
|
||||
|
||||
const templates = icmpTemplateId ? [{ templateid: icmpTemplateId }] : undefined;
|
||||
|
||||
// Build groups: always global, + per-client, + per-ISP
|
||||
const groups: Array<{ groupid: string }> = [{ groupid: globalGroupId }];
|
||||
|
||||
if (companyName) {
|
||||
const clientGroupId = await zabbix.ensureHostGroup(`Clients/${companyName}`);
|
||||
groups.push({ groupid: clientGroupId });
|
||||
}
|
||||
if (ispInfo?.isp) {
|
||||
const ispGroupId = await zabbix.ensureHostGroup(`ISP/${ispInfo.isp}`);
|
||||
groups.push({ groupid: ispGroupId });
|
||||
}
|
||||
|
||||
// Build macros: Autotask identity + ISP context
|
||||
const macros: ZabbixHostMacro[] = [];
|
||||
if (companyId && companyName) {
|
||||
macros.push(
|
||||
{ macro: '{$AUTOTASK_COMPANY_ID}', value: String(companyId), description: 'Autotask company ID' },
|
||||
{ macro: '{$AUTOTASK_COMPANY_NAME}', value: companyName, description: 'Autotask company name' },
|
||||
);
|
||||
}
|
||||
if (rmmSiteUid) {
|
||||
macros.push(
|
||||
{ macro: '{$RMM_SITE_UID}', value: rmmSiteUid, description: 'Datto RMM site UID' },
|
||||
);
|
||||
}
|
||||
if (ispInfo) {
|
||||
macros.push(
|
||||
{ macro: '{$ISP_NAME}', value: ispInfo.isp, description: 'ISP / carrier name' },
|
||||
{ macro: '{$ASN}', value: ispInfo.asn, description: 'Autonomous System Number' },
|
||||
{ macro: '{$ISP_CITY}', value: ispInfo.city, description: 'City (from IP geolocation)' },
|
||||
{ macro: '{$ISP_REGION}', value: ispInfo.region, description: 'Region (from IP geolocation)' },
|
||||
{ macro: '{$ISP_COUNTRY}', value: ispInfo.country, description: 'Country code (from IP geolocation)' },
|
||||
);
|
||||
}
|
||||
if (multiWan && allIps) {
|
||||
macros.push({ macro: '{$MULTI_WAN_IPS}', value: allIps.join(', '), description: 'All public IPs seen (multi-WAN site)' });
|
||||
}
|
||||
|
||||
// Build tags: for dashboard filtering and problem correlation
|
||||
const sourceTag = source ?? 'datto-rmm';
|
||||
const tags: ZabbixHostTag[] = [{ tag: 'source', value: sourceTag }];
|
||||
if (companyName) {
|
||||
tags.push({ tag: 'client', value: companyName });
|
||||
}
|
||||
if (ispInfo?.isp) {
|
||||
tags.push({ tag: 'isp', value: ispInfo.isp });
|
||||
}
|
||||
if (ispInfo?.asn) {
|
||||
tags.push({ tag: 'asn', value: ispInfo.asn });
|
||||
}
|
||||
if (multiWan) {
|
||||
tags.push({ tag: 'multi-wan', value: 'true' });
|
||||
}
|
||||
if (singleDeviceFallback) {
|
||||
tags.push({ tag: 'single-device-fallback', value: 'true' });
|
||||
}
|
||||
|
||||
const descParts = [
|
||||
onlineDeviceCount != null
|
||||
? `Datto RMM site – WAN IP from ${onlineDeviceCount} online devices`
|
||||
: `Manual host – WAN IP ${wanIp}`,
|
||||
ispInfo ? `ISP: ${ispInfo.isp} (${ispInfo.asn}) — ${ispInfo.city}, ${ispInfo.region}, ${ispInfo.country}` : null,
|
||||
multiWan && allIps ? `Multi-WAN detected: ${allIps.join(', ')}` : null,
|
||||
singleDeviceFallback ? `Note: IP sourced from single device (no multi-device confirmation)` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
return {
|
||||
host: sanitizeHostname(siteName),
|
||||
name: siteName,
|
||||
description: descParts,
|
||||
interfaces: [{ type: 1, main: 1, useip: 1, ip: wanIp, dns: '', port: '10050' }],
|
||||
groups,
|
||||
templates,
|
||||
macros: macros.length > 0 ? macros : undefined,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discover ICMP template — tries multiple common names
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ICMP_TEMPLATE_NAMES = [
|
||||
'ICMP Ping',
|
||||
'Template Module ICMP Ping',
|
||||
'Template Module ICMP Ping by Zabbix agent',
|
||||
];
|
||||
|
||||
export async function discoverIcmpTemplate(zabbix: ZabbixClient): Promise<string | null> {
|
||||
for (const name of ICMP_TEMPLATE_NAMES) {
|
||||
const tmpl = await zabbix.findTemplate(name);
|
||||
if (tmpl) return tmpl.templateid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
264
lib/services/zoom-client.ts
Normal file
264
lib/services/zoom-client.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
/**
|
||||
* 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<string> {
|
||||
// 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<T>(path: string, retries = 0): Promise<T> {
|
||||
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<T>(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<ZoomUser[]> {
|
||||
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<ZoomCallLog[]> {
|
||||
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<ZoomMeeting[]> {
|
||||
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<ZoomMeetingParticipant[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
42
lib/services/zoom-factory.ts
Normal file
42
lib/services/zoom-factory.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { ZoomClient, ZoomClientConfig } from './zoom-client';
|
||||
|
||||
let zoomClientInstance: ZoomClient | null = null;
|
||||
|
||||
/**
|
||||
* Check if Zoom Server-to-Server OAuth credentials are configured
|
||||
*/
|
||||
export function isZoomConfigured(): boolean {
|
||||
return !!(
|
||||
process.env.ZOOM_ACCOUNT_ID &&
|
||||
process.env.ZOOM_CLIENT_ID &&
|
||||
process.env.ZOOM_CLIENT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create Zoom client singleton
|
||||
*/
|
||||
export function getZoomClient(): ZoomClient {
|
||||
if (!zoomClientInstance) {
|
||||
const config: ZoomClientConfig = {
|
||||
accountId: process.env.ZOOM_ACCOUNT_ID || '',
|
||||
clientId: process.env.ZOOM_CLIENT_ID || '',
|
||||
clientSecret: process.env.ZOOM_CLIENT_SECRET || '',
|
||||
};
|
||||
|
||||
if (!config.accountId || !config.clientId || !config.clientSecret) {
|
||||
throw new Error(
|
||||
'Zoom credentials missing. Set ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, and ZOOM_CLIENT_SECRET.'
|
||||
);
|
||||
}
|
||||
|
||||
zoomClientInstance = new ZoomClient(config);
|
||||
console.log('[ZOOM] Client initialized');
|
||||
}
|
||||
|
||||
return zoomClientInstance;
|
||||
}
|
||||
|
||||
export function resetZoomClient(): void {
|
||||
zoomClientInstance = null;
|
||||
}
|
||||
387
lib/services/zoom-sync-service.ts
Normal file
387
lib/services/zoom-sync-service.ts
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -281,46 +281,33 @@ export interface AutotaskTimeEntry {
|
|||
resourceID: number;
|
||||
ticketID?: number;
|
||||
taskID?: number;
|
||||
projectID?: number;
|
||||
companyID?: number;
|
||||
dateWorked: string; // ISO date string - Autotask uses dateWorked
|
||||
hoursWorked: number; // Hours worked on this entry
|
||||
summaryNotes?: string; // Autotask uses summaryNotes not notes
|
||||
internalNotes?: string;
|
||||
title?: string;
|
||||
type?: number;
|
||||
startDateTime?: string; // ISO datetime string
|
||||
endDateTime?: string; // ISO datetime string
|
||||
billable?: boolean;
|
||||
billingRate?: number;
|
||||
billingRateCurrencyID?: number;
|
||||
costRate?: number;
|
||||
costRateCurrencyID?: number;
|
||||
cost?: number;
|
||||
costCurrencyID?: number;
|
||||
revenue?: number;
|
||||
revenueCurrencyID?: number;
|
||||
margin?: number;
|
||||
marginCurrencyID?: number;
|
||||
approved?: boolean;
|
||||
approvedByResourceID?: number;
|
||||
approvedDateTime?: string; // ISO datetime string
|
||||
nonBillable?: boolean;
|
||||
contractID?: number;
|
||||
contractServiceID?: number;
|
||||
contractServiceBundleID?: number;
|
||||
dateWorked: string; // ISO date string
|
||||
hoursWorked: number;
|
||||
hoursToBill?: number; // Read-only: actual hours that will be billed (may differ from hoursWorked due to contract caps)
|
||||
summaryNotes?: string;
|
||||
internalNotes?: string;
|
||||
isInternalNotesVisibleToComanaged?: boolean;
|
||||
timeEntryType?: number; // Read-only
|
||||
startDateTime?: string;
|
||||
endDateTime?: string;
|
||||
offsetHours?: number;
|
||||
isNonBillable?: boolean; // True if this entry is non-billable
|
||||
showOnInvoice?: boolean;
|
||||
billingCodeID?: number;
|
||||
internalBillingCodeID?: number;
|
||||
billingApprovalLevelMostRecent?: number; // Read-only
|
||||
billingApprovalResourceID?: number;
|
||||
billingApprovalDateTime?: string;
|
||||
roleID?: number;
|
||||
departmentID?: number;
|
||||
locationID?: number;
|
||||
allocationCodeID?: number;
|
||||
impProjectScheduleID?: number;
|
||||
impProjectScheduleTaskID?: number;
|
||||
apiVendorID?: number;
|
||||
createDate: string; // ISO datetime string
|
||||
lastModifiedDate?: string; // ISO datetime string
|
||||
userDefinedFields?: Array<{
|
||||
name: string;
|
||||
value: any;
|
||||
}>;
|
||||
createDateTime?: string;
|
||||
creatorUserID?: number;
|
||||
lastModifiedDateTime?: string;
|
||||
lastModifiedUserID?: number;
|
||||
impersonatorCreatorResourceID?: number;
|
||||
impersonatorUpdaterResourceID?: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export enum EntityType {
|
|||
CONFIGURATION_ITEMS = 'configuration_items',
|
||||
CONTACTS = 'contacts',
|
||||
CONTRACTS = 'contracts',
|
||||
CONTRACT_SERVICES = 'contract_services',
|
||||
AUTOTASK_SERVICES = 'autotask_services',
|
||||
TIME_ENTRIES = 'time_entries',
|
||||
TICKET_NOTES = 'ticket_notes',
|
||||
}
|
||||
|
|
@ -164,6 +166,8 @@ export const ENTITY_DEPENDENCIES: Record<EntityType, EntityType[]> = {
|
|||
[EntityType.TASKS]: [EntityType.RESOURCES, EntityType.PROJECTS, EntityType.TICKETS], // Depends on resources, projects, tickets
|
||||
[EntityType.CONFIGURATION_ITEMS]: [EntityType.COMPANIES, EntityType.CONTACTS], // Depends on companies and contacts
|
||||
[EntityType.CONTRACTS]: [EntityType.COMPANIES, EntityType.CONTACTS], // Depends on companies and contacts
|
||||
[EntityType.CONTRACT_SERVICES]: [EntityType.CONTRACTS], // Depends on contracts
|
||||
[EntityType.AUTOTASK_SERVICES]: [], // No dependencies — standalone lookup
|
||||
[EntityType.BILLING_ITEMS]: [EntityType.COMPANIES, EntityType.TASKS, EntityType.TICKETS, EntityType.PROJECTS], // Depends on multiple entities
|
||||
[EntityType.TIME_ENTRIES]: [EntityType.COMPANIES, EntityType.RESOURCES, EntityType.CONTACTS, EntityType.PROJECTS, EntityType.TASKS, EntityType.TICKETS], // Depends on many entities
|
||||
[EntityType.TICKET_NOTES]: [EntityType.TICKETS], // Depends on tickets
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ export interface ZabbixHost {
|
|||
host: string;
|
||||
name: string;
|
||||
status: string;
|
||||
description?: string;
|
||||
interfaces?: ZabbixHostInterface[];
|
||||
groups?: ZabbixHostGroup[];
|
||||
templates?: ZabbixTemplate[];
|
||||
description?: string;
|
||||
parentTemplates?: ZabbixTemplate[];
|
||||
macros?: ZabbixHostMacro[];
|
||||
tags?: ZabbixHostTag[];
|
||||
}
|
||||
|
||||
export interface ZabbixHostInterface {
|
||||
|
|
@ -69,6 +72,30 @@ export interface ZabbixHostUpdateParams {
|
|||
tags?: ZabbixHostTag[];
|
||||
}
|
||||
|
||||
export interface ZabbixProblem {
|
||||
eventid: string;
|
||||
objectid: string;
|
||||
name: string;
|
||||
severity: string;
|
||||
clock: string;
|
||||
acknowledged: string;
|
||||
acknowledges?: Array<{ acknowledgeid: string; userid: string; clock: string; message: string }>;
|
||||
suppressed?: string;
|
||||
r_eventid?: string;
|
||||
hosts?: Array<{ hostid: string; name: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface ZabbixEvent {
|
||||
eventid: string;
|
||||
objectid: string;
|
||||
name: string;
|
||||
severity: string;
|
||||
clock: string;
|
||||
r_eventid?: string;
|
||||
r_clock?: string;
|
||||
hosts?: Array<{ hostid: string; name: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface ZabbixRpcResponse<T> {
|
||||
jsonrpc: string;
|
||||
result?: T;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ export function mapAutotaskToDatabase(
|
|||
case EntityType.CONTRACTS:
|
||||
mapped = mapContract(data);
|
||||
break;
|
||||
case EntityType.CONTRACT_SERVICES:
|
||||
mapped = mapContractService(data);
|
||||
break;
|
||||
case EntityType.AUTOTASK_SERVICES:
|
||||
mapped = mapAutotaskService(data);
|
||||
break;
|
||||
case EntityType.BILLING_ITEMS:
|
||||
mapped = mapBillingItem(data);
|
||||
break;
|
||||
|
|
@ -518,6 +524,41 @@ function mapContract(data: any): Record<string, any> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Autotask Service (catalog item) entity
|
||||
*/
|
||||
function mapAutotaskService(data: any): Record<string, any> {
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
unit_price: data.unitPrice,
|
||||
unit_cost: data.unitCost,
|
||||
period_type: data.periodType,
|
||||
is_active: data.isActive !== undefined ? data.isActive : true,
|
||||
synced_at: new Date(),
|
||||
is_deleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Contract Service entity
|
||||
*/
|
||||
function mapContractService(data: any): Record<string, any> {
|
||||
return {
|
||||
id: data.id,
|
||||
contract_id: data.contractID,
|
||||
service_id: data.serviceID,
|
||||
unit_price: data.unitPrice,
|
||||
unit_cost: data.unitCost,
|
||||
adjusted_price: data.internalCurrencyAdjustedPrice,
|
||||
invoice_description: data.invoiceDescription,
|
||||
internal_currency_price: data.internalCurrencyUnitPrice,
|
||||
synced_at: new Date(),
|
||||
is_deleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Billing Item entity
|
||||
*/
|
||||
|
|
@ -558,45 +599,32 @@ function mapBillingItem(data: any): Record<string, any> {
|
|||
* Map Time Entry entity
|
||||
*/
|
||||
function mapTimeEntry(data: any): Record<string, any> {
|
||||
// Autotask uses isNonBillable; derive billable from it
|
||||
const isNonBillable = data.isNonBillable;
|
||||
const billable = isNonBillable != null ? !isNonBillable : null;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
resource_id: data.resourceID,
|
||||
ticket_id: data.ticketID,
|
||||
task_id: data.taskID,
|
||||
project_id: data.projectID,
|
||||
company_id: data.companyID,
|
||||
entry_date: data.dateWorked, // Autotask API returns dateWorked
|
||||
hours_worked: data.hoursWorked, // Autotask API returns hoursWorked
|
||||
notes: data.summaryNotes, // Autotask uses summaryNotes
|
||||
internal_notes: data.internalNotes,
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
start_date_time: data.startDateTime,
|
||||
end_date_time: data.endDateTime,
|
||||
billable: data.billable, // Autotask returns billable
|
||||
billing_rate: data.billingRate,
|
||||
billing_rate_currency_id: data.billingRateCurrencyID,
|
||||
cost_rate: data.costRate,
|
||||
cost_rate_currency_id: data.costRateCurrencyID,
|
||||
cost: data.cost,
|
||||
cost_currency_id: data.costCurrencyID,
|
||||
revenue: data.revenue,
|
||||
revenue_currency_id: data.revenueCurrencyID,
|
||||
margin: data.margin,
|
||||
margin_currency_id: data.marginCurrencyID,
|
||||
approved: data.approved,
|
||||
approved_by_resource_id: data.approvedByResourceID,
|
||||
approved_date_time: data.approvedDateTime,
|
||||
non_billable: data.nonBillable,
|
||||
contract_id: data.contractID,
|
||||
contract_service_id: data.contractServiceID,
|
||||
contract_service_bundle_id: data.contractServiceBundleID,
|
||||
entry_date: data.dateWorked,
|
||||
hours_worked: data.hoursWorked,
|
||||
hours_to_bill: data.hoursToBill,
|
||||
notes: data.summaryNotes,
|
||||
internal_notes: data.internalNotes,
|
||||
type: data.timeEntryType,
|
||||
start_date_time: data.startDateTime,
|
||||
end_date_time: data.endDateTime,
|
||||
billable,
|
||||
non_billable: isNonBillable,
|
||||
allocation_code_id: data.billingCodeID,
|
||||
approved_by_resource_id: data.billingApprovalResourceID,
|
||||
approved_date_time: data.billingApprovalDateTime,
|
||||
role_id: data.roleID,
|
||||
department_id: data.departmentID,
|
||||
location_id: data.locationID,
|
||||
allocation_code_id: data.allocationCodeID,
|
||||
imp_project_schedule_id: data.impProjectScheduleID,
|
||||
imp_project_schedule_task_id: data.impProjectScheduleTaskID,
|
||||
api_vendor_id: data.apiVendorID,
|
||||
synced_at: new Date(),
|
||||
is_deleted: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -65,11 +65,27 @@ export function getAllEntitiesInOrder(): EntityType[] {
|
|||
EntityType.TASKS,
|
||||
EntityType.CONFIGURATION_ITEMS,
|
||||
EntityType.CONTRACTS,
|
||||
EntityType.CONTRACT_SERVICES,
|
||||
EntityType.AUTOTASK_SERVICES,
|
||||
EntityType.BILLING_ITEMS,
|
||||
EntityType.TIME_ENTRIES,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build filter for contract services (requires contractID filter — fetch all via contractIDs)
|
||||
* @returns Query filter array for active contract services
|
||||
*/
|
||||
export function buildContractServicesFilter(): Array<{ field: string; op: string; value: any }> {
|
||||
return [
|
||||
{
|
||||
field: 'contractID',
|
||||
op: 'gt',
|
||||
value: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table name for entity type
|
||||
* @param entity Entity type
|
||||
|
|
@ -102,6 +118,8 @@ export function getAutotaskEntityName(entity: EntityType): string {
|
|||
[EntityType.CONFIGURATION_ITEMS]: 'ConfigurationItems',
|
||||
[EntityType.CONTACTS]: 'Contacts',
|
||||
[EntityType.CONTRACTS]: 'Contracts',
|
||||
[EntityType.CONTRACT_SERVICES]: 'ContractServices',
|
||||
[EntityType.AUTOTASK_SERVICES]: 'Services',
|
||||
[EntityType.TIME_ENTRIES]: 'TimeEntries',
|
||||
[EntityType.TICKET_NOTES]: 'TicketNotes',
|
||||
};
|
||||
|
|
@ -141,6 +159,8 @@ export function getLastModifiedField(entity: EntityType): string {
|
|||
[EntityType.CONFIGURATION_ITEMS]: 'lastModifiedTime',
|
||||
[EntityType.CONTACTS]: 'lastModifiedDate',
|
||||
[EntityType.CONTRACTS]: 'lastModifiedDateTime',
|
||||
[EntityType.CONTRACT_SERVICES]: 'lastModifiedDate',
|
||||
[EntityType.AUTOTASK_SERVICES]: 'lastModifiedDate',
|
||||
[EntityType.BILLING_ITEMS]: 'itemDate',
|
||||
[EntityType.TIME_ENTRIES]: 'dateWorked',
|
||||
[EntityType.TICKET_NOTES]: 'lastActivityDate',
|
||||
|
|
@ -171,6 +191,8 @@ export function getActiveField(entity: EntityType): string | null {
|
|||
[EntityType.CONFIGURATION_ITEMS]: 'isActive',
|
||||
[EntityType.CONTACTS]: 'isActive',
|
||||
[EntityType.CONTRACTS]: null, // Use status field instead
|
||||
[EntityType.CONTRACT_SERVICES]: null,
|
||||
[EntityType.AUTOTASK_SERVICES]: 'isActive',
|
||||
[EntityType.BILLING_ITEMS]: null,
|
||||
[EntityType.TIME_ENTRIES]: null, // Time entries don't have active status
|
||||
[EntityType.TICKET_NOTES]: null, // Ticket notes don't have active status
|
||||
|
|
@ -258,7 +280,9 @@ export function buildDateRangeFilter(
|
|||
[EntityType.TIME_ENTRIES]: 'createDate', // TimeEntry uses createDate for filtering
|
||||
[EntityType.PROJECTS]: 'startDateTime',
|
||||
[EntityType.BILLING_ITEMS]: 'itemDate',
|
||||
[EntityType.CONTRACTS]: 'startDate', // Contracts use startDate
|
||||
[EntityType.CONTRACTS]: 'startDate',
|
||||
[EntityType.CONTRACT_SERVICES]: null,
|
||||
[EntityType.AUTOTASK_SERVICES]: null,
|
||||
[EntityType.COMPANIES]: null,
|
||||
[EntityType.RESOURCES]: null,
|
||||
[EntityType.CONTACTS]: null,
|
||||
|
|
@ -456,6 +480,8 @@ export function getEntityDisplayName(entity: EntityType): string {
|
|||
[EntityType.CONFIGURATION_ITEMS]: 'Configuration Items',
|
||||
[EntityType.CONTACTS]: 'Contacts',
|
||||
[EntityType.CONTRACTS]: 'Contracts',
|
||||
[EntityType.CONTRACT_SERVICES]: 'Contract Services',
|
||||
[EntityType.AUTOTASK_SERVICES]: 'Autotask Services',
|
||||
[EntityType.TIME_ENTRIES]: 'Time Entries',
|
||||
[EntityType.TICKET_NOTES]: 'Ticket Notes',
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue