towerops/docs/plans/2026-02-01-cloudflare-timezone-detection.md
Graham McIntire 77e62df9da
Move CaptureTimezone plug to browser pipeline
The plug was incorrectly placed in the endpoint, causing it to run on
all requests including APIs. Moved to :browser pipeline where it belongs,
and removed redundant fetch_session call since the pipeline handles it.
2026-02-01 11:15:51 -06:00

14 KiB

Cloudflare Timezone Detection Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Automatically set user timezone from Cloudflare's cf-timezone header during registration, with UTC fallback.

Architecture: Session-based timezone capture via plug → LiveView reads from session → merges into registration params → User schema accepts timezone field.

Tech Stack: Phoenix Plug, LiveView, Ecto changesets


Task 1: Create CaptureTimezone Plug

Files:

  • Create: lib/towerops_web/plugs/capture_timezone.ex
  • Create: test/towerops_web/plugs/capture_timezone_test.exs

Step 1: Write the failing test

Create test file with basic header capture tests:

defmodule ToweropsWeb.Plugs.CaptureTimezoneTest do
  use ToweropsWeb.ConnCase, async: true

  alias ToweropsWeb.Plugs.CaptureTimezone

  describe "call/2" do
    test "captures cf-timezone header and stores in session", %{conn: conn} do
      conn =
        conn
        |> put_req_header("cf-timezone", "America/Chicago")
        |> CaptureTimezone.call([])

      assert get_session(conn, :detected_timezone) == "America/Chicago"
    end

    test "defaults to UTC when cf-timezone header is missing", %{conn: conn} do
      conn = CaptureTimezone.call(conn, [])

      assert get_session(conn, :detected_timezone) == "UTC"
    end

    test "defaults to UTC when cf-timezone header is empty", %{conn: conn} do
      conn =
        conn
        |> put_req_header("cf-timezone", "")
        |> CaptureTimezone.call([])

      assert get_session(conn, :detected_timezone) == "UTC"
    end

    test "handles multiple timezone headers by taking first", %{conn: conn} do
      conn =
        conn
        |> put_req_header("cf-timezone", "America/Chicago")
        |> put_req_header("cf-timezone", "Europe/London")
        |> CaptureTimezone.call([])

      assert get_session(conn, :detected_timezone) == "America/Chicago"
    end
  end
end

Step 2: Run test to verify it fails

Run: mix test test/towerops_web/plugs/capture_timezone_test.exs

Expected: FAIL with "module ToweropsWeb.Plugs.CaptureTimezone is not available"

Step 3: Write minimal implementation

Create plug file:

defmodule ToweropsWeb.Plugs.CaptureTimezone do
  @moduledoc """
  Captures the cf-timezone header from Cloudflare and stores it in session.

  Cloudflare provides timezone detection via the cf-timezone header using
  IANA timezone database format (e.g., "America/Chicago", "Europe/London").

  Falls back to "UTC" if the header is not present (development, non-Cloudflare).
  """
  import Plug.Conn

  @doc false
  def init(opts), do: opts

  @doc false
  def call(conn, _opts) do
    timezone =
      case get_req_header(conn, "cf-timezone") do
        [tz] when is_binary(tz) and byte_size(tz) > 0 -> tz
        _ -> "UTC"
      end

    put_session(conn, :detected_timezone, timezone)
  end
end

Step 4: Run test to verify it passes

Run: mix test test/towerops_web/plugs/capture_timezone_test.exs

Expected: All 4 tests PASS

Step 5: Commit

git add lib/towerops_web/plugs/capture_timezone.ex test/towerops_web/plugs/capture_timezone_test.exs
git commit -m "Add CaptureTimezone plug to extract cf-timezone header"

Task 2: Add Plug to Endpoint Pipeline

Files:

  • Modify: lib/towerops_web/endpoint.ex

Step 1: Add plug to browser pipeline

Find the browser pipeline section (after plug Plug.Session and before plug ToweropsWeb.Router) and add:

# Around line 47, after plug Plug.Session
plug ToweropsWeb.Plugs.CaptureTimezone

Full context:

plug Plug.Session,
  store: :cookie,
  key: "_towerops_key",
  signing_salt: "...",
  same_site: "Lax"

plug ToweropsWeb.Plugs.CaptureTimezone  # <-- ADD THIS LINE
plug ToweropsWeb.Router

Step 2: Verify endpoint compiles

Run: mix compile

Expected: Compilation success with no warnings

Step 3: Manual verification via logs

Start server: mix phx.server

Visit: http://localhost:4000

Check logs for session updates (optional verification)

Step 4: Commit

git add lib/towerops_web/endpoint.ex
git commit -m "Add CaptureTimezone plug to browser pipeline"

Task 3: Update User Schema for Timezone

Files:

  • Modify: lib/towerops/accounts/user.ex:123-129
  • Modify: test/towerops/accounts_test.exs

Step 1: Write failing test for timezone in registration

Add test to test/towerops/accounts_test.exs in the "register_user_with_organization/1" section:

test "accepts timezone in registration params" do
  valid_attrs = %{
    email: "user@example.com",
    password: "a valid password",
    privacy_policy_consent: true,
    terms_of_service_consent: true,
    organization_name: "Test Org",
    timezone: "America/Chicago"
  }

  assert {:ok, user} = Accounts.register_user_with_organization(valid_attrs)
  assert user.timezone == "America/Chicago"
end

test "defaults to UTC when timezone not provided" do
  valid_attrs = %{
    email: "user@example.com",
    password: "a valid password",
    privacy_policy_consent: true,
    terms_of_service_consent: true,
    organization_name: "Test Org"
  }

  assert {:ok, user} = Accounts.register_user_with_organization(valid_attrs)
  assert user.timezone == "UTC"
end

Step 2: Run test to verify it fails

Run: mix test test/towerops/accounts_test.exs --seed 0

Expected: Tests fail because timezone is not in cast fields

Step 3: Update registration_changeset to accept timezone

In lib/towerops/accounts/user.ex, modify the registration_changeset/3 function (around line 123):

def registration_changeset(user, attrs, opts \\ []) do
  user
  |> cast(attrs, [:email, :password, :timezone, :privacy_policy_consent, :terms_of_service_consent])
  |> validate_email_for_registration(opts)
  |> validate_password(opts)
  |> validate_consent()
end

Step 4: Run test to verify it passes

Run: mix test test/towerops/accounts_test.exs --seed 0

Expected: Both new tests PASS

Step 5: Run all tests to ensure no regressions

Run: mix test

Expected: All tests PASS

Step 6: Commit

git add lib/towerops/accounts/user.ex test/towerops/accounts_test.exs
git commit -m "Accept timezone parameter in user registration changeset"

Task 4: Update UserRegistrationLive to Use Detected Timezone

Files:

  • Modify: lib/towerops_web/live/user_registration_live.ex:12-26
  • Modify: lib/towerops_web/live/user_registration_live.ex:52-76
  • Create: test/towerops_web/live/user_registration_live_test.exs (if needed)

Step 1: Add timezone to socket assigns in mount

In lib/towerops_web/live/user_registration_live.ex, modify the mount/3 function:

def mount(params, session, socket) do
  # Check if registering via invitation
  invitation_token = params["invitation_token"] || params["token"]
  invitation = invitation_token && Organizations.get_invitation_by_token(invitation_token)

  # Get detected timezone from session (set by CaptureTimezone plug)
  detected_timezone = Map.get(session, "detected_timezone", "UTC")

  changeset = Accounts.change_user_registration(%User{})

  {:ok,
   socket
   |> assign(:form, to_form(changeset))
   |> assign(:invitation, invitation)
   |> assign(:invitation_token, invitation_token)
   |> assign(:password_breach_count, nil)
   |> assign(:detected_timezone, detected_timezone)
   |> assign(:trigger_action, false)}
end

Step 2: Merge timezone into user_params in create_with_organization

Modify the create_with_organization/2 private function (around line 64):

defp create_with_organization(socket, user_params) do
  # Merge detected timezone into params
  user_params_with_timezone = Map.put(user_params, "timezone", socket.assigns.detected_timezone)

  case Accounts.register_user_with_organization(user_params_with_timezone) do
    {:ok, user} ->
      {:noreply,
       socket
       |> put_flash(:info, "Account created successfully.")
       |> redirect(to: ~p"/users/log-in?email=#{user.email}")
       |> assign(:trigger_action, true)}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign(socket, :form, to_form(changeset))}
  end
end

Step 3: Merge timezone into user_params in create_via_invitation

Modify the create_via_invitation/3 private function (around line 78):

defp create_via_invitation(socket, user_params, invitation_token) do
  # Merge detected timezone into params
  user_params_with_timezone = Map.put(user_params, "timezone", socket.assigns.detected_timezone)

  with {:ok, invitation} <- get_valid_invitation(invitation_token),
       {:ok, user} <- Accounts.register_user(user_params_with_timezone),
       {:ok, _membership} <- Organizations.accept_invitation(invitation, user.id) do
    {:noreply,
     socket
     |> put_flash(:info, "Account created successfully. Welcome to #{invitation.organization.name}!")
     |> redirect(to: ~p"/users/log-in?email=#{user.email}")
     |> assign(:trigger_action, true)}
  else
    {:error, :invalid_invitation} ->
      {:noreply,
       socket
       |> put_flash(:error, "Invalid or expired invitation link.")
       |> assign(:invitation, nil)
       |> assign(:invitation_token, nil)}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign(socket, :form, to_form(changeset))}
  end
end

Step 4: Write integration test

Create or update test/towerops_web/live/user_registration_live_test.exs to verify timezone is set:

test "sets timezone from session during registration", %{conn: conn} do
  # Set detected timezone in session (simulating CaptureTimezone plug)
  conn = conn |> init_test_session(%{"detected_timezone" => "America/Chicago"})

  {:ok, lv, _html} = live(conn, ~p"/users/register")

  form =
    form(lv, "#registration_form", %{
      user: %{
        email: "test@example.com",
        password: "valid_password_123",
        organization_name: "Test Org",
        privacy_policy_consent: "true",
        terms_of_service_consent: "true"
      }
    })

  render_submit(form)

  # Verify user was created with detected timezone
  user = Towerops.Accounts.get_user_by_email("test@example.com")
  assert user.timezone == "America/Chicago"
end

test "defaults to UTC when timezone not in session", %{conn: conn} do
  {:ok, lv, _html} = live(conn, ~p"/users/register")

  form =
    form(lv, "#registration_form", %{
      user: %{
        email: "test2@example.com",
        password: "valid_password_123",
        organization_name: "Test Org",
        privacy_policy_consent: "true",
        terms_of_service_consent: "true"
      }
    })

  render_submit(form)

  # Verify user was created with UTC default
  user = Towerops.Accounts.get_user_by_email("test2@example.com")
  assert user.timezone == "UTC"
end

Step 5: Run LiveView tests

Run: mix test test/towerops_web/live/user_registration_live_test.exs

Expected: All tests PASS

Step 6: Run full test suite

Run: mix test

Expected: All tests PASS

Step 7: Commit

git add lib/towerops_web/live/user_registration_live.ex test/towerops_web/live/user_registration_live_test.exs
git commit -m "Use detected timezone from session in user registration"

Task 5: Manual Testing and Verification

Step 1: Test in development (no cf-timezone header)

mix phx.server

Visit: http://localhost:4000/users/register

Register a new user with email dev-test@example.com

Verification:

iex -S mix phx.server
user = Towerops.Accounts.get_user_by_email("dev-test@example.com")
user.timezone
# Expected: "UTC" (fallback)

Step 2: Test with simulated Cloudflare header

Use curl to simulate Cloudflare header:

curl -H "cf-timezone: America/New_York" http://localhost:4000/users/register -v

Or use browser dev tools to add header (requires browser extension).

Step 3: Verify in production logs (after deployment)

Check production logs for cf-timezone header presence:

# On production
grep "cf-timezone" /var/log/phoenix/production.log

Step 4: Document in CHANGELOG or release notes

Add entry noting automatic timezone detection feature.


Task 6: Final Testing and Cleanup

Step 1: Run full test suite with coverage

Run: mix test --cover

Expected: All tests PASS, coverage >90% (excluding protobuf modules)

Step 2: Run precommit checks

Run: mix precommit

Expected: All checks PASS (compile, format, tests)

Step 3: Run dialyzer (optional but recommended)

Run: mix dialyzer

Expected: No new warnings

Step 4: Final commit if any formatting changes

git add .
git commit -m "Run formatter and precommit checks"

Rollback Plan

If issues arise in production:

Option 1: Revert the LiveView changes

  • Remove timezone merge in create_with_organization/2 and create_via_invitation/3
  • Users will get database default "UTC"

Option 2: Disable the plug

  • Comment out plug ToweropsWeb.Plugs.CaptureTimezone in endpoint.ex
  • Session will have no :detected_timezone, LiveView will use "UTC" fallback

Option 3: Full revert

git revert <commit-hash>

Success Criteria

  • All tests pass
  • New users in production have correct timezone (verify via DB query)
  • No regressions in registration flow
  • Development environment still works (UTC fallback)
  • Code follows project conventions (mix precommit passes)

Notes

  • Timezone is IANA format from Cloudflare, compatible with Elixir libraries
  • Users can still change timezone later in settings
  • No UI changes needed (fully transparent to users)
  • Works for both normal registration and invitation-based registration