From 6da05d461ba0d81f968855afd71395da88699bbb Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 11:25:21 -0600 Subject: [PATCH] add gaiia write-back actions (stage 6) Three write-back capabilities: ticket creation from alerts, subscriber notes during outages, and inventory item updates. Adds GraphQL mutation methods to the client and an Actions module that handles credential retrieval and API call orchestration. --- lib/towerops/gaiia/actions.ex | 103 ++++++++++++++++++++++ lib/towerops/gaiia/client.ex | 61 +++++++++++++ test/towerops/gaiia/actions_test.exs | 127 +++++++++++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 lib/towerops/gaiia/actions.ex create mode 100644 test/towerops/gaiia/actions_test.exs diff --git a/lib/towerops/gaiia/actions.ex b/lib/towerops/gaiia/actions.ex new file mode 100644 index 00000000..869721be --- /dev/null +++ b/lib/towerops/gaiia/actions.ex @@ -0,0 +1,103 @@ +defmodule Towerops.Gaiia.Actions do + @moduledoc """ + Write-back actions from Towerops to Gaiia. + + Handles: + - Ticket creation from alerts + - Subscriber notes during outages + - Inventory item updates (IP, firmware, serial) + """ + + alias Towerops.Gaiia.Client + alias Towerops.Integrations + + require Logger + + @doc """ + Create a Gaiia ticket for an alert. + + Returns `{:ok, ticket_id}` on success, or `{:error, reason}`. + """ + def create_ticket_for_alert(organization_id, alert) do + with {:ok, api_key} <- get_api_key(organization_id) do + subject = "Device Down: #{alert.device.name}" + + description = + "Alert: #{alert.message}\nTriggered: #{DateTime.to_iso8601(alert.triggered_at)}" + + variables = %{ + "subject" => subject, + "description" => description + } + + case Client.create_ticket(api_key, variables) do + {:ok, %{"createExternalTicket" => %{"id" => ticket_id}}} -> + {:ok, ticket_id} + + {:error, reason} -> + Logger.warning("Failed to create Gaiia ticket for alert #{alert.id}: #{inspect(reason)}") + {:error, reason} + end + end + end + + @doc """ + Create a note on a Gaiia account (e.g., during an outage). + + Returns `{:ok, note_id}` on success, or `{:error, reason}`. + """ + def create_subscriber_note(organization_id, account_gaiia_id, content) do + with {:ok, api_key} <- get_api_key(organization_id) do + case Client.create_note(api_key, "ACCOUNT", account_gaiia_id, content) do + {:ok, %{"createNote" => %{"id" => note_id}}} -> + {:ok, note_id} + + {:error, reason} -> + Logger.warning("Failed to create Gaiia note for account #{account_gaiia_id}: #{inspect(reason)}") + + {:error, reason} + end + end + end + + @doc """ + Update a Gaiia inventory item with field changes. + + Accepts a map of fields to update (e.g., `%{ip_address: "10.0.0.5"}`). + Returns `{:ok, data}` on success, or `{:error, reason}`. + """ + def update_inventory_item(organization_id, item_gaiia_id, fields) do + with {:ok, api_key} <- get_api_key(organization_id) do + input = build_inventory_input(fields) + + case Client.update_inventory_item(api_key, item_gaiia_id, input) do + {:ok, %{"updateInventoryItem" => data}} -> + {:ok, data} + + {:error, reason} -> + Logger.warning("Failed to update Gaiia inventory item #{item_gaiia_id}: #{inspect(reason)}") + + {:error, reason} + end + end + end + + defp get_api_key(organization_id) do + case Integrations.get_integration(organization_id, "gaiia") do + {:ok, %{credentials: %{"api_key" => api_key}}} when is_binary(api_key) -> + {:ok, api_key} + + _ -> + {:error, :no_integration} + end + end + + defp build_inventory_input(fields) do + Enum.reduce(fields, %{}, fn + {:ip_address, value}, acc -> Map.put(acc, "ipAddress", value) + {:serial_number, value}, acc -> Map.put(acc, "serialNumber", value) + {:mac_address, value}, acc -> Map.put(acc, "macAddress", value) + {key, value}, acc -> Map.put(acc, to_string(key), value) + end) + end +end diff --git a/lib/towerops/gaiia/client.ex b/lib/towerops/gaiia/client.ex index 40ae2eaa..1d469f13 100644 --- a/lib/towerops/gaiia/client.ex +++ b/lib/towerops/gaiia/client.ex @@ -141,6 +141,67 @@ defmodule Towerops.Gaiia.Client do } """ + # --- Mutations --- + + @create_ticket_mutation """ + mutation CreateExternalTicket($subject: String!, $description: String!, $entityId: ID, $entityType: String) { + createExternalTicket(input: { + subject: $subject, + description: $description, + entityId: $entityId, + entityType: $entityType + }) { + id + subject + } + } + """ + + @create_note_mutation """ + mutation CreateNote($entityId: ID!, $entityType: String!, $body: String!) { + createNote(input: { + entityId: $entityId, + entityType: $entityType, + body: $body + }) { + id + } + } + """ + + @update_inventory_item_mutation """ + mutation UpdateInventoryItem($id: ID!, $input: UpdateInventoryItemInput!) { + updateInventoryItem(id: $id, input: $input) { + id + ipAddress + serialNumber + } + } + """ + + @doc "Create an external ticket in Gaiia." + def create_ticket(api_key, attrs, opts \\ []) do + query(api_key, @create_ticket_mutation, attrs, opts) + end + + @doc "Create a note on a Gaiia entity (account, inventory item, etc)." + def create_note(api_key, entity_type, entity_id, body, opts \\ []) do + variables = %{ + "entityId" => entity_id, + "entityType" => entity_type, + "body" => body + } + + query(api_key, @create_note_mutation, variables, opts) + end + + @doc "Update an inventory item in Gaiia." + def update_inventory_item(api_key, item_id, input, opts \\ []) do + query(api_key, @update_inventory_item_mutation, %{"id" => item_id, "input" => input}, opts) + end + + # --- Queries --- + @doc "Test the connection to Gaiia by running a simple viewer query." def test_connection(api_key, opts \\ []) do case query(api_key, "{ viewer { id } }", %{}, opts) do diff --git a/test/towerops/gaiia/actions_test.exs b/test/towerops/gaiia/actions_test.exs new file mode 100644 index 00000000..89403193 --- /dev/null +++ b/test/towerops/gaiia/actions_test.exs @@ -0,0 +1,127 @@ +defmodule Towerops.Gaiia.ActionsTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Gaiia.Actions + alias Towerops.Gaiia.Client + alias Towerops.Integrations + + setup do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, integration} = + Integrations.create_integration(org.id, %{ + provider: "gaiia", + enabled: true, + credentials: %{"api_key" => "test-key"} + }) + + %{org: org, integration: integration} + end + + describe "create_ticket_for_alert/3" do + test "creates a ticket via Gaiia API and returns ticket ID", %{org: org} do + device = device_fixture(%{organization_id: org.id, name: "Tower Router"}) + + alert = %{ + id: Ecto.UUID.generate(), + device: device, + alert_type: :device_down, + message: "Device is not responding to SNMP", + triggered_at: DateTime.utc_now() + } + + Req.Test.stub(Client, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + assert decoded["query"] =~ "createExternalTicket" + + Req.Test.json(conn, %{ + "data" => %{ + "createExternalTicket" => %{ + "id" => "gaiia-ticket-123", + "subject" => "Device Down: Tower Router" + } + } + }) + end) + + assert {:ok, "gaiia-ticket-123"} = Actions.create_ticket_for_alert(org.id, alert) + end + + test "returns error when no integration exists" do + org_id = Ecto.UUID.generate() + + alert = %{ + id: Ecto.UUID.generate(), + alert_type: :device_down, + device: %{name: "Test"}, + message: "Down", + triggered_at: DateTime.utc_now() + } + + assert {:error, :no_integration} = Actions.create_ticket_for_alert(org_id, alert) + end + end + + describe "create_subscriber_note/4" do + test "creates a note on a Gaiia account", %{org: org} do + Req.Test.stub(Client, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + assert decoded["query"] =~ "createNote" + + Req.Test.json(conn, %{ + "data" => %{ + "createNote" => %{ + "id" => "gaiia-note-456" + } + } + }) + end) + + assert {:ok, "gaiia-note-456"} = + Actions.create_subscriber_note(org.id, "gaiia-acct-1", "Outage affecting Tower 3") + end + + test "returns error when no integration exists" do + org_id = Ecto.UUID.generate() + + assert {:error, :no_integration} = + Actions.create_subscriber_note(org_id, "gaiia-acct-1", "note") + end + end + + describe "update_inventory_item/4" do + test "pushes field updates to Gaiia inventory item", %{org: org} do + Req.Test.stub(Client, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + assert decoded["query"] =~ "updateInventoryItem" + + Req.Test.json(conn, %{ + "data" => %{ + "updateInventoryItem" => %{ + "id" => "gaiia-inv-1", + "ipAddress" => "10.0.0.5" + } + } + }) + end) + + assert {:ok, _data} = + Actions.update_inventory_item(org.id, "gaiia-inv-1", %{ip_address: "10.0.0.5"}) + end + + test "returns error when no integration exists" do + org_id = Ecto.UUID.generate() + + assert {:error, :no_integration} = + Actions.update_inventory_item(org_id, "gaiia-inv-1", %{ip_address: "10.0.0.5"}) + end + end +end