| 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 |
|
|
| 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:
- 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.
- SQL:
SELECT COUNT(*) FROM "user" WHERE timezone IS NULL returns 0.
- 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).
- 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`?).