- 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
199 lines
8.8 KiB
TypeScript
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 });
|
|
}
|
|
}
|