wulf-pulse/app/api/engagement/users/route.ts
lorentz 758b7e7f15 feat(engagement): replace Graph email counts with real-time mimecast data
- Broaden mimecast retention from 30 days to 18 months rolling
- Re-enable mimecast-sync schedule (was disabled since March 17)
- Full sync triggered: 35,559 messages loaded for last 30 days
- Users list API: LATERAL join on mimecast_messages for emails_sent/received
- User detail API: add emails{d7,d30,d90} field from mimecast
- Engagement page: prefer mimecast email counts in detail panel sub-label

Graph API has 48-72hr reporting lag; mimecast is same-day
2026-06-02 20:25:14 -04:00

199 lines
8.8 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const period = searchParams.get('period') || 'D30';
const sort = searchParams.get('sort') || 'billable_hours';
const order = searchParams.get('order') === 'asc' ? 'ASC' : 'DESC';
const page = Math.max(1, parseInt(searchParams.get('page') || '1'));
const pageSize = 50;
const offset = (page - 1) * pageSize;
const intervalMap: Record<string, string> = {
D7: '7 days',
D30: '30 days',
D90: '90 days',
};
const interval = intervalMap[period] || '30 days';
const allowedSorts: Record<string, string> = {
hours_worked: 'hours_worked',
billable_hours: 'billable_hours',
teams_meetings_attended: 'teams_meetings_attended',
teams_chat_messages: 'teams_chat_messages',
emails_sent: 'emails_sent',
last_activity: 'last_active',
display_name: 'display_name',
zoom_call_count: 'zoom_call_count',
zoom_meeting_count: 'zoom_meeting_count',
};
const sortCol = allowedSorts[sort] || 'billable_hours';
try {
const latestResult = await postgresClient.query(
`SELECT MAX(period_end) as latest_date FROM engagement_snapshots WHERE period_type = $1`,
[period]
);
const latestDate = latestResult.rows[0]?.latest_date;
if (!latestDate) {
return NextResponse.json({ users: [], pagination: { total: 0, page, pageSize } });
}
const countResult = await postgresClient.query(
`SELECT COUNT(*) as total
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_cnt
ON LOWER(es_cnt.user_email) = LOWER(gu.email)
AND es_cnt.period_type = $1 AND es_cnt.period_end = $2
WHERE gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
AND LOWER(gu.email) NOT LIKE '%#ext#%'
AND NOT (
es_cnt.user_email IS NOT NULL
AND COALESCE(es_cnt.emails_received, 0) = 0
AND COALESCE(es_cnt.teams_chat_messages, 0) = 0
AND COALESCE(es_cnt.teams_meetings_attended, 0) = 0
AND COALESCE(es_cnt.teams_calls, 0) = 0
)`,
[period, latestDate]
);
const total = parseInt(countResult.rows[0]?.total ?? '0');
const usersResult = await postgresClient.query(
`SELECT
gu.id as graph_user_id,
gu.display_name,
gu.email,
gu.job_title,
gu.department,
r.id as autotask_resource_id,
COALESCE(te_agg.total_hours, 0) as hours_worked,
COALESCE(te_agg.billable_hours, 0) as billable_hours,
COALESCE(es.teams_chat_messages, 0) as teams_chat_messages,
COALESCE(es.teams_private_messages, 0) as teams_private_messages,
COALESCE(es.teams_calls, 0) as teams_calls,
COALESCE(es.teams_meetings_attended, 0) as teams_meetings_attended,
COALESCE(es.teams_meetings_organized, 0) as teams_meetings_organized,
COALESCE(mc.emails_sent, 0) as emails_sent,
COALESCE(mc.emails_received, 0) as emails_received,
COALESCE(es.emails_read, 0) as emails_read,
COALESCE(es.audio_duration_seconds, 0) as audio_duration_seconds,
COALESCE(es.meeting_duration_seconds, 0) as meeting_duration_seconds,
COALESCE(es.meetings_with_external, 0) as meetings_with_external,
LEAST(GREATEST(es.last_activity_date, last_te.entry_date::date), CURRENT_DATE) as last_active,
COALESCE(zc.zoom_call_count, 0) as zoom_call_count,
COALESCE(zc.zoom_client_call_count, 0) as zoom_client_call_count,
COALESCE(zc.zoom_call_duration_seconds, 0) as zoom_call_duration_seconds,
COALESCE(zm.zoom_meeting_count, 0) as zoom_meeting_count,
COALESCE(zm.zoom_client_meeting_count, 0) as zoom_client_meeting_count
FROM graph_users gu
LEFT JOIN engagement_snapshots es
ON LOWER(es.user_email) = LOWER(gu.email)
AND es.period_type = $1
AND es.period_end = $2
JOIN (
SELECT DISTINCT ON (LOWER(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 LATERAL (
SELECT
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 time_entries te
WHERE te.resource_id = r.id
AND te.entry_date >= NOW() - INTERVAL '${interval}'
AND (te.is_deleted = false OR te.is_deleted IS NULL)
) te_agg ON true
LEFT JOIN LATERAL (
SELECT MAX(te2.entry_date) as entry_date
FROM time_entries te2
WHERE te2.resource_id = r.id
AND (te2.is_deleted = false OR te2.is_deleted IS NULL)
) last_te ON true
LEFT JOIN (
SELECT resource_email,
COUNT(*) AS zoom_call_count,
COUNT(*) FILTER (WHERE matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) AS zoom_client_call_count,
COALESCE(SUM(duration_seconds), 0) AS zoom_call_duration_seconds
FROM zoom_calls
WHERE start_time >= NOW() - INTERVAL '${interval}'
AND call_status = 'completed'
AND COALESCE(duration_seconds, 0) > 0
GROUP BY resource_email
) zc ON LOWER(r.email) = LOWER(zc.resource_email)
LEFT JOIN (
SELECT host_email,
COUNT(*) AS zoom_meeting_count,
COUNT(*) FILTER (WHERE has_client_attendees = true) AS zoom_client_meeting_count
FROM zoom_meetings
WHERE start_time >= NOW() - INTERVAL '${interval}'
GROUP BY host_email
) zm ON LOWER(r.email) = LOWER(zm.host_email)
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (WHERE LOWER(mm.sender_address) = LOWER(gu.email) AND mm.direction IN ('outbound', 'internal')) AS emails_sent,
COUNT(*) FILTER (WHERE LOWER(mm.recipient_address) = LOWER(gu.email) AND mm.direction IN ('inbound', 'internal') AND mm.status IN ('archived', 'accepted')) AS emails_received
FROM mimecast_messages mm
WHERE (LOWER(mm.sender_address) = LOWER(gu.email) OR LOWER(mm.recipient_address) = LOWER(gu.email))
AND mm.sent_datetime >= NOW() - INTERVAL '${interval}'
) mc ON true
WHERE gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
AND LOWER(gu.email) NOT LIKE '%#ext#%'
AND 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
)
ORDER BY ${sortCol} ${order} NULLS LAST
LIMIT $3 OFFSET $4`,
[period, latestDate, pageSize, offset]
);
const users = usersResult.rows.map(row => ({
graphUserId: row.graph_user_id,
displayName: row.display_name,
email: row.email,
jobTitle: row.job_title,
department: row.department,
autotaskResourceId: row.autotask_resource_id,
hoursWorked: parseFloat(row.hours_worked),
billableHours: parseFloat(row.billable_hours),
teamsMessages: parseInt(row.teams_chat_messages) + parseInt(row.teams_private_messages),
teamsCallCount: parseInt(row.teams_calls),
meetingsAttended: parseInt(row.teams_meetings_attended),
meetingsOrganized: parseInt(row.teams_meetings_organized),
emailsSent: parseInt(row.emails_sent),
emailsReceived: parseInt(row.emails_received),
audioDurationSeconds: parseInt(row.audio_duration_seconds),
meetingDurationSeconds: parseInt(row.meeting_duration_seconds),
meetingsWithExternal: parseInt(row.meetings_with_external),
lastActivity: row.last_active,
zoomCallCount: parseInt(row.zoom_call_count),
zoomClientCallCount: parseInt(row.zoom_client_call_count),
zoomCallDurationSeconds: parseInt(row.zoom_call_duration_seconds),
zoomMeetingCount: parseInt(row.zoom_meeting_count),
zoomClientMeetingCount: parseInt(row.zoom_client_meeting_count),
}));
return NextResponse.json({
users,
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
});
} catch (error) {
console.error('[ENGAGEMENT-USERS] Error:', error);
return NextResponse.json({ error: 'Failed to fetch engagement users' }, { status: 500 });
}
}