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:
Graham McIntire 2026-03-11 14:01:15 -05:00
parent 5026614bf3
commit f6e9063577
No known key found for this signature in database
10 changed files with 2205 additions and 0 deletions

View file

@ -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

View 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

View 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

View 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

View file

@ -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

View 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

View 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

View file

@ -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)

View file

@ -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

View file

@ -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