towerops/docs/plans/2026-02-12-preseem-stage1-foundation.md

47 KiB

Preseem Integration Stage 1: Foundation Implementation Plan

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

Goal: Build the generic integrations system (schema, context, encrypted credentials, UI) and the Preseem API client with test-connection support.

Architecture: New Towerops.Integrations context with per-organization integration records. Credentials encrypted at rest via Cloak (new Encrypted.Map type for JSONB). Preseem API client uses :req. Settings UI is a new LiveView page at /orgs/:org_slug/settings/integrations.

Tech Stack: Phoenix 1.8, Ecto, Cloak AES-256-GCM, Oban, Req, LiveView

Working directory: /Users/graham/dev/towerops/towerops-web/.worktrees/feature/preseem-integration

Pre-existing test failures: 19 failures exist on main (Monitoring, SiteLive, AgentChannel). NIF tests are skipped in this worktree. Do not attempt to fix these.


Task 1: Create Encrypted.Map Ecto Type

Files:

  • Create: lib/towerops/ecto_types/encrypted_map.ex
  • Test: test/towerops/ecto_types/encrypted_map_test.exs

Step 1: Write the failing test

# test/towerops/ecto_types/encrypted_map_test.exs
defmodule Towerops.Encrypted.MapTest do
  use Towerops.DataCase, async: true

  alias Towerops.Encrypted.Map, as: EncryptedMap

  describe "cast/1" do
    test "casts a valid map" do
      assert {:ok, %{"key" => "value"}} = EncryptedMap.cast(%{"key" => "value"})
    end

    test "casts atom-keyed maps" do
      assert {:ok, %{key: "value"}} = EncryptedMap.cast(%{key: "value"})
    end

    test "rejects non-map values" do
      assert :error = EncryptedMap.cast("not a map")
      assert :error = EncryptedMap.cast(123)
    end

    test "casts nil" do
      assert {:ok, nil} = EncryptedMap.cast(nil)
    end
  end

  describe "dump/1 and load/1 round-trip" do
    test "encrypts and decrypts a map" do
      original = %{"api_key" => "secret123", "base_url" => "https://api.preseem.com"}

      assert {:ok, encrypted} = EncryptedMap.dump(original)
      assert is_binary(encrypted)
      # Encrypted value should not be the raw JSON
      refute encrypted == Jason.encode!(original)

      assert {:ok, decrypted} = EncryptedMap.load(encrypted)
      assert decrypted == original
    end

    test "handles nil values" do
      assert {:ok, nil} = EncryptedMap.dump(nil)
      assert {:ok, nil} = EncryptedMap.load(nil)
    end
  end
end

Step 2: Run test to verify it fails

Run: mix test test/towerops/ecto_types/encrypted_map_test.exs Expected: Compilation error — module Towerops.Encrypted.Map not found

Step 3: Write minimal implementation

# lib/towerops/ecto_types/encrypted_map.ex
defmodule Towerops.Encrypted.Map do
  @moduledoc """
  Encrypted map field using Cloak vault.

  Used for storing sensitive configuration like API credentials as encrypted JSON.
  Data is encrypted at rest using AES-256-GCM.

  ## Usage

      field :credentials, Towerops.Encrypted.Map

  ## Database Schema

  The database column must be `:binary`:

      add :credentials, :binary

  Note: Atom keys become string keys after decryption.
  """
  use Cloak.Ecto.Map, vault: Towerops.Vault
end

Step 4: Run test to verify it passes

Run: mix test test/towerops/ecto_types/encrypted_map_test.exs Expected: All tests pass

Step 5: Commit

git add lib/towerops/ecto_types/encrypted_map.ex test/towerops/ecto_types/encrypted_map_test.exs
git commit -m "add encrypted map ecto type for integration credentials"

Task 2: Create Integrations Schema and Migration

Files:

  • Create: lib/towerops/integrations/integration.ex
  • Create: migration via mix ecto.gen.migration create_integrations
  • Test: test/towerops/integrations/integration_test.exs

Step 1: Generate migration

Run: mix ecto.gen.migration create_integrations

Step 2: Write the migration

defmodule Towerops.Repo.Migrations.CreateIntegrations do
  use Ecto.Migration

  def change do
    create table(:integrations, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
        null: false
      add :provider, :string, null: false
      add :enabled, :boolean, default: false, null: false
      add :credentials, :binary
      add :sync_interval_minutes, :integer, default: 10
      add :last_synced_at, :utc_datetime
      add :last_sync_status, :string, default: "never"

      timestamps(type: :utc_datetime)
    end

    create unique_index(:integrations, [:organization_id, :provider])
    create index(:integrations, [:organization_id])
  end
end

Step 3: Run migration

Run: mix ecto.migrate

Step 4: Write failing schema test

# test/towerops/integrations/integration_test.exs
defmodule Towerops.Integrations.IntegrationTest do
  use Towerops.DataCase, async: true

  import Towerops.AccountsFixtures
  import Towerops.OrganizationsFixtures

  alias Towerops.Integrations.Integration

  setup do
    user = user_fixture()
    organization = organization_fixture(user.id)
    %{organization: organization}
  end

  describe "changeset/2" do
    test "valid changeset with required fields", %{organization: org} do
      attrs = %{
        organization_id: org.id,
        provider: "preseem",
        enabled: true,
        credentials: %{"api_key" => "test-key-123"}
      }

      changeset = Integration.changeset(%Integration{}, attrs)
      assert changeset.valid?
    end

    test "requires organization_id" do
      attrs = %{provider: "preseem"}
      changeset = Integration.changeset(%Integration{}, attrs)
      refute changeset.valid?
      assert "can't be blank" in errors_on(changeset).organization_id
    end

    test "requires provider" do
      attrs = %{organization_id: Ecto.UUID.generate()}
      changeset = Integration.changeset(%Integration{}, attrs)
      refute changeset.valid?
      assert "can't be blank" in errors_on(changeset).provider
    end

    test "validates provider is a known value" do
      attrs = %{
        organization_id: Ecto.UUID.generate(),
        provider: "unknown_provider"
      }

      changeset = Integration.changeset(%Integration{}, attrs)
      refute changeset.valid?
      assert "is invalid" in errors_on(changeset).provider
    end

    test "validates sync_interval_minutes is positive" do
      attrs = %{
        organization_id: Ecto.UUID.generate(),
        provider: "preseem",
        sync_interval_minutes: 0
      }

      changeset = Integration.changeset(%Integration{}, attrs)
      refute changeset.valid?
      assert "must be greater than 0" in errors_on(changeset).sync_interval_minutes
    end

    test "validates last_sync_status is a known value" do
      attrs = %{
        organization_id: Ecto.UUID.generate(),
        provider: "preseem",
        last_sync_status: "bogus"
      }

      changeset = Integration.changeset(%Integration{}, attrs)
      refute changeset.valid?
      assert "is invalid" in errors_on(changeset).last_sync_status
    end

    test "enforces unique constraint on org + provider", %{organization: org} do
      attrs = %{organization_id: org.id, provider: "preseem"}

      {:ok, _} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert()

      assert {:error, changeset} =
               %Integration{} |> Integration.changeset(attrs) |> Repo.insert()

      assert "has already been taken" in errors_on(changeset).provider
    end
  end

  describe "credentials encryption" do
    test "credentials are encrypted at rest", %{organization: org} do
      attrs = %{
        organization_id: org.id,
        provider: "preseem",
        credentials: %{"api_key" => "super-secret-key"}
      }

      {:ok, integration} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert()

      # Reload from database
      reloaded = Repo.get!(Integration, integration.id)
      assert reloaded.credentials == %{"api_key" => "super-secret-key"}

      # Verify raw binary in DB is NOT the plain JSON
      raw =
        Repo.one(
          from i in "integrations",
            where: i.id == type(^integration.id, :binary_id),
            select: i.credentials
        )

      refute raw == Jason.encode!(attrs.credentials)
    end
  end
end

Step 5: Run test to verify it fails

Run: mix test test/towerops/integrations/integration_test.exs Expected: Compilation error — module Towerops.Integrations.Integration not found

Step 6: Write the schema

# lib/towerops/integrations/integration.ex
defmodule Towerops.Integrations.Integration do
  @moduledoc """
  Schema for per-organization third-party integrations.

  Each organization can have one integration per provider (e.g., one Preseem
  integration). Credentials are encrypted at rest using Cloak AES-256-GCM.
  """
  use Ecto.Schema
  import Ecto.Changeset

  alias Towerops.Encrypted

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  @valid_providers ~w(preseem)
  @valid_sync_statuses ~w(never success partial failed)

  schema "integrations" do
    field :provider, :string
    field :enabled, :boolean, default: false
    field :credentials, Encrypted.Map
    field :sync_interval_minutes, :integer, default: 10
    field :last_synced_at, :utc_datetime
    field :last_sync_status, :string, default: "never"

    belongs_to :organization, Towerops.Organizations.Organization

    timestamps(type: :utc_datetime)
  end

  def changeset(integration, attrs) do
    integration
    |> cast(attrs, [
      :organization_id,
      :provider,
      :enabled,
      :credentials,
      :sync_interval_minutes,
      :last_synced_at,
      :last_sync_status
    ])
    |> validate_required([:organization_id, :provider])
    |> validate_inclusion(:provider, @valid_providers)
    |> validate_inclusion(:last_sync_status, @valid_sync_statuses)
    |> validate_number(:sync_interval_minutes, greater_than: 0)
    |> unique_constraint([:organization_id, :provider],
      error_key: :provider,
      message: "has already been taken"
    )
    |> foreign_key_constraint(:organization_id)
  end
end

Step 7: Run test to verify it passes

Run: mix test test/towerops/integrations/integration_test.exs Expected: All tests pass

Step 8: Commit

git add lib/towerops/integrations/integration.ex test/towerops/integrations/integration_test.exs priv/repo/migrations/*_create_integrations.exs
git commit -m "add integrations schema with encrypted credentials"

Task 3: Create Integrations Context

Files:

  • Create: lib/towerops/integrations.ex
  • Test: test/towerops/integrations_test.exs

Step 1: Write failing context test

# test/towerops/integrations_test.exs
defmodule Towerops.IntegrationsTest do
  use Towerops.DataCase, async: true

  import Towerops.AccountsFixtures
  import Towerops.OrganizationsFixtures

  alias Towerops.Integrations
  alias Towerops.Integrations.Integration

  setup do
    user = user_fixture()
    organization = organization_fixture(user.id)
    %{organization: organization}
  end

  describe "list_integrations/1" do
    test "returns all integrations for an organization", %{organization: org} do
      {:ok, _} = Integrations.create_integration(org.id, %{provider: "preseem"})
      integrations = Integrations.list_integrations(org.id)
      assert length(integrations) == 1
    end

    test "does not return integrations from other orgs", %{organization: org} do
      user2 = user_fixture()
      org2 = organization_fixture(user2.id)
      {:ok, _} = Integrations.create_integration(org2.id, %{provider: "preseem"})

      assert Integrations.list_integrations(org.id) == []
    end
  end

  describe "get_integration/2" do
    test "returns integration by org and provider", %{organization: org} do
      {:ok, created} = Integrations.create_integration(org.id, %{provider: "preseem"})
      assert {:ok, fetched} = Integrations.get_integration(org.id, "preseem")
      assert fetched.id == created.id
    end

    test "returns error when not found", %{organization: org} do
      assert {:error, :not_found} = Integrations.get_integration(org.id, "preseem")
    end
  end

  describe "get_integration!/2" do
    test "returns integration by org and provider", %{organization: org} do
      {:ok, created} = Integrations.create_integration(org.id, %{provider: "preseem"})
      fetched = Integrations.get_integration!(org.id, "preseem")
      assert fetched.id == created.id
    end

    test "raises when not found", %{organization: org} do
      assert_raise Ecto.NoResultsError, fn ->
        Integrations.get_integration!(org.id, "preseem")
      end
    end
  end

  describe "create_integration/2" do
    test "creates with valid attrs", %{organization: org} do
      attrs = %{
        provider: "preseem",
        credentials: %{"api_key" => "test-123"},
        enabled: true
      }

      assert {:ok, %Integration{} = integration} =
               Integrations.create_integration(org.id, attrs)

      assert integration.provider == "preseem"
      assert integration.credentials == %{"api_key" => "test-123"}
      assert integration.enabled == true
      assert integration.organization_id == org.id
    end

    test "rejects duplicate provider per org", %{organization: org} do
      {:ok, _} = Integrations.create_integration(org.id, %{provider: "preseem"})

      assert {:error, changeset} =
               Integrations.create_integration(org.id, %{provider: "preseem"})

      assert "has already been taken" in errors_on(changeset).provider
    end
  end

  describe "update_integration/2" do
    test "updates credentials", %{organization: org} do
      {:ok, integration} =
        Integrations.create_integration(org.id, %{
          provider: "preseem",
          credentials: %{"api_key" => "old-key"}
        })

      assert {:ok, updated} =
               Integrations.update_integration(integration, %{
                 credentials: %{"api_key" => "new-key"}
               })

      assert updated.credentials == %{"api_key" => "new-key"}
    end

    test "updates enabled flag", %{organization: org} do
      {:ok, integration} =
        Integrations.create_integration(org.id, %{provider: "preseem", enabled: false})

      assert {:ok, updated} = Integrations.update_integration(integration, %{enabled: true})
      assert updated.enabled == true
    end
  end

  describe "update_sync_status/3" do
    test "updates last_synced_at and last_sync_status", %{organization: org} do
      {:ok, integration} =
        Integrations.create_integration(org.id, %{provider: "preseem"})

      assert integration.last_sync_status == "never"
      assert integration.last_synced_at == nil

      assert {:ok, updated} = Integrations.update_sync_status(integration, "success")
      assert updated.last_sync_status == "success"
      assert updated.last_synced_at != nil
    end
  end

  describe "delete_integration/1" do
    test "deletes the integration", %{organization: org} do
      {:ok, integration} =
        Integrations.create_integration(org.id, %{provider: "preseem"})

      assert {:ok, _} = Integrations.delete_integration(integration)
      assert {:error, :not_found} = Integrations.get_integration(org.id, "preseem")
    end
  end

  describe "change_integration/2" do
    test "returns a changeset", %{organization: org} do
      {:ok, integration} =
        Integrations.create_integration(org.id, %{provider: "preseem"})

      assert %Ecto.Changeset{} = Integrations.change_integration(integration)
    end
  end

  describe "list_enabled_integrations/1" do
    test "returns only enabled integrations for a provider", %{organization: org} do
      {:ok, _} =
        Integrations.create_integration(org.id, %{provider: "preseem", enabled: true})

      user2 = user_fixture()
      org2 = organization_fixture(user2.id)

      {:ok, _} =
        Integrations.create_integration(org2.id, %{provider: "preseem", enabled: false})

      enabled = Integrations.list_enabled_integrations("preseem")
      assert length(enabled) == 1
      assert hd(enabled).organization_id == org.id
    end
  end
end

Step 2: Run test to verify it fails

Run: mix test test/towerops/integrations_test.exs Expected: Compilation error — module Towerops.Integrations not found

Step 3: Write the context

# lib/towerops/integrations.ex
defmodule Towerops.Integrations do
  @moduledoc """
  Context for managing third-party integrations per organization.
  """
  import Ecto.Query

  alias Towerops.Integrations.Integration
  alias Towerops.Repo

  def list_integrations(organization_id) do
    Integration
    |> where(organization_id: ^organization_id)
    |> order_by(:provider)
    |> Repo.all()
  end

  def get_integration(organization_id, provider) do
    case Repo.get_by(Integration, organization_id: organization_id, provider: provider) do
      nil -> {:error, :not_found}
      integration -> {:ok, integration}
    end
  end

  def get_integration!(organization_id, provider) do
    Repo.get_by!(Integration, organization_id: organization_id, provider: provider)
  end

  def create_integration(organization_id, attrs) do
    %Integration{}
    |> Integration.changeset(Map.put(attrs, :organization_id, organization_id))
    |> Repo.insert()
  end

  def update_integration(%Integration{} = integration, attrs) do
    integration
    |> Integration.changeset(attrs)
    |> Repo.update()
  end

  def update_sync_status(%Integration{} = integration, status) do
    integration
    |> Integration.changeset(%{
      last_sync_status: status,
      last_synced_at: DateTime.utc_now() |> DateTime.truncate(:second)
    })
    |> Repo.update()
  end

  def delete_integration(%Integration{} = integration) do
    Repo.delete(integration)
  end

  def change_integration(%Integration{} = integration, attrs \\ %{}) do
    Integration.changeset(integration, attrs)
  end

  def list_enabled_integrations(provider) do
    Integration
    |> where(provider: ^provider, enabled: true)
    |> Repo.all()
  end
end

Step 4: Run test to verify it passes

Run: mix test test/towerops/integrations_test.exs Expected: All tests pass

Step 5: Commit

git add lib/towerops/integrations.ex test/towerops/integrations_test.exs
git commit -m "add integrations context with CRUD operations"

Task 4: Create Preseem API Client

Files:

  • Create: lib/towerops/preseem/client.ex
  • Test: test/towerops/preseem/client_test.exs

Step 1: Write failing client test

The client calls the Preseem API. For testing, we use Req.Test (built-in test adapter for Req).

# test/towerops/preseem/client_test.exs
defmodule Towerops.Preseem.ClientTest do
  use ExUnit.Case, async: true

  alias Towerops.Preseem.Client

  describe "test_connection/1" do
    test "returns ok when API responds with 200" do
      Req.Test.stub(Client, fn conn ->
        Req.Test.json(conn, %{"status" => "ok", "account" => "test-isp"})
      end)

      assert {:ok, %{"status" => "ok"}} = Client.test_connection("valid-api-key")
    end

    test "returns error for 401 unauthorized" do
      Req.Test.stub(Client, fn conn ->
        conn
        |> Plug.Conn.put_status(401)
        |> Req.Test.json(%{"error" => "unauthorized"})
      end)

      assert {:error, :unauthorized} = Client.test_connection("bad-key")
    end

    test "returns error for 403 forbidden" do
      Req.Test.stub(Client, fn conn ->
        conn
        |> Plug.Conn.put_status(403)
        |> Req.Test.json(%{"error" => "forbidden"})
      end)

      assert {:error, :forbidden} = Client.test_connection("restricted-key")
    end

    test "returns error for network failure" do
      Req.Test.stub(Client, fn _conn ->
        raise "connection refused"
      end)

      assert {:error, _reason} = Client.test_connection("any-key")
    end
  end

  describe "list_access_points/1" do
    test "returns list of access points on success" do
      Req.Test.stub(Client, fn conn ->
        Req.Test.json(conn, %{
          "data" => [
            %{"id" => "ap-1", "name" => "Tower1-AP1", "ip" => "10.0.0.1"},
            %{"id" => "ap-2", "name" => "Tower1-AP2", "ip" => "10.0.0.2"}
          ]
        })
      end)

      assert {:ok, [ap1, ap2]} = Client.list_access_points("valid-key")
      assert ap1["id"] == "ap-1"
      assert ap2["id"] == "ap-2"
    end

    test "returns error for 401" do
      Req.Test.stub(Client, fn conn ->
        conn
        |> Plug.Conn.put_status(401)
        |> Req.Test.json(%{"error" => "unauthorized"})
      end)

      assert {:error, :unauthorized} = Client.list_access_points("bad-key")
    end
  end

  describe "get_access_point_metrics/2" do
    test "returns metrics for an AP" do
      Req.Test.stub(Client, fn conn ->
        Req.Test.json(conn, %{
          "data" => %{
            "qoe_score" => 85.5,
            "capacity_score" => 72.0,
            "subscriber_count" => 42,
            "p95_latency" => 18.3
          }
        })
      end)

      assert {:ok, metrics} = Client.get_access_point_metrics("valid-key", "ap-1")
      assert metrics["qoe_score"] == 85.5
    end
  end
end

Step 2: Run test to verify it fails

Run: mix test test/towerops/preseem/client_test.exs Expected: Compilation error — module Towerops.Preseem.Client not found

Step 3: Write the client

# lib/towerops/preseem/client.ex
defmodule Towerops.Preseem.Client do
  @moduledoc """
  HTTP client for the Preseem Model API.

  Uses Req with plug-based testing support via `Req.Test`.
  """
  require Logger

  @default_base_url "https://apidocs.preseem.com/model/v1"

  def test_connection(api_key, opts \\ []) do
    base_url = Keyword.get(opts, :base_url, @default_base_url)

    case request(:get, "#{base_url}/account", api_key) do
      {:ok, %{status: status, body: body}} when status in 200..299 ->
        {:ok, body}

      {:ok, %{status: 401}} ->
        {:error, :unauthorized}

      {:ok, %{status: 403}} ->
        {:error, :forbidden}

      {:ok, %{status: status, body: body}} ->
        Logger.warning("Preseem API unexpected status #{status}: #{inspect(body)}")
        {:error, {:unexpected_status, status}}

      {:error, reason} ->
        Logger.error("Preseem API connection error: #{inspect(reason)}")
        {:error, reason}
    end
  end

  def list_access_points(api_key, opts \\ []) do
    base_url = Keyword.get(opts, :base_url, @default_base_url)

    case request(:get, "#{base_url}/access_points", api_key) do
      {:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 ->
        {:ok, data}

      {:ok, %{status: 401}} ->
        {:error, :unauthorized}

      {:ok, %{status: 403}} ->
        {:error, :forbidden}

      {:ok, %{status: status, body: body}} ->
        {:error, {:unexpected_status, status, body}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  def get_access_point_metrics(api_key, ap_id, opts \\ []) do
    base_url = Keyword.get(opts, :base_url, @default_base_url)

    case request(:get, "#{base_url}/access_points/#{ap_id}/metrics", api_key) do
      {:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 ->
        {:ok, data}

      {:ok, %{status: 401}} ->
        {:error, :unauthorized}

      {:ok, %{status: 403}} ->
        {:error, :forbidden}

      {:ok, %{status: status, body: body}} ->
        {:error, {:unexpected_status, status, body}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp request(method, url, api_key) do
    req =
      Req.new(
        method: method,
        url: url,
        headers: [
          {"authorization", "Bearer #{api_key}"},
          {"accept", "application/json"}
        ],
        plug: {Req.Test, __MODULE__}
      )

    Req.request(req)
  rescue
    exception ->
      {:error, Exception.message(exception)}
  end
end

Step 4: Run test to verify it passes

Run: mix test test/towerops/preseem/client_test.exs Expected: All tests pass

Step 5: Commit

git add lib/towerops/preseem/client.ex test/towerops/preseem/client_test.exs
git commit -m "add preseem API client with test connection support"

Task 5: Create Integrations LiveView (Settings Page)

Files:

  • Create: lib/towerops_web/live/org/integrations_live.ex
  • Create: lib/towerops_web/live/org/integrations_live.html.heex
  • Modify: lib/towerops_web/router.ex (add route)
  • Modify: lib/towerops_web/live/org/settings_live.html.heex (add link)
  • Test: test/towerops_web/live/org/integrations_live_test.exs

Step 1: Add route to router

In lib/towerops_web/router.ex, find the :require_authenticated_user_and_organization live_session block (around line 300-312) and add the integrations route next to the existing settings route:

# Find this block:
    scope "/orgs/:org_slug", ToweropsWeb do
      pipe_through [:browser, :require_authenticated_user, :load_current_organization]

      live "/settings", Org.SettingsLive, :index
    end

# Change to:
    scope "/orgs/:org_slug", ToweropsWeb do
      pipe_through [:browser, :require_authenticated_user, :load_current_organization]

      live "/settings", Org.SettingsLive, :index
      live "/settings/integrations", Org.IntegrationsLive, :index
    end

Step 2: Write failing LiveView test

# test/towerops_web/live/org/integrations_live_test.exs
defmodule ToweropsWeb.Org.IntegrationsLiveTest do
  use ToweropsWeb.ConnCase, async: true

  import Phoenix.LiveViewTest
  import Towerops.AccountsFixtures
  import Towerops.OrganizationsFixtures

  setup %{conn: conn} do
    user = user_fixture()
    organization = organization_fixture(user.id)
    conn = log_in_user(conn, user)
    %{conn: conn, user: user, organization: organization}
  end

  describe "index" do
    test "renders integrations page", %{conn: conn, organization: org} do
      {:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations")

      assert html =~ "Integrations"
      assert html =~ "Preseem"
    end

    test "shows preseem as available integration", %{conn: conn, organization: org} do
      {:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations")

      assert html =~ "Preseem"
      assert html =~ "QoE monitoring"
    end

    test "can enable preseem integration", %{conn: conn, organization: org} do
      {:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations")

      html =
        view
        |> element("#configure-preseem")
        |> render_click()

      assert html =~ "API Key"
    end

    test "can save preseem credentials", %{conn: conn, organization: org} do
      {:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations")

      # Open configure panel
      view |> element("#configure-preseem") |> render_click()

      # Fill in API key and save
      view
      |> form("#preseem-form", %{integration: %{api_key: "test-key-123"}})
      |> render_submit()

      # Verify it was saved
      assert {:ok, integration} = Towerops.Integrations.get_integration(org.id, "preseem")
      assert integration.credentials["api_key"] == "test-key-123"
    end

    test "can test preseem connection", %{conn: conn, organization: org} do
      Req.Test.stub(Towerops.Preseem.Client, fn conn ->
        Req.Test.json(conn, %{"status" => "ok"})
      end)

      {:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations")

      view |> element("#configure-preseem") |> render_click()

      view
      |> form("#preseem-form", %{integration: %{api_key: "test-key"}})
      |> render_change()

      html = view |> element("#test-connection") |> render_click()

      assert html =~ "Connection successful"
    end

    test "shows error for invalid preseem API key", %{conn: conn, organization: org} do
      Req.Test.stub(Towerops.Preseem.Client, fn conn ->
        conn
        |> Plug.Conn.put_status(401)
        |> Req.Test.json(%{"error" => "unauthorized"})
      end)

      {:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations")

      view |> element("#configure-preseem") |> render_click()

      view
      |> form("#preseem-form", %{integration: %{api_key: "bad-key"}})
      |> render_change()

      html = view |> element("#test-connection") |> render_click()

      assert html =~ "unauthorized" or html =~ "Invalid API key"
    end
  end
end

Step 3: Run test to verify it fails

Run: mix test test/towerops_web/live/org/integrations_live_test.exs Expected: Compilation error or route not found

Step 4: Write the LiveView module

# lib/towerops_web/live/org/integrations_live.ex
defmodule ToweropsWeb.Org.IntegrationsLive do
  @moduledoc false
  use ToweropsWeb, :live_view

  alias Towerops.Integrations
  alias Towerops.Preseem.Client, as: PreseemClient

  @providers [
    %{
      id: "preseem",
      name: "Preseem",
      description: "QoE monitoring and AP capacity management for WISPs",
      icon: "hero-signal"
    }
  ]

  @impl true
  def mount(_params, _session, socket) do
    organization = socket.assigns.current_scope.organization
    integrations = Integrations.list_integrations(organization.id)

    integrations_by_provider =
      Map.new(integrations, fn i -> {i.provider, i} end)

    {:ok,
     socket
     |> assign(:organization, organization)
     |> assign(:providers, @providers)
     |> assign(:integrations, integrations_by_provider)
     |> assign(:configuring, nil)
     |> assign(:form, nil)
     |> assign(:test_result, nil)}
  end

  @impl true
  def handle_event("configure", %{"provider" => provider}, socket) do
    org = socket.assigns.organization

    integration =
      case Map.get(socket.assigns.integrations, provider) do
        nil ->
          %Integrations.Integration{
            organization_id: org.id,
            provider: provider
          }

        existing ->
          existing
      end

    form = build_form(integration)

    {:noreply,
     socket
     |> assign(:configuring, provider)
     |> assign(:form, form)
     |> assign(:test_result, nil)}
  end

  @impl true
  def handle_event("close_config", _params, socket) do
    {:noreply,
     socket
     |> assign(:configuring, nil)
     |> assign(:form, nil)
     |> assign(:test_result, nil)}
  end

  @impl true
  def handle_event("validate", %{"integration" => params}, socket) do
    changeset =
      socket.assigns.form.source.data
      |> Integrations.change_integration(normalize_params(params))
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, :form, to_form(changeset, as: :integration))}
  end

  @impl true
  def handle_event("save", %{"integration" => params}, socket) do
    org = socket.assigns.organization
    provider = socket.assigns.configuring
    normalized = normalize_params(params)

    result =
      case Map.get(socket.assigns.integrations, provider) do
        nil ->
          Integrations.create_integration(org.id, Map.put(normalized, :provider, provider))

        existing ->
          Integrations.update_integration(existing, normalized)
      end

    case result do
      {:ok, integration} ->
        integrations = Map.put(socket.assigns.integrations, provider, integration)

        {:noreply,
         socket
         |> assign(:integrations, integrations)
         |> assign(:form, build_form(integration))
         |> put_flash(:info, "Integration saved")}

      {:error, changeset} ->
        {:noreply, assign(socket, :form, to_form(changeset, as: :integration))}
    end
  end

  @impl true
  def handle_event("test_connection", _params, socket) do
    api_key = get_api_key_from_form(socket)

    if api_key && api_key != "" do
      case PreseemClient.test_connection(api_key) do
        {:ok, _} ->
          {:noreply, assign(socket, :test_result, :success)}

        {:error, :unauthorized} ->
          {:noreply, assign(socket, :test_result, {:error, "Invalid API key"})}

        {:error, :forbidden} ->
          {:noreply, assign(socket, :test_result, {:error, "API key does not have access"})}

        {:error, reason} ->
          {:noreply,
           assign(socket, :test_result, {:error, "Connection failed: #{inspect(reason)}"})}
      end
    else
      {:noreply, assign(socket, :test_result, {:error, "Enter an API key first"})}
    end
  end

  @impl true
  def handle_event("toggle_enabled", %{"provider" => provider}, socket) do
    case Map.get(socket.assigns.integrations, provider) do
      nil ->
        {:noreply, socket}

      integration ->
        case Integrations.update_integration(integration, %{enabled: !integration.enabled}) do
          {:ok, updated} ->
            integrations = Map.put(socket.assigns.integrations, provider, updated)
            status = if updated.enabled, do: "enabled", else: "disabled"

            {:noreply,
             socket
             |> assign(:integrations, integrations)
             |> put_flash(:info, "Integration #{status}")}

          {:error, _} ->
            {:noreply, put_flash(socket, :error, "Failed to update integration")}
        end
    end
  end

  defp build_form(integration) do
    integration
    |> Integrations.change_integration()
    |> to_form(as: :integration)
  end

  defp normalize_params(params) do
    api_key = Map.get(params, "api_key", "")
    base_url = Map.get(params, "base_url", "")
    enabled = Map.get(params, "enabled", "false")

    credentials =
      %{"api_key" => api_key}
      |> then(fn creds ->
        if base_url != "", do: Map.put(creds, "base_url", base_url), else: creds
      end)

    %{
      credentials: credentials,
      enabled: enabled == "true" || enabled == true
    }
  end

  defp get_api_key_from_form(socket) do
    case socket.assigns.form do
      nil ->
        nil

      form ->
        changeset = form.source
        data = Ecto.Changeset.apply_changes(changeset)

        case data.credentials do
          %{"api_key" => key} -> key
          _ -> nil
        end
    end
  end
end

Step 5: Write the template

<%!-- lib/towerops_web/live/org/integrations_live.html.heex --%>
<Layouts.authenticated
  flash={@flash}
  current_scope={@current_scope}
>
  <div class="border-b border-gray-200 pb-5 dark:border-white/5">
    <div class="mb-4">
      <.link
        navigate={~p"/orgs/#{@organization.slug}/settings"}
        class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
      >
        <.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Settings
      </.link>
    </div>
    <h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
      Integrations
    </h1>
    <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
      Connect third-party services to enhance monitoring and analytics
    </p>
  </div>

  <div class="mt-8 space-y-6">
    <%= for provider <- @providers do %>
      <% integration = Map.get(@integrations, provider.id) %>
      <div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-white/10 dark:bg-white/5">
        <div class="flex items-center justify-between">
          <div class="flex items-center gap-4">
            <div class="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/20">
              <.icon name={provider.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
            </div>
            <div>
              <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
                {provider.name}
              </h3>
              <p class="text-sm text-gray-500 dark:text-gray-400">
                {provider.description}
              </p>
            </div>
          </div>

          <div class="flex items-center gap-3">
            <%= if integration do %>
              <button
                type="button"
                phx-click="toggle_enabled"
                phx-value-provider={provider.id}
                class={[
                  "relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2",
                  if(integration.enabled, do: "bg-indigo-600", else: "bg-gray-200 dark:bg-gray-700")
                ]}
              >
                <span class={[
                  "pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
                  if(integration.enabled, do: "translate-x-5", else: "translate-x-0")
                ]} />
              </button>
            <% end %>

            <button
              type="button"
              id={"configure-#{provider.id}"}
              phx-click="configure"
              phx-value-provider={provider.id}
              class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/20 dark:hover:bg-white/20"
            >
              Configure
            </button>
          </div>
        </div>

        <%= if integration do %>
          <div class="mt-4 flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
            <span class="flex items-center gap-1">
              <span class={[
                "inline-block h-2 w-2 rounded-full",
                case integration.last_sync_status do
                  "success" -> "bg-green-500"
                  "partial" -> "bg-yellow-500"
                  "failed" -> "bg-red-500"
                  _ -> "bg-gray-400"
                end
              ]} />
              Last sync: <%= if integration.last_synced_at do %>
                {Calendar.strftime(integration.last_synced_at, "%b %d, %Y %H:%M UTC")}
              <% else %>
                Never
              <% end %>
            </span>
            <span>Status: {integration.last_sync_status}</span>
          </div>
        <% end %>
      </div>
    <% end %>
  </div>

  <%!-- Configuration Panel --%>
  <%= if @configuring == "preseem" do %>
    <div class="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
      <div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
        <div
          class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 dark:bg-gray-800"
          phx-click-away="close_config"
        >
          <div class="flex items-center justify-between border-b border-gray-200 pb-4 dark:border-white/10">
            <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
              Configure Preseem
            </h3>
            <button
              type="button"
              phx-click="close_config"
              class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
            >
              <.icon name="hero-x-mark" class="h-5 w-5" />
            </button>
          </div>

          <.form
            for={@form}
            id="preseem-form"
            phx-change="validate"
            phx-submit="save"
            class="mt-4 space-y-4"
          >
            <div>
              <label
                for="integration_api_key"
                class="block text-sm font-medium text-gray-700 dark:text-gray-300"
              >
                API Key
              </label>
              <input
                type="password"
                id="integration_api_key"
                name="integration[api_key]"
                value={get_in(@form.source |> Ecto.Changeset.apply_changes() |> Map.from_struct(), [:credentials, "api_key"]) || ""}
                class="mt-1 block w-full rounded-md border-gray-300 shadow-xs focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:border-white/20 dark:bg-white/5 dark:text-white"
                placeholder="Enter your Preseem API key"
                autocomplete="off"
              />
            </div>

            <div>
              <label
                for="integration_base_url"
                class="block text-sm font-medium text-gray-700 dark:text-gray-300"
              >
                Base URL <span class="text-gray-400">(optional)</span>
              </label>
              <input
                type="text"
                id="integration_base_url"
                name="integration[base_url]"
                value={get_in(@form.source |> Ecto.Changeset.apply_changes() |> Map.from_struct(), [:credentials, "base_url"]) || ""}
                class="mt-1 block w-full rounded-md border-gray-300 shadow-xs focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:border-white/20 dark:bg-white/5 dark:text-white"
                placeholder="https://apidocs.preseem.com/model/v1"
                autocomplete="off"
              />
              <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
                Only change this if directed by Preseem support
              </p>
            </div>

            <%!-- Test Connection Result --%>
            <%= if @test_result == :success do %>
              <div class="rounded-md bg-green-50 p-3 dark:bg-green-900/20">
                <div class="flex items-center">
                  <.icon
                    name="hero-check-circle"
                    class="h-5 w-5 text-green-600 dark:text-green-400"
                  />
                  <p class="ml-2 text-sm text-green-800 dark:text-green-200">
                    Connection successful
                  </p>
                </div>
              </div>
            <% end %>

            <%= case @test_result do %>
              <% {:error, message} -> %>
                <div class="rounded-md bg-red-50 p-3 dark:bg-red-900/20">
                  <div class="flex items-center">
                    <.icon
                      name="hero-exclamation-circle"
                      class="h-5 w-5 text-red-600 dark:text-red-400"
                    />
                    <p class="ml-2 text-sm text-red-800 dark:text-red-200">
                      {message}
                    </p>
                  </div>
                </div>
              <% _ -> %>
            <% end %>

            <div class="flex items-center justify-between border-t border-gray-200 pt-4 dark:border-white/10">
              <button
                type="button"
                id="test-connection"
                phx-click="test_connection"
                class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/20 dark:hover:bg-white/20"
              >
                Test Connection
              </button>

              <div class="flex gap-3">
                <button
                  type="button"
                  phx-click="close_config"
                  class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/20 dark:hover:bg-white/20"
                >
                  Cancel
                </button>
                <button
                  type="submit"
                  class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
                >
                  Save
                </button>
              </div>
            </div>
          </.form>
        </div>
      </div>
    </div>
    <div class="fixed inset-0 z-40 bg-gray-500/75 dark:bg-gray-900/75" />
  <% end %>
</Layouts.authenticated>

Step 6: Add link from settings page

In lib/towerops_web/live/org/settings_live.html.heex, add a link to the integrations page. Find the closing </div> of the page header (around line 20, after the subtitle <p> tag) and add:

    <%!-- Add after the subtitle <p> tag, inside the header div --%>
    <div class="mt-4">
      <.link
        navigate={~p"/orgs/#{@organization.slug}/settings/integrations"}
        class="inline-flex items-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/20 dark:hover:bg-white/20"
      >
        <.icon name="hero-puzzle-piece" class="h-4 w-4" /> Integrations
      </.link>
    </div>

Step 7: Run test to verify it passes

Run: mix test test/towerops_web/live/org/integrations_live_test.exs Expected: All tests pass (some tests may need adjustment based on exact HTML output — iterate until green)

Step 8: Commit

git add lib/towerops_web/live/org/integrations_live.ex lib/towerops_web/live/org/integrations_live.html.heex lib/towerops_web/router.ex lib/towerops_web/live/org/settings_live.html.heex test/towerops_web/live/org/integrations_live_test.exs
git commit -m "add integrations settings page with preseem configuration"

Task 6: Create Integrations Test Fixture

Files:

  • Create: test/support/fixtures/integrations_fixtures.ex

Step 1: Write the fixture

# test/support/fixtures/integrations_fixtures.ex
defmodule Towerops.IntegrationsFixtures do
  @moduledoc """
  Test helpers for creating integration entities.
  """

  alias Towerops.Integrations

  def integration_fixture(organization_id, attrs \\ %{}) do
    attrs =
      Enum.into(attrs, %{
        provider: "preseem",
        enabled: true,
        credentials: %{"api_key" => "test-key-#{System.unique_integer()}"}
      })

    {:ok, integration} = Integrations.create_integration(organization_id, attrs)
    integration
  end
end

Step 2: Commit

git add test/support/fixtures/integrations_fixtures.ex
git commit -m "add integrations test fixture"

Task 7: Format, Compile Clean, Run Full Test Suite

Step 1: Format

Run: mix format

Step 2: Compile with warnings as errors

Run: mix compile --warnings-as-errors Expected: Clean compilation, no warnings

Step 3: Run full test suite

Run: mix test Expected: Same failure count as baseline (19 pre-existing failures). No new failures.

Step 4: Fix any issues found

If new warnings or failures, fix them before committing.

Step 5: Final commit if any format/warning fixes needed

git add -A
git commit -m "fix formatting and compiler warnings"

Verification Checklist

After all tasks complete, verify:

  • mix compile --warnings-as-errors — clean
  • mix format --check-formatted — clean
  • mix test — no new failures vs baseline (19 pre-existing)
  • mix test test/towerops/ecto_types/encrypted_map_test.exs — passes
  • mix test test/towerops/integrations/integration_test.exs — passes
  • mix test test/towerops/integrations_test.exs — passes
  • mix test test/towerops/preseem/client_test.exs — passes
  • mix test test/towerops_web/live/org/integrations_live_test.exs — passes
  • Navigate to /orgs/:slug/settings — "Integrations" link visible
  • Navigate to /orgs/:slug/settings/integrations — page renders with Preseem card
  • Click Configure → modal opens with API key field
  • Save credentials → reloads with credentials stored
  • Toggle enable/disable → updates integration state