- 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
227 lines
7.9 KiB
TypeScript
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 });
|
|
}
|
|
}
|