towerops/lib/towerops_web/controllers/api/v1/escalation_policies_controller.ex
Graham McIntire f6e9063577
feat: add REST API and GraphQL for on-call schedules and escalation policies
REST API v1 endpoints for schedules (14 endpoints) and escalation
policies (10 endpoints) with full CRUD for nested resources (layers,
members, overrides, rules, targets). GraphQL types, resolvers, and
schema mutations for both resource trees.
2026-03-11 14:01:15 -05:00

340 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
{:ok, _} = OnCall.delete_escalation_target(target)
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"})
{: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