- 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
151 lines
5.9 KiB
TypeScript
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 });
|
|
}
|
|
}
|