wulf-pulse/app/api/engagement/backfill-meetings/route.ts
lorentz c518eefdb2 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
2026-03-11 09:34:51 -04:00

186 lines
7.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { getMsgraphClient } from '@/lib/services/msgraph-factory';
import { postgresClient } from '@/lib/services/postgres-client';
let backfillInProgress = false;
let backfillStatus: {
running: boolean;
started: string | null;
processed: number;
total: number;
currentUser: string | null;
errors: number;
done: boolean;
log: string[];
} = { running: false, started: null, processed: 0, total: 0, currentUser: null, errors: 0, done: false, log: [] };
export async function GET() {
return NextResponse.json(backfillStatus);
}
export async function POST(request: NextRequest) {
if (backfillInProgress) {
return NextResponse.json({ error: 'Backfill already running' }, { status: 409 });
}
const body = await request.json().catch(() => ({}));
const monthsBack = Math.min(Number(body.monthsBack ?? 12), 24);
backfillInProgress = true;
backfillStatus = {
running: true,
started: new Date().toISOString(),
processed: 0,
total: 0,
currentUser: null,
errors: 0,
done: false,
log: [],
};
// Run async — don't await
runBackfill(monthsBack).finally(() => {
backfillInProgress = false;
});
return NextResponse.json({ started: true, monthsBack });
}
async function runBackfill(monthsBack: number) {
const log = (msg: string) => {
console.log(`[MEETING-BACKFILL] ${msg}`);
backfillStatus.log.push(msg);
if (backfillStatus.log.length > 200) backfillStatus.log.shift();
};
try {
const client = getMsgraphClient();
// Fetch internal domains for attendee classification
const orgDomains = await client.getOrganizationDomains();
const internalDomains = new Set(orgDomains);
log(`Internal domains: ${[...internalDomains].join(', ')}`);
// Build contact email index for client 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 });
}
}
}
log(`Contact index: ${contactEmailIndex.size} emails`);
// Get all active graph users
const usersResult = await postgresClient.query(
`SELECT id, email, display_name FROM graph_users WHERE account_enabled = true ORDER BY display_name`
);
const users = usersResult.rows;
backfillStatus.total = users.length;
log(`Users to backfill: ${users.length}, going back ${monthsBack} months`);
const now = new Date();
// Build date range: from (monthsBack months ago, start of month) to 91 days ago
// (avoid re-syncing data already covered by the regular 90-day sync)
const backfillEnd = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
const backfillStart = new Date(now.getFullYear(), now.getMonth() - monthsBack, 1);
log(`Date range: ${backfillStart.toISOString().slice(0, 10)}${backfillEnd.toISOString().slice(0, 10)}`);
for (const user of users) {
backfillStatus.currentUser = user.display_name;
try {
const events = await client.getUserCalendarEvents(user.id, backfillStart, backfillEnd);
let inserted = 0;
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 attendeeCount = event.attendees.length;
const externalAttendees = event.attendees.filter(a => {
const aEmail = (a.emailAddress?.address ?? '').toLowerCase();
if (aEmail === user.email.toLowerCase()) return false;
const domain = aEmail.split('@')[1];
return domain && !internalDomains.has(domain);
});
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, user.email, event.subject, startTime, endTime,
durationMinutes, event.isOnlineMeeting, attendeeCount]
);
const meetingId = meetingResult.rows[0]?.id;
if (!meetingId) continue;
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]
);
inserted++;
} catch (evErr) {
const msg = evErr instanceof Error ? evErr.message : String(evErr);
log(` Event error ${event.id}: ${msg}`);
}
}
log(`${user.display_name}: ${events.length} events, ${inserted} upserted`);
} catch (userErr) {
const msg = userErr instanceof Error ? userErr.message : String(userErr);
log(`${user.display_name}: SKIP — ${msg}`);
backfillStatus.errors++;
}
backfillStatus.processed++;
}
log(`Done. ${backfillStatus.processed} users, ${backfillStatus.errors} errors.`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log(`FATAL: ${msg}`);
} finally {
backfillStatus.running = false;
backfillStatus.done = true;
backfillStatus.currentUser = null;
}
}