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
121 lines
3.6 KiB
Elixir
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
|