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
This commit is contained in:
Graham McIntire 2026-03-11 12:32:54 -05:00
parent 3b97e437f2
commit 0cd2ed3567
No known key found for this signature in database
45 changed files with 5028 additions and 3 deletions

526
e2e/tests/schedules.spec.ts Normal file
View file

@ -0,0 +1,526 @@
import { test, expect } from '@playwright/test';
test.describe('On-Call Schedules & Escalation Policies', () => {
let consoleErrors: string[];
let pageErrors: Error[];
test.beforeEach(async ({ page }) => {
consoleErrors = [];
pageErrors = [];
page.on('console', msg => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
page.on('pageerror', error => {
pageErrors.push(error);
});
});
test.afterEach(async () => {
if (consoleErrors.length > 0) {
console.log('Console errors:', consoleErrors);
}
if (pageErrors.length > 0) {
console.log('Page errors:', pageErrors);
}
});
test.describe('Smoke Tests', () => {
test('schedules index loads without errors', async ({ page }) => {
const response = await page.goto('/schedules');
expect(response?.status()).toBeLessThan(500);
await page.waitForTimeout(1000);
await expect(page.locator('body')).toBeVisible();
});
test('new schedule form loads without errors', async ({ page }) => {
const response = await page.goto('/schedules/new');
expect(response?.status()).toBeLessThan(500);
await page.waitForTimeout(1000);
await expect(page.locator('body')).toBeVisible();
});
test('new escalation policy form loads without errors', async ({ page }) => {
const response = await page.goto('/schedules/escalation-policies/new');
expect(response?.status()).toBeLessThan(500);
await page.waitForTimeout(1000);
await expect(page.locator('body')).toBeVisible();
});
test('schedules page maintains socket connection', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(2000);
const hasSocketError = consoleErrors.some(err =>
err.includes('socket') ||
err.includes('Jason') ||
err.includes('encode')
);
expect(hasSocketError).toBe(false);
});
});
test.describe('Navigation', () => {
test('nav bar shows Schedules link', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForTimeout(500);
const schedulesLink = page.locator('a[href="/schedules"]').first();
if (await schedulesLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await expect(schedulesLink).toBeVisible();
}
});
test('can navigate to schedules page from nav', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForTimeout(500);
const schedulesLink = page.locator('a[href="/schedules"]').first();
if (await schedulesLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await schedulesLink.click();
await page.waitForTimeout(500);
await expect(page).toHaveURL(/\/schedules/);
}
});
});
test.describe('Schedules Tab', () => {
test('schedules page shows tabs for Schedules and Escalation Policies', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(500);
const schedulesTab = page.getByText('Schedules', { exact: true }).first();
const policiesTab = page.getByText('Escalation Policies', { exact: true }).first();
await expect(schedulesTab).toBeVisible();
await expect(policiesTab).toBeVisible();
});
test('schedules tab shows empty state or schedule list', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(500);
// Either shows empty state or a table of schedules
const emptyState = page.getByText('No schedules');
const scheduleTable = page.locator('table');
const hasEmptyState = await emptyState.isVisible({ timeout: 2000 }).catch(() => false);
const hasTable = await scheduleTable.isVisible({ timeout: 2000 }).catch(() => false);
// One of these should be visible
expect(hasEmptyState || hasTable).toBe(true);
});
test('can navigate to new schedule form', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(500);
const newButton = page.locator('a[href="/schedules/new"]').first();
if (await newButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await newButton.click();
await page.waitForTimeout(500);
await expect(page).toHaveURL(/\/schedules\/new/);
}
});
test('new schedule form has required fields', async ({ page }) => {
await page.goto('/schedules/new');
await page.waitForTimeout(500);
// Check for name input
const nameInput = page.locator('input[name*="name"]').first();
await expect(nameInput).toBeVisible();
// Check for timezone input
const timezoneInput = page.locator('input[name*="timezone"]').first();
await expect(timezoneInput).toBeVisible();
// Check for save button
const saveButton = page.getByText('Save Schedule');
await expect(saveButton).toBeVisible();
});
test('can create a new schedule', async ({ page }) => {
await page.goto('/schedules/new');
await page.waitForTimeout(500);
const nameInput = page.locator('input[name*="name"]').first();
const timezoneInput = page.locator('input[name*="timezone"]').first();
if (await nameInput.isVisible({ timeout: 2000 }).catch(() => false)) {
const scheduleName = `E2E Test Schedule ${Date.now()}`;
await nameInput.fill(scheduleName);
await timezoneInput.fill('America/Chicago');
const saveButton = page.getByText('Save Schedule');
if (await saveButton.isVisible()) {
await saveButton.click();
await page.waitForTimeout(1000);
// Should redirect to the schedule show page
await expect(page).toHaveURL(/\/schedules\/[a-f0-9-]+$/);
}
}
});
test('new schedule form has back link to index', async ({ page }) => {
await page.goto('/schedules/new');
await page.waitForTimeout(500);
// Back arrow link
const backLink = page.locator('a[href="/schedules"]').first();
await expect(backLink).toBeVisible();
});
});
test.describe('Schedule Show Page', () => {
test('schedule show page displays schedule details', async ({ page }) => {
// First, create a schedule or navigate to an existing one
await page.goto('/schedules');
await page.waitForTimeout(500);
// Look for a schedule row in the table
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await scheduleRow.click();
await page.waitForTimeout(500);
// Should show schedule name and on-call status
const onCallSection = page.getByText('Currently On-Call');
await expect(onCallSection).toBeVisible();
// Should show layers section
const layersSection = page.getByText('Layers', { exact: true });
await expect(layersSection).toBeVisible();
// Should show overrides section
const overridesSection = page.getByText('Overrides', { exact: true });
await expect(overridesSection).toBeVisible();
}
});
test('schedule show page has edit and delete buttons', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(500);
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await scheduleRow.click();
await page.waitForTimeout(500);
const editLink = page.getByText('Edit', { exact: true });
const deleteButton = page.getByText('Delete', { exact: true });
if (await editLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await expect(editLink).toBeVisible();
}
if (await deleteButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await expect(deleteButton).toBeVisible();
}
}
});
test('can navigate to edit schedule from show page', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(500);
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await scheduleRow.click();
await page.waitForTimeout(500);
const editLink = page.locator('a[href*="/edit"]').first();
if (await editLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await editLink.click();
await page.waitForTimeout(500);
await expect(page).toHaveURL(/\/schedules\/[a-f0-9-]+\/edit/);
}
}
});
test('back button on show page goes to index', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(500);
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await scheduleRow.click();
await page.waitForTimeout(500);
const backLink = page.locator('a[href="/schedules"]').first();
if (await backLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await backLink.click();
await page.waitForTimeout(500);
await expect(page).toHaveURL(/\/schedules$/);
}
}
});
test('can toggle the Add Layer form', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(500);
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await scheduleRow.click();
await page.waitForTimeout(500);
const addLayerButton = page.getByText('Add Layer', { exact: true }).first();
if (await addLayerButton.isVisible({ timeout: 2000 }).catch(() => false)) {
// Click to show the form
await addLayerButton.click();
await page.waitForTimeout(500);
// Layer form should now be visible with fields
const layerNameInput = page.locator('input[name*="layer"][name*="name"]').first();
const formVisible = await layerNameInput.isVisible({ timeout: 2000 }).catch(() => false);
if (formVisible) {
await expect(layerNameInput).toBeVisible();
// Click cancel to hide
const cancelButton = page.locator('button:has-text("Cancel")').first();
if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await cancelButton.click();
await page.waitForTimeout(500);
}
}
}
}
});
test('can toggle the Add Override form', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(500);
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await scheduleRow.click();
await page.waitForTimeout(500);
const addOverrideButton = page.getByText('Add Override', { exact: true }).first();
if (await addOverrideButton.isVisible({ timeout: 2000 }).catch(() => false)) {
// Click to show the form
await addOverrideButton.click();
await page.waitForTimeout(500);
// Override form should have datetime fields
const startTimeInput = page.locator('input[name*="start_time"]').first();
const formVisible = await startTimeInput.isVisible({ timeout: 2000 }).catch(() => false);
if (formVisible) {
await expect(startTimeInput).toBeVisible();
// Click cancel to hide
const cancelButton = page.locator('button:has-text("Cancel")').first();
if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await cancelButton.click();
await page.waitForTimeout(500);
}
}
}
}
});
});
test.describe('Escalation Policies Tab', () => {
test('can switch to escalation policies tab', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(500);
const policiesTab = page.locator('a[href*="tab=escalation-policies"]').first();
if (await policiesTab.isVisible({ timeout: 2000 }).catch(() => false)) {
await policiesTab.click();
await page.waitForTimeout(500);
await expect(page).toHaveURL(/tab=escalation-policies/);
}
});
test('escalation policies tab shows empty state or policy list', async ({ page }) => {
await page.goto('/schedules?tab=escalation-policies');
await page.waitForTimeout(500);
const emptyState = page.getByText('No escalation policies');
const policyTable = page.locator('table');
const hasEmptyState = await emptyState.isVisible({ timeout: 2000 }).catch(() => false);
const hasTable = await policyTable.isVisible({ timeout: 2000 }).catch(() => false);
expect(hasEmptyState || hasTable).toBe(true);
});
test('can navigate to new escalation policy form', async ({ page }) => {
await page.goto('/schedules?tab=escalation-policies');
await page.waitForTimeout(500);
const newButton = page.locator('a[href*="/schedules/escalation-policies/new"]').first();
if (await newButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await newButton.click();
await page.waitForTimeout(500);
await expect(page).toHaveURL(/\/schedules\/escalation-policies\/new/);
}
});
test('new escalation policy form has required fields', async ({ page }) => {
await page.goto('/schedules/escalation-policies/new');
await page.waitForTimeout(500);
const nameInput = page.locator('input[name*="name"]').first();
await expect(nameInput).toBeVisible();
const repeatInput = page.locator('input[name*="repeat_count"]').first();
await expect(repeatInput).toBeVisible();
const saveButton = page.getByText('Save Policy');
await expect(saveButton).toBeVisible();
});
test('can create a new escalation policy', async ({ page }) => {
await page.goto('/schedules/escalation-policies/new');
await page.waitForTimeout(500);
const nameInput = page.locator('input[name*="name"]').first();
if (await nameInput.isVisible({ timeout: 2000 }).catch(() => false)) {
const policyName = `E2E Test Policy ${Date.now()}`;
await nameInput.fill(policyName);
const saveButton = page.getByText('Save Policy');
if (await saveButton.isVisible()) {
await saveButton.click();
await page.waitForTimeout(1000);
// Should redirect to the policy show page
await expect(page).toHaveURL(/\/schedules\/escalation-policies\/[a-f0-9-]+$/);
}
}
});
test('new escalation policy form has back link', async ({ page }) => {
await page.goto('/schedules/escalation-policies/new');
await page.waitForTimeout(500);
const backLink = page.locator('a[href*="tab=escalation-policies"]').first();
await expect(backLink).toBeVisible();
});
});
test.describe('Escalation Policy Show Page', () => {
test('policy show page displays policy details', async ({ page }) => {
await page.goto('/schedules?tab=escalation-policies');
await page.waitForTimeout(500);
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await policyRow.click();
await page.waitForTimeout(500);
// Should show the policy name
const heading = page.locator('h1').first();
await expect(heading).toBeVisible();
// Should show escalation rules section
const rulesSection = page.getByText('Escalation Rules');
if (await rulesSection.isVisible({ timeout: 2000 }).catch(() => false)) {
await expect(rulesSection).toBeVisible();
}
}
});
test('policy show page has edit and delete buttons', async ({ page }) => {
await page.goto('/schedules?tab=escalation-policies');
await page.waitForTimeout(500);
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await policyRow.click();
await page.waitForTimeout(500);
const editLink = page.getByText('Edit', { exact: true });
const deleteButton = page.getByText('Delete', { exact: true });
if (await editLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await expect(editLink).toBeVisible();
}
if (await deleteButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await expect(deleteButton).toBeVisible();
}
}
});
test('can navigate to edit policy from show page', async ({ page }) => {
await page.goto('/schedules?tab=escalation-policies');
await page.waitForTimeout(500);
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await policyRow.click();
await page.waitForTimeout(500);
const editLink = page.locator('a[href*="/edit"]').first();
if (await editLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await editLink.click();
await page.waitForTimeout(500);
await expect(page).toHaveURL(/\/schedules\/escalation-policies\/[a-f0-9-]+\/edit/);
}
}
});
test('back button on policy show page goes to escalation policies tab', async ({ page }) => {
await page.goto('/schedules?tab=escalation-policies');
await page.waitForTimeout(500);
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await policyRow.click();
await page.waitForTimeout(500);
const backLink = page.locator('a[href*="tab=escalation-policies"]').first();
if (await backLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await backLink.click();
await page.waitForTimeout(500);
await expect(page).toHaveURL(/tab=escalation-policies/);
}
}
});
test('can toggle the Add Rule form on policy show page', async ({ page }) => {
await page.goto('/schedules?tab=escalation-policies');
await page.waitForTimeout(500);
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await policyRow.click();
await page.waitForTimeout(500);
const addRuleButton = page.getByText('Add Rule', { exact: true }).first();
if (await addRuleButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await addRuleButton.click();
await page.waitForTimeout(500);
// Rule form should be visible with timeout field
const timeoutInput = page.locator('input[name="timeout_minutes"]').first();
const formVisible = await timeoutInput.isVisible({ timeout: 2000 }).catch(() => false);
if (formVisible) {
await expect(timeoutInput).toBeVisible();
// Click cancel to hide
const cancelButton = page.locator('button:has-text("Cancel")').first();
if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await cancelButton.click();
await page.waitForTimeout(500);
}
}
}
}
});
});
});

View file

@ -68,6 +68,7 @@ defmodule Towerops.Devices.Device do
belongs_to :site, Site
belongs_to :organization, Organization
belongs_to :escalation_policy, Towerops.OnCall.EscalationPolicy
has_one :snmp_device, SnmpDevice
has_many :agent_assignments, AgentAssignment
@ -119,6 +120,7 @@ defmodule Towerops.Devices.Device do
organization: NotLoaded.t() | Organization.t() | nil,
snmp_device: NotLoaded.t() | SnmpDevice.t() | nil,
agent_assignments: NotLoaded.t() | [AgentAssignment.t()],
escalation_policy_id: Ecto.UUID.t() | nil,
outbound_links: NotLoaded.t() | [DeviceLink.t()],
inbound_links: NotLoaded.t() | [DeviceLink.t()],
inserted_at: DateTime.t(),
@ -164,7 +166,8 @@ defmodule Towerops.Devices.Device do
:mikrotik_enabled,
:mikrotik_credential_source,
:device_role,
:device_role_source
:device_role_source,
:escalation_policy_id
])
|> validate_required([:ip_address, :organization_id])
|> validate_name()
@ -182,6 +185,7 @@ defmodule Towerops.Devices.Device do
|> update_mikrotik_credential_source()
|> foreign_key_constraint(:site_id)
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:escalation_policy_id)
end
defp validate_name(changeset) do

182
lib/towerops/on_call.ex Normal file
View file

@ -0,0 +1,182 @@
defmodule Towerops.OnCall do
@moduledoc """
Context for on-call schedules and escalation policies.
"""
import Ecto.Query
alias Towerops.OnCall.EscalationPolicy
alias Towerops.OnCall.EscalationRule
alias Towerops.OnCall.EscalationTarget
alias Towerops.OnCall.Layer
alias Towerops.OnCall.LayerMember
alias Towerops.OnCall.Override
alias Towerops.OnCall.Resolver
alias Towerops.OnCall.Schedule
alias Towerops.Repo
# --- Schedules ---
def list_schedules(organization_id) do
Schedule
|> where([s], s.organization_id == ^organization_id)
|> order_by([s], asc: s.name)
|> Repo.all()
end
def get_schedule!(id) do
Schedule
|> Repo.get!(id)
|> Repo.preload(overrides: :user, layers: [members: :user])
end
def get_schedule(id) do
case Repo.get(Schedule, id) do
nil -> nil
schedule -> Repo.preload(schedule, overrides: :user, layers: [members: :user])
end
end
def create_schedule(attrs) do
%Schedule{}
|> Schedule.changeset(attrs)
|> Repo.insert()
end
def update_schedule(%Schedule{} = schedule, attrs) do
schedule
|> Schedule.changeset(attrs)
|> Repo.update()
end
def delete_schedule(%Schedule{} = schedule) do
Repo.delete(schedule)
end
def change_schedule(%Schedule{} = schedule, attrs \\ %{}) do
Schedule.changeset(schedule, attrs)
end
# --- Layers ---
def create_layer(attrs) do
%Layer{}
|> Layer.changeset(attrs)
|> Repo.insert()
end
def update_layer(%Layer{} = layer, attrs) do
layer
|> Layer.changeset(attrs)
|> Repo.update()
end
def delete_layer(%Layer{} = layer) do
Repo.delete(layer)
end
# --- Layer Members ---
def add_layer_member(attrs) do
%LayerMember{}
|> LayerMember.changeset(attrs)
|> Repo.insert()
end
def remove_layer_member(%LayerMember{} = member) do
Repo.delete(member)
end
# --- Overrides ---
def create_override(attrs) do
%Override{}
|> Override.changeset(attrs)
|> Repo.insert()
end
def delete_override(%Override{} = override) do
Repo.delete(override)
end
# --- Escalation Policies ---
def list_escalation_policies(organization_id) do
EscalationPolicy
|> where([p], p.organization_id == ^organization_id)
|> order_by([p], asc: p.name)
|> Repo.all()
end
def get_escalation_policy!(id) do
EscalationPolicy
|> Repo.get!(id)
|> Repo.preload(rules: :targets)
end
def get_escalation_policy(id) do
case Repo.get(EscalationPolicy, id) do
nil -> nil
policy -> Repo.preload(policy, rules: :targets)
end
end
def create_escalation_policy(attrs) do
%EscalationPolicy{}
|> EscalationPolicy.changeset(attrs)
|> Repo.insert()
end
def update_escalation_policy(%EscalationPolicy{} = policy, attrs) do
policy
|> EscalationPolicy.changeset(attrs)
|> Repo.update()
end
def delete_escalation_policy(%EscalationPolicy{} = policy) do
Repo.delete(policy)
end
def change_escalation_policy(%EscalationPolicy{} = policy, attrs \\ %{}) do
EscalationPolicy.changeset(policy, attrs)
end
# --- Escalation Rules ---
def create_escalation_rule(attrs) do
%EscalationRule{}
|> EscalationRule.changeset(attrs)
|> Repo.insert()
end
def update_escalation_rule(%EscalationRule{} = rule, attrs) do
rule
|> EscalationRule.changeset(attrs)
|> Repo.update()
end
def delete_escalation_rule(%EscalationRule{} = rule) do
Repo.delete(rule)
end
# --- Escalation Targets ---
def create_escalation_target(attrs) do
%EscalationTarget{}
|> EscalationTarget.changeset(attrs)
|> Repo.insert()
end
def delete_escalation_target(%EscalationTarget{} = target) do
Repo.delete(target)
end
# --- On-Call Resolution ---
def who_is_on_call(schedule_id, datetime \\ DateTime.utc_now()) do
case get_schedule(schedule_id) do
nil -> nil
schedule -> Resolver.resolve(schedule, datetime)
end
end
end

View file

@ -0,0 +1,213 @@
defmodule Towerops.OnCall.Escalation do
@moduledoc """
Manages the escalation lifecycle for on-call incidents.
When an alert fires:
1. Creates an incident linked to the alert and escalation policy
2. Resolves targets at the current rule position
3. Sends notifications to on-call users
4. Schedules a check for escalation timeout
"""
import Ecto.Query
alias Towerops.OnCall
alias Towerops.OnCall.Incident
alias Towerops.OnCall.Notification
alias Towerops.OnCall.Notifier
alias Towerops.Repo
alias Towerops.Workers.EscalationCheckWorker
require Logger
@doc """
Starts escalation for an alert using the given escalation policy.
Creates an incident, sends notifications to the first rule's targets,
and schedules an escalation check.
"""
def start_escalation(_alert, nil), do: {:error, :no_policy}
def start_escalation(_alert, ""), do: {:error, :no_policy}
def start_escalation(alert, policy_id) do
case OnCall.get_escalation_policy(policy_id) do
nil ->
{:error, :no_policy}
policy ->
now = DateTime.truncate(DateTime.utc_now(), :second)
attrs = %{
alert_id: alert.id,
escalation_policy_id: policy.id,
organization_id: alert.organization_id,
status: "triggered",
triggered_at: now,
current_rule_position: 0,
current_loop: 1
}
with {:ok, incident} <- create_incident(attrs) do
execute_current_rule(incident, policy)
schedule_escalation_check(incident, policy)
{:ok, incident}
end
end
end
@doc """
Acknowledges an incident, stopping further escalation.
"""
def acknowledge_incident(incident_id, user_id) do
now = DateTime.truncate(DateTime.utc_now(), :second)
Incident
|> Repo.get!(incident_id)
|> Incident.changeset(%{
status: "acknowledged",
acknowledged_at: now,
acknowledged_by_id: user_id
})
|> Repo.update()
end
@doc """
Resolves an incident.
"""
def resolve_incident(incident_id, user_id) do
now = DateTime.truncate(DateTime.utc_now(), :second)
Incident
|> Repo.get!(incident_id)
|> Incident.changeset(%{
status: "resolved",
resolved_at: now,
resolved_by_id: user_id
})
|> Repo.update()
end
@doc """
Finds the active (triggered or acknowledged) incident for an alert.
"""
def find_incident_for_alert(alert_id) do
Incident
|> where([i], i.alert_id == ^alert_id and i.status in ["triggered", "acknowledged"])
|> order_by([i], desc: :triggered_at)
|> limit(1)
|> Repo.one()
end
@doc """
Called by EscalationCheckWorker. Checks if the incident needs to escalate
to the next rule.
"""
def check_and_escalate(incident_id) do
case Repo.get(Incident, incident_id) do
nil ->
:ok
%{status: status} when status in ["acknowledged", "resolved"] ->
:ok
incident ->
policy = OnCall.get_escalation_policy!(incident.escalation_policy_id)
advance_escalation(incident, policy)
end
end
# --- Private ---
defp create_incident(attrs) do
%Incident{}
|> Incident.changeset(attrs)
|> Repo.insert()
end
defp execute_current_rule(incident, policy) do
rule = Enum.find(policy.rules, &(&1.position == incident.current_rule_position))
if rule do
users = resolve_targets(rule.targets)
Enum.each(users, fn user ->
Notifier.notify(user, incident)
log_notification(incident, user)
end)
end
end
defp resolve_targets(targets) do
targets
|> Enum.flat_map(&resolve_target/1)
|> Enum.uniq_by(& &1.id)
end
defp resolve_target(%{target_type: "user", user_id: user_id}) do
case Repo.get(Towerops.Accounts.User, user_id) do
nil -> []
user -> [user]
end
end
defp resolve_target(%{target_type: "schedule", schedule_id: schedule_id}) do
case OnCall.who_is_on_call(schedule_id) do
nil -> []
user -> [user]
end
end
defp log_notification(incident, user) do
now = DateTime.truncate(DateTime.utc_now(), :second)
%Notification{}
|> Notification.changeset(%{
incident_id: incident.id,
user_id: user.id,
channel: "email",
status: "sent",
sent_at: now
})
|> Repo.insert()
end
defp schedule_escalation_check(incident, policy) do
rule = Enum.find(policy.rules, &(&1.position == incident.current_rule_position))
timeout_seconds = (rule && rule.timeout_minutes * 60) || 1800
%{incident_id: incident.id}
|> EscalationCheckWorker.new(schedule_in: timeout_seconds)
|> Oban.insert()
end
defp advance_escalation(incident, policy) do
rules = Enum.sort_by(policy.rules, & &1.position)
max_position = if rules == [], do: 0, else: List.last(rules).position
cond do
incident.current_rule_position < max_position ->
next_position = incident.current_rule_position + 1
{:ok, updated} =
incident
|> Incident.changeset(%{current_rule_position: next_position})
|> Repo.update()
execute_current_rule(updated, policy)
schedule_escalation_check(updated, policy)
incident.current_loop < policy.repeat_count ->
{:ok, updated} =
incident
|> Incident.changeset(%{current_rule_position: 0, current_loop: incident.current_loop + 1})
|> Repo.update()
execute_current_rule(updated, policy)
schedule_escalation_check(updated, policy)
true ->
Logger.info("Escalation exhausted for incident #{incident.id}")
:ok
end
end
end

View file

@ -0,0 +1,36 @@
defmodule Towerops.OnCall.EscalationPolicy do
@moduledoc """
Schema for escalation policies that define notification rules.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.OnCall.EscalationRule
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "escalation_policies" do
field :name, :string
field :description, :string
field :repeat_count, :integer, default: 3
belongs_to :organization, Organization
has_many :rules, EscalationRule, preload_order: [asc: :position]
timestamps(type: :utc_datetime)
end
@required_fields ~w(name organization_id)a
@optional_fields ~w(description repeat_count)a
def changeset(policy, attrs) do
policy
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_number(:repeat_count, greater_than: 0)
|> foreign_key_constraint(:organization_id)
end
end

View file

@ -0,0 +1,35 @@
defmodule Towerops.OnCall.EscalationRule do
@moduledoc """
Schema for a step within an escalation policy.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.OnCall.EscalationPolicy
alias Towerops.OnCall.EscalationTarget
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "escalation_rules" do
field :position, :integer, default: 0
field :timeout_minutes, :integer, default: 30
belongs_to :escalation_policy, EscalationPolicy
has_many :targets, EscalationTarget
timestamps(type: :utc_datetime)
end
@required_fields ~w(position timeout_minutes escalation_policy_id)a
def changeset(rule, attrs) do
rule
|> cast(attrs, @required_fields)
|> validate_required(@required_fields)
|> validate_number(:timeout_minutes, greater_than: 0)
|> validate_number(:position, greater_than_or_equal_to: 0)
|> foreign_key_constraint(:escalation_policy_id)
end
end

View file

@ -0,0 +1,39 @@
defmodule Towerops.OnCall.EscalationTarget do
@moduledoc """
Schema for notification targets at each escalation step.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Accounts.User
alias Towerops.OnCall.EscalationRule
alias Towerops.OnCall.Schedule
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "escalation_targets" do
field :target_type, :string
belongs_to :escalation_rule, EscalationRule
belongs_to :schedule, Schedule
belongs_to :user, User
timestamps(type: :utc_datetime)
end
@valid_target_types ~w(schedule user)
@required_fields ~w(target_type escalation_rule_id)a
@optional_fields ~w(schedule_id user_id)a
def changeset(target, attrs) do
target
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_inclusion(:target_type, @valid_target_types)
|> foreign_key_constraint(:escalation_rule_id)
|> foreign_key_constraint(:schedule_id)
|> foreign_key_constraint(:user_id)
end
end

View file

@ -0,0 +1,50 @@
defmodule Towerops.OnCall.Incident do
@moduledoc """
Schema for tracking active escalation state for an alert.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Accounts.User
alias Towerops.Alerts.Alert
alias Towerops.OnCall.EscalationPolicy
alias Towerops.OnCall.Notification
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "on_call_incidents" do
field :status, :string, default: "triggered"
field :current_rule_position, :integer, default: 0
field :current_loop, :integer, default: 1
field :triggered_at, :utc_datetime
field :acknowledged_at, :utc_datetime
field :resolved_at, :utc_datetime
belongs_to :alert, Alert
belongs_to :escalation_policy, EscalationPolicy
belongs_to :organization, Organization
belongs_to :acknowledged_by, User
belongs_to :resolved_by, User
has_many :notifications, Notification
timestamps(type: :utc_datetime)
end
@valid_statuses ~w(triggered acknowledged resolved)
@required_fields ~w(status triggered_at alert_id escalation_policy_id organization_id)a
@optional_fields ~w(current_rule_position current_loop acknowledged_at resolved_at acknowledged_by_id resolved_by_id)a
def changeset(incident, attrs) do
incident
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_inclusion(:status, @valid_statuses)
|> foreign_key_constraint(:alert_id)
|> foreign_key_constraint(:escalation_policy_id)
|> foreign_key_constraint(:organization_id)
end
end

View file

@ -0,0 +1,50 @@
defmodule Towerops.OnCall.Layer do
@moduledoc """
Schema for a rotation layer within an on-call schedule.
Layers stack higher position layers override lower layers where they have coverage.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.OnCall.LayerMember
alias Towerops.OnCall.Schedule
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "on_call_layers" do
field :name, :string
field :position, :integer, default: 0
field :rotation_type, :string
field :rotation_interval, :integer, default: 1
field :handoff_time, :time
field :handoff_day, :integer
field :start_date, :utc_datetime
field :restriction_type, :string
field :restrictions, :map
belongs_to :schedule, Schedule
has_many :members, LayerMember, preload_order: [asc: :position]
timestamps(type: :utc_datetime)
end
@valid_rotation_types ~w(daily weekly custom)
@valid_restriction_types ~w(time_of_day time_of_week)
@required_fields ~w(name position rotation_type rotation_interval handoff_time start_date schedule_id)a
@optional_fields ~w(handoff_day restriction_type restrictions)a
def changeset(layer, attrs) do
layer
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_inclusion(:rotation_type, @valid_rotation_types)
|> validate_inclusion(:restriction_type, @valid_restriction_types)
|> validate_number(:rotation_interval, greater_than: 0)
|> validate_number(:handoff_day, greater_than_or_equal_to: 0, less_than_or_equal_to: 6)
|> foreign_key_constraint(:schedule_id)
end
end

View file

@ -0,0 +1,34 @@
defmodule Towerops.OnCall.LayerMember do
@moduledoc """
Schema for a user's position in a layer's rotation.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Accounts.User
alias Towerops.OnCall.Layer
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "on_call_layer_members" do
field :position, :integer, default: 0
belongs_to :layer, Layer
belongs_to :user, User
timestamps(type: :utc_datetime)
end
@required_fields ~w(position layer_id user_id)a
def changeset(member, attrs) do
member
|> cast(attrs, @required_fields)
|> validate_required(@required_fields)
|> validate_number(:position, greater_than_or_equal_to: 0)
|> foreign_key_constraint(:layer_id)
|> foreign_key_constraint(:user_id)
|> unique_constraint([:layer_id, :user_id])
end
end

View file

@ -0,0 +1,38 @@
defmodule Towerops.OnCall.Notification do
@moduledoc """
Schema for logging sent on-call notifications.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Accounts.User
alias Towerops.OnCall.Incident
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "on_call_notifications" do
field :channel, :string, default: "email"
field :sent_at, :utc_datetime
field :status, :string, default: "sent"
field :error_message, :string
belongs_to :incident, Incident
belongs_to :user, User
timestamps(type: :utc_datetime)
end
@required_fields ~w(channel status incident_id user_id)a
@optional_fields ~w(sent_at error_message)a
def changeset(notification, attrs) do
notification
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_inclusion(:channel, ~w(email))
|> validate_inclusion(:status, ~w(sent failed))
|> foreign_key_constraint(:incident_id)
|> foreign_key_constraint(:user_id)
end
end

View file

@ -0,0 +1,44 @@
defmodule Towerops.OnCall.Notifier do
@moduledoc """
Sends on-call alert notifications to users via email.
"""
import Swoosh.Email
alias Towerops.Mailer
require Logger
@doc """
Sends an alert notification email to the given user about an incident.
"""
def notify(user, incident) do
from_address = Application.get_env(:towerops, :mailer_from, {"Towerops", "hi@towerops.net"})
email =
new()
|> to(user.email)
|> from(from_address)
|> subject("[Towerops Alert] Incident triggered")
|> text_body("""
An alert has been triggered and you are on-call.
Incident ID: #{incident.id}
Status: #{incident.status}
Triggered at: #{incident.triggered_at}
Please acknowledge or resolve this incident in Towerops.
""")
case Mailer.deliver(email) do
{:ok, _} ->
Logger.info("On-call notification sent to #{user.email} for incident #{incident.id}")
:ok
{:error, reason} ->
Logger.error("Failed to send on-call notification to #{user.email}: #{inspect(reason)}")
{:error, reason}
end
end
end

View file

@ -0,0 +1,48 @@
defmodule Towerops.OnCall.Override do
@moduledoc """
Schema for temporary on-call overrides on a schedule.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Accounts.User
alias Towerops.OnCall.Schedule
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "on_call_overrides" do
field :start_time, :utc_datetime
field :end_time, :utc_datetime
belongs_to :schedule, Schedule
belongs_to :user, User
belongs_to :created_by, User
timestamps(type: :utc_datetime)
end
@required_fields ~w(start_time end_time schedule_id user_id)a
@optional_fields ~w(created_by_id)a
def changeset(override, attrs) do
override
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_end_after_start()
|> foreign_key_constraint(:schedule_id)
|> foreign_key_constraint(:user_id)
|> foreign_key_constraint(:created_by_id)
end
defp validate_end_after_start(changeset) do
start_time = get_field(changeset, :start_time)
end_time = get_field(changeset, :end_time)
if start_time && end_time && DateTime.compare(end_time, start_time) != :gt do
add_error(changeset, :end_time, "must be after start time")
else
changeset
end
end
end

View file

@ -0,0 +1,121 @@
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

View file

@ -0,0 +1,37 @@
defmodule Towerops.OnCall.Schedule do
@moduledoc """
Schema for on-call schedules containing rotation layers.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.OnCall.Layer
alias Towerops.OnCall.Override
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "on_call_schedules" do
field :name, :string
field :description, :string
field :timezone, :string
belongs_to :organization, Organization
has_many :layers, Layer, preload_order: [asc: :position]
has_many :overrides, Override
timestamps(type: :utc_datetime)
end
@required_fields ~w(name timezone organization_id)a
@optional_fields ~w(description)a
def changeset(schedule, attrs) do
schedule
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> foreign_key_constraint(:organization_id)
end
end

View file

@ -67,6 +67,7 @@ defmodule Towerops.Organizations.Organization do
field :device_count, :integer, virtual: true
belongs_to :default_agent_token, AgentToken
belongs_to :default_escalation_policy, Towerops.OnCall.EscalationPolicy
has_many :memberships, Membership
has_many :users, through: [:memberships, :user]
@ -110,6 +111,7 @@ defmodule Towerops.Organizations.Organization do
last_synced_device_count: integer() | nil,
default_agent_token_id: Ecto.UUID.t() | nil,
default_agent_token: NotLoaded.t() | AgentToken.t() | nil,
default_escalation_policy_id: Ecto.UUID.t() | nil,
memberships: NotLoaded.t() | [Membership.t()],
users: NotLoaded.t() | [Towerops.Accounts.User.t()],
invitations: NotLoaded.t() | [Invitation.t()],
@ -148,7 +150,8 @@ defmodule Towerops.Organizations.Organization do
:subscription_current_period_end,
:payment_method_status,
:last_billing_sync_at,
:last_synced_device_count
:last_synced_device_count,
:default_escalation_policy_id
])
|> validate_required([:name])
|> validate_length(:name, min: 2, max: 100)
@ -161,6 +164,7 @@ defmodule Towerops.Organizations.Organization do
|> validate_required([:slug])
|> unique_constraint(:slug)
|> foreign_key_constraint(:default_agent_token_id)
|> foreign_key_constraint(:default_escalation_policy_id)
end
@doc """

View file

@ -12,6 +12,7 @@ defmodule Towerops.Workers.AlertNotificationWorker do
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.OnCall.Escalation
alias Towerops.PagerDuty.Notifier
require Logger
@ -20,7 +21,9 @@ defmodule Towerops.Workers.AlertNotificationWorker do
def perform(%Oban.Job{args: %{"action" => "trigger", "alert_id" => alert_id}}) do
with {:ok, alert} <- fetch_alert(alert_id),
{:ok, device} <- fetch_device(alert.device_id) do
Notifier.notify_trigger(alert, device)
pagerduty_result = Notifier.notify_trigger(alert, device)
maybe_start_escalation(alert, device)
pagerduty_result
else
{:error, :alert_not_found} ->
Logger.warning("Alert #{alert_id} not found for notification")
@ -32,6 +35,13 @@ defmodule Towerops.Workers.AlertNotificationWorker do
{:error, :not_configured} ->
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping notification")
# Still try built-in escalation even if PagerDuty isn't configured
with {:ok, alert} <- fetch_alert(alert_id),
{:ok, device} <- fetch_device(alert.device_id) do
maybe_start_escalation(alert, device)
end
:ok
{:error, reason} ->
@ -43,6 +53,8 @@ defmodule Towerops.Workers.AlertNotificationWorker do
def perform(%Oban.Job{args: %{"action" => "acknowledge", "alert_id" => alert_id}}) do
case fetch_alert(alert_id) do
{:ok, alert} ->
maybe_acknowledge_incident(alert)
case Notifier.notify_acknowledge(alert) do
{:error, :not_configured} ->
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping notification")
@ -65,6 +77,8 @@ defmodule Towerops.Workers.AlertNotificationWorker do
def perform(%Oban.Job{args: %{"action" => "resolve", "alert_id" => alert_id}}) do
case fetch_alert(alert_id) do
{:ok, alert} ->
maybe_resolve_incident(alert)
case Notifier.notify_resolve(alert) do
{:error, :not_configured} ->
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping notification")
@ -103,6 +117,46 @@ defmodule Towerops.Workers.AlertNotificationWorker do
|> Oban.insert()
end
defp maybe_start_escalation(alert, device) do
policy_id = device.escalation_policy_id || device.organization.default_escalation_policy_id
if policy_id do
case Escalation.start_escalation(alert, policy_id) do
{:ok, _incident} ->
Logger.info("Started built-in escalation for alert #{alert.id}")
{:error, reason} ->
Logger.warning("Failed to start escalation for alert #{alert.id}: #{inspect(reason)}")
end
end
end
defp maybe_acknowledge_incident(alert) do
case Escalation.find_incident_for_alert(alert.id) do
nil ->
:ok
incident ->
case Escalation.acknowledge_incident(incident.id, nil) do
{:ok, _} -> Logger.info("Acknowledged incident #{incident.id} for alert #{alert.id}")
{:error, reason} -> Logger.warning("Failed to acknowledge incident: #{inspect(reason)}")
end
end
end
defp maybe_resolve_incident(alert) do
case Escalation.find_incident_for_alert(alert.id) do
nil ->
:ok
incident ->
case Escalation.resolve_incident(incident.id, nil) do
{:ok, _} -> Logger.info("Resolved incident #{incident.id} for alert #{alert.id}")
{:error, reason} -> Logger.warning("Failed to resolve incident: #{inspect(reason)}")
end
end
end
defp fetch_alert(alert_id) do
case Alerts.get_alert(alert_id) do
nil -> {:error, :alert_not_found}

View file

@ -0,0 +1,18 @@
defmodule Towerops.Workers.EscalationCheckWorker do
@moduledoc """
Oban worker that checks if an on-call incident needs to escalate
to the next rule after the timeout period.
"""
use Oban.Worker,
queue: :notifications,
max_attempts: 3,
priority: 1
alias Towerops.OnCall.Escalation
@impl Oban.Worker
def perform(%Oban.Job{args: %{"incident_id" => incident_id}}) do
Escalation.check_and_escalate(incident_id)
:ok
end
end

View file

@ -287,6 +287,12 @@ defmodule ToweropsWeb.Layouts do
>
{t("Maintenance")}
</.nav_link>
<.nav_link
navigate={~p"/schedules"}
active={@active_page == "schedules"}
>
{t("Schedules")}
</.nav_link>
<!-- Separator -->
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
@ -582,6 +588,9 @@ defmodule ToweropsWeb.Layouts do
<.mobile_nav_link navigate={~p"/maintenance"} active={@active_page == "maintenance"}>
<.icon name="hero-wrench-screwdriver" class="size-5" /> {t("Maintenance")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/schedules"} active={@active_page == "schedules"}>
<.icon name="hero-calendar-days" class="size-5" /> {t("Schedules")}
</.mobile_nav_link>
</div>
<!-- Secondary nav -->

View file

@ -6,6 +6,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
alias Towerops.Agents
alias Towerops.Devices
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.OnCall
alias Towerops.Organizations.SubscriptionLimits
alias Towerops.Repo
alias Towerops.Sites
@ -22,6 +23,9 @@ defmodule ToweropsWeb.DeviceLive.Form do
cloud_pollers = Agents.list_cloud_pollers()
all_agents = agents ++ cloud_pollers
# Load escalation policies for this organization
escalation_policies = OnCall.list_escalation_policies(organization.id)
# Load device quota
{current_devices, device_limit} =
SubscriptionLimits.device_quota(organization)
@ -38,6 +42,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
|> assign(:organization, organization)
|> assign(:available_sites, sites)
|> assign(:available_agents, all_agents)
|> assign(:escalation_policies, escalation_policies)
|> assign(:preselected_site_id, params["site_id"])
|> assign(:snmp_test_result, nil)
|> assign(:duplicate_device, nil)

View file

@ -299,6 +299,23 @@
<% end %>
</div>
<% end %>
<%= if @escalation_policies != [] do %>
<div class="col-span-full">
<.input
field={@form[:escalation_policy_id]}
type="select"
label={t("Escalation Policy")}
prompt={t("Inherit from organization default")}
options={Enum.map(@escalation_policies, &{&1.name, &1.id})}
/>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t(
"Select an escalation policy for alert notifications. Leave empty to use the organization default."
)}
</p>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,83 @@
defmodule ToweropsWeb.EscalationPolicyLive.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.OnCall
alias Towerops.OnCall.EscalationPolicy
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :active_page, "schedules")}
end
@impl true
def handle_params(params, _url, socket) do
case socket.assigns.live_action do
:new ->
policy = %EscalationPolicy{}
changeset = EscalationPolicy.changeset(policy, %{})
{:noreply,
socket
|> assign(:page_title, t("New Escalation Policy"))
|> assign(:policy, policy)
|> assign(:form, to_form(changeset))}
:edit ->
policy = OnCall.get_escalation_policy!(params["id"])
changeset = EscalationPolicy.changeset(policy, %{})
{:noreply,
socket
|> assign(:page_title, t("Edit Escalation Policy"))
|> assign(:policy, policy)
|> assign(:form, to_form(changeset))}
end
end
@impl true
def handle_event("validate", %{"escalation_policy" => params}, socket) do
changeset =
socket.assigns.policy
|> EscalationPolicy.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
def handle_event("save", %{"escalation_policy" => params}, socket) do
org_id = socket.assigns.current_scope.organization.id
params = Map.put(params, "organization_id", org_id)
case socket.assigns.live_action do
:new -> save_policy(socket, :create, params)
:edit -> save_policy(socket, :update, params)
end
end
defp save_policy(socket, :create, params) do
case OnCall.create_escalation_policy(params) do
{:ok, policy} ->
{:noreply,
socket
|> put_flash(:info, t("Escalation policy created"))
|> push_navigate(to: ~p"/schedules/escalation-policies/#{policy.id}")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp save_policy(socket, :update, params) do
case OnCall.update_escalation_policy(socket.assigns.policy, params) do
{:ok, policy} ->
{:noreply,
socket
|> put_flash(:info, t("Escalation policy updated"))
|> push_navigate(to: ~p"/schedules/escalation-policies/#{policy.id}")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
end

View file

@ -0,0 +1,46 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
>
<div class="flex items-center gap-3 mb-6">
<.link
navigate={~p"/schedules?tab=escalation-policies"}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<.icon name="hero-arrow-left" class="h-5 w-5" />
</.link>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@page_title}</h1>
</div>
<div class="max-w-2xl">
<.form for={@form} phx-change="validate" phx-submit="save" class="space-y-6">
<div>
<.input field={@form[:name]} type="text" label={t("Name")} required />
</div>
<div>
<.input field={@form[:description]} type="textarea" label={t("Description")} />
</div>
<div>
<.input
field={@form[:repeat_count]}
type="number"
label={t("Repeat Count")}
min="1"
max="10"
/>
</div>
<div class="flex items-center gap-3">
<.button type="submit" phx-disable-with={t("Saving...")}>
{t("Save Policy")}
</.button>
<.link
navigate={~p"/schedules?tab=escalation-policies"}
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
{t("Cancel")}
</.link>
</div>
</.form>
</div>
</Layouts.authenticated>

View file

@ -0,0 +1,18 @@
defmodule ToweropsWeb.EscalationPolicyLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.OnCall
@impl true
def mount(_params, _session, socket) do
org_id = socket.assigns.current_scope.organization.id
policies = OnCall.list_escalation_policies(org_id)
{:ok,
socket
|> assign(:page_title, t("Escalation Policies"))
|> assign(:active_page, "schedules")
|> assign(:policies, policies)}
end
end

View file

@ -0,0 +1,71 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
>
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<.link
navigate={~p"/schedules"}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<.icon name="hero-arrow-left" class="h-5 w-5" />
</.link>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("Escalation Policies")}</h1>
</div>
<.link
navigate={~p"/schedules/escalation-policies/new"}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
>
<.icon name="hero-plus" class="h-4 w-4" />
{t("New Policy")}
</.link>
</div>
<%= if Enum.empty?(@policies) do %>
<div class="flex items-center justify-center py-16">
<div class="text-center">
<.icon
name="hero-arrow-trending-up"
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
/>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
{t("No escalation policies")}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Create an escalation policy to define how alerts are escalated to on-call users.")}
</p>
</div>
</div>
<% else %>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Name")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Repeat Count")}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
<%= for policy <- @policies do %>
<tr
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
phx-click={JS.navigate(~p"/schedules/escalation-policies/#{policy.id}")}
>
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
{policy.name}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{policy.repeat_count}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</Layouts.authenticated>

View file

@ -0,0 +1,115 @@
defmodule ToweropsWeb.EscalationPolicyLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.OnCall
alias Towerops.Organizations
@impl true
def mount(%{"id" => id}, _session, socket) do
policy = OnCall.get_escalation_policy!(id)
org_id = socket.assigns.current_scope.organization.id
members = Organizations.list_organization_members(org_id)
users = Enum.map(members, & &1.user)
schedules = OnCall.list_schedules(org_id)
{:ok,
socket
|> assign(:page_title, policy.name)
|> assign(:active_page, "schedules")
|> assign(:policy, policy)
|> assign(:org_users, users)
|> assign(:org_schedules, schedules)
|> assign(:show_add_rule, false)}
end
@impl true
def handle_event("delete", _params, socket) do
case OnCall.delete_escalation_policy(socket.assigns.policy) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Escalation policy deleted"))
|> push_navigate(to: ~p"/schedules?tab=escalation-policies")}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Unable to delete escalation policy"))}
end
end
def handle_event("toggle_add_rule", _params, socket) do
{:noreply, assign(socket, :show_add_rule, !socket.assigns.show_add_rule)}
end
def handle_event("save_rule", %{"timeout_minutes" => timeout}, socket) do
policy = socket.assigns.policy
next_position = length(policy.rules)
case OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: next_position,
timeout_minutes: timeout
}) do
{:ok, _} ->
{:noreply,
socket
|> assign(:show_add_rule, false)
|> reload_policy()}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Unable to add rule"))}
end
end
def handle_event("delete_rule", %{"id" => rule_id}, socket) do
rule = Enum.find(socket.assigns.policy.rules, &(&1.id == rule_id))
if rule do
case OnCall.delete_escalation_rule(rule) do
{:ok, _} -> {:noreply, reload_policy(socket)}
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete rule"))}
end
else
{:noreply, socket}
end
end
def handle_event("add_target", %{"rule_id" => rule_id, "target_type" => target_type} = params, socket) do
attrs = %{
escalation_rule_id: rule_id,
target_type: target_type
}
attrs =
case target_type do
"user" -> Map.put(attrs, :user_id, params["target_id"])
"schedule" -> Map.put(attrs, :schedule_id, params["target_id"])
end
case OnCall.create_escalation_target(attrs) do
{:ok, _} -> {:noreply, reload_policy(socket)}
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to add target"))}
end
end
def handle_event("delete_target", %{"id" => target_id}, socket) do
target =
socket.assigns.policy.rules
|> Enum.flat_map(& &1.targets)
|> Enum.find(&(&1.id == target_id))
if target do
case OnCall.delete_escalation_target(target) do
{:ok, _} -> {:noreply, reload_policy(socket)}
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete target"))}
end
else
{:noreply, socket}
end
end
defp reload_policy(socket) do
policy = OnCall.get_escalation_policy!(socket.assigns.policy.id)
assign(socket, :policy, policy)
end
end

View file

@ -0,0 +1,197 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
>
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<.link
navigate={~p"/schedules?tab=escalation-policies"}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<.icon name="hero-arrow-left" class="h-5 w-5" />
</.link>
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@policy.name}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("Repeats %{count} times", count: @policy.repeat_count)}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<.link
navigate={~p"/schedules/escalation-policies/#{@policy.id}/edit"}
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-pencil" class="h-4 w-4" />
{t("Edit")}
</.link>
<button
phx-click="delete"
data-confirm={t("Are you sure you want to delete this escalation policy?")}
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-gray-300 dark:border-gray-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<.icon name="hero-trash" class="h-4 w-4" />
{t("Delete")}
</button>
</div>
</div>
<%= if @policy.description do %>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">{@policy.description}</p>
<% end %>
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{t("Escalation Rules")}
</h2>
<button
phx-click="toggle_add_rule"
class="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500"
>
<.icon name="hero-plus" class="h-4 w-4" />
{t("Add Rule")}
</button>
</div>
<%= if @show_add_rule do %>
<div class="mb-4 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10 p-4">
<form phx-submit="save_rule">
<div class="flex items-end gap-4 mb-4">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Escalate after (minutes)")}
</label>
<input
type="number"
name="timeout_minutes"
value="30"
min="1"
max="1440"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-sm"
/>
</div>
</div>
<div class="flex items-center gap-2">
<.button type="submit" phx-disable-with={t("Saving...")}>
{t("Add Rule")}
</.button>
<button
type="button"
phx-click="toggle_add_rule"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
{t("Cancel")}
</button>
</div>
</form>
</div>
<% end %>
<%= if Enum.empty?(@policy.rules) do %>
<div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-6 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No rules configured. Add a rule to define escalation steps.")}
</p>
</div>
<% else %>
<div class="space-y-3">
<%= for rule <- Enum.sort_by(@policy.rules, & &1.position) do %>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Step %{position}", position: rule.position + 1)}
</h3>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-500 dark:text-gray-400">
{t("Escalate after %{minutes} min", minutes: rule.timeout_minutes)}
</span>
<button
phx-click="delete_rule"
phx-value-id={rule.id}
data-confirm={t("Are you sure you want to delete this rule?")}
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
</div>
</div>
<%!-- Targets --%>
<div class="mb-2">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
{t("Targets")}
</p>
<%= if Enum.empty?(rule.targets) do %>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
{t("No targets yet")}
</p>
<% else %>
<div class="flex flex-wrap gap-2 mb-2">
<%= for target <- rule.targets do %>
<span class="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-2.5 pr-1 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">
<%= case target.target_type do %>
<% "user" -> %>
<.icon name="hero-user" class="h-3 w-3 mr-0.5" />
<% user = Enum.find(@org_users, &(&1.id == target.user_id)) %>
{if user, do: user.email, else: t("Unknown user")}
<% "schedule" -> %>
<.icon name="hero-calendar-days" class="h-3 w-3 mr-0.5" />
<% sched = Enum.find(@org_schedules, &(&1.id == target.schedule_id)) %>
{if sched, do: sched.name, else: t("Unknown schedule")}
<% end %>
<button
phx-click="delete_target"
phx-value-id={target.id}
class="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-x-mark" class="h-3 w-3" />
</button>
</span>
<% end %>
</div>
<% end %>
<%!-- Add target form --%>
<form phx-submit="add_target" class="flex items-center gap-2">
<input type="hidden" name="rule_id" value={rule.id} />
<select
name="target_type"
id={"target-type-#{rule.id}"}
class="block rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
>
<option value="user">{t("User")}</option>
<option value="schedule">{t("Schedule")}</option>
</select>
<select
name="target_id"
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
>
<option value="">{t("Select...")}</option>
<optgroup label={t("Users")}>
<%= for user <- @org_users do %>
<option value={user.id}>{user.email}</option>
<% end %>
</optgroup>
<optgroup label={t("Schedules")}>
<%= for sched <- @org_schedules do %>
<option value={sched.id}>{sched.name}</option>
<% end %>
</optgroup>
</select>
<button
type="submit"
class="inline-flex items-center rounded-md bg-gray-100 dark:bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-plus" class="h-3 w-3 mr-1" />
{t("Add")}
</button>
</form>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
</Layouts.authenticated>

View file

@ -0,0 +1,83 @@
defmodule ToweropsWeb.ScheduleLive.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.OnCall
alias Towerops.OnCall.Schedule
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :active_page, "schedules")}
end
@impl true
def handle_params(params, _url, socket) do
case socket.assigns.live_action do
:new ->
schedule = %Schedule{}
changeset = Schedule.changeset(schedule, %{})
{:noreply,
socket
|> assign(:page_title, t("New Schedule"))
|> assign(:schedule, schedule)
|> assign(:form, to_form(changeset))}
:edit ->
schedule = OnCall.get_schedule!(params["id"])
changeset = Schedule.changeset(schedule, %{})
{:noreply,
socket
|> assign(:page_title, t("Edit Schedule"))
|> assign(:schedule, schedule)
|> assign(:form, to_form(changeset))}
end
end
@impl true
def handle_event("validate", %{"schedule" => params}, socket) do
changeset =
socket.assigns.schedule
|> Schedule.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
def handle_event("save", %{"schedule" => params}, socket) do
org_id = socket.assigns.current_scope.organization.id
params = Map.put(params, "organization_id", org_id)
case socket.assigns.live_action do
:new -> save_schedule(socket, :create, params)
:edit -> save_schedule(socket, :update, params)
end
end
defp save_schedule(socket, :create, params) do
case OnCall.create_schedule(params) do
{:ok, schedule} ->
{:noreply,
socket
|> put_flash(:info, t("Schedule created"))
|> push_navigate(to: ~p"/schedules/#{schedule.id}")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp save_schedule(socket, :update, params) do
case OnCall.update_schedule(socket.assigns.schedule, params) do
{:ok, schedule} ->
{:noreply,
socket
|> put_flash(:info, t("Schedule updated"))
|> push_navigate(to: ~p"/schedules/#{schedule.id}")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
end

View file

@ -0,0 +1,46 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
>
<div class="flex items-center gap-3 mb-6">
<.link
navigate={~p"/schedules"}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<.icon name="hero-arrow-left" class="h-5 w-5" />
</.link>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@page_title}</h1>
</div>
<div class="max-w-2xl">
<.form for={@form} phx-change="validate" phx-submit="save" class="space-y-6">
<div>
<.input field={@form[:name]} type="text" label={t("Name")} required />
</div>
<div>
<.input
field={@form[:timezone]}
type="text"
label={t("Timezone")}
placeholder="America/Chicago"
required
/>
</div>
<div>
<.input field={@form[:description]} type="textarea" label={t("Description")} />
</div>
<div class="flex items-center gap-3">
<.button type="submit" phx-disable-with={t("Saving...")}>
{t("Save Schedule")}
</.button>
<.link
navigate={~p"/schedules"}
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
{t("Cancel")}
</.link>
</div>
</.form>
</div>
</Layouts.authenticated>

View file

@ -0,0 +1,48 @@
defmodule ToweropsWeb.ScheduleLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.OnCall
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
{:ok,
socket
|> assign(:page_title, t("Schedules"))
|> assign(:active_page, "schedules")
|> assign(:tab, "schedules")
|> load_data(organization.id)}
end
@impl true
def handle_params(params, _url, socket) do
tab = Map.get(params, "tab", "schedules")
org_id = socket.assigns.current_scope.organization.id
{:noreply,
socket
|> assign(:tab, tab)
|> load_data(org_id)}
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)
_ ->
schedules = OnCall.list_schedules(org_id)
schedules_with_on_call =
Enum.map(schedules, fn schedule ->
on_call = OnCall.who_is_on_call(schedule.id)
Map.put(schedule, :current_on_call, on_call)
end)
assign(socket, :schedules, schedules_with_on_call)
end
end
end

View file

@ -0,0 +1,168 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
>
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("Schedules")}</h1>
<div class="flex items-center gap-2">
<%= if @tab == "escalation-policies" do %>
<.link
navigate={~p"/schedules/escalation-policies/new"}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
>
<.icon name="hero-plus" class="h-4 w-4" />
{t("New Policy")}
</.link>
<% else %>
<.link
navigate={~p"/schedules/new"}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
>
<.icon name="hero-plus" class="h-4 w-4" />
{t("New Schedule")}
</.link>
<% end %>
</div>
</div>
<div class="flex items-center gap-2 mb-6">
<.link
patch={~p"/schedules?tab=schedules"}
class={[
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
@tab == "schedules" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
@tab != "schedules" &&
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
]}
>
{t("Schedules")}
</.link>
<.link
patch={~p"/schedules?tab=escalation-policies"}
class={[
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
@tab == "escalation-policies" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
@tab != "escalation-policies" &&
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
]}
>
{t("Escalation Policies")}
</.link>
</div>
<%= if @tab == "escalation-policies" do %>
<%= if Enum.empty?(@policies) do %>
<div class="flex items-center justify-center py-16">
<div class="text-center">
<.icon
name="hero-arrow-trending-up"
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
/>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
{t("No escalation policies")}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Create an escalation policy to define how alerts are escalated to on-call users.")}
</p>
</div>
</div>
<% else %>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Name")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Rules")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Repeat Count")}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
<%= for policy <- @policies do %>
<tr
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
phx-click={JS.navigate(~p"/schedules/escalation-policies/#{policy.id}")}
>
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
{policy.name}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{policy.repeat_count}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% else %>
<%= if Enum.empty?(@schedules) do %>
<div class="flex items-center justify-center py-16">
<div class="text-center">
<.icon
name="hero-calendar-days"
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
/>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
{t("No schedules")}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Create an on-call schedule to define rotation coverage.")}
</p>
</div>
</div>
<% else %>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Name")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Timezone")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Currently On-Call")}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
<%= for schedule <- @schedules do %>
<tr
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
phx-click={JS.navigate(~p"/schedules/#{schedule.id}")}
>
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
{schedule.name}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{schedule.timezone}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
<%= if schedule.current_on_call do %>
<span class="inline-flex items-center gap-1.5">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{schedule.current_on_call.email}
</span>
<% else %>
<span class="text-gray-400 dark:text-gray-500">{t("No one")}</span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% end %>
</Layouts.authenticated>

View file

@ -0,0 +1,185 @@
defmodule ToweropsWeb.ScheduleLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.OnCall
alias Towerops.OnCall.Layer
alias Towerops.OnCall.Override
alias Towerops.Organizations
@impl true
def mount(%{"id" => id}, _session, socket) do
schedule = OnCall.get_schedule!(id)
on_call = OnCall.who_is_on_call(schedule.id)
org_id = socket.assigns.current_scope.organization.id
members = Organizations.list_organization_members(org_id)
users = Enum.map(members, & &1.user)
{:ok,
socket
|> assign(:page_title, schedule.name)
|> assign(:active_page, "schedules")
|> assign(:schedule, schedule)
|> assign(:on_call, on_call)
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:org_users, users)
|> assign(:show_add_layer, false)
|> assign(:show_add_override, false)
|> assign(:layer_form, to_form(Layer.changeset(%Layer{}, %{})))
|> assign(:override_form, to_form(Override.changeset(%Override{}, %{})))}
end
@impl true
def handle_event("delete", _params, socket) do
case OnCall.delete_schedule(socket.assigns.schedule) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Schedule deleted"))
|> push_navigate(to: ~p"/schedules")}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Unable to delete schedule"))}
end
end
def handle_event("toggle_add_layer", _params, socket) do
{:noreply, assign(socket, :show_add_layer, !socket.assigns.show_add_layer)}
end
def handle_event("toggle_add_override", _params, socket) do
{:noreply, assign(socket, :show_add_override, !socket.assigns.show_add_override)}
end
def handle_event("validate_layer", %{"layer" => params}, socket) do
changeset =
%Layer{}
|> Layer.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :layer_form, to_form(changeset))}
end
def handle_event("save_layer", %{"layer" => params}, socket) do
schedule = socket.assigns.schedule
next_position = length(schedule.layers)
params =
params
|> Map.put("schedule_id", schedule.id)
|> Map.put("position", next_position)
case OnCall.create_layer(params) do
{:ok, _layer} ->
{:noreply,
socket
|> assign(:show_add_layer, false)
|> assign(:layer_form, to_form(Layer.changeset(%Layer{}, %{})))
|> reload_schedule()}
{:error, changeset} ->
{:noreply, assign(socket, :layer_form, to_form(changeset))}
end
end
def handle_event("delete_layer", %{"id" => layer_id}, socket) do
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id))
if layer do
case OnCall.delete_layer(layer) do
{:ok, _} -> {:noreply, reload_schedule(socket)}
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete layer"))}
end
else
{:noreply, socket}
end
end
def handle_event("add_member", %{"layer_id" => layer_id, "user_id" => user_id}, socket) do
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id))
if layer do
next_position = length(layer.members)
case OnCall.add_layer_member(%{
layer_id: layer_id,
user_id: user_id,
position: next_position
}) do
{:ok, _} -> {:noreply, reload_schedule(socket)}
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to add member"))}
end
else
{:noreply, socket}
end
end
def handle_event("remove_member", %{"id" => member_id}, socket) do
member =
socket.assigns.schedule.layers
|> Enum.flat_map(& &1.members)
|> Enum.find(&(&1.id == member_id))
if member do
case OnCall.remove_layer_member(member) do
{:ok, _} -> {:noreply, reload_schedule(socket)}
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to remove member"))}
end
else
{:noreply, socket}
end
end
def handle_event("validate_override", %{"override" => params}, socket) do
changeset =
%Override{}
|> Override.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :override_form, to_form(changeset))}
end
def handle_event("save_override", %{"override" => params}, socket) do
schedule = socket.assigns.schedule
user_id = socket.assigns.current_scope.user.id
params =
params
|> Map.put("schedule_id", schedule.id)
|> Map.put("created_by_id", user_id)
case OnCall.create_override(params) do
{:ok, _} ->
{:noreply,
socket
|> assign(:show_add_override, false)
|> assign(:override_form, to_form(Override.changeset(%Override{}, %{})))
|> reload_schedule()}
{:error, changeset} ->
{:noreply, assign(socket, :override_form, to_form(changeset))}
end
end
def handle_event("delete_override", %{"id" => override_id}, socket) do
override = Enum.find(socket.assigns.schedule.overrides, &(&1.id == override_id))
if override do
case OnCall.delete_override(override) do
{:ok, _} -> {:noreply, reload_schedule(socket)}
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete override"))}
end
else
{:noreply, socket}
end
end
defp reload_schedule(socket) do
schedule = OnCall.get_schedule!(socket.assigns.schedule.id)
on_call = OnCall.who_is_on_call(schedule.id)
socket
|> assign(:schedule, schedule)
|> assign(:on_call, on_call)
end
end

View file

@ -0,0 +1,342 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
>
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<.link
navigate={~p"/schedules"}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<.icon name="hero-arrow-left" class="h-5 w-5" />
</.link>
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@schedule.name}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">{@schedule.timezone}</p>
</div>
</div>
<div class="flex items-center gap-2">
<.link
navigate={~p"/schedules/#{@schedule.id}/edit"}
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-pencil" class="h-4 w-4" />
{t("Edit")}
</.link>
<button
phx-click="delete"
data-confirm={t("Are you sure you want to delete this schedule?")}
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-gray-300 dark:border-gray-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<.icon name="hero-trash" class="h-4 w-4" />
{t("Delete")}
</button>
</div>
</div>
<%= if @schedule.description do %>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">{@schedule.description}</p>
<% end %>
<%!-- On-Call Status --%>
<div class="mb-8 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
<div class="flex items-center gap-3">
<div class={[
"h-10 w-10 rounded-full flex items-center justify-center",
@on_call && "bg-green-100 dark:bg-green-900/40",
!@on_call && "bg-gray-100 dark:bg-gray-800"
]}>
<.icon
name="hero-user"
class={[
"h-5 w-5",
@on_call && "text-green-600 dark:text-green-400",
!@on_call && "text-gray-400 dark:text-gray-500"
]}
/>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Currently On-Call")}
</p>
<%= if @on_call do %>
<p class="text-sm font-semibold text-gray-900 dark:text-white">{@on_call.email}</p>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No one is currently on-call")}
</p>
<% end %>
</div>
</div>
</div>
<%!-- Layers --%>
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{t("Layers")}</h2>
<button
phx-click="toggle_add_layer"
class="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500"
>
<.icon name="hero-plus" class="h-4 w-4" />
{t("Add Layer")}
</button>
</div>
<%= if @show_add_layer do %>
<div class="mb-4 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10 p-4">
<.form for={@layer_form} phx-change="validate_layer" phx-submit="save_layer">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<.input
field={@layer_form[:name]}
type="text"
label={t("Layer Name")}
placeholder="Layer 1"
/>
<.input
field={@layer_form[:rotation_type]}
type="select"
label={t("Rotation Type")}
options={[{t("Daily"), "daily"}, {t("Weekly"), "weekly"}, {t("Custom"), "custom"}]}
/>
<.input
field={@layer_form[:rotation_interval]}
type="number"
label={t("Rotation Interval")}
min="1"
/>
<.input field={@layer_form[:handoff_time]} type="time" label={t("Handoff Time")} />
<.input
field={@layer_form[:handoff_day]}
type="select"
label={t("Handoff Day")}
options={[
{t("Sunday"), "0"},
{t("Monday"), "1"},
{t("Tuesday"), "2"},
{t("Wednesday"), "3"},
{t("Thursday"), "4"},
{t("Friday"), "5"},
{t("Saturday"), "6"}
]}
/>
<.input
field={@layer_form[:start_date]}
type="datetime-local"
label={t("Start Date")}
/>
</div>
<div class="flex items-center gap-2">
<.button type="submit" phx-disable-with={t("Saving...")}>
{t("Add Layer")}
</.button>
<button
type="button"
phx-click="toggle_add_layer"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
{t("Cancel")}
</button>
</div>
</.form>
</div>
<% end %>
<%= if Enum.empty?(@schedule.layers) do %>
<div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-6 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No layers configured. Add a layer to define rotation coverage.")}
</p>
</div>
<% else %>
<div class="space-y-4">
<%= for layer <- Enum.sort_by(@schedule.layers, & &1.position) do %>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{layer.name}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{String.capitalize(layer.rotation_type)} rotation, every {layer.rotation_interval}
<%= case layer.rotation_type do %>
<% "daily" -> %>
{ngettext("day", "days", layer.rotation_interval)}
<% "weekly" -> %>
{ngettext("week", "weeks", layer.rotation_interval)}
<% _ -> %>
{ngettext("day", "days", layer.rotation_interval)}
<% end %>
· Handoff at {Calendar.strftime(layer.handoff_time, "%H:%M")}
</p>
</div>
<button
phx-click="delete_layer"
phx-value-id={layer.id}
data-confirm={t("Are you sure you want to delete this layer?")}
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
</div>
<%!-- Members --%>
<div class="mb-2">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
{t("Members")}
</p>
<%= if Enum.empty?(layer.members) do %>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
{t("No members yet")}
</p>
<% else %>
<div class="flex flex-wrap gap-2 mb-2">
<%= for member <- Enum.sort_by(layer.members, & &1.position) do %>
<span class="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-2.5 pr-1 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">
{member.user.email}
<button
phx-click="remove_member"
phx-value-id={member.id}
class="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-x-mark" class="h-3 w-3" />
</button>
</span>
<% end %>
</div>
<% end %>
<%!-- Add member dropdown --%>
<% existing_user_ids = Enum.map(layer.members, & &1.user_id) %>
<% available_users = Enum.reject(@org_users, &(&1.id in existing_user_ids)) %>
<%= unless Enum.empty?(available_users) do %>
<form phx-submit="add_member" class="flex items-center gap-2">
<input type="hidden" name="layer_id" value={layer.id} />
<select
name="user_id"
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
>
<option value="">{t("Select a member...")}</option>
<%= for user <- available_users do %>
<option value={user.id}>{user.email}</option>
<% end %>
</select>
<button
type="submit"
class="inline-flex items-center rounded-md bg-gray-100 dark:bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-plus" class="h-3 w-3 mr-1" />
{t("Add")}
</button>
</form>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
<%!-- Overrides --%>
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{t("Overrides")}</h2>
<button
phx-click="toggle_add_override"
class="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500"
>
<.icon name="hero-plus" class="h-4 w-4" />
{t("Add Override")}
</button>
</div>
<%= if @show_add_override do %>
<div class="mb-4 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10 p-4">
<.form for={@override_form} phx-change="validate_override" phx-submit="save_override">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<.input
field={@override_form[:user_id]}
type="select"
label={t("User")}
options={Enum.map(@org_users, &{&1.email, &1.id})}
prompt={t("Select user...")}
/>
<.input
field={@override_form[:start_time]}
type="datetime-local"
label={t("Start Time")}
/>
<.input
field={@override_form[:end_time]}
type="datetime-local"
label={t("End Time")}
/>
</div>
<div class="flex items-center gap-2">
<.button type="submit" phx-disable-with={t("Saving...")}>
{t("Add Override")}
</.button>
<button
type="button"
phx-click="toggle_add_override"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
{t("Cancel")}
</button>
</div>
</.form>
</div>
<% end %>
<%= if Enum.empty?(@schedule.overrides) do %>
<div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-6 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No overrides configured.")}
</p>
</div>
<% else %>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("User")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Start")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("End")}
</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
<%= for override <- @schedule.overrides do %>
<tr>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">
{override.user.email}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{Calendar.strftime(override.start_time, "%Y-%m-%d %H:%M")}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{Calendar.strftime(override.end_time, "%Y-%m-%d %H:%M")}
</td>
<td class="px-4 py-3 text-right">
<button
phx-click="delete_override"
phx-value-id={override.id}
data-confirm={t("Are you sure?")}
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</Layouts.authenticated>

View file

@ -435,6 +435,16 @@ defmodule ToweropsWeb.Router do
# Activity feed
live "/activity", ActivityFeedLive, :index
# On-call schedules
live "/schedules", ScheduleLive.Index, :index
live "/schedules/new", ScheduleLive.Form, :new
live "/schedules/escalation-policies", EscalationPolicyLive.Index, :index
live "/schedules/escalation-policies/new", EscalationPolicyLive.Form, :new
live "/schedules/escalation-policies/:id", EscalationPolicyLive.Show, :show
live "/schedules/escalation-policies/:id/edit", EscalationPolicyLive.Form, :edit
live "/schedules/:id", ScheduleLive.Show, :show
live "/schedules/:id/edit", ScheduleLive.Form, :edit
# Maintenance windows
live "/maintenance", MaintenanceLive.Index, :index
live "/maintenance/new", MaintenanceLive.Form, :new

View file

@ -0,0 +1,71 @@
defmodule Towerops.Repo.Migrations.CreateOnCallSchedules do
use Ecto.Migration
def change do
create table(:on_call_schedules, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
add :description, :string
add :timezone, :string, null: false
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
timestamps(type: :utc_datetime)
end
create index(:on_call_schedules, [:organization_id])
create table(:on_call_layers, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
add :position, :integer, null: false, default: 0
add :rotation_type, :string, null: false
add :rotation_interval, :integer, null: false, default: 1
add :handoff_time, :time, null: false
add :handoff_day, :integer
add :start_date, :utc_datetime, null: false
add :restriction_type, :string
add :restrictions, :map
add :schedule_id, references(:on_call_schedules, type: :binary_id, on_delete: :delete_all),
null: false
timestamps(type: :utc_datetime)
end
create index(:on_call_layers, [:schedule_id])
create table(:on_call_layer_members, primary_key: false) do
add :id, :binary_id, primary_key: true
add :position, :integer, null: false, default: 0
add :layer_id, references(:on_call_layers, type: :binary_id, on_delete: :delete_all),
null: false
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
timestamps(type: :utc_datetime)
end
create index(:on_call_layer_members, [:layer_id])
create unique_index(:on_call_layer_members, [:layer_id, :user_id])
create table(:on_call_overrides, primary_key: false) do
add :id, :binary_id, primary_key: true
add :start_time, :utc_datetime, null: false
add :end_time, :utc_datetime, null: false
add :schedule_id, references(:on_call_schedules, type: :binary_id, on_delete: :delete_all),
null: false
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
add :created_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
timestamps(type: :utc_datetime)
end
create index(:on_call_overrides, [:schedule_id, :start_time, :end_time])
end
end

View file

@ -0,0 +1,49 @@
defmodule Towerops.Repo.Migrations.CreateEscalationPolicies do
use Ecto.Migration
def change do
create table(:escalation_policies, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
add :description, :string
add :repeat_count, :integer, null: false, default: 3
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
timestamps(type: :utc_datetime)
end
create index(:escalation_policies, [:organization_id])
create table(:escalation_rules, primary_key: false) do
add :id, :binary_id, primary_key: true
add :position, :integer, null: false, default: 0
add :timeout_minutes, :integer, null: false, default: 30
add :escalation_policy_id,
references(:escalation_policies, type: :binary_id, on_delete: :delete_all),
null: false
timestamps(type: :utc_datetime)
end
create index(:escalation_rules, [:escalation_policy_id])
create table(:escalation_targets, primary_key: false) do
add :id, :binary_id, primary_key: true
add :target_type, :string, null: false
add :escalation_rule_id,
references(:escalation_rules, type: :binary_id, on_delete: :delete_all),
null: false
add :schedule_id, references(:on_call_schedules, type: :binary_id, on_delete: :delete_all)
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
create index(:escalation_targets, [:escalation_rule_id])
end
end

View file

@ -0,0 +1,50 @@
defmodule Towerops.Repo.Migrations.CreateOnCallIncidentsAndNotifications do
use Ecto.Migration
def change do
create table(:on_call_incidents, primary_key: false) do
add :id, :binary_id, primary_key: true
add :status, :string, null: false, default: "triggered"
add :current_rule_position, :integer, null: false, default: 0
add :current_loop, :integer, null: false, default: 1
add :triggered_at, :utc_datetime, null: false
add :acknowledged_at, :utc_datetime
add :resolved_at, :utc_datetime
add :alert_id, references(:alerts, type: :binary_id, on_delete: :delete_all), null: false
add :escalation_policy_id,
references(:escalation_policies, type: :binary_id, on_delete: :delete_all),
null: false
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
add :acknowledged_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
add :resolved_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
timestamps(type: :utc_datetime)
end
create index(:on_call_incidents, [:alert_id])
create index(:on_call_incidents, [:organization_id, :status])
create table(:on_call_notifications, primary_key: false) do
add :id, :binary_id, primary_key: true
add :channel, :string, null: false, default: "email"
add :sent_at, :utc_datetime
add :status, :string, null: false, default: "sent"
add :error_message, :string
add :incident_id,
references(:on_call_incidents, type: :binary_id, on_delete: :delete_all),
null: false
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
timestamps(type: :utc_datetime)
end
create index(:on_call_notifications, [:incident_id])
end
end

View file

@ -0,0 +1,15 @@
defmodule Towerops.Repo.Migrations.AddEscalationPolicyToDevicesAndOrganizations do
use Ecto.Migration
def change do
alter table(:devices) do
add :escalation_policy_id,
references(:escalation_policies, type: :binary_id, on_delete: :nilify_all)
end
alter table(:organizations) do
add :default_escalation_policy_id,
references(:escalation_policies, type: :binary_id, on_delete: :nilify_all)
end
end
end

View file

@ -0,0 +1,110 @@
defmodule Towerops.OnCallFixtures do
@moduledoc """
Test helpers for creating on-call schedule and escalation policy entities.
"""
alias Towerops.OnCall.EscalationPolicy
alias Towerops.OnCall.EscalationRule
alias Towerops.OnCall.EscalationTarget
alias Towerops.OnCall.Layer
alias Towerops.OnCall.LayerMember
alias Towerops.OnCall.Override
alias Towerops.OnCall.Schedule
alias Towerops.Repo
def schedule_fixture(organization_id, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
name: "Schedule #{System.unique_integer([:positive])}",
timezone: "America/Chicago",
organization_id: organization_id
})
%Schedule{}
|> Schedule.changeset(attrs)
|> Repo.insert!()
end
def layer_fixture(schedule_id, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
name: "Layer #{System.unique_integer([:positive])}",
position: 1,
rotation_type: "weekly",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
handoff_day: 1,
start_date: ~U[2026-01-01 09:00:00Z],
schedule_id: schedule_id
})
%Layer{}
|> Layer.changeset(attrs)
|> Repo.insert!()
end
def layer_member_fixture(layer_id, user_id, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
position: 0,
layer_id: layer_id,
user_id: user_id
})
%LayerMember{}
|> LayerMember.changeset(attrs)
|> Repo.insert!()
end
def override_fixture(schedule_id, user_id, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
start_time: ~U[2026-03-15 09:00:00Z],
end_time: ~U[2026-03-16 09:00:00Z],
schedule_id: schedule_id,
user_id: user_id
})
%Override{}
|> Override.changeset(attrs)
|> Repo.insert!()
end
def escalation_policy_fixture(organization_id, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
name: "Policy #{System.unique_integer([:positive])}",
repeat_count: 3,
organization_id: organization_id
})
%EscalationPolicy{}
|> EscalationPolicy.changeset(attrs)
|> Repo.insert!()
end
def escalation_rule_fixture(escalation_policy_id, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
position: 0,
timeout_minutes: 30,
escalation_policy_id: escalation_policy_id
})
%EscalationRule{}
|> EscalationRule.changeset(attrs)
|> Repo.insert!()
end
def escalation_target_fixture(escalation_rule_id, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
target_type: "user",
escalation_rule_id: escalation_rule_id
})
%EscalationTarget{}
|> EscalationTarget.changeset(attrs)
|> Repo.insert!()
end
end

View file

@ -0,0 +1,149 @@
defmodule Towerops.OnCall.EscalationTest do
use Towerops.DataCase, async: true
use Oban.Testing, repo: Towerops.Repo
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OnCallFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Alerts
alias Towerops.OnCall.Escalation
setup do
user = user_fixture()
organization = organization_fixture(user.id)
site = Towerops.OrganizationsFixtures.site_fixture(organization.id)
device = device_fixture(organization.id, site.id)
%{user: user, organization: organization, device: device}
end
describe "start_escalation/2" do
test "creates incident and enqueues escalation check", ctx do
schedule = schedule_fixture(ctx.organization.id)
layer =
layer_fixture(schedule.id, %{
rotation_type: "daily",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
start_date: ~U[2026-01-01 09:00:00Z],
position: 1
})
layer_member_fixture(layer.id, ctx.user.id, %{position: 0})
policy = escalation_policy_fixture(ctx.organization.id)
rule = escalation_rule_fixture(policy.id, %{position: 0, timeout_minutes: 5})
escalation_target_fixture(rule.id, %{target_type: "schedule", schedule_id: schedule.id})
{:ok, alert} =
Alerts.create_alert(%{
alert_type: "device_down",
device_id: ctx.device.id,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
assert {:ok, incident} = Escalation.start_escalation(alert, policy.id)
assert incident.status == "triggered"
assert incident.alert_id == alert.id
assert incident.escalation_policy_id == policy.id
assert incident.current_rule_position == 0
assert incident.current_loop == 1
# Should have enqueued an escalation check worker
assert_enqueued(
worker: Towerops.Workers.EscalationCheckWorker,
args: %{incident_id: incident.id}
)
end
test "returns :no_policy when policy doesn't exist", ctx do
{:ok, alert} =
Alerts.create_alert(%{
alert_type: "device_down",
device_id: ctx.device.id,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
assert {:error, :no_policy} = Escalation.start_escalation(alert, nil)
end
end
describe "acknowledge_incident/2" do
test "marks incident as acknowledged", ctx do
policy = escalation_policy_fixture(ctx.organization.id)
{:ok, alert} =
Alerts.create_alert(%{
alert_type: "device_down",
device_id: ctx.device.id,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
rule = escalation_rule_fixture(policy.id, %{position: 0, timeout_minutes: 5})
escalation_target_fixture(rule.id, %{target_type: "user", user_id: ctx.user.id})
{:ok, incident} = Escalation.start_escalation(alert, policy.id)
assert {:ok, updated} = Escalation.acknowledge_incident(incident.id, ctx.user.id)
assert updated.status == "acknowledged"
assert updated.acknowledged_by_id == ctx.user.id
assert updated.acknowledged_at
end
end
describe "resolve_incident/2" do
test "marks incident as resolved", ctx do
policy = escalation_policy_fixture(ctx.organization.id)
{:ok, alert} =
Alerts.create_alert(%{
alert_type: "device_down",
device_id: ctx.device.id,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
rule = escalation_rule_fixture(policy.id, %{position: 0, timeout_minutes: 5})
escalation_target_fixture(rule.id, %{target_type: "user", user_id: ctx.user.id})
{:ok, incident} = Escalation.start_escalation(alert, policy.id)
assert {:ok, updated} = Escalation.resolve_incident(incident.id, ctx.user.id)
assert updated.status == "resolved"
assert updated.resolved_by_id == ctx.user.id
assert updated.resolved_at
end
end
describe "find_incident_for_alert/1" do
test "finds active incident for alert", ctx do
policy = escalation_policy_fixture(ctx.organization.id)
{:ok, alert} =
Alerts.create_alert(%{
alert_type: "device_down",
device_id: ctx.device.id,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
rule = escalation_rule_fixture(policy.id, %{position: 0, timeout_minutes: 5})
escalation_target_fixture(rule.id, %{target_type: "user", user_id: ctx.user.id})
{:ok, incident} = Escalation.start_escalation(alert, policy.id)
found = Escalation.find_incident_for_alert(alert.id)
assert found.id == incident.id
end
test "returns nil when no active incident exists", _ctx do
assert Escalation.find_incident_for_alert(Ecto.UUID.generate()) == nil
end
end
end

View file

@ -0,0 +1,308 @@
defmodule Towerops.OnCall.ResolverTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OnCallFixtures
import Towerops.OrganizationsFixtures
alias Towerops.OnCall.Resolver
setup do
user1 = user_fixture(%{email: "oncall1@example.com"})
user2 = user_fixture(%{email: "oncall2@example.com"})
user3 = user_fixture(%{email: "oncall3@example.com"})
organization = organization_fixture(user1.id)
%{user1: user1, user2: user2, user3: user3, organization: organization}
end
describe "resolve/2 with daily rotation" do
test "returns first user on start date", ctx do
schedule = schedule_fixture(ctx.organization.id)
layer =
layer_fixture(schedule.id, %{
rotation_type: "daily",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
start_date: ~U[2026-03-01 09:00:00Z],
position: 1
})
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
layer_member_fixture(layer.id, ctx.user2.id, %{position: 1})
schedule = preload_schedule(schedule)
result = Resolver.resolve(schedule, ~U[2026-03-01 12:00:00Z])
assert result.id == ctx.user1.id
end
test "rotates to second user after one day", ctx do
schedule = schedule_fixture(ctx.organization.id)
layer =
layer_fixture(schedule.id, %{
rotation_type: "daily",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
start_date: ~U[2026-03-01 09:00:00Z],
position: 1
})
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
layer_member_fixture(layer.id, ctx.user2.id, %{position: 1})
schedule = preload_schedule(schedule)
result = Resolver.resolve(schedule, ~U[2026-03-02 12:00:00Z])
assert result.id == ctx.user2.id
end
test "wraps around after all users have rotated", ctx do
schedule = schedule_fixture(ctx.organization.id)
layer =
layer_fixture(schedule.id, %{
rotation_type: "daily",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
start_date: ~U[2026-03-01 09:00:00Z],
position: 1
})
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
layer_member_fixture(layer.id, ctx.user2.id, %{position: 1})
schedule = preload_schedule(schedule)
# Day 3 = position 0 again (wraps)
result = Resolver.resolve(schedule, ~U[2026-03-03 12:00:00Z])
assert result.id == ctx.user1.id
end
end
describe "resolve/2 with weekly rotation" do
test "rotates weekly", ctx do
schedule = schedule_fixture(ctx.organization.id)
layer =
layer_fixture(schedule.id, %{
rotation_type: "weekly",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
handoff_day: 1,
start_date: ~U[2026-03-02 09:00:00Z],
position: 1
})
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
layer_member_fixture(layer.id, ctx.user2.id, %{position: 1})
layer_member_fixture(layer.id, ctx.user3.id, %{position: 2})
schedule = preload_schedule(schedule)
# Week 1
assert Resolver.resolve(schedule, ~U[2026-03-03 12:00:00Z]).id == ctx.user1.id
# Week 2
assert Resolver.resolve(schedule, ~U[2026-03-10 12:00:00Z]).id == ctx.user2.id
# Week 3
assert Resolver.resolve(schedule, ~U[2026-03-17 12:00:00Z]).id == ctx.user3.id
# Week 4 wraps
assert Resolver.resolve(schedule, ~U[2026-03-24 12:00:00Z]).id == ctx.user1.id
end
end
describe "resolve/2 with custom rotation" do
test "supports multi-day custom interval", ctx do
schedule = schedule_fixture(ctx.organization.id)
layer =
layer_fixture(schedule.id, %{
rotation_type: "custom",
rotation_interval: 3,
handoff_time: ~T[09:00:00],
start_date: ~U[2026-03-01 09:00:00Z],
position: 1
})
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
layer_member_fixture(layer.id, ctx.user2.id, %{position: 1})
schedule = preload_schedule(schedule)
# Days 1-3: user1
assert Resolver.resolve(schedule, ~U[2026-03-01 12:00:00Z]).id == ctx.user1.id
assert Resolver.resolve(schedule, ~U[2026-03-03 12:00:00Z]).id == ctx.user1.id
# Days 4-6: user2
assert Resolver.resolve(schedule, ~U[2026-03-04 12:00:00Z]).id == ctx.user2.id
# Days 7-9: wraps to user1
assert Resolver.resolve(schedule, ~U[2026-03-07 12:00:00Z]).id == ctx.user1.id
end
end
describe "resolve/2 with layer stacking" do
test "higher position layer overrides lower", ctx do
schedule = schedule_fixture(ctx.organization.id)
# Layer 1 (lower priority): user1 every day
layer1 =
layer_fixture(schedule.id, %{
name: "Base",
rotation_type: "daily",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
start_date: ~U[2026-03-01 09:00:00Z],
position: 1
})
layer_member_fixture(layer1.id, ctx.user1.id, %{position: 0})
# Layer 2 (higher priority): user2 every day
layer2 =
layer_fixture(schedule.id, %{
name: "Override Layer",
rotation_type: "daily",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
start_date: ~U[2026-03-01 09:00:00Z],
position: 2
})
layer_member_fixture(layer2.id, ctx.user2.id, %{position: 0})
schedule = preload_schedule(schedule)
# Higher priority layer wins
result = Resolver.resolve(schedule, ~U[2026-03-01 12:00:00Z])
assert result.id == ctx.user2.id
end
end
describe "resolve/2 with time-of-day restriction" do
test "layer only active during restricted hours", ctx do
schedule = schedule_fixture(ctx.organization.id)
# Base layer: user1 always on-call
base =
layer_fixture(schedule.id, %{
name: "Base",
rotation_type: "daily",
rotation_interval: 1,
handoff_time: ~T[00:00:00],
start_date: ~U[2026-03-01 00:00:00Z],
position: 1
})
layer_member_fixture(base.id, ctx.user1.id, %{position: 0})
# Restricted layer: user2 only 18:00-08:00 (after hours)
restricted =
layer_fixture(schedule.id, %{
name: "After Hours",
rotation_type: "daily",
rotation_interval: 1,
handoff_time: ~T[18:00:00],
start_date: ~U[2026-03-01 18:00:00Z],
position: 2,
restriction_type: "time_of_day",
restrictions: %{"start_time" => "18:00", "end_time" => "08:00"}
})
layer_member_fixture(restricted.id, ctx.user2.id, %{position: 0})
schedule = preload_schedule(schedule)
# During business hours: base layer (user1)
assert Resolver.resolve(schedule, ~U[2026-03-01 14:00:00Z]).id == ctx.user1.id
# After hours: restricted layer overrides (user2)
assert Resolver.resolve(schedule, ~U[2026-03-01 20:00:00Z]).id == ctx.user2.id
# Early morning (still after hours): restricted layer (user2)
assert Resolver.resolve(schedule, ~U[2026-03-02 05:00:00Z]).id == ctx.user2.id
end
end
describe "resolve/2 with overrides" do
test "override takes precedence over all layers", ctx do
schedule = schedule_fixture(ctx.organization.id)
layer =
layer_fixture(schedule.id, %{
rotation_type: "daily",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
start_date: ~U[2026-03-01 09:00:00Z],
position: 1
})
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
# Override: user3 from March 5-6
override_fixture(schedule.id, ctx.user3.id, %{
start_time: ~U[2026-03-05 09:00:00Z],
end_time: ~U[2026-03-06 09:00:00Z],
created_by_id: ctx.user1.id
})
schedule = preload_schedule(schedule)
# Before override: normal rotation
assert Resolver.resolve(schedule, ~U[2026-03-04 12:00:00Z]).id == ctx.user1.id
# During override: user3
assert Resolver.resolve(schedule, ~U[2026-03-05 12:00:00Z]).id == ctx.user3.id
# After override: normal rotation
assert Resolver.resolve(schedule, ~U[2026-03-07 12:00:00Z]).id == ctx.user1.id
end
end
describe "resolve/2 edge cases" do
test "returns nil for schedule with no layers", ctx do
schedule = schedule_fixture(ctx.organization.id)
schedule = preload_schedule(schedule)
assert Resolver.resolve(schedule, ~U[2026-03-01 12:00:00Z]) == nil
end
test "returns nil for layer with no members", ctx do
schedule = schedule_fixture(ctx.organization.id)
layer_fixture(schedule.id, %{
rotation_type: "daily",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
start_date: ~U[2026-03-01 09:00:00Z],
position: 1
})
schedule = preload_schedule(schedule)
assert Resolver.resolve(schedule, ~U[2026-03-01 12:00:00Z]) == nil
end
test "returns nil for datetime before layer start_date", ctx do
schedule = schedule_fixture(ctx.organization.id)
layer =
layer_fixture(schedule.id, %{
rotation_type: "daily",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
start_date: ~U[2026-03-10 09:00:00Z],
position: 1
})
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
schedule = preload_schedule(schedule)
assert Resolver.resolve(schedule, ~U[2026-03-01 12:00:00Z]) == nil
end
end
defp preload_schedule(schedule) do
Towerops.Repo.preload(schedule, overrides: :user, layers: [members: :user])
end
end

View file

@ -0,0 +1,249 @@
defmodule Towerops.OnCall.SchemaTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OnCallFixtures
import Towerops.OrganizationsFixtures
alias Towerops.OnCall.EscalationPolicy
alias Towerops.OnCall.EscalationTarget
alias Towerops.OnCall.Layer
alias Towerops.OnCall.Override
alias Towerops.OnCall.Schedule
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{user: user, organization: organization}
end
describe "Schedule" do
test "valid changeset with required fields", %{organization: org} do
changeset =
Schedule.changeset(%Schedule{}, %{
name: "Primary On-Call",
timezone: "America/Chicago",
organization_id: org.id
})
assert changeset.valid?
end
test "invalid without name", %{organization: org} do
changeset =
Schedule.changeset(%Schedule{}, %{
timezone: "America/Chicago",
organization_id: org.id
})
refute changeset.valid?
assert errors_on(changeset).name
end
test "invalid without timezone", %{organization: org} do
changeset =
Schedule.changeset(%Schedule{}, %{
name: "Primary On-Call",
organization_id: org.id
})
refute changeset.valid?
assert errors_on(changeset).timezone
end
test "persists to database", %{organization: org} do
schedule = schedule_fixture(org.id)
assert schedule.id
assert schedule.name
assert schedule.timezone == "America/Chicago"
end
end
describe "Layer" do
test "valid changeset with required fields", %{organization: org} do
schedule = schedule_fixture(org.id)
changeset =
Layer.changeset(%Layer{}, %{
name: "Layer 1",
position: 1,
rotation_type: "weekly",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
handoff_day: 1,
start_date: ~U[2026-01-01 09:00:00Z],
schedule_id: schedule.id
})
assert changeset.valid?
end
test "invalid rotation_type rejected", %{organization: org} do
schedule = schedule_fixture(org.id)
changeset =
Layer.changeset(%Layer{}, %{
name: "Layer 1",
position: 1,
rotation_type: "invalid",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
start_date: ~U[2026-01-01 09:00:00Z],
schedule_id: schedule.id
})
refute changeset.valid?
assert errors_on(changeset).rotation_type
end
test "persists to database", %{organization: org} do
schedule = schedule_fixture(org.id)
layer = layer_fixture(schedule.id)
assert layer.id
assert layer.rotation_type == "weekly"
end
end
describe "LayerMember" do
test "persists to database", %{user: user, organization: org} do
schedule = schedule_fixture(org.id)
layer = layer_fixture(schedule.id)
member = layer_member_fixture(layer.id, user.id, %{position: 0})
assert member.id
assert member.position == 0
end
test "enforces unique user per layer", %{user: user, organization: org} do
schedule = schedule_fixture(org.id)
layer = layer_fixture(schedule.id)
layer_member_fixture(layer.id, user.id, %{position: 0})
assert_raise Ecto.InvalidChangesetError, fn ->
layer_member_fixture(layer.id, user.id, %{position: 1})
end
end
end
describe "Override" do
test "valid changeset", %{user: user, organization: org} do
schedule = schedule_fixture(org.id)
changeset =
Override.changeset(%Override{}, %{
start_time: ~U[2026-03-15 09:00:00Z],
end_time: ~U[2026-03-16 09:00:00Z],
schedule_id: schedule.id,
user_id: user.id,
created_by_id: user.id
})
assert changeset.valid?
end
test "invalid when end_time before start_time", %{user: user, organization: org} do
schedule = schedule_fixture(org.id)
changeset =
Override.changeset(%Override{}, %{
start_time: ~U[2026-03-16 09:00:00Z],
end_time: ~U[2026-03-15 09:00:00Z],
schedule_id: schedule.id,
user_id: user.id,
created_by_id: user.id
})
refute changeset.valid?
assert errors_on(changeset).end_time
end
test "persists to database", %{user: user, organization: org} do
schedule = schedule_fixture(org.id)
override = override_fixture(schedule.id, user.id, %{created_by_id: user.id})
assert override.id
end
end
describe "EscalationPolicy" do
test "valid changeset", %{organization: org} do
changeset =
EscalationPolicy.changeset(%EscalationPolicy{}, %{
name: "Default Policy",
repeat_count: 3,
organization_id: org.id
})
assert changeset.valid?
end
test "invalid without name", %{organization: org} do
changeset =
EscalationPolicy.changeset(%EscalationPolicy{}, %{
repeat_count: 3,
organization_id: org.id
})
refute changeset.valid?
assert errors_on(changeset).name
end
test "persists to database", %{organization: org} do
policy = escalation_policy_fixture(org.id)
assert policy.id
assert policy.repeat_count == 3
end
end
describe "EscalationRule" do
test "persists to database", %{organization: org} do
policy = escalation_policy_fixture(org.id)
rule = escalation_rule_fixture(policy.id, %{position: 0, timeout_minutes: 15})
assert rule.id
assert rule.timeout_minutes == 15
end
end
describe "EscalationTarget" do
test "target with schedule type", %{user: _user, organization: org} do
schedule = schedule_fixture(org.id)
policy = escalation_policy_fixture(org.id)
rule = escalation_rule_fixture(policy.id)
target =
escalation_target_fixture(rule.id, %{
target_type: "schedule",
schedule_id: schedule.id
})
assert target.target_type == "schedule"
assert target.schedule_id == schedule.id
end
test "target with user type", %{user: user, organization: org} do
policy = escalation_policy_fixture(org.id)
rule = escalation_rule_fixture(policy.id)
target =
escalation_target_fixture(rule.id, %{
target_type: "user",
user_id: user.id
})
assert target.target_type == "user"
assert target.user_id == user.id
end
test "invalid target_type rejected", %{organization: org} do
policy = escalation_policy_fixture(org.id)
rule = escalation_rule_fixture(policy.id)
changeset =
EscalationTarget.changeset(%EscalationTarget{}, %{
target_type: "invalid",
escalation_rule_id: rule.id
})
refute changeset.valid?
assert errors_on(changeset).target_type
end
end
end

View file

@ -0,0 +1,217 @@
defmodule Towerops.OnCallTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OnCallFixtures
import Towerops.OrganizationsFixtures
alias Towerops.OnCall
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{user: user, organization: organization}
end
describe "schedules" do
test "list_schedules/1 returns schedules for org", %{organization: org} do
schedule = schedule_fixture(org.id)
assert [found] = OnCall.list_schedules(org.id)
assert found.id == schedule.id
end
test "get_schedule!/1 returns schedule with preloaded associations", %{
user: user,
organization: org
} do
schedule = schedule_fixture(org.id)
layer = layer_fixture(schedule.id)
layer_member_fixture(layer.id, user.id)
found = OnCall.get_schedule!(schedule.id)
assert found.id == schedule.id
assert length(found.layers) == 1
assert length(hd(found.layers).members) == 1
end
test "create_schedule/1 with valid attrs", %{organization: org} do
attrs = %{name: "Primary", timezone: "America/Chicago", organization_id: org.id}
assert {:ok, schedule} = OnCall.create_schedule(attrs)
assert schedule.name == "Primary"
end
test "create_schedule/1 with invalid attrs returns error" do
assert {:error, changeset} = OnCall.create_schedule(%{})
assert errors_on(changeset).name
end
test "update_schedule/2", %{organization: org} do
schedule = schedule_fixture(org.id)
assert {:ok, updated} = OnCall.update_schedule(schedule, %{name: "Updated"})
assert updated.name == "Updated"
end
test "delete_schedule/1", %{organization: org} do
schedule = schedule_fixture(org.id)
assert {:ok, _} = OnCall.delete_schedule(schedule)
assert_raise Ecto.NoResultsError, fn -> OnCall.get_schedule!(schedule.id) end
end
end
describe "layers" do
test "create_layer/1", %{organization: org} do
schedule = schedule_fixture(org.id)
attrs = %{
name: "Layer 1",
position: 1,
rotation_type: "weekly",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
handoff_day: 1,
start_date: ~U[2026-01-01 09:00:00Z],
schedule_id: schedule.id
}
assert {:ok, layer} = OnCall.create_layer(attrs)
assert layer.name == "Layer 1"
end
test "update_layer/2", %{organization: org} do
schedule = schedule_fixture(org.id)
layer = layer_fixture(schedule.id)
assert {:ok, updated} = OnCall.update_layer(layer, %{name: "Updated Layer"})
assert updated.name == "Updated Layer"
end
test "delete_layer/1", %{organization: org} do
schedule = schedule_fixture(org.id)
layer = layer_fixture(schedule.id)
assert {:ok, _} = OnCall.delete_layer(layer)
end
end
describe "layer_members" do
test "add_layer_member/1 and remove_layer_member/1", %{user: user, organization: org} do
schedule = schedule_fixture(org.id)
layer = layer_fixture(schedule.id)
assert {:ok, member} =
OnCall.add_layer_member(%{layer_id: layer.id, user_id: user.id, position: 0})
assert member.user_id == user.id
assert {:ok, _} = OnCall.remove_layer_member(member)
end
end
describe "overrides" do
test "create_override/1 and delete_override/1", %{user: user, organization: org} do
schedule = schedule_fixture(org.id)
attrs = %{
start_time: ~U[2026-03-15 09:00:00Z],
end_time: ~U[2026-03-16 09:00:00Z],
schedule_id: schedule.id,
user_id: user.id,
created_by_id: user.id
}
assert {:ok, override} = OnCall.create_override(attrs)
assert override.user_id == user.id
assert {:ok, _} = OnCall.delete_override(override)
end
end
describe "escalation_policies" do
test "list_escalation_policies/1", %{organization: org} do
policy = escalation_policy_fixture(org.id)
assert [found] = OnCall.list_escalation_policies(org.id)
assert found.id == policy.id
end
test "get_escalation_policy!/1 with preloaded rules and targets", %{
user: user,
organization: org
} do
policy = escalation_policy_fixture(org.id)
rule = escalation_rule_fixture(policy.id)
escalation_target_fixture(rule.id, %{target_type: "user", user_id: user.id})
found = OnCall.get_escalation_policy!(policy.id)
assert found.id == policy.id
assert length(found.rules) == 1
assert length(hd(found.rules).targets) == 1
end
test "create_escalation_policy/1", %{organization: org} do
attrs = %{name: "Default", repeat_count: 3, organization_id: org.id}
assert {:ok, policy} = OnCall.create_escalation_policy(attrs)
assert policy.name == "Default"
end
test "update_escalation_policy/2", %{organization: org} do
policy = escalation_policy_fixture(org.id)
assert {:ok, updated} = OnCall.update_escalation_policy(policy, %{name: "Updated"})
assert updated.name == "Updated"
end
test "delete_escalation_policy/1", %{organization: org} do
policy = escalation_policy_fixture(org.id)
assert {:ok, _} = OnCall.delete_escalation_policy(policy)
end
end
describe "escalation_rules" do
test "create_escalation_rule/1 and delete_escalation_rule/1", %{organization: org} do
policy = escalation_policy_fixture(org.id)
attrs = %{position: 0, timeout_minutes: 15, escalation_policy_id: policy.id}
assert {:ok, rule} = OnCall.create_escalation_rule(attrs)
assert rule.timeout_minutes == 15
assert {:ok, _} = OnCall.delete_escalation_rule(rule)
end
end
describe "escalation_targets" do
test "create_escalation_target/1 and delete_escalation_target/1", %{
user: user,
organization: org
} do
policy = escalation_policy_fixture(org.id)
rule = escalation_rule_fixture(policy.id)
attrs = %{target_type: "user", user_id: user.id, escalation_rule_id: rule.id}
assert {:ok, target} = OnCall.create_escalation_target(attrs)
assert target.target_type == "user"
assert {:ok, _} = OnCall.delete_escalation_target(target)
end
end
describe "who_is_on_call/2" do
test "resolves who is on call from schedule id", %{user: user, organization: org} do
schedule = schedule_fixture(org.id)
layer =
layer_fixture(schedule.id, %{
rotation_type: "daily",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
start_date: ~U[2026-03-01 09:00:00Z],
position: 1
})
layer_member_fixture(layer.id, user.id, %{position: 0})
result = OnCall.who_is_on_call(schedule.id, ~U[2026-03-01 12:00:00Z])
assert result.id == user.id
end
test "returns nil for nonexistent schedule" do
assert OnCall.who_is_on_call(Ecto.UUID.generate(), ~U[2026-03-01 12:00:00Z]) == nil
end
end
end

View file

@ -0,0 +1,332 @@
defmodule ToweropsWeb.EscalationPolicyLiveTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Towerops.OnCallFixtures
alias Towerops.OnCall
setup :register_and_log_in_user
setup %{conn: conn, user: user} do
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
conn = Plug.Conn.put_session(conn, :current_organization_id, organization.id)
%{conn: conn, organization: organization}
end
describe "Index (via /schedules?tab=escalation-policies)" do
test "shows escalation policies tab content", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
assert html =~ "Escalation Policies"
assert html =~ "New Policy"
end
test "lists escalation policies", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id, %{name: "Critical Alerts Policy"})
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
assert html =~ "Critical Alerts Policy"
assert html =~ "#{policy.repeat_count}"
end
test "shows empty state when no policies", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
assert html =~ "No escalation policies"
end
test "has link to create new policy", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules?tab=escalation-policies")
assert has_element?(view, "a", "New Policy")
end
end
describe "Show" do
test "mounts and displays policy details", %{conn: conn, organization: organization} do
policy =
escalation_policy_fixture(organization.id, %{name: "Ops Policy", repeat_count: 5})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "Ops Policy"
assert html =~ "5"
end
test "shows description if present", %{conn: conn, organization: organization} do
policy =
escalation_policy_fixture(organization.id, %{
name: "Described Policy",
description: "Handles critical network alerts"
})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "Handles critical network alerts"
end
test "shows empty state when no rules", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "No rules configured"
end
test "can toggle add rule form", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
refute html =~ "Escalate after (minutes)"
html =
view
|> element("button", "Add Rule")
|> render_click()
assert html =~ "Escalate after (minutes)"
end
test "can add a rule with timeout_minutes", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
view |> element("button", "Add Rule") |> render_click()
html =
view
|> form("form[phx-submit=\"save_rule\"]", %{"timeout_minutes" => "15"})
|> render_submit()
assert html =~ "Step 1"
assert html =~ "15 min"
end
test "can delete a rule", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
rule = escalation_rule_fixture(policy.id, %{timeout_minutes: 20})
{:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "20 min"
view
|> element(~s|button[phx-click="delete_rule"][phx-value-id="#{rule.id}"]|)
|> render_click()
html = render(view)
assert html =~ "No rules configured"
end
test "shows rule targets", %{conn: conn, organization: organization, user: user} do
policy = escalation_policy_fixture(organization.id)
rule = escalation_rule_fixture(policy.id)
escalation_target_fixture(rule.id, %{
target_type: "user",
user_id: user.id
})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ user.email
end
test "can add a user target to a rule", %{conn: conn, organization: organization, user: user} do
policy = escalation_policy_fixture(organization.id)
rule = escalation_rule_fixture(policy.id)
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
html =
view
|> form("form[phx-submit=\"add_target\"]", %{
"rule_id" => rule.id,
"target_type" => "user",
"target_id" => user.id
})
|> render_submit()
assert html =~ user.email
end
test "can add a schedule target to a rule", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
rule = escalation_rule_fixture(policy.id)
schedule = schedule_fixture(organization.id, %{name: "On-Call Rotation"})
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
html =
view
|> form("form[phx-submit=\"add_target\"]", %{
"rule_id" => rule.id,
"target_type" => "schedule",
"target_id" => schedule.id
})
|> render_submit()
assert html =~ "On-Call Rotation"
end
test "can delete a target", %{conn: conn, organization: organization, user: user} 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
})
{:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ user.email
view
|> element(~s|button[phx-click="delete_target"][phx-value-id="#{target.id}"]|)
|> render_click()
html = render(view)
assert html =~ "No targets yet"
end
test "has edit and delete buttons", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert has_element?(view, "a", "Edit")
assert has_element?(view, "button", "Delete")
end
test "delete redirects to index with correct tab", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
view
|> element("button", "Delete")
|> render_click()
{path, flash} = assert_redirect(view)
assert path == ~p"/schedules?tab=escalation-policies"
assert flash["info"] =~ "deleted"
end
test "requires authentication", %{organization: organization} do
policy = escalation_policy_fixture(organization.id)
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "Form - New" do
test "renders new policy form", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/new")
assert html =~ "New Escalation Policy"
assert html =~ "Name"
assert html =~ "Description"
assert html =~ "Repeat Count"
assert html =~ "Save Policy"
end
test "validates required fields", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/new")
html =
view
|> form("form", escalation_policy: %{name: ""})
|> render_change()
assert html =~ "can&#39;t be blank"
end
test "creates policy and redirects to show", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/new")
view
|> form("form", escalation_policy: %{name: "New Alert Policy", repeat_count: 5})
|> render_submit()
{path, flash} = assert_redirect(view)
assert path =~ ~r|^/schedules/escalation-policies/.+|
assert flash["info"] =~ "created"
end
test "requires authentication" do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules/escalation-policies/new")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "Form - Edit" do
test "renders edit form with existing data", %{conn: conn, organization: organization} do
policy =
escalation_policy_fixture(organization.id, %{
name: "Existing Policy",
description: "Some description",
repeat_count: 7
})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
assert html =~ "Edit Escalation Policy"
assert html =~ "Existing Policy"
assert html =~ "Some description"
assert html =~ "7"
end
test "validates required fields on change", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
html =
view
|> form("form", escalation_policy: %{name: ""})
|> render_change()
assert html =~ "can&#39;t be blank"
end
test "updates policy and redirects to show", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id, %{name: "Old Name"})
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
view
|> form("form", escalation_policy: %{name: "Updated Name"})
|> render_submit()
{path, flash} = assert_redirect(view)
assert path == ~p"/schedules/escalation-policies/#{policy.id}"
assert flash["info"] =~ "updated"
updated = OnCall.get_escalation_policy!(policy.id)
assert updated.name == "Updated Name"
end
test "requires authentication", %{organization: organization} do
policy = escalation_policy_fixture(organization.id)
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
end

View file

@ -0,0 +1,499 @@
defmodule ToweropsWeb.ScheduleLiveTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Towerops.OnCallFixtures
alias Towerops.OnCall
setup :register_and_log_in_user
setup %{conn: conn, user: user} do
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
conn = Plug.Conn.put_session(conn, :current_organization_id, organization.id)
%{conn: conn, organization: organization}
end
describe "Index" do
test "mounts and lists schedules", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id, %{name: "Primary Rotation"})
{:ok, _view, html} = live(conn, ~p"/schedules")
assert html =~ "Schedules"
assert html =~ "Primary Rotation"
assert html =~ schedule.timezone
end
test "shows empty state when no schedules", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules")
assert html =~ "No schedules"
assert html =~ "Create an on-call schedule"
end
test "shows who is currently on-call for each schedule", %{
conn: conn,
organization: organization,
user: user
} do
schedule = schedule_fixture(organization.id)
layer =
layer_fixture(schedule.id, %{
start_date: ~U[2025-01-01 09:00:00Z],
handoff_time: ~T[09:00:00],
rotation_type: "weekly"
})
layer_member_fixture(layer.id, user.id)
{:ok, _view, html} = live(conn, ~p"/schedules")
assert html =~ user.email
end
test "shows 'No one' when nobody is on-call", %{conn: conn, organization: organization} do
schedule_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules")
assert html =~ "No one"
end
test "has link to create new schedule", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules")
assert has_element?(view, "a", "New Schedule")
end
test "has tabs for Schedules and Escalation Policies", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules")
assert html =~ "Schedules"
assert html =~ "Escalation Policies"
end
test "switching to escalation policies tab shows policies", %{
conn: conn,
organization: organization
} do
escalation_policy_fixture(organization.id, %{name: "Critical Alerts Policy"})
{:ok, view, _html} = live(conn, ~p"/schedules")
html = view |> element("a", "Escalation Policies") |> render_click()
assert html =~ "Critical Alerts Policy"
end
test "escalation policies tab shows empty state when none exist", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
assert html =~ "No escalation policies"
end
test "requires authentication" do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "Show" do
test "mounts and displays schedule details", %{conn: conn, organization: organization} do
schedule =
schedule_fixture(organization.id, %{
name: "Weekend Rotation",
timezone: "America/New_York"
})
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "Weekend Rotation"
assert html =~ "America/New_York"
end
test "shows description if present", %{conn: conn, organization: organization} do
schedule =
schedule_fixture(organization.id, %{
name: "Described Schedule",
description: "Covers weekday shifts"
})
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "Covers weekday shifts"
end
test "shows Currently On-Call section with no one on-call", %{
conn: conn,
organization: organization
} do
schedule = schedule_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "Currently On-Call"
assert html =~ "No one is currently on-call"
end
test "shows currently on-call user", %{conn: conn, organization: organization, user: user} do
schedule = schedule_fixture(organization.id)
layer =
layer_fixture(schedule.id, %{
start_date: ~U[2025-01-01 09:00:00Z],
handoff_time: ~T[09:00:00],
rotation_type: "weekly"
})
layer_member_fixture(layer.id, user.id)
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "Currently On-Call"
assert html =~ user.email
end
test "shows layers with rotation details", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
layer_fixture(schedule.id, %{
name: "Primary Layer",
rotation_type: "weekly",
rotation_interval: 2,
handoff_time: ~T[09:00:00]
})
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "Primary Layer"
assert html =~ "Weekly"
assert html =~ "09:00"
end
test "shows layer members", %{conn: conn, organization: organization, user: user} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id, %{name: "Layer Alpha"})
layer_member_fixture(layer.id, user.id)
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ user.email
end
test "shows empty state for layers", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "No layers configured"
end
test "shows empty state for overrides", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "No overrides configured"
end
test "can toggle add layer form", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
refute has_element?(view, "form[phx-submit='save_layer']")
view |> element("button", "Add Layer") |> render_click()
assert has_element?(view, "form[phx-submit='save_layer']")
end
test "can add a layer", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
view |> element("button", "Add Layer") |> render_click()
view
|> form("form[phx-submit='save_layer']",
layer: %{
name: "New Layer",
rotation_type: "daily",
rotation_interval: 1,
handoff_time: "09:00",
start_date: "2026-03-01T09:00"
}
)
|> render_submit()
html = render(view)
assert html =~ "New Layer"
refute has_element?(view, "form[phx-submit='save_layer']")
end
test "can add a member to a layer", %{conn: conn, organization: organization, user: user} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id, %{name: "Staffing Layer"})
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
view
|> form("form[phx-submit='add_member']", %{
layer_id: layer.id,
user_id: user.id
})
|> render_submit()
html = render(view)
assert html =~ user.email
end
test "can delete a layer", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id, %{name: "Temporary Layer"})
{:ok, view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "Temporary Layer"
view
|> element("button[phx-click='delete_layer'][phx-value-id='#{layer.id}']")
|> render_click()
html = render(view)
refute html =~ "Temporary Layer"
end
test "can remove a member", %{conn: conn, organization: organization, user: user} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id)
member = layer_member_fixture(layer.id, user.id)
{:ok, view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ user.email
view
|> element("button[phx-click='remove_member'][phx-value-id='#{member.id}']")
|> render_click()
# The user email should no longer appear as a member badge
refute has_element?(view, "span", user.email)
end
test "can toggle add override form", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
refute has_element?(view, "form[phx-submit='save_override']")
view |> element("button", "Add Override") |> render_click()
assert has_element?(view, "form[phx-submit='save_override']")
end
test "can add an override", %{conn: conn, organization: organization, user: user} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
view |> element("button", "Add Override") |> render_click()
view
|> form("form[phx-submit='save_override']",
override: %{
user_id: user.id,
start_time: "2026-04-01T09:00",
end_time: "2026-04-02T09:00"
}
)
|> render_submit()
html = render(view)
assert html =~ user.email
assert html =~ "2026-04-01"
refute has_element?(view, "form[phx-submit='save_override']")
end
test "can delete an override", %{conn: conn, organization: organization, user: user} do
schedule = schedule_fixture(organization.id)
override = override_fixture(schedule.id, user.id)
{:ok, view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ user.email
view
|> element("button[phx-click='delete_override'][phx-value-id='#{override.id}']")
|> render_click()
html = render(view)
assert html =~ "No overrides configured"
end
test "has edit and delete buttons", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
assert has_element?(view, "a", "Edit")
assert has_element?(view, "button", "Delete")
end
test "delete redirects to index", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
view |> element("button", "Delete") |> render_click()
{path, flash} = assert_redirect(view)
assert path == ~p"/schedules"
assert flash["info"] =~ "Schedule deleted"
end
test "requires authentication", %{organization: organization} do
schedule = schedule_fixture(organization.id)
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules/#{schedule.id}")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "Form - New" do
test "renders new schedule form", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules/new")
assert html =~ "New Schedule"
assert html =~ "Name"
assert html =~ "Timezone"
assert html =~ "Save Schedule"
end
test "validates required fields", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules/new")
html =
view
|> form("form", schedule: %{name: "", timezone: ""})
|> render_change()
assert html =~ "can&#39;t be blank"
end
test "creates schedule and redirects to show", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules/new")
view
|> form("form",
schedule: %{
name: "New On-Call Schedule",
timezone: "America/Chicago"
}
)
|> render_submit()
{path, flash} = assert_redirect(view)
assert path =~ ~r|^/schedules/.+|
assert flash["info"] =~ "Schedule created"
end
test "creates schedule with optional description", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/schedules/new")
view
|> form("form",
schedule: %{
name: "Documented Schedule",
timezone: "America/Chicago",
description: "Handles weekend coverage"
}
)
|> render_submit()
assert_redirect(view)
schedules = OnCall.list_schedules(organization.id)
schedule = Enum.find(schedules, &(&1.name == "Documented Schedule"))
assert schedule
end
test "requires authentication" do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules/new")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "Form - Edit" do
test "renders edit form with existing data", %{conn: conn, organization: organization} do
schedule =
schedule_fixture(organization.id, %{
name: "Existing Schedule",
timezone: "America/New_York"
})
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}/edit")
assert html =~ "Edit Schedule"
assert html =~ "Existing Schedule"
assert html =~ "America/New_York"
end
test "validates required fields on change", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}/edit")
html =
view
|> form("form", schedule: %{name: ""})
|> render_change()
assert html =~ "can&#39;t be blank"
end
test "updates schedule and redirects to show", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id, %{name: "Old Name"})
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}/edit")
view
|> form("form", schedule: %{name: "Updated Name"})
|> render_submit()
{path, flash} = assert_redirect(view)
assert path == ~p"/schedules/#{schedule.id}"
assert flash["info"] =~ "Schedule updated"
updated = OnCall.get_schedule!(schedule.id)
assert updated.name == "Updated Name"
end
test "requires authentication", %{organization: organization} do
schedule = schedule_fixture(organization.id)
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules/#{schedule.id}/edit")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
end