From 0cd2ed35673a4a9eb4ab864959ee35d6467397dd Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 11 Mar 2026 12:32:54 -0500 Subject: [PATCH] feat: add on-call schedules and escalation policies Built-in PagerDuty-equivalent system for on-call scheduling and alert escalation. Users can now manage schedules, rotation layers, overrides, and escalation policies directly in the app alongside PagerDuty. - On-call schedules with rotation layers (daily/weekly/custom), member management, and temporary overrides - Escalation policies with ordered rules, timeout-based escalation, and user/schedule targets - Automatic escalation via Oban worker with configurable repeat count - Email notifications via Swoosh for on-call alerts - Resolver computes who's on-call from layer stacking and overrides - AlertNotificationWorker integration: starts escalation alongside PagerDuty, acknowledges/resolves incidents on alert state changes - Device and organization schemas support escalation_policy_id - Escalation policy picker on device form - Schedules nav item with tabbed index (schedules + escalation policies) - Full CRUD UI for schedules, layers, members, overrides, rules, targets - 62 LiveView tests, 56 context/schema/resolver/escalation tests - 26 E2E Playwright tests for smoke and critical path coverage --- e2e/tests/schedules.spec.ts | 526 ++++++++++++++++++ lib/towerops/devices/device.ex | 6 +- lib/towerops/on_call.ex | 182 ++++++ lib/towerops/on_call/escalation.ex | 213 +++++++ lib/towerops/on_call/escalation_policy.ex | 36 ++ lib/towerops/on_call/escalation_rule.ex | 35 ++ lib/towerops/on_call/escalation_target.ex | 39 ++ lib/towerops/on_call/incident.ex | 50 ++ lib/towerops/on_call/layer.ex | 50 ++ lib/towerops/on_call/layer_member.ex | 34 ++ lib/towerops/on_call/notification.ex | 38 ++ lib/towerops/on_call/notifier.ex | 44 ++ lib/towerops/on_call/override.ex | 48 ++ lib/towerops/on_call/resolver.ex | 121 ++++ lib/towerops/on_call/schedule.ex | 37 ++ lib/towerops/organizations/organization.ex | 6 +- .../workers/alert_notification_worker.ex | 56 +- .../workers/escalation_check_worker.ex | 18 + lib/towerops_web/components/layouts.ex | 9 + lib/towerops_web/live/device_live/form.ex | 5 + .../live/device_live/form.html.heex | 17 + .../live/escalation_policy_live/form.ex | 83 +++ .../escalation_policy_live/form.html.heex | 46 ++ .../live/escalation_policy_live/index.ex | 18 + .../escalation_policy_live/index.html.heex | 71 +++ .../live/escalation_policy_live/show.ex | 115 ++++ .../escalation_policy_live/show.html.heex | 197 +++++++ lib/towerops_web/live/schedule_live/form.ex | 83 +++ .../live/schedule_live/form.html.heex | 46 ++ lib/towerops_web/live/schedule_live/index.ex | 48 ++ .../live/schedule_live/index.html.heex | 168 ++++++ lib/towerops_web/live/schedule_live/show.ex | 185 ++++++ .../live/schedule_live/show.html.heex | 342 ++++++++++++ lib/towerops_web/router.ex | 10 + ...0260311160550_create_on_call_schedules.exs | 71 +++ ...60311160554_create_escalation_policies.exs | 49 ++ ...te_on_call_incidents_and_notifications.exs | 50 ++ ...on_policy_to_devices_and_organizations.exs | 15 + test/support/fixtures/on_call_fixtures.ex | 110 ++++ test/towerops/on_call/escalation_test.exs | 149 +++++ test/towerops/on_call/resolver_test.exs | 308 ++++++++++ test/towerops/on_call/schema_test.exs | 249 +++++++++ test/towerops/on_call_test.exs | 217 ++++++++ .../live/escalation_policy_live_test.exs | 332 +++++++++++ test/towerops_web/live/schedule_live_test.exs | 499 +++++++++++++++++ 45 files changed, 5028 insertions(+), 3 deletions(-) create mode 100644 e2e/tests/schedules.spec.ts create mode 100644 lib/towerops/on_call.ex create mode 100644 lib/towerops/on_call/escalation.ex create mode 100644 lib/towerops/on_call/escalation_policy.ex create mode 100644 lib/towerops/on_call/escalation_rule.ex create mode 100644 lib/towerops/on_call/escalation_target.ex create mode 100644 lib/towerops/on_call/incident.ex create mode 100644 lib/towerops/on_call/layer.ex create mode 100644 lib/towerops/on_call/layer_member.ex create mode 100644 lib/towerops/on_call/notification.ex create mode 100644 lib/towerops/on_call/notifier.ex create mode 100644 lib/towerops/on_call/override.ex create mode 100644 lib/towerops/on_call/resolver.ex create mode 100644 lib/towerops/on_call/schedule.ex create mode 100644 lib/towerops/workers/escalation_check_worker.ex create mode 100644 lib/towerops_web/live/escalation_policy_live/form.ex create mode 100644 lib/towerops_web/live/escalation_policy_live/form.html.heex create mode 100644 lib/towerops_web/live/escalation_policy_live/index.ex create mode 100644 lib/towerops_web/live/escalation_policy_live/index.html.heex create mode 100644 lib/towerops_web/live/escalation_policy_live/show.ex create mode 100644 lib/towerops_web/live/escalation_policy_live/show.html.heex create mode 100644 lib/towerops_web/live/schedule_live/form.ex create mode 100644 lib/towerops_web/live/schedule_live/form.html.heex create mode 100644 lib/towerops_web/live/schedule_live/index.ex create mode 100644 lib/towerops_web/live/schedule_live/index.html.heex create mode 100644 lib/towerops_web/live/schedule_live/show.ex create mode 100644 lib/towerops_web/live/schedule_live/show.html.heex create mode 100644 priv/repo/migrations/20260311160550_create_on_call_schedules.exs create mode 100644 priv/repo/migrations/20260311160554_create_escalation_policies.exs create mode 100644 priv/repo/migrations/20260311160559_create_on_call_incidents_and_notifications.exs create mode 100644 priv/repo/migrations/20260311160604_add_escalation_policy_to_devices_and_organizations.exs create mode 100644 test/support/fixtures/on_call_fixtures.ex create mode 100644 test/towerops/on_call/escalation_test.exs create mode 100644 test/towerops/on_call/resolver_test.exs create mode 100644 test/towerops/on_call/schema_test.exs create mode 100644 test/towerops/on_call_test.exs create mode 100644 test/towerops_web/live/escalation_policy_live_test.exs create mode 100644 test/towerops_web/live/schedule_live_test.exs 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 %> +
+ + + + + + + + + <%= for policy <- @policies do %> + + + + + <% end %> + +
+ {t("Name")} + + {t("Repeat Count")} +
+ {policy.name} + + {policy.repeat_count} +
+
+ <% 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 %> +
+
+
+
+ + +
+
+
+ <.button type="submit" phx-disable-with={t("Saving...")}> + {t("Add Rule")} + + +
+
+
+ <% 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 %> +
+ + + + + + + + + + <%= for policy <- @policies do %> + + + + + + <% end %> + +
+ {t("Name")} + + {t("Rules")} + + {t("Repeat Count")} +
+ {policy.name} + + — + + {policy.repeat_count} +
+
+ <% 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 %> +
+ + + + + + + + + + <%= for schedule <- @schedules do %> + + + + + + <% end %> + +
+ {t("Name")} + + {t("Timezone")} + + {t("Currently On-Call")} +
+ {schedule.name} + + {schedule.timezone} + + <%= if schedule.current_on_call do %> + + + {schedule.current_on_call.email} + + <% else %> + {t("No one")} + <% 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 %> +
+ + + + + + + + + + + <%= for override <- @schedule.overrides do %> + + + + + + + <% end %> + +
+ {t("User")} + + {t("Start")} + + {t("End")} +
+ {override.user.email} + + {Calendar.strftime(override.start_time, "%Y-%m-%d %H:%M")} + + {Calendar.strftime(override.end_time, "%Y-%m-%d %H:%M")} + + +
+
+ <% 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