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