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