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.
This commit is contained in:
parent
5026614bf3
commit
f6e9063577
10 changed files with 2205 additions and 0 deletions
|
|
@ -0,0 +1,340 @@
|
|||
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
|
||||
467
lib/towerops_web/controllers/api/v1/schedules_controller.ex
Normal file
467
lib/towerops_web/controllers/api/v1/schedules_controller.ex
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
defmodule ToweropsWeb.Api.V1.SchedulesController do
|
||||
@moduledoc """
|
||||
API controller for managing on-call schedules.
|
||||
|
||||
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.Layer
|
||||
alias Towerops.OnCall.LayerMember
|
||||
alias Towerops.OnCall.Override
|
||||
alias Towerops.OnCall.Schedule
|
||||
alias Towerops.Repo
|
||||
alias ToweropsWeb.ScopedResource
|
||||
|
||||
# --- Schedule CRUD ---
|
||||
|
||||
@doc "GET /api/v1/schedules"
|
||||
def index(conn, _params) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
schedules =
|
||||
organization_id
|
||||
|> OnCall.list_schedules()
|
||||
|> Enum.map(&format_schedule/1)
|
||||
|
||||
json(conn, %{schedules: schedules})
|
||||
end
|
||||
|
||||
@doc "POST /api/v1/schedules"
|
||||
def create(conn, %{"schedule" => schedule_params}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
attrs = Map.put(schedule_params, "organization_id", organization_id)
|
||||
|
||||
case OnCall.create_schedule(attrs) do
|
||||
{:ok, schedule} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(format_schedule(schedule))
|
||||
|
||||
{: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 'schedule' parameter"})
|
||||
end
|
||||
|
||||
@doc "GET /api/v1/schedules/:id"
|
||||
def show(conn, %{"id" => id}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
case ScopedResource.fetch_preload(Schedule, id, organization_id,
|
||||
overrides: :user,
|
||||
layers: [members: :user]
|
||||
) do
|
||||
{:ok, schedule} ->
|
||||
json(conn, format_schedule_detail(schedule))
|
||||
|
||||
{:error, :forbidden} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Access denied to this schedule"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Schedule not found"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc "PATCH /api/v1/schedules/:id"
|
||||
def update(conn, %{"id" => id, "schedule" => schedule_params}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
case ScopedResource.fetch(Schedule, id, organization_id) do
|
||||
{:ok, schedule} ->
|
||||
case OnCall.update_schedule(schedule, schedule_params) do
|
||||
{:ok, updated} ->
|
||||
json(conn, format_schedule(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 schedule"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Schedule not found"})
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Missing 'schedule' parameter"})
|
||||
end
|
||||
|
||||
@doc "DELETE /api/v1/schedules/:id"
|
||||
def delete(conn, %{"id" => id}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
case ScopedResource.fetch(Schedule, id, organization_id) do
|
||||
{:ok, schedule} ->
|
||||
case OnCall.delete_schedule(schedule) 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 schedule"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Schedule not found"})
|
||||
end
|
||||
end
|
||||
|
||||
# --- On-Call Resolution ---
|
||||
|
||||
@doc "GET /api/v1/schedules/:id/on_call"
|
||||
def on_call(conn, %{"id" => id}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
case ScopedResource.fetch(Schedule, id, organization_id) do
|
||||
{:ok, _schedule} ->
|
||||
user = OnCall.who_is_on_call(id)
|
||||
json(conn, %{on_call: format_user(user)})
|
||||
|
||||
{:error, :forbidden} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Access denied to this schedule"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Schedule not found"})
|
||||
end
|
||||
end
|
||||
|
||||
# --- Layers ---
|
||||
|
||||
@doc "POST /api/v1/schedules/:id/layers"
|
||||
def create_layer(conn, %{"id" => schedule_id, "layer" => layer_params}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
case ScopedResource.fetch(Schedule, schedule_id, organization_id) do
|
||||
{:ok, _schedule} ->
|
||||
attrs = Map.put(layer_params, "schedule_id", schedule_id)
|
||||
|
||||
case OnCall.create_layer(attrs) do
|
||||
{:ok, layer} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(format_layer(layer))
|
||||
|
||||
{: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 schedule"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Schedule not found"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc "PATCH /api/v1/schedules/:id/layers/:layer_id"
|
||||
def update_layer(conn, %{"id" => schedule_id, "layer_id" => layer_id, "layer" => layer_params}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
with {:ok, _schedule} <- ScopedResource.fetch(Schedule, schedule_id, organization_id),
|
||||
{:ok, layer} <- fetch_layer(layer_id, schedule_id) do
|
||||
case OnCall.update_layer(layer, layer_params) do
|
||||
{:ok, updated} ->
|
||||
json(conn, format_layer(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 schedule"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Schedule not found"})
|
||||
|
||||
{:error, :layer_not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Layer not found"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc "DELETE /api/v1/schedules/:id/layers/:layer_id"
|
||||
def delete_layer(conn, %{"id" => schedule_id, "layer_id" => layer_id}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
with {:ok, _schedule} <- ScopedResource.fetch(Schedule, schedule_id, organization_id),
|
||||
{:ok, layer} <- fetch_layer(layer_id, schedule_id) do
|
||||
{:ok, _} = OnCall.delete_layer(layer)
|
||||
json(conn, %{success: true})
|
||||
else
|
||||
{:error, :forbidden} ->
|
||||
conn |> put_status(:forbidden) |> json(%{error: "Access denied to this schedule"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Schedule not found"})
|
||||
|
||||
{:error, :layer_not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Layer not found"})
|
||||
end
|
||||
end
|
||||
|
||||
# --- Members ---
|
||||
|
||||
@doc "POST /api/v1/schedules/:id/layers/:layer_id/members"
|
||||
def create_member(conn, %{"id" => schedule_id, "layer_id" => layer_id, "member" => member_params}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
with {:ok, _schedule} <- ScopedResource.fetch(Schedule, schedule_id, organization_id),
|
||||
{:ok, _layer} <- fetch_layer(layer_id, schedule_id) do
|
||||
attrs = Map.put(member_params, "layer_id", layer_id)
|
||||
|
||||
case OnCall.add_layer_member(attrs) do
|
||||
{:ok, member} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(format_member(member))
|
||||
|
||||
{: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 schedule"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Schedule not found"})
|
||||
|
||||
{:error, :layer_not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Layer not found"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc "DELETE /api/v1/schedules/:id/layers/:layer_id/members/:member_id"
|
||||
def delete_member(conn, %{"id" => schedule_id, "layer_id" => layer_id, "member_id" => member_id}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
with {:ok, _schedule} <- ScopedResource.fetch(Schedule, schedule_id, organization_id),
|
||||
{:ok, _layer} <- fetch_layer(layer_id, schedule_id),
|
||||
{:ok, member} <- fetch_member(member_id, layer_id) do
|
||||
{:ok, _} = OnCall.remove_layer_member(member)
|
||||
json(conn, %{success: true})
|
||||
else
|
||||
{:error, :forbidden} ->
|
||||
conn |> put_status(:forbidden) |> json(%{error: "Access denied to this schedule"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Schedule not found"})
|
||||
|
||||
{:error, :layer_not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Layer not found"})
|
||||
|
||||
{:error, :member_not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Member not found"})
|
||||
end
|
||||
end
|
||||
|
||||
# --- Overrides ---
|
||||
|
||||
@doc "POST /api/v1/schedules/:id/overrides"
|
||||
def create_override(conn, %{"id" => schedule_id, "override" => override_params}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
case ScopedResource.fetch(Schedule, schedule_id, organization_id) do
|
||||
{:ok, _schedule} ->
|
||||
attrs = Map.put(override_params, "schedule_id", schedule_id)
|
||||
|
||||
case OnCall.create_override(attrs) do
|
||||
{:ok, override} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(format_override(override))
|
||||
|
||||
{: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 schedule"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Schedule not found"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc "DELETE /api/v1/schedules/:id/overrides/:override_id"
|
||||
def delete_override(conn, %{"id" => schedule_id, "override_id" => override_id}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
with {:ok, _schedule} <- ScopedResource.fetch(Schedule, schedule_id, organization_id),
|
||||
{:ok, override} <- fetch_override(override_id, schedule_id) do
|
||||
{:ok, _} = OnCall.delete_override(override)
|
||||
json(conn, %{success: true})
|
||||
else
|
||||
{:error, :forbidden} ->
|
||||
conn |> put_status(:forbidden) |> json(%{error: "Access denied to this schedule"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Schedule not found"})
|
||||
|
||||
{:error, :override_not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Override not found"})
|
||||
end
|
||||
end
|
||||
|
||||
# --- Private helpers ---
|
||||
|
||||
defp fetch_layer(layer_id, schedule_id) do
|
||||
case Repo.get(Layer, layer_id) do
|
||||
%Layer{schedule_id: ^schedule_id} = layer -> {:ok, layer}
|
||||
_ -> {:error, :layer_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_member(member_id, layer_id) do
|
||||
case Repo.get(LayerMember, member_id) do
|
||||
%LayerMember{layer_id: ^layer_id} = member -> {:ok, member}
|
||||
_ -> {:error, :member_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_override(override_id, schedule_id) do
|
||||
case Repo.get(Override, override_id) do
|
||||
%Override{schedule_id: ^schedule_id} = override -> {:ok, override}
|
||||
_ -> {:error, :override_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_schedule(schedule) do
|
||||
%{
|
||||
id: schedule.id,
|
||||
name: schedule.name,
|
||||
description: schedule.description,
|
||||
timezone: schedule.timezone,
|
||||
inserted_at: schedule.inserted_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_schedule_detail(schedule) do
|
||||
%{
|
||||
id: schedule.id,
|
||||
name: schedule.name,
|
||||
description: schedule.description,
|
||||
timezone: schedule.timezone,
|
||||
inserted_at: schedule.inserted_at,
|
||||
layers: Enum.map(schedule.layers, &format_layer_detail/1),
|
||||
overrides: Enum.map(schedule.overrides, &format_override_detail/1)
|
||||
}
|
||||
end
|
||||
|
||||
defp format_layer(layer) do
|
||||
%{
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
position: layer.position,
|
||||
rotation_type: layer.rotation_type,
|
||||
rotation_interval: layer.rotation_interval,
|
||||
handoff_time: layer.handoff_time,
|
||||
handoff_day: layer.handoff_day,
|
||||
start_date: layer.start_date,
|
||||
inserted_at: layer.inserted_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_layer_detail(layer) do
|
||||
layer
|
||||
|> format_layer()
|
||||
|> Map.put(:members, Enum.map(layer.members, &format_member_detail/1))
|
||||
end
|
||||
|
||||
defp format_member(member) do
|
||||
%{
|
||||
id: member.id,
|
||||
position: member.position,
|
||||
user_id: member.user_id,
|
||||
layer_id: member.layer_id,
|
||||
inserted_at: member.inserted_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_member_detail(member) do
|
||||
%{
|
||||
id: member.id,
|
||||
position: member.position,
|
||||
user_id: member.user_id,
|
||||
user: format_user(member.user)
|
||||
}
|
||||
end
|
||||
|
||||
defp format_override(override) do
|
||||
%{
|
||||
id: override.id,
|
||||
start_time: override.start_time,
|
||||
end_time: override.end_time,
|
||||
user_id: override.user_id,
|
||||
schedule_id: override.schedule_id,
|
||||
inserted_at: override.inserted_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_override_detail(override) do
|
||||
%{
|
||||
id: override.id,
|
||||
start_time: override.start_time,
|
||||
end_time: override.end_time,
|
||||
user_id: override.user_id,
|
||||
user: format_user(override.user)
|
||||
}
|
||||
end
|
||||
|
||||
defp format_user(nil), do: nil
|
||||
|
||||
defp format_user(user) do
|
||||
%{
|
||||
id: user.id,
|
||||
email: user.email
|
||||
}
|
||||
end
|
||||
end
|
||||
168
lib/towerops_web/graphql/resolvers/escalation_policy.ex
Normal file
168
lib/towerops_web/graphql/resolvers/escalation_policy.ex
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
defmodule ToweropsWeb.GraphQL.Resolvers.EscalationPolicy do
|
||||
@moduledoc "GraphQL resolvers for escalation policy queries and mutations."
|
||||
|
||||
alias Towerops.OnCall
|
||||
alias Towerops.OnCall.EscalationPolicy
|
||||
alias Towerops.OnCall.EscalationRule
|
||||
alias Towerops.OnCall.EscalationTarget
|
||||
alias Towerops.Repo
|
||||
alias ToweropsWeb.GraphQL.Resolvers.Helpers
|
||||
alias ToweropsWeb.ScopedResource
|
||||
|
||||
# --- Queries ---
|
||||
|
||||
def list(_parent, _args, %{context: %{organization_id: org_id}}) do
|
||||
{:ok, OnCall.list_escalation_policies(org_id)}
|
||||
end
|
||||
|
||||
def list(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
|
||||
case ScopedResource.fetch_preload(EscalationPolicy, id, org_id, rules: :targets) do
|
||||
{:ok, policy} -> {:ok, policy}
|
||||
{:error, _} -> {:error, "Escalation policy not found"}
|
||||
end
|
||||
end
|
||||
|
||||
def get(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
# --- Mutations ---
|
||||
|
||||
def create(_parent, %{input: input}, %{context: %{organization_id: org_id}}) do
|
||||
attrs =
|
||||
input
|
||||
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|
||||
|> Map.put("organization_id", org_id)
|
||||
|
||||
case OnCall.create_escalation_policy(attrs) do
|
||||
{:ok, policy} -> {:ok, policy}
|
||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
def create(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def update(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, policy} <- fetch_org_policy(id, org_id) do
|
||||
attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end)
|
||||
|
||||
case OnCall.update_escalation_policy(policy, attrs) do
|
||||
{:ok, updated} -> {:ok, updated}
|
||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, policy} <- fetch_org_policy(id, org_id) do
|
||||
case OnCall.delete_escalation_policy(policy) do
|
||||
{:ok, _} -> {:ok, %{success: true, message: "Escalation policy deleted"}}
|
||||
{:error, _} -> {:ok, %{success: false, message: "Could not delete escalation policy"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
# --- Rule mutations ---
|
||||
|
||||
def create_rule(_parent, %{escalation_policy_id: policy_id, input: input}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, _policy} <- fetch_org_policy(policy_id, org_id) do
|
||||
attrs =
|
||||
input
|
||||
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|
||||
|> Map.put("escalation_policy_id", policy_id)
|
||||
|
||||
case OnCall.create_escalation_rule(attrs) do
|
||||
{:ok, rule} -> {:ok, rule}
|
||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_rule(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def update_rule(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, rule} <- fetch_rule_by_org(id, org_id) do
|
||||
attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end)
|
||||
|
||||
case OnCall.update_escalation_rule(rule, attrs) do
|
||||
{:ok, updated} -> {:ok, updated}
|
||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_rule(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def delete_rule(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, rule} <- fetch_rule_by_org(id, org_id) do
|
||||
case OnCall.delete_escalation_rule(rule) do
|
||||
{:ok, _} -> {:ok, %{success: true, message: "Rule deleted"}}
|
||||
{:error, _} -> {:ok, %{success: false, message: "Could not delete rule"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete_rule(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
# --- Target mutations ---
|
||||
|
||||
def create_target(_parent, %{escalation_rule_id: rule_id, input: input}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, _rule} <- fetch_rule_by_org(rule_id, org_id) do
|
||||
attrs =
|
||||
input
|
||||
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|
||||
|> Map.put("escalation_rule_id", rule_id)
|
||||
|
||||
case OnCall.create_escalation_target(attrs) do
|
||||
{:ok, target} -> {:ok, target}
|
||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_target(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def delete_target(_parent, %{id: id}, %{context: %{organization_id: _org_id}}) do
|
||||
case Repo.get(EscalationTarget, id) do
|
||||
nil ->
|
||||
{:error, "Target not found"}
|
||||
|
||||
target ->
|
||||
case OnCall.delete_escalation_target(target) do
|
||||
{:ok, _} -> {:ok, %{success: true, message: "Target deleted"}}
|
||||
{:error, _} -> {:ok, %{success: false, message: "Could not delete target"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete_target(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
# --- Private helpers ---
|
||||
|
||||
defp fetch_org_policy(id, org_id) do
|
||||
case ScopedResource.fetch(EscalationPolicy, id, org_id) do
|
||||
{:ok, policy} -> {:ok, policy}
|
||||
{:error, _} -> {:error, "Escalation policy not found"}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_rule_by_org(rule_id, org_id) do
|
||||
case Repo.get(EscalationRule, rule_id) do
|
||||
nil ->
|
||||
{:error, "Rule not found"}
|
||||
|
||||
rule ->
|
||||
rule = Repo.preload(rule, :escalation_policy)
|
||||
|
||||
if rule.escalation_policy.organization_id == org_id do
|
||||
{:ok, rule}
|
||||
else
|
||||
{:error, "Rule not found"}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
223
lib/towerops_web/graphql/resolvers/schedule.ex
Normal file
223
lib/towerops_web/graphql/resolvers/schedule.ex
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
defmodule ToweropsWeb.GraphQL.Resolvers.Schedule do
|
||||
@moduledoc "GraphQL resolvers for on-call schedule queries and mutations."
|
||||
|
||||
alias Towerops.OnCall
|
||||
alias Towerops.OnCall.Layer
|
||||
alias Towerops.OnCall.LayerMember
|
||||
alias Towerops.OnCall.Override
|
||||
alias Towerops.OnCall.Schedule
|
||||
alias Towerops.Repo
|
||||
alias ToweropsWeb.GraphQL.Resolvers.Helpers
|
||||
alias ToweropsWeb.ScopedResource
|
||||
|
||||
# --- Queries ---
|
||||
|
||||
def list(_parent, _args, %{context: %{organization_id: org_id}}) do
|
||||
{:ok, OnCall.list_schedules(org_id)}
|
||||
end
|
||||
|
||||
def list(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
|
||||
case ScopedResource.fetch_preload(Schedule, id, org_id,
|
||||
overrides: :user,
|
||||
layers: [members: :user]
|
||||
) do
|
||||
{:ok, schedule} -> {:ok, schedule}
|
||||
{:error, _} -> {:error, "Schedule not found"}
|
||||
end
|
||||
end
|
||||
|
||||
def get(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def on_call(_parent, %{schedule_id: id}, %{context: %{organization_id: org_id}}) do
|
||||
case ScopedResource.fetch(Schedule, id, org_id) do
|
||||
{:ok, _} ->
|
||||
case OnCall.who_is_on_call(id) do
|
||||
nil -> {:ok, nil}
|
||||
user -> {:ok, %{user_id: user.id, email: user.email}}
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
{:error, "Schedule not found"}
|
||||
end
|
||||
end
|
||||
|
||||
def on_call(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
# --- Mutations ---
|
||||
|
||||
def create(_parent, %{input: input}, %{context: %{organization_id: org_id}}) do
|
||||
attrs =
|
||||
input
|
||||
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|
||||
|> Map.put("organization_id", org_id)
|
||||
|
||||
case OnCall.create_schedule(attrs) do
|
||||
{:ok, schedule} -> {:ok, schedule}
|
||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
def create(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def update(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, schedule} <- fetch_org_schedule(id, org_id) do
|
||||
attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end)
|
||||
|
||||
case OnCall.update_schedule(schedule, attrs) do
|
||||
{:ok, updated} -> {:ok, updated}
|
||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, schedule} <- fetch_org_schedule(id, org_id) do
|
||||
case OnCall.delete_schedule(schedule) do
|
||||
{:ok, _} -> {:ok, %{success: true, message: "Schedule deleted"}}
|
||||
{:error, _} -> {:ok, %{success: false, message: "Could not delete schedule"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
# --- Layer mutations ---
|
||||
|
||||
def create_layer(_parent, %{schedule_id: schedule_id, input: input}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, _schedule} <- fetch_org_schedule(schedule_id, org_id) do
|
||||
attrs =
|
||||
input
|
||||
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|
||||
|> Map.put("schedule_id", schedule_id)
|
||||
|
||||
case OnCall.create_layer(attrs) do
|
||||
{:ok, layer} -> {:ok, layer}
|
||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_layer(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def update_layer(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, layer} <- fetch_layer_by_org(id, org_id) do
|
||||
attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end)
|
||||
|
||||
case OnCall.update_layer(layer, attrs) do
|
||||
{:ok, updated} -> {:ok, updated}
|
||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_layer(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def delete_layer(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, layer} <- fetch_layer_by_org(id, org_id) do
|
||||
case OnCall.delete_layer(layer) do
|
||||
{:ok, _} -> {:ok, %{success: true, message: "Layer deleted"}}
|
||||
{:error, _} -> {:ok, %{success: false, message: "Could not delete layer"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete_layer(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
# --- Member mutations ---
|
||||
|
||||
def add_member(_parent, %{layer_id: layer_id, user_id: user_id, position: position}, %{
|
||||
context: %{organization_id: org_id}
|
||||
}) do
|
||||
with {:ok, _layer} <- fetch_layer_by_org(layer_id, org_id) do
|
||||
attrs = %{
|
||||
"layer_id" => layer_id,
|
||||
"user_id" => user_id,
|
||||
"position" => position
|
||||
}
|
||||
|
||||
case OnCall.add_layer_member(attrs) do
|
||||
{:ok, member} -> {:ok, member}
|
||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def add_member(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def remove_member(_parent, %{id: id}, %{context: %{organization_id: _org_id}}) do
|
||||
case Repo.get(LayerMember, id) do
|
||||
nil ->
|
||||
{:error, "Member not found"}
|
||||
|
||||
member ->
|
||||
case OnCall.remove_layer_member(member) do
|
||||
{:ok, _} -> {:ok, %{success: true, message: "Member removed"}}
|
||||
{:error, _} -> {:ok, %{success: false, message: "Could not remove member"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def remove_member(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
# --- Override mutations ---
|
||||
|
||||
def create_override(_parent, %{schedule_id: schedule_id, input: input}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, _schedule} <- fetch_org_schedule(schedule_id, org_id) do
|
||||
attrs =
|
||||
input
|
||||
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|
||||
|> Map.put("schedule_id", schedule_id)
|
||||
|
||||
case OnCall.create_override(attrs) do
|
||||
{:ok, override} -> {:ok, override}
|
||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_override(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def delete_override(_parent, %{id: id}, %{context: %{organization_id: _org_id}}) do
|
||||
case Repo.get(Override, id) do
|
||||
nil ->
|
||||
{:error, "Override not found"}
|
||||
|
||||
override ->
|
||||
case OnCall.delete_override(override) do
|
||||
{:ok, _} -> {:ok, %{success: true, message: "Override deleted"}}
|
||||
{:error, _} -> {:ok, %{success: false, message: "Could not delete override"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete_override(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
# --- Private helpers ---
|
||||
|
||||
defp fetch_org_schedule(id, org_id) do
|
||||
case ScopedResource.fetch(Schedule, id, org_id) do
|
||||
{:ok, schedule} -> {:ok, schedule}
|
||||
{:error, _} -> {:error, "Schedule not found"}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_layer_by_org(layer_id, org_id) do
|
||||
case Repo.get(Layer, layer_id) do
|
||||
nil ->
|
||||
{:error, "Layer not found"}
|
||||
|
||||
layer ->
|
||||
layer = Repo.preload(layer, :schedule)
|
||||
|
||||
if layer.schedule.organization_id == org_id do
|
||||
{:ok, layer}
|
||||
else
|
||||
{:error, "Layer not found"}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -16,6 +16,8 @@ defmodule ToweropsWeb.GraphQL.Schema do
|
|||
import_types(ToweropsWeb.GraphQL.Types.Member)
|
||||
import_types(ToweropsWeb.GraphQL.Types.Integration)
|
||||
import_types(ToweropsWeb.GraphQL.Types.Activity)
|
||||
import_types(ToweropsWeb.GraphQL.Types.Schedule)
|
||||
import_types(ToweropsWeb.GraphQL.Types.EscalationPolicy)
|
||||
|
||||
query do
|
||||
# Devices
|
||||
|
|
@ -99,6 +101,31 @@ defmodule ToweropsWeb.GraphQL.Schema do
|
|||
arg(:device_id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Device.interfaces/3)
|
||||
end
|
||||
|
||||
# Schedules
|
||||
field :schedules, list_of(:schedule) do
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.list/3)
|
||||
end
|
||||
|
||||
field :schedule, :schedule do
|
||||
arg(:id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.get/3)
|
||||
end
|
||||
|
||||
field :on_call, :on_call_result do
|
||||
arg(:schedule_id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.on_call/3)
|
||||
end
|
||||
|
||||
# Escalation Policies
|
||||
field :escalation_policies, list_of(:escalation_policy) do
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.list/3)
|
||||
end
|
||||
|
||||
field :escalation_policy, :escalation_policy do
|
||||
arg(:id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.get/3)
|
||||
end
|
||||
end
|
||||
|
||||
mutation do
|
||||
|
|
@ -208,5 +235,112 @@ defmodule ToweropsWeb.GraphQL.Schema do
|
|||
arg(:id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Integration.test_connection/3)
|
||||
end
|
||||
|
||||
# Schedule CRUD
|
||||
field :create_schedule, :schedule do
|
||||
arg(:input, non_null(:schedule_input))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.create/3)
|
||||
end
|
||||
|
||||
field :update_schedule, :schedule do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:input, non_null(:schedule_input))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.update/3)
|
||||
end
|
||||
|
||||
field :delete_schedule, :delete_result do
|
||||
arg(:id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.delete/3)
|
||||
end
|
||||
|
||||
# Layer CRUD
|
||||
field :create_layer, :layer do
|
||||
arg(:schedule_id, non_null(:id))
|
||||
arg(:input, non_null(:layer_input))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.create_layer/3)
|
||||
end
|
||||
|
||||
field :update_layer, :layer do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:input, non_null(:layer_input))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.update_layer/3)
|
||||
end
|
||||
|
||||
field :delete_layer, :delete_result do
|
||||
arg(:id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.delete_layer/3)
|
||||
end
|
||||
|
||||
# Layer member management
|
||||
field :add_layer_member, :layer_member do
|
||||
arg(:layer_id, non_null(:id))
|
||||
arg(:user_id, non_null(:id))
|
||||
arg(:position, non_null(:integer))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.add_member/3)
|
||||
end
|
||||
|
||||
field :remove_layer_member, :delete_result do
|
||||
arg(:id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.remove_member/3)
|
||||
end
|
||||
|
||||
# Override management
|
||||
field :create_override, :override do
|
||||
arg(:schedule_id, non_null(:id))
|
||||
arg(:input, non_null(:override_input))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.create_override/3)
|
||||
end
|
||||
|
||||
field :delete_override, :delete_result do
|
||||
arg(:id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.delete_override/3)
|
||||
end
|
||||
|
||||
# Escalation Policy CRUD
|
||||
field :create_escalation_policy, :escalation_policy do
|
||||
arg(:input, non_null(:escalation_policy_input))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.create/3)
|
||||
end
|
||||
|
||||
field :update_escalation_policy, :escalation_policy do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:input, non_null(:escalation_policy_input))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.update/3)
|
||||
end
|
||||
|
||||
field :delete_escalation_policy, :delete_result do
|
||||
arg(:id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.delete/3)
|
||||
end
|
||||
|
||||
# Escalation Rule CRUD
|
||||
field :create_escalation_rule, :escalation_rule do
|
||||
arg(:escalation_policy_id, non_null(:id))
|
||||
arg(:input, non_null(:escalation_rule_input))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.create_rule/3)
|
||||
end
|
||||
|
||||
field :update_escalation_rule, :escalation_rule do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:input, non_null(:escalation_rule_input))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.update_rule/3)
|
||||
end
|
||||
|
||||
field :delete_escalation_rule, :delete_result do
|
||||
arg(:id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.delete_rule/3)
|
||||
end
|
||||
|
||||
# Escalation Target management
|
||||
field :create_escalation_target, :escalation_target do
|
||||
arg(:escalation_rule_id, non_null(:id))
|
||||
arg(:input, non_null(:escalation_target_input))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.create_target/3)
|
||||
end
|
||||
|
||||
field :delete_escalation_target, :delete_result do
|
||||
arg(:id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.delete_target/3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
52
lib/towerops_web/graphql/types/escalation_policy.ex
Normal file
52
lib/towerops_web/graphql/types/escalation_policy.ex
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
defmodule ToweropsWeb.GraphQL.Types.EscalationPolicy do
|
||||
@moduledoc "GraphQL types for escalation policies."
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
object :escalation_policy do
|
||||
field :id, :id
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :repeat_count, :integer
|
||||
field :organization_id, :id
|
||||
field :inserted_at, :string
|
||||
field :updated_at, :string
|
||||
|
||||
field :rules, list_of(:escalation_rule)
|
||||
end
|
||||
|
||||
object :escalation_rule do
|
||||
field :id, :id
|
||||
field :position, :integer
|
||||
field :timeout_minutes, :integer
|
||||
field :escalation_policy_id, :id
|
||||
field :inserted_at, :string
|
||||
|
||||
field :targets, list_of(:escalation_target)
|
||||
end
|
||||
|
||||
object :escalation_target do
|
||||
field :id, :id
|
||||
field :target_type, :string
|
||||
field :user_id, :id
|
||||
field :schedule_id, :id
|
||||
field :escalation_rule_id, :id
|
||||
field :inserted_at, :string
|
||||
end
|
||||
|
||||
input_object :escalation_policy_input do
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :repeat_count, :integer
|
||||
end
|
||||
|
||||
input_object :escalation_rule_input do
|
||||
field :position, :integer
|
||||
field :timeout_minutes, :integer
|
||||
end
|
||||
|
||||
input_object :escalation_target_input do
|
||||
field :target_type, :string
|
||||
field :user_id, :id
|
||||
field :schedule_id, :id
|
||||
end
|
||||
end
|
||||
78
lib/towerops_web/graphql/types/schedule.ex
Normal file
78
lib/towerops_web/graphql/types/schedule.ex
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
defmodule ToweropsWeb.GraphQL.Types.Schedule do
|
||||
@moduledoc "GraphQL types for on-call schedules."
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
object :schedule do
|
||||
field :id, :id
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :timezone, :string
|
||||
field :organization_id, :id
|
||||
field :inserted_at, :string
|
||||
field :updated_at, :string
|
||||
|
||||
field :layers, list_of(:layer)
|
||||
field :overrides, list_of(:override)
|
||||
end
|
||||
|
||||
object :layer do
|
||||
field :id, :id
|
||||
field :name, :string
|
||||
field :position, :integer
|
||||
field :rotation_type, :string
|
||||
field :rotation_interval, :integer
|
||||
field :handoff_time, :string
|
||||
field :handoff_day, :integer
|
||||
field :start_date, :string
|
||||
field :restriction_type, :string
|
||||
field :inserted_at, :string
|
||||
|
||||
field :members, list_of(:layer_member)
|
||||
end
|
||||
|
||||
object :layer_member do
|
||||
field :id, :id
|
||||
field :position, :integer
|
||||
field :user_id, :id
|
||||
field :layer_id, :id
|
||||
field :inserted_at, :string
|
||||
end
|
||||
|
||||
object :override do
|
||||
field :id, :id
|
||||
field :start_time, :string
|
||||
field :end_time, :string
|
||||
field :user_id, :id
|
||||
field :schedule_id, :id
|
||||
field :inserted_at, :string
|
||||
end
|
||||
|
||||
object :on_call_result do
|
||||
field :user_id, :id
|
||||
field :email, :string
|
||||
end
|
||||
|
||||
input_object :schedule_input do
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :timezone, :string
|
||||
end
|
||||
|
||||
input_object :layer_input do
|
||||
field :name, :string
|
||||
field :position, :integer
|
||||
field :rotation_type, :string
|
||||
field :rotation_interval, :integer
|
||||
field :handoff_time, :string
|
||||
field :handoff_day, :integer
|
||||
field :start_date, :string
|
||||
field :restriction_type, :string
|
||||
field :restrictions, :json
|
||||
end
|
||||
|
||||
input_object :override_input do
|
||||
field :user_id, :id
|
||||
field :start_time, :string
|
||||
field :end_time, :string
|
||||
end
|
||||
end
|
||||
|
|
@ -169,6 +169,25 @@ defmodule ToweropsWeb.Router do
|
|||
get "/devices/:device_id/interfaces", CheckResultsController, :interfaces
|
||||
|
||||
get "/activity", ActivityController, :index
|
||||
|
||||
# On-call schedules
|
||||
resources "/schedules", SchedulesController, except: [:new, :edit]
|
||||
get "/schedules/:id/on_call", SchedulesController, :on_call
|
||||
post "/schedules/:id/layers", SchedulesController, :create_layer
|
||||
patch "/schedules/:id/layers/:layer_id", SchedulesController, :update_layer
|
||||
delete "/schedules/:id/layers/:layer_id", SchedulesController, :delete_layer
|
||||
post "/schedules/:id/layers/:layer_id/members", SchedulesController, :create_member
|
||||
delete "/schedules/:id/layers/:layer_id/members/:member_id", SchedulesController, :delete_member
|
||||
post "/schedules/:id/overrides", SchedulesController, :create_override
|
||||
delete "/schedules/:id/overrides/:override_id", SchedulesController, :delete_override
|
||||
|
||||
# Escalation policies
|
||||
resources "/escalation_policies", EscalationPoliciesController, except: [:new, :edit]
|
||||
post "/escalation_policies/:id/rules", EscalationPoliciesController, :create_rule
|
||||
patch "/escalation_policies/:id/rules/:rule_id", EscalationPoliciesController, :update_rule
|
||||
delete "/escalation_policies/:id/rules/:rule_id", EscalationPoliciesController, :delete_rule
|
||||
post "/escalation_policies/:id/rules/:rule_id/targets", EscalationPoliciesController, :create_target
|
||||
delete "/escalation_policies/:id/rules/:rule_id/targets/:target_id", EscalationPoliciesController, :delete_target
|
||||
end
|
||||
|
||||
# Webhook routes (shared secret authentication)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,326 @@
|
|||
defmodule ToweropsWeb.Api.V1.EscalationPoliciesControllerTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OnCallFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.ApiTokens
|
||||
alias Towerops.OnCall
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
{:ok, {_token, raw_token}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Test Token"
|
||||
})
|
||||
|
||||
conn =
|
||||
build_conn()
|
||||
|> put_req_header("authorization", "Bearer #{raw_token}")
|
||||
|> put_req_header("accept", "application/json")
|
||||
|
||||
%{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: organization
|
||||
}
|
||||
end
|
||||
|
||||
describe "index/2" do
|
||||
test "lists all escalation policies for authenticated organization", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
policy1 = escalation_policy_fixture(organization.id, %{name: "Critical"})
|
||||
policy2 = escalation_policy_fixture(organization.id, %{name: "Warning"})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/escalation_policies")
|
||||
|
||||
assert %{"escalation_policies" => policies} = json_response(conn, 200)
|
||||
assert length(policies) == 2
|
||||
|
||||
ids = Enum.map(policies, & &1["id"])
|
||||
assert policy1.id in ids
|
||||
assert policy2.id in ids
|
||||
end
|
||||
|
||||
test "returns empty list when organization has no policies", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/v1/escalation_policies")
|
||||
|
||||
assert %{"escalation_policies" => []} = json_response(conn, 200)
|
||||
end
|
||||
|
||||
test "does not return policies from other organizations", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
_other_policy = escalation_policy_fixture(other_org.id)
|
||||
|
||||
conn = get(conn, ~p"/api/v1/escalation_policies")
|
||||
|
||||
assert %{"escalation_policies" => []} = json_response(conn, 200)
|
||||
end
|
||||
end
|
||||
|
||||
describe "create/2" do
|
||||
test "creates policy with valid attributes", %{conn: conn, organization: organization} do
|
||||
params = %{
|
||||
"escalation_policy" => %{
|
||||
"name" => "Critical Alerts",
|
||||
"description" => "For P1 incidents",
|
||||
"repeat_count" => 5
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/escalation_policies", params)
|
||||
|
||||
assert %{
|
||||
"id" => id,
|
||||
"name" => "Critical Alerts",
|
||||
"description" => "For P1 incidents",
|
||||
"repeat_count" => 5
|
||||
} = json_response(conn, 201)
|
||||
|
||||
assert is_binary(id)
|
||||
policy = OnCall.get_escalation_policy!(id)
|
||||
assert policy.organization_id == organization.id
|
||||
end
|
||||
|
||||
test "returns 422 with invalid params", %{conn: conn} do
|
||||
params = %{"escalation_policy" => %{"name" => ""}}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/escalation_policies", params)
|
||||
|
||||
assert %{"errors" => _} = json_response(conn, 422)
|
||||
end
|
||||
|
||||
test "returns 400 when escalation_policy parameter is missing", %{conn: conn} do
|
||||
conn = post(conn, ~p"/api/v1/escalation_policies", %{})
|
||||
|
||||
assert %{"error" => "Missing 'escalation_policy' parameter"} = json_response(conn, 400)
|
||||
end
|
||||
end
|
||||
|
||||
describe "show/2" do
|
||||
test "returns policy with rules and targets", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: organization
|
||||
} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
_target = escalation_target_fixture(rule.id, %{target_type: "user", user_id: user.id})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/escalation_policies/#{policy.id}")
|
||||
|
||||
assert %{
|
||||
"id" => _,
|
||||
"name" => _,
|
||||
"rules" => [rule_json]
|
||||
} = json_response(conn, 200)
|
||||
|
||||
assert %{"targets" => [_target_json]} = rule_json
|
||||
end
|
||||
|
||||
test "returns 404 when policy does not exist", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/v1/escalation_policies/#{Ecto.UUID.generate()}")
|
||||
|
||||
assert %{"error" => "Escalation policy not found"} = json_response(conn, 404)
|
||||
end
|
||||
|
||||
test "returns 403 when policy belongs to different organization", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
other_policy = escalation_policy_fixture(other_org.id)
|
||||
|
||||
conn = get(conn, ~p"/api/v1/escalation_policies/#{other_policy.id}")
|
||||
|
||||
assert %{"error" => "Access denied to this escalation policy"} = json_response(conn, 403)
|
||||
end
|
||||
end
|
||||
|
||||
describe "update/2" do
|
||||
test "updates policy with valid attributes", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id, %{name: "Original"})
|
||||
|
||||
params = %{"escalation_policy" => %{"name" => "Updated"}}
|
||||
conn = patch(conn, ~p"/api/v1/escalation_policies/#{policy.id}", params)
|
||||
|
||||
assert %{"id" => _, "name" => "Updated"} = json_response(conn, 200)
|
||||
end
|
||||
|
||||
test "returns 404 when policy does not exist", %{conn: conn} do
|
||||
params = %{"escalation_policy" => %{"name" => "Updated"}}
|
||||
conn = patch(conn, ~p"/api/v1/escalation_policies/#{Ecto.UUID.generate()}", params)
|
||||
|
||||
assert %{"error" => "Escalation policy not found"} = json_response(conn, 404)
|
||||
end
|
||||
|
||||
test "returns 403 when policy belongs to different organization", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
other_policy = escalation_policy_fixture(other_org.id)
|
||||
|
||||
params = %{"escalation_policy" => %{"name" => "Hacked"}}
|
||||
conn = patch(conn, ~p"/api/v1/escalation_policies/#{other_policy.id}", params)
|
||||
|
||||
assert %{"error" => "Access denied to this escalation policy"} = json_response(conn, 403)
|
||||
end
|
||||
|
||||
test "returns 400 when escalation_policy parameter is missing", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
|
||||
conn = patch(conn, ~p"/api/v1/escalation_policies/#{policy.id}", %{})
|
||||
|
||||
assert %{"error" => "Missing 'escalation_policy' parameter"} = json_response(conn, 400)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete/2" do
|
||||
test "deletes policy successfully", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
|
||||
conn = delete(conn, ~p"/api/v1/escalation_policies/#{policy.id}")
|
||||
|
||||
assert %{"success" => true} = json_response(conn, 200)
|
||||
assert OnCall.get_escalation_policy(policy.id) == nil
|
||||
end
|
||||
|
||||
test "returns 404 when policy does not exist", %{conn: conn} do
|
||||
conn = delete(conn, ~p"/api/v1/escalation_policies/#{Ecto.UUID.generate()}")
|
||||
|
||||
assert %{"error" => "Escalation policy not found"} = json_response(conn, 404)
|
||||
end
|
||||
|
||||
test "returns 403 when policy belongs to different organization", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
other_policy = escalation_policy_fixture(other_org.id)
|
||||
|
||||
conn = delete(conn, ~p"/api/v1/escalation_policies/#{other_policy.id}")
|
||||
|
||||
assert %{"error" => "Access denied to this escalation policy"} = json_response(conn, 403)
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_rule/2" do
|
||||
test "creates rule on policy", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
|
||||
params = %{
|
||||
"rule" => %{
|
||||
"position" => 0,
|
||||
"timeout_minutes" => 15
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/escalation_policies/#{policy.id}/rules", params)
|
||||
|
||||
assert %{"id" => _, "position" => 0, "timeout_minutes" => 15} = json_response(conn, 201)
|
||||
end
|
||||
|
||||
test "returns 404 when policy does not exist", %{conn: conn} do
|
||||
params = %{"rule" => %{"position" => 0, "timeout_minutes" => 15}}
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/api/v1/escalation_policies/#{Ecto.UUID.generate()}/rules", params)
|
||||
|
||||
assert %{"error" => "Escalation policy not found"} = json_response(conn, 404)
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_rule/2" do
|
||||
test "updates rule", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
|
||||
params = %{"rule" => %{"timeout_minutes" => 60}}
|
||||
conn = patch(conn, ~p"/api/v1/escalation_policies/#{policy.id}/rules/#{rule.id}", params)
|
||||
|
||||
assert %{"timeout_minutes" => 60} = json_response(conn, 200)
|
||||
end
|
||||
|
||||
test "returns 404 when rule does not exist", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
|
||||
params = %{"rule" => %{"timeout_minutes" => 60}}
|
||||
|
||||
conn =
|
||||
patch(
|
||||
conn,
|
||||
~p"/api/v1/escalation_policies/#{policy.id}/rules/#{Ecto.UUID.generate()}",
|
||||
params
|
||||
)
|
||||
|
||||
assert %{"error" => "Rule not found"} = json_response(conn, 404)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_rule/2" do
|
||||
test "deletes rule", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
|
||||
conn = delete(conn, ~p"/api/v1/escalation_policies/#{policy.id}/rules/#{rule.id}")
|
||||
|
||||
assert %{"success" => true} = json_response(conn, 200)
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_target/2" do
|
||||
test "adds target to rule", %{conn: conn, user: user, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
|
||||
params = %{
|
||||
"target" => %{
|
||||
"target_type" => "user",
|
||||
"user_id" => user.id
|
||||
}
|
||||
}
|
||||
|
||||
conn =
|
||||
post(
|
||||
conn,
|
||||
~p"/api/v1/escalation_policies/#{policy.id}/rules/#{rule.id}/targets",
|
||||
params
|
||||
)
|
||||
|
||||
assert %{"id" => _, "target_type" => "user"} = json_response(conn, 201)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_target/2" do
|
||||
test "removes target from rule", %{conn: conn, user: user, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
target = escalation_target_fixture(rule.id, %{target_type: "user", user_id: user.id})
|
||||
|
||||
conn =
|
||||
delete(
|
||||
conn,
|
||||
~p"/api/v1/escalation_policies/#{policy.id}/rules/#{rule.id}/targets/#{target.id}"
|
||||
)
|
||||
|
||||
assert %{"success" => true} = json_response(conn, 200)
|
||||
end
|
||||
end
|
||||
|
||||
describe "authentication" do
|
||||
test "returns 401 without authorization header" do
|
||||
conn =
|
||||
build_conn()
|
||||
|> put_req_header("accept", "application/json")
|
||||
|> get(~p"/api/v1/escalation_policies")
|
||||
|
||||
assert %{"error" => _message} = json_response(conn, 401)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
defmodule ToweropsWeb.Api.V1.SchedulesControllerTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OnCallFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.ApiTokens
|
||||
alias Towerops.OnCall
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
{:ok, {_token, raw_token}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Test Token"
|
||||
})
|
||||
|
||||
conn =
|
||||
build_conn()
|
||||
|> put_req_header("authorization", "Bearer #{raw_token}")
|
||||
|> put_req_header("accept", "application/json")
|
||||
|
||||
%{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: organization
|
||||
}
|
||||
end
|
||||
|
||||
describe "index/2" do
|
||||
test "lists all schedules for authenticated organization", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule1 = schedule_fixture(organization.id, %{name: "Primary"})
|
||||
schedule2 = schedule_fixture(organization.id, %{name: "Secondary"})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/schedules")
|
||||
|
||||
assert %{"schedules" => schedules} = json_response(conn, 200)
|
||||
assert length(schedules) == 2
|
||||
|
||||
ids = Enum.map(schedules, & &1["id"])
|
||||
assert schedule1.id in ids
|
||||
assert schedule2.id in ids
|
||||
end
|
||||
|
||||
test "returns empty list when organization has no schedules", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/v1/schedules")
|
||||
|
||||
assert %{"schedules" => []} = json_response(conn, 200)
|
||||
end
|
||||
|
||||
test "does not return schedules from other organizations", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
_other_schedule = schedule_fixture(other_org.id)
|
||||
|
||||
conn = get(conn, ~p"/api/v1/schedules")
|
||||
|
||||
assert %{"schedules" => []} = json_response(conn, 200)
|
||||
end
|
||||
end
|
||||
|
||||
describe "create/2" do
|
||||
test "creates schedule with valid attributes", %{conn: conn, organization: organization} do
|
||||
params = %{
|
||||
"schedule" => %{
|
||||
"name" => "On-Call Rotation",
|
||||
"timezone" => "America/New_York",
|
||||
"description" => "Primary rotation"
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/schedules", params)
|
||||
|
||||
assert %{
|
||||
"id" => id,
|
||||
"name" => "On-Call Rotation",
|
||||
"timezone" => "America/New_York",
|
||||
"description" => "Primary rotation"
|
||||
} = json_response(conn, 201)
|
||||
|
||||
assert is_binary(id)
|
||||
schedule = OnCall.get_schedule!(id)
|
||||
assert schedule.organization_id == organization.id
|
||||
end
|
||||
|
||||
test "returns 422 with invalid params", %{conn: conn} do
|
||||
params = %{"schedule" => %{"name" => ""}}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/schedules", params)
|
||||
|
||||
assert %{"errors" => errors} = json_response(conn, 422)
|
||||
assert Map.has_key?(errors, "name") or Map.has_key?(errors, "timezone")
|
||||
end
|
||||
|
||||
test "returns 400 when schedule parameter is missing", %{conn: conn} do
|
||||
conn = post(conn, ~p"/api/v1/schedules", %{})
|
||||
|
||||
assert %{"error" => "Missing 'schedule' parameter"} = json_response(conn, 400)
|
||||
end
|
||||
end
|
||||
|
||||
describe "show/2" do
|
||||
test "returns schedule with layers, members, and overrides", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
_member = layer_member_fixture(layer.id, user.id)
|
||||
_override = override_fixture(schedule.id, user.id)
|
||||
|
||||
conn = get(conn, ~p"/api/v1/schedules/#{schedule.id}")
|
||||
|
||||
assert %{
|
||||
"id" => _,
|
||||
"name" => _,
|
||||
"timezone" => _,
|
||||
"layers" => [layer_json],
|
||||
"overrides" => [override_json]
|
||||
} = json_response(conn, 200)
|
||||
|
||||
assert %{"members" => [_member_json]} = layer_json
|
||||
assert Map.has_key?(override_json, "start_time")
|
||||
end
|
||||
|
||||
test "returns 404 when schedule does not exist", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}")
|
||||
|
||||
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
|
||||
end
|
||||
|
||||
test "returns 403 when schedule belongs to different organization", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
other_schedule = schedule_fixture(other_org.id)
|
||||
|
||||
conn = get(conn, ~p"/api/v1/schedules/#{other_schedule.id}")
|
||||
|
||||
assert %{"error" => "Access denied to this schedule"} = json_response(conn, 403)
|
||||
end
|
||||
end
|
||||
|
||||
describe "update/2" do
|
||||
test "updates schedule with valid attributes", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id, %{name: "Original"})
|
||||
|
||||
params = %{"schedule" => %{"name" => "Updated"}}
|
||||
conn = patch(conn, ~p"/api/v1/schedules/#{schedule.id}", params)
|
||||
|
||||
assert %{"id" => _, "name" => "Updated"} = json_response(conn, 200)
|
||||
end
|
||||
|
||||
test "returns 404 when schedule does not exist", %{conn: conn} do
|
||||
params = %{"schedule" => %{"name" => "Updated"}}
|
||||
conn = patch(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}", params)
|
||||
|
||||
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
|
||||
end
|
||||
|
||||
test "returns 403 when schedule belongs to different organization", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
other_schedule = schedule_fixture(other_org.id)
|
||||
|
||||
params = %{"schedule" => %{"name" => "Hacked"}}
|
||||
conn = patch(conn, ~p"/api/v1/schedules/#{other_schedule.id}", params)
|
||||
|
||||
assert %{"error" => "Access denied to this schedule"} = json_response(conn, 403)
|
||||
end
|
||||
|
||||
test "returns 400 when schedule parameter is missing", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
conn = patch(conn, ~p"/api/v1/schedules/#{schedule.id}", %{})
|
||||
|
||||
assert %{"error" => "Missing 'schedule' parameter"} = json_response(conn, 400)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete/2" do
|
||||
test "deletes schedule successfully", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
conn = delete(conn, ~p"/api/v1/schedules/#{schedule.id}")
|
||||
|
||||
assert %{"success" => true} = json_response(conn, 200)
|
||||
assert OnCall.get_schedule(schedule.id) == nil
|
||||
end
|
||||
|
||||
test "returns 404 when schedule does not exist", %{conn: conn} do
|
||||
conn = delete(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}")
|
||||
|
||||
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
|
||||
end
|
||||
|
||||
test "returns 403 when schedule belongs to different organization", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
other_schedule = schedule_fixture(other_org.id)
|
||||
|
||||
conn = delete(conn, ~p"/api/v1/schedules/#{other_schedule.id}")
|
||||
|
||||
assert %{"error" => "Access denied to this schedule"} = json_response(conn, 403)
|
||||
end
|
||||
end
|
||||
|
||||
describe "on_call/2" do
|
||||
test "returns on-call user for schedule", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
layer = layer_fixture(schedule.id, %{start_date: ~U[2025-01-01 09:00:00Z]})
|
||||
_member = layer_member_fixture(layer.id, user.id)
|
||||
|
||||
conn = get(conn, ~p"/api/v1/schedules/#{schedule.id}/on_call")
|
||||
|
||||
assert %{"on_call" => on_call} = json_response(conn, 200)
|
||||
assert on_call["id"] == user.id
|
||||
end
|
||||
|
||||
test "returns null when no one is on call", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
conn = get(conn, ~p"/api/v1/schedules/#{schedule.id}/on_call")
|
||||
|
||||
assert %{"on_call" => nil} = json_response(conn, 200)
|
||||
end
|
||||
|
||||
test "returns 404 when schedule does not exist", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}/on_call")
|
||||
|
||||
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_layer/2" do
|
||||
test "creates layer on schedule", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
params = %{
|
||||
"layer" => %{
|
||||
"name" => "Primary",
|
||||
"position" => 1,
|
||||
"rotation_type" => "weekly",
|
||||
"rotation_interval" => 1,
|
||||
"handoff_time" => "09:00:00",
|
||||
"start_date" => "2026-01-01T09:00:00Z"
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/schedules/#{schedule.id}/layers", params)
|
||||
|
||||
assert %{"id" => _, "name" => "Primary", "rotation_type" => "weekly"} =
|
||||
json_response(conn, 201)
|
||||
end
|
||||
|
||||
test "returns 404 when schedule does not exist", %{conn: conn} do
|
||||
params = %{
|
||||
"layer" => %{
|
||||
"name" => "Primary",
|
||||
"position" => 1,
|
||||
"rotation_type" => "weekly",
|
||||
"rotation_interval" => 1,
|
||||
"handoff_time" => "09:00:00",
|
||||
"start_date" => "2026-01-01T09:00:00Z"
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}/layers", params)
|
||||
|
||||
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_layer/2" do
|
||||
test "updates layer", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
|
||||
params = %{"layer" => %{"name" => "Updated Layer"}}
|
||||
conn = patch(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}", params)
|
||||
|
||||
assert %{"name" => "Updated Layer"} = json_response(conn, 200)
|
||||
end
|
||||
|
||||
test "returns 404 when layer does not exist", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
params = %{"layer" => %{"name" => "Updated"}}
|
||||
|
||||
conn =
|
||||
patch(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{Ecto.UUID.generate()}", params)
|
||||
|
||||
assert %{"error" => "Layer not found"} = json_response(conn, 404)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_layer/2" do
|
||||
test "deletes layer", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
|
||||
conn = delete(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}")
|
||||
|
||||
assert %{"success" => true} = json_response(conn, 200)
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_member/2" do
|
||||
test "adds member to layer", %{conn: conn, user: user, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
|
||||
params = %{
|
||||
"member" => %{
|
||||
"user_id" => user.id,
|
||||
"position" => 0
|
||||
}
|
||||
}
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}/members", params)
|
||||
|
||||
assert %{"id" => _, "user_id" => uid, "position" => 0} = json_response(conn, 201)
|
||||
assert uid == user.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_member/2" do
|
||||
test "removes member from layer", %{conn: conn, user: user, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
member = layer_member_fixture(layer.id, user.id)
|
||||
|
||||
conn =
|
||||
delete(
|
||||
conn,
|
||||
~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}/members/#{member.id}"
|
||||
)
|
||||
|
||||
assert %{"success" => true} = json_response(conn, 200)
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_override/2" do
|
||||
test "creates override on schedule", %{conn: conn, user: user, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
params = %{
|
||||
"override" => %{
|
||||
"user_id" => user.id,
|
||||
"start_time" => "2026-03-15T09:00:00Z",
|
||||
"end_time" => "2026-03-16T09:00:00Z"
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/schedules/#{schedule.id}/overrides", params)
|
||||
|
||||
assert %{"id" => _, "start_time" => _, "end_time" => _} = json_response(conn, 201)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_override/2" do
|
||||
test "deletes override", %{conn: conn, user: user, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
override = override_fixture(schedule.id, user.id)
|
||||
|
||||
conn =
|
||||
delete(conn, ~p"/api/v1/schedules/#{schedule.id}/overrides/#{override.id}")
|
||||
|
||||
assert %{"success" => true} = json_response(conn, 200)
|
||||
end
|
||||
end
|
||||
|
||||
describe "authentication" do
|
||||
test "returns 401 without authorization header" do
|
||||
conn =
|
||||
build_conn()
|
||||
|> put_req_header("accept", "application/json")
|
||||
|> get(~p"/api/v1/schedules")
|
||||
|
||||
assert %{"error" => _message} = json_response(conn, 401)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue