425 lines
18 KiB
TypeScript
425 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;
|
|||
|
|
}
|