20 KiB
| 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 |
|
|
|
|
|
|
|
|
|
~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)returnssession.user.timezoneif it's a valid IANA zone (≤64 chars; present inIntl.supportedValuesOf('timeZone')); otherwise returnsprocess.env.DEFAULT_TIMEZONE || 'UTC'.DEFAULT_TIMEZONE_FALLBACK()is a function (not a const) so test harnesses can overrideprocess.env.DEFAULT_TIMEZONEbetween 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 matchdue_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'last7AvgResolved7-day window + GROUP BY → user-tz day bucketsdue_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
/api/dashboard/trends (Task 4)
volumeByDaygenerate_serieswindow +t.create_date::date = days.djoin → user-tz two-step idiomresolutionByDaygenerate_series+t.completed_date::date = days.djoin → user-tzactiveEngineerste.entry_date::date = CURRENT_DATEfilter → user-tzqueueHeatmap(open-only counts) PRESERVED — no day-boundary math; comment added
/api/mobile/finance (Task 3A)
- NEW:
requireAuth()first call inGET()— aligns with every other/api/mobile/*handler paid_mtd/paid_ytdDATE_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_overduearithmetic:CURRENT_DATE - due_date::date→ user-tz(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - due_date::datemonthlyRevenue12-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
latestResultreferencing REQUIREMENTS.md
/api/mobile/engagement/trend (Task 3C)
generate_serieswindow endpoints (× 2) → user-tzdaily_hoursentry_date::date AS dayGROUP 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_endis computed bylib/services/engagement-sync-service.tsagainst 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_entriesWHERE clause is the only part of/api/mobile/engagement/summarythat uses user-tz. - Documented in REQUIREMENTS.md (TZ-02 carve-out clause) AND in a code comment immediately above
latestResultso future readers don't try to "fix" it.
Task Commits
Each task was committed atomically (parallel-executor mode, --no-verify):
- Task 1: Create lib/services/user-timezone.ts —
ea5532c(feat) - Task 2: Migrate /api/mobile/dashboard + /api/dashboard/overview —
8a9887f(feat) - Task 3: Migrate /api/mobile/finance + engagement/(summary,trend) —
dc0b06b(feat) - Task 4: Migrate /api/dashboard/trends —
04d036a(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 $1over compact form — decoded both columns types Pulse uses (timestamp + timestamptz) without behavioral surprises. The compact form(value AT TIME ZONE $tz)::datewould silently misbehave ontimestamp without time zonecolumns (it would interpret the value as being in$tzand returntimestamptz). - Auth-gate
/api/mobile/financein this plan — aligned with the rest of/api/mobile/*, and gave us thesessionwe needed forgetUserTimezone(). 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.truthsclause 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
requireAuthmachinery 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.tsexplaining whyINTERVAL '24 hours'is NOT migrated. Same formonthlyRevenue12-month rolling window in/api/mobile/finance/route.ts. Future maintainers will not be tempted to "fix" them. heatmapResin/api/dashboard/trends/route.tsleft 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 base3f3142bcontaining prior-wave commits (Plans 01, 02, 04).db375fb0was an ancestor of3f3142b, sogit merge --ff-only 3f3142bfast-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/timezonereturns 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) — FOUND8a9887f(Task 2) — FOUNDdc0b06b(Task 3) — FOUND04d036a(Task 4) — FOUND
- Type-check:
npx tsc --noEmit --prettyreports 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