diff --git a/docs/v1.5/README.md b/docs/v1.5/README.md index 5150ff11..ce24001a 100644 --- a/docs/v1.5/README.md +++ b/docs/v1.5/README.md @@ -16,7 +16,7 @@ This tree is **v1.4**. Switch versions in the sidebar for older releases. - [Navigation, search, and alerts](./core-concepts/navigation-search-notifications) — Find records and use the in-app inbox - [Installation](./getting-started/installation) — Compose, Helm, Kustomize, from source. First boot needs `NEXTAUTH_SECRET` and `ENCRYPTION_KEY`. - [Troubleshooting](./troubleshooting) — Compose, database, auth, paging -- [Notifications](./administration/notifications) — How someone actually gets paged (no voice). +- [Notifications](./administration/notifications) — How someone actually gets paged (no voice), including user-controlled Quiet Hours. - [Incidents](./core-concepts/incidents) - [Escalation policies](./core-concepts/escalation-policies) - [On-call schedules](./core-concepts/schedules) diff --git a/docs/v1.5/administration/notifications.md b/docs/v1.5/administration/notifications.md index 0111ccbf..ab57ecbf 100644 --- a/docs/v1.5/administration/notifications.md +++ b/docs/v1.5/administration/notifications.md @@ -15,6 +15,7 @@ valid incident recipient + enabled workspace provider + enabled user preference and contact/device data + escalation or service event + + user Quiet Hours policy when explicitly enabled → notification attempt and history ``` @@ -23,7 +24,7 @@ Saving one layer does not verify the whole path. Test every production recipient ## Permissions and settings - An application **Admin** configures workspace providers in **Settings → Notification Providers**. -- Each user configures personal Email, SMS, Push, and WhatsApp preferences under **Settings → Notifications**. +- Each user configures personal Email, SMS, Push, WhatsApp, and Quiet Hours preferences under **Settings → Profile & Preferences → Notification Preferences**. - Admins/Responders configure service-level Slack and webhook events under **Service → Settings**. - Policy administration is Admin-only. New steps in the current v1.4 UI inherit user preferences; the UI does not expose new per-step channel overrides. - Signed-in users can open **Settings → Notification History**; access to operational data should still be governed by deployment policy. @@ -55,10 +56,32 @@ It attempts channels in order and normally stops after the first successful non- This is ordered fallback, not guaranteed fan-out to every enabled channel. An in-app notification is created separately even when no external channel is available. -Stored escalation-channel data, when present, is intersected with the user's available channels. If the intersection is empty, the implementation falls back to the user's available preferences rather than dropping the page. +Stored escalation-channel data, when present, is intersected with the user's available channels. If the intersection is empty, the implementation falls back to the user's available preferences rather than dropping the page. Quiet Hours filtering remains in force during fallback, so an intentionally suppressed LOW-urgency channel is not reintroduced by the fallback path. Service-level Slack/webhook/email/SMS/push/WhatsApp notifications are a separate path selected by service event settings. Avoid configuring duplicate paths until you have observed their combined behavior. +## Quiet Hours + +Quiet Hours is a **personal, explicit opt-in** notification policy. It is **off by default for both existing and new users**. OpsKnight never enables it automatically during an upgrade or when an account is created. + +Users configure it at **Settings → Profile & Preferences → Notification Preferences → Quiet Hours**. When enabled, they can choose: + +- start time; +- end time; and +- whether Saturday and Sunday are quiet all day. + +The configured times are evaluated in the user's profile timezone. The initial editable schedule is 18:00–08:00 with all-day weekends, but it has no effect until the user turns Quiet Hours on. + +During an active Quiet Hours window: + +- **LOW urgency**: Push, SMS, and WhatsApp are suppressed; +- **Email and in-app** notifications remain available; and +- **MEDIUM and HIGH urgency** bypass Quiet Hours and continue paging normally. + +Suppression is intentional policy behavior, not a provider failure. OpsKnight does not create a false failed-delivery result merely because a channel was excluded by Quiet Hours. Invalid timezone/window configuration fails open rather than silently suppressing a page. + +For incident-response safety, do not use Quiet Hours as a substitute for escalation-policy design, schedule coverage, or urgency mapping. If a responder must always receive a class of alert, classify and route it appropriately instead of relying on a personal LOW-urgency policy. + ## Configure email Choose exactly the provider intended for workspace email and supply: @@ -157,6 +180,7 @@ For every on-call user: - [ ] at least one external channel is enabled and usable; - [ ] phone number is E.164 when SMS/WhatsApp is enabled; - [ ] push is registered on the intended device; +- [ ] Quiet Hours is understood and intentionally configured if enabled; - [ ] Team notification participation is enabled for team-targeted paging; - [ ] a direct test policy reaches the user; - [ ] the user can open and acknowledge the incident. @@ -181,7 +205,7 @@ There is no manual Retry button in the history page. Correct the provider or rec ### No notification record -Check that the incident actually targeted the user/team/schedule, the policy ran, assignment and lifecycle event are correct, and the user was eligible. Review the incident timeline and escalation state. +Check that the incident actually targeted the user/team/schedule, the policy ran, assignment and lifecycle event are correct, and the user was eligible. For LOW urgency, also check whether the user explicitly enabled Quiet Hours and the channel was intentionally suppressed. Review the incident timeline and escalation state. ### `FAILED` email @@ -193,7 +217,7 @@ For Twilio, check credentials, sender capability, trial verification, regional p ### Push does not arrive -Check HTTPS, browser support and permission, service worker `/sw.js`, VAPID public/private pairing, saved subscription, user preference, OS/browser background restrictions, and Test Push. +Check HTTPS, browser support and permission, service worker `/sw.js`, VAPID public/private pairing, saved subscription, user preference, Quiet Hours for LOW urgency, OS/browser background restrictions, and Test Push. ### Slack or webhook fails diff --git a/docs/v1.5/core-concepts/escalation-policies.md b/docs/v1.5/core-concepts/escalation-policies.md index de318163..ae66b53a 100644 --- a/docs/v1.5/core-concepts/escalation-policies.md +++ b/docs/v1.5/core-concepts/escalation-policies.md @@ -17,11 +17,11 @@ Only an application **Admin** can create, change, reorder, or delete policies an 3. It resolves the step target to one or more users, assigns an unassigned incident to the target, and sends notifications. 4. It schedules the next step using that next step's delay. 5. Acknowledging, resolving, snoozing, or suppressing the incident stops or pauses further escalation according to the incident lifecycle. -6. If a target is invalid or resolves to no users, the timeline records the failure and OpsKnight advances to the next step. After the last step, escalation is complete. +6. If a target is invalid or resolves to no users, the timeline records the failure and OpsKnight advances when another step exists. If the final step cannot resolve a valid recipient, the escalation ends in `FAILED` rather than being reported as successfully completed. Delays mean “wait before this step,” not “wait after the previous notification.” A zero delay executes the step immediately. -Policies do not repeat in v1.4. After the last step is exhausted, the escalation is complete. +Policies do not repeat in v1.4. A policy that executes through its valid steps can finish as completed; terminal routing failures remain failed so operators can distinguish exhaustion from successful execution. ## Target types @@ -49,6 +49,8 @@ The policy can be saved with no steps, but it cannot page anyone until at least New steps created in the current v1.4 policy interface do not expose a per-step channel selector. They use each resolved user's enabled notification preferences and the configured workspace providers. Although the data model supports stored step-channel overrides, do not depend on an undocumented database-level configuration as a public workflow. +Personal Quiet Hours is a separate recipient policy. It is off by default and must be explicitly enabled by the user. When active, it can suppress LOW-urgency Push, SMS, and WhatsApp delivery for that recipient; Email and in-app remain available, and MEDIUM/HIGH urgency bypasses Quiet Hours. Fallback does not reintroduce a channel that Quiet Hours intentionally suppressed. + Likewise, the current add-step interface does not expose the Team Lead-only toggle. Existing lead-only steps can execute and are labeled in the policy view, but new policy design should not depend on setting that flag through the v1.4 UI. ## Design a resilient policy @@ -70,6 +72,7 @@ For every policy: - monitor schedule end dates and coverage gaps; - ensure each target has at least one configured delivery channel; - use descriptions that distinguish critical and non-critical paths; +- verify urgency mapping is appropriate for alerts that must always page regardless of personal Quiet Hours; and - retest after membership, schedule, provider, or policy changes. ## Reorder and edit safely @@ -102,13 +105,14 @@ Use a non-production service or coordinated test window: 4. Allow the next step to execute and verify its target and timing. 5. Acknowledge and confirm no later step runs. 6. Repeat during a schedule override and a known coverage edge. -7. Resolve and remove test artifacts according to retention policy. +7. For a LOW-urgency test, explicitly verify Quiet Hours behavior only on a user who chose to enable it. +8. Resolve and remove test artifacts according to retention policy. ## Troubleshooting ### Escalation does not start -Confirm the service has this policy, the incident is Open, the policy has steps, and the incident escalation state is not already completed or paused. +Confirm the service has this policy, the incident is Open, the policy has steps, and the incident escalation state is not already completed, failed, or paused. ### A step resolves to no users @@ -116,11 +120,11 @@ Confirm the service has this policy, the incident is Open, the policy has steps, - Team: confirm eligible members have team notifications enabled; for a lead-only legacy step, configure a Team Lead and enable their team notifications. - Schedule: inspect effective coverage at the execution time, including timezone, restrictions, gaps, priority, and overrides. -OpsKnight records the problem in the incident timeline and advances when another step exists. +OpsKnight records the problem in the incident timeline and advances when another step exists. If the final step has an invalid target or no eligible users, the terminal escalation state remains `FAILED` so the routing problem is visible instead of being mislabeled `COMPLETED`. ### A target is correct but receives no message -Check the user's notification preferences, contact/device data, provider settings, notification history, and system logs. A policy target is not proof that a delivery provider accepted the message. +Check the user's notification preferences, contact/device data, Quiet Hours state for LOW urgency, provider settings, notification history, and system logs. A policy target is not proof that a delivery provider accepted the message. ### Later steps continue after response diff --git a/docs/v1.5/core-concepts/users.md b/docs/v1.5/core-concepts/users.md index e5c4f3e9..d29877cb 100644 --- a/docs/v1.5/core-concepts/users.md +++ b/docs/v1.5/core-concepts/users.md @@ -86,16 +86,21 @@ Each user can open **Settings → Profile** to manage supported profile fields, OpsKnight provides a zero-latency, local `@dicebear` vector avatar generator at `/api/avatar`. Users can select from 15 curated SVG style presets (including `bottts`, `shapes`, `initials`, `personas`, `identicon`, `avataaars`, `thumbs`, `lorelei`, `notionists`, `open-peeps`, `micah`, `miniavs`, `pixel-art`, `rings`, and `glass`) with gender-appropriate styling. Avatar SVGs are rendered locally without third-party network dependencies, cached immutably with `Cache-Control: public, max-age=31536000, immutable`, and served with strict SVG sandbox headers (`Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`). -Timezone affects how the application displays dates for that user. Schedule calculation remains authoritative in each schedule's timezone. +Timezone affects how the application displays dates for that user. Schedule calculation remains authoritative in each schedule's timezone. Quiet Hours also evaluates its configured times in this profile timezone. ## Notification preferences -Open **Settings → Notifications** to enable supported user channels: +Open **Settings → Profile & Preferences → Notification Preferences** to configure personal notification behavior: - Email; - SMS, with a phone number; - Push, with a registered supported device and provider; -- WhatsApp, with a phone number in E.164 format. +- WhatsApp, with a phone number in E.164 format; and +- **Quiet Hours**, an optional LOW-urgency suppression policy. + +Quiet Hours is **off by default** for both existing and new users. OpsKnight does not silently mute paging after an upgrade or account creation. A user must explicitly enable Quiet Hours before it can suppress any delivery channel. + +When enabled, the user chooses a start time, end time, and whether weekends are quiet all day. Times use the user's profile timezone. During an active Quiet Hours window, only LOW-urgency Push, SMS, and WhatsApp delivery is suppressed. Email and in-app notifications remain available, and MEDIUM/HIGH urgency bypasses Quiet Hours entirely. These switches express user preference; they do not configure workspace providers. Delivery requires all of the following: @@ -103,9 +108,10 @@ These switches express user preference; they do not configure workspace provider 2. the user enabled the channel; 3. required contact/device data exists; 4. the escalation or service event selects or inherits that channel; -5. the provider accepts the message. +5. Quiet Hours does not intentionally suppress that LOW-urgency disruptive channel; and +6. the provider accepts the message. -Team paging also respects the membership's `Receive team notifications` setting. Test the full chain and review notification history rather than assuming a saved switch guarantees delivery. +Team paging also respects the membership's `Receive team notifications` setting. Test the full chain and review notification history rather than assuming a saved switch guarantees delivery. Quiet Hours suppression is an intentional policy decision and should not be interpreted as a provider delivery failure. ## Passwords and sessions @@ -167,7 +173,7 @@ If the menu shows **Allow OIDC linking**, approval is not currently present. If ### A user is targeted but receives no page -Confirm account status, channel preference, contact/device data, team notification participation, workspace provider, and notification history. +Confirm account status, channel preference, contact/device data, team notification participation, Quiet Hours state for LOW urgency, workspace provider, and notification history. ### An account cannot be removed diff --git a/docs/v1.5/integrations/inbound-webhook-reference.md b/docs/v1.5/integrations/inbound-webhook-reference.md index 307267ef..e970fbd4 100644 --- a/docs/v1.5/integrations/inbound-webhook-reference.md +++ b/docs/v1.5/integrations/inbound-webhook-reference.md @@ -32,6 +32,8 @@ The middleware also accepts `Authorization: Token token=…`, `X-API-Key`, or th The integration must exist, be enabled, and point to the intended service. The route validates the route-specific provider schema, but v1.4 does not separately compare the stored integration type with the route segment. Protect each integration ID/key pair and use only the URL generated for its intended sender. A workspace API key is not interchangeable with an integration key. +Authentication is resolved before OpsKnight consumes the database-backed per-integration rate limit. Requests using a nonexistent integration ID or an invalid integration key therefore do not create arbitrary per-integration rate-limit rows and do not consume the valid integration's quota. This limiter protects authenticated integration traffic; upstream proxy/WAF controls remain appropriate for broad unauthenticated abuse protection. + ## Optional signature verification The integration key is always required. When a signature secret is configured on the integration, OpsKnight also verifies the raw request according to the route's signature mode unless `INTEGRATION_VERIFY_SIGNATURES=false`. Verification is enabled by default; the environment override is a diagnostic escape hatch, not a production baseline. @@ -90,16 +92,16 @@ Accepted event requests normally return HTTP 202 with a JSON result. Common fail | 401 | Invalid key on legacy-handler routes, disabled integration on standardized-handler routes, or missing/invalid required signature. | | 403 | Disabled integration on legacy-handler routes. | | 404 | Integration not found. | -| 429 | Per-integration rate limit exceeded; honor `Retry-After`. | +| 429 | Per-integration rate limit exceeded after integration authentication; honor `Retry-After`. | | 500 | Unexpected processing failure. | Provider routes are being consolidated on the standardized handler, so some authentication failures currently differ between `400`, `401`, and `403`. Treat all three as non-retryable configuration/authentication failures; inspect the JSON message rather than branching only on one status. -Integration routes default to 100 requests per 60 seconds per integration when integration rate limiting is enabled. Rate-limit responses include remaining/reset information; successful routes do not guarantee every provider displays those headers. +Integration routes default to 100 requests per 60 seconds per authenticated integration when integration rate limiting is enabled. Invalid IDs and invalid integration keys are rejected before this database-backed quota is consumed. Rate-limit responses include remaining/reset information; successful routes do not guarantee every provider displays those headers. ## Production acceptance -For every configured provider, test a failure and recovery using the real upstream sender. Confirm the first event creates or updates the intended service incident, repeated events deduplicate, recovery resolves the same incident, invalid credentials are rejected, and responders receive the expected notification. Preserve the provider delivery record and OpsKnight event/timeline evidence for troubleshooting. +For every configured provider, test a failure and recovery using the real upstream sender. Confirm the first event creates or updates the intended service incident, repeated events deduplicate, recovery resolves the same incident, invalid credentials are rejected without consuming authenticated integration quota, and responders receive the expected notification. Preserve the provider delivery record and OpsKnight event/timeline evidence for troubleshooting. ## Related topics diff --git a/prisma/migrations/20260826013000_add_user_quiet_hours/migration.sql b/prisma/migrations/20260826013000_add_user_quiet_hours/migration.sql new file mode 100644 index 00000000..98528e7a --- /dev/null +++ b/prisma/migrations/20260826013000_add_user_quiet_hours/migration.sql @@ -0,0 +1,14 @@ +-- Quiet Hours is an explicit user opt-in. Existing and new users keep full paging by default. +ALTER TABLE "User" +ADD COLUMN "quietHoursEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "quietHoursStartMinutes" INTEGER NOT NULL DEFAULT 1080, +ADD COLUMN "quietHoursEndMinutes" INTEGER NOT NULL DEFAULT 480, +ADD COLUMN "quietHoursWeekendAllDay" BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE "User" +ADD CONSTRAINT "User_quietHoursStartMinutes_check" +CHECK ("quietHoursStartMinutes" >= 0 AND "quietHoursStartMinutes" < 1440), +ADD CONSTRAINT "User_quietHoursEndMinutes_check" +CHECK ("quietHoursEndMinutes" >= 0 AND "quietHoursEndMinutes" < 1440), +ADD CONSTRAINT "User_quietHours_window_check" +CHECK ("quietHoursStartMinutes" <> "quietHoursEndMinutes"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 54d791df..926393da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -176,6 +176,10 @@ model User { pushNotificationsEnabled Boolean @default(false) whatsappNotificationsEnabled Boolean @default(false) phoneNumber String? // For SMS notifications + quietHoursEnabled Boolean @default(false) // Explicit opt-in: never suppress paging unless the user enables Quiet Hours + quietHoursStartMinutes Int @default(1080) // 18:00 local time + quietHoursEndMinutes Int @default(480) // 08:00 local time + quietHoursWeekendAllDay Boolean @default(true) // JIT Profile Sync - synced from OIDC provider department String? jobTitle String? @@ -1094,7 +1098,7 @@ model StatusPageService { id String @id @default(cuid()) statusPageId String serviceId String - displayName String? // Override service name for status page + displayName String? // Override service name for display showOnPage Boolean @default(true) order Int @default(0) createdAt DateTime @default(now()) diff --git a/src/app/(app)/settings/profile/page.tsx b/src/app/(app)/settings/profile/page.tsx index 41b4c94e..a42e4b75 100644 --- a/src/app/(app)/settings/profile/page.tsx +++ b/src/app/(app)/settings/profile/page.tsx @@ -4,6 +4,7 @@ import { getServerSession } from 'next-auth'; import ProfileForm from '@/components/settings/ProfileForm'; import PreferencesForm from '@/components/settings/PreferencesForm'; import NotificationPreferencesForm from '@/components/settings/NotificationPreferencesForm'; +import QuietHoursForm from '@/components/settings/QuietHoursForm'; import { SettingsPageHeader } from '@/components/settings/layout/SettingsPageHeader'; import { SettingsSection } from '@/components/settings/layout/SettingsSection'; import { getUserTimeZone, formatDateTime } from '@/lib/timezone'; @@ -34,6 +35,10 @@ export default async function ProfileSettingsPage() { pushNotificationsEnabled: true, whatsappNotificationsEnabled: true, phoneNumber: true, + quietHoursEnabled: true, + quietHoursStartMinutes: true, + quietHoursEndMinutes: true, + quietHoursWeekendAllDay: true, }, }) : null; @@ -83,7 +88,7 @@ export default async function ProfileSettingsPage() { Preference updates apply to this workspace once saved. @@ -97,6 +102,16 @@ export default async function ProfileSettingsPage() { whatsappEnabled={user?.whatsappNotificationsEnabled ?? false} phoneNumber={user?.phoneNumber ?? null} /> + +
+ +
); diff --git a/src/app/(app)/settings/quiet-hours-actions.ts b/src/app/(app)/settings/quiet-hours-actions.ts new file mode 100644 index 00000000..a7d63e32 --- /dev/null +++ b/src/app/(app)/settings/quiet-hours-actions.ts @@ -0,0 +1,70 @@ +'use server'; + +import prisma from '@/lib/prisma'; +import { getAuthOptions } from '@/lib/auth'; +import { getServerSession } from 'next-auth'; +import { revalidatePath } from 'next/cache'; + +type QuietHoursActionState = { + error?: string | null; + success?: boolean; +}; + +function parseTimeToMinutes(value: string): number | null { + const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value); + if (!match) return null; + return Number(match[1]) * 60 + Number(match[2]); +} + +export async function updateQuietHoursPreferences( + _prevState: QuietHoursActionState, + formData: FormData +): Promise { + try { + const session = await getServerSession(await getAuthOptions()); + const email = session?.user?.email; + if (!email) return { error: 'Unauthorized' }; + + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + if (!user) return { error: 'User not found' }; + + const enabled = + formData.get('quietHoursEnabled') === 'on' || + formData.get('quietHoursEnabled') === 'true'; + const weekendAllDay = + formData.get('quietHoursWeekendAllDay') === 'on' || + formData.get('quietHoursWeekendAllDay') === 'true'; + + const startValue = String(formData.get('quietHoursStart') || '18:00'); + const endValue = String(formData.get('quietHoursEnd') || '08:00'); + const startMinutes = parseTimeToMinutes(startValue); + const endMinutes = parseTimeToMinutes(endValue); + + if (startMinutes === null || endMinutes === null) { + return { error: 'Quiet-hours start and end must be valid times.' }; + } + if (startMinutes === endMinutes) { + return { error: 'Quiet-hours start and end must be different.' }; + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + quietHoursEnabled: enabled, + quietHoursStartMinutes: startMinutes, + quietHoursEndMinutes: endMinutes, + quietHoursWeekendAllDay: weekendAllDay, + }, + }); + + revalidatePath('/settings/profile'); + return { success: true }; + } catch (error) { + return { + error: error instanceof Error ? error.message : 'Unable to update quiet-hours preferences.', + }; + } +} diff --git a/src/components/settings/QuietHoursForm.tsx b/src/components/settings/QuietHoursForm.tsx new file mode 100644 index 00000000..fb25238e --- /dev/null +++ b/src/components/settings/QuietHoursForm.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useActionState, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { updateQuietHoursPreferences } from '@/app/(app)/settings/quiet-hours-actions'; +import { SettingsRow } from '@/components/settings/layout/SettingsRow'; +import { Switch } from '@/components/ui/shadcn/switch'; +import { Input } from '@/components/ui/shadcn/input'; +import { Button } from '@/components/ui/shadcn/button'; +import { Label } from '@/components/ui/shadcn/label'; + +type State = { + error?: string | null; + success?: boolean; +}; + +type Props = { + enabled: boolean; + startMinutes: number; + endMinutes: number; + weekendAllDay: boolean; + timeZone: string; +}; + +function minutesToTime(minutes: number): string { + const safeMinutes = Number.isInteger(minutes) && minutes >= 0 && minutes < 1440 ? minutes : 0; + const hours = Math.floor(safeMinutes / 60); + const mins = safeMinutes % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} + +export default function QuietHoursForm({ + enabled, + startMinutes, + endMinutes, + weekendAllDay, + timeZone, +}: Props) { + const [state, formAction, isPending] = useActionState( + updateQuietHoursPreferences, + { error: null, success: false } + ); + const [enabledChecked, setEnabledChecked] = useState(enabled); + const [weekendChecked, setWeekendChecked] = useState(weekendAllDay); + const [startTime, setStartTime] = useState(minutesToTime(startMinutes)); + const [endTime, setEndTime] = useState(minutesToTime(endMinutes)); + const router = useRouter(); + + useEffect(() => { + if (state?.success) { + const timer = setTimeout(() => router.refresh(), 500); + return () => clearTimeout(timer); + } + }, [state?.success, router]); + + return ( +
+ + + + + + +
+
+ + +
+ + {enabledChecked && ( +
+
+
+ + setStartTime(event.target.value)} + /> +
+
+ + setEndTime(event.target.value)} + /> +
+
+ +
+
+ +

+ Saturday and Sunday are treated as quiet hours for LOW urgency. +

+
+ +
+ +

+ Times use your profile timezone: {timeZone}. + Change it under General Preferences if needed. +

+
+ )} +
+
+ + {(state?.error || state?.success) && ( +
+ {state?.error ? state.error : 'Quiet-hours preferences saved successfully'} +
+ )} + +
+ +
+
+ ); +} diff --git a/src/components/settings/navConfig.ts b/src/components/settings/navConfig.ts index 0bc0af13..856a1ea4 100644 --- a/src/components/settings/navConfig.ts +++ b/src/components/settings/navConfig.ts @@ -40,7 +40,16 @@ export const SETTINGS_NAV_SECTIONS: SettingsNavSection[] = [ description: 'Personal info, timezone, and notifications', href: '/settings/profile', icon: 'user', - keywords: ['name', 'email', 'role', 'timezone', 'notifications'], + keywords: [ + 'name', + 'email', + 'role', + 'timezone', + 'notifications', + 'quiet hours', + 'mute alerts', + 'low urgency', + ], }, { id: 'security', diff --git a/src/lib/escalation.ts b/src/lib/escalation.ts index 95a71a68..3bc1ec57 100644 --- a/src/lib/escalation.ts +++ b/src/lib/escalation.ts @@ -865,26 +865,28 @@ export async function processPendingEscalations( if (result.escalated) { processed++; } else { - const benignReason = (result.reason || '').toLowerCase(); - const isBenign = - benignReason.includes('already in progress') || - benignReason.includes('scheduled') || - benignReason.includes('already completed'); - - if (isBenign) continue; - - const isExhausted = - benignReason.includes('exhausted') || - benignReason.includes('completed') || - benignReason.includes('no escalation policy') || - benignReason.includes('no users to notify') || - benignReason.includes('invalid target'); + const reason = (result.reason || '').toLowerCase(); + const stateAlreadyHandled = + reason.includes('already in progress') || + reason.includes('scheduled') || + reason.includes('already completed') || + reason.includes('exhausted') || + reason.includes('completed') || + reason.includes('no escalation policy') || + reason.includes('no users to notify') || + reason.includes('invalid target') || + reason.includes('step not found'); + + // executeEscalation persists terminal states itself (including FAILED). + // Do not reinterpret those human-readable reasons here and overwrite + // the state that the executor just committed. + if (stateAlreadyHandled) continue; await prisma.incident.update({ where: { id: incident.id }, data: { - escalationStatus: isExhausted ? 'COMPLETED' : 'ESCALATING', - nextEscalationAt: isExhausted ? null : new Date(Date.now() + 30000), + escalationStatus: 'ESCALATING', + nextEscalationAt: new Date(Date.now() + 30000), escalationProcessingAt: null, }, }); @@ -908,7 +910,7 @@ export async function processPendingEscalations( await prisma.incident.update({ where: { id: incident.id }, data: { - escalationStatus: 'COMPLETED', + escalationStatus: 'FAILED', nextEscalationAt: null, escalationProcessingAt: null, }, diff --git a/src/lib/integrations/handler.ts b/src/lib/integrations/handler.ts index 2570f10b..197eb341 100644 --- a/src/lib/integrations/handler.ts +++ b/src/lib/integrations/handler.ts @@ -85,31 +85,9 @@ export function createIntegrationHandler( throw IntegrationErrors.invalidPayload('integrationId is required'); } - // 2. Rate limiting - if (RATE_LIMIT_ENABLED && !options.skipRateLimit) { - const rateResult = await checkRateLimit(integrationId); - - if (!rateResult.allowed) { - const headers = createRateLimitHeaders(rateResult); - recordWebhookReceived( - integrationType, - integrationId, - false, - performance.now() - startTime, - 'RATE_LIMITED' - ); - - return new Response( - JSON.stringify({ error: 'RATE_LIMITED', message: 'Rate limit exceeded' }), - { - status: 429, - headers: { 'Content-Type': 'application/json', ...headers }, - } - ); - } - } - - // 3. Lookup integration + // 2. Lookup integration before touching the per-integration limiter. + // The integration ID is caller-controlled, so rate limiting it first lets + // unauthenticated requests create arbitrary DB-backed rate-limit buckets. const integration = await prisma.integration.findUnique({ where: { id: integrationId }, select: { @@ -142,6 +120,32 @@ export function createIntegrationHandler( integrationType = integration.type; + // 3. Apply the DB-backed limiter only after the integration is authenticated. + // This prevents invalid IDs/keys from generating arbitrary rate-limit rows + // or consuming a valid integration's quota. + if (RATE_LIMIT_ENABLED && !options.skipRateLimit) { + const rateResult = await checkRateLimit(integrationId); + + if (!rateResult.allowed) { + const headers = createRateLimitHeaders(rateResult); + recordWebhookReceived( + integrationType, + integrationId, + false, + performance.now() - startTime, + 'RATE_LIMITED' + ); + + return new Response( + JSON.stringify({ error: 'RATE_LIMITED', message: 'Rate limit exceeded' }), + { + status: 429, + headers: { 'Content-Type': 'application/json', ...headers }, + } + ); + } + } + // 4. Get raw body for signature verification const rawPayload = await readIntegrationBody(req); @@ -309,28 +313,7 @@ export async function withIntegrationMiddleware( return jsonError('integrationId is required', 400); } - // 2. Rate limiting - if (RATE_LIMIT_ENABLED) { - const rateResult = await checkRateLimit(integrationId); - if (!rateResult.allowed) { - recordWebhookReceived( - integrationType, - integrationId, - false, - performance.now() - startTime, - 'RATE_LIMITED' - ); - return new Response( - JSON.stringify({ error: 'RATE_LIMITED', message: 'Rate limit exceeded' }), - { - status: 429, - headers: { 'Content-Type': 'application/json', ...createRateLimitHeaders(rateResult) }, - } - ); - } - } - - // 3. Integration key validation (required - industry standard) + // 2. Integration key validation (required - industry standard) // All webhook URLs include the key, so we always validate it // This provides baseline security for non-HMAC providers (Datadog, New Relic, etc.) // HMAC providers (GitHub, Sentry) get additional signature verification in route handlers @@ -376,6 +359,28 @@ export async function withIntegrationMiddleware( return jsonError('Invalid integration key', 401); } + // 3. Apply the DB-backed limiter only after authentication so arbitrary + // integration IDs/keys cannot create rate-limit rows or burn valid quotas. + if (RATE_LIMIT_ENABLED) { + const rateResult = await checkRateLimit(integrationId); + if (!rateResult.allowed) { + recordWebhookReceived( + integrationType, + integrationId, + false, + performance.now() - startTime, + 'RATE_LIMITED' + ); + return new Response( + JSON.stringify({ error: 'RATE_LIMITED', message: 'Rate limit exceeded' }), + { + status: 429, + headers: { 'Content-Type': 'application/json', ...createRateLimitHeaders(rateResult) }, + } + ); + } + } + try { const response = await handler(); const success = response.status >= 200 && response.status < 300; diff --git a/src/lib/quiet-hours.ts b/src/lib/quiet-hours.ts new file mode 100644 index 00000000..8120ddde --- /dev/null +++ b/src/lib/quiet-hours.ts @@ -0,0 +1,95 @@ +import type { NotificationChannel } from './notifications'; + +export type QuietHoursPreferences = { + quietHoursEnabled: boolean; + quietHoursStartMinutes: number; + quietHoursEndMinutes: number; + quietHoursWeekendAllDay: boolean; + timeZone: string | null; +}; + +type IncidentUrgency = 'LOW' | 'MEDIUM' | 'HIGH' | string | null | undefined; + +const DISRUPTIVE_CHANNELS = new Set(['PUSH', 'SMS', 'WHATSAPP']); +const WEEKEND_DAYS = new Set(['Sat', 'Sun']); + +function isValidMinuteOfDay(value: number): boolean { + return Number.isInteger(value) && value >= 0 && value < 24 * 60; +} + +function isMinuteInsideWindow(minute: number, start: number, end: number): boolean { + if (start === end) return false; + + if (start < end) { + return minute >= start && minute < end; + } + + // Overnight window, e.g. 18:00 -> 08:00. + return minute >= start || minute < end; +} + +export function isQuietHoursActive( + preferences: QuietHoursPreferences | null | undefined, + at: Date = new Date() +): boolean { + if (!preferences?.quietHoursEnabled) return false; + + const start = preferences.quietHoursStartMinutes; + const end = preferences.quietHoursEndMinutes; + if (!isValidMinuteOfDay(start) || !isValidMinuteOfDay(end) || start === end) { + // Fail open for malformed configuration so notification delivery is never + // accidentally disabled by invalid persisted values. + return false; + } + + try { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: preferences.timeZone || 'UTC', + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(at); + + const weekday = parts.find(part => part.type === 'weekday')?.value; + const hour = Number(parts.find(part => part.type === 'hour')?.value); + const minute = Number(parts.find(part => part.type === 'minute')?.value); + + if (!weekday || !Number.isInteger(hour) || !Number.isInteger(minute)) { + return false; + } + + if (preferences.quietHoursWeekendAllDay && WEEKEND_DAYS.has(weekday)) { + return true; + } + + return isMinuteInsideWindow(hour * 60 + minute, start, end); + } catch { + // Invalid IANA timezone: fail open rather than suppressing a page. + return false; + } +} + +export function filterChannelsForQuietHours( + channels: NotificationChannel[], + urgency: IncidentUrgency, + preferences: QuietHoursPreferences | null | undefined, + at: Date = new Date() +): { channels: NotificationChannel[]; blockedChannels: Set } { + const blockedChannels = new Set(); + + // Quiet hours intentionally apply only to LOW urgency. MEDIUM/HIGH incidents + // continue paging so personal preferences cannot hide operationally urgent work. + if (urgency !== 'LOW' || !isQuietHoursActive(preferences, at)) { + return { channels, blockedChannels }; + } + + for (const channel of DISRUPTIVE_CHANNELS) { + blockedChannels.add(channel); + } + + return { + channels: channels.filter(channel => !blockedChannels.has(channel)), + blockedChannels, + }; +} diff --git a/src/lib/user-notifications.ts b/src/lib/user-notifications.ts index 8b4bbff2..3b68f459 100644 --- a/src/lib/user-notifications.ts +++ b/src/lib/user-notifications.ts @@ -13,6 +13,7 @@ import { sendNotification, NotificationChannel } from './notifications'; import { isChannelAvailable } from './notification-providers'; import { createInAppNotifications } from './in-app-notifications'; import { logger } from './logger'; +import { filterChannelsForQuietHours } from './quiet-hours'; /** * Get user's enabled notification channels based on their preferences @@ -95,8 +96,13 @@ export async function sendUserNotification( excludedChannels?: NotificationChannel[]; createInApp?: boolean; } = {} -): Promise<{ success: boolean; channelsUsed: NotificationChannel[]; errors?: string[] }> { - // Create In-App Notification first +): Promise<{ + success: boolean; + channelsUsed: NotificationChannel[]; + errors?: string[]; + suppressedByQuietHours?: boolean; +}> { + // Create In-App Notification first. In-app remains available during quiet hours. if (options.createInApp !== false) { try { await createInAppNotifications({ @@ -113,7 +119,6 @@ export async function sendUserNotification( } let channels: NotificationChannel[]; - // ... rest of function const excludedChannels = new Set(options.excludedChannels ?? []); const userChannels = (await getUserNotificationChannels(userId)).filter( channel => !excludedChannels.has(channel) @@ -147,28 +152,22 @@ export async function sendUserNotification( const [incident, recipient] = await Promise.all([ prisma.incident.findUnique({ where: { id: incidentId }, select: { urgency: true } }), - prisma.user.findUnique({ where: { id: userId }, select: { timeZone: true } }), + prisma.user.findUnique({ + where: { id: userId }, + select: { + timeZone: true, + quietHoursEnabled: true, + quietHoursStartMinutes: true, + quietHoursEndMinutes: true, + quietHoursWeekendAllDay: true, + }, + }), ]); const isHighUrgency = incident?.urgency === 'HIGH'; - if (incident?.urgency === 'LOW') { - try { - const timeZone = recipient?.timeZone || 'UTC'; - const localParts = new Intl.DateTimeFormat('en-US', { - timeZone, - weekday: 'short', - hour: 'numeric', - hourCycle: 'h23', - }).formatToParts(new Date()); - const weekday = localParts.find(part => part.type === 'weekday')?.value; - const hour = Number(localParts.find(part => part.type === 'hour')?.value || 0) % 24; - const offHours = weekday === 'Sat' || weekday === 'Sun' || hour < 8 || hour >= 18; - if (offHours) { - channels = channels.filter(channel => !['PUSH', 'SMS', 'WHATSAPP'].includes(channel)); - } - } catch { - // Fallback for invalid timezone string without throwing - } - } + + const quietHoursResult = filterChannelsForQuietHours(channels, incident?.urgency, recipient); + channels = quietHoursResult.channels; + const quietHoursBlockedChannels = quietHoursResult.blockedChannels; let primarySuccess = false; @@ -191,9 +190,12 @@ export async function sendUserNotification( } } - // Fallback: If all primary specified channels failed, attempt delivery via user's other available channels + // Fallback: If all primary specified channels failed, attempt delivery via user's other available channels. + // Never reintroduce disruptive channels that quiet-hours filtering intentionally blocked. if (channelsUsed.length === 0 && userChannels.length > 0) { - const fallbackChannels = userChannels.filter(ch => !channels.includes(ch)); + const fallbackChannels = userChannels.filter( + ch => !channels.includes(ch) && !quietHoursBlockedChannels.has(ch) + ); for (const fbChannel of fallbackChannels) { const fbResult = await sendNotification(incidentId, userId, fbChannel, message); if (fbResult.success) { @@ -209,6 +211,24 @@ export async function sendUserNotification( } } + // A deliberate quiet-hours suppression is a successful policy decision, not a + // notification-provider failure. In-app was already created above. + if ( + channelsUsed.length === 0 && + errors.length === 0 && + quietHoursBlockedChannels.size > 0 + ) { + logger.info('[UserNotification] External delivery suppressed by quiet hours', { + incidentId, + userId, + }); + return { + success: true, + channelsUsed: [], + suppressedByQuietHours: true, + }; + } + return { success: channelsUsed.length > 0, channelsUsed, @@ -248,11 +268,9 @@ export async function sendIncidentNotifications( })); if (!incidentData || !incidentData.service) { - // Use incidentData instead of incident return { success: false, errors: ['Incident or service not found'] }; } - // Use incidentData for the rest of the function const incidentRecord = incidentData; const errors: string[] = []; @@ -307,6 +325,11 @@ export async function sendIncidentNotifications( whatsappNotificationsEnabled: true, phoneNumber: true, email: true, + timeZone: true, + quietHoursEnabled: true, + quietHoursStartMinutes: true, + quietHoursEndMinutes: true, + quietHoursWeekendAllDay: true, }, }); @@ -359,12 +382,32 @@ export async function sendIncidentNotifications( }; } + const quietHoursResult = filterChannelsForQuietHours( + channels, + incidentRecord.urgency, + user + ); + const deliveryChannels = quietHoursResult.channels; + + if (deliveryChannels.length === 0 && quietHoursResult.blockedChannels.size > 0) { + logger.info('[IncidentNotification] External delivery suppressed by quiet hours', { + incidentId, + userId, + }); + return { + userId, + success: true, + channelsUsed: [] as NotificationChannel[], + suppressedByQuietHours: true, + }; + } + const isHighUrgency = incidentRecord.urgency === 'HIGH'; let primarySuccess = false; const successful = []; const failed = []; - for (const channel of channels) { + for (const channel of deliveryChannels) { if (primarySuccess) { if (isHighUrgency && channel === 'EMAIL') { // Continue to send email diff --git a/tests/lib/escalation-pending.test.ts b/tests/lib/escalation-pending.test.ts index 5d8d1747..4617a2d4 100644 --- a/tests/lib/escalation-pending.test.ts +++ b/tests/lib/escalation-pending.test.ts @@ -62,14 +62,9 @@ describe('processPendingEscalations', () => { expect(executor).toHaveBeenNthCalledWith(1, 'inc-1', 0); expect(executor).toHaveBeenNthCalledWith(2, 'inc-2', 2); expect(prisma.incident.updateMany).not.toHaveBeenCalled(); - expect(prisma.incident.update).toHaveBeenCalledWith({ - where: { id: 'inc-2' }, - data: { - escalationStatus: 'COMPLETED', - nextEscalationAt: null, - escalationProcessingAt: null, - }, - }); + // Terminal states are persisted by executeEscalation itself. The pending + // processor must not reinterpret a terminal reason and overwrite that state. + expect(prisma.incident.update).not.toHaveBeenCalled(); expect(result.processed).toBe(1); expect(result.total).toBe(2); }); diff --git a/tests/lib/quiet-hours.test.ts b/tests/lib/quiet-hours.test.ts new file mode 100644 index 00000000..81f688cb --- /dev/null +++ b/tests/lib/quiet-hours.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { filterChannelsForQuietHours, isQuietHoursActive } from '@/lib/quiet-hours'; + +const basePreferences = { + quietHoursEnabled: true, + quietHoursStartMinutes: 18 * 60, + quietHoursEndMinutes: 8 * 60, + quietHoursWeekendAllDay: true, + timeZone: 'UTC', +}; + +describe('quiet-hours notification policy', () => { + it('recognizes an overnight quiet-hours window', () => { + expect(isQuietHoursActive(basePreferences, new Date('2026-08-26T22:00:00.000Z'))).toBe(true); + expect(isQuietHoursActive(basePreferences, new Date('2026-08-26T10:00:00.000Z'))).toBe(false); + }); + + it('treats weekends as all-day quiet hours when enabled', () => { + expect(isQuietHoursActive(basePreferences, new Date('2026-08-29T12:00:00.000Z'))).toBe(true); + }); + + it('does not suppress anything when quiet hours are disabled', () => { + const result = filterChannelsForQuietHours( + ['PUSH', 'SMS', 'WHATSAPP', 'EMAIL'], + 'LOW', + { ...basePreferences, quietHoursEnabled: false }, + new Date('2026-08-26T22:00:00.000Z') + ); + + expect(result.channels).toEqual(['PUSH', 'SMS', 'WHATSAPP', 'EMAIL']); + expect(result.blockedChannels.size).toBe(0); + }); + + it('suppresses disruptive channels for LOW urgency but keeps email', () => { + const result = filterChannelsForQuietHours( + ['PUSH', 'SMS', 'WHATSAPP', 'EMAIL'], + 'LOW', + basePreferences, + new Date('2026-08-26T22:00:00.000Z') + ); + + expect(result.channels).toEqual(['EMAIL']); + expect(result.blockedChannels).toEqual(new Set(['PUSH', 'SMS', 'WHATSAPP'])); + }); + + it('allows MEDIUM and HIGH urgency to bypass quiet hours', () => { + const at = new Date('2026-08-26T22:00:00.000Z'); + + expect(filterChannelsForQuietHours(['PUSH'], 'MEDIUM', basePreferences, at).channels).toEqual([ + 'PUSH', + ]); + expect(filterChannelsForQuietHours(['PUSH'], 'HIGH', basePreferences, at).channels).toEqual([ + 'PUSH', + ]); + }); + + it('fails open for an invalid timezone', () => { + expect( + isQuietHoursActive( + { ...basePreferences, timeZone: 'Definitely/Not-A-Timezone' }, + new Date('2026-08-26T22:00:00.000Z') + ) + ).toBe(false); + }); +});