From d857a38ca3e21bf11697a5716417c69e35a509bb Mon Sep 17 00:00:00 2001
From: Graham McIntie
Date: Sun, 15 Feb 2026 08:27:23 -0600
Subject: [PATCH] feat: group integrations by category with exclusive billing
provider selection
Billing (Gaiia, Sonar, Splynx, VISP) - choose one
QoE Monitoring (Preseem)
Incident Management (PagerDuty)
Infrastructure & IPAM (NetBox)
Prevents enabling multiple billing providers simultaneously.
---
.../live/org/integrations_live.ex | 180 ++++++++++++++----
.../live/org/integrations_live.html.heex | 22 ++-
2 files changed, 168 insertions(+), 34 deletions(-)
diff --git a/lib/towerops_web/live/org/integrations_live.ex b/lib/towerops_web/live/org/integrations_live.ex
index 85f8afb6..7ba9271c 100644
--- a/lib/towerops_web/live/org/integrations_live.ex
+++ b/lib/towerops_web/live/org/integrations_live.ex
@@ -9,29 +9,85 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
require Logger
- @providers [
+ @provider_categories [
%{
- id: "preseem",
- name: "Preseem",
- description: "QoE monitoring and subscriber experience analytics for wireless ISPs.",
- icon: "hero-signal"
+ id: "billing",
+ name: "Billing & Subscriber Management",
+ description: "Connect your billing platform to enable outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.",
+ exclusive: true,
+ providers: [
+ %{
+ id: "gaiia",
+ name: "Gaiia",
+ description: "Full-featured ISP billing with GraphQL API and real-time webhooks.",
+ icon: "hero-user-group"
+ },
+ %{
+ id: "sonar",
+ name: "Sonar",
+ description: "Cloud-based ISP billing and operations platform.",
+ icon: "hero-user-group"
+ },
+ %{
+ id: "splynx",
+ name: "Splynx",
+ description: "ISP billing, CRM, and network management.",
+ icon: "hero-user-group"
+ },
+ %{
+ id: "visp",
+ name: "VISP",
+ description: "Billing and subscriber management for WISPs.",
+ icon: "hero-user-group"
+ }
+ ]
},
%{
- id: "gaiia",
- name: "Gaiia",
- description:
- "Billing and subscriber management. Enables outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.",
- icon: "hero-user-group"
+ id: "monitoring",
+ name: "Quality of Experience",
+ description: "Monitor subscriber experience and network quality metrics.",
+ exclusive: false,
+ providers: [
+ %{
+ id: "preseem",
+ name: "Preseem",
+ description: "QoE monitoring and subscriber experience analytics for wireless ISPs.",
+ icon: "hero-signal"
+ }
+ ]
},
%{
- id: "pagerduty",
- name: "PagerDuty",
- description:
- "Incident management and on-call alerting. Automatically triggers, acknowledges, and resolves PagerDuty incidents from TowerOps alerts.",
- icon: "hero-bell-alert"
+ id: "incidents",
+ name: "Incident Management",
+ description: "Automate alert routing and on-call notifications.",
+ exclusive: false,
+ providers: [
+ %{
+ id: "pagerduty",
+ name: "PagerDuty",
+ description: "Automatically triggers, acknowledges, and resolves PagerDuty incidents from TowerOps alerts.",
+ icon: "hero-bell-alert"
+ }
+ ]
+ },
+ %{
+ id: "infrastructure",
+ name: "Infrastructure & IPAM",
+ description: "Track network infrastructure, IP addressing, and device inventory.",
+ exclusive: false,
+ providers: [
+ %{
+ id: "netbox",
+ name: "NetBox",
+ description: "Network source of truth for IPAM, DCIM, and infrastructure documentation.",
+ icon: "hero-server-stack"
+ }
+ ]
}
]
+ @providers Enum.flat_map(@provider_categories, & &1.providers)
+
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
@@ -42,6 +98,7 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|> assign(:organization, organization)
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:providers, @providers)
+ |> assign(:provider_categories, @provider_categories)
|> assign(:integrations, integrations)
|> assign(:configuring, nil)
|> assign(:form, nil)
@@ -101,15 +158,21 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
attrs = normalize_params(params, existing)
result =
- case existing do
- nil ->
- Integrations.create_integration(
- organization.id,
- attrs |> Map.put(:provider, provider) |> Map.put(:enabled, true)
- )
+ if exclusive_conflict?(provider, socket.assigns.integrations) do
+ category = find_category(provider)
+ active = active_exclusive_provider(category, socket.assigns.integrations)
+ {:error, :exclusive, category.name, active}
+ else
+ case existing do
+ nil ->
+ Integrations.create_integration(
+ organization.id,
+ attrs |> Map.put(:provider, provider) |> Map.put(:enabled, true)
+ )
- integration ->
- Integrations.update_integration(integration, attrs)
+ integration ->
+ Integrations.update_integration(integration, attrs)
+ end
end
case result do
@@ -124,6 +187,17 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|> assign(:test_result, nil)
|> put_flash(:info, t("Integration saved successfully"))}
+ {:error, :exclusive, category_name, active_provider} ->
+ {:noreply,
+ put_flash(
+ socket,
+ :error,
+ t("Only one %{category} integration can be active. You already have %{provider} configured. Disable it first to switch.",
+ category: category_name,
+ provider: active_provider
+ )
+ )}
+
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
@@ -176,21 +250,63 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
{:noreply, socket}
integration ->
- case Integrations.update_integration(integration, %{enabled: !integration.enabled}) do
- {:ok, _updated} ->
- integrations = load_integrations(socket.assigns.organization.id)
+ # If enabling, check exclusive category constraints
+ if !integration.enabled && exclusive_conflict?(provider, socket.assigns.integrations) do
+ category = find_category(provider)
+ active = active_exclusive_provider(category, socket.assigns.integrations)
- {:noreply,
- socket
- |> assign(:integrations, integrations)
- |> put_flash(:info, t("Integration updated"))}
+ {:noreply,
+ put_flash(
+ socket,
+ :error,
+ t("Only one %{category} integration can be active. Disable %{provider} first.",
+ category: category.name,
+ provider: active
+ )
+ )}
+ else
+ case Integrations.update_integration(integration, %{enabled: !integration.enabled}) do
+ {:ok, _updated} ->
+ integrations = load_integrations(socket.assigns.organization.id)
- {:error, _changeset} ->
- {:noreply, put_flash(socket, :error, t("Failed to update integration"))}
+ {:noreply,
+ socket
+ |> assign(:integrations, integrations)
+ |> put_flash(:info, t("Integration updated"))}
+
+ {:error, _changeset} ->
+ {:noreply, put_flash(socket, :error, t("Failed to update integration"))}
+ end
end
end
end
+ defp find_category(provider_id) do
+ Enum.find(@provider_categories, fn cat ->
+ Enum.any?(cat.providers, &(&1.id == provider_id))
+ end)
+ end
+
+ defp exclusive_conflict?(provider_id, integrations) do
+ case find_category(provider_id) do
+ %{exclusive: true} = category ->
+ active = active_exclusive_provider(category, integrations)
+ active != nil && active != provider_id
+
+ _ ->
+ false
+ end
+ end
+
+ defp active_exclusive_provider(%{providers: providers}, integrations) do
+ Enum.find_value(providers, fn p ->
+ case Map.get(integrations, p.id) do
+ %{enabled: true} -> p.name
+ _ -> nil
+ end
+ end)
+ end
+
defp test_provider_connection("preseem", api_key), do: PreseemClient.test_connection(api_key)
defp test_provider_connection("gaiia", api_key), do: GaiiaClient.test_connection(api_key)
defp test_provider_connection(_, _api_key), do: {:error, "Unknown provider"}
diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex
index a115789f..7dd3e443 100644
--- a/lib/towerops_web/live/org/integrations_live.html.heex
+++ b/lib/towerops_web/live/org/integrations_live.html.heex
@@ -19,9 +19,25 @@
-
+
+
+
+
+ {category.name}
+
+
+ {category.description}
+ <%= if category.exclusive do %>
+
+ Choose one
+
+ <% end %>
+
+
+
+
@@ -357,5 +373,7 @@
<% end %>
+
+