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

467 lines
14 KiB
Elixir

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