- 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
186 lines
7.2 KiB
TypeScript
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;
|
|
}
|
|
}
|