docs(phase-07.1): plan urgent user timezone fix (TZ-01..TZ-04)
Insert Phase 7.1 between Phase 7 and Phase 8 to address dashboards/filters rendering wrong dates because day/week boundary math runs in server UTC instead of the viewing user's timezone. Persistence stays UTC; only the read/display path changes. 5 plans in 3 waves: - 07.1-01 (Wave 1): migration 083 + Better Auth additionalField timezone - 07.1-02 (Wave 1): /api/me/timezone GET+PUT with IANA validation - 07.1-03 (Wave 2): server-side AT TIME ZONE migration across 6 routes, including auth-gate fix on /api/mobile/finance and trends route - 07.1-04 (Wave 2): useUserTimezone() hook + 2 mobile pages + codebase audit - 07.1-05 (Wave 3): codebase-wide useUserTimezone() adoption per audit Add Phase 9 stub (User Profile & Preferences) to roadmap for the picker UI that reuses 7.1's hook + endpoint. REQUIREMENTS.md TZ-02 carves out engagement_snapshots UTC bucketing as a documented exception (≤24h drift acceptable for admin overview). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0dec54ca4f
commit
f9ab954518
8 changed files with 2263 additions and 8 deletions
|
|
@ -82,6 +82,13 @@ Requirements for this milestone. Each maps to a spec section and a roadmap phase
|
|||
- [ ] **ENG-08**: Profile reuses existing engagement profile data endpoints; no new data
|
||||
- [ ] **ENG-09**: Engagement is reachable from the More drawer, NOT the bottom bar
|
||||
|
||||
### TZ — User Timezone (Phase 7.1, urgent insertion)
|
||||
|
||||
- [ ] **TZ-01**: Better Auth users table extended with a `timezone` field (IANA string). Default for users with no value = `process.env.DEFAULT_TIMEZONE || 'UTC'`. Existing rows backfill to the default; UTC remains the storage timezone for all date columns
|
||||
- [ ] **TZ-02**: Date math that powers dashboards, ticket filters, finance views, and engagement period selectors computes day/week boundaries against the viewing user's `timezone` — NOT UTC, NOT the browser's local zone (browsers may differ from the user's chosen tz, e.g. travel) (Exception: `engagement_snapshots`-derived metrics — active users D7/D30/D90, MS Graph hours — bucket by UTC at sync time and remain UTC-bucketed in this phase; ≤24h drift is accepted because engagement is an admin-overview surface, not an operational date display. Per-user snapshot bucketing is deferred to a future phase if needed.)
|
||||
- [ ] **TZ-03**: `GET /api/me/timezone` (auth required) returns `{ timezone, source: 'user' | 'default' }`. `PUT /api/me/timezone` accepts `{ timezone }`, validates against `Intl.supportedValuesOf('timeZone')`, persists to the user row, returns the new value
|
||||
- [ ] **TZ-04**: Shared client hook `useUserTimezone()` reads tz from `useSession()` (Better Auth additionalField). All date-formatting and range-bucketing in mobile + desktop pages goes through this hook — no scattered `Intl.DateTimeFormat` instantiations with hardcoded zones
|
||||
|
||||
## v2 Requirements
|
||||
|
||||
Acknowledged but deferred. Not in this milestone's roadmap.
|
||||
|
|
@ -121,6 +128,7 @@ Explicitly excluded for v1. Documented to prevent scope creep.
|
|||
| Multi-series chart on mobile Engagement overview | Replaced by single sparkline (spec §6.5) |
|
||||
| Modal-based user detail on mobile | Replaced by real page so back gesture works (spec §6.5) |
|
||||
| `/mobile-v2` parallel directory | Rebuild `/mobile` in place — keep canonical URLs (spec §2) |
|
||||
| Per-user-tz `engagement_snapshots` bucketing (TZ-02 carve-out) | Engagement metrics derived from `engagement_snapshots` (active users D7/D30/D90, MS Graph hours) are bucketed by UTC at sync time. Per-user re-bucketing would require either per-request re-bucket (expensive) or per-user snapshot rebuild (doubles storage). ≤24h drift accepted on this admin-overview surface. May revisit in a future phase. |
|
||||
|
||||
## Traceability
|
||||
|
||||
|
|
@ -193,4 +201,6 @@ Updated during roadmap creation.
|
|||
|
||||
---
|
||||
*Requirements defined: 2026-05-03*
|
||||
*Last updated: 2026-05-03 — traceability filled in at roadmap creation*
|
||||
*Last updated: 2026-05-07 — TZ-02 amended with engagement_snapshots carve-out (Phase 7.1 revision)*
|
||||
</content>
|
||||
</invoke>
|
||||
|
|
@ -28,7 +28,9 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||
- [x] **Phase 5: Finance Restyle** — Adopt new Card + typography scale, swap wide tables for stacked lists (completed 2026-05-03)
|
||||
- [ ] **Phase 6: Analyzer Feed (NEW)** — `/mobile/analyzer` read-only stream + `/api/mobile/analyzer/feed`
|
||||
- [ ] **Phase 7: Engagement Overview (NEW)** — `/mobile/engagement` phone-first overview reachable from the More drawer
|
||||
- [ ] **Phase 7.1: User Timezone Fix (INSERTED — urgent)** — Per-user IANA timezone column + viewer-tz date math so dashboards and filters render the right "today"
|
||||
- [ ] **Phase 8: Engagement User Profile (NEW)** — `/mobile/engagement/[userId]` real-page profile that replaces the desktop modal pattern
|
||||
- [ ] **Phase 9: User Profile & Preferences (NEW)** — `/mobile/profile` settings page (timezone chooser, theme, mobile push, Teams + ntfy channels)
|
||||
|
||||
## Phase Details
|
||||
|
||||
|
|
@ -136,6 +138,24 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||
- [x] 07-03-PLAN.md — app/mobile/engagement/page.tsx orchestration (period/sort state, IntersectionObserver, empty/error/not-configured states) (ENG-01, ENG-02, ENG-03, ENG-04, ENG-05, ENG-09)
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 7.1: User Timezone Fix (INSERTED — urgent)
|
||||
**Goal**: A user opening Pulse sees dashboards, filters, and "today/this week" date math computed in their own IANA timezone — not server UTC — so reports stop showing yesterday's data as today (and vice versa). Persistence layer remains UTC; only the read/display path changes.
|
||||
**Depends on**: Nothing structural (Better Auth users table extension + read-path changes)
|
||||
**Requirements**: TZ-01, TZ-02, TZ-03, TZ-04
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Each user has an IANA timezone (e.g. `America/New_York`) persisted server-side; default = `process.env.DEFAULT_TIMEZONE || 'UTC'` for users with no value yet
|
||||
2. Mobile and desktop dashboards, ticket filters, finance views, and engagement period selectors compute day/week boundaries against the viewer's timezone — not UTC and not the browser's local zone (browser zone may differ from the user's chosen zone, e.g. travel)
|
||||
3. Authenticated `GET /api/me/timezone` returns the user's tz; `PUT /api/me/timezone` accepts an IANA string and rejects anything not in `Intl.supportedValuesOf('timeZone')`
|
||||
4. A shared client hook (`useUserTimezone()`) reads the value from `useSession()` so all components use a single source of truth — no per-page `Intl` calls scattered around
|
||||
5. Existing UTC-stored data stays untouched (no destructive migration); only formatting and range-bucketing change
|
||||
**Plans**: 5 plans
|
||||
- [ ] 07.1-01-PLAN.md — Add timezone column to user table + Better Auth additionalField (TZ-01)
|
||||
- [ ] 07.1-02-PLAN.md — /api/me/timezone GET + PUT with IANA validation (TZ-03)
|
||||
- [ ] 07.1-03-PLAN.md — Server-side read paths use user.timezone for day/week/month boundaries; auth-gates /api/mobile/finance; migrates /api/dashboard/trends (TZ-02)
|
||||
- [ ] 07.1-04-PLAN.md — useUserTimezone() client hook + reported-bug-surface mobile page migration + codebase-wide audit (TZ-04, TZ-02 client portion)
|
||||
- [ ] 07.1-05-PLAN.md — Codebase-wide useUserTimezone() adoption per the Plan 04 audit (TZ-04 SC#4 single-source-of-truth at codebase scale)
|
||||
**UI hint**: no (this is a data/plumbing phase; the picker UI is part of Phase 9)
|
||||
|
||||
### Phase 8: Engagement User Profile (NEW)
|
||||
**Goal**: From the Engagement overview, a manager taps an employee row and arrives at a real, shareable profile page — single-column phone-first — and the device back gesture returns them to the overview.
|
||||
**Depends on**: Phase 7
|
||||
|
|
@ -147,6 +167,17 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 9: User Profile & Preferences (NEW)
|
||||
**Goal**: A logged-in user reaches a profile/settings page from the More drawer and can configure timezone (chooser UI for TZ-01), theme (light/dark/system, persisted server-side for cross-device consistency), mobile push notifications (per-event toggles), and personal notification channels (Teams webhook URL, ntfy topic). Changes persist per-user and the existing notify pipeline routes through these per-user channels for events the user is subscribed to.
|
||||
**Depends on**: Phase 7.1 (timezone schema), Phase 2 (More drawer)
|
||||
**Requirements**: TBD — flesh out via `/gsd-discuss-phase 9` before planning
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Tapping Profile/Account in the More drawer routes to `/mobile/profile` (real page, not modal)
|
||||
2. Page exposes four sections — Timezone, Theme, Notifications, Channels — each persisting per-user; cross-device consistent; gated by `requireAuth()`
|
||||
3. Notification routing in `lib/services/pipeline-steps/notify.ts` resolves per-user channel preferences when the event has a user owner, falling back to global channels otherwise
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
## Progress
|
||||
|
||||
**Execution Order:**
|
||||
|
|
@ -161,8 +192,12 @@ Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, paral
|
|||
| 5. Finance Restyle | 2/2 | Complete | 2026-05-03 |
|
||||
| 6. Analyzer Feed | 0/3 | Not started | - |
|
||||
| 7. Engagement Overview | 0/3 | Not started | - |
|
||||
| 7.1. User Timezone Fix | 0/5 | Not started | - |
|
||||
| 8. Engagement User Profile | 0/TBD | Not started | - |
|
||||
| 9. User Profile & Preferences | 0/TBD | Not started | - |
|
||||
|
||||
---
|
||||
*Roadmap created: 2026-05-03*
|
||||
*Source spec: `docs/superpowers/specs/2026-05-03-mobile-shell-design.md`*
|
||||
</content>
|
||||
</invoke>
|
||||
|
|
@ -4,14 +4,14 @@ milestone: v1.0
|
|||
milestone_name: milestone
|
||||
status: executing
|
||||
stopped_at: Phase 7 UI-SPEC approved
|
||||
last_updated: "2026-05-04T03:04:12.504Z"
|
||||
last_activity: 2026-05-04
|
||||
last_updated: "2026-05-07T11:01:40.811Z"
|
||||
last_activity: 2026-05-07 -- Phase 7.1 planning complete
|
||||
progress:
|
||||
total_phases: 8
|
||||
total_phases: 10
|
||||
completed_phases: 7
|
||||
total_plans: 17
|
||||
total_plans: 22
|
||||
completed_plans: 17
|
||||
percent: 100
|
||||
percent: 77
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
|
@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-05-03)
|
|||
|
||||
Phase: 8
|
||||
Plan: Not started
|
||||
Status: Executing Phase 07
|
||||
Last activity: 2026-05-04
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-05-07 -- Phase 7.1 planning complete
|
||||
|
||||
Progress: [░░░░░░░░░░] 0%
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
---
|
||||
phase: 07.1-user-timezone-fix-inserted-urgent
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- migrations/083_add_user_timezone.sql
|
||||
- lib/auth.ts
|
||||
autonomous: true
|
||||
requirements: [TZ-01]
|
||||
requirements_addressed: [TZ-01]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Every row in the Better Auth `user` table has a non-NULL `timezone` value"
|
||||
- "New users created after this change default to `process.env.DEFAULT_TIMEZONE || 'UTC'`"
|
||||
- "Existing rows are backfilled to the same default (UTC unless DEFAULT_TIMEZONE is set)"
|
||||
- "session.user.timezone is populated on subsequent logins (Better Auth additionalField)"
|
||||
- "All existing UTC-stored timestamp columns are unchanged (no destructive migration)"
|
||||
artifacts:
|
||||
- path: "migrations/083_add_user_timezone.sql"
|
||||
provides: "Adds timezone TEXT column to \"user\" table with default + backfill"
|
||||
contains: "ADD COLUMN IF NOT EXISTS timezone"
|
||||
- path: "lib/auth.ts"
|
||||
provides: "Better Auth additionalField config exposing timezone on session.user"
|
||||
contains: "timezone:"
|
||||
key_links:
|
||||
- from: "Better Auth session"
|
||||
to: "user.timezone column"
|
||||
via: "additionalFields config in lib/auth.ts"
|
||||
pattern: "additionalFields[\\s\\S]*timezone"
|
||||
- from: "Default value at insert time"
|
||||
to: "process.env.DEFAULT_TIMEZONE"
|
||||
via: "SQL DEFAULT clause + Better Auth defaultValue"
|
||||
pattern: "COALESCE\\(.*DEFAULT_TIMEZONE.*'UTC'\\)|defaultValue.*timezone"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add a per-user IANA timezone field to the Better Auth `user` table and surface it on
|
||||
every session via Better Auth's `additionalFields`. This is the ground floor for
|
||||
Phase 7.1 — without it, no read path or hook in subsequent plans has anything to
|
||||
read. Storage stays UTC; only the `user.timezone` column is added.
|
||||
|
||||
Purpose: Resolve TZ-01. Provide the data plumbing that Plan 02 (the
|
||||
`/api/me/timezone` endpoint) and Plans 03 + 04 (read-path fixes and the client
|
||||
hook) depend on.
|
||||
Output: Migration `083_add_user_timezone.sql`, updated `lib/auth.ts` exposing
|
||||
`timezone` on `session.user`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@CLAUDE.md
|
||||
@lib/auth.ts
|
||||
@migrations/012_create_auth_tables.sql
|
||||
@migrations/081_integration_settings.sql
|
||||
@lib/bootstrap.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing additionalField pattern from lib/auth.ts:84-96 — extend, do NOT replace -->
|
||||
```typescript
|
||||
// lib/auth.ts current shape:
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
defaultValue: "user",
|
||||
},
|
||||
requires_setup: {
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
<!-- Existing user table from migrations/012_create_auth_tables.sql:5-18 -->
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
image TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
banned BOOLEAN DEFAULT FALSE,
|
||||
banned_reason TEXT,
|
||||
ban_expires TIMESTAMP,
|
||||
requires_setup BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
<!-- Migration idiom from migrations/081_integration_settings.sql — IF NOT EXISTS, no destructive ops -->
|
||||
<!-- Highest existing migration number: 082_company_scope.sql → next is 083 -->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create migration 083_add_user_timezone.sql</name>
|
||||
<files>migrations/083_add_user_timezone.sql</files>
|
||||
<read_first>
|
||||
- migrations/012_create_auth_tables.sql (the "user" table definition; column type/casing — note TEXT and TIMESTAMP, not VARCHAR/TIMESTAMPTZ)
|
||||
- migrations/081_integration_settings.sql (recent migration idiom: IF NOT EXISTS, ON CONFLICT DO NOTHING, header comment block)
|
||||
- migrations/082_company_scope.sql (confirm 082 is the latest — the new file MUST be 083)
|
||||
- CLAUDE.md (Database section: snake_case columns, IF NOT EXISTS guards, no destructive ops, numbered migrations apply alphabetically on Postgres init only)
|
||||
</read_first>
|
||||
<action>
|
||||
Create the file `migrations/083_add_user_timezone.sql` with EXACTLY this content (DEFAULT_TIMEZONE is read at INSERT time from a Postgres GUC fallback, not the SQL itself, since psql can't read process.env — so the SQL default is `'UTC'` and the application layer in `lib/auth.ts` overrides via `defaultValue` referencing `process.env.DEFAULT_TIMEZONE`):
|
||||
|
||||
```sql
|
||||
-- =============================================================================
|
||||
-- Per-user IANA timezone (Phase 7.1 — TZ-01)
|
||||
-- =============================================================================
|
||||
-- Adds a `timezone` column to the Better Auth "user" table so day/week
|
||||
-- boundary math (dashboards, ticket filters, finance, engagement) can be
|
||||
-- computed against the viewer's zone instead of server UTC.
|
||||
--
|
||||
-- Storage zone for every existing TIMESTAMP / TIMESTAMPTZ column is unchanged.
|
||||
-- Only display/range-bucketing logic in subsequent plans reads this column.
|
||||
--
|
||||
-- The SQL default here is the literal 'UTC'. The application-level default
|
||||
-- (process.env.DEFAULT_TIMEZONE || 'UTC') is enforced by Better Auth's
|
||||
-- additionalField `defaultValue` in lib/auth.ts so new sessions see the env-
|
||||
-- driven value even if a row was created without it.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE "user"
|
||||
ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC';
|
||||
|
||||
-- Backfill any rows that may have been created with NULL (defensive — the
|
||||
-- DEFAULT clause above covers new inserts, but on managed Postgres a column
|
||||
-- added with DEFAULT may briefly show NULL in flight on some replicas).
|
||||
UPDATE "user" SET timezone = 'UTC' WHERE timezone IS NULL;
|
||||
|
||||
COMMENT ON COLUMN "user".timezone IS
|
||||
'IANA timezone string (e.g. America/New_York). Storage timezone for all date columns remains UTC; this column only affects display and range-bucketing.';
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` so re-running the file on an
|
||||
existing DB (via `scripts/apply-migrations`) is a no-op. Postgres init only
|
||||
applies migrations on first boot — for the running container an operator
|
||||
will run the apply-migrations script.
|
||||
- Do NOT add a CHECK constraint validating against `Intl.supportedValuesOf` —
|
||||
Postgres can't evaluate JS APIs. Validation lives in Plan 02's PUT route.
|
||||
- `TIMESTAMP` (without time zone) is the existing column type for `created_at`
|
||||
/ `updated_at` in this table — match the file style. The `timezone` column
|
||||
itself is `TEXT`, not a Postgres `TIMESTAMP WITH TIME ZONE`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f migrations/083_add_user_timezone.sql && grep -q 'ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT' migrations/083_add_user_timezone.sql && grep -q "ON COLUMN \"user\".timezone IS" migrations/083_add_user_timezone.sql</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists at exact path `migrations/083_add_user_timezone.sql`
|
||||
- Filename matches regex `^083_add_user_timezone\.sql$`
|
||||
- File contains the literal string `ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC'`
|
||||
- File contains a `COMMENT ON COLUMN "user".timezone` statement
|
||||
- File contains a backfill `UPDATE "user" SET timezone = 'UTC' WHERE timezone IS NULL` line
|
||||
- File has NO `DROP`, `DELETE FROM`, or `TRUNCATE` (non-destructive)
|
||||
- File has NO Zod, no JS — pure SQL
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Migration file is committed and applying it (via Postgres init on a fresh
|
||||
volume OR via scripts/apply-migrations on the running DB) results in the
|
||||
`user` table having a `timezone TEXT NOT NULL DEFAULT 'UTC'` column with
|
||||
every existing row populated.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Extend Better Auth additionalFields with timezone</name>
|
||||
<files>lib/auth.ts</files>
|
||||
<read_first>
|
||||
- lib/auth.ts (current additionalFields block at lines 84-96 — this is the source of truth for the existing pattern; copy the shape exactly)
|
||||
- lib/auth-utils.ts (UserWithRole type at lines 7-14 — note `[key: string]: unknown` already accepts new fields; no type change needed there)
|
||||
- lib/auth-client.ts (the magicLink / twoFactor / admin client plugins — no change needed; additionalFields propagate via Better Auth's session inference)
|
||||
- CLAUDE.md (Auth section: Better Auth 1.4, additionalFields pattern, no Zod here)
|
||||
</read_first>
|
||||
<action>
|
||||
Edit `lib/auth.ts`. In the `user.additionalFields` object (currently lines
|
||||
86-95), add a third field `timezone` after `requires_setup`. The exact final
|
||||
shape of the `user` block must be:
|
||||
|
||||
```typescript
|
||||
// User configuration
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
defaultValue: "user",
|
||||
},
|
||||
requires_setup: {
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
timezone: {
|
||||
type: "string",
|
||||
defaultValue: process.env.DEFAULT_TIMEZONE || "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `defaultValue` is evaluated when Better Auth provisions a new user row that
|
||||
didn't supply the field. Reading `process.env.DEFAULT_TIMEZONE` here means
|
||||
a fresh user gets the operator-configured default, while existing rows
|
||||
(already backfilled to 'UTC' by Task 1) keep their stored value.
|
||||
- Do NOT add a custom validator here — Better Auth's `additionalFields`
|
||||
doesn't run runtime IANA validation, and we don't want to. Validation for
|
||||
writes is owned by Plan 02's PUT /api/me/timezone route.
|
||||
- Do NOT touch any other config in this file (session, plugins, social
|
||||
providers, account linking). Only add the one field inside additionalFields.
|
||||
- The exported `User` type at the bottom of the file (`typeof
|
||||
auth.$Infer.Session.user`) automatically picks up the new field — no
|
||||
explicit type addition needed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "lib/auth\.(ts|tsx)"); [ -z "$ERR" ] && grep -A1 "timezone:" lib/auth.ts | grep -q 'process.env.DEFAULT_TIMEZONE || "UTC"'</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `lib/auth.ts` contains the literal substring `timezone: {` inside the `additionalFields` object
|
||||
- The same field block contains `defaultValue: process.env.DEFAULT_TIMEZONE || "UTC"`
|
||||
- `npx tsc --noEmit --pretty` reports no NEW errors in `lib/auth.ts` (pre-existing errors elsewhere are out of scope; this file in particular must be clean)
|
||||
- Existing `role` and `requires_setup` additionalFields are preserved unchanged
|
||||
- No new imports added (the change is one new property on an existing object)
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
`lib/auth.ts` exports an `auth` instance whose `User` type now includes
|
||||
`timezone: string`. After re-deploying, `session.user.timezone` is available
|
||||
on every authenticated request — populated either from the stored row value
|
||||
or from the env-driven default for newly-provisioned users.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Operator → DB schema | Migration runs at deploy time; only operator-controlled SQL crosses this boundary |
|
||||
| Better Auth → session payload | additionalField propagates to client-readable session — must be a benign string, not credentials |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07.1-01-01 | Tampering | migration 083 (SQL injection via env var) | accept | `process.env.DEFAULT_TIMEZONE` is read by Better Auth at runtime in TypeScript, not interpolated into SQL. The SQL DEFAULT is the literal `'UTC'`. No user input touches the migration. |
|
||||
| T-07.1-01-02 | Information Disclosure | session.user.timezone exposed to client | accept | IANA timezone is non-sensitive metadata (visible in `Intl.DateTimeFormat().resolvedOptions().timeZone` on any device). Already the level of exposure assumed by every web app that renders dates. |
|
||||
| T-07.1-01-03 | Denial of Service | NOT NULL DEFAULT 'UTC' on existing rows | mitigate | Postgres handles ADD COLUMN with constant DEFAULT in O(1) since version 11 (no table rewrite). For very large `user` tables this is still safe; Pulse's user count is bounded by employee headcount (small). |
|
||||
| T-07.1-01-04 | Elevation of Privilege | Writing through additionalField | mitigate | additionalFields default config does NOT make the field client-writable. Writes are gated by Plan 02's authenticated /api/me/timezone PUT route only. Confirm by checking that Better Auth's default `input` flag for additionalField is false (it is, per Better Auth 1.4 docs). |
|
||||
| T-07.1-01-05 | Spoofing | Default value injection via env var rewrite | accept | An attacker who can rewrite `process.env.DEFAULT_TIMEZONE` already controls the deploy. Out of scope. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
End-to-end checks for this plan:
|
||||
|
||||
1. SQL: After applying the migration on a fresh DB,
|
||||
`SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name='user' AND column_name='timezone'`
|
||||
returns exactly one row: `timezone | text | NO | 'UTC'::text`.
|
||||
2. SQL: `SELECT COUNT(*) FROM "user" WHERE timezone IS NULL` returns 0.
|
||||
3. Type: `ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "lib/auth\.(ts|tsx)"); [ -z "$ERR" ]` (TS errors in lib/auth.ts fail the check; pre-existing errors elsewhere in the codebase are out of scope for Phase 7.1).
|
||||
4. Runtime: After redeploying with this change, sign in once and inspect
|
||||
`session.user` in DevTools — `timezone` is a string property of the user
|
||||
object.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `migrations/083_add_user_timezone.sql` exists, idempotent, non-destructive
|
||||
- `lib/auth.ts` `additionalFields` includes `timezone` with the env-driven default
|
||||
- TypeScript compilation of `lib/auth.ts` succeeds
|
||||
- Storage timezone of every existing TIMESTAMP column in the database remains UTC (no migration touches them)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-01-SUMMARY.md`
|
||||
documenting: the migration filename, the additionalField shape, and any gotchas
|
||||
encountered (e.g. did `scripts/apply-migrations` exist and behave as expected?
|
||||
which env vars need to be set in `.env.local` for `DEFAULT_TIMEZONE`?).
|
||||
</output>
|
||||
</content>
|
||||
</invoke>
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
---
|
||||
phase: 07.1-user-timezone-fix-inserted-urgent
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- app/api/me/timezone/route.ts
|
||||
- middleware.ts
|
||||
autonomous: true
|
||||
requirements: [TZ-03]
|
||||
requirements_addressed: [TZ-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Authenticated GET /api/me/timezone returns { timezone: string, source: 'user' | 'default' }"
|
||||
- "Authenticated PUT /api/me/timezone with a valid IANA tz persists to the calling user's row and returns the new value"
|
||||
- "PUT with an invalid tz string returns 400 (rejected via Intl.supportedValuesOf('timeZone'))"
|
||||
- "Unauthenticated GET or PUT returns 401 (via requireAuth())"
|
||||
- "PUT only updates the calling user's row — no userId parameter is accepted"
|
||||
- "/api/me/* is NOT in middleware.ts publicRoutes"
|
||||
artifacts:
|
||||
- path: "app/api/me/timezone/route.ts"
|
||||
provides: "GET + PUT /api/me/timezone handlers"
|
||||
exports: ["GET", "PUT"]
|
||||
- path: "middleware.ts"
|
||||
provides: "Confirms /api/me/* is excluded from publicRoutes (no change needed unless an audit reveals it leaked in)"
|
||||
contains: "publicRoutes"
|
||||
key_links:
|
||||
- from: "app/api/me/timezone/route.ts"
|
||||
to: "lib/auth-utils.ts requireAuth()"
|
||||
via: "import { requireAuth } from '@/lib/auth-utils'"
|
||||
pattern: "import.*requireAuth.*from.*auth-utils"
|
||||
- from: "PUT handler"
|
||||
to: "UPDATE user SET timezone WHERE id = session.user.id"
|
||||
via: "session-scoped UPDATE"
|
||||
pattern: "UPDATE \"user\" SET timezone"
|
||||
- from: "PUT validation"
|
||||
to: "Intl.supportedValuesOf timeZone whitelist"
|
||||
via: "runtime IANA whitelist"
|
||||
pattern: "Intl.supportedValuesOf"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Ship the authenticated GET + PUT endpoint for a user's timezone. This is the
|
||||
read/write surface that the (Phase 9) timezone picker UI will eventually call;
|
||||
in 7.1 it's API-only — admins / curl can set tz before the UI lands. Validation
|
||||
uses Intl.supportedValuesOf('timeZone') so callers can't store an arbitrary
|
||||
string that would crash toLocaleString downstream.
|
||||
|
||||
Purpose: Resolve TZ-03. Provide the only writeable surface for user.timezone —
|
||||
no other code path mutates this column.
|
||||
Output: New app/api/me/timezone/route.ts exporting GET and PUT.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@CLAUDE.md
|
||||
@lib/auth-utils.ts
|
||||
@lib/services/postgres-client.ts
|
||||
@middleware.ts
|
||||
@app/api/mobile/engagement/summary/route.ts
|
||||
|
||||
<interfaces>
|
||||
Existing helpers in this codebase that the new route must use verbatim:
|
||||
|
||||
- `requireAuth()` from `@/lib/auth-utils` returns `{ session, error }`. When unauthenticated, `error` is a `NextResponse` with status 401 — return it as-is.
|
||||
- `postgresClient` from `@/lib/services/postgres-client` exposes `query<RowShape>(sql, params)` returning a `pg` `QueryResult`. There is no ORM.
|
||||
- After Plan 01, `session.user.timezone` is typed `string` and `session.user.id` is `string` (TEXT primary key in the `"user"` table).
|
||||
- API response convention: `NextResponse.json({ error: 'short', message: 'detail' }, { status: N })` for failures; bare `NextResponse.json(payload)` for success. No Zod.
|
||||
|
||||
middleware.ts publicRoutes list (lines 6-43 at planning time): NONE of the entries is a prefix of `/api/me/...`, so the route is correctly auth-gated by the existing middleware + requireAuth() combo.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Confirm middleware.ts does not whitelist /api/me</name>
|
||||
<files>middleware.ts</files>
|
||||
<read_first>
|
||||
- middleware.ts (the publicRoutes array, lines 6-43 — verify no entry has a prefix that would match /api/me; specifically look at every string and confirm none is a prefix of /api/me/timezone)
|
||||
</read_first>
|
||||
<action>
|
||||
Read middleware.ts and confirm by inspection that none of the publicRoutes
|
||||
entries is a prefix of /api/me. The current list (verified at planning
|
||||
time) contains entries like /api/auth, /api/webhooks, /api/kiosk,
|
||||
/api/mobile, /api/sync, etc. — none of which match /api/me/.
|
||||
|
||||
Action: NO file changes are required. Run a verification grep to PROVE no
|
||||
entry is a prefix of /api/me:
|
||||
|
||||
grep -nE '"/api/me' middleware.ts
|
||||
|
||||
The grep MUST return zero matches. If it does match, STOP — that's a
|
||||
surprise that needs investigation before Task 2 (some past commit may have
|
||||
whitelisted /api/me which would defeat the auth gate).
|
||||
|
||||
If grep returns zero matches: do not edit middleware.ts. The next task can
|
||||
proceed knowing the route handler's own requireAuth() is the authoritative
|
||||
auth gate.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>! grep -nE '"/api/me' middleware.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Command `grep -nE '"/api/me' middleware.ts` exits non-zero (no matches)
|
||||
- middleware.ts is unchanged (`git diff --quiet middleware.ts`)
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Confirmed by automated grep that /api/me/* is NOT exempt from auth in
|
||||
middleware.ts. Plan 02 Task 2 may proceed knowing the route handler's own
|
||||
requireAuth() is the authoritative gate.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create app/api/me/timezone/route.ts (GET + PUT)</name>
|
||||
<files>app/api/me/timezone/route.ts</files>
|
||||
<read_first>
|
||||
- lib/auth-utils.ts (`requireAuth()` at lines 31-45 — copy the call shape exactly: `const { session, error } = await requireAuth(); if (error) return error;`)
|
||||
- lib/services/postgres-client.ts (the `query()` method signature; this codebase uses `postgresClient.query(sql, params)` and gets back a `QueryResult`)
|
||||
- app/api/mobile/engagement/summary/route.ts (canonical Pulse API route shape: imports, requireAuth, parametrized query, NextResponse.json with `error`/`message` envelope on failure, no Zod)
|
||||
- CLAUDE.md ("API routes" section: no Zod, manual try/catch, status code conventions — 401 from auth helper, 400 for bad input, 500 for runtime, 503 for missing config)
|
||||
</read_first>
|
||||
<action>
|
||||
Create the new file `app/api/me/timezone/route.ts` with EXACTLY the
|
||||
following content. No Zod. Manual validation. Matches the
|
||||
engagement/summary route shape.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
// GET /api/me/timezone -> { timezone: string, source: 'user' | 'default' }
|
||||
// PUT /api/me/timezone -> body { timezone: string } -> { timezone: string }
|
||||
//
|
||||
// TZ-03. Authentication: requireAuth(). The PUT handler updates ONLY the
|
||||
// calling user's row — there is no `userId` query param or body field. The
|
||||
// write target is always `session.user.id`.
|
||||
//
|
||||
// Validation: the input timezone must appear in
|
||||
// `Intl.supportedValuesOf('timeZone')`. Anything else is rejected with 400
|
||||
// before touching the database.
|
||||
|
||||
function getDefaultTimezone(): string {
|
||||
return process.env.DEFAULT_TIMEZONE || 'UTC';
|
||||
}
|
||||
|
||||
function isValidIanaTimezone(tz: unknown): tz is string {
|
||||
if (typeof tz !== 'string' || tz.length === 0 || tz.length > 64) return false;
|
||||
try {
|
||||
const zones = Intl.supportedValuesOf('timeZone');
|
||||
return zones.includes(tz);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const result = await postgresClient.query<{ timezone: string | null }>(
|
||||
'SELECT timezone FROM "user" WHERE id = $1',
|
||||
[session!.user.id],
|
||||
);
|
||||
const stored = result.rows[0]?.timezone;
|
||||
const fallback = getDefaultTimezone();
|
||||
const timezone = stored && stored.length > 0 ? stored : fallback;
|
||||
const source: 'user' | 'default' =
|
||||
stored && stored.length > 0 && stored !== fallback ? 'user' : 'default';
|
||||
return NextResponse.json({ timezone, source });
|
||||
} catch (e) {
|
||||
console.error('GET /api/me/timezone failed:', e);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read timezone', message: e instanceof Error ? e.message : 'unknown' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest): Promise<NextResponse> {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid JSON', message: 'Request body must be JSON' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const candidate =
|
||||
body && typeof body === 'object' && 'timezone' in body
|
||||
? (body as { timezone: unknown }).timezone
|
||||
: undefined;
|
||||
|
||||
if (!isValidIanaTimezone(candidate)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid timezone',
|
||||
message: "timezone must be an IANA zone present in Intl.supportedValuesOf('timeZone')",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Authoritative write target: session.user.id. NO userId from body.
|
||||
const result = await postgresClient.query<{ timezone: string }>(
|
||||
'UPDATE "user" SET timezone = $1, updated_at = NOW() WHERE id = $2 RETURNING timezone',
|
||||
[candidate, session!.user.id],
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found', message: 'No user row matched the session' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ timezone: result.rows[0].timezone });
|
||||
} catch (e) {
|
||||
console.error('PUT /api/me/timezone failed:', e);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update timezone', message: e instanceof Error ? e.message : 'unknown' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Notes:
|
||||
- The route is `/api/me/timezone` (matches the orchestrator's spec and the
|
||||
Task 1 audit).
|
||||
- `Intl.supportedValuesOf('timeZone')` is called per request. It returns a
|
||||
static ~600-entry array; V8 caches internally. No module-scope memo
|
||||
needed (would also miss tzdata updates between Node restarts).
|
||||
- The `source` field on GET helps the future Phase 9 picker show "(default)".
|
||||
A row equal to the env default is reported as 'default' even if it was a
|
||||
no-op write — intentional and acceptable.
|
||||
- 64-char length cap is belt-and-suspenders before the IANA whitelist.
|
||||
- Do NOT add Zod (Pulse convention, CLAUDE.md API routes section).
|
||||
- UPDATE writes `updated_at = NOW()` to match audit-column conventions used
|
||||
throughout the codebase.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/me/timezone/route\.ts"); [ -z "$ERR" ] && grep -q "export async function GET" app/api/me/timezone/route.ts && grep -q "export async function PUT" app/api/me/timezone/route.ts && grep -q "Intl.supportedValuesOf('timeZone')" app/api/me/timezone/route.ts && grep -q 'UPDATE "user" SET timezone' app/api/me/timezone/route.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists at exact path `app/api/me/timezone/route.ts`
|
||||
- File exports `GET` (no params) and `PUT` (NextRequest param)
|
||||
- File imports `requireAuth` from `@/lib/auth-utils` and uses it as the FIRST line of each handler
|
||||
- File contains the literal `Intl.supportedValuesOf('timeZone')`
|
||||
- File contains the literal `UPDATE "user" SET timezone = $1, updated_at = NOW() WHERE id = $2`
|
||||
- File contains NO `userId` parameter parsing — only `session.user.id` is used as the WHERE id target
|
||||
- File does NOT import `zod` or `z` from `zod`
|
||||
- `npx tsc --noEmit --pretty` reports no NEW errors in this file
|
||||
- Behavioral (manual once running):
|
||||
- `curl -X GET http://localhost:3100/api/me/timezone` (no cookie) returns HTTP 401
|
||||
- `curl -X PUT -H 'Content-Type: application/json' -d '{"timezone":"Etc/Garbage"}' http://localhost:3100/api/me/timezone` (with valid cookie) returns HTTP 400
|
||||
- `curl -X PUT -H 'Content-Type: application/json' -d '{"timezone":"America/New_York"}' http://localhost:3100/api/me/timezone` (with valid cookie) returns HTTP 200 with `{"timezone":"America/New_York"}`
|
||||
- `curl -X GET http://localhost:3100/api/me/timezone` (with valid cookie, after the PUT above) returns HTTP 200 with `{"timezone":"America/New_York","source":"user"}`
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
GET /api/me/timezone returns the calling user's stored tz (or env default)
|
||||
with a `source` discriminator. PUT validates the input against
|
||||
`Intl.supportedValuesOf('timeZone')`, persists only to `session.user.id`'s
|
||||
row, and returns the stored value. Unauthenticated calls return 401.
|
||||
Invalid tz strings return 400. The route is the SOLE write surface for
|
||||
`user.timezone`.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → API route | Untrusted JSON body crosses here on PUT |
|
||||
| Session cookie → handler | Better Auth cookie carries the authoritative user identity |
|
||||
| Handler → Postgres | Parametrized writes; the handler's `id` parameter MUST come from the verified session, never from the request body |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07.1-02-01 | Tampering | PUT body — arbitrary timezone string | mitigate | `isValidIanaTimezone()` rejects anything not in `Intl.supportedValuesOf('timeZone')` and anything longer than 64 chars; returns 400 before any DB call. |
|
||||
| T-07.1-02-02 | Spoofing | Cross-user write (PUT updating someone else's row) | mitigate | UPDATE WHERE clause uses `session!.user.id` exclusively. The handler does NOT read or accept any `userId` field from query string, body, or headers. Test: a request body of `{"timezone":"Etc/UTC","userId":"someone-else"}` writes to the caller's own row only. |
|
||||
| T-07.1-02-03 | Information Disclosure | Unauthenticated read of user's tz | mitigate | `requireAuth()` is the FIRST statement of GET. Returns 401 without touching the DB. |
|
||||
| T-07.1-02-04 | Denial of Service | Repeated PUTs spamming the user table | accept | Rate limiting is out of scope for this phase; Pulse has no global rate limiter today. The UPDATE is O(1) on a tiny table. If abuse becomes a concern, add a per-session limiter in a follow-up. |
|
||||
| T-07.1-02-05 | Repudiation | Audit of who set what tz | accept | `updated_at = NOW()` records when the change happened. We do NOT log the old→new value pair; user-controlled timezone is low-sensitivity. |
|
||||
| T-07.1-02-06 | Elevation of Privilege | An admin endpoint masquerading as /api/me | accept | Route lives at the user-self path; no admin-targeted user-id parameter is accepted, so there is no role-confusion surface here. |
|
||||
| T-07.1-02-07 | Tampering | SQL injection via timezone string | mitigate | Parameterized query (`$1`, `$2`); the value is also pre-validated against the IANA whitelist (no injection-shaped strings will pass `Intl.supportedValuesOf` membership). |
|
||||
| T-07.1-02-08 | Tampering | JSON parse errors crashing the handler | mitigate | `try { await request.json() } catch` returns a 400 on invalid JSON instead of letting the framework return a 500. |
|
||||
| T-07.1-02-09 | Information Disclosure | Middleware leaking /api/me as public | mitigate | Task 1 audits middleware.ts and asserts no publicRoutes entry prefixes /api/me. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
End-to-end checks for this plan:
|
||||
|
||||
1. Static: `grep -nE '"/api/me' middleware.ts` returns nothing.
|
||||
2. Static: `grep -E "Intl.supportedValuesOf\\('timeZone'\\)" app/api/me/timezone/route.ts` returns one line.
|
||||
3. Static: `grep -E 'WHERE id = \$2' app/api/me/timezone/route.ts` returns the PUT handler's UPDATE.
|
||||
4. Static: `grep -E 'userId|user_id' app/api/me/timezone/route.ts` returns nothing (no cross-user write surface).
|
||||
5. Type: `ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/me/timezone/route\.ts"); [ -z "$ERR" ]` (TS errors in this file fail the check; pre-existing errors elsewhere in the codebase are out of scope for Phase 7.1).
|
||||
6. Runtime (with the dev server running and a logged-in cookie in `curl`):
|
||||
- GET unauthenticated → 401 JSON `{"error":"Unauthorized"}`
|
||||
- PUT with `{"timezone":"Etc/Garbage"}` → 400 JSON `{"error":"Invalid timezone",...}`
|
||||
- PUT with `{"timezone":"America/New_York"}` → 200 JSON `{"timezone":"America/New_York"}`
|
||||
- GET after the successful PUT → 200 JSON `{"timezone":"America/New_York","source":"user"}`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- New `app/api/me/timezone/route.ts` exports working GET and PUT handlers
|
||||
- Validation rejects non-IANA strings with HTTP 400
|
||||
- Auth gates reject unauthenticated requests with HTTP 401
|
||||
- Write target is exclusively `session.user.id` — no user-supplied id parameter
|
||||
- middleware.ts is unchanged and confirmed not to leak /api/me/* to publicRoutes
|
||||
- TypeScript compiles for the new file
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-SUMMARY.md`
|
||||
documenting: the exact response shapes for GET and PUT, the validation rule
|
||||
(IANA whitelist + length cap), the threat-model dispositions actually
|
||||
implemented, and any deviation from the plan (e.g. did the middleware audit
|
||||
turn up something unexpected?).
|
||||
</output>
|
||||
</content>
|
||||
</invoke>
|
||||
|
|
@ -0,0 +1,669 @@
|
|||
---
|
||||
phase: 07.1-user-timezone-fix-inserted-urgent
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [07.1-01]
|
||||
files_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
|
||||
autonomous: true
|
||||
requirements: [TZ-02]
|
||||
requirements_addressed: [TZ-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "GET /api/dashboard/overview computes 'today/yesterday/last 7 days' against the calling user's tz, not server UTC"
|
||||
- "GET /api/mobile/dashboard computes 'opened today / resolved today / SLA breaches' against the calling user's tz"
|
||||
- "GET /api/dashboard/trends returns daily ticket counts and time-entry hours with day buckets aligned to the calling user's timezone, not UTC"
|
||||
- "GET /api/mobile/finance computes 'paid_mtd' / 'paid_ytd' / aging buckets against the calling user's tz (after the route is auth-gated by this plan)"
|
||||
- "GET /api/mobile/finance now requires authentication (requireAuth() returns 401 to anonymous callers); previously-authenticated browser sessions reach the route unchanged via session cookie"
|
||||
- "GET /api/mobile/engagement/summary computes the rolling D7/D30/D90 time-entries window against the calling user's tz"
|
||||
- "GET /api/mobile/engagement/trend produces day buckets aligned to the calling user's tz (point N corresponds to the user-tz day, not UTC day)"
|
||||
- "Engagement summary's snapshot-derived counters (D7/D30/D90 active users, total MS Graph hours) bucket by UTC at sync time; only the rolling time-entries window uses user-tz (per TZ-02 carve-out — see REQUIREMENTS.md)"
|
||||
- "Dashboard ticket counters (`opened_today`, `resolved_today` etc. in `/api/dashboard/overview` and `/api/mobile/dashboard`) are bucketed against the viewing user's timezone. The mobile and desktop ticket *list* pages do not currently expose today/7d/30d range filters; if/when those filters are added, they MUST consume `useUserTimezone()` from Phase 7.1"
|
||||
- "All affected routes still require auth (existing requireAuth() preserved; finance gains it for the first time)"
|
||||
- "Storage timezone of every TIMESTAMP column on disk is unchanged (no schema migration in this plan)"
|
||||
artifacts:
|
||||
- path: "lib/services/user-timezone.ts"
|
||||
provides: "Server-side helper getUserTimezone(session) returning a validated IANA string with safe fallback"
|
||||
exports: ["getUserTimezone", "DEFAULT_TIMEZONE_FALLBACK"]
|
||||
- path: "app/api/mobile/dashboard/route.ts"
|
||||
provides: "Dashboard KPIs scoped to user-tz day boundaries"
|
||||
contains: "getUserTimezone"
|
||||
- path: "app/api/dashboard/overview/route.ts"
|
||||
provides: "Desktop dashboard overview scoped to user-tz day boundaries"
|
||||
contains: "getUserTimezone"
|
||||
- path: "app/api/dashboard/trends/route.ts"
|
||||
provides: "Desktop dashboard trends with daily buckets aligned to the calling user's tz"
|
||||
contains: "getUserTimezone"
|
||||
- path: "app/api/mobile/finance/route.ts"
|
||||
provides: "Mobile finance summary scoped to user-tz month boundaries; now auth-gated via requireAuth()"
|
||||
contains: "getUserTimezone"
|
||||
- path: "app/api/mobile/engagement/summary/route.ts"
|
||||
provides: "Mobile engagement summary with user-tz window math (rolling time_entries only; snapshot bucketing remains UTC by design)"
|
||||
contains: "getUserTimezone"
|
||||
- path: "app/api/mobile/engagement/trend/route.ts"
|
||||
provides: "Mobile engagement trend bucketed in user-tz days"
|
||||
contains: "getUserTimezone"
|
||||
key_links:
|
||||
- from: "Each affected route"
|
||||
to: "session.user.timezone via lib/services/user-timezone.ts"
|
||||
via: "import { getUserTimezone } and call it after requireAuth()"
|
||||
pattern: "getUserTimezone"
|
||||
- from: "SQL queries"
|
||||
to: "Postgres timezone-aware day boundaries"
|
||||
via: "(value AT TIME ZONE 'UTC') AT TIME ZONE $tz idiom"
|
||||
pattern: "AT TIME ZONE"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Switch every server-side day/week/month boundary computation in the affected
|
||||
read paths from server UTC to the calling user's IANA timezone. Storage stays
|
||||
UTC; only the WHERE clauses and DATE_TRUNC arguments change. This plan also
|
||||
adds `requireAuth()` to `/api/mobile/finance` (a 5-line hardening that aligns
|
||||
it with every other `/api/mobile/*` route) so it can use the proper
|
||||
`getUserTimezone(session)` resolution path instead of the env-default fallback.
|
||||
|
||||
Purpose: Resolve TZ-02 server-side. The six routes touched here are the ones
|
||||
that surfaced the bug (dashboards and filters showing wrong dates) per the
|
||||
phase scope context. The Plan 04 client work follows up on TZ-02 client-side +
|
||||
TZ-04.
|
||||
|
||||
Output: A single shared helper `lib/services/user-timezone.ts` and edits to
|
||||
six existing route handlers (the four originally listed plus
|
||||
`/api/dashboard/trends`, which the first revision pass missed). No new
|
||||
endpoints. No schema changes.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@CLAUDE.md
|
||||
@lib/auth-utils.ts
|
||||
@lib/auth.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
|
||||
|
||||
<interfaces>
|
||||
After Plan 01 ships, `session.user.timezone: string` is on the Better Auth
|
||||
`User` type. Before that, it's not. This plan therefore depends on Plan 01.
|
||||
|
||||
The Postgres idiom for "day boundary in user tz" is:
|
||||
|
||||
-- "Today" in user's local zone, comparing a UTC-stored timestamp:
|
||||
WHERE create_date AT TIME ZONE $tz_param >= DATE_TRUNC('day', NOW() AT TIME ZONE $tz_param)
|
||||
AND create_date AT TIME ZONE $tz_param < DATE_TRUNC('day', NOW() AT TIME ZONE $tz_param) + INTERVAL '1 day'
|
||||
|
||||
Or, more compactly:
|
||||
|
||||
WHERE (create_date AT TIME ZONE $tz_param)::date = (NOW() AT TIME ZONE $tz_param)::date
|
||||
|
||||
Notes on Postgres `AT TIME ZONE` semantics:
|
||||
- For a `TIMESTAMP WITH TIME ZONE` (timestamptz) input: `value AT TIME ZONE 'America/New_York'` returns a `TIMESTAMP WITHOUT TIME ZONE` adjusted to that zone (correct for our purpose).
|
||||
- For a `TIMESTAMP WITHOUT TIME ZONE` input: `value AT TIME ZONE 'America/New_York'` ASSUMES the input is in `America/New_York` and returns a `timestamptz`. This is the wrong direction for us.
|
||||
- The Pulse `tickets`, `qbo_invoices`, `engagement_snapshots`, and `time_entries` tables use `TIMESTAMP WITHOUT TIME ZONE` for their date columns (per the existing 069/070/079 migrations and confirmed by the routes using `::date` casts directly). UTC-stored. So the correct idiom is `(value AT TIME ZONE 'UTC') AT TIME ZONE $tz`.
|
||||
|
||||
Using a TWO-STEP convert is the safe canonical form regardless of column type:
|
||||
|
||||
-- "today" in user tz:
|
||||
(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
|
||||
|
||||
-- "row in today (user tz)":
|
||||
((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
|
||||
|
||||
This works for both `timestamp` and `timestamptz` columns:
|
||||
- For `timestamptz`, `AT TIME ZONE 'UTC'` returns a `timestamp` already in UTC.
|
||||
- For `timestamp` (assumed UTC, which is Pulse's convention per CLAUDE.md), `AT TIME ZONE 'UTC'` interprets the value as UTC and returns a `timestamptz` representing that instant; the second `AT TIME ZONE $1` then shifts it to the user zone.
|
||||
|
||||
Use this two-step idiom in every replacement.
|
||||
|
||||
Postgres validates the IANA string at query time and throws `invalid_parameter_value` for unknown zones. Since `session.user.timezone` is constrained at write time by Plan 02's PUT validation (and at read time by `getUserTimezone()`'s safe fallback below), we never expect that error in practice — but it's also not catastrophic if it ever fires; the catch block returns 500 like any other DB error.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create lib/services/user-timezone.ts helper</name>
|
||||
<files>lib/services/user-timezone.ts</files>
|
||||
<read_first>
|
||||
- lib/auth-utils.ts (the `requireAuth` return shape and `UserWithRole` type — note `[key: string]: unknown` so `timezone` is accessible without a type cast)
|
||||
- lib/auth.ts (the additionalFields block, after Plan 01 — confirms `timezone` is on `session.user` and is a string)
|
||||
- CLAUDE.md (Layout / Conventions — `lib/services/` is the right home for shared server helpers)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `lib/services/user-timezone.ts` with EXACTLY this content:
|
||||
|
||||
// Server-side helper for resolving the calling user's IANA timezone.
|
||||
//
|
||||
// After Phase 7.1 Plan 01, `session.user.timezone` is a string populated
|
||||
// either from the stored `"user".timezone` column or from Better Auth's
|
||||
// additionalField `defaultValue` (`process.env.DEFAULT_TIMEZONE || 'UTC'`).
|
||||
//
|
||||
// This helper:
|
||||
// - reads the value off a Better Auth session
|
||||
// - validates it against `Intl.supportedValuesOf('timeZone')` (defence in
|
||||
// depth — Plan 02 already validates writes, but a corrupt row from
|
||||
// before this phase, or a manual SQL edit, must not crash dashboards)
|
||||
// - falls back to `process.env.DEFAULT_TIMEZONE || 'UTC'` if invalid
|
||||
//
|
||||
// Use this in every API route that does day/week/month boundary math.
|
||||
|
||||
export const DEFAULT_TIMEZONE_FALLBACK = (): string =>
|
||||
process.env.DEFAULT_TIMEZONE || 'UTC';
|
||||
|
||||
type SessionLike = {
|
||||
user?: { timezone?: unknown } | null;
|
||||
} | null | undefined;
|
||||
|
||||
function isValidIanaTimezone(tz: unknown): tz is string {
|
||||
if (typeof tz !== 'string' || tz.length === 0 || tz.length > 64) return false;
|
||||
try {
|
||||
return Intl.supportedValuesOf('timeZone').includes(tz);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a validated IANA timezone string for the given session.
|
||||
* Never throws; always returns a usable string (worst case: 'UTC').
|
||||
*/
|
||||
export function getUserTimezone(session: SessionLike): string {
|
||||
const raw = session?.user?.timezone;
|
||||
if (isValidIanaTimezone(raw)) return raw;
|
||||
return DEFAULT_TIMEZONE_FALLBACK();
|
||||
}
|
||||
|
||||
Notes:
|
||||
- Do NOT import from `@/lib/auth-utils` here (would create a circular module
|
||||
graph for routes that already import requireAuth). Accept a duck-typed
|
||||
session.
|
||||
- The helper is intentionally pure and synchronous — no DB calls, no env
|
||||
lookups beyond the fallback. Routes already have the session in hand from
|
||||
requireAuth(), so we just pass it in.
|
||||
- DEFAULT_TIMEZONE_FALLBACK is a function (not a const) so test code can
|
||||
override `process.env.DEFAULT_TIMEZONE` between calls without resetting
|
||||
module state.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "lib/services/user-timezone\.ts"); [ -z "$ERR" ] && grep -q "export function getUserTimezone" lib/services/user-timezone.ts && grep -q "Intl.supportedValuesOf('timeZone')" lib/services/user-timezone.ts && grep -q "DEFAULT_TIMEZONE_FALLBACK" lib/services/user-timezone.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists at `lib/services/user-timezone.ts`
|
||||
- Exports a function named `getUserTimezone`
|
||||
- Exports a function named `DEFAULT_TIMEZONE_FALLBACK`
|
||||
- Contains the literal `Intl.supportedValuesOf('timeZone')`
|
||||
- Contains the literal `process.env.DEFAULT_TIMEZONE || 'UTC'`
|
||||
- Does NOT import from `@/lib/auth-utils` (no circular)
|
||||
- Does NOT import `pg` or `postgresClient` (no DB)
|
||||
- `npx tsc --noEmit --pretty` reports no errors in this file
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
`getUserTimezone(session)` is available to every server route. Given a
|
||||
session with `user.timezone === 'America/New_York'`, returns
|
||||
`'America/New_York'`. Given a session with garbage or missing tz, returns
|
||||
the env default (or `'UTC'`).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Migrate /api/mobile/dashboard and /api/dashboard/overview to user-tz day math</name>
|
||||
<files>app/api/mobile/dashboard/route.ts, app/api/dashboard/overview/route.ts</files>
|
||||
<read_first>
|
||||
- app/api/mobile/dashboard/route.ts (lines 48-133 — the SQL block; note the existing UTC anchors at lines 70-76: `create_date::date = CURRENT_DATE`, `completed_date::date = CURRENT_DATE`, and the SLA-breach `due_date_time < NOW()` line)
|
||||
- app/api/dashboard/overview/route.ts (lines 38-78 — same idiom: `create_date::date = CURRENT_DATE`, `CURRENT_DATE - INTERVAL '1 day'`, `CURRENT_DATE - INTERVAL '7 days'`)
|
||||
- lib/services/user-timezone.ts (created in Task 1 — the import target)
|
||||
- The interfaces block above (the `(value AT TIME ZONE 'UTC') AT TIME ZONE $tz`::date idiom — apply verbatim)
|
||||
</read_first>
|
||||
<action>
|
||||
Two route files. Edit them in this order:
|
||||
|
||||
--- A: app/api/mobile/dashboard/route.ts ---
|
||||
|
||||
1. Add import at the top of the imports block:
|
||||
`import { getUserTimezone } from '@/lib/services/user-timezone';`
|
||||
2. After the `requireAuth()` line in `GET()`, add:
|
||||
`const tz = getUserTimezone(session);`
|
||||
(note: `requireAuth()` currently destructures only `error` — change it
|
||||
to `const { session, error } = await requireAuth(); if (error) return error;`)
|
||||
3. The KPI snapshot query at lines 67-80 has TWO occurrences of
|
||||
`create_date::date = CURRENT_DATE` and `completed_date::date = CURRENT_DATE`,
|
||||
plus an `AND due_date_time < NOW()` clause. Replace as follows
|
||||
(parametrize tz as `$1`):
|
||||
|
||||
Before:
|
||||
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
|
||||
AND due_date_time IS NOT NULL
|
||||
AND due_date_time < NOW()
|
||||
)::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)
|
||||
|
||||
After (pass `[tz]` as the params arg to `postgresClient.query<...>`):
|
||||
SELECT
|
||||
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
|
||||
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)
|
||||
|
||||
The `due_date_time < NOW()` clause stays as-is (compares two
|
||||
UTC-relative instants — the SLA-breach concept is "is this ticket past
|
||||
its due time RIGHT NOW", which is timezone-independent).
|
||||
|
||||
4. The other queries in the Promise.all (failed backups 24h, stalled
|
||||
workflows 5min, analyzer 1h, RMM 1h, backup success 24h) all use
|
||||
`INTERVAL '24 hours'` / `INTERVAL '5 minutes'` / `INTERVAL '1 hour'`
|
||||
with `NOW() - INTERVAL ...`. These compare UTC instants to UTC
|
||||
instants — they are NOT day-boundary calculations. DO NOT MODIFY THEM.
|
||||
Add a code comment immediately above the failed-backups query
|
||||
confirming this:
|
||||
// INTERVAL '24 hours' here is rolling — not a calendar-day boundary —
|
||||
// so timezone does not apply. Do not migrate to user-tz.
|
||||
|
||||
--- B: app/api/dashboard/overview/route.ts ---
|
||||
|
||||
1. Add the import:
|
||||
`import { getUserTimezone } from '@/lib/services/user-timezone';`
|
||||
2. Inside `GET()`, after the `requireAuth()` call, change the destructure
|
||||
to `const { session, error } = await requireAuth(); if (error) return error;`
|
||||
and add `const tz = getUserTimezone(session);`.
|
||||
3. The `today` query at lines 38-57: same idiom as A.3 above. Replace
|
||||
`WHERE create_date::date = CURRENT_DATE` and
|
||||
`WHERE completed_date::date = CURRENT_DATE` with the user-tz forms,
|
||||
parametrize as `$1`, pass `[tz]`. The `due_date_time < NOW()` clause
|
||||
stays as-is.
|
||||
4. The `yesterdayOpened` query at lines 59-65 currently:
|
||||
WHERE create_date::date = CURRENT_DATE - INTERVAL '1 day'
|
||||
Replace with (parametrized `[tz]`):
|
||||
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'
|
||||
5. The `last7AvgResolvedRes` query at lines 67-78:
|
||||
WHERE completed_date >= CURRENT_DATE - INTERVAL '7 days'
|
||||
AND completed_date < CURRENT_DATE
|
||||
GROUP BY completed_date::date
|
||||
Replace with:
|
||||
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
|
||||
GROUP BY ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date
|
||||
Pass `[tz]` to the query.
|
||||
6. The remaining queries in this route (linkConflicts, itglueUnlinked,
|
||||
s1Unmapped, schedules, observations, audits, syncHealth, companies, ci,
|
||||
xref) do NOT use day-boundary math. DO NOT MODIFY THEM.
|
||||
|
||||
Both files: keep the existing `try/catch` shape, the existing
|
||||
`NextResponse.json` envelope, the existing `Promise.all` ordering, and the
|
||||
existing return shapes. Only the SQL strings and the new `tz` parameter
|
||||
change. No new exports.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/(mobile/)?dashboard/(route|overview/route)\.ts"); [ -z "$ERR" ] && [ "$(grep -c 'AT TIME ZONE' app/api/mobile/dashboard/route.ts)" -ge 2 ] && [ "$(grep -c 'AT TIME ZONE' app/api/dashboard/overview/route.ts)" -ge 4 ] && grep -q "getUserTimezone" app/api/mobile/dashboard/route.ts && grep -q "getUserTimezone" app/api/dashboard/overview/route.ts && ! grep -E '::date = CURRENT_DATE|create_date::date = CURRENT_DATE - INTERVAL' app/api/dashboard/overview/route.ts && ! grep -E '::date = CURRENT_DATE' app/api/mobile/dashboard/route.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Both files import `getUserTimezone` from `@/lib/services/user-timezone`
|
||||
- Both files destructure `session` from `requireAuth()` and pass it to `getUserTimezone`
|
||||
- `app/api/mobile/dashboard/route.ts` no longer contains the literal `::date = CURRENT_DATE`
|
||||
- `app/api/dashboard/overview/route.ts` no longer contains `::date = CURRENT_DATE` (today, yesterday, or 7-day-avg variants)
|
||||
- `app/api/mobile/dashboard/route.ts` contains at least 2 occurrences of `AT TIME ZONE`
|
||||
- `app/api/dashboard/overview/route.ts` contains at least 4 occurrences of `AT TIME ZONE`
|
||||
- The `due_date_time < NOW()` clauses are PRESERVED (rolling-now SLA check is tz-independent)
|
||||
- The `INTERVAL '24 hours'` / `INTERVAL '5 minutes'` / `INTERVAL '1 hour'` queries are PRESERVED unchanged
|
||||
- `npx tsc --noEmit --pretty` reports no NEW errors in either file
|
||||
- Behavioral (manual once running):
|
||||
- With user.timezone = 'America/New_York' and a ticket created at 2026-05-07T03:30:00Z (which is 2026-05-06 23:30 ET), the `opened_today` count for that user includes that ticket on 2026-05-06 ET — NOT on 2026-05-07 ET
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
`/api/mobile/dashboard` and `/api/dashboard/overview` compute "today",
|
||||
"yesterday", and "last 7 days" against the calling user's tz. SLA
|
||||
breach-and rolling-window metrics are unchanged. No new endpoints were
|
||||
added; no schema migrations ran.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Migrate /api/mobile/finance (with auth-gate hardening) and the engagement endpoints to user-tz boundaries</name>
|
||||
<files>app/api/mobile/finance/route.ts, app/api/mobile/engagement/summary/route.ts, app/api/mobile/engagement/trend/route.ts</files>
|
||||
<read_first>
|
||||
- app/api/mobile/finance/route.ts (full file — note it currently has NO requireAuth() call; this plan adds it)
|
||||
- app/mobile/finance/page.tsx (the consumer — confirm it uses a session-cookie-bearing fetch with no extra Authorization header; that's the default for browser fetches to same-origin Next.js routes, and Better Auth's session cookie travels automatically — no changes needed on the page)
|
||||
- lib/auth-utils.ts (`requireAuth()` shape — copy from `/api/mobile/engagement/summary/route.ts`)
|
||||
- app/api/mobile/engagement/summary/route.ts (the `interval` map at lines 53-58 and the `te.entry_date >= NOW() - INTERVAL '${interval}'` line at 122 — that's the calendar-window seam; also the `period_end = $2` join on snapshots, which is a stored DATE so tz doesn't apply there)
|
||||
- app/api/mobile/engagement/trend/route.ts (the `generate_series` block at lines 46-72 — `CURRENT_DATE` is the seam)
|
||||
- lib/services/user-timezone.ts (the helper from Task 1)
|
||||
</read_first>
|
||||
<action>
|
||||
Three files.
|
||||
|
||||
--- A: app/api/mobile/finance/route.ts ---
|
||||
|
||||
The route currently has NO auth. That has been an outstanding pre-existing
|
||||
gap; this plan fixes it as a 5-line change because (a) every other
|
||||
`/api/mobile/*` route already uses `requireAuth()`, (b) the consumer at
|
||||
`app/mobile/finance/page.tsx` fetches via the browser with the Better
|
||||
Auth session cookie automatically attached, so adding the gate does not
|
||||
break the existing UI, and (c) once the gate is in place we can resolve
|
||||
the calling user's tz the proper way (`getUserTimezone(session)`) instead
|
||||
of the env-default fallback.
|
||||
|
||||
Steps:
|
||||
1. Add imports:
|
||||
`import { requireAuth } from '@/lib/auth-utils';`
|
||||
`import { getUserTimezone } from '@/lib/services/user-timezone';`
|
||||
2. As the FIRST line of the existing `GET()` handler (before
|
||||
`Promise.all`), add:
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const tz = getUserTimezone(session);
|
||||
Add a code comment immediately above the requireAuth call:
|
||||
// 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.
|
||||
3. The `summary` query (lines 6-17): replace
|
||||
DATE_TRUNC('month', NOW()) → DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)
|
||||
DATE_TRUNC('year', NOW()) → DATE_TRUNC('year', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)
|
||||
AND change the comparison operands so both sides are in the same tz —
|
||||
`txn_date` is `TIMESTAMP WITHOUT TIME ZONE` (UTC-stored), so:
|
||||
txn_date >= DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)
|
||||
compares a UTC timestamp to a `timestamp` (without tz) in user-zone —
|
||||
semantically wrong. Correct form (compare like with like):
|
||||
(txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)
|
||||
Apply this transformation to BOTH `paid_mtd` and `paid_ytd` filters.
|
||||
Pass `[tz]` as the params arg to `postgresClient.query`.
|
||||
4. The `aging` query (lines 18-29) uses `due_date >= CURRENT_DATE - 30`
|
||||
etc. `due_date` is a `DATE` (date-only, not a timestamp). For DATE
|
||||
columns, `CURRENT_DATE` is server-local (UTC in our deploy) and
|
||||
comparing user-tz "today" to a stored DATE column is the right move:
|
||||
CURRENT_DATE → (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
|
||||
Apply this transformation to all SIX comparisons in the aging query
|
||||
(`days_1_30`, `cnt_1_30`, `days_31_60`, `cnt_31_60`, `days_60_plus`,
|
||||
`cnt_60_plus`). Pass `[tz]` as params.
|
||||
5. The `overdueInvoices` query (lines 37-43) has
|
||||
CURRENT_DATE - due_date::date as days_overdue
|
||||
Replace `CURRENT_DATE` with `(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date`
|
||||
and pass `[tz]` as params.
|
||||
6. The `topCustomers`, `recentPayments`, `monthlyRevenue` queries do not
|
||||
use any day-boundary date math relative to "today/this month/this year"
|
||||
(monthlyRevenue uses `>= NOW() - INTERVAL '12 months'` which is a
|
||||
rolling window, NOT a calendar boundary — leave it). DO NOT MODIFY
|
||||
THESE THREE.
|
||||
|
||||
--- B: app/api/mobile/engagement/summary/route.ts ---
|
||||
|
||||
1. Add import: `import { getUserTimezone } from '@/lib/services/user-timezone';`
|
||||
2. After the existing `const { error: authError } = await requireAuth()`,
|
||||
change to:
|
||||
const { session, error: authError } = await requireAuth();
|
||||
if (authError) return authError;
|
||||
const tz = getUserTimezone(session);
|
||||
3. The Autotask-hours query (lines 117-127) currently uses:
|
||||
WHERE te.entry_date >= NOW() - INTERVAL '${interval}'
|
||||
`entry_date` is `TIMESTAMP WITHOUT TIME ZONE` (UTC-stored). The
|
||||
`interval` is one of '7 days', '30 days', '90 days'. Change the WHERE
|
||||
to anchor on user-tz "today":
|
||||
WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${interval}'
|
||||
Pass `[tz]` as the params arg (currently no params; add the array).
|
||||
Keep the SQL injection comment that's already in the file — the
|
||||
`${interval}` interpolation safety still applies.
|
||||
4. The other queries (`activeResult`, `graphHoursResult`) join on
|
||||
`period_end = $2` where `period_end` is a stored DATE (precomputed by
|
||||
the engagement-sync service against UTC). Period-bucket DATEs are
|
||||
NOT migrated by this phase — see "TZ-02 carve-out" note below. Add a
|
||||
code comment immediately above the `latestResult` query (around line
|
||||
37) — DO NOT MODIFY THESE QUERIES:
|
||||
// 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.
|
||||
|
||||
--- C: app/api/mobile/engagement/trend/route.ts ---
|
||||
|
||||
1. Add import: `import { getUserTimezone } from '@/lib/services/user-timezone';`
|
||||
2. After the `requireAuth()` call, destructure session and resolve tz:
|
||||
const { session, error: authError } = await requireAuth();
|
||||
if (authError) return authError;
|
||||
const tz = getUserTimezone(session);
|
||||
3. The `generate_series` SQL (lines 45-72) uses `CURRENT_DATE` four times.
|
||||
Replace EACH with `(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date`,
|
||||
parametrized as `$1`. Specifically:
|
||||
(CURRENT_DATE - INTERVAL '${days - 1} days')::date
|
||||
→ ((NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date
|
||||
CURRENT_DATE, -- 2nd arg of generate_series
|
||||
→ (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date,
|
||||
te.entry_date >= CURRENT_DATE - INTERVAL '${days - 1} days'
|
||||
→ (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 <= CURRENT_DATE
|
||||
→ AND (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date <= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
|
||||
GROUP BY te.entry_date::date
|
||||
→ GROUP BY (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
|
||||
And in the daily_hours.day reference downstream, use the same expression.
|
||||
4. Pass `[tz]` as the params arg to `postgresClient.query(sql, [tz])`.
|
||||
5. The existing comment at line 38 ("T-07-03 mitigation: period whitelist
|
||||
bounds the date range to max 90 days") still applies — keep it. Add an
|
||||
additional comment immediately below it:
|
||||
// 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/mobile/(finance|engagement/(summary|trend))/route\.ts"); [ -z "$ERR" ] && grep -q "requireAuth" app/api/mobile/finance/route.ts && grep -q "getUserTimezone" app/api/mobile/finance/route.ts && grep -q "getUserTimezone" app/api/mobile/engagement/summary/route.ts && grep -q "getUserTimezone" app/api/mobile/engagement/trend/route.ts && [ "$(grep -c 'AT TIME ZONE' app/api/mobile/finance/route.ts)" -ge 5 ] && [ "$(grep -c 'AT TIME ZONE' app/api/mobile/engagement/trend/route.ts)" -ge 3 ] && ! grep -E "DATE_TRUNC\('(month|year)', NOW\(\)\)" app/api/mobile/finance/route.ts && ! grep -wE "CURRENT_DATE" app/api/mobile/engagement/trend/route.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `app/api/mobile/finance/route.ts` imports `requireAuth` AND `getUserTimezone`; calls both at the top of `GET()`
|
||||
- `app/api/mobile/finance/route.ts` no longer contains `DATE_TRUNC('month', NOW())` or `DATE_TRUNC('year', NOW())` (note the original had a double-space)
|
||||
- `app/api/mobile/finance/route.ts` contains at least 5 occurrences of `AT TIME ZONE` (paid_mtd, paid_ytd, six aging filters, days_overdue — actually MORE than 5; tolerance: ≥ 5)
|
||||
- `app/api/mobile/engagement/summary/route.ts` imports `getUserTimezone`, destructures `session` from `requireAuth()`, passes session to `getUserTimezone`
|
||||
- `app/api/mobile/engagement/summary/route.ts` `time_entries` query parametrizes `tz` and uses `AT TIME ZONE` on both sides of the `>=` comparison
|
||||
- `app/api/mobile/engagement/summary/route.ts` contains the `TZ-02 carve-out` comment block referencing REQUIREMENTS.md
|
||||
- `app/api/mobile/engagement/trend/route.ts` imports `getUserTimezone`, destructures session, passes to helper
|
||||
- `app/api/mobile/engagement/trend/route.ts` no longer contains the bare token `CURRENT_DATE` (every occurrence becomes the AT TIME ZONE expression). Verify: `! grep -wE "CURRENT_DATE" app/api/mobile/engagement/trend/route.ts`
|
||||
- `app/api/mobile/engagement/trend/route.ts` passes `[tz]` to `postgresClient.query`
|
||||
- `npx tsc --noEmit --pretty` reports no NEW errors in any of the three files
|
||||
- Behavioral (manual once running):
|
||||
- `curl -s -o /dev/null -w '%{http_code}' http://localhost:3100/api/mobile/finance` returns `401` (no auth header)
|
||||
- With user.timezone = 'America/New_York' and a time_entries row at 2026-05-07T03:30:00Z (= 2026-05-06 23:30 ET), `/api/mobile/engagement/trend?period=D7` puts that row in the 2026-05-06 bucket — NOT 2026-05-07
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
`/api/mobile/finance` is now auth-gated and computes month / aging / days-overdue
|
||||
boundaries against the calling user's tz. `/api/mobile/engagement/summary`
|
||||
(the rolling time_entries window only — snapshots remain UTC by the
|
||||
explicit TZ-02 carve-out) and `/api/mobile/engagement/trend` both compute
|
||||
their day boundaries in user-tz.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Migrate /api/dashboard/trends to user-tz day buckets</name>
|
||||
<files>app/api/dashboard/trends/route.ts</files>
|
||||
<read_first>
|
||||
- app/api/dashboard/trends/route.ts (lines 21-98 — the four queries; note the two `generate_series(CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days', CURRENT_DATE, INTERVAL '1 day')::date` blocks at lines 27-32 and 44-49, the `t.create_date::date = days.d` join at line 38, the `t.completed_date::date = days.d` join at line 55, and the `te.entry_date::date = CURRENT_DATE` filter at line 92)
|
||||
- lib/services/user-timezone.ts (helper from Task 1)
|
||||
- lib/auth-utils.ts (`requireAuth()` already used by this route at line 22 — verify with `grep -q requireAuth app/api/dashboard/trends/route.ts`)
|
||||
</read_first>
|
||||
<action>
|
||||
`/api/dashboard/trends` was missed in the original plan but powers the
|
||||
desktop dashboard's chart row + queue posture. Migrate its day-bucket math
|
||||
to the same `(value AT TIME ZONE 'UTC') AT TIME ZONE $1` two-step idiom
|
||||
used in Tasks 2 and 3.
|
||||
|
||||
Steps:
|
||||
1. Confirm the route already calls `requireAuth()` (it does, line 22). If
|
||||
it doesn't, add it: import `requireAuth` from `@/lib/auth-utils`, call
|
||||
it as the first line of `GET()`, return `error` on failure.
|
||||
2. Add import:
|
||||
`import { getUserTimezone } from '@/lib/services/user-timezone';`
|
||||
3. Change the destructure on line 22 from `const { error } = await requireAuth();`
|
||||
to:
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
const tz = getUserTimezone(session);
|
||||
4. The `volumeRes` query (lines 26-42) — apply the user-tz substitutions
|
||||
and parametrize `tz` as `$1`:
|
||||
generate_series(
|
||||
CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days',
|
||||
CURRENT_DATE,
|
||||
INTERVAL '1 day'
|
||||
)::date AS d
|
||||
→
|
||||
generate_series(
|
||||
(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '${TREND_DAYS - 1} days',
|
||||
(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date,
|
||||
INTERVAL '1 day'
|
||||
)::date AS d
|
||||
And:
|
||||
ON t.create_date::date = days.d
|
||||
→
|
||||
ON ((t.create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = days.d
|
||||
Pass `[tz]` as the params arg.
|
||||
5. The `resolutionRes` query (lines 43-60) — same idiom, applied to the
|
||||
`generate_series` block AND the `t.completed_date::date = days.d` join:
|
||||
ON t.completed_date::date = days.d
|
||||
→
|
||||
ON ((t.completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = days.d
|
||||
Pass `[tz]` as the params arg.
|
||||
6. The `heatmapRes` query (lines 61-77) — does NOT use day-boundary math
|
||||
(filters on `t.completed_date IS NULL` only). DO NOT MODIFY.
|
||||
7. The `engineersRes` query (lines 78-97) — `te.entry_date::date = CURRENT_DATE`
|
||||
filter on line 92. Replace with the user-tz form and parametrize `tz`
|
||||
as `$1`:
|
||||
WHERE te.entry_date::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)::date
|
||||
Pass `[tz]` as the params arg. Note the existing `LIMIT ${TOP_ENGINEERS}`
|
||||
is a server-side constant interpolation — leave it.
|
||||
|
||||
Notes:
|
||||
- All four `postgresClient.query<...>(...)` calls take ONE bind value (`$1` =
|
||||
tz). Use `[tz]` consistently. The existing query signatures don't have
|
||||
a params arg today; add one.
|
||||
- Keep the existing types on each `query<>` generic. No shape changes to
|
||||
the response.
|
||||
- The `INTERVAL '${TREND_DAYS - 1} days'` interpolation is server-side
|
||||
constant — safe to leave as a JS template literal.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/dashboard/trends/route\.ts"); [ -z "$ERR" ] && grep -q requireAuth app/api/dashboard/trends/route.ts && grep -q "getUserTimezone" app/api/dashboard/trends/route.ts && [ "$(grep -c 'AT TIME ZONE' app/api/dashboard/trends/route.ts)" -ge 6 ] && ! grep -wE "CURRENT_DATE" app/api/dashboard/trends/route.ts && ! grep -E '\.create_date::date = days\.d|\.completed_date::date = days\.d|\.entry_date::date = CURRENT_DATE' app/api/dashboard/trends/route.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `app/api/dashboard/trends/route.ts` imports `getUserTimezone` from `@/lib/services/user-timezone`
|
||||
- The route destructures `session` from `requireAuth()` and resolves `tz` via `getUserTimezone(session)`
|
||||
- The route still calls `requireAuth()` first (auth gate preserved)
|
||||
- `app/api/dashboard/trends/route.ts` no longer contains the bare token `CURRENT_DATE`
|
||||
- `app/api/dashboard/trends/route.ts` no longer contains any of the literal patterns `t.create_date::date = days.d`, `t.completed_date::date = days.d`, or `te.entry_date::date = CURRENT_DATE`
|
||||
- `app/api/dashboard/trends/route.ts` contains at least 6 occurrences of `AT TIME ZONE` (two per migrated query × three migrated queries)
|
||||
- The `heatmapRes` query (queue/priority counts) is preserved unchanged
|
||||
- All migrated queries pass `[tz]` as the params arg
|
||||
- `npx tsc --noEmit --pretty` reports no NEW errors in this file
|
||||
- Behavioral (manual once running):
|
||||
- With user.timezone = 'America/New_York' and a ticket created at 2026-05-07T03:30:00Z, the `volumeByDay` count for 2026-05-06 (ET) includes that ticket — the 2026-05-07 (ET) bucket does not.
|
||||
- The trend covers exactly TREND_DAYS (30) consecutive ET days ending today (ET).
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
`/api/dashboard/trends` returns daily ticket counts and time-entry hours
|
||||
with day buckets aligned to the calling user's timezone, not UTC. The
|
||||
queue/priority heatmap is unchanged.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → API route | No new untrusted input; tz is read off the verified session |
|
||||
| Handler → Postgres | tz string is parameterized via `$N` — no string interpolation into SQL |
|
||||
| Stored row → handler | A corrupt `user.timezone` value (manual SQL edit, pre-Plan-02 row) is sanitized by `getUserTimezone()`'s IANA whitelist |
|
||||
| (NEW) Anonymous → /api/mobile/finance | This phase newly adds `requireAuth()` to a previously-public route |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07.1-03-01 | Tampering | SQL injection via tz parameter | mitigate | tz is passed as a parameterized `$N` value to `postgresClient.query`, never interpolated. |
|
||||
| T-07.1-03-02 | Tampering | Garbage tz from stored row crashing query | mitigate | `getUserTimezone()` validates against `Intl.supportedValuesOf('timeZone')` and falls back to `DEFAULT_TIMEZONE_FALLBACK()` before the SQL ever sees the value. Even if it slipped through, Postgres would throw and the existing try/catch returns a 500 (no crash). |
|
||||
| T-07.1-03-03 | Information Disclosure | Cross-user data leak via tz parameter | accept | tz only affects WHERE clause day boundaries — 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 read from wrong session | mitigate | Each handler reads tz from its own `requireAuth()` result; `getUserTimezone()` is pure and accepts only the passed-in session. No global state. |
|
||||
| T-07.1-03-05 | Denial of Service | `Intl.supportedValuesOf` per request | accept | V8 caches internally; the array is ~600 entries. Plan 02 already accepted this risk for the PUT endpoint. |
|
||||
| T-07.1-03-06 | Repudiation | Engagement snapshot bucketing left UTC | accept | Documented explicitly in REQUIREMENTS.md TZ-02 carve-out and re-asserted in code comment. The drift is bounded at ≤24h on an admin-overview surface; per-user snapshot bucketing is deferred (would require either per-request re-bucket or per-user snapshot rebuild). |
|
||||
| T-07.1-03-fin-auth | Spoofing / Information Disclosure | Previously-public `/api/mobile/finance` now requires auth | mitigate | Adding `requireAuth()` aligns this route with every other `/api/mobile/*` handler. Verifies authenticated callers can still reach it (no callsite breakage): `app/mobile/finance/page.tsx` is the sole consumer and uses a same-origin browser fetch — Better Auth's session cookie is set on every authenticated browser session and travels with the request automatically (no extra `Authorization` header is required). Acceptance criterion: anonymous `curl` returns 401; the existing `/mobile/finance` page renders unchanged for signed-in users. |
|
||||
| T-07.1-03-08 | Tampering | `/api/dashboard/trends` already has auth gate | accept | The route already imports `requireAuth()`; this plan only adds tz resolution after the existing gate. No new attack surface. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
End-to-end checks for this plan:
|
||||
|
||||
1. Static: `[ "$(grep -c 'AT TIME ZONE' app/api/mobile/dashboard/route.ts)" -ge 2 ]`
|
||||
2. Static: `[ "$(grep -c 'AT TIME ZONE' app/api/dashboard/overview/route.ts)" -ge 4 ]`
|
||||
3. Static: `[ "$(grep -c 'AT TIME ZONE' app/api/dashboard/trends/route.ts)" -ge 6 ]`
|
||||
4. Static: `[ "$(grep -c 'AT TIME ZONE' app/api/mobile/finance/route.ts)" -ge 5 ]`
|
||||
5. Static: `[ "$(grep -c 'AT TIME ZONE' app/api/mobile/engagement/trend/route.ts)" -ge 3 ]`
|
||||
6. Static: `! grep -E '::date = CURRENT_DATE' app/api/mobile/dashboard/route.ts app/api/dashboard/overview/route.ts`
|
||||
7. Static: `! grep -wE "CURRENT_DATE" app/api/mobile/engagement/trend/route.ts`
|
||||
8. Static: `! grep -wE "CURRENT_DATE" app/api/dashboard/trends/route.ts`
|
||||
9. Static: `! grep -E "DATE_TRUNC\\('(month|year)', NOW\\(\\)\\)" app/api/mobile/finance/route.ts`
|
||||
10. Static (auth-gate hardening): `grep -q "requireAuth" app/api/mobile/finance/route.ts`
|
||||
11. Static (no UTC-bucketed ticket-list filters were missed by SC#2 mapping): `! grep -rE "\\.create_date >= NOW\\(\\) - INTERVAL.*'(today|day|hour)" app/api/tickets/ app/api/mobile/tickets/`
|
||||
12. Type: per-file `npx tsc --noEmit --pretty` reports no NEW errors for the six modified files.
|
||||
13. Runtime (auth gate): `curl -s -o /dev/null -w '%{http_code}' http://localhost:3100/api/mobile/finance` returns `401`.
|
||||
14. Runtime (user-tz buckets): With `DEFAULT_TIMEZONE=UTC` and the calling user's `timezone='America/New_York'`, hitting `/api/mobile/engagement/trend?period=D7` returns exactly 7 points whose `date` strings are the most recent 7 calendar days in ET (verifiable by setting the user's tz to UTC vs ET and diffing the returned `date` arrays around midnight ET).
|
||||
15. Runtime (trends): With the same user-tz settings, `/api/dashboard/trends` returns `volumeByDay` with TREND_DAYS rows ending on today (ET).
|
||||
16. Storage: `SELECT data_type FROM information_schema.columns WHERE table_name IN ('tickets','qbo_invoices','time_entries','engagement_snapshots') AND column_name LIKE '%date%'` shows the same `timestamp without time zone` / `date` types as before this plan ran.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All six route files compute day/week/month boundaries against the calling user's tz
|
||||
- The shared helper `lib/services/user-timezone.ts` is the only source of truth for resolving tz from a session
|
||||
- Storage tz of every column on disk is unchanged
|
||||
- Rolling-window queries (`INTERVAL '24 hours'`, `INTERVAL '5 minutes'`, `INTERVAL '1 hour'`, `INTERVAL '12 months'`) are preserved unchanged
|
||||
- Engagement snapshot bucketing left UTC by the explicit TZ-02 carve-out documented in REQUIREMENTS.md and code
|
||||
- `/api/mobile/finance` now requires auth (aligned with every other `/api/mobile/*` route)
|
||||
- `/api/dashboard/trends` daily buckets are user-tz aligned (was missed by the original plan)
|
||||
- TypeScript compiles for every modified file
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-03-SUMMARY.md`
|
||||
documenting: the helper signature, the canonical SQL idiom used (the two-step
|
||||
`AT TIME ZONE 'UTC' AT TIME ZONE $1` form), the list of routes migrated
|
||||
(including `/api/dashboard/trends`), the queries explicitly preserved (rolling
|
||||
windows, snapshot joins, queue heatmap), the new `/api/mobile/finance` auth
|
||||
gate, the engagement-snapshots TZ-02 carve-out, and the behavioral test result
|
||||
for at least one user-tz vs UTC midnight scenario.
|
||||
</output>
|
||||
</content>
|
||||
</invoke>
|
||||
|
|
@ -0,0 +1,531 @@
|
|||
---
|
||||
phase: 07.1-user-timezone-fix-inserted-urgent
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [07.1-01]
|
||||
files_modified:
|
||||
- lib/hooks/use-user-timezone.ts
|
||||
- app/mobile/finance/page.tsx
|
||||
- app/mobile/tickets/[id]/page.tsx
|
||||
autonomous: true
|
||||
requirements: [TZ-04, TZ-02]
|
||||
requirements_addressed: [TZ-04, TZ-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A single client hook `useUserTimezone()` returns the calling user's IANA tz from the Better Auth session"
|
||||
- "The hook returns a safe fallback (`process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'`) when the session is loading or the field is missing"
|
||||
- "The hook validates the session value against Intl.supportedValuesOf('timeZone') — corrupt values fall back, never crash"
|
||||
- "The mobile pages that previously called `toLocaleDateString` / `toLocaleString` with the implicit browser zone now use the user's chosen tz via the hook"
|
||||
- "Every `toLocaleDateString` / `toLocaleString` callsite in `app/mobile/finance/page.tsx` and `app/mobile/tickets/[id]/page.tsx` passes a `timeZone:` option (verified with positive-assertion greps)"
|
||||
- "A discovery audit (Task 3) classifies every `Intl.DateTimeFormat` / `toLocale*String(` callsite across `app/`, `components/`, and `lib/hooks/` as 'browser-local zone leak' / 'explicit zone passed' / 'server-side' — guides whether SC#4 (single source of truth) is satisfied by Plan 04 alone or requires Plan 05"
|
||||
artifacts:
|
||||
- path: "lib/hooks/use-user-timezone.ts"
|
||||
provides: "Client hook reading user.timezone from useSession()"
|
||||
exports: ["useUserTimezone", "formatInUserTimezone"]
|
||||
- path: "app/mobile/finance/page.tsx"
|
||||
provides: "Mobile finance page formats dates in the user's tz, not the browser's"
|
||||
contains: "useUserTimezone"
|
||||
- path: "app/mobile/tickets/[id]/page.tsx"
|
||||
provides: "Mobile ticket detail formats timestamps in the user's tz"
|
||||
contains: "useUserTimezone"
|
||||
key_links:
|
||||
- from: "lib/hooks/use-user-timezone.ts"
|
||||
to: "useSession() from @/lib/auth-client"
|
||||
via: "additionalField propagated by Better Auth Plan 01 config"
|
||||
pattern: "useSession\\(\\)"
|
||||
- from: "Mobile pages"
|
||||
to: "useUserTimezone hook"
|
||||
via: "import { useUserTimezone } from '@/lib/hooks/use-user-timezone'"
|
||||
pattern: "useUserTimezone"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Ship the shared client hook `useUserTimezone()` and migrate the two mobile
|
||||
pages whose existing `toLocaleDateString` / `toLocaleString` calls render in
|
||||
the browser's local zone. Going forward, any future client-side date
|
||||
formatting must go through this hook — no scattered `Intl.DateTimeFormat`
|
||||
instantiations.
|
||||
|
||||
Purpose: Resolve TZ-04 (the hook itself) and the client-side portion of TZ-02
|
||||
on the directly-reported bug surface (the two mobile pages with absolute date
|
||||
formatters). This plan migrates only the two pages whose dates were the
|
||||
reported bug; a Task 3 audit produces the full codebase-wide leak inventory
|
||||
that the follow-up Plan 05 (Wave 2 sibling, depends_on `07.1-04`) will close
|
||||
to satisfy SC#4 (single source of truth) at the codebase scale.
|
||||
|
||||
Output: New `lib/hooks/use-user-timezone.ts`, edits to two existing mobile
|
||||
pages, and a Task 3 audit log committed to the phase directory.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@CLAUDE.md
|
||||
@lib/auth.ts
|
||||
@lib/auth-client.ts
|
||||
@components/auth/auth-provider.tsx
|
||||
@app/mobile/finance/page.tsx
|
||||
@app/mobile/tickets/[id]/page.tsx
|
||||
|
||||
<interfaces>
|
||||
After Plan 01 ships, `useSession().data?.user.timezone: string` is available
|
||||
on every client. Before that, it's not — this plan therefore depends on Plan
|
||||
01 (but NOT on Plan 02 or 03, which are independent).
|
||||
|
||||
`useSession` is exported from `@/lib/auth-client`:
|
||||
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
const { data, isPending, error } = useSession();
|
||||
// data?.user.timezone : string | undefined
|
||||
|
||||
The codebase has scattered callsites today (verified by grep at planning time):
|
||||
- `app/mobile/finance/page.tsx:43,75,373` — `toLocaleDateString` / `toLocaleString`
|
||||
- `app/mobile/tickets/[id]/page.tsx:49` — `toLocaleString`
|
||||
|
||||
Other mobile files (analyzer, dashboard, engagement) either don't format
|
||||
absolute dates client-side or already drive bucket boundaries from the server
|
||||
(now user-tz aware via Plan 03). The desktop callsites (`/components/admin/*`,
|
||||
`/app/dashboard/page.tsx`'s header `new Date().toLocaleDateString`, etc.) are
|
||||
known to be numerous (>10 leak callsites — verified by codebase grep at
|
||||
revision time). Migrating all of them in this plan would balloon Plan 04 past
|
||||
its budget; Task 3 produces a classified inventory and Plan 05 (sibling in
|
||||
Wave 2) closes them.
|
||||
|
||||
Browser environment variable:
|
||||
- `process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE` is the client-readable equivalent
|
||||
of `DEFAULT_TIMEZONE`. If unset, fall back to `'UTC'`. Setting it is an
|
||||
operator concern (Phase 9 / `.env.local`), out of scope here.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create lib/hooks/use-user-timezone.ts</name>
|
||||
<files>lib/hooks/use-user-timezone.ts</files>
|
||||
<read_first>
|
||||
- lib/auth-client.ts (the `useSession` export — line 34)
|
||||
- components/auth/auth-provider.tsx (canonical example of consuming useSession in this codebase: `const { data: session, isPending, error } = useSession();`)
|
||||
- app/mobile/finance/page.tsx (existing scattered formatting call shape — what API the hook needs to support so a one-line replacement works)
|
||||
- CLAUDE.md (Frontend section: 'use client' pages, no SWR/react-query, useState/useEffect pattern)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `lib/hooks/use-user-timezone.ts` with EXACTLY this content:
|
||||
|
||||
"use client";
|
||||
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
|
||||
// Public-readable default (Next.js exposes NEXT_PUBLIC_* to the browser).
|
||||
// Operators can set this in .env.local to match the server-side
|
||||
// DEFAULT_TIMEZONE. If unset, both server and client default to 'UTC'.
|
||||
function getClientDefaultTimezone(): string {
|
||||
return process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE || "UTC";
|
||||
}
|
||||
|
||||
function isValidIanaTimezone(tz: unknown): tz is string {
|
||||
if (typeof tz !== "string" || tz.length === 0 || tz.length > 64) return false;
|
||||
try {
|
||||
return Intl.supportedValuesOf("timeZone").includes(tz);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* useUserTimezone — TZ-04.
|
||||
*
|
||||
* Returns the calling user's IANA timezone string, sourced from the
|
||||
* Better Auth additionalField on `useSession()`. Falls back to
|
||||
* `process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'` while the session
|
||||
* is loading, when the field is missing, or when the stored value is
|
||||
* not a recognized IANA zone.
|
||||
*
|
||||
* This is the ONLY supported way to read the user's tz on the client.
|
||||
* Do NOT call `Intl.DateTimeFormat()` with a hardcoded zone or rely on
|
||||
* the browser's local zone — the user may have travelled or set a
|
||||
* preference that differs from the device.
|
||||
*/
|
||||
export function useUserTimezone(): string {
|
||||
const { data } = useSession();
|
||||
// Better Auth additionalField is typed `string` post-Plan-01; defence
|
||||
// in depth: validate before returning.
|
||||
const raw = (data?.user as { timezone?: unknown } | undefined)?.timezone;
|
||||
if (isValidIanaTimezone(raw)) return raw;
|
||||
return getClientDefaultTimezone();
|
||||
}
|
||||
|
||||
/**
|
||||
* formatInUserTimezone — convenience wrapper for the common case of
|
||||
* "format an ISO string in the user's tz". Equivalent to
|
||||
* `new Date(iso).toLocaleString(locale, { ...options, timeZone: tz })`.
|
||||
*
|
||||
* Pass `tz` from `useUserTimezone()` and the same options object you'd
|
||||
* pass to toLocaleString / toLocaleDateString — this helper keeps
|
||||
* existing format strings working without rewrites.
|
||||
*/
|
||||
export function formatInUserTimezone(
|
||||
input: string | number | Date,
|
||||
tz: string,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
locale: string = "en-US",
|
||||
): string {
|
||||
const date = input instanceof Date ? input : new Date(input);
|
||||
return date.toLocaleString(locale, { ...options, timeZone: tz });
|
||||
}
|
||||
|
||||
Notes:
|
||||
- The "use client" pragma is required because the hook calls
|
||||
`useSession()`. Without it, attempting to use the hook from a server
|
||||
component would error at build time.
|
||||
- We do NOT memoize the validation — `Intl.supportedValuesOf` is fast and
|
||||
`useSession()` already de-duplicates renders internally. Premature memo
|
||||
adds a `useMemo` dependency that's the same object identity anyway.
|
||||
- `formatInUserTimezone` is a pure function (not a hook), so it can be
|
||||
called inside loops/maps without violating rules-of-hooks.
|
||||
- Default locale `'en-US'` matches the existing callsites in mobile
|
||||
pages. Callers can override.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "lib/hooks/use-user-timezone\.ts"); [ -z "$ERR" ] && grep -q '"use client"' lib/hooks/use-user-timezone.ts && grep -q "export function useUserTimezone" lib/hooks/use-user-timezone.ts && grep -q "export function formatInUserTimezone" lib/hooks/use-user-timezone.ts && grep -q "useSession" lib/hooks/use-user-timezone.ts && grep -q "Intl.supportedValuesOf" lib/hooks/use-user-timezone.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists at `lib/hooks/use-user-timezone.ts`
|
||||
- First non-blank line is `"use client";`
|
||||
- Exports a function `useUserTimezone(): string`
|
||||
- Exports a function `formatInUserTimezone(input, tz, options?, locale?): string`
|
||||
- Imports `useSession` from `@/lib/auth-client`
|
||||
- Contains the literal `Intl.supportedValuesOf("timeZone")`
|
||||
- Contains the literal `process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE || "UTC"`
|
||||
- `npx tsc --noEmit --pretty` reports no errors in this file
|
||||
- Behavioral (manual): in a `'use client'` component that calls
|
||||
`useUserTimezone()`, the returned string is the user's stored tz; toggling
|
||||
the user's tz to `'America/Los_Angeles'` via curl PUT (Plan 02) and
|
||||
refreshing the page returns `'America/Los_Angeles'`.
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
`useUserTimezone()` is the canonical client-side accessor for the user's
|
||||
IANA tz. Any 'use client' component can import it and consume the result
|
||||
safely (always a usable string, never undefined).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Migrate app/mobile/finance/page.tsx and app/mobile/tickets/[id]/page.tsx to useUserTimezone</name>
|
||||
<files>app/mobile/finance/page.tsx, app/mobile/tickets/[id]/page.tsx</files>
|
||||
<read_first>
|
||||
- app/mobile/finance/page.tsx (the existing helpers — `formatDate` at ~line 43, the `setLastSync` line at ~75, and the `monthLabel` computation at ~373; all three currently rely on the browser's local tz)
|
||||
- app/mobile/tickets/[id]/page.tsx (the timestamp formatter at ~line 49 — same pattern)
|
||||
- lib/hooks/use-user-timezone.ts (the hook + helper from Task 1)
|
||||
- CLAUDE.md (Frontend: 'use client' is already in these files; no server-component conversion needed)
|
||||
</read_first>
|
||||
<action>
|
||||
Two files. Both already declare `"use client"`. Edits are minimal — call
|
||||
the hook at the top of the component, thread `tz` into each existing
|
||||
`toLocaleDateString` / `toLocaleString` call.
|
||||
|
||||
--- A: app/mobile/finance/page.tsx ---
|
||||
|
||||
1. Add import (next to the other `@/lib` imports near the top):
|
||||
`import { useUserTimezone } from '@/lib/hooks/use-user-timezone';`
|
||||
2. Inside the default-exported component function, BEFORE any
|
||||
useState/useEffect calls, add:
|
||||
`const tz = useUserTimezone();`
|
||||
3. The `formatDate` helper at line ~43 currently:
|
||||
function formatDate(ts: string | undefined): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
This helper is currently a module-scope function with no access to
|
||||
`tz`. Convert it to accept `tz` as an argument:
|
||||
function formatDate(ts: string | undefined, tz: string): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: tz });
|
||||
}
|
||||
And update every call site of `formatDate(...)` inside this file to
|
||||
pass `tz` as the second argument (search the file for `formatDate(` —
|
||||
fix each occurrence).
|
||||
4. The `setLastSync` line at ~line 75:
|
||||
setLastSync(ts ? new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : null);
|
||||
Change to:
|
||||
setLastSync(ts ? new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }) : null);
|
||||
5. The `monthLabel` computation at ~line 373:
|
||||
const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
Change to:
|
||||
const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: tz });
|
||||
6. Do NOT change anything else in this file — no behavioral changes
|
||||
beyond the timezone of the rendered strings.
|
||||
|
||||
--- B: app/mobile/tickets/[id]/page.tsx ---
|
||||
|
||||
1. Add import:
|
||||
`import { useUserTimezone } from '@/lib/hooks/use-user-timezone';`
|
||||
2. Inside the default-exported component function, add:
|
||||
`const tz = useUserTimezone();`
|
||||
3. The formatter at ~line 49:
|
||||
function formatTs(ts: string | undefined): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
(or whatever the exact name/shape — adapt to the actual file). Convert
|
||||
to accept `tz`:
|
||||
function formatTs(ts: string | undefined, tz: string): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz });
|
||||
}
|
||||
Update every callsite in the file to pass `tz`.
|
||||
|
||||
Important: if EITHER file declares its formatter at module scope (outside
|
||||
the component), it must be moved INSIDE the component OR keep its module
|
||||
scope AND accept tz as a param. The latter is the lighter-touch fix. Do
|
||||
not introduce a `useMemo` for the formatter — overhead exceeds benefit at
|
||||
these call frequencies.
|
||||
|
||||
Verify nothing else in either file calls `toLocaleDateString` /
|
||||
`toLocaleString` without a `timeZone:` option after this change. Use
|
||||
POSITIVE assertions (count `timeZone:` occurrences) rather than the
|
||||
fragile `! grep | grep -v` chain — see verify section.
|
||||
|
||||
Threshold note for the verify positive-assertion: at planning time
|
||||
`app/mobile/finance/page.tsx` has 3 date-formatter callsites (formatDate
|
||||
helper, setLastSync, monthLabel) and `app/mobile/tickets/[id]/page.tsx`
|
||||
has 1. After this task, the threshold for `timeZone:` count must be ≥ the
|
||||
count of `toLocaleDateString(` + `toLocaleString(` callsites in each file.
|
||||
The thresholds in the verify command (3 and 1) reflect those pre-existing
|
||||
counts.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/(finance|tickets/\[id\])/page\.tsx"); [ -z "$ERR" ] && grep -q "useUserTimezone" app/mobile/finance/page.tsx && grep -q "useUserTimezone" 'app/mobile/tickets/[id]/page.tsx' && [ "$(grep -cE 'toLocaleDateString\(|toLocaleString\(' app/mobile/finance/page.tsx)" -ge 3 ] && [ "$(grep -c 'timeZone:' app/mobile/finance/page.tsx)" -ge 3 ] && [ "$(grep -cE 'toLocaleDateString\(|toLocaleString\(' 'app/mobile/tickets/[id]/page.tsx')" -ge 1 ] && [ "$(grep -c 'timeZone:' 'app/mobile/tickets/[id]/page.tsx')" -ge 1 ]</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `app/mobile/finance/page.tsx` imports `useUserTimezone` from `@/lib/hooks/use-user-timezone`
|
||||
- `app/mobile/finance/page.tsx` calls `useUserTimezone()` exactly once inside the default-exported component
|
||||
- `app/mobile/finance/page.tsx`: count of `timeZone:` ≥ count of `toLocaleDateString(` + `toLocaleString(` (positive assertion: every formatter callsite has been threaded with `timeZone:`)
|
||||
- `app/mobile/tickets/[id]/page.tsx` imports `useUserTimezone` and calls it inside the component
|
||||
- `app/mobile/tickets/[id]/page.tsx`: count of `timeZone:` ≥ count of `toLocaleString(` callsites
|
||||
- `npx tsc --noEmit --pretty` reports no NEW errors in either file
|
||||
- Both files still compile as `'use client'` (the directive at top is preserved)
|
||||
- Behavioral (manual once running):
|
||||
- With user.timezone = 'America/New_York' and the device set to UTC, opening `/mobile/finance` renders `monthLabel` strings that match Eastern Time (e.g. an invoice dated 2026-01-01T03:00Z renders as "Dec 2025" — last day of December ET — not "Jan 2026" UTC).
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
The two mobile pages with absolute date formatting now render in the user's
|
||||
chosen tz, regardless of the browser's local zone. The hook is the only
|
||||
source of truth for these two pages.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Codebase-wide audit + classification of remaining toLocale* / Intl.DateTimeFormat callsites</name>
|
||||
<files>.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md</files>
|
||||
<read_first>
|
||||
- lib/hooks/use-user-timezone.ts (the migration target — Task 1 just created it)
|
||||
- app/mobile/finance/page.tsx, app/mobile/tickets/[id]/page.tsx (the two files Task 2 already migrated — exclude them from the audit)
|
||||
- CLAUDE.md (Frontend section: 'use client' pages — components/ and app/ are the consumer surface)
|
||||
</read_first>
|
||||
<action>
|
||||
Produce a one-pass audit of every `Intl.DateTimeFormat` /
|
||||
`toLocaleString(` / `toLocaleDateString(` / `toLocaleTimeString(` callsite
|
||||
across `app/`, `components/`, and `lib/hooks/`, EXCLUDING the two files
|
||||
Task 2 just migrated. Classify each callsite, then write the result to
|
||||
`07.1-04-AUDIT.md` so Plan 05 can consume it.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Run the full discovery grep:
|
||||
grep -rEn "Intl\.DateTimeFormat|\.toLocaleDateString\(|\.toLocaleTimeString\(|\.toLocaleString\(" app/ components/ lib/hooks/ \
|
||||
| grep -v "node_modules" \
|
||||
| grep -v "app/mobile/finance/page.tsx" \
|
||||
| grep -v "app/mobile/tickets/\[id\]/page.tsx" \
|
||||
| grep -v "components/ui/calendar.tsx" \
|
||||
> /tmp/tz-audit-raw.txt
|
||||
|
||||
(`components/ui/calendar.tsx` is a shadcn/ui primitive; its
|
||||
`toLocaleString("default", { month: "short" })` call is a calendar-cell
|
||||
label, not a user-visible date — exclude.)
|
||||
|
||||
2. For each line in `/tmp/tz-audit-raw.txt`, classify into ONE of:
|
||||
|
||||
- **leak**: `toLocaleString(` / `toLocaleDateString(` / `toLocaleTimeString(`
|
||||
on a Date instance with NO `timeZone:` option in the same call. These
|
||||
render in the device's local zone — the bug TZ-02 is patching.
|
||||
Examples: `new Date(ts).toLocaleString()`,
|
||||
`d.toLocaleDateString('en-US', { month: 'short' })`.
|
||||
|
||||
- **explicit_zone**: a `timeZone:` option IS passed in the same call
|
||||
(e.g., `{ timeZone: 'UTC' }` for deliberate UTC display, or
|
||||
`{ timeZone: tz }` already migrated). Leave as-is.
|
||||
|
||||
- **number_format**: `.toLocaleString()` called on a `number` /
|
||||
`bigint` (formatted thousand separators, NOT a date). Recognizable
|
||||
because the callee is not a `Date` instance — e.g., `count.toLocaleString()`,
|
||||
`value.toLocaleString()`, `summary.organizations?.toLocaleString()`.
|
||||
These are not date callsites; ignore.
|
||||
|
||||
- **server_side**: file path matches `app/api/**/route.ts` or otherwise
|
||||
runs in Node (not a React component). Out of scope for the client
|
||||
hook. Server-side formatting belongs to Plan 03's `getUserTimezone`
|
||||
server helper if it ever needs to format dates server-side; today the
|
||||
only such callsites are the analyzer prompt builders
|
||||
(`app/api/veeam/ticket-analysis/run/route.ts`,
|
||||
`app/api/veeam/rpo-analyze/route.ts`) which are deliberately
|
||||
locale-only (LLM input). Do NOT migrate.
|
||||
|
||||
- **deliberate_utc**: file already passes `{ timeZone: 'UTC' }` for a
|
||||
specific reason (e.g., `components/mobile/EngagementHoursSparkline.tsx:39`
|
||||
pins UTC because the data points are stored as UTC dates and the
|
||||
sparkline is a 7/30-day shape, not a clock). Leave as-is.
|
||||
|
||||
3. Write the inventory to
|
||||
`.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md`
|
||||
with EXACTLY this structure:
|
||||
|
||||
# Phase 7.1 — Codebase-wide tz audit (Plan 04 Task 3)
|
||||
|
||||
Discovery date: <ISO date>
|
||||
Excluded: `app/mobile/finance/page.tsx`,
|
||||
`app/mobile/tickets/[id]/page.tsx`,
|
||||
`components/ui/calendar.tsx`,
|
||||
`node_modules/**`
|
||||
|
||||
## Leak callsites (must migrate via Plan 05)
|
||||
|
||||
| File | Line | Snippet | Notes |
|
||||
|------|------|---------|-------|
|
||||
| app/foo/page.tsx | 42 | `new Date(ts).toLocaleDateString()` | client component, default-zone leak |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
## Explicit-zone callsites (no migration)
|
||||
|
||||
| File | Line | Snippet |
|
||||
|
||||
## Number-format callsites (not date — ignore)
|
||||
|
||||
Count: <N>
|
||||
|
||||
## Server-side callsites (out of scope)
|
||||
|
||||
| File | Line | Reason |
|
||||
| app/api/veeam/ticket-analysis/run/route.ts | 78,90,97,98 | LLM prompt builder — locale-only by design |
|
||||
| ... | ... | ... |
|
||||
|
||||
## Deliberate UTC callsites
|
||||
|
||||
| File | Line | Reason |
|
||||
| components/mobile/EngagementHoursSparkline.tsx | 39 | UTC pin for sparkline shape (not a clock) |
|
||||
|
||||
## Summary
|
||||
|
||||
- Leak count: N
|
||||
- Explicit-zone count: N
|
||||
- Server-side count: N
|
||||
- Deliberate-UTC count: N
|
||||
|
||||
## Plan 05 dispatch
|
||||
|
||||
- If Leak count == 0: Plan 05 is unnecessary. Mark in SUMMARY.
|
||||
- If Leak count > 0: Plan 05 (sibling, depends_on `07.1-04`) closes
|
||||
every Leak file in this audit. Plan 05's `files_modified` is the
|
||||
unique set of leak file paths above.
|
||||
|
||||
4. Do NOT modify any of the leak files in this task — only inventory them.
|
||||
Plan 05 owns the migration. The audit file IS the deliverable.
|
||||
|
||||
Notes:
|
||||
- This task is intentionally scoped to discovery + classification, not
|
||||
migration. It's the bridge between Plan 04 (two reported-bug-surface
|
||||
pages) and Plan 05 (codebase-wide adoption).
|
||||
- The audit file becomes the SOURCE OF TRUTH for Plan 05's
|
||||
`files_modified` and Plan 05's per-file acceptance criteria.
|
||||
- From the planning-time grep, the leak count is >10 (a `grep -rEn` across
|
||||
`app/`, `components/`, `lib/hooks/` returned ~96 candidate lines; many
|
||||
are number formatters, but Mimecast / engagement / analyzer / dashboard
|
||||
pages alone yield >10 confirmed Date-instance leaks). Plan 05 is
|
||||
therefore expected to be created. Confirm by counting the rows in the
|
||||
"Leak callsites" table.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md && grep -q '## Leak callsites' .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md && grep -q '## Explicit-zone callsites' .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md && grep -q '## Server-side callsites' .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md && grep -q '## Plan 05 dispatch' .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md && grep -q '## Summary' .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists at `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md`
|
||||
- Contains the five required sections (Leak / Explicit-zone / Number-format / Server-side / Deliberate-UTC) plus Summary and Plan 05 dispatch
|
||||
- Every callsite from the discovery grep appears in exactly one section (no double-counting)
|
||||
- The audit committed to git in the same commit as Tasks 1+2
|
||||
- The Plan 05 dispatch decision (create / skip) is unambiguous
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
A complete codebase-wide leak inventory is committed at
|
||||
`07.1-04-AUDIT.md`. If Leak count > 0, Plan 05 will be drafted as a
|
||||
Wave 2 sibling (depends_on `07.1-04`) consuming this audit verbatim. If
|
||||
Leak count == 0, the audit becomes a one-time deliverable proving SC#4 is
|
||||
already satisfied by Plan 04 alone.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Server → client (session payload) | tz string travels via Better Auth session cookie; client trusts it for formatting only |
|
||||
| Browser → display | tz misuse only affects what the user themselves sees on their own screen — no cross-user impact |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07.1-04-01 | Tampering | Tampered session payload with malformed tz crashing toLocaleString | mitigate | `useUserTimezone()` validates against `Intl.supportedValuesOf('timeZone')` before returning; falls back to env default. `toLocaleString` with the validated value cannot throw. |
|
||||
| T-07.1-04-02 | Information Disclosure | tz exposed in client memory | accept | Same disposition as T-07.1-01-02: tz is non-sensitive metadata. |
|
||||
| T-07.1-04-03 | Denial of Service | Calling `Intl.supportedValuesOf` on every hook call | accept | Hook is called per-render; V8 caches internally; ~600-entry array. Negligible. |
|
||||
| T-07.1-04-04 | Tampering | NEXT_PUBLIC_DEFAULT_TIMEZONE override at build time | accept | Public env var is intentionally operator-controlled; same trust level as the server-side `DEFAULT_TIMEZONE`. Out of scope. |
|
||||
| T-07.1-04-05 | Spoofing | Client showing one user's tz while session has another's | mitigate | Hook reads exclusively from `useSession()`; Better Auth invalidates sessions on sign-out. No cross-session leakage. |
|
||||
| T-07.1-04-06 | Information Disclosure | Audit file leaks file paths / snippets | accept | The audit file lives in `.planning/` (already part of the planning artefact tree), references only file paths and short code snippets that exist in the public repo, no secrets. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
End-to-end checks for this plan:
|
||||
|
||||
1. Static: `grep -q '"use client"' lib/hooks/use-user-timezone.ts`
|
||||
2. Static: `grep -q "useSession" lib/hooks/use-user-timezone.ts`
|
||||
3. Static: `grep -q "useUserTimezone" app/mobile/finance/page.tsx`
|
||||
4. Static: `grep -q "useUserTimezone" 'app/mobile/tickets/[id]/page.tsx'`
|
||||
5. Static (positive): `[ "$(grep -c 'timeZone:' app/mobile/finance/page.tsx)" -ge 3 ]` and `[ "$(grep -c 'timeZone:' 'app/mobile/tickets/[id]/page.tsx')" -ge 1 ]`
|
||||
6. Static: `[ "$(grep -cE 'toLocaleDateString\(|toLocaleString\(' app/mobile/finance/page.tsx)" -ge 3 ]` (the threshold matches the pre-existing date-formatter call count; if a future commit adds another formatter without `timeZone:`, the assertion above (5) will catch it because counts must be equal)
|
||||
7. Static: audit file exists and contains all six required sections
|
||||
8. Type: `ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "lib/hooks/use-user-timezone\.ts|app/mobile/(finance|tickets/\[id\])/page\.tsx"); [ -z "$ERR" ]`
|
||||
9. Runtime (with the dev server running, two browsers — one on UTC, one on
|
||||
Eastern, same user with `timezone='America/New_York'`):
|
||||
- Opening `/mobile/finance` in both browsers shows IDENTICAL date strings
|
||||
(because both pull the same user-tz from session, regardless of device tz).
|
||||
- Toggling the user's tz via `curl -X PUT /api/me/timezone` then refreshing
|
||||
re-renders the page with the new tz applied to all date strings.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `lib/hooks/use-user-timezone.ts` is the single canonical source of truth for client tz
|
||||
- `app/mobile/finance/page.tsx` and `app/mobile/tickets/[id]/page.tsx` both consume it; every date formatter passes `timeZone:`
|
||||
- TypeScript compiles for all three migrated files
|
||||
- A complete codebase-wide leak audit is committed; Plan 05 dispatch decision is recorded
|
||||
- Plan 05 closes the codebase-wide adoption gap to satisfy SC#4 (single source of truth)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-SUMMARY.md`
|
||||
documenting: the hook signature, the migrated callsites (file + line numbers
|
||||
before vs after), the audit results (leak count, explicit-zone count, etc.),
|
||||
the Plan 05 dispatch decision (create / skip), and the behavioral test
|
||||
result for two-browser-same-user tz consistency.
|
||||
</output>
|
||||
</content>
|
||||
</invoke>
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
---
|
||||
phase: 07.1-user-timezone-fix-inserted-urgent
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [07.1-04]
|
||||
files_modified:
|
||||
- .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md
|
||||
autonomous: true
|
||||
requirements: [TZ-04, TZ-02]
|
||||
requirements_addressed: [TZ-04, TZ-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Every 'browser-local zone leak' callsite identified by Plan 04 Task 3's audit (07.1-04-AUDIT.md) is migrated to consume `useUserTimezone()` from `@/lib/hooks/use-user-timezone`"
|
||||
- "Each migrated client component imports `useUserTimezone` and threads `timeZone: tz` into every previously-leaking `toLocaleDateString(` / `toLocaleString(` / `toLocaleTimeString(` callsite in the same file"
|
||||
- "Module-scope formatter helpers that previously had no access to the user's tz are converted to accept `tz: string` as an argument; every callsite passes `tz` resolved from `useUserTimezone()`"
|
||||
- "Non-leak callsites (server-side route handlers, deliberate UTC pins, number formatters) are NOT modified"
|
||||
- "After this plan ships, a codebase-wide grep for `toLocale*String(` / `Intl.DateTimeFormat` returns ZERO 'browser-local zone leak' callsites in client components — satisfying Phase 7.1 SC#4 (single source of truth)"
|
||||
artifacts:
|
||||
- path: ".planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md"
|
||||
provides: "Per-file migration plan derived from 07.1-04-AUDIT.md, recording every leak callsite, its replacement, and a per-file acceptance grep"
|
||||
contains: "Leak migration manifest"
|
||||
key_links:
|
||||
- from: "Each leak file"
|
||||
to: "lib/hooks/use-user-timezone.ts"
|
||||
via: "import { useUserTimezone } from '@/lib/hooks/use-user-timezone'"
|
||||
pattern: "useUserTimezone"
|
||||
- from: "Each leaking toLocale call"
|
||||
to: "tz from useUserTimezone()"
|
||||
via: "{ ...options, timeZone: tz }"
|
||||
pattern: "timeZone: tz"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the codebase-wide adoption gap for `useUserTimezone()`. Plan 04 migrated
|
||||
the two reported-bug-surface mobile pages; Plan 04 Task 3 produced a complete
|
||||
audit (`07.1-04-AUDIT.md`) classifying every other `toLocale*String(` /
|
||||
`Intl.DateTimeFormat` callsite across `app/`, `components/`, and `lib/hooks/`.
|
||||
This plan migrates every callsite the audit classified as a "browser-local
|
||||
zone leak" so that Phase 7.1 SC#4 ("single source of truth — no scattered
|
||||
`Intl.DateTimeFormat` instantiations") is satisfied at the codebase scale.
|
||||
|
||||
Purpose: Resolve the codebase-scale portion of TZ-04 + TZ-02 (client side).
|
||||
Plans 03 + 04 together cover the read paths and the hook itself; this plan
|
||||
finishes the migration work the audit revealed (>10 known leak callsites in
|
||||
admin/analyzer/engagement/dashboard pages and shared components).
|
||||
|
||||
Output: A `07.1-05-MANIFEST.md` derived from the audit, plus edits to every
|
||||
file the audit classified as a leak. The manifest is the source of truth for
|
||||
`files_modified` (Plan 04's audit is what populates it) — at planning time
|
||||
the exact list isn't known; the executor MUST consume the audit and update
|
||||
this plan's `files_modified` array as the first action.
|
||||
|
||||
Conditional execution: If `07.1-04-AUDIT.md` "Plan 05 dispatch" reports
|
||||
"Plan 05 is unnecessary. Mark in SUMMARY." (Leak count == 0), skip every task
|
||||
and return CLEAN immediately. Otherwise proceed.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@CLAUDE.md
|
||||
@.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md
|
||||
@lib/hooks/use-user-timezone.ts
|
||||
|
||||
<interfaces>
|
||||
This plan depends on Plan 04 — the hook (`@/lib/hooks/use-user-timezone`) and
|
||||
the audit file MUST exist before Plan 05 starts. The audit is the SINGLE
|
||||
source of truth for which files are migrated.
|
||||
|
||||
The migration recipe for every leak callsite is:
|
||||
|
||||
1. If the file is not already `'use client'`, the migration is impossible
|
||||
(server components can't call `useUserTimezone`). Re-classify the callsite
|
||||
as 'server_side' in a follow-up audit. (Audit step at planning time
|
||||
already filtered out server route files.)
|
||||
2. Add at the top of the imports (next to other `@/lib` imports):
|
||||
`import { useUserTimezone } from '@/lib/hooks/use-user-timezone';`
|
||||
3. Inside the default-exported component (or the hook entry point in a custom
|
||||
component), call:
|
||||
`const tz = useUserTimezone();`
|
||||
4. For each leaking call:
|
||||
|
||||
new Date(ts).toLocaleString('en-US', { month: 'short', ... })
|
||||
|
||||
→
|
||||
|
||||
new Date(ts).toLocaleString('en-US', { month: 'short', ..., timeZone: tz })
|
||||
|
||||
For module-scope helper functions (`function fmt(d) { return new Date(d).toLocaleString() }`):
|
||||
- Convert to accept `tz: string` as a new parameter:
|
||||
`function fmt(d, tz) { return new Date(d).toLocaleString(undefined, { timeZone: tz }) }`
|
||||
- Update every callsite of that helper in the same file to pass `tz`.
|
||||
|
||||
5. For DataTable column `render: (value) => new Date(value).toLocaleDateString()`
|
||||
patterns (common in admin pages), the migration is to:
|
||||
- Move the column definitions inside the component, OR
|
||||
- Pass `tz` via a closure when the columns are constructed inside the
|
||||
component, OR
|
||||
- Use the `formatInUserTimezone` helper from
|
||||
`@/lib/hooks/use-user-timezone` if column definitions stay at module
|
||||
scope and `tz` can be threaded as a param to a column-builder function.
|
||||
|
||||
6. Re-run the codebase-wide grep AFTER all migrations:
|
||||
|
||||
grep -rEn "Intl\\.DateTimeFormat|\\.toLocaleDateString\\(|\\.toLocaleTimeString\\(|\\.toLocaleString\\(" \\
|
||||
app/ components/ lib/hooks/
|
||||
|
||||
The post-migration result must contain ONLY:
|
||||
- `timeZone:` in the same call (migrated → satisfied)
|
||||
- server-side route handlers under `app/api/**/route.ts` (deliberately
|
||||
server, out of scope)
|
||||
- `components/ui/calendar.tsx` (shadcn primitive — calendar-cell labels,
|
||||
not user-visible dates)
|
||||
- Number formatters (`.toLocaleString()` on `number`/`bigint` — not date
|
||||
formatters)
|
||||
- Files in the audit's "deliberate_utc" classification
|
||||
|
||||
Browser environment variable:
|
||||
- `process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE` is the client-readable equivalent
|
||||
of `DEFAULT_TIMEZONE`. Same fallback semantics as `useUserTimezone()`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Build the migration manifest from the audit</name>
|
||||
<files>.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md</files>
|
||||
<read_first>
|
||||
- .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md (the input — Plan 04 Task 3's deliverable)
|
||||
- lib/hooks/use-user-timezone.ts (the migration target)
|
||||
</read_first>
|
||||
<action>
|
||||
Read `07.1-04-AUDIT.md`. If "Plan 05 dispatch" reports "Plan 05 is
|
||||
unnecessary", write a one-line `07.1-05-MANIFEST.md` with `Leak count: 0
|
||||
— Plan 05 skipped` and return CLEAN. Otherwise, build the manifest.
|
||||
|
||||
Group the leak callsites by file. For each file, produce a section:
|
||||
|
||||
### <file path>
|
||||
|
||||
- 'use client' status: <yes|no — needs `'use client'` added or refactor>
|
||||
- Pre-migration leak count: <N>
|
||||
- Post-migration acceptance grep:
|
||||
[ "$(grep -c 'timeZone:' <file>)" -ge <N> ]
|
||||
- Per-callsite plan:
|
||||
- Line <L>: `<snippet before>` → `<snippet after>`
|
||||
- ...
|
||||
|
||||
Notes / risks: <any per-file gotchas — e.g., DataTable column factory
|
||||
at module scope, helper that needs `tz` parameter threading>
|
||||
|
||||
Then write a `## Files to migrate` summary list at the top with `[ ]`
|
||||
checkboxes — Task 2 ticks them off as it migrates each file.
|
||||
|
||||
After writing the manifest, update Plan 05's `files_modified` frontmatter
|
||||
array to include EVERY file path enumerated in `## Files to migrate`,
|
||||
PLUS the manifest path itself. (Note: this requires editing
|
||||
`07.1-05-PLAN.md` in place. Use the `Edit` tool to update only the
|
||||
`files_modified:` block.)
|
||||
|
||||
Notes:
|
||||
- Do NOT skip module-scope helpers — converting them to accept `tz` as a
|
||||
parameter is part of the migration. The audit may flag these as a
|
||||
"Notes" gotcha; the manifest captures the exact transformation.
|
||||
- Files where the migration would require >5 component-shape changes
|
||||
(e.g., refactoring a class component to functional, or moving a large
|
||||
module-scope formatter into the component) should be flagged as
|
||||
"DEFER — out of scope for Plan 05" with a brief rationale and added to
|
||||
a `## Deferred` list at the bottom of the manifest. The deferred set
|
||||
becomes a v2 follow-up (a future phase).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md && grep -q '## Files to migrate' .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists at `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md`
|
||||
- Either: contains `Leak count: 0 — Plan 05 skipped` (and Tasks 2+3 are skipped), OR
|
||||
- Contains a `## Files to migrate` checklist with one entry per leak file from the audit
|
||||
- Each leak file has a `Per-callsite plan:` block enumerating every leak callsite by line number with before/after snippets
|
||||
- Plan 05's `files_modified` frontmatter has been updated to include every file in `## Files to migrate` plus the manifest path
|
||||
- Any file deferred is listed under `## Deferred` with rationale
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
The manifest is the single source of truth for what Task 2 migrates and
|
||||
what Task 3's verification grep checks. Plan 05's `files_modified`
|
||||
accurately reflects every file this plan will touch.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Migrate every leak callsite per the manifest</name>
|
||||
<files>(see 07.1-05-MANIFEST.md `## Files to migrate` — populated by Task 1)</files>
|
||||
<read_first>
|
||||
- .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md (Task 1's output — the per-file migration plan)
|
||||
- lib/hooks/use-user-timezone.ts (the import target)
|
||||
- app/mobile/finance/page.tsx, app/mobile/tickets/[id]/page.tsx (Plan 04's reference migrations — copy the call shape)
|
||||
</read_first>
|
||||
<action>
|
||||
For each file in the manifest's `## Files to migrate` checklist, in the
|
||||
order they appear:
|
||||
|
||||
1. Open the file.
|
||||
2. If it does not already declare `'use client'` at the top, STOP and
|
||||
move it to `## Deferred` in the manifest with rationale "would require
|
||||
'use client' conversion or component refactor — out of scope for Plan
|
||||
05". Do NOT add `'use client'` to a file that doesn't have it — that's
|
||||
a non-trivial change in Pulse (server components are fine for static
|
||||
shells; the user explicitly chose this layering).
|
||||
3. Add `import { useUserTimezone } from '@/lib/hooks/use-user-timezone';`
|
||||
next to the other `@/lib` imports.
|
||||
4. Inside the component (or each component if the file exports multiple),
|
||||
add `const tz = useUserTimezone();` near the top of the function body
|
||||
(before any useState/useEffect calls).
|
||||
5. Apply each per-callsite transformation from the manifest verbatim.
|
||||
6. For module-scope helpers, convert to accept `tz: string` as a new
|
||||
parameter and update every callsite in the same file.
|
||||
7. Tick off the file in the manifest's `## Files to migrate` checklist.
|
||||
8. Verify the file with the per-file acceptance grep recorded in the
|
||||
manifest:
|
||||
[ "$(grep -c 'timeZone:' <file>)" -ge <pre-migration leak count> ]
|
||||
|
||||
Do NOT modify files outside the manifest's `## Files to migrate` list.
|
||||
Do NOT modify files in `## Deferred`.
|
||||
|
||||
Type-check after each file (or at the end of the batch) to catch any
|
||||
parameter-threading regressions:
|
||||
ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "<file_path>"); [ -z "$ERR" ]
|
||||
|
||||
Notes:
|
||||
- The DataTable column-render pattern (common in `app/admin/data-browser/*/page.tsx`)
|
||||
may need a column-builder function that takes `tz` as a closure variable.
|
||||
The manifest will have called this out per file. Do NOT introduce a
|
||||
`useMemo` for column definitions unless the file already uses one
|
||||
(premature optimization).
|
||||
- Some helper functions (e.g., `relTime`, `formatDate`, `fmt`) are defined
|
||||
at module scope in many files. Threading `tz` as an extra parameter is
|
||||
intentional — no `React.useContext` workaround.
|
||||
- The shared admin sync formatter (e.g., `app/admin/sync/datto-rmm/page.tsx:23`)
|
||||
is a candidate for moving inside the component OR threading `tz`. The
|
||||
lighter-touch fix is threading.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>MANIFEST=.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md; if grep -q "Plan 05 skipped" "$MANIFEST"; then echo "skipped"; else FILES=$(grep -E "^- \[x\] " "$MANIFEST" | sed -E 's/^- \[x\] //' | tr '\n' ' '); ALL_OK=1; for f in $FILES; do if ! grep -q "useUserTimezone" "$f"; then echo "MISSING: $f"; ALL_OK=0; fi; done; [ "$ALL_OK" = "1" ]; fi</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Either: manifest reports "Plan 05 skipped" (Task 2 is a no-op) — accepted, OR
|
||||
- Every file in the manifest's `## Files to migrate` checklist is checked off `[x]`
|
||||
- Every checked-off file imports `useUserTimezone` from `@/lib/hooks/use-user-timezone`
|
||||
- Every checked-off file calls `useUserTimezone()` inside the component
|
||||
- Every per-file acceptance grep in the manifest passes
|
||||
- `ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "<each migrated file>"); [ -z "$ERR" ]` for every migrated file
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Every leak file in the audit has been migrated to consume
|
||||
`useUserTimezone()`. Deferred files (refactor-blocking) are documented
|
||||
explicitly. TypeScript compiles for every migrated file.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Codebase-wide post-migration verification grep</name>
|
||||
<files>(read-only verification; no file edits)</files>
|
||||
<read_first>
|
||||
- .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md (the deferred list — informs the expected residue)
|
||||
- .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md (the deliberate_utc + server_side classifications — informs the expected residue)
|
||||
</read_first>
|
||||
<action>
|
||||
Re-run the codebase-wide leak discovery grep AFTER Task 2 finishes:
|
||||
|
||||
grep -rEn "Intl\\.DateTimeFormat|\\.toLocaleDateString\\(|\\.toLocaleTimeString\\(" \\
|
||||
app/ components/ lib/hooks/ \\
|
||||
| grep -v "node_modules" \\
|
||||
| grep -v "components/ui/calendar.tsx" \\
|
||||
> /tmp/tz-post-migration.txt
|
||||
|
||||
For each line in the output, classify:
|
||||
- Has `timeZone:` in the same call → migrated ✓
|
||||
- Lives under `app/api/**/route.ts` → server-side, out of scope ✓
|
||||
- Listed in audit's `deliberate_utc` section → out of scope ✓
|
||||
- Listed in manifest's `## Deferred` section → known follow-up ✓
|
||||
- None of the above → REGRESSION. Stop and re-migrate.
|
||||
|
||||
Acceptable residue:
|
||||
- Server-side route handlers (Plan 03's responsibility, but they don't
|
||||
consume the client hook anyway)
|
||||
- Deliberate UTC pins
|
||||
- Deferred files (count must match the manifest's `## Deferred` count)
|
||||
|
||||
Document the residue list in `07.1-05-SUMMARY.md` for posterity.
|
||||
|
||||
Also run a final TypeScript check across all modified files:
|
||||
FILES=$(grep -E "^- \\[x\\] " .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md | sed -E 's/^- \\[x\\] //')
|
||||
for f in $FILES; do
|
||||
ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "$f")
|
||||
[ -z "$ERR" ] || { echo "TS errors in $f"; exit 1; }
|
||||
done
|
||||
</action>
|
||||
<verify>
|
||||
<automated>RESIDUE=$(grep -rEn "Intl\.DateTimeFormat|\.toLocaleDateString\(|\.toLocaleTimeString\(" app/ components/ lib/hooks/ 2>/dev/null | grep -v node_modules | grep -v "components/ui/calendar.tsx" | grep -v "timeZone:" | grep -v "/route.ts:" | wc -l); MANIFEST=.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md; DEFERRED=$(grep -cE "^- " "$MANIFEST" 2>/dev/null | head -1); DELIBERATE=$(grep -cE "^\| " .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md 2>/dev/null); echo "post-migration residue lines: $RESIDUE — must equal deferred + deliberate-UTC count from audit/manifest"; [ "$RESIDUE" -le "$((DEFERRED + DELIBERATE))" ]</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Post-migration residue grep returns ≤ (deferred files in manifest + deliberate-UTC files in audit)
|
||||
- Every residue line is accounted for by either: deferred classification, deliberate-UTC classification, or `timeZone:` already present
|
||||
- No new "leak" callsite exists that wasn't classified by the audit OR migrated by Task 2
|
||||
- All migrated files pass `npx tsc --noEmit --pretty` filtered to that file
|
||||
- `07.1-05-SUMMARY.md` documents the residue list and any deferred-file rationale
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
The codebase-wide grep proves SC#4 is satisfied: every leak callsite is
|
||||
either migrated, deferred (with rationale), or out-of-scope (server-side
|
||||
or deliberate UTC). No new leak surfaces have been introduced.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Server → client (session payload) | Same boundary as Plan 04 — tz string travels via Better Auth session cookie; client trusts it for formatting only |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07.1-05-01 | Tampering | Tampered session payload with malformed tz | mitigate | Same as Plan 04: `useUserTimezone()` validates against `Intl.supportedValuesOf('timeZone')` and falls back to `NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'`. No new attack surface introduced. |
|
||||
| T-07.1-05-02 | Information Disclosure | tz exposed in client memory across more pages | accept | Same as Plan 04: tz is non-sensitive metadata. The change here just propagates the same exposure to additional pages — not a new threat. |
|
||||
| T-07.1-05-03 | Tampering | A migration accidentally drops or reformats the date | mitigate | Per-file acceptance grep in the manifest verifies that the count of `timeZone:` ≥ the pre-migration leak count. If a migration accidentally drops a `timeZone:` thread, the grep catches it. TypeScript also catches missing-arg regressions where module-scope helpers gained a `tz` parameter. |
|
||||
| T-07.1-05-04 | Repudiation | Inconsistent date display between users with different tz preferences | accept | Intentional — this is the whole point of the phase. Two users in different tzs SHOULD see different "today" buckets. |
|
||||
| T-07.1-05-05 | Elevation of Privilege | Component rendering server-only data with client-only hook | mitigate | Task 2's `'use client'` precondition: any file lacking the directive is moved to deferred. We do NOT silently add `'use client'` to a server component (would change rendering semantics). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
End-to-end checks for this plan:
|
||||
|
||||
1. Static (precondition): `test -f .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md`
|
||||
2. Static (manifest exists): `test -f .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md`
|
||||
3. Static (skip path): if manifest contains `Plan 05 skipped`, plan returns CLEAN with no other checks.
|
||||
4. Static (migration coverage): every file in manifest's `## Files to migrate` checklist is checked-off and contains `useUserTimezone`.
|
||||
5. Static (post-migration residue): see Task 3 verify.
|
||||
6. Type: `ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "<each modified file>"); [ -z "$ERR" ]` for every migrated file.
|
||||
7. Runtime (spot check, with the dev server running and a logged-in user with `timezone='America/New_York'`): open one or two of the most-trafficked migrated pages (e.g., `/admin/audit/audit-log-table` consumer, `/analyzer/queue`, `/dashboard`); verify date strings respect the user-tz (set device tz to UTC, confirm rendered strings match ET).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Phase 7.1 SC#4 satisfied at codebase scale: no client-component leak callsites remain (only server-side, deliberate-UTC, and explicitly-deferred residues)
|
||||
- Every migrated file imports `useUserTimezone` and threads `timeZone:` into every formatter call
|
||||
- Module-scope helpers that previously had no tz access now accept `tz: string` as a parameter
|
||||
- TypeScript compiles for every migrated file
|
||||
- Deferred-file rationale is documented for any file the manifest excluded
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-SUMMARY.md`
|
||||
documenting: the manifest's `## Files to migrate` count vs the leak count
|
||||
from the audit (should match minus deferred), the per-file before/after leak
|
||||
counts, the deferred file list with rationale, the post-migration residue
|
||||
grep result, and any TypeScript regressions caught + fixed during migration.
|
||||
</output>
|
||||
</content>
|
||||
</invoke>
|
||||
Loading…
Add table
Add a link
Reference in a new issue