From dc0b06b9c74defefd282aeb04f99d17332b2e5ba Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 7 May 2026 08:04:47 -0400 Subject: [PATCH] feat(07.1-03): user-tz boundaries on /api/mobile/finance + engagement; auth-gate finance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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. --- app/api/mobile/engagement/summary/route.ts | 17 ++++++- app/api/mobile/engagement/trend/route.ts | 21 ++++---- app/api/mobile/finance/route.ts | 56 +++++++++++++++------- 3 files changed, 67 insertions(+), 27 deletions(-) diff --git a/app/api/mobile/engagement/summary/route.ts b/app/api/mobile/engagement/summary/route.ts index 42fb804..04500d2 100644 --- a/app/api/mobile/engagement/summary/route.ts +++ b/app/api/mobile/engagement/summary/route.ts @@ -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 { - 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 { } 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 { // 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; diff --git a/app/api/mobile/engagement/trend/route.ts b/app/api/mobile/engagement/trend/route.ts index 653ccd0..3191e5a 100644 --- a/app/api/mobile/engagement/trend/route.ts +++ b/app/api/mobile/engagement/trend/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth-utils'; import { postgresClient } from '@/lib/services/postgres-client'; +import { getUserTimezone } from '@/lib/services/user-timezone'; export interface SparklinePoint { date: string; // ISO date string "YYYY-MM-DD" @@ -21,8 +22,9 @@ function parsePeriod(raw: string | null): AllowedPeriod | null { } export async function GET(request: NextRequest): Promise { - 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')); @@ -42,27 +44,30 @@ export async function GET(request: NextRequest): Promise { // were logged (D-15: continuous series, no gaps in the sparkline). // ${days - 1} is interpolated, NOT parameterized — safe because period is whitelisted // to one of ['D7','D30','D90'] and days comes from a static map (not user input). + // TZ-02 (Phase 7.1): day buckets are aligned to the calling user's + // IANA timezone via $1 (validated by getUserTimezone). Storage tz + // for `time_entries.entry_date` remains UTC. const sql = ` WITH day_series AS ( SELECT generate_series( - (CURRENT_DATE - INTERVAL '${days - 1} days')::date, - CURRENT_DATE, + ((NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date, + (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date, INTERVAL '1 day' )::date AS day ), daily_hours AS ( - SELECT te.entry_date::date AS day, COALESCE(SUM(te.hours_worked), 0) AS hours + SELECT (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date AS day, COALESCE(SUM(te.hours_worked), 0) AS 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 >= CURRENT_DATE - INTERVAL '${days - 1} days' - AND te.entry_date <= CURRENT_DATE + WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date >= ((NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date + AND (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date <= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date 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#%' - GROUP BY te.entry_date::date + GROUP BY (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date ) SELECT to_char(ds.day, 'YYYY-MM-DD') AS date, COALESCE(dh.hours, 0)::numeric AS hours @@ -71,7 +76,7 @@ export async function GET(request: NextRequest): Promise { ORDER BY ds.day ASC `; - const result = await postgresClient.query(sql); + const result = await postgresClient.query(sql, [tz]); const points: SparklinePoint[] = result.rows.map((row) => ({ date: String(row.date), diff --git a/app/api/mobile/finance/route.ts b/app/api/mobile/finance/route.ts index 12ddbe6..b1a3805 100644 --- a/app/api/mobile/finance/route.ts +++ b/app/api/mobile/finance/route.ts @@ -1,9 +1,21 @@ import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; import { postgresClient } from '@/lib/services/postgres-client'; +import { getUserTimezone } from '@/lib/services/user-timezone'; export async function GET() { + // Auth gate (Phase 7.1): aligns this route with every other + // /api/mobile/* handler and lets us resolve the caller's tz from + // the session. Browser callers carry the Better Auth session + // cookie automatically, so the existing /mobile/finance page works + // unchanged. + const { session, error } = await requireAuth(); + if (error) return error; + const tz = getUserTimezone(session); + const [summary, aging, topCustomers, overdueInvoices, recentPayments, monthlyRevenue] = await Promise.all([ - postgresClient.query(` + postgresClient.query( + ` SELECT SUM(balance) FILTER (WHERE status IN ('Open','Overdue')) as total_ar, COUNT(*) FILTER (WHERE status IN ('Open','Overdue')) as total_ar_count, @@ -11,22 +23,27 @@ export async function GET() { COUNT(*) FILTER (WHERE status = 'Open') as current_count, SUM(balance) FILTER (WHERE status = 'Overdue') as overdue_balance, COUNT(*) FILTER (WHERE status = 'Overdue') as overdue_count, - SUM(total_amt) FILTER (WHERE status = 'Paid' AND txn_date >= DATE_TRUNC('month', NOW())) as paid_mtd, - SUM(total_amt) FILTER (WHERE status = 'Paid' AND txn_date >= DATE_TRUNC('year', NOW())) as paid_ytd + SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)) as paid_mtd, + SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('year', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)) as paid_ytd FROM qbo_invoices - `), - postgresClient.query(` + `, + [tz], + ), + postgresClient.query( + ` SELECT - SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date >= CURRENT_DATE - 30) as days_1_30, - COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date >= CURRENT_DATE - 30) as cnt_1_30, - SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < CURRENT_DATE - 30 - AND due_date >= CURRENT_DATE - 60) as days_31_60, - COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < CURRENT_DATE - 30 - AND due_date >= CURRENT_DATE - 60) as cnt_31_60, - SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < CURRENT_DATE - 60) as days_60_plus, - COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < CURRENT_DATE - 60) as cnt_60_plus + SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30) as days_1_30, + COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30) as cnt_1_30, + SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30 + AND due_date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 60) as days_31_60, + COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30 + AND due_date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 60) as cnt_31_60, + SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 60) as days_60_plus, + COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 60) as cnt_60_plus FROM qbo_invoices - `), + `, + [tz], + ), postgresClient.query(` SELECT customer_ref_name, SUM(balance) as balance, COUNT(*) as invoice_count FROM qbo_invoices @@ -34,18 +51,23 @@ export async function GET() { GROUP BY customer_ref_name ORDER BY balance DESC LIMIT 8 `), - postgresClient.query(` + postgresClient.query( + ` SELECT id, doc_number, txn_date, due_date, customer_ref_name, total_amt, balance, status, - CURRENT_DATE - due_date::date as days_overdue + (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - due_date::date as days_overdue FROM qbo_invoices WHERE status IN ('Open','Overdue') ORDER BY status DESC, balance DESC LIMIT 30 - `), + `, + [tz], + ), postgresClient.query(` SELECT id, txn_date, customer_ref_name, total_amt FROM qbo_payments ORDER BY txn_date DESC LIMIT 10 `), + // monthlyRevenue: rolling 12-month window — NOT a calendar boundary; + // tz does not apply. postgresClient.query(` SELECT DATE_TRUNC('month', txn_date) as month, SUM(total_amt) as revenue, COUNT(*) as invoice_count