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.
392 lines
10 KiB
Elixir
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
|