Skip to content

Notification

The notification module delivers real-time alerts for calls, tasks, plans, and system events. Notifications appear in a bell icon dropdown in the header and a dedicated inbox page at /notifications. Users filter by category, toggle read/unread, and configure per-channel preferences.

Notifications follow a fire-and-forget pattern. Any service calls notify() to dispatch a notification — it never blocks the caller’s flow.

Service event (call completed, task extracted, etc.)
→ notify(org_id, target, category, title, body, entity)
→ Resolve target users
→ Check each user's preferences
→ Insert notification row (if in-app enabled)
→ Send email (if email enabled)
→ Publish WebSocket event → client badge updates instantly

The module lives at src/mods/notification/ as a vertical slice with types, API, services, views, and components.

ColumnTypeDescription
idUUIDPrimary key
organization_idUUIDFK → organization
user_idUUIDFK → user (recipient)
categoryvarchar(50)call, task, task_completed, plan, system
titlevarchar(255)Notification headline
bodytext (nullable)Optional detail text
entity_typevarchar(50) (nullable)call, contact, task, agent, plan
entity_idUUID (nullable)Referenced entity for click-through
read_attimestamp (nullable)Set when user reads the notification
created_attimestampDefaults to now()

Indexed on (user_id, created_at) for list queries and (user_id, read_at) for unread counts.

ColumnTypeDefault
idUUIDPK
user_idUUIDFK → user
categoryvarchar(50)One of the five categories
channel_in_appbooleantrue
channel_emailbooleanfalse
channel_smsbooleanfalse (not yet implemented)

Unique constraint on (user_id, category). Default preferences are seeded at signup and invitation acceptance.

src/mods/notification/types/notification_category_type.rs
pub enum NotificationCategory {
Call, // "call" — incoming call completed
Task, // "task" (alias: "todo" for legacy rows)
TaskCompleted, // "task_completed" (alias: "todo_completed")
Plan, // "plan" — autonomous plan events
System, // "system" — errors, welcome messages
}

The Task and TaskCompleted variants accept legacy "todo" / "todo_completed" strings via FromStr for backward compatibility with existing database rows.

Utility methods: as_str(), label(), ALL (constant array of all variants).

Controls where the user navigates when clicking a notification:

pub enum NotificationEntity {
Call(Uuid), // → /calls/{id}
Contact(Uuid), // → /contacts/{id}
Task(Option<Uuid>), // → /todos (list) or specific task
Agent(Uuid), // → /agents/{id}
Plan(Uuid), // → /plans/{id}
}
src/mods/notification/services/notify_service.rs
pub async fn notify(
organization_id: Uuid,
target: NotifyTarget,
category: NotificationCategory,
title: &str,
body: Option<&str>,
entity: Option<&NotificationEntity>,
) -> Result<(), AppError>

NotifyTarget determines who receives the notification:

VariantBehavior
User(Uuid)Single user
Users(Vec<Uuid>)Specific list
AllMembersAll users in the organization

For call-related notifications, use resolve_call_notify_target() to build a targeted recipient list:

pub async fn resolve_call_notify_target(
contact_id: Option<Uuid>,
phone_number_id: Uuid,
organization_id: Uuid,
db: &DatabaseConnection,
) -> NotifyTarget

This function uses a union targeting pattern instead of falling back to all members:

  1. Collect contact assignees — query contact_user for members assigned to the caller’s contact (skipped if contact_id is None)
  2. Collect phone number assignees — query member_phone_number for members assigned to the receiving phone line
  3. Deduplicate — merge both lists into a HashSet to remove overlaps
  4. Resolve to user IDs — batch query the member table with .is_in() to convert member IDs to user IDs
  5. Fallback to org owners — if the union is empty, query members where is_owner = true

The function never returns NotifyTarget::AllMembers for call notifications. If a database query fails, it logs a tracing::warn! and continues with partial data rather than broadcasting to everyone.

ScenarioRecipients
Contact and phone number both have assigneesUnion of both (deduplicated)
Only phone number has assigneesPhone number assignees
Unknown caller, phone number has assigneesPhone number assignees
Neither has assigneesOrganization owners
DB error on one sourceOther source’s assignees (graceful degradation)

For plan notifications, use resolve_plan_notify_target() which filters by permission. For other notification types, construct a NotifyTarget directly.

RouteMethodDescription
/api/notificationsGETPaginated list with filters (see below)
/api/notifications/unread-countGETReturns u64 count of unread notifications
/api/notifications/:id/readPUTMarks one notification as read
/api/notifications/read-allPUTMarks all unread notifications as read
/api/notification-preferencesGETReturns all preferences for current user
/api/notification-preferencesPUTUpserts preferences (body: { preferences: [...] })

All endpoints require authentication. Queries are scoped to the current user’s user_id and organization_id.

Notification endpoints enforce ABAC permissions:

EndpointPermission Required
GET /api/notificationsNotification:Collection:List
GET /api/notifications/unread-countNotification:Collection:List
PUT /api/notifications/read-allNotification:Collection:List
PUT /api/notifications/:id/readNotification:Instance:MarkRead

The NotificationResource is defined in the ABAC system with two instance permissions (View, MarkRead) and one collection permission (List).

pub struct NotificationInstance {
pub org_id: Uuid,
}

The notifications sidebar link and notification bell are conditionally rendered — members without Notification:Collection:List don’t see them. The notifications page shows an AccessDenied guard for unauthorized members.

ParamTypeDefaultDescription
pageu6411-indexed page number
page_sizeu6420Items per page, clamped 1–50
categorystringFilter by category (validated against NotificationCategory::ALL)
unread_onlyboolfalseShow only unread notifications

Returns NotificationListResponse:

pub struct NotificationListResponse {
pub notifications: Vec<Notification>,
pub total_count: u64,
}

When notify() inserts a notification, it publishes a WebSocket event through the EventHub:

event_hub().publish_user(user_id, AppEvent {
event_type: "notification.new",
payload: serde_json::to_value(&notification)?,
});

The NotificationBell component listens for these events and increments the badge count instantly — no polling required.

NotificationsView (src/mods/notification/views/notifications_view.rs) renders the full-page inbox at /notifications. Notifications are grouped by day — Today, Yesterday, or a specific date — using calendar_group_label() and grouped_notifications().

The page header includes a “Mark all as read” button, category summary chips, and a filter bar.

NotificationPanel opens from the bell icon in the header. It loads 25 recent notifications in dense mode with category chips, filters, and a footer showing “Showing 25 of X notifications” with a “View all” link to /notifications.

NotificationFiltersBar (notification_filters_component.rs) provides:

  • All / Unread segmented toggle
  • Category dropdown with all five categories plus “All types”
  • A compact prop controls sizing for panel vs. full-page use

NotificationInboxFilters type backs the filter state:

pub struct NotificationInboxFilters {
pub category: Option<NotificationCategory>,
pub unread_only: bool,
}

NotificationCategoryChips (notification_category_chips_component.rs) renders a row of chips showing unread counts per category. Click a chip to filter by that category. Counts are computed client-side via category_unread_counts().

NotificationItem (notification_item_component.rs) renders a single notification with:

  • Category iconPhoneIncoming (Call), ClipboardCheck (TaskCompleted), ClipboardList (Task), TriangleAlert (System)
  • Entity badge — color-coded pill (Call, Task, Contact, Agent, Plan) using entity_badge_info()
  • Relative timestamp — “2h ago”, “3d ago”
  • Unread indicator — blue dot (hides on hover in dense mode when action buttons appear)

Visual differentiation between read and unread:

ModeUnreadRead
Dense (panel)2px left border (primary), semibold, tinted backgroundNo border, normal weight
Comfortable (page)Ring border (primary/25), semibold, tinted background, larger dotStandard border, medium weight

Per-item actions appear on hover (dense) or always (comfortable):

  • Checkmark — marks as read without navigating
  • External link — opens entity in new context

NotificationEmptyState renders contextual messages:

  • No filters active: “You’re all caught up!” with BellOff icon
  • Filters active: “No matching notifications” with Search icon and a “Clear filters” button

NotificationErrorState shows “Something went wrong” with a “Retry” button.

NotificationPreferences — rendered in Settings. A table of categories × channels with toggle checkboxes. Saves via upsert on the preferences endpoint.

The notification UI adapts across three breakpoints using Tailwind’s mobile-first approach. All responsive classes use the md: prefix (≥ 768px) to separate mobile from desktop behavior.

ViewportTargetBehavior
< 768pxMobile phonesTouch-optimized, stacked layouts, fixed positioning
≥ 768pxTablets and desktopsHover interactions, grid layouts, absolute positioning

On mobile, NotificationPanel uses fixed positioning to span the viewport edge-to-edge with small insets. On desktop, it drops down as an absolute-positioned element anchored to the bell icon.

notification_panel_component.rs
"fixed left-2 right-2 top-[3.5rem] md:absolute md:right-0 md:left-auto md:top-full md:w-[28rem]"

Scroll height caps at max-h-[min(420px,60vh)] on mobile and 70vh on desktop to prevent the panel from overflowing the viewport.

Action buttons and category chips use larger padding on mobile to meet the 44px minimum touch target:

// Dense mode (panel) — mark-read button
"p-2 md:p-1"
// Comfortable mode (inbox page) — mark-read button
"p-2.5 md:p-1.5"
// Category chips — compact variant
"px-2.5 py-1.5 md:px-2 md:py-0.5"
// Category chips — standard variant
"px-3 py-1.5 md:px-2.5 md:py-1"

Desktop notification items reveal action buttons on hover via group-hover/item. On mobile, these buttons are always visible at reduced opacity since hover isn’t available on touch devices:

// notification_item_component.rs — action button wrapper
"opacity-60 md:opacity-0 md:group-hover/item:opacity-100"

The unread indicator dot hides on desktop hover (replaced by action buttons) but stays hidden on mobile where actions are always visible:

"hidden md:block md:group-hover/item:hidden"

Notification preferences switch from a 4-column grid table on desktop to a stacked card layout on mobile:

notification_preferences_component.rs
"flex flex-col gap-2 md:grid md:grid-cols-4"

Category icon hides on mobile to save horizontal space:

"hidden md:block"

Body text clamps to 2 lines on mobile (vs. 3 on desktop) to keep items compact.

Category select in the filter bar uses min-w-0 flex-1 on mobile for fluid width, and min-w-[12rem] on desktop for a fixed minimum.

Scrollbar hiding on the category chips row uses scrollbar-none for clean horizontal scrolling on touch devices.

ComponentMobile (< 768px)Desktop (≥ 768px)
NotificationPanelFixed, edge-to-edge, 60vh maxAbsolute dropdown, 70vh max
NotificationItem actionsAlways visible, 60% opacityHidden, revealed on hover
NotificationItem category iconHiddenVisible
NotificationPreferencesStacked cards with inline labels4-column grid table
NotificationCategoryChipsLarger touch targets, hidden scrollbarCompact, standard scrollbar
NotificationsView spacingTight gaps (gap-2, gap-5)Standard gaps (gap-3, gap-8)

Services call notify() at these points:

TriggerCategoryEntityTargeting
Call recording processedCallCall(call_id)resolve_call_notify_target
Tasks extracted from callTaskTask(None)resolve_call_notify_target
Task execution succeededTaskCompletedTask(Some(id))Direct
Task execution failedSystemTask(Some(id))Direct
Call analysis failedSystemCall(call_id)resolve_call_notify_target
User signupSystemNoneDirect
Plan created needing reviewPlanPlan(plan_id)resolve_plan_notify_target
Plan action needs approvalPlanPlan(plan_id)resolve_plan_notify_target
Inbound SMS wakes a planPlanPlan(plan_id)resolve_plan_notify_target
Plan auto-failPlanPlan(plan_id)resolve_plan_notify_target
Executor notify_user tool callPlanPlan(plan_id)resolve_plan_notify_target
FilePurpose
src/mods/notification/services/notify_service.rsCore notify() function and targeting
src/mods/notification/views/notifications_view.rsFull-page inbox at /notifications
src/mods/notification/types/All type definitions
src/mods/notification/api/Six API endpoints
src/mods/notification/components/Bell, panel, item, filters, chips, empty states
src/mods/notification/helpers.rscategory_unread_counts(), category_chip_icon()
src/shared/utils/format_relative_time.rsRelative timestamp formatting
src/bases/realtime/event_hub.rsWebSocket broadcast infrastructure