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:
parent
8a9887faa1
commit
dc0b06b9c7
3 changed files with 67 additions and 27 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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'));
|
||||
|
|
@ -42,27 +44,30 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||
// 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<NextResponse> {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue