import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth-utils'; import { postgresClient } from '@/lib/services/postgres-client'; import { isMsgraphConfigured } from '@/lib/services/msgraph-factory'; import { getUserTimezone } from '@/lib/services/user-timezone'; export interface MobileEngagementSummary { configured: boolean; activeUsers: number; totalGraphHours: number; totalAutotaskHours: number; hoursPerActiveUser: number; } const ALLOWED_PERIODS = ['D7', 'D30', 'D90'] as const; type AllowedPeriod = (typeof ALLOWED_PERIODS)[number]; function parsePeriod(raw: string | null): AllowedPeriod | null { if (!raw) return 'D30'; // default per D-04 return (ALLOWED_PERIODS as readonly string[]).includes(raw) ? (raw as AllowedPeriod) : null; } export async function GET(request: NextRequest): Promise { const { session, error: authError } = await requireAuth(); if (authError) return authError; const tz = getUserTimezone(session); const { searchParams } = request.nextUrl; const period = parsePeriod(searchParams.get('period')); if (period === null) { return NextResponse.json( { error: 'Invalid period', message: "period must be one of 'D7', 'D30', 'D90'" }, { status: 400 }, ); } try { // NOTE (TZ-02 carve-out, see REQUIREMENTS.md): engagement_snapshots // are bucketed by UTC at sync time by lib/services/engagement-sync-service.ts. // Per-user-tz snapshot bucketing is deferred to a future phase // (would require either per-request re-bucketing — expensive — or // per-user snapshot rebuild — doubles storage). The ≤24h drift on // active-users D7/D30/D90 + total MS Graph hours is acceptable for // an admin-overview surface. Only the rolling time_entries window // below is migrated to user-tz. // Get the latest snapshot date for this period 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({ configured: isMsgraphConfigured(), activeUsers: 0, totalGraphHours: 0, totalAutotaskHours: 0, hoursPerActiveUser: 0, } satisfies MobileEngagementSummary); } const intervalMap: Record = { D7: '7 days', D30: '30 days', D90: '90 days', }; const interval = intervalMap[period]; // Exclude pure-outbound service/automation accounts — copy verbatim from // app/api/engagement/summary/route.ts:42-49 const notAutomatedFilter = `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 )`; // Active users — count distinct users with any Teams/email activity const activeResult = await postgresClient.query( `SELECT COUNT(DISTINCT es.user_email) AS count FROM engagement_snapshots es JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email) 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) WHERE es.period_type = $1 AND es.period_end = $2 AND (es.teams_meetings_attended > 0 OR es.teams_chat_messages > 0 OR es.emails_sent > 0) AND gu.account_enabled = true AND LOWER(gu.email) LIKE '%@wulfconsulting.%' AND LOWER(gu.email) NOT LIKE '%#ext#%' AND ${notAutomatedFilter}`, [period, latestDate], ); const activeUsers = parseInt(activeResult.rows[0]?.count ?? '0', 10); // Total Graph hours — sum(audio_duration_seconds + meeting_duration_seconds) / 3600 const graphHoursResult = await postgresClient.query( `SELECT COALESCE(SUM( COALESCE(es.audio_duration_seconds, 0) + COALESCE(es.meeting_duration_seconds, 0) ), 0) AS total_seconds FROM engagement_snapshots es JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email) 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) WHERE es.period_type = $1 AND es.period_end = $2 AND gu.account_enabled = true AND LOWER(gu.email) LIKE '%@wulfconsulting.%' AND LOWER(gu.email) NOT LIKE '%#ext#%' AND ${notAutomatedFilter}`, [period, latestDate], ); const totalGraphSeconds = parseFloat(graphHoursResult.rows[0]?.total_seconds ?? '0'); const totalGraphHours = Math.round((totalGraphSeconds / 3600) * 10) / 10; // Total Autotask hours — SUM(time_entries.hours_worked) for matched resources in the period. // NOTE: ${interval} is interpolated, NOT parameterized — safe because period is whitelisted // to one of ['D7','D30','D90'] and interval is looked up from a static map (not user input). // TZ-02 (Phase 7.1): the rolling window is anchored to "now" in the calling // user's tz. tz is parameterized as $1 (no SQL interpolation). const atHoursResult = await postgresClient.query( `SELECT COALESCE(SUM(te.hours_worked), 0) AS total_hours FROM time_entries te JOIN resources r ON r.id = te.resource_id AND (r.is_deleted = false OR r.is_deleted IS NULL) JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email) WHERE te.entry_date >= (NOW() AT TIME ZONE $1)::date - INTERVAL '${interval}' AND (te.is_deleted = false OR te.is_deleted IS NULL) AND gu.account_enabled = true AND LOWER(gu.email) LIKE '%@wulfconsulting.%' AND LOWER(gu.email) NOT LIKE '%#ext#%'`, [tz], ); const totalAutotaskHours = Math.round(parseFloat(atHoursResult.rows[0]?.total_hours ?? '0') * 10) / 10; // Hours per active user — 0 when activeUsers === 0 (UI renders "—") const hoursPerActiveUser = activeUsers === 0 ? 0 : Math.round((totalAutotaskHours / activeUsers) * 10) / 10; return NextResponse.json({ configured: isMsgraphConfigured(), activeUsers, totalGraphHours, totalAutotaskHours, hoursPerActiveUser, } satisfies MobileEngagementSummary); } catch (error) { console.error('GET /api/mobile/engagement/summary failed:', error); return NextResponse.json( { error: 'Failed to fetch engagement summary', message: error instanceof Error ? error.message : 'unknown', }, { status: 500 }, ); } }