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.
467 lines
14 KiB
Elixir
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
|