# 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:

```elixir
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:

```elixir
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**

```bash
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:

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

Full context:

```elixir
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**

```bash
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:

```elixir
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):

```elixir
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**

```bash
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:

```elixir
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):

```elixir
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):

```elixir
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:

```elixir
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**

```bash
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)**

```bash
mix phx.server
```

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

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

**Verification:**

```bash
iex -S mix phx.server
```

```elixir
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:

```bash
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:

```bash
# 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**

```bash
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**
```bash
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
