wulf-pulse/app/api/engagement/summary/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

151 lines
5.9 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
import { isMsgraphConfigured } from '@/lib/services/msgraph-factory';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const period = searchParams.get('period') || 'D30';
try {
// Get latest snapshot date for this period
const latestResult = await postgresClient.query(
`SELECT MAX(period_end) as latest_date, MAX(synced_at) as synced_at
FROM engagement_snapshots WHERE period_type = $1`,
[period]
);
const latestDate = latestResult.rows[0]?.latest_date;
const lastSynced = latestResult.rows[0]?.synced_at;
if (!latestDate) {
return NextResponse.json({
totalStaff: 0,
activeThisPeriod: 0,
avgHoursWorked: 0,
avgBillableHours: 0,
avgTeamsMeetings: 0,
avgEmailsSent: 0,
lastSynced: null,
configured: isMsgraphConfigured(),
});
}
// Interval map
const intervalMap: Record<string, string> = {
D7: '7 days',
D30: '30 days',
D90: '90 days',
};
const interval = intervalMap[period] || '30 days';
// Exclude service/automation accounts: those with a snapshot showing zero inbound
// across all channels (pure outbound senders like Autotask relay accounts)
const notAutomatedFilter = `NOT (
es.user_email IS NOT NULL
AND COALESCE(es.emails_received, 0) = 0
AND COALESCE(es.teams_chat_messages, 0) = 0
AND COALESCE(es.teams_meetings_attended, 0) = 0
AND COALESCE(es.teams_calls, 0) = 0
)`;
// Staff count: human accounts (exclude pure-outbound service accounts)
const staffResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM graph_users gu
JOIN (
SELECT DISTINCT ON (LOWER(email)) id, email
FROM resources
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
ORDER BY LOWER(email), id
) r ON LOWER(r.email) = LOWER(gu.email)
LEFT JOIN engagement_snapshots es
ON LOWER(es.user_email) = LOWER(gu.email)
AND es.period_type = $1 AND es.period_end = $2
WHERE gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
AND LOWER(gu.email) NOT LIKE '%#ext#%'
AND ${notAutomatedFilter}`,
[period, latestDate]
);
const totalStaff = parseInt(staffResult.rows[0]?.count ?? '0');
// Active users (had any Teams or email activity)
const activeResult = await postgresClient.query(
`SELECT COUNT(DISTINCT es.user_email) as count
FROM engagement_snapshots es
JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email)
JOIN (
SELECT DISTINCT ON (LOWER(email)) id, email
FROM resources
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
ORDER BY LOWER(email), id
) r ON LOWER(r.email) = LOWER(gu.email)
WHERE es.period_type = $1 AND es.period_end = $2
AND (es.teams_meetings_attended > 0 OR es.teams_chat_messages > 0 OR es.emails_sent > 0)
AND gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
AND LOWER(gu.email) NOT LIKE '%#ext#%'
AND ${notAutomatedFilter}`,
[period, latestDate]
);
const activeThisPeriod = parseInt(activeResult.rows[0]?.count ?? '0');
// Avg Teams meetings and emails (excluding automated senders)
const avgResult = await postgresClient.query(
`SELECT
AVG(es.teams_meetings_attended) as avg_meetings,
AVG(es.emails_sent) as avg_emails_sent
FROM engagement_snapshots es
JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email)
JOIN (
SELECT DISTINCT ON (LOWER(email)) id, email
FROM resources
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
ORDER BY LOWER(email), id
) r ON LOWER(r.email) = LOWER(gu.email)
WHERE es.period_type = $1 AND es.period_end = $2
AND gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
AND LOWER(gu.email) NOT LIKE '%#ext#%'
AND ${notAutomatedFilter}`,
[period, latestDate]
);
// Avg hours from Autotask time entries joined via resources
const hoursResult = await postgresClient.query(
`SELECT
AVG(resource_hours.total_hours) as avg_hours,
AVG(resource_hours.billable_hours) as avg_billable
FROM (
SELECT
r.email,
COALESCE(SUM(te.hours_worked), 0) as total_hours,
COALESCE(SUM(CASE WHEN COALESCE(te.billable, true) = true THEN te.hours_worked ELSE 0 END), 0) as billable_hours
FROM graph_users gu
JOIN resources r ON LOWER(r.email) = LOWER(gu.email)
AND (r.is_deleted = false OR r.is_deleted IS NULL)
LEFT JOIN time_entries te ON te.resource_id = r.id
AND te.entry_date >= NOW() - INTERVAL '${interval}'
AND (te.is_deleted = false OR te.is_deleted IS NULL)
WHERE gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
AND LOWER(gu.email) NOT LIKE '%#ext#%'
GROUP BY r.email
) resource_hours`
);
return NextResponse.json({
totalStaff,
activeThisPeriod,
avgHoursWorked: parseFloat(hoursResult.rows[0]?.avg_hours ?? '0').toFixed(1),
avgBillableHours: parseFloat(hoursResult.rows[0]?.avg_billable ?? '0').toFixed(1),
avgTeamsMeetings: parseFloat(avgResult.rows[0]?.avg_meetings ?? '0').toFixed(1),
avgEmailsSent: parseFloat(avgResult.rows[0]?.avg_emails_sent ?? '0').toFixed(0),
lastSynced,
configured: isMsgraphConfigured(),
});
} catch (error) {
console.error('[ENGAGEMENT-SUMMARY] Error:', error);
return NextResponse.json({ error: 'Failed to fetch engagement summary' }, { status: 500 });
}
}