towerops/lib/towerops/gaiia/impact_analysis.ex
Graham McIntire 934ee5bb86
add gaiia outage impact analysis (stage 5)
Computes subscriber impact when device alerts fire. Three analysis
layers: device-level (inventory item -> direct subscriber) and
site-level (network site -> all subscribers at tower). Impact data
stored as JSONB on alert records and displayed as badges on alert list.
2026-02-13 11:21:59 -06:00

128 lines
3.8 KiB
Elixir

defmodule Towerops.Gaiia.ImpactAnalysis do
@moduledoc """
Calculates subscriber impact when a Towerops device goes down.
Three layers of impact analysis (broadest to most precise):
1. Site-level: Device → Site → Gaiia Network Site → all subscribers at that site
2. Device-level: Device → Gaiia Inventory Item → directly assigned subscriber
"""
alias Towerops.Devices
alias Towerops.Gaiia
@doc """
Analyze the subscriber impact of a device going down.
Returns a map with:
- `:device_level` - direct subscriber impact (from inventory item assignment)
- `:site_level` - broader site-level impact (from network site mapping)
- `:total_subscribers` - broadest subscriber count (site > device)
- `:total_mrr` - broadest MRR estimate
- `:analyzed_at` - timestamp of analysis
"""
@spec analyze_device_impact(String.t(), String.t()) :: map()
def analyze_device_impact(organization_id, device_id) do
device = Devices.get_device(device_id)
device_level = analyze_device_level(organization_id, device_id)
site_level = analyze_site_level(organization_id, device)
{total_subscribers, total_mrr} = compute_totals(device_level, site_level)
%{
device_level: device_level,
site_level: site_level,
total_subscribers: total_subscribers,
total_mrr: total_mrr,
analyzed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
end
@doc """
Convert impact analysis result to a JSON-serializable map for storage.
"""
@spec to_json(map()) :: map()
def to_json(impact) do
%{
"device_level" => encode_level(impact.device_level),
"site_level" => encode_level(impact.site_level),
"total_subscribers" => impact.total_subscribers,
"total_mrr" => to_string(impact.total_mrr),
"analyzed_at" => DateTime.to_iso8601(impact.analyzed_at)
}
end
defp analyze_device_level(organization_id, device_id) do
case Gaiia.get_inventory_item_for_device(device_id) do
nil ->
nil
item ->
{subscribers, mrr} = get_item_subscriber_impact(organization_id, item)
if subscribers > 0 do
%{
subscriber_count: subscribers,
mrr: mrr,
inventory_item_name: item.name,
confidence: :device_level
}
end
end
end
defp analyze_site_level(_organization_id, nil), do: nil
defp analyze_site_level(_organization_id, %{site_id: nil}), do: nil
defp analyze_site_level(_organization_id, device) do
case Gaiia.get_network_site_for_site(device.site_id) do
nil ->
nil
network_site ->
count = network_site.account_count || 0
mrr = network_site.total_mrr || Decimal.new("0")
%{
subscriber_count: count,
mrr: mrr,
site_name: network_site.name,
confidence: :site_level
}
end
end
defp get_item_subscriber_impact(organization_id, item) do
case item.assigned_account_gaiia_id do
nil ->
{0, Decimal.new("0")}
account_gaiia_id ->
case Gaiia.get_account(organization_id, account_gaiia_id) do
nil -> {1, Decimal.new("0")}
account -> {1, account.mrr || Decimal.new("0")}
end
end
end
defp compute_totals(nil, nil), do: {0, Decimal.new("0")}
defp compute_totals(nil, site), do: {site.subscriber_count, site.mrr}
defp compute_totals(device, nil), do: {device.subscriber_count, device.mrr}
defp compute_totals(_device, site) do
# Site-level is the broadest impact estimate
{site.subscriber_count, site.mrr}
end
defp encode_level(nil), do: nil
defp encode_level(level) do
level
|> maybe_from_struct()
|> Map.update(:mrr, "0", &to_string/1)
|> Map.update(:confidence, nil, &to_string/1)
end
defp maybe_from_struct(%_{} = struct), do: Map.from_struct(struct)
defp maybe_from_struct(map) when is_map(map), do: map
end