towerops/lib/towerops/on_call/resolver.ex
Graham McIntire 0cd2ed3567
feat: add on-call schedules and escalation policies
Built-in PagerDuty-equivalent system for on-call scheduling and alert
escalation. Users can now manage schedules, rotation layers, overrides,
and escalation policies directly in the app alongside PagerDuty.

- On-call schedules with rotation layers (daily/weekly/custom), member
  management, and temporary overrides
- Escalation policies with ordered rules, timeout-based escalation,
  and user/schedule targets
- Automatic escalation via Oban worker with configurable repeat count
- Email notifications via Swoosh for on-call alerts
- Resolver computes who's on-call from layer stacking and overrides
- AlertNotificationWorker integration: starts escalation alongside
  PagerDuty, acknowledges/resolves incidents on alert state changes
- Device and organization schemas support escalation_policy_id
- Escalation policy picker on device form
- Schedules nav item with tabbed index (schedules + escalation policies)
- Full CRUD UI for schedules, layers, members, overrides, rules, targets
- 62 LiveView tests, 56 context/schema/resolver/escalation tests
- 26 E2E Playwright tests for smoke and critical path coverage
2026-03-11 12:32:54 -05:00

121 lines
3.6 KiB
Elixir

defmodule Towerops.OnCall.Resolver do
@moduledoc """
Pure computation of who's on-call for a given schedule at a given time.
Layers stack — higher position layers override lower layers where they have coverage.
Overrides take precedence over all layers.
"""
alias Towerops.OnCall.Schedule
@seconds_per_day 86_400
@seconds_per_week 604_800
@doc """
Resolves who is on-call for the given schedule at the given datetime.
The schedule must have `layers` (with `:members`) and `overrides` preloaded.
Returns the on-call `%User{}` or `nil` if no one is on-call.
"""
@spec resolve(Schedule.t(), DateTime.t()) :: map() | nil
def resolve(%Schedule{} = schedule, %DateTime{} = datetime) do
case resolve_override(schedule.overrides, datetime) do
{:ok, user} -> user
:none -> resolve_layers(schedule.layers, datetime)
end
end
defp resolve_override(overrides, datetime) do
overrides
|> Enum.find(fn override ->
DateTime.compare(datetime, override.start_time) in [:gt, :eq] and
DateTime.before?(datetime, override.end_time)
end)
|> case do
nil -> :none
override -> {:ok, override.user}
end
end
defp resolve_layers(layers, datetime) do
layers
|> Enum.sort_by(& &1.position, :desc)
|> Enum.reduce_while(nil, fn layer, _acc ->
case resolve_layer(layer, datetime) do
nil -> {:cont, nil}
user -> {:halt, user}
end
end)
end
defp resolve_layer(layer, datetime) do
with true <- datetime_after_start?(layer, datetime),
true <- within_restriction?(layer, datetime),
{:ok, user} <- compute_rotation(layer, datetime) do
user
else
_ -> nil
end
end
defp datetime_after_start?(layer, datetime) do
DateTime.compare(datetime, layer.start_date) in [:gt, :eq]
end
defp within_restriction?(%{restriction_type: nil}, _datetime), do: true
defp within_restriction?(%{restriction_type: "time_of_day", restrictions: restrictions}, datetime) do
start_time = parse_time(restrictions["start_time"])
end_time = parse_time(restrictions["end_time"])
current_time = DateTime.to_time(datetime)
if Time.after?(start_time, end_time) do
# Overnight restriction (e.g., 18:00 - 08:00)
Time.compare(current_time, start_time) in [:gt, :eq] or
Time.before?(current_time, end_time)
else
# Same-day restriction (e.g., 09:00 - 17:00)
Time.compare(current_time, start_time) in [:gt, :eq] and
Time.before?(current_time, end_time)
end
end
defp within_restriction?(_layer, _datetime), do: true
defp compute_rotation(%{members: []}, _datetime), do: nil
defp compute_rotation(layer, datetime) do
members = Enum.sort_by(layer.members, & &1.position)
member_count = length(members)
if member_count == 0 do
nil
else
elapsed_seconds = DateTime.diff(datetime, layer.start_date, :second)
rotation_seconds = rotation_duration_seconds(layer)
position = elapsed_seconds |> div(rotation_seconds) |> rem(member_count)
member = Enum.at(members, position)
{:ok, member.user}
end
end
defp rotation_duration_seconds(%{rotation_type: "daily", rotation_interval: interval}) do
interval * @seconds_per_day
end
defp rotation_duration_seconds(%{rotation_type: "weekly", rotation_interval: interval}) do
interval * @seconds_per_week
end
defp rotation_duration_seconds(%{rotation_type: "custom", rotation_interval: interval}) do
# Custom defaults to days
interval * @seconds_per_day
end
defp parse_time(time_string) when is_binary(time_string) do
{:ok, time} = Time.from_iso8601(time_string <> ":00")
time
end
end