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:
lorentz 2026-03-11 09:34:51 -04:00
parent 19605f82aa
commit c518eefdb2
61 changed files with 11236 additions and 237 deletions

View 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;
}

View file

@ -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

View 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;
}

View 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;
}
}

View 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;
}

View file

@ -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 MonFri',
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 {

View file

@ -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.

View 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
View 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;
}
}

View 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;
}

View 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;
}

View file

@ -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> {

View file

@ -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

View file

@ -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;

View file

@ -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,
};

View file

@ -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',
};