towerops/lib/towerops/on_call.ex
Graham McIntire 2528e78ff9 refactor: extract per-schema Query modules for organization_id filters
Adds 7 new composable Query modules following the existing
DeviceQuery/AlertQuery pattern. Each exposes base/0 and for_organization/1,2
plus filters used by 2+ callers.

New modules:
- Towerops.OnCall.ScheduleQuery
- Towerops.OnCall.EscalationPolicyQuery
- Towerops.Maintenance.MaintenanceWindowQuery
- Towerops.Organizations.MembershipQuery
- Towerops.Organizations.InvitationQuery
- Towerops.Agents.AgentTokenQuery
- Towerops.Gaiia.DeviceSubscriberLinkQuery

Refactors callsites in alerts, agents, devices, gaiia, maintenance, on_call,
organizations, search, and subscription_limits to use existing or new Query
modules instead of inline 'where: x.organization_id == ^...' clauses.
2026-04-30 14:26:30 -05:00

392 lines
10 KiB
Elixir

defmodule Towerops.OnCall do
@moduledoc """
Context for on-call schedules and escalation policies.
"""
import Ecto.Query
alias Towerops.Devices.DeviceQuery
alias Towerops.OnCall.EscalationPolicy
alias Towerops.OnCall.EscalationPolicyQuery
alias Towerops.OnCall.EscalationRule
alias Towerops.OnCall.EscalationTarget
alias Towerops.OnCall.Layer
alias Towerops.OnCall.LayerMember
alias Towerops.OnCall.Override
alias Towerops.OnCall.Resolver
alias Towerops.OnCall.Schedule
alias Towerops.OnCall.ScheduleQuery
alias Towerops.Organizations.Organization
alias Towerops.Repo
# --- Schedules ---
def list_schedules(organization_id) do
organization_id
|> ScheduleQuery.for_organization()
|> ScheduleQuery.order_by_name()
|> Repo.all()
end
def get_schedule!(id) do
Schedule
|> Repo.get!(id)
|> Repo.preload(overrides: :user, layers: [members: :user])
end
def get_schedule!(id, organization_id) do
organization_id
|> ScheduleQuery.for_organization()
|> ScheduleQuery.with_id(id)
|> Repo.one!()
|> Repo.preload(overrides: :user, layers: [members: :user])
end
def get_schedule(id) do
case Repo.get(Schedule, id) do
nil -> nil
schedule -> Repo.preload(schedule, overrides: :user, layers: [members: :user])
end
end
def create_schedule(attrs) do
%Schedule{}
|> Schedule.changeset(attrs)
|> Repo.insert()
end
def update_schedule(%Schedule{} = schedule, attrs) do
schedule
|> Schedule.changeset(attrs)
|> Repo.update()
end
def delete_schedule(%Schedule{} = schedule) do
Repo.delete(schedule)
end
def change_schedule(%Schedule{} = schedule, attrs \\ %{}) do
Schedule.changeset(schedule, attrs)
end
# --- Layers ---
def create_layer(attrs) do
%Layer{}
|> Layer.changeset(attrs)
|> Repo.insert()
end
def update_layer(%Layer{} = layer, attrs) do
layer
|> Layer.changeset(attrs)
|> Repo.update()
end
def delete_layer(%Layer{} = layer) do
Repo.delete(layer)
end
@doc """
Sets or clears the on-call restriction window for a layer.
Pass `nil` for `restriction_type` (or any of the value pairs) to clear
restrictions and have the layer cover its full rotation continuously.
"""
def update_layer_restrictions(%Layer{} = layer, attrs) when is_map(attrs) do
layer
|> Layer.changeset(%{
restriction_type: attrs[:restriction_type] || attrs["restriction_type"],
restrictions: attrs[:restrictions] || attrs["restrictions"]
})
|> Repo.update()
end
def clear_layer_restrictions(%Layer{} = layer) do
layer
|> Layer.changeset(%{restriction_type: nil, restrictions: nil})
|> Repo.update()
end
@doc """
Swaps a layer with its neighbour in the given direction (`:up` or `:down`).
Returns `{:ok, layer}` (unchanged when at the boundary), or `{:error, reason}`.
"""
def move_layer(%Layer{} = layer, direction) when direction in [:up, :down] do
schedule_layers =
Layer
|> where([l], l.schedule_id == ^layer.schedule_id)
|> order_by([l], asc: l.position)
|> Repo.all()
case find_neighbour_layer(schedule_layers, layer, direction) do
nil ->
{:ok, layer}
neighbour ->
case swap_layer_positions(layer, neighbour) do
:ok -> {:ok, Repo.get!(Layer, layer.id)}
{:error, _} = err -> err
end
end
end
defp find_neighbour_layer(layers, layer, :up), do: Enum.find(layers, &(&1.position == layer.position - 1))
defp find_neighbour_layer(layers, layer, :down), do: Enum.find(layers, &(&1.position == layer.position + 1))
@doc """
Persists a new ordering for all layers of `schedule_id`.
`ordered_ids` must contain every layer id exactly once. Positions are
assigned 0..n-1 in the order given, in a single transaction.
"""
def reorder_layers(schedule_id, ordered_ids) when is_binary(schedule_id) and is_list(ordered_ids) do
layers =
Layer
|> where([l], l.schedule_id == ^schedule_id)
|> Repo.all()
reorder_in_transaction(layers, ordered_ids, &Layer.changeset/2)
end
@doc """
Persists a new ordering for all rules of `policy_id`. Same contract as
`reorder_layers/2`.
"""
def reorder_escalation_rules(policy_id, ordered_ids) when is_binary(policy_id) and is_list(ordered_ids) do
rules =
EscalationRule
|> where([r], r.escalation_policy_id == ^policy_id)
|> Repo.all()
reorder_in_transaction(rules, ordered_ids, &EscalationRule.changeset/2)
end
defp reorder_in_transaction(records, ordered_ids, changeset_fn) do
by_id = Map.new(records, &{&1.id, &1})
cond do
length(ordered_ids) != length(records) -> {:error, :id_count_mismatch}
not Enum.all?(ordered_ids, &Map.has_key?(by_id, &1)) -> {:error, :unknown_id}
true -> persist_reorder(by_id, ordered_ids, changeset_fn)
end
end
defp persist_reorder(by_id, ordered_ids, changeset_fn) do
fn -> Enum.each(Enum.with_index(ordered_ids), &assign_position(&1, by_id, changeset_fn)) end
|> Repo.transaction()
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp assign_position({id, idx}, by_id, changeset_fn) do
{:ok, _} =
by_id
|> Map.fetch!(id)
|> changeset_fn.(%{position: idx})
|> Repo.update()
end
defp swap_layer_positions(a, b) do
a_pos = a.position
b_pos = b.position
fn ->
{:ok, _} = a |> Layer.changeset(%{position: b_pos}) |> Repo.update()
{:ok, _} = b |> Layer.changeset(%{position: a_pos}) |> Repo.update()
end
|> Repo.transaction()
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
# --- Layer Members ---
def add_layer_member(attrs) do
%LayerMember{}
|> LayerMember.changeset(attrs)
|> Repo.insert()
end
def remove_layer_member(%LayerMember{} = member) do
Repo.delete(member)
end
# --- Overrides ---
def create_override(attrs) do
%Override{}
|> Override.changeset(attrs)
|> Repo.insert()
end
def delete_override(%Override{} = override) do
Repo.delete(override)
end
# --- Escalation Policies ---
def list_escalation_policies(organization_id) do
organization_id
|> EscalationPolicyQuery.for_organization()
|> EscalationPolicyQuery.order_by_name()
|> Repo.all()
|> Repo.preload(:rules)
end
def get_escalation_policy!(id) do
EscalationPolicy
|> Repo.get!(id)
|> Repo.preload(rules: :targets)
end
def get_escalation_policy!(id, organization_id) do
organization_id
|> EscalationPolicyQuery.for_organization()
|> EscalationPolicyQuery.with_id(id)
|> Repo.one!()
|> Repo.preload(rules: :targets)
end
def get_escalation_policy(id) do
case Repo.get(EscalationPolicy, id) do
nil -> nil
policy -> Repo.preload(policy, rules: :targets)
end
end
def create_escalation_policy(attrs) do
%EscalationPolicy{}
|> EscalationPolicy.changeset(attrs)
|> Repo.insert()
end
def update_escalation_policy(%EscalationPolicy{} = policy, attrs) do
policy
|> EscalationPolicy.changeset(attrs)
|> Repo.update()
end
def delete_escalation_policy(%EscalationPolicy{} = policy) do
Repo.delete(policy)
end
def change_escalation_policy(%EscalationPolicy{} = policy, attrs \\ %{}) do
EscalationPolicy.changeset(policy, attrs)
end
# --- Escalation Rules ---
def create_escalation_rule(attrs) do
%EscalationRule{}
|> EscalationRule.changeset(attrs)
|> Repo.insert()
end
def update_escalation_rule(%EscalationRule{} = rule, attrs) do
rule
|> EscalationRule.changeset(attrs)
|> Repo.update()
end
def delete_escalation_rule(%EscalationRule{} = rule) do
Repo.delete(rule)
end
@doc """
Swaps a rule with its neighbour in the given direction (`:up` or `:down`).
Returns `{:ok, rule}` (unchanged when at the boundary), or `{:error, reason}`.
"""
def move_escalation_rule(%EscalationRule{} = rule, direction) when direction in [:up, :down] do
policy_rules =
EscalationRule
|> where([r], r.escalation_policy_id == ^rule.escalation_policy_id)
|> order_by([r], asc: r.position)
|> Repo.all()
case find_neighbour(policy_rules, rule, direction) do
nil ->
{:ok, rule}
neighbour ->
case swap_rule_positions(rule, neighbour) do
:ok -> {:ok, Repo.get!(EscalationRule, rule.id)}
{:error, _} = err -> err
end
end
end
defp find_neighbour(rules, rule, :up), do: Enum.find(rules, &(&1.position == rule.position - 1))
defp find_neighbour(rules, rule, :down), do: Enum.find(rules, &(&1.position == rule.position + 1))
defp swap_rule_positions(a, b) do
a_pos = a.position
b_pos = b.position
fn ->
{:ok, _} =
a
|> EscalationRule.changeset(%{position: b_pos})
|> Repo.update()
{:ok, _} =
b
|> EscalationRule.changeset(%{position: a_pos})
|> Repo.update()
end
|> Repo.transaction()
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
# --- Escalation Targets ---
def create_escalation_target(attrs) do
%EscalationTarget{}
|> EscalationTarget.changeset(attrs)
|> Repo.insert()
end
def delete_escalation_target(%EscalationTarget{} = target) do
Repo.delete(target)
end
@doc """
Returns devices in `organization_id` whose effective escalation policy is
`policy_id` — either set directly on the device, or inherited from the
organization's default policy.
"""
def list_devices_using_policy(policy_id, organization_id) do
org_default_query =
from o in Organization,
where: o.id == ^organization_id and o.default_escalation_policy_id == ^policy_id,
select: o.id
organization_id
|> DeviceQuery.for_organization()
|> where(
[d],
d.escalation_policy_id == ^policy_id or
(is_nil(d.escalation_policy_id) and d.organization_id in subquery(org_default_query))
)
|> order_by([d], asc: d.name)
|> Repo.all()
end
# --- On-Call Resolution ---
def who_is_on_call(schedule_id, datetime \\ DateTime.utc_now()) do
case get_schedule(schedule_id) do
nil -> nil
schedule -> Resolver.resolve(schedule, datetime)
end
end
end