Removes unreachable catch-all clauses, drops unused `require Logger` lines, adds pin operators to bitstring size patterns, reorders function heads for correct dispatch, and replaces deprecated LoggerBackends calls.
432 lines
13 KiB
Elixir
432 lines
13 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
|
|
|
|
require Logger
|
|
|
|
# --- 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, :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, :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, :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, :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, :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, :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, :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, :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, :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, :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
|
|
case OnCall.delete_override(override) do
|
|
{:ok, _} ->
|
|
json(conn, %{success: true})
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Failed to delete override #{override_id}: #{inspect(reason)}")
|
|
conn |> put_status(:internal_server_error) |> json(%{error: "Failed to delete override"})
|
|
end
|
|
else
|
|
{: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
|