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

581 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 });
}
}