diff --git a/lib/towerops_web/controllers/api/v1/escalation_policies_controller.ex b/lib/towerops_web/controllers/api/v1/escalation_policies_controller.ex new file mode 100644 index 00000000..cf5e5f73 --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/escalation_policies_controller.ex @@ -0,0 +1,340 @@ +defmodule ToweropsWeb.Api.V1.EscalationPoliciesController do + @moduledoc """ + API controller for managing escalation policies. + + 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.EscalationPolicy + alias Towerops.OnCall.EscalationRule + alias Towerops.OnCall.EscalationTarget + alias Towerops.Repo + alias ToweropsWeb.ScopedResource + + # --- Policy CRUD --- + + @doc "GET /api/v1/escalation_policies" + def index(conn, _params) do + organization_id = conn.assigns.current_organization_id + + policies = + organization_id + |> OnCall.list_escalation_policies() + |> Enum.map(&format_policy/1) + + json(conn, %{escalation_policies: policies}) + end + + @doc "POST /api/v1/escalation_policies" + def create(conn, %{"escalation_policy" => policy_params}) do + organization_id = conn.assigns.current_organization_id + attrs = Map.put(policy_params, "organization_id", organization_id) + + case OnCall.create_escalation_policy(attrs) do + {:ok, policy} -> + conn + |> put_status(:created) + |> json(format_policy(policy)) + + {: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 'escalation_policy' parameter"}) + end + + @doc "GET /api/v1/escalation_policies/:id" + def show(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + case ScopedResource.fetch_preload(EscalationPolicy, id, organization_id, rules: :targets) do + {:ok, policy} -> + json(conn, format_policy_detail(policy)) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Access denied to this escalation policy"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Escalation policy not found"}) + end + end + + @doc "PATCH /api/v1/escalation_policies/:id" + def update(conn, %{"id" => id, "escalation_policy" => policy_params}) do + organization_id = conn.assigns.current_organization_id + + case ScopedResource.fetch(EscalationPolicy, id, organization_id) do + {:ok, policy} -> + case OnCall.update_escalation_policy(policy, policy_params) do + {:ok, updated} -> + json(conn, format_policy(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 escalation policy"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Escalation policy not found"}) + end + end + + def update(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "Missing 'escalation_policy' parameter"}) + end + + @doc "DELETE /api/v1/escalation_policies/:id" + def delete(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + case ScopedResource.fetch(EscalationPolicy, id, organization_id) do + {:ok, policy} -> + case OnCall.delete_escalation_policy(policy) 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 escalation policy"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Escalation policy not found"}) + end + end + + # --- Rules --- + + @doc "POST /api/v1/escalation_policies/:id/rules" + def create_rule(conn, %{"id" => policy_id, "rule" => rule_params}) do + organization_id = conn.assigns.current_organization_id + + case ScopedResource.fetch(EscalationPolicy, policy_id, organization_id) do + {:ok, _policy} -> + attrs = Map.put(rule_params, "escalation_policy_id", policy_id) + + case OnCall.create_escalation_rule(attrs) do + {:ok, rule} -> + conn + |> put_status(:created) + |> json(format_rule(rule)) + + {: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 escalation policy"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Escalation policy not found"}) + end + end + + @doc "PATCH /api/v1/escalation_policies/:id/rules/:rule_id" + def update_rule(conn, %{"id" => policy_id, "rule_id" => rule_id, "rule" => rule_params}) do + organization_id = conn.assigns.current_organization_id + + with {:ok, _policy} <- ScopedResource.fetch(EscalationPolicy, policy_id, organization_id), + {:ok, rule} <- fetch_rule(rule_id, policy_id) do + case OnCall.update_escalation_rule(rule, rule_params) do + {:ok, updated} -> + json(conn, format_rule(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 escalation policy"}) + + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"}) + + {:error, :rule_not_found} -> + conn |> put_status(:not_found) |> json(%{error: "Rule not found"}) + end + end + + @doc "DELETE /api/v1/escalation_policies/:id/rules/:rule_id" + def delete_rule(conn, %{"id" => policy_id, "rule_id" => rule_id}) do + organization_id = conn.assigns.current_organization_id + + with {:ok, _policy} <- ScopedResource.fetch(EscalationPolicy, policy_id, organization_id), + {:ok, rule} <- fetch_rule(rule_id, policy_id) do + {:ok, _} = OnCall.delete_escalation_rule(rule) + json(conn, %{success: true}) + else + {:error, :forbidden} -> + conn |> put_status(:forbidden) |> json(%{error: "Access denied to this escalation policy"}) + + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"}) + + {:error, :rule_not_found} -> + conn |> put_status(:not_found) |> json(%{error: "Rule not found"}) + end + end + + # --- Targets --- + + @doc "POST /api/v1/escalation_policies/:id/rules/:rule_id/targets" + def create_target(conn, %{"id" => policy_id, "rule_id" => rule_id, "target" => target_params}) do + organization_id = conn.assigns.current_organization_id + + with {:ok, _policy} <- ScopedResource.fetch(EscalationPolicy, policy_id, organization_id), + {:ok, _rule} <- fetch_rule(rule_id, policy_id) do + attrs = Map.put(target_params, "escalation_rule_id", rule_id) + + case OnCall.create_escalation_target(attrs) do + {:ok, target} -> + conn + |> put_status(:created) + |> json(format_target(target)) + + {: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 escalation policy"}) + + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"}) + + {:error, :rule_not_found} -> + conn |> put_status(:not_found) |> json(%{error: "Rule not found"}) + end + end + + @doc "DELETE /api/v1/escalation_policies/:id/rules/:rule_id/targets/:target_id" + def delete_target(conn, %{"id" => policy_id, "rule_id" => rule_id, "target_id" => target_id}) do + organization_id = conn.assigns.current_organization_id + + with {:ok, _policy} <- ScopedResource.fetch(EscalationPolicy, policy_id, organization_id), + {:ok, _rule} <- fetch_rule(rule_id, policy_id), + {:ok, target} <- fetch_target(target_id, rule_id) do + {:ok, _} = OnCall.delete_escalation_target(target) + json(conn, %{success: true}) + else + {:error, :forbidden} -> + conn |> put_status(:forbidden) |> json(%{error: "Access denied to this escalation policy"}) + + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"}) + + {:error, :rule_not_found} -> + conn |> put_status(:not_found) |> json(%{error: "Rule not found"}) + + {:error, :target_not_found} -> + conn |> put_status(:not_found) |> json(%{error: "Target not found"}) + end + end + + # --- Private helpers --- + + defp fetch_rule(rule_id, policy_id) do + case Repo.get(EscalationRule, rule_id) do + %EscalationRule{escalation_policy_id: ^policy_id} = rule -> {:ok, rule} + _ -> {:error, :rule_not_found} + end + end + + defp fetch_target(target_id, rule_id) do + case Repo.get(EscalationTarget, target_id) do + %EscalationTarget{escalation_rule_id: ^rule_id} = target -> {:ok, target} + _ -> {:error, :target_not_found} + end + end + + defp format_policy(policy) do + %{ + id: policy.id, + name: policy.name, + description: policy.description, + repeat_count: policy.repeat_count, + inserted_at: policy.inserted_at + } + end + + defp format_policy_detail(policy) do + %{ + id: policy.id, + name: policy.name, + description: policy.description, + repeat_count: policy.repeat_count, + inserted_at: policy.inserted_at, + rules: Enum.map(policy.rules, &format_rule_detail/1) + } + end + + defp format_rule(rule) do + %{ + id: rule.id, + position: rule.position, + timeout_minutes: rule.timeout_minutes, + escalation_policy_id: rule.escalation_policy_id, + inserted_at: rule.inserted_at + } + end + + defp format_rule_detail(rule) do + rule + |> format_rule() + |> Map.put(:targets, Enum.map(rule.targets, &format_target/1)) + end + + defp format_target(target) do + %{ + id: target.id, + target_type: target.target_type, + user_id: target.user_id, + schedule_id: target.schedule_id, + escalation_rule_id: target.escalation_rule_id, + inserted_at: target.inserted_at + } + end +end diff --git a/lib/towerops_web/controllers/api/v1/schedules_controller.ex b/lib/towerops_web/controllers/api/v1/schedules_controller.ex new file mode 100644 index 00000000..f9b71f63 --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/schedules_controller.ex @@ -0,0 +1,467 @@ +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 + + # --- 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 + {:ok, _} = OnCall.delete_override(override) + 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, :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 diff --git a/lib/towerops_web/graphql/resolvers/escalation_policy.ex b/lib/towerops_web/graphql/resolvers/escalation_policy.ex new file mode 100644 index 00000000..21e10c6b --- /dev/null +++ b/lib/towerops_web/graphql/resolvers/escalation_policy.ex @@ -0,0 +1,168 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.EscalationPolicy do + @moduledoc "GraphQL resolvers for escalation policy queries and mutations." + + alias Towerops.OnCall + alias Towerops.OnCall.EscalationPolicy + alias Towerops.OnCall.EscalationRule + alias Towerops.OnCall.EscalationTarget + alias Towerops.Repo + alias ToweropsWeb.GraphQL.Resolvers.Helpers + alias ToweropsWeb.ScopedResource + + # --- Queries --- + + def list(_parent, _args, %{context: %{organization_id: org_id}}) do + {:ok, OnCall.list_escalation_policies(org_id)} + end + + def list(_parent, _args, _resolution), do: Helpers.authentication_error() + + def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + case ScopedResource.fetch_preload(EscalationPolicy, id, org_id, rules: :targets) do + {:ok, policy} -> {:ok, policy} + {:error, _} -> {:error, "Escalation policy not found"} + end + end + + def get(_parent, _args, _resolution), do: Helpers.authentication_error() + + # --- Mutations --- + + def create(_parent, %{input: input}, %{context: %{organization_id: org_id}}) do + attrs = + input + |> Map.new(fn {k, v} -> {to_string(k), v} end) + |> Map.put("organization_id", org_id) + + case OnCall.create_escalation_policy(attrs) do + {:ok, policy} -> {:ok, policy} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + + def create(_parent, _args, _resolution), do: Helpers.authentication_error() + + def update(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do + with {:ok, policy} <- fetch_org_policy(id, org_id) do + attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end) + + case OnCall.update_escalation_policy(policy, attrs) do + {:ok, updated} -> {:ok, updated} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + end + + def update(_parent, _args, _resolution), do: Helpers.authentication_error() + + def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + with {:ok, policy} <- fetch_org_policy(id, org_id) do + case OnCall.delete_escalation_policy(policy) do + {:ok, _} -> {:ok, %{success: true, message: "Escalation policy deleted"}} + {:error, _} -> {:ok, %{success: false, message: "Could not delete escalation policy"}} + end + end + end + + def delete(_parent, _args, _resolution), do: Helpers.authentication_error() + + # --- Rule mutations --- + + def create_rule(_parent, %{escalation_policy_id: policy_id, input: input}, %{context: %{organization_id: org_id}}) do + with {:ok, _policy} <- fetch_org_policy(policy_id, org_id) do + attrs = + input + |> Map.new(fn {k, v} -> {to_string(k), v} end) + |> Map.put("escalation_policy_id", policy_id) + + case OnCall.create_escalation_rule(attrs) do + {:ok, rule} -> {:ok, rule} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + end + + def create_rule(_parent, _args, _resolution), do: Helpers.authentication_error() + + def update_rule(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do + with {:ok, rule} <- fetch_rule_by_org(id, org_id) do + attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end) + + case OnCall.update_escalation_rule(rule, attrs) do + {:ok, updated} -> {:ok, updated} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + end + + def update_rule(_parent, _args, _resolution), do: Helpers.authentication_error() + + def delete_rule(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + with {:ok, rule} <- fetch_rule_by_org(id, org_id) do + case OnCall.delete_escalation_rule(rule) do + {:ok, _} -> {:ok, %{success: true, message: "Rule deleted"}} + {:error, _} -> {:ok, %{success: false, message: "Could not delete rule"}} + end + end + end + + def delete_rule(_parent, _args, _resolution), do: Helpers.authentication_error() + + # --- Target mutations --- + + def create_target(_parent, %{escalation_rule_id: rule_id, input: input}, %{context: %{organization_id: org_id}}) do + with {:ok, _rule} <- fetch_rule_by_org(rule_id, org_id) do + attrs = + input + |> Map.new(fn {k, v} -> {to_string(k), v} end) + |> Map.put("escalation_rule_id", rule_id) + + case OnCall.create_escalation_target(attrs) do + {:ok, target} -> {:ok, target} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + end + + def create_target(_parent, _args, _resolution), do: Helpers.authentication_error() + + def delete_target(_parent, %{id: id}, %{context: %{organization_id: _org_id}}) do + case Repo.get(EscalationTarget, id) do + nil -> + {:error, "Target not found"} + + target -> + case OnCall.delete_escalation_target(target) do + {:ok, _} -> {:ok, %{success: true, message: "Target deleted"}} + {:error, _} -> {:ok, %{success: false, message: "Could not delete target"}} + end + end + end + + def delete_target(_parent, _args, _resolution), do: Helpers.authentication_error() + + # --- Private helpers --- + + defp fetch_org_policy(id, org_id) do + case ScopedResource.fetch(EscalationPolicy, id, org_id) do + {:ok, policy} -> {:ok, policy} + {:error, _} -> {:error, "Escalation policy not found"} + end + end + + defp fetch_rule_by_org(rule_id, org_id) do + case Repo.get(EscalationRule, rule_id) do + nil -> + {:error, "Rule not found"} + + rule -> + rule = Repo.preload(rule, :escalation_policy) + + if rule.escalation_policy.organization_id == org_id do + {:ok, rule} + else + {:error, "Rule not found"} + end + end + end +end diff --git a/lib/towerops_web/graphql/resolvers/schedule.ex b/lib/towerops_web/graphql/resolvers/schedule.ex new file mode 100644 index 00000000..b77b4b73 --- /dev/null +++ b/lib/towerops_web/graphql/resolvers/schedule.ex @@ -0,0 +1,223 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.Schedule do + @moduledoc "GraphQL resolvers for on-call schedule queries and mutations." + + alias Towerops.OnCall + alias Towerops.OnCall.Layer + alias Towerops.OnCall.LayerMember + alias Towerops.OnCall.Override + alias Towerops.OnCall.Schedule + alias Towerops.Repo + alias ToweropsWeb.GraphQL.Resolvers.Helpers + alias ToweropsWeb.ScopedResource + + # --- Queries --- + + def list(_parent, _args, %{context: %{organization_id: org_id}}) do + {:ok, OnCall.list_schedules(org_id)} + end + + def list(_parent, _args, _resolution), do: Helpers.authentication_error() + + def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + case ScopedResource.fetch_preload(Schedule, id, org_id, + overrides: :user, + layers: [members: :user] + ) do + {:ok, schedule} -> {:ok, schedule} + {:error, _} -> {:error, "Schedule not found"} + end + end + + def get(_parent, _args, _resolution), do: Helpers.authentication_error() + + def on_call(_parent, %{schedule_id: id}, %{context: %{organization_id: org_id}}) do + case ScopedResource.fetch(Schedule, id, org_id) do + {:ok, _} -> + case OnCall.who_is_on_call(id) do + nil -> {:ok, nil} + user -> {:ok, %{user_id: user.id, email: user.email}} + end + + {:error, _} -> + {:error, "Schedule not found"} + end + end + + def on_call(_parent, _args, _resolution), do: Helpers.authentication_error() + + # --- Mutations --- + + def create(_parent, %{input: input}, %{context: %{organization_id: org_id}}) do + attrs = + input + |> Map.new(fn {k, v} -> {to_string(k), v} end) + |> Map.put("organization_id", org_id) + + case OnCall.create_schedule(attrs) do + {:ok, schedule} -> {:ok, schedule} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + + def create(_parent, _args, _resolution), do: Helpers.authentication_error() + + def update(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do + with {:ok, schedule} <- fetch_org_schedule(id, org_id) do + attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end) + + case OnCall.update_schedule(schedule, attrs) do + {:ok, updated} -> {:ok, updated} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + end + + def update(_parent, _args, _resolution), do: Helpers.authentication_error() + + def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + with {:ok, schedule} <- fetch_org_schedule(id, org_id) do + case OnCall.delete_schedule(schedule) do + {:ok, _} -> {:ok, %{success: true, message: "Schedule deleted"}} + {:error, _} -> {:ok, %{success: false, message: "Could not delete schedule"}} + end + end + end + + def delete(_parent, _args, _resolution), do: Helpers.authentication_error() + + # --- Layer mutations --- + + def create_layer(_parent, %{schedule_id: schedule_id, input: input}, %{context: %{organization_id: org_id}}) do + with {:ok, _schedule} <- fetch_org_schedule(schedule_id, org_id) do + attrs = + input + |> Map.new(fn {k, v} -> {to_string(k), v} end) + |> Map.put("schedule_id", schedule_id) + + case OnCall.create_layer(attrs) do + {:ok, layer} -> {:ok, layer} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + end + + def create_layer(_parent, _args, _resolution), do: Helpers.authentication_error() + + def update_layer(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do + with {:ok, layer} <- fetch_layer_by_org(id, org_id) do + attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end) + + case OnCall.update_layer(layer, attrs) do + {:ok, updated} -> {:ok, updated} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + end + + def update_layer(_parent, _args, _resolution), do: Helpers.authentication_error() + + def delete_layer(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + with {:ok, layer} <- fetch_layer_by_org(id, org_id) do + case OnCall.delete_layer(layer) do + {:ok, _} -> {:ok, %{success: true, message: "Layer deleted"}} + {:error, _} -> {:ok, %{success: false, message: "Could not delete layer"}} + end + end + end + + def delete_layer(_parent, _args, _resolution), do: Helpers.authentication_error() + + # --- Member mutations --- + + def add_member(_parent, %{layer_id: layer_id, user_id: user_id, position: position}, %{ + context: %{organization_id: org_id} + }) do + with {:ok, _layer} <- fetch_layer_by_org(layer_id, org_id) do + attrs = %{ + "layer_id" => layer_id, + "user_id" => user_id, + "position" => position + } + + case OnCall.add_layer_member(attrs) do + {:ok, member} -> {:ok, member} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + end + + def add_member(_parent, _args, _resolution), do: Helpers.authentication_error() + + def remove_member(_parent, %{id: id}, %{context: %{organization_id: _org_id}}) do + case Repo.get(LayerMember, id) do + nil -> + {:error, "Member not found"} + + member -> + case OnCall.remove_layer_member(member) do + {:ok, _} -> {:ok, %{success: true, message: "Member removed"}} + {:error, _} -> {:ok, %{success: false, message: "Could not remove member"}} + end + end + end + + def remove_member(_parent, _args, _resolution), do: Helpers.authentication_error() + + # --- Override mutations --- + + def create_override(_parent, %{schedule_id: schedule_id, input: input}, %{context: %{organization_id: org_id}}) do + with {:ok, _schedule} <- fetch_org_schedule(schedule_id, org_id) do + attrs = + input + |> Map.new(fn {k, v} -> {to_string(k), v} end) + |> Map.put("schedule_id", schedule_id) + + case OnCall.create_override(attrs) do + {:ok, override} -> {:ok, override} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + end + + def create_override(_parent, _args, _resolution), do: Helpers.authentication_error() + + def delete_override(_parent, %{id: id}, %{context: %{organization_id: _org_id}}) do + case Repo.get(Override, id) do + nil -> + {:error, "Override not found"} + + override -> + case OnCall.delete_override(override) do + {:ok, _} -> {:ok, %{success: true, message: "Override deleted"}} + {:error, _} -> {:ok, %{success: false, message: "Could not delete override"}} + end + end + end + + def delete_override(_parent, _args, _resolution), do: Helpers.authentication_error() + + # --- Private helpers --- + + defp fetch_org_schedule(id, org_id) do + case ScopedResource.fetch(Schedule, id, org_id) do + {:ok, schedule} -> {:ok, schedule} + {:error, _} -> {:error, "Schedule not found"} + end + end + + defp fetch_layer_by_org(layer_id, org_id) do + case Repo.get(Layer, layer_id) do + nil -> + {:error, "Layer not found"} + + layer -> + layer = Repo.preload(layer, :schedule) + + if layer.schedule.organization_id == org_id do + {:ok, layer} + else + {:error, "Layer not found"} + end + end + end +end diff --git a/lib/towerops_web/graphql/schema.ex b/lib/towerops_web/graphql/schema.ex index 2a764046..68a9e7d1 100644 --- a/lib/towerops_web/graphql/schema.ex +++ b/lib/towerops_web/graphql/schema.ex @@ -16,6 +16,8 @@ defmodule ToweropsWeb.GraphQL.Schema do import_types(ToweropsWeb.GraphQL.Types.Member) import_types(ToweropsWeb.GraphQL.Types.Integration) import_types(ToweropsWeb.GraphQL.Types.Activity) + import_types(ToweropsWeb.GraphQL.Types.Schedule) + import_types(ToweropsWeb.GraphQL.Types.EscalationPolicy) query do # Devices @@ -99,6 +101,31 @@ defmodule ToweropsWeb.GraphQL.Schema do arg(:device_id, non_null(:id)) resolve(&ToweropsWeb.GraphQL.Resolvers.Device.interfaces/3) end + + # Schedules + field :schedules, list_of(:schedule) do + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.list/3) + end + + field :schedule, :schedule do + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.get/3) + end + + field :on_call, :on_call_result do + arg(:schedule_id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.on_call/3) + end + + # Escalation Policies + field :escalation_policies, list_of(:escalation_policy) do + resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.list/3) + end + + field :escalation_policy, :escalation_policy do + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.get/3) + end end mutation do @@ -208,5 +235,112 @@ defmodule ToweropsWeb.GraphQL.Schema do arg(:id, non_null(:id)) resolve(&ToweropsWeb.GraphQL.Resolvers.Integration.test_connection/3) end + + # Schedule CRUD + field :create_schedule, :schedule do + arg(:input, non_null(:schedule_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.create/3) + end + + field :update_schedule, :schedule do + arg(:id, non_null(:id)) + arg(:input, non_null(:schedule_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.update/3) + end + + field :delete_schedule, :delete_result do + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.delete/3) + end + + # Layer CRUD + field :create_layer, :layer do + arg(:schedule_id, non_null(:id)) + arg(:input, non_null(:layer_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.create_layer/3) + end + + field :update_layer, :layer do + arg(:id, non_null(:id)) + arg(:input, non_null(:layer_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.update_layer/3) + end + + field :delete_layer, :delete_result do + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.delete_layer/3) + end + + # Layer member management + field :add_layer_member, :layer_member do + arg(:layer_id, non_null(:id)) + arg(:user_id, non_null(:id)) + arg(:position, non_null(:integer)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.add_member/3) + end + + field :remove_layer_member, :delete_result do + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.remove_member/3) + end + + # Override management + field :create_override, :override do + arg(:schedule_id, non_null(:id)) + arg(:input, non_null(:override_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.create_override/3) + end + + field :delete_override, :delete_result do + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.delete_override/3) + end + + # Escalation Policy CRUD + field :create_escalation_policy, :escalation_policy do + arg(:input, non_null(:escalation_policy_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.create/3) + end + + field :update_escalation_policy, :escalation_policy do + arg(:id, non_null(:id)) + arg(:input, non_null(:escalation_policy_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.update/3) + end + + field :delete_escalation_policy, :delete_result do + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.delete/3) + end + + # Escalation Rule CRUD + field :create_escalation_rule, :escalation_rule do + arg(:escalation_policy_id, non_null(:id)) + arg(:input, non_null(:escalation_rule_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.create_rule/3) + end + + field :update_escalation_rule, :escalation_rule do + arg(:id, non_null(:id)) + arg(:input, non_null(:escalation_rule_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.update_rule/3) + end + + field :delete_escalation_rule, :delete_result do + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.delete_rule/3) + end + + # Escalation Target management + field :create_escalation_target, :escalation_target do + arg(:escalation_rule_id, non_null(:id)) + arg(:input, non_null(:escalation_target_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.create_target/3) + end + + field :delete_escalation_target, :delete_result do + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.delete_target/3) + end end end diff --git a/lib/towerops_web/graphql/types/escalation_policy.ex b/lib/towerops_web/graphql/types/escalation_policy.ex new file mode 100644 index 00000000..6e562c1b --- /dev/null +++ b/lib/towerops_web/graphql/types/escalation_policy.ex @@ -0,0 +1,52 @@ +defmodule ToweropsWeb.GraphQL.Types.EscalationPolicy do + @moduledoc "GraphQL types for escalation policies." + use Absinthe.Schema.Notation + + object :escalation_policy do + field :id, :id + field :name, :string + field :description, :string + field :repeat_count, :integer + field :organization_id, :id + field :inserted_at, :string + field :updated_at, :string + + field :rules, list_of(:escalation_rule) + end + + object :escalation_rule do + field :id, :id + field :position, :integer + field :timeout_minutes, :integer + field :escalation_policy_id, :id + field :inserted_at, :string + + field :targets, list_of(:escalation_target) + end + + object :escalation_target do + field :id, :id + field :target_type, :string + field :user_id, :id + field :schedule_id, :id + field :escalation_rule_id, :id + field :inserted_at, :string + end + + input_object :escalation_policy_input do + field :name, :string + field :description, :string + field :repeat_count, :integer + end + + input_object :escalation_rule_input do + field :position, :integer + field :timeout_minutes, :integer + end + + input_object :escalation_target_input do + field :target_type, :string + field :user_id, :id + field :schedule_id, :id + end +end diff --git a/lib/towerops_web/graphql/types/schedule.ex b/lib/towerops_web/graphql/types/schedule.ex new file mode 100644 index 00000000..5f3965d5 --- /dev/null +++ b/lib/towerops_web/graphql/types/schedule.ex @@ -0,0 +1,78 @@ +defmodule ToweropsWeb.GraphQL.Types.Schedule do + @moduledoc "GraphQL types for on-call schedules." + use Absinthe.Schema.Notation + + object :schedule do + field :id, :id + field :name, :string + field :description, :string + field :timezone, :string + field :organization_id, :id + field :inserted_at, :string + field :updated_at, :string + + field :layers, list_of(:layer) + field :overrides, list_of(:override) + end + + object :layer do + field :id, :id + field :name, :string + field :position, :integer + field :rotation_type, :string + field :rotation_interval, :integer + field :handoff_time, :string + field :handoff_day, :integer + field :start_date, :string + field :restriction_type, :string + field :inserted_at, :string + + field :members, list_of(:layer_member) + end + + object :layer_member do + field :id, :id + field :position, :integer + field :user_id, :id + field :layer_id, :id + field :inserted_at, :string + end + + object :override do + field :id, :id + field :start_time, :string + field :end_time, :string + field :user_id, :id + field :schedule_id, :id + field :inserted_at, :string + end + + object :on_call_result do + field :user_id, :id + field :email, :string + end + + input_object :schedule_input do + field :name, :string + field :description, :string + field :timezone, :string + end + + input_object :layer_input do + field :name, :string + field :position, :integer + field :rotation_type, :string + field :rotation_interval, :integer + field :handoff_time, :string + field :handoff_day, :integer + field :start_date, :string + field :restriction_type, :string + field :restrictions, :json + end + + input_object :override_input do + field :user_id, :id + field :start_time, :string + field :end_time, :string + end +end diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 3f43e8d2..87d8907e 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -169,6 +169,25 @@ defmodule ToweropsWeb.Router do get "/devices/:device_id/interfaces", CheckResultsController, :interfaces get "/activity", ActivityController, :index + + # On-call schedules + resources "/schedules", SchedulesController, except: [:new, :edit] + get "/schedules/:id/on_call", SchedulesController, :on_call + post "/schedules/:id/layers", SchedulesController, :create_layer + patch "/schedules/:id/layers/:layer_id", SchedulesController, :update_layer + delete "/schedules/:id/layers/:layer_id", SchedulesController, :delete_layer + post "/schedules/:id/layers/:layer_id/members", SchedulesController, :create_member + delete "/schedules/:id/layers/:layer_id/members/:member_id", SchedulesController, :delete_member + post "/schedules/:id/overrides", SchedulesController, :create_override + delete "/schedules/:id/overrides/:override_id", SchedulesController, :delete_override + + # Escalation policies + resources "/escalation_policies", EscalationPoliciesController, except: [:new, :edit] + post "/escalation_policies/:id/rules", EscalationPoliciesController, :create_rule + patch "/escalation_policies/:id/rules/:rule_id", EscalationPoliciesController, :update_rule + delete "/escalation_policies/:id/rules/:rule_id", EscalationPoliciesController, :delete_rule + post "/escalation_policies/:id/rules/:rule_id/targets", EscalationPoliciesController, :create_target + delete "/escalation_policies/:id/rules/:rule_id/targets/:target_id", EscalationPoliciesController, :delete_target end # Webhook routes (shared secret authentication) diff --git a/test/towerops_web/controllers/api/v1/escalation_policies_controller_test.exs b/test/towerops_web/controllers/api/v1/escalation_policies_controller_test.exs new file mode 100644 index 00000000..254bd2cf --- /dev/null +++ b/test/towerops_web/controllers/api/v1/escalation_policies_controller_test.exs @@ -0,0 +1,326 @@ +defmodule ToweropsWeb.Api.V1.EscalationPoliciesControllerTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OnCallFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + alias Towerops.OnCall + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + + {:ok, {_token, raw_token}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Test Token" + }) + + conn = + build_conn() + |> put_req_header("authorization", "Bearer #{raw_token}") + |> put_req_header("accept", "application/json") + + %{ + conn: conn, + user: user, + organization: organization + } + end + + describe "index/2" do + test "lists all escalation policies for authenticated organization", %{ + conn: conn, + organization: organization + } do + policy1 = escalation_policy_fixture(organization.id, %{name: "Critical"}) + policy2 = escalation_policy_fixture(organization.id, %{name: "Warning"}) + + conn = get(conn, ~p"/api/v1/escalation_policies") + + assert %{"escalation_policies" => policies} = json_response(conn, 200) + assert length(policies) == 2 + + ids = Enum.map(policies, & &1["id"]) + assert policy1.id in ids + assert policy2.id in ids + end + + test "returns empty list when organization has no policies", %{conn: conn} do + conn = get(conn, ~p"/api/v1/escalation_policies") + + assert %{"escalation_policies" => []} = json_response(conn, 200) + end + + test "does not return policies from other organizations", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + _other_policy = escalation_policy_fixture(other_org.id) + + conn = get(conn, ~p"/api/v1/escalation_policies") + + assert %{"escalation_policies" => []} = json_response(conn, 200) + end + end + + describe "create/2" do + test "creates policy with valid attributes", %{conn: conn, organization: organization} do + params = %{ + "escalation_policy" => %{ + "name" => "Critical Alerts", + "description" => "For P1 incidents", + "repeat_count" => 5 + } + } + + conn = post(conn, ~p"/api/v1/escalation_policies", params) + + assert %{ + "id" => id, + "name" => "Critical Alerts", + "description" => "For P1 incidents", + "repeat_count" => 5 + } = json_response(conn, 201) + + assert is_binary(id) + policy = OnCall.get_escalation_policy!(id) + assert policy.organization_id == organization.id + end + + test "returns 422 with invalid params", %{conn: conn} do + params = %{"escalation_policy" => %{"name" => ""}} + + conn = post(conn, ~p"/api/v1/escalation_policies", params) + + assert %{"errors" => _} = json_response(conn, 422) + end + + test "returns 400 when escalation_policy parameter is missing", %{conn: conn} do + conn = post(conn, ~p"/api/v1/escalation_policies", %{}) + + assert %{"error" => "Missing 'escalation_policy' parameter"} = json_response(conn, 400) + end + end + + describe "show/2" do + test "returns policy with rules and targets", %{ + conn: conn, + user: user, + organization: organization + } do + policy = escalation_policy_fixture(organization.id) + rule = escalation_rule_fixture(policy.id) + _target = escalation_target_fixture(rule.id, %{target_type: "user", user_id: user.id}) + + conn = get(conn, ~p"/api/v1/escalation_policies/#{policy.id}") + + assert %{ + "id" => _, + "name" => _, + "rules" => [rule_json] + } = json_response(conn, 200) + + assert %{"targets" => [_target_json]} = rule_json + end + + test "returns 404 when policy does not exist", %{conn: conn} do + conn = get(conn, ~p"/api/v1/escalation_policies/#{Ecto.UUID.generate()}") + + assert %{"error" => "Escalation policy not found"} = json_response(conn, 404) + end + + test "returns 403 when policy belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_policy = escalation_policy_fixture(other_org.id) + + conn = get(conn, ~p"/api/v1/escalation_policies/#{other_policy.id}") + + assert %{"error" => "Access denied to this escalation policy"} = json_response(conn, 403) + end + end + + describe "update/2" do + test "updates policy with valid attributes", %{conn: conn, organization: organization} do + policy = escalation_policy_fixture(organization.id, %{name: "Original"}) + + params = %{"escalation_policy" => %{"name" => "Updated"}} + conn = patch(conn, ~p"/api/v1/escalation_policies/#{policy.id}", params) + + assert %{"id" => _, "name" => "Updated"} = json_response(conn, 200) + end + + test "returns 404 when policy does not exist", %{conn: conn} do + params = %{"escalation_policy" => %{"name" => "Updated"}} + conn = patch(conn, ~p"/api/v1/escalation_policies/#{Ecto.UUID.generate()}", params) + + assert %{"error" => "Escalation policy not found"} = json_response(conn, 404) + end + + test "returns 403 when policy belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_policy = escalation_policy_fixture(other_org.id) + + params = %{"escalation_policy" => %{"name" => "Hacked"}} + conn = patch(conn, ~p"/api/v1/escalation_policies/#{other_policy.id}", params) + + assert %{"error" => "Access denied to this escalation policy"} = json_response(conn, 403) + end + + test "returns 400 when escalation_policy parameter is missing", %{ + conn: conn, + organization: organization + } do + policy = escalation_policy_fixture(organization.id) + + conn = patch(conn, ~p"/api/v1/escalation_policies/#{policy.id}", %{}) + + assert %{"error" => "Missing 'escalation_policy' parameter"} = json_response(conn, 400) + end + end + + describe "delete/2" do + test "deletes policy successfully", %{conn: conn, organization: organization} do + policy = escalation_policy_fixture(organization.id) + + conn = delete(conn, ~p"/api/v1/escalation_policies/#{policy.id}") + + assert %{"success" => true} = json_response(conn, 200) + assert OnCall.get_escalation_policy(policy.id) == nil + end + + test "returns 404 when policy does not exist", %{conn: conn} do + conn = delete(conn, ~p"/api/v1/escalation_policies/#{Ecto.UUID.generate()}") + + assert %{"error" => "Escalation policy not found"} = json_response(conn, 404) + end + + test "returns 403 when policy belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_policy = escalation_policy_fixture(other_org.id) + + conn = delete(conn, ~p"/api/v1/escalation_policies/#{other_policy.id}") + + assert %{"error" => "Access denied to this escalation policy"} = json_response(conn, 403) + end + end + + describe "create_rule/2" do + test "creates rule on policy", %{conn: conn, organization: organization} do + policy = escalation_policy_fixture(organization.id) + + params = %{ + "rule" => %{ + "position" => 0, + "timeout_minutes" => 15 + } + } + + conn = post(conn, ~p"/api/v1/escalation_policies/#{policy.id}/rules", params) + + assert %{"id" => _, "position" => 0, "timeout_minutes" => 15} = json_response(conn, 201) + end + + test "returns 404 when policy does not exist", %{conn: conn} do + params = %{"rule" => %{"position" => 0, "timeout_minutes" => 15}} + + conn = + post(conn, ~p"/api/v1/escalation_policies/#{Ecto.UUID.generate()}/rules", params) + + assert %{"error" => "Escalation policy not found"} = json_response(conn, 404) + end + end + + describe "update_rule/2" do + test "updates rule", %{conn: conn, organization: organization} do + policy = escalation_policy_fixture(organization.id) + rule = escalation_rule_fixture(policy.id) + + params = %{"rule" => %{"timeout_minutes" => 60}} + conn = patch(conn, ~p"/api/v1/escalation_policies/#{policy.id}/rules/#{rule.id}", params) + + assert %{"timeout_minutes" => 60} = json_response(conn, 200) + end + + test "returns 404 when rule does not exist", %{conn: conn, organization: organization} do + policy = escalation_policy_fixture(organization.id) + + params = %{"rule" => %{"timeout_minutes" => 60}} + + conn = + patch( + conn, + ~p"/api/v1/escalation_policies/#{policy.id}/rules/#{Ecto.UUID.generate()}", + params + ) + + assert %{"error" => "Rule not found"} = json_response(conn, 404) + end + end + + describe "delete_rule/2" do + test "deletes rule", %{conn: conn, organization: organization} do + policy = escalation_policy_fixture(organization.id) + rule = escalation_rule_fixture(policy.id) + + conn = delete(conn, ~p"/api/v1/escalation_policies/#{policy.id}/rules/#{rule.id}") + + assert %{"success" => true} = json_response(conn, 200) + end + end + + describe "create_target/2" do + test "adds target to rule", %{conn: conn, user: user, organization: organization} do + policy = escalation_policy_fixture(organization.id) + rule = escalation_rule_fixture(policy.id) + + params = %{ + "target" => %{ + "target_type" => "user", + "user_id" => user.id + } + } + + conn = + post( + conn, + ~p"/api/v1/escalation_policies/#{policy.id}/rules/#{rule.id}/targets", + params + ) + + assert %{"id" => _, "target_type" => "user"} = json_response(conn, 201) + end + end + + describe "delete_target/2" do + test "removes target from rule", %{conn: conn, user: user, organization: organization} do + policy = escalation_policy_fixture(organization.id) + rule = escalation_rule_fixture(policy.id) + target = escalation_target_fixture(rule.id, %{target_type: "user", user_id: user.id}) + + conn = + delete( + conn, + ~p"/api/v1/escalation_policies/#{policy.id}/rules/#{rule.id}/targets/#{target.id}" + ) + + assert %{"success" => true} = json_response(conn, 200) + end + end + + describe "authentication" do + test "returns 401 without authorization header" do + conn = + build_conn() + |> put_req_header("accept", "application/json") + |> get(~p"/api/v1/escalation_policies") + + assert %{"error" => _message} = json_response(conn, 401) + end + end +end diff --git a/test/towerops_web/controllers/api/v1/schedules_controller_test.exs b/test/towerops_web/controllers/api/v1/schedules_controller_test.exs new file mode 100644 index 00000000..f9a994b9 --- /dev/null +++ b/test/towerops_web/controllers/api/v1/schedules_controller_test.exs @@ -0,0 +1,398 @@ +defmodule ToweropsWeb.Api.V1.SchedulesControllerTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OnCallFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + alias Towerops.OnCall + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + + {:ok, {_token, raw_token}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Test Token" + }) + + conn = + build_conn() + |> put_req_header("authorization", "Bearer #{raw_token}") + |> put_req_header("accept", "application/json") + + %{ + conn: conn, + user: user, + organization: organization + } + end + + describe "index/2" do + test "lists all schedules for authenticated organization", %{ + conn: conn, + organization: organization + } do + schedule1 = schedule_fixture(organization.id, %{name: "Primary"}) + schedule2 = schedule_fixture(organization.id, %{name: "Secondary"}) + + conn = get(conn, ~p"/api/v1/schedules") + + assert %{"schedules" => schedules} = json_response(conn, 200) + assert length(schedules) == 2 + + ids = Enum.map(schedules, & &1["id"]) + assert schedule1.id in ids + assert schedule2.id in ids + end + + test "returns empty list when organization has no schedules", %{conn: conn} do + conn = get(conn, ~p"/api/v1/schedules") + + assert %{"schedules" => []} = json_response(conn, 200) + end + + test "does not return schedules from other organizations", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + _other_schedule = schedule_fixture(other_org.id) + + conn = get(conn, ~p"/api/v1/schedules") + + assert %{"schedules" => []} = json_response(conn, 200) + end + end + + describe "create/2" do + test "creates schedule with valid attributes", %{conn: conn, organization: organization} do + params = %{ + "schedule" => %{ + "name" => "On-Call Rotation", + "timezone" => "America/New_York", + "description" => "Primary rotation" + } + } + + conn = post(conn, ~p"/api/v1/schedules", params) + + assert %{ + "id" => id, + "name" => "On-Call Rotation", + "timezone" => "America/New_York", + "description" => "Primary rotation" + } = json_response(conn, 201) + + assert is_binary(id) + schedule = OnCall.get_schedule!(id) + assert schedule.organization_id == organization.id + end + + test "returns 422 with invalid params", %{conn: conn} do + params = %{"schedule" => %{"name" => ""}} + + conn = post(conn, ~p"/api/v1/schedules", params) + + assert %{"errors" => errors} = json_response(conn, 422) + assert Map.has_key?(errors, "name") or Map.has_key?(errors, "timezone") + end + + test "returns 400 when schedule parameter is missing", %{conn: conn} do + conn = post(conn, ~p"/api/v1/schedules", %{}) + + assert %{"error" => "Missing 'schedule' parameter"} = json_response(conn, 400) + end + end + + describe "show/2" do + test "returns schedule with layers, members, and overrides", %{ + conn: conn, + user: user, + organization: organization + } do + schedule = schedule_fixture(organization.id) + layer = layer_fixture(schedule.id) + _member = layer_member_fixture(layer.id, user.id) + _override = override_fixture(schedule.id, user.id) + + conn = get(conn, ~p"/api/v1/schedules/#{schedule.id}") + + assert %{ + "id" => _, + "name" => _, + "timezone" => _, + "layers" => [layer_json], + "overrides" => [override_json] + } = json_response(conn, 200) + + assert %{"members" => [_member_json]} = layer_json + assert Map.has_key?(override_json, "start_time") + end + + test "returns 404 when schedule does not exist", %{conn: conn} do + conn = get(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}") + + assert %{"error" => "Schedule not found"} = json_response(conn, 404) + end + + test "returns 403 when schedule belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_schedule = schedule_fixture(other_org.id) + + conn = get(conn, ~p"/api/v1/schedules/#{other_schedule.id}") + + assert %{"error" => "Access denied to this schedule"} = json_response(conn, 403) + end + end + + describe "update/2" do + test "updates schedule with valid attributes", %{conn: conn, organization: organization} do + schedule = schedule_fixture(organization.id, %{name: "Original"}) + + params = %{"schedule" => %{"name" => "Updated"}} + conn = patch(conn, ~p"/api/v1/schedules/#{schedule.id}", params) + + assert %{"id" => _, "name" => "Updated"} = json_response(conn, 200) + end + + test "returns 404 when schedule does not exist", %{conn: conn} do + params = %{"schedule" => %{"name" => "Updated"}} + conn = patch(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}", params) + + assert %{"error" => "Schedule not found"} = json_response(conn, 404) + end + + test "returns 403 when schedule belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_schedule = schedule_fixture(other_org.id) + + params = %{"schedule" => %{"name" => "Hacked"}} + conn = patch(conn, ~p"/api/v1/schedules/#{other_schedule.id}", params) + + assert %{"error" => "Access denied to this schedule"} = json_response(conn, 403) + end + + test "returns 400 when schedule parameter is missing", %{ + conn: conn, + organization: organization + } do + schedule = schedule_fixture(organization.id) + + conn = patch(conn, ~p"/api/v1/schedules/#{schedule.id}", %{}) + + assert %{"error" => "Missing 'schedule' parameter"} = json_response(conn, 400) + end + end + + describe "delete/2" do + test "deletes schedule successfully", %{conn: conn, organization: organization} do + schedule = schedule_fixture(organization.id) + + conn = delete(conn, ~p"/api/v1/schedules/#{schedule.id}") + + assert %{"success" => true} = json_response(conn, 200) + assert OnCall.get_schedule(schedule.id) == nil + end + + test "returns 404 when schedule does not exist", %{conn: conn} do + conn = delete(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}") + + assert %{"error" => "Schedule not found"} = json_response(conn, 404) + end + + test "returns 403 when schedule belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_schedule = schedule_fixture(other_org.id) + + conn = delete(conn, ~p"/api/v1/schedules/#{other_schedule.id}") + + assert %{"error" => "Access denied to this schedule"} = json_response(conn, 403) + end + end + + describe "on_call/2" do + test "returns on-call user for schedule", %{ + conn: conn, + user: user, + organization: organization + } do + schedule = schedule_fixture(organization.id) + layer = layer_fixture(schedule.id, %{start_date: ~U[2025-01-01 09:00:00Z]}) + _member = layer_member_fixture(layer.id, user.id) + + conn = get(conn, ~p"/api/v1/schedules/#{schedule.id}/on_call") + + assert %{"on_call" => on_call} = json_response(conn, 200) + assert on_call["id"] == user.id + end + + test "returns null when no one is on call", %{conn: conn, organization: organization} do + schedule = schedule_fixture(organization.id) + + conn = get(conn, ~p"/api/v1/schedules/#{schedule.id}/on_call") + + assert %{"on_call" => nil} = json_response(conn, 200) + end + + test "returns 404 when schedule does not exist", %{conn: conn} do + conn = get(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}/on_call") + + assert %{"error" => "Schedule not found"} = json_response(conn, 404) + end + end + + describe "create_layer/2" do + test "creates layer on schedule", %{conn: conn, organization: organization} do + schedule = schedule_fixture(organization.id) + + params = %{ + "layer" => %{ + "name" => "Primary", + "position" => 1, + "rotation_type" => "weekly", + "rotation_interval" => 1, + "handoff_time" => "09:00:00", + "start_date" => "2026-01-01T09:00:00Z" + } + } + + conn = post(conn, ~p"/api/v1/schedules/#{schedule.id}/layers", params) + + assert %{"id" => _, "name" => "Primary", "rotation_type" => "weekly"} = + json_response(conn, 201) + end + + test "returns 404 when schedule does not exist", %{conn: conn} do + params = %{ + "layer" => %{ + "name" => "Primary", + "position" => 1, + "rotation_type" => "weekly", + "rotation_interval" => 1, + "handoff_time" => "09:00:00", + "start_date" => "2026-01-01T09:00:00Z" + } + } + + conn = post(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}/layers", params) + + assert %{"error" => "Schedule not found"} = json_response(conn, 404) + end + end + + describe "update_layer/2" do + test "updates layer", %{conn: conn, organization: organization} do + schedule = schedule_fixture(organization.id) + layer = layer_fixture(schedule.id) + + params = %{"layer" => %{"name" => "Updated Layer"}} + conn = patch(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}", params) + + assert %{"name" => "Updated Layer"} = json_response(conn, 200) + end + + test "returns 404 when layer does not exist", %{conn: conn, organization: organization} do + schedule = schedule_fixture(organization.id) + + params = %{"layer" => %{"name" => "Updated"}} + + conn = + patch(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{Ecto.UUID.generate()}", params) + + assert %{"error" => "Layer not found"} = json_response(conn, 404) + end + end + + describe "delete_layer/2" do + test "deletes layer", %{conn: conn, organization: organization} do + schedule = schedule_fixture(organization.id) + layer = layer_fixture(schedule.id) + + conn = delete(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}") + + assert %{"success" => true} = json_response(conn, 200) + end + end + + describe "create_member/2" do + test "adds member to layer", %{conn: conn, user: user, organization: organization} do + schedule = schedule_fixture(organization.id) + layer = layer_fixture(schedule.id) + + params = %{ + "member" => %{ + "user_id" => user.id, + "position" => 0 + } + } + + conn = + post(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}/members", params) + + assert %{"id" => _, "user_id" => uid, "position" => 0} = json_response(conn, 201) + assert uid == user.id + end + end + + describe "delete_member/2" do + test "removes member from layer", %{conn: conn, user: user, organization: organization} do + schedule = schedule_fixture(organization.id) + layer = layer_fixture(schedule.id) + member = layer_member_fixture(layer.id, user.id) + + conn = + delete( + conn, + ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}/members/#{member.id}" + ) + + assert %{"success" => true} = json_response(conn, 200) + end + end + + describe "create_override/2" do + test "creates override on schedule", %{conn: conn, user: user, organization: organization} do + schedule = schedule_fixture(organization.id) + + params = %{ + "override" => %{ + "user_id" => user.id, + "start_time" => "2026-03-15T09:00:00Z", + "end_time" => "2026-03-16T09:00:00Z" + } + } + + conn = post(conn, ~p"/api/v1/schedules/#{schedule.id}/overrides", params) + + assert %{"id" => _, "start_time" => _, "end_time" => _} = json_response(conn, 201) + end + end + + describe "delete_override/2" do + test "deletes override", %{conn: conn, user: user, organization: organization} do + schedule = schedule_fixture(organization.id) + override = override_fixture(schedule.id, user.id) + + conn = + delete(conn, ~p"/api/v1/schedules/#{schedule.id}/overrides/#{override.id}") + + assert %{"success" => true} = json_response(conn, 200) + end + end + + describe "authentication" do + test "returns 401 without authorization header" do + conn = + build_conn() + |> put_req_header("accept", "application/json") + |> get(~p"/api/v1/schedules") + + assert %{"error" => _message} = json_response(conn, 401) + end + end +end