feat(07.1-03): user-tz boundaries on /api/mobile/finance + engagement; auth-gate finance

- /api/mobile/finance: add requireAuth() (aligns with all other /api/mobile/*
  handlers) + getUserTimezone(); migrate paid_mtd / paid_ytd to user-tz
  DATE_TRUNC, six aging-bucket comparisons to user-tz CURRENT_DATE, and
  days_overdue arithmetic. Preserved unchanged: 12-month rolling
  monthlyRevenue (rolling — not a calendar boundary).
- /api/mobile/engagement/summary: destructure session, resolve tz; migrate
  rolling time_entries WHERE clause to user-tz on both sides of >=. Added
  TZ-02 carve-out comment above the snapshot queries documenting why
  engagement_snapshots remain UTC-bucketed (deferred per REQUIREMENTS.md).
- /api/mobile/engagement/trend: replace every bare CURRENT_DATE with
  (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date; pass [tz] as params
  to postgresClient.query. Day buckets now align to user-tz days.
This commit is contained in:
lorentz 2026-05-07 08:04:47 -04:00
parent 8a9887faa1
commit dc0b06b9c7
3 changed files with 67 additions and 27 deletions

View file

@ -2,6 +2,7 @@ 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;
@ -20,8 +21,9 @@ function parsePeriod(raw: string | null): AllowedPeriod | null {
}
export async function GET(request: NextRequest): Promise<NextResponse> {
const { error: authError } = await requireAuth();
const { session, error: authError } = await requireAuth();
if (authError) return authError;
const tz = getUserTimezone(session);
const { searchParams } = request.nextUrl;
const period = parsePeriod(searchParams.get('period'));
@ -33,6 +35,14 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
}
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`,
@ -114,16 +124,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
// 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() - INTERVAL '${interval}'
WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - 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;