towerops/lib/towerops/gaiia/sync.ex
Graham McIntie db40a3e5b7 feat: show human-readable sync status messages for integrations
- Added last_sync_message field to integrations schema
- Gaiia sync: shows item counts on success, friendly error on failure
- Preseem sync: shows AP/device counts on success, friendly error on failure
- Error messages translated from technical errors to user-friendly text
- Message displayed on integration card next to status badge
- Exposed in REST API response
2026-02-14 14:11:02 -06:00

192 lines
6.1 KiB
Elixir

defmodule Towerops.Gaiia.Sync do
@moduledoc """
Orchestrates syncing data from the Gaiia GraphQL API into the local database.
Pulls accounts, network sites, inventory items, and billing subscriptions
from Gaiia, upserts them into local cache tables, and updates integration
sync status.
"""
alias Towerops.Gaiia
alias Towerops.Gaiia.Client
alias Towerops.Integrations
require Logger
@doc """
Main entry point: syncs all entity types for the given integration.
Returns `{:ok, %{accounts: n, network_sites: n, inventory_items: n, subscriptions: n}}`
or `{:error, reason}`.
"""
def sync_organization(%Integrations.Integration{} = integration) do
api_key = integration.credentials["api_key"]
org_id = integration.organization_id
opts = client_opts(integration)
with {:ok, accounts} <- Client.list_accounts(api_key, opts),
accounts_count = upsert_nodes(org_id, accounts, :account),
{:ok, sites} <- Client.list_network_sites(api_key, opts),
sites_count = upsert_nodes(org_id, sites, :network_site),
{:ok, items} <- Client.list_inventory_items(api_key, opts),
items_count = upsert_nodes(org_id, items, :inventory_item),
{:ok, subs} <- Client.list_billing_subscriptions(api_key, opts) do
subs_count = upsert_nodes(org_id, subs, :billing_subscription)
message =
"Synced #{accounts_count} accounts, #{sites_count} sites, #{items_count} inventory items, #{subs_count} subscriptions"
Integrations.update_sync_status(integration, "success", message)
{:ok,
%{
accounts: accounts_count,
network_sites: sites_count,
inventory_items: items_count,
subscriptions: subs_count
}}
else
{:error, reason} ->
Integrations.update_sync_status(integration, "failed", humanize_sync_error(reason))
{:error, reason}
end
end
defp upsert_nodes(org_id, nodes, type) do
Enum.reduce(nodes, 0, fn node, count ->
case upsert_node(org_id, node, type) do
{:ok, _} ->
count + 1
{:error, changeset} ->
Logger.warning("Failed to upsert Gaiia #{type}: #{inspect(changeset.errors)}")
count
end
end)
end
defp upsert_node(org_id, node, :account) do
Gaiia.upsert_account(org_id, map_account(node))
end
defp upsert_node(org_id, node, :network_site) do
Gaiia.upsert_network_site(org_id, map_network_site(node))
end
defp upsert_node(org_id, node, :inventory_item) do
Gaiia.upsert_inventory_item(org_id, map_inventory_item(node))
end
defp upsert_node(org_id, node, :billing_subscription) do
Gaiia.upsert_billing_subscription(org_id, map_billing_subscription(node))
end
defp map_account(node) do
subs = get_in(node, ["billingSubscriptions", "edges"]) || []
active_count = Enum.count(subs, &(get_in(&1, ["node", "status", "name"]) == "Active"))
%{
gaiia_id: node["id"],
readable_id: node["readableId"],
name: node["name"],
status: get_in(node, ["status", "name"]),
account_type: get_in(node, ["type", "name"]),
address: map_address(node["physicalAddress"]),
subscription_count: active_count,
raw_data: node
}
end
defp map_network_site(node) do
ip_block_edges = get_in(node, ["ipBlocks", "edges"]) || []
ip_blocks = Enum.map(ip_block_edges, & &1["node"]["subnet"])
%{
gaiia_id: node["id"],
name: node["name"],
address: map_address(node["address"]),
ip_blocks: ip_blocks,
raw_data: node
}
end
defp map_inventory_item(node) do
assignation = node["assignation"]
{account_id, site_id} =
case assignation do
%{"assigneeType" => "Account", "assignee" => %{"id" => id}} -> {id, nil}
%{"assigneeType" => "NetworkSite", "assignee" => %{"id" => id}} -> {nil, id}
_ -> {nil, nil}
end
%{
gaiia_id: node["id"],
name: get_in(node, ["model", "name"]),
status: get_in(node, ["status", "name"]),
serial_number: nil,
mac_address: nil,
ip_address: node["ipAddressV4"],
model_name: get_in(node, ["model", "name"]),
manufacturer_name: get_in(node, ["model", "manufacturer", "name"]),
category: get_in(node, ["model", "category", "name"]),
assigned_account_gaiia_id: account_id,
assigned_network_site_gaiia_id: site_id,
raw_data: node
}
end
defp map_billing_subscription(node) do
price_cents = get_in(node, ["productVersion", "price"])
mrr_amount =
if is_integer(price_cents),
do: Decimal.div(Decimal.new(price_cents), 100)
%{
gaiia_id: node["id"],
account_gaiia_id: get_in(node, ["entity", "id"]),
status: get_in(node, ["status", "name"]),
product_name: get_in(node, ["productVersion", "product", "name"]),
mrr_amount: mrr_amount,
currency: get_in(node, ["productVersion", "currency"]),
raw_data: node
}
end
defp map_address(nil), do: nil
defp map_address(addr) do
%{
"line1" => addr["line1"],
"city" => addr["locality"],
"state" => addr["region"],
"zip" => addr["postalCode"],
"country" => addr["country"],
"latitude" => addr["latitude"],
"longitude" => addr["longitude"]
}
end
defp client_opts(integration) do
case integration.credentials["endpoint"] do
nil -> []
endpoint -> [endpoint: endpoint]
end
end
defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API key"
defp humanize_sync_error(:forbidden), do: "Access denied — your API key may not have sufficient permissions"
defp humanize_sync_error(:not_found), do: "API endpoint not found — the Gaiia API may have changed"
defp humanize_sync_error(:timeout), do: "Connection timed out — Gaiia may be temporarily unavailable"
defp humanize_sync_error(%Req.TransportError{reason: :econnrefused}), do: "Connection refused — unable to reach Gaiia"
defp humanize_sync_error(%Req.TransportError{reason: reason}), do: "Connection error: #{reason}"
defp humanize_sync_error(reason) when is_binary(reason), do: reason
defp humanize_sync_error(_reason), do: "An unexpected error occurred during sync"
end