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 --%> + +
+
+ <.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 + +
+

+ Integrations +

+

+ Connect third-party services to enhance monitoring and analytics +

+
+ +
+ <%= for provider <- @providers do %> + <% integration = Map.get(@integrations, provider.id) %> +
+
+
+
+ <.icon name={provider.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" /> +
+
+

+ {provider.name} +

+

+ {provider.description} +

+
+
+ +
+ <%= if integration do %> + + <% end %> + + +
+
+ + <%= if integration do %> +
+ + "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 %> + + Status: {integration.last_sync_status} +
+ <% end %> +
+ <% end %> +
+ + <%!-- Configuration Panel --%> + <%= if @configuring == "preseem" do %> + +
+ <% end %> + +``` + +**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 `
` of the page header (around line 20, after the subtitle `

` tag) and add: + +```heex + <%!-- Add after the subtitle

tag, inside the header div --%> +

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