Notifications
AutoCom includes a persistent, real-time notification system that stores notifications in the tenant database and delivers them instantly via WebSocket. Any module can send notifications without registration — just call the NotificationService.
How It Works
Module calls NotificationService::send()
│
├─► Row created in tenant `notifications` table
│
└─► NotificationCreated event dispatched (ShouldBroadcast)
│
▼
Horizon processes broadcast job
│
▼
Reverb delivers to private user.{id} channel
│
▼
Frontend NotificationProvider receives event
│
├─► Prepends to notification list
├─► Increments unread badge count
└─► Shows toast popup
Database Schema
Notifications are stored in the tenant database (not the central DB). The table uses UUID primary keys.
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key |
user_id |
bigint | Target user (references central users table) |
type |
string(100) | Notification type (e.g. order.created, workflow.failed) |
module |
string(50) | Source module name (e.g. orders, products, system) |
title |
string | Human-readable title |
body |
text | Detail text (optional) |
icon |
string(50) | Lucide icon name (e.g. Package, AlertTriangle) |
severity |
string(20) | info, success, warning, or error |
action_url |
string | Relative URL for click-through (optional) |
action_label |
string(100) | Button/link text (optional) |
data |
JSON | Arbitrary payload for module-specific data |
group_key |
string(100) | For batching similar notifications |
read_at |
timestamp | Null = unread |
created_at |
timestamp | |
updated_at |
timestamp |
Note: No foreign key constraint on
user_idbecause PostgreSQL doesn't support cross-database foreign keys (users live in central DB, notifications in tenant DB). The application layer handles the association via theforUser()scope.
Sending Notifications
Basic Usage
use App\Services\NotificationService;
$service = app(NotificationService::class);
$service->send($userId, [
'type' => 'order.created',
'module' => 'orders',
'title' => 'New order received',
'body' => "Order #{$order->order_number} from {$customer->name}",
'icon' => 'ShoppingCart',
'severity' => 'info',
'action_url' => "/orders/{$order->id}",
]);
Send to Multiple Users
$service->send([1, 2, 3], [
'type' => 'team.announcement',
'title' => 'Meeting in 15 minutes',
'body' => 'Daily standup in the main conference room',
'icon' => 'Users',
]);
Send to All Tenant Members
$service->sendToAllTenantMembers([
'type' => 'system.maintenance',
'module' => 'system',
'title' => 'Scheduled maintenance',
'body' => 'The system will be down for maintenance at 2:00 AM UTC',
'icon' => 'AlertTriangle',
'severity' => 'warning',
]);
Payload Reference
| Field | Required | Default | Description |
|---|---|---|---|
type |
Yes | — | Dot-notation type identifier |
title |
Yes | — | Short title shown in bell dropdown and toast |
body |
No | null | Detail text (2-line clamp in dropdown) |
module |
No | null | Source module for filtering |
icon |
No | null | Lucide icon name for the dropdown |
severity |
No | info |
info, success, warning, error — determines icon color |
action_url |
No | null | Relative path — makes the notification clickable |
action_label |
No | null | Text for the action button |
data |
No | null | Arbitrary JSON payload for programmatic use |
group_key |
No | null | Key for batching similar notifications |
Available Icons
The frontend maps these icon names to Lucide components:
Package, AlertTriangle, CheckCircle, Info, ShoppingCart, Users, Zap, Settings, MessageSquare, TrendingUp, Shield
To add more icons, update the iconMap in frontend/components/notification-dropdown.tsx.
Severity Styles
| Severity | Color | Use Case |
|---|---|---|
info |
Blue | General notifications, new orders, updates |
success |
Green | Completed workflows, milestones, confirmations |
warning |
Amber | Low stock, expiring subscriptions, degraded service |
error |
Red | Failed workflows, security alerts, out of stock |
REST API
All endpoints require authentication and tenant context (Authorization: Bearer {token}, X-Tenant: {id}). Users can only access their own notifications.
List Notifications
GET /api/v1/notifications
Query parameters:
| Param | Type | Description |
|---|---|---|
unread_only |
boolean | Filter to unread only |
type |
string | Filter by notification type |
module |
string | Filter by source module |
per_page |
integer | Results per page (1-100, default 20) |
page |
integer | Page number |
Response:
{
"notifications": [
{
"id": "a12a9a75-831a-4a73-be3b-02ca93286c90",
"user_id": 1,
"type": "order.created",
"module": "orders",
"title": "New order received",
"body": "Order #1234 from John Smith",
"icon": "ShoppingCart",
"severity": "info",
"action_url": "/orders/abc-123",
"action_label": null,
"data": null,
"group_key": null,
"read_at": null,
"created_at": "2026-02-25T18:18:40.000000Z",
"updated_at": "2026-02-25T18:18:40.000000Z"
}
],
"pagination": {
"current_page": 1,
"last_page": 1,
"per_page": 20,
"total": 1
}
}
Get Unread Count
GET /api/v1/notifications/unread-count
Response:
{ "unread_count": 5 }
Mark as Read
PATCH /api/v1/notifications/{id}/read
Mark All as Read
POST /api/v1/notifications/mark-all-read
Response:
{ "message": "All notifications marked as read", "count": 5 }
Delete Notification
DELETE /api/v1/notifications/{id}
Frontend Components
NotificationProvider
Located at frontend/contexts/notification-context.tsx. Wraps the application and provides notification state + actions.
What it does:
- Fetches the 10 most recent notifications + unread count on authentication
- Listens to
user.{id}channel for.notification.createdevents via WebSocket - Shows a toast popup when a new notification arrives
- Uses optimistic updates for mark-read and delete actions (reverts on API failure)
Context value:
interface NotificationContextValue {
notifications: AppNotification[]; // Recent notifications
unreadCount: number; // Total unread count
loading: boolean; // Initial load in progress
markAsRead: (id: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
deleteNotification: (id: string) => Promise<void>;
refresh: () => Promise<void>; // Re-fetch from server
}
Usage in components:
import { useNotifications } from "@/contexts/notification-context";
function MyComponent() {
const { unreadCount, notifications, markAsRead } = useNotifications();
return (
<div>
<span>You have {unreadCount} unread notifications</span>
{notifications.map(n => (
<div key={n.id} onClick={() => markAsRead(n.id)}>
{n.title}
</div>
))}
</div>
);
}
NotificationDropdown
Located at frontend/components/notification-dropdown.tsx. The bell icon in the header with a popover dropdown.
Features:
- Bell icon with red unread count badge (shows "99+" for > 99)
- "Mark all read" button (only when unread > 0)
- Scrollable list of recent notifications with:
- Severity-colored icon
- Title (bold if unread) + unread dot indicator
- Body text (2-line clamp)
- Relative time ("just now", "5m ago", "2h ago", "3d ago")
- Click-through to
action_urlwhen set
- "View all notifications" link to
/settings/notifications
Provider Hierarchy
AuthProvider
└─ EchoProvider (WebSocket connection)
└─ NotificationProvider (listens to user channel)
└─ ThemeProvider
└─ ModulesProvider
└─ {children}
Notification Model
The App\Models\Tenant\Notification model provides useful scopes and methods:
use App\Models\Tenant\Notification;
// Query scopes
Notification::forUser($userId)->unread()->get();
Notification::forUser($userId)->ofType('order.created')->get();
Notification::forUser($userId)->fromModule('orders')->latest()->paginate(20);
// Instance methods
$notification->markAsRead();
$notification->markAsUnread();
$notification->isRead(); // returns bool
Broadcast Event
When a notification is created, a NotificationCreated event is dispatched. It extends TenantBroadcastEvent and broadcasts to the user's private channel:
- Channel:
private-user.{userId} - Event name:
.notification.created - Payload: Full notification object including
id,type,title,body,icon,severity,action_url,created_at
The event is processed by Horizon (queued via Redis) and delivered by Reverb.
Module Integration Example
Here's a complete example of a module sending notifications when an order is created:
// In modules/Orders/backend/App/Listeners/SendOrderNotification.php
namespace Modules\Orders\App\Listeners;
use App\Services\NotificationService;
use Modules\Orders\App\Events\OrderCreated;
class SendOrderNotification
{
public function handle(OrderCreated $event): void
{
$order = $event->order;
// Notify the store owner
app(NotificationService::class)->send($order->store_owner_id, [
'type' => 'order.created',
'module' => 'orders',
'title' => 'New order received',
'body' => "Order #{$order->order_number} - {$order->item_count} items, \${$order->total}",
'icon' => 'ShoppingCart',
'severity' => 'info',
'action_url' => "/orders/{$order->id}",
'data' => [
'order_id' => $order->id,
'total' => $order->total,
],
]);
}
}
No channel registration or configuration is needed. The NotificationService handles database persistence and real-time delivery automatically.
Mobile Push Notifications
The mobile app receives notifications two ways:
- WebSocket (foreground) —
notification.createdevent onprivate-user.{id}channel updates the in-app notification list and shows a banner if the notification handler allows it - Push notification (background/closed) — FCM/APNs delivery via the backend's device token registration
Both paths originate from the same NotificationService::send() call, so the flow works identically whether the app is open or closed.
Smart Notification Handling
The mobile NotificationsContext tracks the active pathname and passes it to the push notification handler. If the notification's action_url points to a chat the user is currently viewing, the banner is suppressed — the WebSocket event already updates the chat in real-time.
Calling from Public Routes
If you call NotificationService::send() from a route that runs outside tenant middleware (like public webhook endpoints or widget routes), you must initialize tenant context first so the broadcast job can properly serialize tenant-scoped models:
$tenant = \App\Models\Tenant::find($tenantId);
if ($tenant) tenancy()->initialize($tenant);
try {
app(NotificationService::class)->send([$userId], [
'type' => 'order.updated',
'title' => 'Order status changed',
'body' => 'Your order is now shipped',
// ...
]);
} finally {
tenancy()->end();
}
Without tenant context, the NotificationCreated event's queued broadcast job fails silently during serialization — the database row is created but no WebSocket event fires.
Notification Tap Routing (Mobile)
On notification tap, the mobile app extracts conversation_id / order_id from any of these locations:
data.conversation_id(local notifications created by mobile)data.data.conversation_id(backend push, nested payload)data.notification.data.conversation_id(deeply nested)data.action_urlregex match (e.g.,/live-chat/conversations/55)
This handles both local notifications scheduled by the app and backend-delivered push notifications with different payload shapes.