wulf-pulse/lib/services/engagement-sync-service.ts

425 lines
18 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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;
}