wulf-pulse/app/api/engagement/user/[userId]/history/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

227 lines
7.9 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
import { isZoomConfigured } from '@/lib/services/zoom-factory';
interface MonthData {
month: string;
hoursWorked: number;
billableHours: number;
daysWorked: number;
teamsMessages: number;
teamsPrivateMessages: number;
teamsCalls: number;
meetingsAttended: number;
meetingsOrganized: number;
emailsSent: number;
emailsReceived: number;
totalMeetings: number;
clientMeetings: number;
meetingDurationMinutes: number;
zoomCalls: number;
zoomClientCalls: number;
}
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ userId: string }> }
) {
const { userId } = await params;
try {
const userResult = await postgresClient.query(
`SELECT gu.*,
(SELECT r2.id FROM resources r2
WHERE LOWER(r2.email) = LOWER(gu.email)
AND (r2.is_deleted = false OR r2.is_deleted IS NULL)
ORDER BY (SELECT MAX(te.entry_date) FROM time_entries te WHERE te.resource_id = r2.id AND (te.is_deleted = false OR te.is_deleted IS NULL)) DESC NULLS LAST
LIMIT 1) as autotask_resource_id
FROM graph_users gu
WHERE gu.id = $1`,
[userId]
);
if (userResult.rows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const user = userResult.rows[0];
// Daily time entries for the past 365 days
const dailyResult = user.autotask_resource_id
? await postgresClient.query(
`SELECT
TO_CHAR(entry_date, 'YYYY-MM-DD') as date,
SUM(hours_worked) as hours_worked,
SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_hours
FROM time_entries
WHERE resource_id = $1
AND (is_deleted = false OR is_deleted IS NULL)
AND entry_date >= NOW() - INTERVAL '365 days'
GROUP BY TO_CHAR(entry_date, 'YYYY-MM-DD')
ORDER BY date`,
[user.autotask_resource_id]
)
: null;
// Monthly time entries for the past 12 months
const monthlyHoursResult = user.autotask_resource_id
? await postgresClient.query(
`SELECT
TO_CHAR(DATE_TRUNC('month', entry_date), 'YYYY-MM') as month,
SUM(hours_worked) as hours_worked,
SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_hours,
COUNT(DISTINCT TO_CHAR(entry_date, 'YYYY-MM-DD')) as days_worked
FROM time_entries
WHERE resource_id = $1
AND (is_deleted = false OR is_deleted IS NULL)
AND entry_date >= DATE_TRUNC('month', NOW() - INTERVAL '11 months')
GROUP BY DATE_TRUNC('month', entry_date)
ORDER BY month`,
[user.autotask_resource_id]
)
: null;
// Monthly engagement snapshots — latest D30 per calendar month
const monthlySnapshotsResult = await postgresClient.query(
`SELECT DISTINCT ON (TO_CHAR(period_end, 'YYYY-MM'))
TO_CHAR(period_end, 'YYYY-MM') as month,
teams_chat_messages,
teams_private_messages,
teams_calls,
teams_meetings_attended,
teams_meetings_organized,
emails_sent,
emails_received
FROM engagement_snapshots
WHERE LOWER(user_email) = LOWER($1)
AND period_type = 'D30'
AND period_end >= NOW() - INTERVAL '13 months'
ORDER BY TO_CHAR(period_end, 'YYYY-MM'), period_end DESC`,
[user.email]
);
// Monthly Teams meetings
let monthlyMeetingsResult = null;
try {
monthlyMeetingsResult = await postgresClient.query(
`SELECT
TO_CHAR(DATE_TRUNC('month', start_time), 'YYYY-MM') as month,
COUNT(*) as total_meetings,
SUM(CASE WHEN has_client_attendees THEN 1 ELSE 0 END) as client_meetings,
SUM(COALESCE(duration_minutes, 0)) as total_duration_minutes
FROM teams_meetings
WHERE LOWER(user_email) = LOWER($1)
AND start_time >= DATE_TRUNC('month', NOW() - INTERVAL '11 months')
GROUP BY DATE_TRUNC('month', start_time)
ORDER BY month`,
[user.email]
);
} catch { /* table may not exist */ }
// Monthly Zoom calls
let monthlyZoomResult = null;
if (isZoomConfigured()) {
try {
monthlyZoomResult = await postgresClient.query(
`SELECT
TO_CHAR(DATE_TRUNC('month', start_time), 'YYYY-MM') as month,
COUNT(*) as call_count,
SUM(CASE WHEN matched_company_id IS NOT NULL THEN 1 ELSE 0 END) as client_calls
FROM zoom_calls
WHERE LOWER(resource_email) = LOWER($1)
AND call_status = 'completed'
AND COALESCE(duration_seconds, 0) > 0
AND start_time >= DATE_TRUNC('month', NOW() - INTERVAL '11 months')
GROUP BY DATE_TRUNC('month', start_time)
ORDER BY month`,
[user.email]
);
} catch { /* table may not exist */ }
}
// Build a complete 12-month map
const now = new Date();
const monthMap = new Map<string, MonthData>();
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
monthMap.set(key, {
month: key,
hoursWorked: 0,
billableHours: 0,
daysWorked: 0,
teamsMessages: 0,
teamsPrivateMessages: 0,
teamsCalls: 0,
meetingsAttended: 0,
meetingsOrganized: 0,
emailsSent: 0,
emailsReceived: 0,
totalMeetings: 0,
clientMeetings: 0,
meetingDurationMinutes: 0,
zoomCalls: 0,
zoomClientCalls: 0,
});
}
for (const row of monthlyHoursResult?.rows ?? []) {
const m = monthMap.get(row.month);
if (m) {
m.hoursWorked = parseFloat(row.hours_worked ?? 0);
m.billableHours = parseFloat(row.billable_hours ?? 0);
m.daysWorked = parseInt(row.days_worked ?? 0);
}
}
for (const row of monthlySnapshotsResult.rows) {
const m = monthMap.get(row.month);
if (m) {
m.teamsMessages = parseInt(row.teams_chat_messages ?? 0);
m.teamsPrivateMessages = parseInt(row.teams_private_messages ?? 0);
m.teamsCalls = parseInt(row.teams_calls ?? 0);
m.meetingsAttended = parseInt(row.teams_meetings_attended ?? 0);
m.meetingsOrganized = parseInt(row.teams_meetings_organized ?? 0);
m.emailsSent = parseInt(row.emails_sent ?? 0);
m.emailsReceived = parseInt(row.emails_received ?? 0);
}
}
for (const row of monthlyMeetingsResult?.rows ?? []) {
const m = monthMap.get(row.month);
if (m) {
m.totalMeetings = parseInt(row.total_meetings ?? 0);
m.clientMeetings = parseInt(row.client_meetings ?? 0);
m.meetingDurationMinutes = parseInt(row.total_duration_minutes ?? 0);
}
}
for (const row of monthlyZoomResult?.rows ?? []) {
const m = monthMap.get(row.month);
if (m) {
m.zoomCalls = parseInt(row.call_count ?? 0);
m.zoomClientCalls = parseInt(row.client_calls ?? 0);
}
}
return NextResponse.json({
user: {
id: user.id,
displayName: user.display_name,
email: user.email,
jobTitle: user.job_title,
department: user.department,
autotaskResourceId: user.autotask_resource_id,
},
daily: (dailyResult?.rows ?? []).map(r => ({
date: r.date,
hoursWorked: parseFloat(r.hours_worked ?? 0),
billableHours: parseFloat(r.billable_hours ?? 0),
})),
monthly: Array.from(monthMap.values()),
});
} catch (error) {
console.error('[ENGAGEMENT-HISTORY] Error:', error);
return NextResponse.json({ error: 'Failed to fetch history' }, { status: 500 });
}
}