- Client IP: only trust X-Forwarded-For from RFC 1918 proxy IPs - Webhook auth: handle nil/blank secret with controlled error, not 500 crash - Sudo redirect: reuse validated return_path? from login to prevent open redirect - Map live: remove redundant inline script (ensureLeaflet hook handles loading) - Bang calls: convert crash-prone exact matches to case in QR live and API controllers
348 lines
11 KiB
Elixir
348 lines
11 KiB
Elixir
defmodule ToweropsWeb.Api.V1.EscalationPoliciesController do
|
|
@moduledoc """
|
|
API controller for managing escalation policies.
|
|
|
|
All endpoints require API token authentication and operations are scoped
|
|
to the organization associated with the token.
|
|
"""
|
|
use ToweropsWeb, :controller
|
|
|
|
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
|
|
|
|
alias Towerops.OnCall
|
|
alias Towerops.OnCall.EscalationPolicy
|
|
alias Towerops.OnCall.EscalationRule
|
|
alias Towerops.OnCall.EscalationTarget
|
|
alias Towerops.Repo
|
|
alias ToweropsWeb.ScopedResource
|
|
|
|
# --- Policy CRUD ---
|
|
|
|
@doc "GET /api/v1/escalation_policies"
|
|
def index(conn, _params) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
policies =
|
|
organization_id
|
|
|> OnCall.list_escalation_policies()
|
|
|> Enum.map(&format_policy/1)
|
|
|
|
json(conn, %{escalation_policies: policies})
|
|
end
|
|
|
|
@doc "POST /api/v1/escalation_policies"
|
|
def create(conn, %{"escalation_policy" => policy_params}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
attrs = Map.put(policy_params, "organization_id", organization_id)
|
|
|
|
case OnCall.create_escalation_policy(attrs) do
|
|
{:ok, policy} ->
|
|
conn
|
|
|> put_status(:created)
|
|
|> json(format_policy(policy))
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
end
|
|
|
|
def create(conn, _params) do
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: "Missing 'escalation_policy' parameter"})
|
|
end
|
|
|
|
@doc "GET /api/v1/escalation_policies/:id"
|
|
def show(conn, %{"id" => id}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
case ScopedResource.fetch_preload(EscalationPolicy, id, organization_id, rules: :targets) do
|
|
{:ok, policy} ->
|
|
json(conn, format_policy_detail(policy))
|
|
|
|
{:error, :forbidden} ->
|
|
conn
|
|
|> put_status(:forbidden)
|
|
|> json(%{error: "Access denied to this escalation policy"})
|
|
|
|
{:error, :not_found} ->
|
|
conn
|
|
|> put_status(:not_found)
|
|
|> json(%{error: "Escalation policy not found"})
|
|
end
|
|
end
|
|
|
|
@doc "PATCH /api/v1/escalation_policies/:id"
|
|
def update(conn, %{"id" => id, "escalation_policy" => policy_params}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
case ScopedResource.fetch(EscalationPolicy, id, organization_id) do
|
|
{:ok, policy} ->
|
|
case OnCall.update_escalation_policy(policy, policy_params) do
|
|
{:ok, updated} ->
|
|
json(conn, format_policy(updated))
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
|
|
{:error, :forbidden} ->
|
|
conn
|
|
|> put_status(:forbidden)
|
|
|> json(%{error: "Access denied to this escalation policy"})
|
|
|
|
{:error, :not_found} ->
|
|
conn
|
|
|> put_status(:not_found)
|
|
|> json(%{error: "Escalation policy not found"})
|
|
end
|
|
end
|
|
|
|
def update(conn, _params) do
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: "Missing 'escalation_policy' parameter"})
|
|
end
|
|
|
|
@doc "DELETE /api/v1/escalation_policies/:id"
|
|
def delete(conn, %{"id" => id}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
case ScopedResource.fetch(EscalationPolicy, id, organization_id) do
|
|
{:ok, policy} ->
|
|
case OnCall.delete_escalation_policy(policy) do
|
|
{:ok, _} ->
|
|
json(conn, %{success: true})
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
|
|
{:error, :forbidden} ->
|
|
conn
|
|
|> put_status(:forbidden)
|
|
|> json(%{error: "Access denied to this escalation policy"})
|
|
|
|
{:error, :not_found} ->
|
|
conn
|
|
|> put_status(:not_found)
|
|
|> json(%{error: "Escalation policy not found"})
|
|
end
|
|
end
|
|
|
|
# --- Rules ---
|
|
|
|
@doc "POST /api/v1/escalation_policies/:id/rules"
|
|
def create_rule(conn, %{"id" => policy_id, "rule" => rule_params}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
case ScopedResource.fetch(EscalationPolicy, policy_id, organization_id) do
|
|
{:ok, _policy} ->
|
|
attrs = Map.put(rule_params, "escalation_policy_id", policy_id)
|
|
|
|
case OnCall.create_escalation_rule(attrs) do
|
|
{:ok, rule} ->
|
|
conn
|
|
|> put_status(:created)
|
|
|> json(format_rule(rule))
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
|
|
{:error, :forbidden} ->
|
|
conn
|
|
|> put_status(:forbidden)
|
|
|> json(%{error: "Access denied to this escalation policy"})
|
|
|
|
{:error, :not_found} ->
|
|
conn
|
|
|> put_status(:not_found)
|
|
|> json(%{error: "Escalation policy not found"})
|
|
end
|
|
end
|
|
|
|
@doc "PATCH /api/v1/escalation_policies/:id/rules/:rule_id"
|
|
def update_rule(conn, %{"id" => policy_id, "rule_id" => rule_id, "rule" => rule_params}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
with {:ok, _policy} <- ScopedResource.fetch(EscalationPolicy, policy_id, organization_id),
|
|
{:ok, rule} <- fetch_rule(rule_id, policy_id) do
|
|
case OnCall.update_escalation_rule(rule, rule_params) do
|
|
{:ok, updated} ->
|
|
json(conn, format_rule(updated))
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
else
|
|
{:error, :forbidden} ->
|
|
conn |> put_status(:forbidden) |> json(%{error: "Access denied to this escalation policy"})
|
|
|
|
{:error, :not_found} ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"})
|
|
|
|
{:error, :rule_not_found} ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Rule not found"})
|
|
end
|
|
end
|
|
|
|
@doc "DELETE /api/v1/escalation_policies/:id/rules/:rule_id"
|
|
def delete_rule(conn, %{"id" => policy_id, "rule_id" => rule_id}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
with {:ok, _policy} <- ScopedResource.fetch(EscalationPolicy, policy_id, organization_id),
|
|
{:ok, rule} <- fetch_rule(rule_id, policy_id) do
|
|
{:ok, _} = OnCall.delete_escalation_rule(rule)
|
|
json(conn, %{success: true})
|
|
else
|
|
{:error, :forbidden} ->
|
|
conn |> put_status(:forbidden) |> json(%{error: "Access denied to this escalation policy"})
|
|
|
|
{:error, :not_found} ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"})
|
|
|
|
{:error, :rule_not_found} ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Rule not found"})
|
|
end
|
|
end
|
|
|
|
# --- Targets ---
|
|
|
|
@doc "POST /api/v1/escalation_policies/:id/rules/:rule_id/targets"
|
|
def create_target(conn, %{"id" => policy_id, "rule_id" => rule_id, "target" => target_params}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
with {:ok, _policy} <- ScopedResource.fetch(EscalationPolicy, policy_id, organization_id),
|
|
{:ok, _rule} <- fetch_rule(rule_id, policy_id) do
|
|
attrs = Map.put(target_params, "escalation_rule_id", rule_id)
|
|
|
|
case OnCall.create_escalation_target(attrs) do
|
|
{:ok, target} ->
|
|
conn
|
|
|> put_status(:created)
|
|
|> json(format_target(target))
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
else
|
|
{:error, :forbidden} ->
|
|
conn |> put_status(:forbidden) |> json(%{error: "Access denied to this escalation policy"})
|
|
|
|
{:error, :not_found} ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"})
|
|
|
|
{:error, :rule_not_found} ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Rule not found"})
|
|
end
|
|
end
|
|
|
|
@doc "DELETE /api/v1/escalation_policies/:id/rules/:rule_id/targets/:target_id"
|
|
def delete_target(conn, %{"id" => policy_id, "rule_id" => rule_id, "target_id" => target_id}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
with {:ok, _policy} <- ScopedResource.fetch(EscalationPolicy, policy_id, organization_id),
|
|
{:ok, _rule} <- fetch_rule(rule_id, policy_id),
|
|
{:ok, target} <- fetch_target(target_id, rule_id) do
|
|
case OnCall.delete_escalation_target(target) do
|
|
{:ok, _} ->
|
|
json(conn, %{success: true})
|
|
|
|
{:error, reason} ->
|
|
require Logger
|
|
|
|
Logger.error("Failed to delete escalation target #{target_id}: #{inspect(reason)}")
|
|
conn |> put_status(:internal_server_error) |> json(%{error: "Failed to delete escalation target"})
|
|
end
|
|
else
|
|
{:error, :forbidden} ->
|
|
conn |> put_status(:forbidden) |> json(%{error: "Access denied to this escalation policy"})
|
|
|
|
{:error, :not_found} ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"})
|
|
|
|
{:error, :rule_not_found} ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Rule not found"})
|
|
|
|
{:error, :target_not_found} ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Target not found"})
|
|
end
|
|
end
|
|
|
|
# --- Private helpers ---
|
|
|
|
defp fetch_rule(rule_id, policy_id) do
|
|
case Repo.get(EscalationRule, rule_id) do
|
|
%EscalationRule{escalation_policy_id: ^policy_id} = rule -> {:ok, rule}
|
|
_ -> {:error, :rule_not_found}
|
|
end
|
|
end
|
|
|
|
defp fetch_target(target_id, rule_id) do
|
|
case Repo.get(EscalationTarget, target_id) do
|
|
%EscalationTarget{escalation_rule_id: ^rule_id} = target -> {:ok, target}
|
|
_ -> {:error, :target_not_found}
|
|
end
|
|
end
|
|
|
|
defp format_policy(policy) do
|
|
%{
|
|
id: policy.id,
|
|
name: policy.name,
|
|
description: policy.description,
|
|
repeat_count: policy.repeat_count,
|
|
inserted_at: policy.inserted_at
|
|
}
|
|
end
|
|
|
|
defp format_policy_detail(policy) do
|
|
%{
|
|
id: policy.id,
|
|
name: policy.name,
|
|
description: policy.description,
|
|
repeat_count: policy.repeat_count,
|
|
inserted_at: policy.inserted_at,
|
|
rules: Enum.map(policy.rules, &format_rule_detail/1)
|
|
}
|
|
end
|
|
|
|
defp format_rule(rule) do
|
|
%{
|
|
id: rule.id,
|
|
position: rule.position,
|
|
timeout_minutes: rule.timeout_minutes,
|
|
escalation_policy_id: rule.escalation_policy_id,
|
|
inserted_at: rule.inserted_at
|
|
}
|
|
end
|
|
|
|
defp format_rule_detail(rule) do
|
|
rule
|
|
|> format_rule()
|
|
|> Map.put(:targets, Enum.map(rule.targets, &format_target/1))
|
|
end
|
|
|
|
defp format_target(target) do
|
|
%{
|
|
id: target.id,
|
|
target_type: target.target_type,
|
|
user_id: target.user_id,
|
|
schedule_id: target.schedule_id,
|
|
escalation_rule_id: target.escalation_rule_id,
|
|
inserted_at: target.inserted_at
|
|
}
|
|
end
|
|
end
|