wulf-pulse/app/api/mobile/engagement/summary/route.ts
lorentz 672f17b7f9 chore: check in pending work — queue preferences, QBO AR diagnostics, mobile engagement fixes, ops scripts
Bundles several in-progress efforts that were sitting uncommitted:
- User queue-preferences (migration 087, API route, popover component)
- QBO invoice soft-delete (migration 088) and AR diagnostics route
- Dashboard/mobile engagement route and page adjustments
- Docker Compose log-rotation config
- One-off ticket/RMM investigation scripts (scripts/)
- Planning docs: phase verification/pattern notes, mobile shell design spec
- .gitignore: exclude local scratch financial/inventory data and Claude Code
  worktree/local-settings runtime state (never meant for version control)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
2026-07-18 06:34:57 -04:00

164 lines
6.8 KiB
TypeScript

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<NextResponse> {
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<AllowedPeriod, string> = {
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 },
);
}
}