wulf-pulse/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-01-PLAN.md
lorentz f9ab954518 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>
2026-05-07 07:02:07 -04:00

14 KiB

phase plan type wave depends_on files_modified autonomous requirements requirements_addressed must_haves
07.1-user-timezone-fix-inserted-urgent 01 execute 1
migrations/083_add_user_timezone.sql
lib/auth.ts
true
TZ-01
TZ-01
truths artifacts key_links
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)
path provides contains
migrations/083_add_user_timezone.sql Adds timezone TEXT column to "user" table with default + backfill ADD COLUMN IF NOT EXISTS timezone
path provides contains
lib/auth.ts Better Auth additionalField config exposing timezone on session.user timezone:
from to via pattern
Better Auth session user.timezone column additionalFields config in lib/auth.ts additionalFields[\s\S]*timezone
from to via pattern
Default value at insert time process.env.DEFAULT_TIMEZONE SQL DEFAULT clause + Better Auth defaultValue COALESCE(.*DEFAULT_TIMEZONE.*'UTC')|defaultValue.*timezone
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 ```typescript // lib/auth.ts current shape: user: { additionalFields: { role: { type: "string", defaultValue: "user", }, requires_setup: { type: "boolean", defaultValue: false, }, }, }, ```
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()
);
Task 1: Create migration 083_add_user_timezone.sql migrations/083_add_user_timezone.sql - 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) 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`.
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 - 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 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. Task 2: Extend Better Auth additionalFields with timezone lib/auth.ts - 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) 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.
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"' - `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) `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.

<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>
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.

<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>
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`?).