towerops/lib/towerops_web/live/schedule_live/index.ex
Graham McIntire 3a408a8dc1 Security hardening + performance fixes across codebase
CRITICAL:
- Membership: remove :role/:org_id/:user_id from mass-assignment cast; use explicit create_changeset/4 and role_update_changeset/2
- GraphQL member resolver: add authorize_invite/3 checking admin/owner role and role hierarchy
- REST invitations controller: add auth check for invite creation

HIGH:
- ApiToken: remove :organization_id/:user_id from cast; use explicit create_changeset/4

MEDIUM:
- Move 8 LiveView Ecto queries into context modules (Admin, Alerts, Coverages, OnCall, Snmp)
- Replace Process.put/Process.get with socket assigns for unresolved_alert_count (user_auth + layouts + 50 templates)
- Add batch get_utilization_for_interfaces/1 to eliminate N+1 capacity queries in device show
- Replace Process.sleep with Process.monitor/assert_receive or Process.send_after in 5 test files

LOW:
- Add handle_params/3 to UserResetPasswordLive, UserRegistrationLive, StatusPageLive
- Remove redundant Repo.preload calls; add preloads to list_site_devices/1
- Fix @impl annotations and credo nesting warnings
2026-06-21 17:40:50 -05:00

242 lines
7.1 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule ToweropsWeb.ScheduleLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.OnCall
alias Towerops.OnCall.Resolver
alias Towerops.OnCall.UserColor
@valid_ranges [7, 14, 28]
@default_range 14
@per_page 10
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, t("Schedules"))
|> assign(:tab, "schedules")
|> assign(:start_date, default_start_date())
|> assign(:range_days, @default_range)
|> assign(:search, "")
|> assign(:page, 1)}
end
@impl true
def handle_params(params, _url, socket) do
tab = Map.get(params, "tab", "schedules")
start_date = parse_start_date(params["start"])
range_days = parse_range_days(params["range"])
search = params["search"] |> to_string() |> String.trim()
page = parse_page(params["page"])
org_id = socket.assigns.current_scope.organization.id
{:noreply,
socket
|> assign(:tab, tab)
|> assign(:start_date, start_date)
|> assign(:range_days, range_days)
|> assign(:search, search)
|> assign(:page, page)
|> load_data(org_id)}
end
@impl true
def handle_event("today", _params, socket) do
{:noreply, navigate_to_window(socket, default_start_date(), socket.assigns.range_days)}
end
def handle_event("prev", _params, socket) do
new_start = Date.add(socket.assigns.start_date, -socket.assigns.range_days)
{:noreply, navigate_to_window(socket, new_start, socket.assigns.range_days)}
end
def handle_event("next", _params, socket) do
new_start = Date.add(socket.assigns.start_date, socket.assigns.range_days)
{:noreply, navigate_to_window(socket, new_start, socket.assigns.range_days)}
end
def handle_event("set_range", %{"range" => range}, socket) do
parsed = parse_range_days(range)
{:noreply, navigate_to_window(socket, socket.assigns.start_date, parsed)}
end
def handle_event("search", %{"search" => search}, socket) do
overrides = %{search: String.trim(search), page: 1}
{:noreply,
push_patch(socket,
to: ~p"/schedules?#{base_query_params(socket, overrides)}"
)}
end
defp navigate_to_window(socket, start_date, range_days) do
overrides = %{start: Date.to_iso8601(start_date), range: range_days, page: 1}
push_patch(socket,
to: ~p"/schedules?#{base_query_params(socket, overrides)}"
)
end
defp base_query_params(socket, overrides) do
%{
tab: socket.assigns.tab,
start: Date.to_iso8601(socket.assigns.start_date),
range: socket.assigns.range_days,
search: socket.assigns.search,
page: socket.assigns.page
}
|> Map.merge(overrides)
|> Enum.reject(fn {_k, v} -> v in [nil, "", 1] end)
|> Map.new()
end
defp load_data(socket, org_id) do
case socket.assigns.tab do
"escalation-policies" ->
policies = OnCall.list_escalation_policies(org_id)
assign(socket, :policies, policies)
_ ->
all_schedules = OnCall.list_schedules(org_id)
filtered = filter_schedules(all_schedules, socket.assigns.search)
total_count = length(filtered)
total_pages = max(1, ceil(total_count / @per_page))
page = max(1, min(socket.assigns.page, total_pages))
page_schedules =
filtered
|> Enum.slice((page - 1) * @per_page, @per_page)
|> hydrate_schedules(socket.assigns.start_date, socket.assigns.range_days)
socket
|> assign(:schedules, page_schedules)
|> assign(:page, page)
|> assign(:pagination, %{
page: page,
per_page: @per_page,
total_count: total_count,
total_pages: total_pages
})
end
end
defp filter_schedules(schedules, ""), do: schedules
defp filter_schedules(schedules, term) when is_binary(term) do
needle = String.downcase(term)
Enum.filter(schedules, fn s ->
String.contains?(String.downcase(s.name || ""), needle)
end)
end
defp hydrate_schedules(schedules, start_date, range_days) do
end_date = Date.add(start_date, range_days)
{:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC")
{:ok, end_at} = DateTime.new(end_date, ~T[00:00:00], "Etc/UTC")
Enum.map(schedules, fn preloaded ->
schedule = preloaded
on_call = Resolver.resolve(preloaded, DateTime.utc_now())
segments =
preloaded
|> Resolver.resolve_range(start_at, end_at)
|> Enum.map(fn segment ->
Map.merge(segment, %{
color_class: UserColor.class_for(segment.user),
label: display_name(segment.user),
day_offset: day_offset(segment.start_at, start_date),
day_span: day_span(segment.start_at, segment.end_at)
})
end)
schedule
|> Map.put(:current_on_call, on_call)
|> Map.put(:current_on_call_name, display_name(on_call))
|> Map.put(:current_on_call_color, UserColor.class_for(on_call))
|> Map.put(:segments, segments)
end)
end
defp parse_page(nil), do: 1
defp parse_page(value) when is_binary(value) do
case Integer.parse(value) do
{int, ""} when int >= 1 -> int
_ -> 1
end
end
defp parse_page(value) when is_integer(value) and value >= 1, do: value
defp parse_page(_), do: 1
# Day index (0-based) within the visible window.
defp day_offset(%DateTime{} = at, %Date{} = window_start) do
at
|> DateTime.to_date()
|> Date.diff(window_start)
|> max(0)
end
# How many day-columns this segment spans (minimum 1).
defp day_span(%DateTime{} = start_at, %DateTime{} = end_at) do
start_date = DateTime.to_date(start_at)
end_date = DateTime.to_date(end_at)
end_date |> Date.diff(start_date) |> max(1)
end
def days_in_window(start_date, range_days) do
Enum.map(0..(range_days - 1), &Date.add(start_date, &1))
end
def window_label(start_date, range_days) do
end_date = Date.add(start_date, range_days - 1)
"#{format_short(start_date)} #{format_short(end_date)}"
end
def today_offset(start_date, range_days) do
today = Date.utc_today()
cond do
Date.before?(today, start_date) -> nil
Date.diff(today, start_date) >= range_days -> nil
true -> Date.diff(today, start_date)
end
end
defp format_short(date), do: Calendar.strftime(date, "%b %-d")
defp default_start_date do
Date.beginning_of_week(Date.utc_today(), :sunday)
end
defp parse_start_date(nil), do: default_start_date()
defp parse_start_date(value) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> date
_ -> default_start_date()
end
end
defp parse_range_days(nil), do: @default_range
defp parse_range_days(value) when is_binary(value) do
case Integer.parse(value) do
{int, ""} when int in @valid_ranges -> int
_ -> @default_range
end
end
defp parse_range_days(value) when is_integer(value) and value in @valid_ranges, do: value
defp parse_range_days(_), do: @default_range
defp display_name(%{first_name: first, last_name: last}) when first != nil and last != nil do
"#{first} #{last}"
end
defp display_name(%{email: email}), do: email
defp display_name(nil), do: nil
end