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.
Architecture
Section titled “Architecture”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 instantlyThe module lives at src/mods/notification/ as a vertical slice with types, API, services, views, and components.
Data Model
Section titled “Data Model”notification table
Section titled “notification table”| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
organization_id | UUID | FK → organization |
user_id | UUID | FK → user (recipient) |
category | varchar(50) | call, task, task_completed, plan, system |
title | varchar(255) | Notification headline |
body | text (nullable) | Optional detail text |
entity_type | varchar(50) (nullable) | call, contact, task, agent, plan |
entity_id | UUID (nullable) | Referenced entity for click-through |
read_at | timestamp (nullable) | Set when user reads the notification |
created_at | timestamp | Defaults to now() |
Indexed on (user_id, created_at) for list queries and (user_id, read_at) for unread counts.
notification_preference table
Section titled “notification_preference table”| Column | Type | Default |
|---|---|---|
id | UUID | PK |
user_id | UUID | FK → user |
category | varchar(50) | One of the five categories |
channel_in_app | boolean | true |
channel_email | boolean | false |
channel_sms | boolean | false (not yet implemented) |
Unique constraint on (user_id, category). Default preferences are seeded at signup and invitation acceptance.
Categories and Entities
Section titled “Categories and Entities”NotificationCategory
Section titled “NotificationCategory”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).
NotificationEntity
Section titled “NotificationEntity”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}}The notify() Service
Section titled “The notify() Service”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:
| Variant | Behavior |
|---|---|
User(Uuid) | Single user |
Users(Vec<Uuid>) | Specific list |
AllMembers | All users in the organization |
Call Notification Targeting
Section titled “Call Notification Targeting”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,) -> NotifyTargetThis function uses a union targeting pattern instead of falling back to all members:
- Collect contact assignees — query
contact_userfor members assigned to the caller’s contact (skipped ifcontact_idisNone) - Collect phone number assignees — query
member_phone_numberfor members assigned to the receiving phone line - Deduplicate — merge both lists into a
HashSetto remove overlaps - Resolve to user IDs — batch query the
membertable with.is_in()to convert member IDs to user IDs - 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.
| Scenario | Recipients |
|---|---|
| Contact and phone number both have assignees | Union of both (deduplicated) |
| Only phone number has assignees | Phone number assignees |
| Unknown caller, phone number has assignees | Phone number assignees |
| Neither has assignees | Organization owners |
| DB error on one source | Other source’s assignees (graceful degradation) |
Other Notification Targeting
Section titled “Other Notification Targeting”For plan notifications, use resolve_plan_notify_target() which filters by permission. For other notification types, construct a NotifyTarget directly.
API Endpoints
Section titled “API Endpoints”| Route | Method | Description |
|---|---|---|
/api/notifications | GET | Paginated list with filters (see below) |
/api/notifications/unread-count | GET | Returns u64 count of unread notifications |
/api/notifications/:id/read | PUT | Marks one notification as read |
/api/notifications/read-all | PUT | Marks all unread notifications as read |
/api/notification-preferences | GET | Returns all preferences for current user |
/api/notification-preferences | PUT | Upserts preferences (body: { preferences: [...] }) |
All endpoints require authentication. Queries are scoped to the current user’s user_id and organization_id.
Permission Enforcement
Section titled “Permission Enforcement”Notification endpoints enforce ABAC permissions:
| Endpoint | Permission Required |
|---|---|
GET /api/notifications | Notification:Collection:List |
GET /api/notifications/unread-count | Notification:Collection:List |
PUT /api/notifications/read-all | Notification:Collection:List |
PUT /api/notifications/:id/read | Notification: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.
GET /api/notifications
Section titled “GET /api/notifications”| Param | Type | Default | Description |
|---|---|---|---|
page | u64 | 1 | 1-indexed page number |
page_size | u64 | 20 | Items per page, clamped 1–50 |
category | string | — | Filter by category (validated against NotificationCategory::ALL) |
unread_only | bool | false | Show only unread notifications |
Returns NotificationListResponse:
pub struct NotificationListResponse { pub notifications: Vec<Notification>, pub total_count: u64,}Real-Time Delivery
Section titled “Real-Time Delivery”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(¬ification)?,});The NotificationBell component listens for these events and increments the badge count instantly — no polling required.
UI Components
Section titled “UI Components”Inbox Page
Section titled “Inbox Page”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.
Notification Panel (Dropdown)
Section titled “Notification Panel (Dropdown)”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.
Filters
Section titled “Filters”NotificationFiltersBar (notification_filters_component.rs) provides:
- All / Unread segmented toggle
- Category dropdown with all five categories plus “All types”
- A
compactprop 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,}Category Chips
Section titled “Category Chips”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().
Notification Item
Section titled “Notification Item”NotificationItem (notification_item_component.rs) renders a single notification with:
- Category icon —
PhoneIncoming(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:
| Mode | Unread | Read |
|---|---|---|
| Dense (panel) | 2px left border (primary), semibold, tinted background | No border, normal weight |
| Comfortable (page) | Ring border (primary/25), semibold, tinted background, larger dot | Standard 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
Empty and Error States
Section titled “Empty and Error States”NotificationEmptyState renders contextual messages:
- No filters active: “You’re all caught up!” with
BellOfficon - Filters active: “No matching notifications” with
Searchicon and a “Clear filters” button
NotificationErrorState shows “Something went wrong” with a “Retry” button.
Preferences
Section titled “Preferences”NotificationPreferences — rendered in Settings. A table of categories × channels with toggle checkboxes. Saves via upsert on the preferences endpoint.
Mobile Responsiveness
Section titled “Mobile Responsiveness”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.
Breakpoints
Section titled “Breakpoints”| Viewport | Target | Behavior |
|---|---|---|
| < 768px | Mobile phones | Touch-optimized, stacked layouts, fixed positioning |
| ≥ 768px | Tablets and desktops | Hover interactions, grid layouts, absolute positioning |
Panel Positioning
Section titled “Panel 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.
"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.
Touch Targets
Section titled “Touch Targets”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"Hover vs. Touch Interactions
Section titled “Hover vs. Touch Interactions”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"Layout Adaptations
Section titled “Layout Adaptations”Notification preferences switch from a 4-column grid table on desktop to a stacked card layout on mobile:
"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.
Component Summary
Section titled “Component Summary”| Component | Mobile (< 768px) | Desktop (≥ 768px) |
|---|---|---|
NotificationPanel | Fixed, edge-to-edge, 60vh max | Absolute dropdown, 70vh max |
NotificationItem actions | Always visible, 60% opacity | Hidden, revealed on hover |
NotificationItem category icon | Hidden | Visible |
NotificationPreferences | Stacked cards with inline labels | 4-column grid table |
NotificationCategoryChips | Larger touch targets, hidden scrollbar | Compact, standard scrollbar |
NotificationsView spacing | Tight gaps (gap-2, gap-5) | Standard gaps (gap-3, gap-8) |
Integration Points
Section titled “Integration Points”Services call notify() at these points:
| Trigger | Category | Entity | Targeting |
|---|---|---|---|
| Call recording processed | Call | Call(call_id) | resolve_call_notify_target |
| Tasks extracted from call | Task | Task(None) | resolve_call_notify_target |
| Task execution succeeded | TaskCompleted | Task(Some(id)) | Direct |
| Task execution failed | System | Task(Some(id)) | Direct |
| Call analysis failed | System | Call(call_id) | resolve_call_notify_target |
| User signup | System | None | Direct |
| Plan created needing review | Plan | Plan(plan_id) | resolve_plan_notify_target |
| Plan action needs approval | Plan | Plan(plan_id) | resolve_plan_notify_target |
| Inbound SMS wakes a plan | Plan | Plan(plan_id) | resolve_plan_notify_target |
| Plan auto-fail | Plan | Plan(plan_id) | resolve_plan_notify_target |
Executor notify_user tool call | Plan | Plan(plan_id) | resolve_plan_notify_target |
Key Files
Section titled “Key Files”| File | Purpose |
|---|---|
src/mods/notification/services/notify_service.rs | Core notify() function and targeting |
src/mods/notification/views/notifications_view.rs | Full-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.rs | category_unread_counts(), category_chip_icon() |
src/shared/utils/format_relative_time.rs | Relative timestamp formatting |
src/bases/realtime/event_hub.rs | WebSocket broadcast infrastructure |