diff --git a/e2e/tests/schedules.spec.ts b/e2e/tests/schedules.spec.ts
new file mode 100644
index 00000000..d3ff2057
--- /dev/null
+++ b/e2e/tests/schedules.spec.ts
@@ -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);
+ }
+ }
+ }
+ }
+ });
+ });
+});
diff --git a/lib/towerops/devices/device.ex b/lib/towerops/devices/device.ex
index a27dcad0..59f7832d 100644
--- a/lib/towerops/devices/device.ex
+++ b/lib/towerops/devices/device.ex
@@ -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
diff --git a/lib/towerops/on_call.ex b/lib/towerops/on_call.ex
new file mode 100644
index 00000000..2ccbdc56
--- /dev/null
+++ b/lib/towerops/on_call.ex
@@ -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
diff --git a/lib/towerops/on_call/escalation.ex b/lib/towerops/on_call/escalation.ex
new file mode 100644
index 00000000..8487ca56
--- /dev/null
+++ b/lib/towerops/on_call/escalation.ex
@@ -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
diff --git a/lib/towerops/on_call/escalation_policy.ex b/lib/towerops/on_call/escalation_policy.ex
new file mode 100644
index 00000000..93eb408c
--- /dev/null
+++ b/lib/towerops/on_call/escalation_policy.ex
@@ -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
diff --git a/lib/towerops/on_call/escalation_rule.ex b/lib/towerops/on_call/escalation_rule.ex
new file mode 100644
index 00000000..2eb03644
--- /dev/null
+++ b/lib/towerops/on_call/escalation_rule.ex
@@ -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
diff --git a/lib/towerops/on_call/escalation_target.ex b/lib/towerops/on_call/escalation_target.ex
new file mode 100644
index 00000000..ab1890a9
--- /dev/null
+++ b/lib/towerops/on_call/escalation_target.ex
@@ -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
diff --git a/lib/towerops/on_call/incident.ex b/lib/towerops/on_call/incident.ex
new file mode 100644
index 00000000..f8d1bbd1
--- /dev/null
+++ b/lib/towerops/on_call/incident.ex
@@ -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
diff --git a/lib/towerops/on_call/layer.ex b/lib/towerops/on_call/layer.ex
new file mode 100644
index 00000000..56160dbb
--- /dev/null
+++ b/lib/towerops/on_call/layer.ex
@@ -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
diff --git a/lib/towerops/on_call/layer_member.ex b/lib/towerops/on_call/layer_member.ex
new file mode 100644
index 00000000..fd64560c
--- /dev/null
+++ b/lib/towerops/on_call/layer_member.ex
@@ -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
diff --git a/lib/towerops/on_call/notification.ex b/lib/towerops/on_call/notification.ex
new file mode 100644
index 00000000..5e6700db
--- /dev/null
+++ b/lib/towerops/on_call/notification.ex
@@ -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
diff --git a/lib/towerops/on_call/notifier.ex b/lib/towerops/on_call/notifier.ex
new file mode 100644
index 00000000..a9e02a42
--- /dev/null
+++ b/lib/towerops/on_call/notifier.ex
@@ -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
diff --git a/lib/towerops/on_call/override.ex b/lib/towerops/on_call/override.ex
new file mode 100644
index 00000000..9bbdcab0
--- /dev/null
+++ b/lib/towerops/on_call/override.ex
@@ -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
diff --git a/lib/towerops/on_call/resolver.ex b/lib/towerops/on_call/resolver.ex
new file mode 100644
index 00000000..ff042079
--- /dev/null
+++ b/lib/towerops/on_call/resolver.ex
@@ -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
diff --git a/lib/towerops/on_call/schedule.ex b/lib/towerops/on_call/schedule.ex
new file mode 100644
index 00000000..92a77348
--- /dev/null
+++ b/lib/towerops/on_call/schedule.ex
@@ -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
diff --git a/lib/towerops/organizations/organization.ex b/lib/towerops/organizations/organization.ex
index 9d261e44..db842256 100644
--- a/lib/towerops/organizations/organization.ex
+++ b/lib/towerops/organizations/organization.ex
@@ -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 """
diff --git a/lib/towerops/workers/alert_notification_worker.ex b/lib/towerops/workers/alert_notification_worker.ex
index eda44cb7..4df611b5 100644
--- a/lib/towerops/workers/alert_notification_worker.ex
+++ b/lib/towerops/workers/alert_notification_worker.ex
@@ -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}
diff --git a/lib/towerops/workers/escalation_check_worker.ex b/lib/towerops/workers/escalation_check_worker.ex
new file mode 100644
index 00000000..094786f2
--- /dev/null
+++ b/lib/towerops/workers/escalation_check_worker.ex
@@ -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
diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex
index 648dd0b6..c7af218f 100644
--- a/lib/towerops_web/components/layouts.ex
+++ b/lib/towerops_web/components/layouts.ex
@@ -287,6 +287,12 @@ defmodule ToweropsWeb.Layouts do
>
{t("Maintenance")}
+ <.nav_link
+ navigate={~p"/schedules"}
+ active={@active_page == "schedules"}
+ >
+ {t("Schedules")}
+
@@ -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 navigate={~p"/schedules"} active={@active_page == "schedules"}>
+ <.icon name="hero-calendar-days" class="size-5" /> {t("Schedules")}
+
diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex
index 78b81184..550dca87 100644
--- a/lib/towerops_web/live/device_live/form.ex
+++ b/lib/towerops_web/live/device_live/form.ex
@@ -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)
diff --git a/lib/towerops_web/live/device_live/form.html.heex b/lib/towerops_web/live/device_live/form.html.heex
index fda89bf6..552abfcd 100644
--- a/lib/towerops_web/live/device_live/form.html.heex
+++ b/lib/towerops_web/live/device_live/form.html.heex
@@ -299,6 +299,23 @@
<% end %>
<% end %>
+
+ <%= if @escalation_policies != [] do %>
+
+ <.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})}
+ />
+
+ {t(
+ "Select an escalation policy for alert notifications. Leave empty to use the organization default."
+ )}
+
+
+ <% end %>
diff --git a/lib/towerops_web/live/escalation_policy_live/form.ex b/lib/towerops_web/live/escalation_policy_live/form.ex
new file mode 100644
index 00000000..69758d3f
--- /dev/null
+++ b/lib/towerops_web/live/escalation_policy_live/form.ex
@@ -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
diff --git a/lib/towerops_web/live/escalation_policy_live/form.html.heex b/lib/towerops_web/live/escalation_policy_live/form.html.heex
new file mode 100644
index 00000000..8d3c5361
--- /dev/null
+++ b/lib/towerops_web/live/escalation_policy_live/form.html.heex
@@ -0,0 +1,46 @@
+
+
+ <.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" />
+
+
{@page_title}
+
+
+
+ <.form for={@form} phx-change="validate" phx-submit="save" class="space-y-6">
+
+ <.input field={@form[:name]} type="text" label={t("Name")} required />
+
+
+ <.input field={@form[:description]} type="textarea" label={t("Description")} />
+
+
+ <.input
+ field={@form[:repeat_count]}
+ type="number"
+ label={t("Repeat Count")}
+ min="1"
+ max="10"
+ />
+
+
+ <.button type="submit" phx-disable-with={t("Saving...")}>
+ {t("Save Policy")}
+
+ <.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")}
+
+
+
+
+
diff --git a/lib/towerops_web/live/escalation_policy_live/index.ex b/lib/towerops_web/live/escalation_policy_live/index.ex
new file mode 100644
index 00000000..10b24690
--- /dev/null
+++ b/lib/towerops_web/live/escalation_policy_live/index.ex
@@ -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
diff --git a/lib/towerops_web/live/escalation_policy_live/index.html.heex b/lib/towerops_web/live/escalation_policy_live/index.html.heex
new file mode 100644
index 00000000..a50137b4
--- /dev/null
+++ b/lib/towerops_web/live/escalation_policy_live/index.html.heex
@@ -0,0 +1,71 @@
+
+
+
+ <.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" />
+
+
{t("Escalation Policies")}
+
+ <.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")}
+
+
+
+ <%= if Enum.empty?(@policies) do %>
+
+
+ <.icon
+ name="hero-arrow-trending-up"
+ class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
+ />
+
+ {t("No escalation policies")}
+
+
+ {t("Create an escalation policy to define how alerts are escalated to on-call users.")}
+
+
+
+ <% else %>
+
+
+
+
+ |
+ {t("Name")}
+ |
+
+ {t("Repeat Count")}
+ |
+
+
+
+ <%= for policy <- @policies do %>
+
+ |
+ {policy.name}
+ |
+
+ {policy.repeat_count}
+ |
+
+ <% end %>
+
+
+
+ <% end %>
+
diff --git a/lib/towerops_web/live/escalation_policy_live/show.ex b/lib/towerops_web/live/escalation_policy_live/show.ex
new file mode 100644
index 00000000..c535bef2
--- /dev/null
+++ b/lib/towerops_web/live/escalation_policy_live/show.ex
@@ -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
diff --git a/lib/towerops_web/live/escalation_policy_live/show.html.heex b/lib/towerops_web/live/escalation_policy_live/show.html.heex
new file mode 100644
index 00000000..d944bd6e
--- /dev/null
+++ b/lib/towerops_web/live/escalation_policy_live/show.html.heex
@@ -0,0 +1,197 @@
+
+
+
+ <.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" />
+
+
+
{@policy.name}
+
+ {t("Repeats %{count} times", count: @policy.repeat_count)}
+
+
+
+
+ <.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")}
+
+
+
+
+
+ <%= if @policy.description do %>
+ {@policy.description}
+ <% end %>
+
+
+
+
+ {t("Escalation Rules")}
+
+
+
+
+ <%= if @show_add_rule do %>
+
+ <% end %>
+
+ <%= if Enum.empty?(@policy.rules) do %>
+
+
+ {t("No rules configured. Add a rule to define escalation steps.")}
+
+
+ <% else %>
+
+ <%= for rule <- Enum.sort_by(@policy.rules, & &1.position) do %>
+
+
+
+ {t("Step %{position}", position: rule.position + 1)}
+
+
+
+ {t("Escalate after %{minutes} min", minutes: rule.timeout_minutes)}
+
+
+
+
+
+ <%!-- Targets --%>
+
+
+ {t("Targets")}
+
+ <%= if Enum.empty?(rule.targets) do %>
+
+ {t("No targets yet")}
+
+ <% else %>
+
+ <%= for target <- rule.targets do %>
+
+ <%= 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 %>
+
+
+ <% end %>
+
+ <% end %>
+
+ <%!-- Add target form --%>
+
+
+
+ <% end %>
+
+ <% end %>
+
+
diff --git a/lib/towerops_web/live/schedule_live/form.ex b/lib/towerops_web/live/schedule_live/form.ex
new file mode 100644
index 00000000..81eca8fe
--- /dev/null
+++ b/lib/towerops_web/live/schedule_live/form.ex
@@ -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
diff --git a/lib/towerops_web/live/schedule_live/form.html.heex b/lib/towerops_web/live/schedule_live/form.html.heex
new file mode 100644
index 00000000..8f7bee84
--- /dev/null
+++ b/lib/towerops_web/live/schedule_live/form.html.heex
@@ -0,0 +1,46 @@
+
+
+ <.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" />
+
+
{@page_title}
+
+
+
+ <.form for={@form} phx-change="validate" phx-submit="save" class="space-y-6">
+
+ <.input field={@form[:name]} type="text" label={t("Name")} required />
+
+
+ <.input
+ field={@form[:timezone]}
+ type="text"
+ label={t("Timezone")}
+ placeholder="America/Chicago"
+ required
+ />
+
+
+ <.input field={@form[:description]} type="textarea" label={t("Description")} />
+
+
+ <.button type="submit" phx-disable-with={t("Saving...")}>
+ {t("Save Schedule")}
+
+ <.link
+ navigate={~p"/schedules"}
+ class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
+ >
+ {t("Cancel")}
+
+
+
+
+
diff --git a/lib/towerops_web/live/schedule_live/index.ex b/lib/towerops_web/live/schedule_live/index.ex
new file mode 100644
index 00000000..3cac9942
--- /dev/null
+++ b/lib/towerops_web/live/schedule_live/index.ex
@@ -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
diff --git a/lib/towerops_web/live/schedule_live/index.html.heex b/lib/towerops_web/live/schedule_live/index.html.heex
new file mode 100644
index 00000000..2afa6b2f
--- /dev/null
+++ b/lib/towerops_web/live/schedule_live/index.html.heex
@@ -0,0 +1,168 @@
+
+
+
{t("Schedules")}
+
+ <%= 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")}
+
+ <% 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")}
+
+ <% end %>
+
+
+
+
+ <.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
+ 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")}
+
+
+
+ <%= if @tab == "escalation-policies" do %>
+ <%= if Enum.empty?(@policies) do %>
+
+
+ <.icon
+ name="hero-arrow-trending-up"
+ class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
+ />
+
+ {t("No escalation policies")}
+
+
+ {t("Create an escalation policy to define how alerts are escalated to on-call users.")}
+
+
+
+ <% else %>
+
+
+
+
+ |
+ {t("Name")}
+ |
+
+ {t("Rules")}
+ |
+
+ {t("Repeat Count")}
+ |
+
+
+
+ <%= for policy <- @policies do %>
+
+ |
+ {policy.name}
+ |
+
+ —
+ |
+
+ {policy.repeat_count}
+ |
+
+ <% end %>
+
+
+
+ <% end %>
+ <% else %>
+ <%= if Enum.empty?(@schedules) do %>
+
+
+ <.icon
+ name="hero-calendar-days"
+ class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
+ />
+
+ {t("No schedules")}
+
+
+ {t("Create an on-call schedule to define rotation coverage.")}
+
+
+
+ <% else %>
+
+
+
+
+ |
+ {t("Name")}
+ |
+
+ {t("Timezone")}
+ |
+
+ {t("Currently On-Call")}
+ |
+
+
+
+ <%= for schedule <- @schedules do %>
+
+ |
+ {schedule.name}
+ |
+
+ {schedule.timezone}
+ |
+
+ <%= if schedule.current_on_call do %>
+
+
+ {schedule.current_on_call.email}
+
+ <% else %>
+ {t("No one")}
+ <% end %>
+ |
+
+ <% end %>
+
+
+
+ <% end %>
+ <% end %>
+
diff --git a/lib/towerops_web/live/schedule_live/show.ex b/lib/towerops_web/live/schedule_live/show.ex
new file mode 100644
index 00000000..4bc6fdb1
--- /dev/null
+++ b/lib/towerops_web/live/schedule_live/show.ex
@@ -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
diff --git a/lib/towerops_web/live/schedule_live/show.html.heex b/lib/towerops_web/live/schedule_live/show.html.heex
new file mode 100644
index 00000000..77a8a7bb
--- /dev/null
+++ b/lib/towerops_web/live/schedule_live/show.html.heex
@@ -0,0 +1,342 @@
+
+
+
+ <.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" />
+
+
+
{@schedule.name}
+
{@schedule.timezone}
+
+
+
+ <.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")}
+
+
+
+
+
+ <%= if @schedule.description do %>
+ {@schedule.description}
+ <% end %>
+
+ <%!-- On-Call Status --%>
+
+
+
+ <.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"
+ ]}
+ />
+
+
+
+ {t("Currently On-Call")}
+
+ <%= if @on_call do %>
+
{@on_call.email}
+ <% else %>
+
+ {t("No one is currently on-call")}
+
+ <% end %>
+
+
+
+
+ <%!-- Layers --%>
+
+
+
{t("Layers")}
+
+
+
+ <%= if @show_add_layer do %>
+
+ <.form for={@layer_form} phx-change="validate_layer" phx-submit="save_layer">
+
+ <.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")}
+ />
+
+
+ <.button type="submit" phx-disable-with={t("Saving...")}>
+ {t("Add Layer")}
+
+
+
+
+
+ <% end %>
+
+ <%= if Enum.empty?(@schedule.layers) do %>
+
+
+ {t("No layers configured. Add a layer to define rotation coverage.")}
+
+
+ <% else %>
+
+ <%= for layer <- Enum.sort_by(@schedule.layers, & &1.position) do %>
+
+
+
+
{layer.name}
+
+ {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")}
+
+
+
+
+
+ <%!-- Members --%>
+
+
+ {t("Members")}
+
+ <%= if Enum.empty?(layer.members) do %>
+
+ {t("No members yet")}
+
+ <% else %>
+
+ <%= for member <- Enum.sort_by(layer.members, & &1.position) do %>
+
+ {member.user.email}
+
+
+ <% end %>
+
+ <% 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 %>
+
+ <% end %>
+
+
+ <% end %>
+
+ <% end %>
+
+
+ <%!-- Overrides --%>
+
+
+
{t("Overrides")}
+
+
+
+ <%= if @show_add_override do %>
+
+ <.form for={@override_form} phx-change="validate_override" phx-submit="save_override">
+
+ <.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")}
+ />
+
+
+ <.button type="submit" phx-disable-with={t("Saving...")}>
+ {t("Add Override")}
+
+
+
+
+
+ <% end %>
+
+ <%= if Enum.empty?(@schedule.overrides) do %>
+
+
+ {t("No overrides configured.")}
+
+
+ <% else %>
+
+
+
+
+ |
+ {t("User")}
+ |
+
+ {t("Start")}
+ |
+
+ {t("End")}
+ |
+ |
+
+
+
+ <%= for override <- @schedule.overrides do %>
+
+ |
+ {override.user.email}
+ |
+
+ {Calendar.strftime(override.start_time, "%Y-%m-%d %H:%M")}
+ |
+
+ {Calendar.strftime(override.end_time, "%Y-%m-%d %H:%M")}
+ |
+
+
+ |
+
+ <% end %>
+
+
+
+ <% end %>
+
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex
index 948cd600..3f43e8d2 100644
--- a/lib/towerops_web/router.ex
+++ b/lib/towerops_web/router.ex
@@ -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
diff --git a/priv/repo/migrations/20260311160550_create_on_call_schedules.exs b/priv/repo/migrations/20260311160550_create_on_call_schedules.exs
new file mode 100644
index 00000000..3080a9a3
--- /dev/null
+++ b/priv/repo/migrations/20260311160550_create_on_call_schedules.exs
@@ -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
diff --git a/priv/repo/migrations/20260311160554_create_escalation_policies.exs b/priv/repo/migrations/20260311160554_create_escalation_policies.exs
new file mode 100644
index 00000000..a91c552e
--- /dev/null
+++ b/priv/repo/migrations/20260311160554_create_escalation_policies.exs
@@ -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
diff --git a/priv/repo/migrations/20260311160559_create_on_call_incidents_and_notifications.exs b/priv/repo/migrations/20260311160559_create_on_call_incidents_and_notifications.exs
new file mode 100644
index 00000000..2308ad6e
--- /dev/null
+++ b/priv/repo/migrations/20260311160559_create_on_call_incidents_and_notifications.exs
@@ -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
diff --git a/priv/repo/migrations/20260311160604_add_escalation_policy_to_devices_and_organizations.exs b/priv/repo/migrations/20260311160604_add_escalation_policy_to_devices_and_organizations.exs
new file mode 100644
index 00000000..f7c195dc
--- /dev/null
+++ b/priv/repo/migrations/20260311160604_add_escalation_policy_to_devices_and_organizations.exs
@@ -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
diff --git a/test/support/fixtures/on_call_fixtures.ex b/test/support/fixtures/on_call_fixtures.ex
new file mode 100644
index 00000000..ca6bb19e
--- /dev/null
+++ b/test/support/fixtures/on_call_fixtures.ex
@@ -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
diff --git a/test/towerops/on_call/escalation_test.exs b/test/towerops/on_call/escalation_test.exs
new file mode 100644
index 00000000..a74f69b7
--- /dev/null
+++ b/test/towerops/on_call/escalation_test.exs
@@ -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
diff --git a/test/towerops/on_call/resolver_test.exs b/test/towerops/on_call/resolver_test.exs
new file mode 100644
index 00000000..d6ce8134
--- /dev/null
+++ b/test/towerops/on_call/resolver_test.exs
@@ -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
diff --git a/test/towerops/on_call/schema_test.exs b/test/towerops/on_call/schema_test.exs
new file mode 100644
index 00000000..4edce5fc
--- /dev/null
+++ b/test/towerops/on_call/schema_test.exs
@@ -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
diff --git a/test/towerops/on_call_test.exs b/test/towerops/on_call_test.exs
new file mode 100644
index 00000000..3c50deb5
--- /dev/null
+++ b/test/towerops/on_call_test.exs
@@ -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
diff --git a/test/towerops_web/live/escalation_policy_live_test.exs b/test/towerops_web/live/escalation_policy_live_test.exs
new file mode 100644
index 00000000..e8c967a7
--- /dev/null
+++ b/test/towerops_web/live/escalation_policy_live_test.exs
@@ -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'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'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
diff --git a/test/towerops_web/live/schedule_live_test.exs b/test/towerops_web/live/schedule_live_test.exs
new file mode 100644
index 00000000..e7536c8a
--- /dev/null
+++ b/test/towerops_web/live/schedule_live_test.exs
@@ -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'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'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