towerops/lib/towerops_web/controllers/api/v1/schedules_controller.ex
Graham McIntire 3632580d1b fix: address security and reliability findings from bugs.md review
- 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
2026-05-11 18:54:12 -05:00

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