- 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
581 lines
27 KiB
TypeScript
581 lines
27 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
||
import { postgresClient } from '@/lib/services/postgres-client';
|
||
import { isZoomConfigured } from '@/lib/services/zoom-factory';
|
||
|
||
export async function GET(
|
||
request: NextRequest,
|
||
{ params }: { params: Promise<{ userId: string }> }
|
||
) {
|
||
const { userId } = await params;
|
||
const periodParam = (request.nextUrl.searchParams.get('period') ?? 'D30').toUpperCase();
|
||
const periodDays = periodParam === 'D7' ? 7 : periodParam === 'D90' ? 90 : 30;
|
||
|
||
try {
|
||
// Get user from graph_users
|
||
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];
|
||
|
||
// Get all snapshots for this user across periods
|
||
const snapshotsResult = await postgresClient.query(
|
||
`SELECT *
|
||
FROM engagement_snapshots
|
||
WHERE LOWER(user_email) = LOWER($1)
|
||
ORDER BY period_end DESC, period_type`,
|
||
[user.email]
|
||
);
|
||
|
||
// Get Autotask hours per period
|
||
const hoursResult = user.autotask_resource_id
|
||
? await postgresClient.query(
|
||
`SELECT
|
||
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '7 days' THEN hours_worked ELSE 0 END) as hours_d7,
|
||
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '30 days' THEN hours_worked ELSE 0 END) as hours_d30,
|
||
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '90 days' THEN hours_worked ELSE 0 END) as hours_d90,
|
||
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '7 days' AND COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_d7,
|
||
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '30 days' AND COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_d30,
|
||
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '90 days' AND COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_d90
|
||
FROM time_entries
|
||
WHERE resource_id = $1
|
||
AND (is_deleted = false OR is_deleted IS NULL)
|
||
AND COALESCE(type, 0) NOT IN (15, 16)
|
||
AND COALESCE(allocation_code_id, 0) NOT IN (91206, 91209)`,
|
||
[user.autotask_resource_id]
|
||
)
|
||
: null;
|
||
|
||
const hours = hoursResult?.rows[0];
|
||
|
||
// Recent time entries
|
||
const recentEntriesResult = user.autotask_resource_id
|
||
? await postgresClient.query(
|
||
`SELECT te.entry_date, te.hours_worked, te.billable, te.notes, te.title,
|
||
te.start_date_time, te.end_date_time,
|
||
COALESCE(c.company_name, tc.company_name) as company_name
|
||
FROM time_entries te
|
||
LEFT JOIN companies c ON c.id = te.company_id
|
||
LEFT JOIN tickets t ON t.id = te.ticket_id
|
||
LEFT JOIN companies tc ON tc.id = t.company_id
|
||
WHERE te.resource_id = $1
|
||
AND (te.is_deleted = false OR te.is_deleted IS NULL)
|
||
AND te.entry_date >= NOW() - ($2 || ' days')::INTERVAL
|
||
AND COALESCE(te.type, 0) NOT IN (15, 16)
|
||
AND COALESCE(te.allocation_code_id, 0) NOT IN (91206, 91209)
|
||
ORDER BY te.entry_date DESC
|
||
LIMIT 500`,
|
||
[user.autotask_resource_id, periodDays]
|
||
)
|
||
: null;
|
||
|
||
// Teams meeting detail records (client-attended)
|
||
let recentTeamsMeetings: Array<{
|
||
subject: string | null;
|
||
startTime: string;
|
||
durationMinutes: number | null;
|
||
attendeeCount: number;
|
||
clientAttendeeCount: number;
|
||
hasClientAttendees: boolean;
|
||
clientCompanies: string[];
|
||
participantNames: string[];
|
||
}> = [];
|
||
try {
|
||
const teamsMeetingsResult = await postgresClient.query(
|
||
`SELECT tm.subject, tm.start_time, tm.duration_minutes,
|
||
tm.attendee_count, tm.client_attendee_count, tm.has_client_attendees,
|
||
ARRAY_REMOVE(ARRAY_AGG(DISTINCT co.company_name), NULL) AS client_companies,
|
||
ARRAY_REMOVE(ARRAY_AGG(DISTINCT COALESCE(tma.attendee_name, tma.attendee_email)), NULL) AS participant_names
|
||
FROM teams_meetings tm
|
||
LEFT JOIN teams_meeting_attendees tma ON tma.meeting_id = tm.id
|
||
LEFT JOIN companies co ON co.id = tma.matched_company_id
|
||
WHERE LOWER(tm.user_email) = LOWER($1)
|
||
AND tm.start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||
GROUP BY tm.id
|
||
ORDER BY tm.start_time DESC
|
||
LIMIT 200`,
|
||
[user.email, periodDays]
|
||
);
|
||
recentTeamsMeetings = teamsMeetingsResult.rows.map(r => ({
|
||
subject: r.subject,
|
||
startTime: r.start_time,
|
||
durationMinutes: r.duration_minutes,
|
||
attendeeCount: r.attendee_count,
|
||
clientAttendeeCount: r.client_attendee_count,
|
||
hasClientAttendees: r.has_client_attendees,
|
||
clientCompanies: r.client_companies ?? [],
|
||
participantNames: r.participant_names ?? [],
|
||
}));
|
||
} catch {
|
||
// teams_meetings table may not exist yet
|
||
}
|
||
|
||
// Peer max benchmarks — highest value across all active employees for this period
|
||
const peerMaxResult = await postgresClient.query(
|
||
`SELECT
|
||
MAX(h.hours_total) as max_hours,
|
||
MAX(h.hours_billable) as max_billable_hours,
|
||
MAX(m.meeting_count) as max_meetings,
|
||
MAX(m.client_meetings)as max_client_meetings,
|
||
MAX(s.messages) as max_messages,
|
||
MAX(s.emails) as max_emails,
|
||
MAX(zc.calls) as max_calls
|
||
FROM (
|
||
SELECT resource_id,
|
||
SUM(hours_worked) as hours_total,
|
||
SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as hours_billable
|
||
FROM time_entries
|
||
WHERE (is_deleted = false OR is_deleted IS NULL)
|
||
AND entry_date >= NOW() - ($1 || ' days')::INTERVAL
|
||
AND COALESCE(type, 0) NOT IN (15, 16)
|
||
AND COALESCE(allocation_code_id, 0) NOT IN (91206, 91209)
|
||
GROUP BY resource_id
|
||
) h
|
||
CROSS JOIN (
|
||
SELECT user_email,
|
||
COUNT(*) as meeting_count,
|
||
SUM(CASE WHEN has_client_attendees THEN 1 ELSE 0 END) as client_meetings
|
||
FROM teams_meetings
|
||
WHERE start_time >= NOW() - ($1 || ' days')::INTERVAL
|
||
GROUP BY user_email
|
||
) m
|
||
CROSS JOIN (
|
||
SELECT user_email,
|
||
MAX(teams_chat_messages + teams_private_messages) as messages,
|
||
MAX(emails_sent) as emails
|
||
FROM engagement_snapshots
|
||
WHERE period_type = $2
|
||
GROUP BY user_email
|
||
) s
|
||
CROSS JOIN (
|
||
SELECT resource_email,
|
||
COUNT(*) as calls
|
||
FROM zoom_calls
|
||
WHERE call_status = 'completed'
|
||
AND start_time >= NOW() - ($1 || ' days')::INTERVAL
|
||
GROUP BY resource_email
|
||
) zc`,
|
||
[periodDays, periodParam]
|
||
).catch(() => null);
|
||
|
||
// Previous period values for trend calculation
|
||
const prevPeriodResult = user.autotask_resource_id
|
||
? await postgresClient.query(
|
||
`SELECT
|
||
SUM(hours_worked) as prev_hours,
|
||
SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as prev_billable
|
||
FROM time_entries
|
||
WHERE resource_id = $1
|
||
AND (is_deleted = false OR is_deleted IS NULL)
|
||
AND entry_date >= NOW() - ($2 || ' days')::INTERVAL * 2
|
||
AND entry_date < NOW() - ($2 || ' days')::INTERVAL
|
||
AND COALESCE(type, 0) NOT IN (15, 16)
|
||
AND COALESCE(allocation_code_id, 0) NOT IN (91206, 91209)`,
|
||
[user.autotask_resource_id, periodDays]
|
||
).catch(() => null)
|
||
: null;
|
||
|
||
const prevMeetingsResult = await postgresClient.query(
|
||
`SELECT COUNT(*) as prev_meetings,
|
||
SUM(CASE WHEN has_client_attendees THEN 1 ELSE 0 END) as prev_client_meetings
|
||
FROM teams_meetings
|
||
WHERE LOWER(user_email) = LOWER($1)
|
||
AND start_time >= NOW() - ($2 || ' days')::INTERVAL * 2
|
||
AND start_time < NOW() - ($2 || ' days')::INTERVAL`,
|
||
[user.email, periodDays]
|
||
).catch(() => null);
|
||
|
||
const prevZoomResult = await postgresClient.query(
|
||
`SELECT COUNT(*) as prev_calls
|
||
FROM zoom_calls
|
||
WHERE LOWER(resource_email) = LOWER($1)
|
||
AND call_status = 'completed'
|
||
AND start_time >= NOW() - ($2 || ' days')::INTERVAL * 2
|
||
AND start_time < NOW() - ($2 || ' days')::INTERVAL`,
|
||
[user.email, periodDays]
|
||
).catch(() => null);
|
||
|
||
// After-hours meetings (5:30 PM – 7:00 AM America/New_York)
|
||
const afterHoursMeetingsResult = await postgresClient.query(
|
||
`SELECT COUNT(*) as count
|
||
FROM teams_meetings
|
||
WHERE LOWER(user_email) = LOWER($1)
|
||
AND start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||
AND (
|
||
EXTRACT(HOUR FROM start_time AT TIME ZONE 'America/New_York') * 60
|
||
+ EXTRACT(MINUTE FROM start_time AT TIME ZONE 'America/New_York') >= 1050
|
||
OR
|
||
EXTRACT(HOUR FROM start_time AT TIME ZONE 'America/New_York') * 60
|
||
+ EXTRACT(MINUTE FROM start_time AT TIME ZONE 'America/New_York') < 420
|
||
)`,
|
||
[user.email, periodDays]
|
||
).catch(() => ({ rows: [{ count: 0 }] }));
|
||
const afterHoursMeetings = parseInt(afterHoursMeetingsResult.rows[0]?.count ?? 0);
|
||
|
||
// Daily activity heatmap data
|
||
let dailyActivity: Array<{ date: string; meetings: number; zoomCalls: number; hours: number; meetingMins: number }> = [];
|
||
try {
|
||
const dailyResult = await postgresClient.query(
|
||
`SELECT
|
||
day::date as date,
|
||
COALESCE(SUM(meetings), 0)::int as meetings,
|
||
COALESCE(SUM(zoom_calls), 0)::int as zoom_calls,
|
||
COALESCE(SUM(hours), 0)::float as hours,
|
||
COALESCE(SUM(meeting_mins), 0)::int as meeting_mins
|
||
FROM (
|
||
SELECT DATE(start_time) as day, COUNT(*) as meetings, SUM(duration_minutes) as meeting_mins, 0 as zoom_calls, 0 as hours
|
||
FROM teams_meetings
|
||
WHERE LOWER(user_email) = LOWER($1)
|
||
AND start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||
GROUP BY DATE(start_time)
|
||
UNION ALL
|
||
SELECT DATE(start_time) as day, 0, 0, COUNT(*) as zoom_calls, 0
|
||
FROM zoom_calls
|
||
WHERE LOWER(resource_email) = LOWER($1)
|
||
AND call_status = 'completed'
|
||
AND start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||
GROUP BY DATE(start_time)
|
||
UNION ALL
|
||
SELECT DATE(entry_date) as day, 0, 0, 0, SUM(hours_worked) as hours
|
||
FROM time_entries te
|
||
WHERE te.resource_id = $3
|
||
AND (te.is_deleted = false OR te.is_deleted IS NULL)
|
||
AND entry_date >= NOW() - ($2 || ' days')::INTERVAL
|
||
AND COALESCE(te.type, 0) NOT IN (15, 16)
|
||
AND COALESCE(te.allocation_code_id, 0) NOT IN (91206, 91209)
|
||
GROUP BY DATE(entry_date)
|
||
) combined
|
||
GROUP BY day
|
||
ORDER BY day`,
|
||
[user.email, periodDays, user.autotask_resource_id]
|
||
);
|
||
dailyActivity = dailyResult.rows.map(r => ({
|
||
date: r.date instanceof Date ? r.date.toISOString().slice(0, 10) : String(r.date).slice(0, 10),
|
||
meetings: Number(r.meetings),
|
||
zoomCalls: Number(r.zoom_calls),
|
||
hours: Number(r.hours),
|
||
meetingMins: Number(r.meeting_mins),
|
||
}));
|
||
} catch {
|
||
// ignore if tables missing
|
||
}
|
||
|
||
// Zoom data (only if configured and tables exist)
|
||
let zoomData = null;
|
||
if (isZoomConfigured()) {
|
||
try {
|
||
const email = user.email;
|
||
|
||
const [zoomCallsResult, zoomMeetingsResult, zoomTopClientsResult, recentCallsResult, recentMeetingsResult] = await Promise.all([
|
||
postgresClient.query(
|
||
`SELECT
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' THEN 1 ELSE 0 END) as calls_d7,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' THEN 1 ELSE 0 END) as calls_d30,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' THEN 1 ELSE 0 END) as calls_d90,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND (matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) THEN 1 ELSE 0 END) as client_calls_d7,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND (matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) THEN 1 ELSE 0 END) as client_calls_d30,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND (matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) THEN 1 ELSE 0 END) as client_calls_d90,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND direction = 'outbound' THEN 1 ELSE 0 END) as outbound_d7,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND direction = 'outbound' THEN 1 ELSE 0 END) as outbound_d30,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND direction = 'outbound' THEN 1 ELSE 0 END) as outbound_d90,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND direction = 'inbound' THEN 1 ELSE 0 END) as inbound_d7,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND direction = 'inbound' THEN 1 ELSE 0 END) as inbound_d30,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND direction = 'inbound' THEN 1 ELSE 0 END) as inbound_d90,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' THEN COALESCE(duration_seconds, 0) ELSE 0 END) as duration_d7,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' THEN COALESCE(duration_seconds, 0) ELSE 0 END) as duration_d30,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' THEN COALESCE(duration_seconds, 0) ELSE 0 END) as duration_d90
|
||
FROM zoom_calls
|
||
WHERE LOWER(resource_email) = LOWER($1)
|
||
AND call_status = 'completed'
|
||
AND COALESCE(duration_seconds, 0) > 0`,
|
||
[email]
|
||
),
|
||
postgresClient.query(
|
||
`SELECT
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' THEN 1 ELSE 0 END) as meetings_d7,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' THEN 1 ELSE 0 END) as meetings_d30,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' THEN 1 ELSE 0 END) as meetings_d90,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND has_client_attendees = true THEN 1 ELSE 0 END) as client_meetings_d7,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND has_client_attendees = true THEN 1 ELSE 0 END) as client_meetings_d30,
|
||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND has_client_attendees = true THEN 1 ELSE 0 END) as client_meetings_d90
|
||
FROM zoom_meetings
|
||
WHERE LOWER(host_email) = LOWER($1)`,
|
||
[email]
|
||
),
|
||
postgresClient.query(
|
||
`SELECT
|
||
co.company_name,
|
||
COUNT(DISTINCT zc.id) as call_count,
|
||
COUNT(DISTINCT zm.id) as meeting_count
|
||
FROM companies co
|
||
LEFT JOIN zoom_calls zc
|
||
ON zc.matched_company_id = co.id
|
||
AND LOWER(zc.resource_email) = LOWER($1)
|
||
AND zc.start_time >= NOW() - INTERVAL '30 days'
|
||
LEFT JOIN zoom_meetings zm
|
||
ON zm.id IN (
|
||
SELECT zmp.meeting_id FROM zoom_meeting_participants zmp
|
||
WHERE zmp.matched_company_id = co.id
|
||
)
|
||
AND LOWER(zm.host_email) = LOWER($1)
|
||
AND zm.start_time >= NOW() - INTERVAL '30 days'
|
||
WHERE (zc.id IS NOT NULL OR zm.id IS NOT NULL)
|
||
GROUP BY co.id, co.company_name
|
||
ORDER BY (COUNT(DISTINCT zc.id) + COUNT(DISTINCT zm.id)) DESC
|
||
LIMIT 5`,
|
||
[email]
|
||
),
|
||
postgresClient.query(
|
||
`SELECT zc.direction, zc.call_status, zc.other_party_name, zc.other_party_number,
|
||
zc.start_time, zc.duration_seconds,
|
||
co.company_name
|
||
FROM zoom_calls zc
|
||
LEFT JOIN companies co ON co.id = zc.matched_company_id
|
||
WHERE LOWER(zc.resource_email) = LOWER($1)
|
||
AND zc.call_status = 'completed'
|
||
AND COALESCE(zc.duration_seconds, 0) > 0
|
||
ORDER BY zc.start_time DESC
|
||
LIMIT 30`,
|
||
[email]
|
||
),
|
||
postgresClient.query(
|
||
`SELECT zm.id, zm.topic, zm.start_time, zm.end_time, zm.duration_minutes,
|
||
zm.participant_count, zm.client_participant_count, zm.has_client_attendees,
|
||
ARRAY_REMOVE(ARRAY_AGG(DISTINCT CASE WHEN NOT zmp.is_internal AND zmp.participant_name IS NOT NULL THEN zmp.participant_name ELSE NULL END), NULL) AS external_participant_names,
|
||
ARRAY_REMOVE(ARRAY_AGG(DISTINCT co.company_name), NULL) AS client_companies
|
||
FROM zoom_meetings zm
|
||
LEFT JOIN zoom_meeting_participants zmp ON zmp.meeting_id = zm.id
|
||
LEFT JOIN companies co ON co.id = zmp.matched_company_id
|
||
WHERE (LOWER(zm.host_email) = LOWER($1)
|
||
OR EXISTS (SELECT 1 FROM zoom_meeting_participants p WHERE p.meeting_id = zm.id AND LOWER(p.participant_email) = LOWER($1)))
|
||
AND zm.start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||
GROUP BY zm.id
|
||
ORDER BY zm.start_time DESC
|
||
LIMIT 100`,
|
||
[email, periodDays]
|
||
),
|
||
]);
|
||
|
||
const cr = zoomCallsResult.rows[0];
|
||
const mr = zoomMeetingsResult.rows[0];
|
||
|
||
zoomData = {
|
||
calls: {
|
||
d7: {
|
||
total: parseInt(cr.calls_d7 ?? 0),
|
||
client: parseInt(cr.client_calls_d7 ?? 0),
|
||
outbound: parseInt(cr.outbound_d7 ?? 0),
|
||
inbound: parseInt(cr.inbound_d7 ?? 0),
|
||
durationSeconds: parseInt(cr.duration_d7 ?? 0),
|
||
},
|
||
d30: {
|
||
total: parseInt(cr.calls_d30 ?? 0),
|
||
client: parseInt(cr.client_calls_d30 ?? 0),
|
||
outbound: parseInt(cr.outbound_d30 ?? 0),
|
||
inbound: parseInt(cr.inbound_d30 ?? 0),
|
||
durationSeconds: parseInt(cr.duration_d30 ?? 0),
|
||
},
|
||
d90: {
|
||
total: parseInt(cr.calls_d90 ?? 0),
|
||
client: parseInt(cr.client_calls_d90 ?? 0),
|
||
outbound: parseInt(cr.outbound_d90 ?? 0),
|
||
inbound: parseInt(cr.inbound_d90 ?? 0),
|
||
durationSeconds: parseInt(cr.duration_d90 ?? 0),
|
||
},
|
||
},
|
||
meetings: {
|
||
d7: { total: parseInt(mr.meetings_d7 ?? 0), withClients: parseInt(mr.client_meetings_d7 ?? 0) },
|
||
d30: { total: parseInt(mr.meetings_d30 ?? 0), withClients: parseInt(mr.client_meetings_d30 ?? 0) },
|
||
d90: { total: parseInt(mr.meetings_d90 ?? 0), withClients: parseInt(mr.client_meetings_d90 ?? 0) },
|
||
},
|
||
topClients: zoomTopClientsResult.rows.map(r => ({
|
||
companyName: r.company_name,
|
||
callCount: parseInt(r.call_count),
|
||
meetingCount: parseInt(r.meeting_count),
|
||
})),
|
||
recentCalls: recentCallsResult.rows.map(r => ({
|
||
direction: r.direction,
|
||
status: r.call_status,
|
||
otherPartyName: r.other_party_name,
|
||
otherPartyNumber: r.other_party_number,
|
||
startTime: r.start_time,
|
||
durationSeconds: r.duration_seconds,
|
||
companyName: r.company_name,
|
||
})),
|
||
recentMeetings: recentMeetingsResult.rows.map(r => {
|
||
const zmStart = new Date(r.start_time).getTime();
|
||
const zmEnd = r.end_time
|
||
? new Date(r.end_time).getTime()
|
||
: r.duration_minutes
|
||
? zmStart + r.duration_minutes * 60_000
|
||
: zmStart + 60 * 60_000;
|
||
const zmClientNames: string[] = (r.client_companies ?? []).map((n: string) => n.toLowerCase());
|
||
const toMs = (ts: string | null): number | null => {
|
||
if (!ts) return null;
|
||
const s = ts.toString();
|
||
const normalized = /[Z+\-]\d*$/.test(s.trim()) ? s : s.trim() + 'Z';
|
||
return new Date(normalized).getTime();
|
||
};
|
||
const matched = (recentEntriesResult?.rows ?? []).filter(te => {
|
||
if (r.has_client_attendees && zmClientNames.length > 0) {
|
||
if (!te.company_name) return false;
|
||
const teCo = te.company_name.toLowerCase();
|
||
if (!zmClientNames.some((c: string) => teCo.includes(c) || c.includes(teCo))) return false;
|
||
}
|
||
if (te.start_date_time) {
|
||
const teStart = toMs(te.start_date_time)!;
|
||
const teEnd = te.end_date_time
|
||
? toMs(te.end_date_time)!
|
||
: teStart + te.hours_worked * 3_600_000;
|
||
const tolerance = 30 * 60_000;
|
||
return teStart < zmEnd + tolerance && teEnd > zmStart - tolerance;
|
||
}
|
||
const teDate = new Date((toMs(te.entry_date) ?? 0)).toUTCString().slice(0, 16);
|
||
const zmDate = new Date(r.start_time).toUTCString().slice(0, 16);
|
||
return teDate === zmDate;
|
||
});
|
||
return {
|
||
topic: r.topic,
|
||
startTime: r.start_time,
|
||
endTime: r.end_time,
|
||
durationMinutes: r.duration_minutes,
|
||
participantCount: r.participant_count,
|
||
clientParticipantCount: r.client_participant_count,
|
||
hasClientAttendees: r.has_client_attendees,
|
||
externalParticipantNames: r.external_participant_names ?? [],
|
||
clientCompanies: r.client_companies ?? [],
|
||
matchedEntries: matched.map(te => ({
|
||
hours_worked: te.hours_worked,
|
||
billable: te.billable,
|
||
notes: te.notes,
|
||
title: te.title,
|
||
company_name: te.company_name,
|
||
start_date_time: te.start_date_time,
|
||
end_date_time: te.end_date_time,
|
||
})),
|
||
};
|
||
}),
|
||
};
|
||
} catch {
|
||
// Zoom tables may not exist yet — return null
|
||
zoomData = null;
|
||
}
|
||
}
|
||
|
||
// After-hours summary (messages from snapshot for current period, meetings from DB)
|
||
const currentSnap = snapshotsResult.rows.find(s => s.period_type === periodParam);
|
||
const totalMessages = (currentSnap?.teams_chat_messages ?? 0) + (currentSnap?.teams_private_messages ?? 0);
|
||
const totalMeetings = recentTeamsMeetings.length;
|
||
const afterHoursMessages = currentSnap?.after_hours_messages ?? 0;
|
||
|
||
return NextResponse.json({
|
||
user: {
|
||
id: user.id,
|
||
displayName: user.display_name,
|
||
email: user.email,
|
||
jobTitle: user.job_title,
|
||
department: user.department,
|
||
accountEnabled: user.account_enabled,
|
||
autotaskResourceId: user.autotask_resource_id,
|
||
},
|
||
afterHours: {
|
||
messages: afterHoursMessages,
|
||
meetings: afterHoursMeetings,
|
||
messagesPct: totalMessages > 0 ? Math.round((afterHoursMessages / totalMessages) * 100) : 0,
|
||
meetingsPct: totalMeetings > 0 ? Math.round((afterHoursMeetings / totalMeetings) * 100) : 0,
|
||
},
|
||
snapshots: snapshotsResult.rows,
|
||
hours: hours
|
||
? {
|
||
d7: { total: parseFloat(hours.hours_d7 ?? 0), billable: parseFloat(hours.billable_d7 ?? 0) },
|
||
d30: { total: parseFloat(hours.hours_d30 ?? 0), billable: parseFloat(hours.billable_d30 ?? 0) },
|
||
d90: { total: parseFloat(hours.hours_d90 ?? 0), billable: parseFloat(hours.billable_d90 ?? 0) },
|
||
}
|
||
: null,
|
||
recentEntries: recentEntriesResult?.rows ?? [],
|
||
recentTeamsMeetings: recentTeamsMeetings.map(mtg => {
|
||
const mtgStart = new Date(mtg.startTime).getTime();
|
||
const mtgEnd = mtg.durationMinutes
|
||
? mtgStart + mtg.durationMinutes * 60_000
|
||
: mtgStart + 60 * 60_000; // assume 1h if unknown
|
||
const mtgClientNames = mtg.clientCompanies.map((n: string) => n.toLowerCase());
|
||
// Normalize a DB timestamp to ms — treat naive timestamps as UTC
|
||
const toMs = (ts: string | null): number | null => {
|
||
if (!ts) return null;
|
||
const s = ts.toString();
|
||
// If no timezone info, append Z so Date parses it as UTC
|
||
const normalized = /[Z+\-]\d*$/.test(s.trim()) ? s : s.trim() + 'Z';
|
||
return new Date(normalized).getTime();
|
||
};
|
||
const matched = (recentEntriesResult?.rows ?? []).filter(te => {
|
||
// Company match: if the meeting has client companies, the time entry must be for one of them
|
||
if (mtg.hasClientAttendees && mtgClientNames.length > 0) {
|
||
if (!te.company_name) return false;
|
||
const teCo = te.company_name.toLowerCase();
|
||
if (!mtgClientNames.some((c: string) => teCo.includes(c) || c.includes(teCo))) return false;
|
||
}
|
||
// Time match
|
||
if (te.start_date_time) {
|
||
const teStart = toMs(te.start_date_time)!;
|
||
const teEnd = te.end_date_time
|
||
? toMs(te.end_date_time)!
|
||
: teStart + te.hours_worked * 3_600_000;
|
||
const tolerance = 30 * 60_000;
|
||
return teStart < mtgEnd + tolerance && teEnd > mtgStart - tolerance;
|
||
}
|
||
// Fallback: same UTC date
|
||
const teDate = new Date((toMs(te.entry_date) ?? 0)).toUTCString().slice(0, 16);
|
||
const mtgDate = new Date(mtg.startTime).toUTCString().slice(0, 16);
|
||
return teDate === mtgDate;
|
||
});
|
||
return { ...mtg, matchedEntries: matched.map(te => ({
|
||
hours_worked: te.hours_worked,
|
||
billable: te.billable,
|
||
notes: te.notes,
|
||
title: te.title,
|
||
company_name: te.company_name,
|
||
start_date_time: te.start_date_time,
|
||
end_date_time: te.end_date_time,
|
||
})) };
|
||
}),
|
||
meetingCounts: {
|
||
total: recentTeamsMeetings.length,
|
||
withClients: recentTeamsMeetings.filter(m => m.hasClientAttendees).length,
|
||
},
|
||
dailyActivity,
|
||
zoom: zoomData,
|
||
peerMax: peerMaxResult?.rows[0]
|
||
? {
|
||
hours: parseFloat(peerMaxResult.rows[0].max_hours ?? 0),
|
||
billableHours:parseFloat(peerMaxResult.rows[0].max_billable_hours ?? 0),
|
||
meetings: parseInt(peerMaxResult.rows[0].max_meetings ?? 0),
|
||
clientMeetings:parseInt(peerMaxResult.rows[0].max_client_meetings ?? 0),
|
||
messages: parseInt(peerMaxResult.rows[0].max_messages ?? 0),
|
||
emails: parseInt(peerMaxResult.rows[0].max_emails ?? 0),
|
||
calls: parseInt(peerMaxResult.rows[0].max_calls ?? 0),
|
||
}
|
||
: null,
|
||
trend: {
|
||
hours: parseFloat(prevPeriodResult?.rows[0]?.prev_hours ?? 0),
|
||
billable: parseFloat(prevPeriodResult?.rows[0]?.prev_billable ?? 0),
|
||
meetings: parseInt(prevMeetingsResult?.rows[0]?.prev_meetings ?? 0),
|
||
calls: parseInt(prevZoomResult?.rows[0]?.prev_calls ?? 0),
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('[ENGAGEMENT-USER-DETAIL] Error:', error);
|
||
return NextResponse.json({ error: 'Failed to fetch user detail' }, { status: 500 });
|
||
}
|
||
}
|