diff --git a/docs/plans/2026-02-12-preseem-integration-design.md b/docs/plans/2026-02-12-preseem-integration-design.md
new file mode 100644
index 00000000..6b74f094
--- /dev/null
+++ b/docs/plans/2026-02-12-preseem-integration-design.md
@@ -0,0 +1,305 @@
+# Preseem API Integration Design
+
+## Overview
+
+Integrate Preseem's QoE (Quality of Experience) data with Towerops' SNMP-based device monitoring to provide network engineers with combined infrastructure health + traffic quality intelligence.
+
+**Primary persona (Phase 1):** Network engineer — planning capacity, upgrading towers, optimizing RF.
+
+**Future personas:** NOC technician (alert correlation), ISP owner/manager (investment prioritization). See `TODO.md` at project root.
+
+## What Preseem Provides
+
+Preseem sits inline on WISP networks and measures per-subscriber, per-AP, per-tower:
+- **QoE metrics**: latency, loss, jitter, throughput (per subscriber and aggregate)
+- **AP capacity scores**: airtime utilization, busy hours, subscriber density
+- **RF metrics**: RSSI, modulation, frequency, channel width
+- **Subscriber data**: plan rates, actual throughput, data usage
+
+Preseem has a Model API (beta, in production use) and REST APIs.
+
+## What Towerops Provides
+
+- SNMP-based device inventory (hardware, firmware, serial numbers)
+- 570+ vendor device profiles
+- Network topology (LLDP/CDP neighbors, ARP discovery)
+- Sensor readings (temperature, voltage, power, signal strength)
+- Interface statistics (traffic counters, errors, discards)
+- MikroTik configuration tracking
+
+## The Value of Combining Both
+
+Towerops knows *about devices* (hardware, config, topology, sensor health).
+Preseem knows *about traffic* (QoE, capacity, subscriber experience).
+
+Together: "This AF5XHD at Tower 3 is running hot (Towerops sensor), serving 45 subscribers (Preseem), with QoE dropping during busy hours (Preseem), and its firmware is 2 versions behind the best-performing instances in your fleet (both)."
+
+---
+
+## Data Model
+
+### Integration Settings
+
+`integrations` — Per-organization, generic for future integrations.
+
+| Column | Type | Notes |
+|--------|------|-------|
+| id | binary_id | PK |
+| organization_id | binary_id | FK to organizations |
+| provider | string | "preseem" (future: "splynx", "sonar", etc.) |
+| enabled | boolean | default false |
+| credentials | encrypted JSONB | API key, base URL (Cloak AES-256-GCM) |
+| sync_interval_minutes | integer | default 10 |
+| last_synced_at | utc_datetime | nullable |
+| last_sync_status | string | "success", "partial", "failed", "never" |
+| timestamps | utc_datetime | |
+
+Unique constraint on `{organization_id, provider}`.
+
+### Preseem Access Points
+
+`preseem_access_points` — Central entity synced from Preseem API.
+
+| Column | Type | Notes |
+|--------|------|-------|
+| id | binary_id | PK |
+| organization_id | binary_id | FK, tenant isolation |
+| preseem_id | string | Preseem's identifier, unique per org |
+| name | string | AP name from Preseem |
+| mac_address | string | for device matching |
+| ip_address | string | for device matching |
+| model | string | for fleet analysis |
+| firmware | string | for fleet analysis |
+| capacity_score | float | Preseem computed score |
+| qoe_score | float | Preseem computed score |
+| rf_score | float | Preseem computed score |
+| busy_hours | integer | hours per day at/near capacity |
+| airtime_utilization | float | percentage |
+| subscriber_count | integer | connected subscribers |
+| device_id | binary_id | nullable FK to devices (matched) |
+| match_confidence | string | "auto_mac", "auto_ip", "auto_hostname", "manual", "ambiguous", "unmatched" |
+| raw_data | map (JSONB) | full Preseem API response |
+| timestamps | utc_datetime | |
+
+Unique constraint on `{organization_id, preseem_id}`.
+
+### Preseem Subscriber Metrics (Time-Series)
+
+`preseem_subscriber_metrics` — Aggregate QoE per AP, stored as time-series.
+
+| Column | Type | Notes |
+|--------|------|-------|
+| id | binary_id | PK |
+| preseem_access_point_id | binary_id | FK |
+| avg_latency | float | ms |
+| avg_jitter | float | ms |
+| avg_loss | float | percentage |
+| avg_throughput | float | Mbps |
+| p95_latency | float | ms, key QoE indicator |
+| subscriber_count | integer | at time of measurement |
+| recorded_at | utc_datetime | hypertable partition key |
+
+### Preseem Sync Logs
+
+`preseem_sync_logs` — Audit trail per sync operation.
+
+| Column | Type | Notes |
+|--------|------|-------|
+| id | binary_id | PK |
+| organization_id | binary_id | FK |
+| integration_id | binary_id | FK |
+| status | string | "success", "partial", "failed" |
+| records_synced | integer | |
+| errors | map (JSONB) | error details |
+| duration_ms | integer | |
+| inserted_at | utc_datetime | |
+
+---
+
+## Device Matching Engine
+
+When sync pulls Preseem APs, a matching pipeline runs against Towerops devices (same org).
+
+### Match Strategy (ordered by confidence)
+
+1. **MAC address** — Normalize (lowercase, strip delimiters), match against `devices.mac_address` and `interfaces.mac_address`. Unique match → `:auto_mac`.
+2. **IP address** — Match against `devices.ip_address`. Unique match → `:auto_ip`.
+3. **Hostname** — Normalize (lowercase, strip domain suffix), match against `devices.name`. Unique match → `:auto_hostname`.
+4. **No match** → `:unmatched`.
+
+### Conflict Handling
+
+If multiple Towerops devices match a single Preseem AP → `:ambiguous`, surfaced for manual review.
+
+### Manual Override
+
+- Link/unlink from integrations page (unmatched/ambiguous list)
+- Link/unlink from device detail page
+- Manual links stored as `:manual`, never overwritten by auto-matching
+
+---
+
+## Analysis Engine
+
+### Individual Device Baselines
+
+For each matched device, compute a rolling 14-day baseline (nightly Oban worker):
+
+- **QoE baseline**: typical qoe_score, p95_latency, subscriber_count (busy vs off-peak)
+- **Capacity baseline**: normal airtime_utilization, busy_hours, throughput ranges
+- **RF baseline**: typical rf_score, Towerops SNMP signal sensors
+
+Stored in `preseem_device_baselines`:
+- Per-metric: mean, stddev, p5, p95
+- Separate busy-hour vs off-peak profiles
+
+When fresh sync data arrives, compare against baseline → flag deviations.
+
+### Fleet Intelligence
+
+Aggregate across all matched devices per org, grouped by device model (manufacturer + model from SNMP profile):
+
+Stored in `preseem_fleet_profiles` keyed by `{organization_id, manufacturer, model}`:
+
+- **Model performance profiles**: avg subscribers, avg QoE per model
+- **Capacity ceilings**: subscriber count where QoE degrades per model
+- **Firmware correlation**: QoE/capacity grouped by firmware version
+- **Configuration patterns**: correlate config differences with performance (using MikroTik backup tracking)
+
+Computed nightly.
+
+---
+
+## Insight Generation & Delivery
+
+### Insight Types
+
+| Insight | Source | Delivery |
+|---------|--------|----------|
+| QoE degradation on specific AP | Baseline deviation | Proactive alert |
+| AP approaching capacity saturation | Baseline trend + fleet ceiling | Proactive alert |
+| Firmware upgrade opportunity | Fleet firmware correlation | Contextual guidance |
+| Frequency/interference conflict | Preseem RF + Towerops topology | Contextual guidance |
+| Device model underperforming fleet avg | Fleet profile comparison | Passive feed |
+| Subscriber growth toward capacity ceiling | Baseline trend + fleet intel | Passive feed |
+| Config drift from best-performing peers | Config tracking + fleet correlation | Passive feed |
+
+### Delivery Channels
+
+1. **Proactive alerts** — Generated as Towerops monitoring checks. Uses existing alerting infrastructure (soft/hard state transitions, flapping detection, notifications).
+
+2. **Contextual guidance** — On device detail page, new "Insights" section when device is matched to a Preseem AP. Dismissable recommendation cards.
+
+3. **Passive insights feed** — New LiveView page under network section. Filterable, sortable list of all insights across the org.
+
+---
+
+## UI Components
+
+### Org Settings > Integrations
+
+- List of available integration providers with enable/disable toggle
+- Per-integration config panel:
+ - Preseem: API key field, optional base URL override
+ - "Test Connection" button (validates API key)
+ - Sync status, last sync time, record counts
+ - Sync history log
+
+### Device Detail > Preseem Tab (when matched)
+
+- Current QoE score, capacity score, RF score
+- Subscriber count and busy hours
+- Trend charts (from preseem_subscriber_metrics time-series)
+- Contextual insights/recommendations
+- Link to Preseem dashboard for this AP
+
+### Unmatched Devices Page (under Integrations)
+
+- List of Preseem APs not yet matched to Towerops devices
+- Ambiguous matches needing resolution
+- Search/select to manually link
+
+### Network Insights Page
+
+- All generated insights across the org
+- Filter by: type, urgency, device, site
+- Sortable by urgency, date, device
+- Bulk dismiss
+
+---
+
+## Sync Architecture
+
+### PreseemSyncWorker (Oban Cron)
+
+Runs every 10 minutes (configurable per org). For each org with enabled Preseem integration:
+
+1. Fetch API key from encrypted credentials
+2. Pull AP list from Preseem Model API
+3. Upsert `preseem_access_points` (by preseem_id)
+4. Pull QoE metrics per AP
+5. Insert `preseem_subscriber_metrics` time-series records
+6. Run device matching pipeline for unmatched/new APs
+7. Log sync result to `preseem_sync_logs`
+
+### PreseemBaselineWorker (Oban Cron, nightly)
+
+1. Compute individual device baselines from 14-day window
+2. Compute fleet profiles grouped by model
+3. Compare current data against baselines
+4. Generate insights for deviations
+
+### Preseem API Client
+
+`Towerops.Preseem.Client` — Uses `:req` (only approved HTTP client).
+
+- `list_access_points/1` — Pull all APs
+- `get_access_point_metrics/2` — QoE metrics for specific AP
+- `test_connection/1` — Validate API key
+
+---
+
+## Context Structure
+
+```
+Towerops.Integrations — Generic integration CRUD (settings, credentials)
+Towerops.Preseem — Preseem-specific context
+Towerops.Preseem.Client — API client (Req-based)
+Towerops.Preseem.Sync — Sync logic (upsert, metrics insert)
+Towerops.Preseem.DeviceMatcher — Matching pipeline
+Towerops.Preseem.Baseline — Individual device baselining
+Towerops.Preseem.FleetIntelligence — Fleet-wide model analysis
+Towerops.Preseem.Insights — Insight generation and storage
+```
+
+---
+
+## Implementation Stages
+
+### Stage 1: Foundation
+- `integrations` schema + migration
+- Org Settings > Integrations UI (generic)
+- Preseem API client with test connection
+- Encrypted credential storage
+
+### Stage 2: Data Sync
+- `preseem_access_points` + `preseem_subscriber_metrics` schemas + migrations
+- `preseem_sync_logs` schema + migration
+- PreseemSyncWorker
+- Device matching engine
+
+### Stage 3: UI — Device Integration
+- Preseem tab on device detail page
+- Unmatched devices management page
+- Manual link/unlink UI
+
+### Stage 4: Analysis Engine
+- `preseem_device_baselines` + `preseem_fleet_profiles` schemas
+- PreseemBaselineWorker (nightly)
+- Fleet intelligence computation
+
+### Stage 5: Insights & Guidance
+- Insight generation from baselines + fleet data
+- Proactive alerts (monitoring check integration)
+- Contextual guidance on device pages
+- Network insights feed page
diff --git a/docs/plans/2026-02-12-preseem-stage1-foundation.md b/docs/plans/2026-02-12-preseem-stage1-foundation.md
new file mode 100644
index 00000000..1dc667b0
--- /dev/null
+++ b/docs/plans/2026-02-12-preseem-stage1-foundation.md
@@ -0,0 +1,1518 @@
+# 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**
+
+```elixir
+# 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**
+
+```elixir
+# 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**
+
+```bash
+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**
+
+```elixir
+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**
+
+```elixir
+# 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**
+
+```elixir
+# 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**
+
+```bash
+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**
+
+```elixir
+# 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**
+
+```elixir
+# 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**
+
+```bash
+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).
+
+```elixir
+# 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**
+
+```elixir
+# 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**
+
+```bash
+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:
+
+```elixir
+# 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**
+
+```elixir
+# 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**
+
+```elixir
+# 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**
+
+```heex
+<%!-- lib/towerops_web/live/org/integrations_live.html.heex --%>
+
+ Connect third-party services to enhance monitoring and analytics
+
+ {provider.description}
+
+ Only change this if directed by Preseem support
+
+ Connection successful
+
+ {message}
+
+ Integrations
+
+
+ {provider.name}
+
+
+ Configure Preseem
+
+
+
` tag) and add: + +```heex + <%!-- Add after the subtitle
tag, inside the header div --%> +