towerops/lib/towerops_web/controllers/api/v1/schedules_controller.ex
Graham McIntire a4e08b2aca Major codebase cleanup: DRY schemas, remove dead config, consolidate modules
- Created Towerops.Schema base macro (95+ schemas updated to use it)
- Created Towerops.Snmp.Reading macro (7 reading schemas consolidated)
- Created Towerops.Gaiia.BaseSchema macro (4 Gaiia schemas consolidated)
- Created Towerops.SyncLog shared module (UISP + Preseem merge)
- Created Towerops.LogFilters (3 log filter modules merged into 1)
- Created Towerops.OnCall.ChangesetHelpers (9 on-call schemas simplified)
- Created API v1 ResourceController shared helpers (7 controllers)
- Extracted ConnectionHelpers.format_connection_result (2 LiveViews)
- Removed 5 dead config keys (scopes, mib_dirs, stripe_meter_id, etc.)
- Fixed 2 pre-existing broken tests
- All 13,219 tests pass, Credo clean (0 issues)
2026-06-16 15:29:22 -05:00

382 lines
11 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
alias Towerops.OnCall
alias Towerops.OnCall.Layer
alias Towerops.OnCall.LayerMember
alias Towerops.OnCall.Override
alias Towerops.OnCall.Schedule
alias Towerops.Repo
alias ToweropsWeb.Api.V1.ResourceController, as: Resources
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)
Resources.created_or_error(conn, OnCall.create_schedule(attrs), &format_schedule/1)
end
def create(conn, _params) do
Resources.missing_param(conn, "schedule")
end
@doc "GET /api/v1/schedules/:id"
def show(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
Resources.fetch_and_render(
conn,
Schedule,
id,
organization_id,
[overrides: :user, layers: [members: :user]],
&format_schedule_detail/1,
"Schedule not found"
)
end
@doc "PATCH /api/v1/schedules/:id"
def update(conn, %{"id" => id, "schedule" => schedule_params}) do
organization_id = conn.assigns.current_organization_id
Resources.update_and_render(
conn,
Schedule,
id,
organization_id,
%{
context: OnCall,
update_fn: :update_schedule,
params: schedule_params,
format_fn: &format_schedule/1,
not_found_msg: "Schedule not found"
}
)
end
def update(conn, _params) do
Resources.missing_param(conn, "schedule")
end
@doc "DELETE /api/v1/schedules/:id"
def delete(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
Resources.delete_and_render(conn, Schedule, id, organization_id, OnCall, :delete_schedule, "Schedule not found")
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} ->
Resources.not_found(conn, "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} ->
Resources.unprocessable_entity(conn, changeset)
end
{:error, :not_found} ->
Resources.not_found(conn, "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} ->
Resources.unprocessable_entity(conn, changeset)
end
else
{:error, :not_found} ->
Resources.not_found(conn, "Schedule not found")
{:error, :layer_not_found} ->
Resources.not_found(conn, "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} ->
Resources.not_found(conn, "Schedule not found")
{:error, :layer_not_found} ->
Resources.not_found(conn, "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} ->
Resources.unprocessable_entity(conn, changeset)
end
else
{:error, :not_found} ->
Resources.not_found(conn, "Schedule not found")
{:error, :layer_not_found} ->
Resources.not_found(conn, "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} ->
Resources.not_found(conn, "Schedule not found")
{:error, :layer_not_found} ->
Resources.not_found(conn, "Layer not found")
{:error, :member_not_found} ->
Resources.not_found(conn, "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} ->
Resources.unprocessable_entity(conn, changeset)
end
{:error, :not_found} ->
Resources.not_found(conn, "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} ->
Resources.not_found(conn, "Schedule not found")
{:error, :override_not_found} ->
Resources.not_found(conn, "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