From f4a9fd83dbb4d1ad9c39c18a3ea4e8f8661e1427 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 22:43:36 -0400 Subject: [PATCH] feat(07-01): add /api/mobile/engagement/summary endpoint - GET handler with requireAuth() gate before any DB query (T-07-01) - Period whitelist ['D7','D30','D90'] with 400 for invalid values (T-07-02) - Returns MobileEngagementSummary: configured, activeUsers, totalGraphHours, totalAutotaskHours, hoursPerActiveUser - Reuses notAutomatedFilter and wulfconsulting email scope from desktop summary - Exports MobileEngagementSummary interface for Plan 03 page import --- app/api/mobile/engagement/summary/route.ts | 151 +++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 app/api/mobile/engagement/summary/route.ts diff --git a/app/api/mobile/engagement/summary/route.ts b/app/api/mobile/engagement/summary/route.ts new file mode 100644 index 0000000..42fb804 --- /dev/null +++ b/app/api/mobile/engagement/summary/route.ts @@ -0,0 +1,151 @@ +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'; + +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 { error: authError } = await requireAuth(); + if (authError) return authError; + + 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 { + // 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). + 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() - 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#%'`, + ); + 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 }, + ); + } +}