feat(07.1-03): user-tz day boundaries on /api/(mobile/)dashboard(/overview)

- Switch opened_today / resolved_today / yesterday / 7d-avg buckets from
  CURRENT_DATE to ((value AT TIME ZONE 'UTC') AT TIME ZONE $1)::date.
- Both routes destructure session from requireAuth() and resolve tz via
  getUserTimezone(); tz parameterized as $1 (no SQL interpolation).
- Preserved unchanged: due_date_time < NOW() (rolling SLA, tz-independent),
  the INTERVAL '24h/5min/1h' rolling-window queries (failed backups,
  stalled workflows, analyzer/RMM 1h fail counts, backup-success 24h).
- Added a code comment above the 24h failed-backups query explaining why
  it stays UTC-NOW relative.
This commit is contained in:
lorentz 2026-05-07 08:02:52 -04:00
parent ea5532c5c3
commit 8a9887faa1
2 changed files with 50 additions and 27 deletions

View file

@ -13,10 +13,12 @@
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() {
const { error } = await requireAuth();
const { session, error } = await requireAuth();
if (error) return error;
const tz = getUserTimezone(session);
type Counts = { count: string };
@ -35,47 +37,59 @@ export async function GET() {
ciRes,
xrefRes,
] = await Promise.all([
/* today snapshot — single row, all four KPIs */
/* today snapshot single row, all four KPIs.
Day-boundary counts are anchored to the calling user's tz via $1.
The `due_date_time < NOW()` clause stays as-is (rolling-now SLA check
is timezone-independent). */
postgresClient.query<{
opened_today: string;
resolved_today: string;
open_total: string;
sla_breaches: string;
}>(`
}>(
`
SELECT
COUNT(*) FILTER (WHERE create_date::date = CURRENT_DATE)::text AS opened_today,
COUNT(*) FILTER (WHERE completed_date::date = CURRENT_DATE)::text AS resolved_today,
COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
COUNT(*) FILTER (WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date)::text AS opened_today,
COUNT(*) FILTER (WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date)::text AS resolved_today,
COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
COUNT(*) FILTER (
WHERE completed_date IS NULL
AND due_date_time IS NOT NULL
AND due_date_time < NOW()
)::text AS sla_breaches
)::text AS sla_breaches
FROM tickets
WHERE (is_deleted = false OR is_deleted IS NULL)
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
`),
/* yesterday's opened count for the today-vs-yesterday delta */
postgresClient.query<{ count: string }>(`
`,
[tz],
),
/* yesterday's opened count for the today-vs-yesterday delta (user-tz) */
postgresClient.query<{ count: string }>(
`
SELECT COUNT(*)::text AS count
FROM tickets
WHERE create_date::date = CURRENT_DATE - INTERVAL '1 day'
WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '1 day'
AND (is_deleted = false OR is_deleted IS NULL)
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
`),
/* 7-day average resolved (excluding today) for the resolved delta */
postgresClient.query<{ avg_resolved: string }>(`
`,
[tz],
),
/* 7-day average resolved (excluding today) for the resolved delta (user-tz) */
postgresClient.query<{ avg_resolved: string }>(
`
SELECT COALESCE(AVG(daily_count), 0)::text AS avg_resolved
FROM (
SELECT completed_date::date AS d, COUNT(*) AS daily_count
SELECT ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date AS d, COUNT(*) AS daily_count
FROM tickets
WHERE completed_date >= CURRENT_DATE - INTERVAL '7 days'
AND completed_date < CURRENT_DATE
WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '7 days'
AND ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
AND (is_deleted = false OR is_deleted IS NULL)
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
GROUP BY completed_date::date
GROUP BY ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date
) sub
`),
`,
[tz],
),
postgresClient.query<Counts>(
`SELECT COUNT(*)::text AS count FROM device_link_review WHERE resolved_at IS NULL`
),

View file

@ -11,6 +11,7 @@
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';
// ─── Response shape ──────────────────────────────────────────────────────────
@ -46,8 +47,9 @@ export interface MobileDashboardResponse {
// ─── Handler ─────────────────────────────────────────────────────────────────
export async function GET() {
const { error } = await requireAuth();
const { session, error } = await requireAuth();
if (error) return error;
const tz = getUserTimezone(session);
try {
const [
@ -58,27 +60,34 @@ export async function GET() {
rmmRes,
backupSuccessRes,
] = await Promise.all([
/* 1. KPI snapshot — four counts in one row, scoped companies excluded */
/* 1. KPI snapshot four counts in one row, scoped companies excluded.
Day-boundary counts (opened_today, resolved_today) are anchored to
the calling user's IANA timezone via $1; storage tz unchanged. */
postgresClient.query<{
open_total: string;
opened_today: string;
resolved_today: string;
sla_breaches: string;
}>(`
}>(
`
SELECT
COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
COUNT(*) FILTER (WHERE create_date::date = CURRENT_DATE)::text AS opened_today,
COUNT(*) FILTER (WHERE completed_date::date = CURRENT_DATE)::text AS resolved_today,
COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
COUNT(*) FILTER (WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date)::text AS opened_today,
COUNT(*) FILTER (WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date)::text AS resolved_today,
COUNT(*) FILTER (
WHERE completed_date IS NULL
AND due_date_time IS NOT NULL
AND due_date_time < NOW()
)::text AS sla_breaches
)::text AS sla_breaches
FROM tickets
WHERE (is_deleted = false OR is_deleted IS NULL)
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
`),
`,
[tz],
),
// INTERVAL '24 hours' here is rolling — not a calendar-day boundary —
// so timezone does not apply. Do not migrate to user-tz.
/* 2. Failed backups in the last 24 hours (Needs Attention) */
postgresClient.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM (