- 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
424 lines
18 KiB
TypeScript
424 lines
18 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|