- Client IP: only trust X-Forwarded-For from RFC 1918 proxy IPs - Webhook auth: handle nil/blank secret with controlled error, not 500 crash - Sudo redirect: reuse validated return_path? from login to prevent open redirect - Map live: remove redundant inline script (ensureLeaflet hook handles loading) - Bang calls: convert crash-prone exact matches to case in QR live and API controllers
475 lines
14 KiB
Elixir
475 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
|
|
|
|
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, :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
|
|
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, :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
|