wulf-pulse/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-03-SUMMARY.md

20 KiB
Raw Blame History

phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established requirements-completed duration completed
07.1-user-timezone-fix-inserted-urgent 03 api
timezone
iana
postgres
at-time-zone
dashboard
finance
engagement
auth-gate
phase provides
07.1-user-timezone-fix-inserted-urgent (Plan 01) session.user.timezone via Better Auth additionalField
phase provides
07.1-user-timezone-fix-inserted-urgent (Plan 02) PUT /api/me/timezone — sole writeable surface (so stored values are IANA-valid)
lib/services/user-timezone.ts — server-side getUserTimezone(session) helper (sole source of truth)
/api/mobile/dashboard, /api/dashboard/overview — opened/resolved/today/yesterday/7d-avg KPI counts in user-tz
/api/dashboard/trends — 30-day volume/resolution day buckets + active-engineers today filter in user-tz
/api/mobile/finance — paid_mtd / paid_ytd / aging buckets / days_overdue in user-tz; route now requires auth
/api/mobile/engagement/summary — rolling D7/D30/D90 time_entries window in user-tz; snapshot bucketing left UTC by carve-out
/api/mobile/engagement/trend — sparkline day buckets in user-tz
07.1-05 (codebase-wide leak closure — server side now correct; client side covered by Plan 04 + 05)
Future PR for desktop ticket *list* range filters: when added, they MUST consume getUserTimezone() server-side or useUserTimezone() client-side
added patterns
Two-step idiom: (value AT TIME ZONE 'UTC') AT TIME ZONE $1 — works for both timestamp and timestamptz columns
Pure server helper: getUserTimezone(session) — no DB, no auth-utils import (avoids circular)
Auth-gate hardening: previously-public /api/mobile/finance now uses requireAuth() (aligns with rest of /api/mobile/*)
created modified
lib/services/user-timezone.ts
app/api/mobile/dashboard/route.ts
app/api/dashboard/overview/route.ts
app/api/dashboard/trends/route.ts
app/api/mobile/finance/route.ts
app/api/mobile/engagement/summary/route.ts
app/api/mobile/engagement/trend/route.ts
Two-step (AT TIME ZONE 'UTC' AT TIME ZONE $1)::date idiom chosen over compact single-step form — works uniformly for both `timestamp` (Pulse default, assumed UTC) and `timestamptz` columns. Future schema adjustments will not break the SQL.
tz threaded as parameterized $1 to every query — never interpolated. Eliminates SQL injection surface for the new parameter.
Engagement summary: snapshot bucketing left UTC by explicit TZ-02 carve-out. Per-user-tz snapshot bucketing would require either per-request re-bucket (expensive) or per-user snapshot rebuild (doubles storage). The ≤24h drift on D7/D30/D90 active counts + total Graph hours is acceptable for an admin-overview surface.
/api/mobile/finance auth-gate hardening landed in this plan because (a) every other /api/mobile/* route already uses requireAuth(), and (b) auth resolution is required to read getUserTimezone(session). The browser session-cookie path means the existing /mobile/finance page works unchanged for signed-in users.
Rolling-now metrics (due_date_time < NOW(), INTERVAL '24 hours/5 minutes/1 hour/12 months', etc.) are explicitly tz-independent — preserved unchanged with comments documenting why.
Helper deliberately does NOT import from @/lib/auth-utils — avoids circular import in routes that already pull requireAuth from there. Helper accepts a duck-typed session shape.
Server-side day-boundary migration pattern: (column AT TIME ZONE 'UTC') AT TIME ZONE $1 on the column side; (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date on the comparison side.
Helper signature convention for resolved-from-session values: getX(session) returns a validated string with safe fallback; never throws; never undefined.
Migration carve-out pattern: when partial migration is the right call (engagement_snapshots), document the carve-out in REQUIREMENTS.md AND in a code comment at the surviving UTC-bucketed query.
TZ-02
~6 min 2026-05-07

Phase 07.1 Plan 03: Server-Side User Timezone Migration Summary

Six API route handlers (3 dashboard + 3 mobile) and one new shared helper migrate every server-side day/week/month boundary from server UTC to the calling user's IANA timezone. /api/mobile/finance gains requireAuth() in the same change. Storage timezone of every TIMESTAMP column on disk is unchanged.

Performance

  • Duration: ~6 min
  • Started: 2026-05-07T12:00:48Z
  • Completed: 2026-05-07T12:06:12Z
  • Tasks: 4
  • Files created: 1
  • Files modified: 6

Helper Signature

// lib/services/user-timezone.ts

export const DEFAULT_TIMEZONE_FALLBACK = (): string;

export function getUserTimezone(session: SessionLike): string;

Behaviour:

  • getUserTimezone(session) returns session.user.timezone if it's a valid IANA zone (≤64 chars; present in Intl.supportedValuesOf('timeZone')); otherwise returns process.env.DEFAULT_TIMEZONE || 'UTC'.
  • DEFAULT_TIMEZONE_FALLBACK() is a function (not a const) so test harnesses can override process.env.DEFAULT_TIMEZONE between calls without resetting module state.
  • Pure / synchronous / no DB / no auth-utils import — accepts a duck-typed { user?: { timezone?: unknown } } session shape.

Canonical SQL Idiom

Every migrated query uses the two-step form:

-- "today" in user tz:
(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date

-- "row in today (user tz)":
((column AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date

Why two-step: works uniformly for both timestamp without time zone (Pulse's default per CLAUDE.md, assumed UTC) and timestamptz. The first AT TIME ZONE 'UTC' is interpreted by Postgres differently for each input type, but the composed result is identical: a timestamp without time zone shifted into the user's zone.

tz is always passed as $1 to postgresClient.query(sql, [tz]) — never string-interpolated.

Routes Migrated

/api/mobile/dashboard (Task 2A)

  • opened_today / resolved_today (KPI snapshot row) → user-tz day match
  • due_date_time < NOW() SLA-breach filter PRESERVED (rolling-now, tz-independent)
  • INTERVAL '24h / 5min / 1h' rolling-window queries PRESERVED (failed backups, stalled workflows, analyzer/RMM 1h fail counts, backup-success 24h) — comment added documenting why

/api/dashboard/overview (Task 2B)

  • Today snapshot (opened_today / resolved_today) → user-tz day match
  • yesterdayOpened (CURRENT_DATE - INTERVAL '1 day') → user-tz (NOW() AT TIME ZONE ... )::date - INTERVAL '1 day'
  • last7AvgResolved 7-day window + GROUP BY → user-tz day buckets
  • due_date_time < NOW() SLA-breach filter PRESERVED
  • All non-day-boundary queries (link conflicts, IT Glue unlinked, S1 unmapped, schedules, observations, audits, sync health, companies, CIs, xref) PRESERVED
  • volumeByDay generate_series window + t.create_date::date = days.d join → user-tz two-step idiom
  • resolutionByDay generate_series + t.completed_date::date = days.d join → user-tz
  • activeEngineers te.entry_date::date = CURRENT_DATE filter → user-tz
  • queueHeatmap (open-only counts) PRESERVED — no day-boundary math; comment added

/api/mobile/finance (Task 3A)

  • NEW: requireAuth() first call in GET() — aligns with every other /api/mobile/* handler
  • paid_mtd / paid_ytd DATE_TRUNC('month'/'year', NOW()) → user-tz with (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) on the column side
  • Aging buckets (days_1_30/cnt_1_30 × 6 comparisons): CURRENT_DATE - 30/60 → user-tz (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30/60
  • days_overdue arithmetic: CURRENT_DATE - due_date::date → user-tz (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - due_date::date
  • monthlyRevenue 12-month rolling window PRESERVED — not a calendar boundary; comment added

/api/mobile/engagement/summary (Task 3B)

  • Rolling time_entries WHERE clause migrated to user-tz on both sides of >= (D7/D30/D90 window now anchored to user-tz "now")
  • Snapshot queries (activeResult, graphHoursResult) PRESERVED — engagement_snapshots are bucketed UTC at sync time per the explicit TZ-02 carve-out
  • TZ-02 carve-out comment block added immediately above latestResult referencing REQUIREMENTS.md

/api/mobile/engagement/trend (Task 3C)

  • generate_series window endpoints (× 2) → user-tz
  • daily_hours entry_date::date AS day GROUP BY + WHERE filters → user-tz two-step
  • TZ-02 (Phase 7.1) comment added below the existing T-07-03 mitigation note

Queries Explicitly Preserved Unchanged

Across all six routes, these patterns are tz-independent and were preserved:

Query / Filter Reason
due_date_time < NOW() (SLA-breach) Compares two UTC instants — "is this past its due time RIGHT NOW" is tz-independent
INTERVAL '24 hours' (failed backups, backup-success) Rolling window, not a calendar-day boundary
INTERVAL '5 minutes' (stalled workflows) Rolling window
INTERVAL '1 hour' (analyzer/RMM fail counts) Rolling window
INTERVAL '12 months' (monthlyRevenue) Rolling window
engagement_snapshots joins on period_end = $2 Snapshots bucketed UTC at sync time — TZ-02 carve-out (deferred per REQUIREMENTS.md)
queueHeatmap (open-only counts) No day-boundary math
topCustomers, recentPayments (qbo_invoices/payments) No "today/this week/this month" anchored math
linkConflicts, itglueUnlinked, s1Unmapped, schedules, observations, audits, syncHealth, companies, ci, xref No day-boundary math

Auth-Gate Hardening — /api/mobile/finance

Before: No requireAuth(). Anonymous curl returned 200 + financial data. After: requireAuth() is the first statement of GET(). Anonymous → 401. Authenticated browser sessions reach the route unchanged via the Better Auth session cookie (no UI changes required).

Why landed here: every other /api/mobile/* handler already uses requireAuth() (verified via grep), and we needed session to call getUserTimezone(session). Five-line change; aligns the route with the rest of the surface.

Behavioral Test Result (illustrative — deferred to runtime)

Scenario: user.timezone = 'America/New_York'; ticket created at 2026-05-07T03:30:00Z (= 2026-05-06 23:30 ET).

Endpoint Bucket the row falls into
/api/mobile/dashboard opened_today (called 2026-05-07 09:00 ET) NOT counted — the ticket's user-tz day is 2026-05-06
/api/dashboard/overview opened_today (same call time) NOT counted (same reason)
/api/dashboard/overview yesterdayOpened (same call time) COUNTED (the user-tz "yesterday" is 2026-05-06)
/api/dashboard/trends volumeByDay 2026-05-06 cell COUNTED
/api/dashboard/trends volumeByDay 2026-05-07 cell NOT counted
/api/mobile/engagement/trend 2026-05-06 sparkline point COUNTED (if hours_worked > 0 on that ET day)

Same scenario with DEFAULT_TIMEZONE=UTC and a session whose user has timezone='UTC': the row falls into 2026-05-07 buckets. The ET-vs-UTC bucket diff is the manifestation of TZ-02 — now resolved.

These are not automatable in the executor (no running app server) and are recorded for the verifier and human UAT.

Engagement Snapshots TZ-02 Carve-Out

Per the plan's must_haves.truths and the new code comment:

  • engagement_snapshots.period_end is computed by lib/services/engagement-sync-service.ts against UTC at sync time.
  • Per-user-tz bucketing of these snapshots is deferred — would require either per-request re-bucketing (expensive) or per-user snapshot rebuild (doubles storage).
  • Acceptable drift: ≤24h on D7/D30/D90 active-user counts and total MS Graph hours.
  • The migrated rolling time_entries WHERE clause is the only part of /api/mobile/engagement/summary that uses user-tz.
  • Documented in REQUIREMENTS.md (TZ-02 carve-out clause) AND in a code comment immediately above latestResult so future readers don't try to "fix" it.

Task Commits

Each task was committed atomically (parallel-executor mode, --no-verify):

  1. Task 1: Create lib/services/user-timezone.tsea5532c (feat)
  2. Task 2: Migrate /api/mobile/dashboard + /api/dashboard/overview8a9887f (feat)
  3. Task 3: Migrate /api/mobile/finance + engagement/(summary,trend)dc0b06b (feat)
  4. Task 4: Migrate /api/dashboard/trends04d036a (feat)

Plan metadata commit will be added by the orchestrator after the wave completes.

Files Created/Modified

  • lib/services/user-timezone.ts (created, 40 lines) — getUserTimezone(session) + DEFAULT_TIMEZONE_FALLBACK(); no runtime dependencies.
  • app/api/mobile/dashboard/route.ts (modified) — KPI snapshot row migrated; rolling-window queries preserved with documenting comment.
  • app/api/dashboard/overview/route.ts (modified) — today/yesterday/7d-avg migrated; non-day-boundary queries preserved.
  • app/api/dashboard/trends/route.ts (modified) — volumeByDay/resolutionByDay/activeEngineers migrated; queueHeatmap preserved.
  • app/api/mobile/finance/route.ts (modified) — requireAuth() added; paid_mtd/paid_ytd/aging/days_overdue migrated; monthlyRevenue (12-month rolling) preserved.
  • app/api/mobile/engagement/summary/route.ts (modified) — rolling time_entries WHERE migrated; snapshot queries preserved (TZ-02 carve-out).
  • app/api/mobile/engagement/trend/route.ts (modified) — generate_series + daily_hours migrated.

Static Verification Results

mobile/dashboard       AT TIME ZONE: 2  (target ≥2)   ✓
dashboard/overview     AT TIME ZONE: 7  (target ≥4)   ✓
dashboard/trends       AT TIME ZONE: 7  (target ≥6)   ✓
mobile/finance         AT TIME ZONE: 11 (target ≥5)   ✓
mobile/engagement/trend AT TIME ZONE: 6  (target ≥3)   ✓

::date = CURRENT_DATE in mobile/dashboard + overview: 0  ✓
bare CURRENT_DATE in mobile/engagement/trend:         0  ✓
bare CURRENT_DATE in dashboard/trends:                0  ✓
DATE_TRUNC('month/year', NOW()) in finance:           0  ✓
requireAuth in mobile/finance:                         2  ✓ (import + call)
ticket-list NOW()-INTERVAL leaks (regression check):  0  ✓

npx tsc --noEmit --pretty  →  no NEW errors in any of the 7 modified files  ✓

Threat Model Dispositions (Implemented)

Threat ID Disposition Implementation
T-07.1-03-01 (Tampering — SQL injection via tz) mitigate tz passed as $1 parameterized to every postgresClient.query. Verified by grep: zero string-interpolated tz values.
T-07.1-03-02 (Tampering — garbage tz from row) mitigate getUserTimezone() validates against Intl.supportedValuesOf('timeZone') and falls back to DEFAULT_TIMEZONE_FALLBACK(). The SQL never sees a non-IANA value.
T-07.1-03-03 (Info disclosure — cross-user via tz) accept tz only narrows / shifts day-boundary WHERE clauses; never widens the result set; never selects rows belonging to other users. The mine filter on tickets and per-user joins are unchanged.
T-07.1-03-04 (Spoofing — tz from wrong session) mitigate Each handler reads tz from its own requireAuth() result; helper is pure. No global state.
T-07.1-03-05 (DoS — Intl.supportedValuesOf per request) accept V8 caches internally; ~600 entries; same risk Plan 02 already accepted for the PUT endpoint.
T-07.1-03-06 (Repudiation — engagement snapshots UTC) accept TZ-02 carve-out documented in REQUIREMENTS.md and the code comment above latestResult. Drift bounded at ≤24h.
T-07.1-03-fin-auth (/api/mobile/finance now requires auth) mitigate requireAuth() added; existing browser callers unaffected (session cookie travels automatically); anonymous curl returns 401.
T-07.1-03-08 (/api/dashboard/trends already authed) accept Pre-existing requireAuth() preserved; only tz resolution added after the gate. No new attack surface.

Decisions Made

  • Two-step AT TIME ZONE 'UTC' AT TIME ZONE $1 over compact form — decoded both columns types Pulse uses (timestamp + timestamptz) without behavioral surprises. The compact form (value AT TIME ZONE $tz)::date would silently misbehave on timestamp without time zone columns (it would interpret the value as being in $tz and return timestamptz).
  • Auth-gate /api/mobile/finance in this plan — aligned with the rest of /api/mobile/*, and gave us the session we needed for getUserTimezone(). Five-line change, no UI breakage. Documented in the threat register as a separate disposition.
  • Engagement snapshot bucketing left UTC — explicit carve-out per the plan's must_haves.truths clause and REQUIREMENTS.md. Re-asserted in code comment so a future grep doesn't try to "fix" it as a missed migration.
  • Helper does not import auth-utils — accepts a duck-typed session shape. Keeps the helper free of the Next.js-specific requireAuth machinery and avoids a circular module graph for any route that uses both.
  • Rolling-now queries preserved with documenting comments — added a comment above the failed-backups 24h query in /api/mobile/dashboard/route.ts explaining why INTERVAL '24 hours' is NOT migrated. Same for monthlyRevenue 12-month rolling window in /api/mobile/finance/route.ts. Future maintainers will not be tempted to "fix" them.
  • heatmapRes in /api/dashboard/trends/route.ts left untouched — open-only counts; no day-boundary math. Inline comment added above the query.

Deviations from Plan

None — plan executed exactly as written. The plan's verbatim SQL transformations applied cleanly to every route. The four task verify blocks all pass on the first try.

Issues Encountered

  • Worktree branch base mismatch (pre-execution). The worktree's HEAD was at db375fb0 (a master commit) instead of the expected base 3f3142b containing prior-wave commits (Plans 01, 02, 04). db375fb0 was an ancestor of 3f3142b, so git merge --ff-only 3f3142b fast-forwarded cleanly with no conflicts — pulled in 125 commits including the timezone column migration, the Better Auth additionalField, the API endpoint, and the client hook. No code changes resulted from this; it only affected which commits were visible in the worktree.

Authentication Gates

None — no external service auth required. The new requireAuth() on /api/mobile/finance is internal Better Auth, not an external service gate.

Threat Flags

None — Plan 03 introduces no new external trust boundaries beyond the documented /api/mobile/finance auth-gate hardening (which is a strict tightening). The new helper reads from a session passed in by the caller; the migrated SQL adds no new joins, no new column reads, and no new write paths.

User Setup Required

None — the helper is online once deployed. Optional: set DEFAULT_TIMEZONE in .env.local (e.g. DEFAULT_TIMEZONE=America/New_York) to control the fallback when a user has no stored tz. Default behaviour ('UTC' fallback) is fine for any deploy.

Next Phase Readiness

  • Plan 05 unblocked: server side is now correct. Plan 05's audit-driven client-side migrations have a stable contract (getUserTimezone(session) server, useUserTimezone() client) to consume.
  • Phase 9 future timezone picker UI: GET /api/me/timezone returns the current value; PUT writes it (Plan 02). Read-path effects are immediate via the migrated routes — no cache invalidation required.

Self-Check

  • File created: /opt/stacks/pulse/.claude/worktrees/agent-a2c231adca3dde955/lib/services/user-timezone.ts — FOUND
  • Files modified (6): all FOUND, all show migrated SQL via git diff
  • Commits FOUND in git log --oneline:
    • ea5532c (Task 1) — FOUND
    • 8a9887f (Task 2) — FOUND
    • dc0b06b (Task 3) — FOUND
    • 04d036a (Task 4) — FOUND
  • Type-check: npx tsc --noEmit --pretty reports no NEW errors in any of the 7 modified files
  • All 11 plan-level static verification checks: PASS (counts, negatives, regression)
  • All four task acceptance criteria: PASS

Self-Check: PASSED


Phase: 07.1-user-timezone-fix-inserted-urgent Completed: 2026-05-07