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.
This commit is contained in:
Graham McIntire 2026-02-13 11:25:21 -06:00
parent 934ee5bb86
commit 6da05d461b
No known key found for this signature in database
3 changed files with 291 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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