19 — Notification System¶
Table of Contents¶
- Overview
- Two Notification Channels
- notifications Table
- device_tokens Table
- DeviceToken Model
- FcmService
- Push Notification Flow
- In-App Notification Flow
- NotificationDashboardController
- AdminNotificationController
- Notifications/Index.jsx Page
- Notification Types and Targeting
- Read Tracking
- Interaction with SendWhatsAppNotificationJob
Overview¶
PyroRadius operates two parallel notification channels:
| Channel | Mechanism | Destination |
|---|---|---|
| In-app | notifications table queried on page load |
Admin/dealer web dashboard |
| Push (FCM) | Firebase Cloud Messaging via FcmService |
Customer mobile app |
These channels are independent. A single notification event (e.g., a new payment) can trigger both in-app records for staff and push notifications to the affected customer's phone simultaneously.
The notification system is separate from the WhatsApp system but can be coordinated: when a payment is recorded, the controller may dispatch both SendWhatsAppNotificationJob (for the customer's phone) and create a Notification record (for admin/dealer visibility).
Two Notification Channels¶
Channel 1 — In-App Notifications¶
In-app notifications are stored as rows in the notifications table and surfaced in the PyroRadius web UI via the bell icon in the top navigation bar. They are targeted at admin users and dealers — not directly at customers browsing the portal.
Polling or websocket push (Laravel Echo) updates the unread count badge in real time without full page refreshes.
Channel 2 — Push Notifications (FCM)¶
Push notifications are sent to customers via Firebase Cloud Messaging (FCM). Customers register their device token through the customer portal or mobile app, which stores a record in device_tokens. When a push notification is triggered, FcmService uses the stored token to deliver a push payload to the customer's Android or iOS device.
notifications Table¶
| Column | Type | Description |
|---|---|---|
id |
bigint (PK) | Auto-increment |
title |
varchar | Short notification title |
body |
text | Full notification body text |
type |
varchar | Category label (e.g., payment, expiry, system, campaign) |
target |
enum | all, dealer, customer — who should see this notification |
dealer_id |
bigint nullable (FK) | If target = dealer, restricts visibility to this dealer |
customer_id |
bigint nullable (FK) | If target = customer, restricts visibility to this customer |
is_push |
boolean | Whether this notification was also sent via FCM push |
sent_at |
timestamp nullable | When the push was dispatched (null if push not attempted) |
created_at |
timestamp | Record creation time |
updated_at |
timestamp | Last update |
Target Logic¶
target |
dealer_id |
customer_id |
Who Sees It |
|---|---|---|---|
all |
NULL | NULL | Every admin and dealer |
dealer |
set | NULL | Only that dealer (and super admins) |
customer |
nullable | set | Only that customer in their portal |
device_tokens Table¶
Stores FCM registration tokens for customer devices.
| Column | Type | Description |
|---|---|---|
id |
bigint (PK) | Auto-increment |
customer_id |
bigint (FK) | Owning customer |
token |
varchar (unique) | FCM device registration token |
platform |
enum | android, ios, web |
last_used_at |
timestamp nullable | Updated on each successful push send |
created_at |
timestamp | When the device registered |
updated_at |
timestamp | — |
A single customer may have multiple device tokens (multiple devices or reinstalls). FCM tokens expire after prolonged inactivity; FcmService removes stale tokens when FCM returns messaging/registration-token-not-registered errors.
DeviceToken Model¶
Class: App\Models\DeviceToken
Key Relationships¶
Registration Endpoint¶
When a customer logs into the portal or mobile app, the frontend sends the FCM token to:
The controller performs an updateOrCreate on the token value to avoid duplicates:
DeviceToken::updateOrCreate(
['token' => $request->token],
[
'customer_id' => auth()->id(),
'platform' => $request->platform,
'last_used_at' => now(),
]
);
Token Cleanup¶
Expired tokens (no last_used_at in 90 days) are pruned by the PruneDeviceTokens artisan command, scheduled weekly.
FcmService¶
Namespace: App\Services\FcmService
Wraps the Firebase Admin SDK (or HTTP v1 API calls) to send push notifications.
Method Reference¶
sendToCustomer(Customer $customer, string $title, string $body, array $data = []): array¶
Retrieves all active DeviceToken records for the customer and calls send() for each.
Returns a summary array:
[
'sent' => 3, // successful sends
'failed' => 0, // failed sends (token removed if expired)
'tokens' => 3, // total tokens found
]
send(string $token, string $title, string $body, array $data = []): bool¶
Dispatches a single FCM message to one device token.
Request payload (HTTP v1 API):
{
"message": {
"token": "device_fcm_token",
"notification": {
"title": "Payment Received",
"body": "PKR 2,500 received. Thank you!"
},
"data": {
"type": "payment",
"customer_id": "42"
},
"android": {
"priority": "HIGH"
}
}
}
On messaging/registration-token-not-registered response, the token is deleted from device_tokens to avoid future failed attempts.
sendToAll(string $title, string $body, array $data = []): array¶
Sends a push to all customers system-wide. Used for global announcements. Internally paginates through all customers with at least one device token to avoid memory exhaustion.
Push Notification Flow¶
Event occurs (e.g., payment recorded)
│
▼
PaymentController::store()
│
├─► [WhatsApp] SendWhatsAppNotificationJob::dispatch(...)
│
└─► [FCM] FcmService::sendToCustomer($customer, 'Payment Received', $message)
│
├─ Fetch DeviceToken[] for customer
│
├─ For each token → POST to FCM HTTP v1 API
│
├─ On success → update last_used_at
│
└─ On token-expired error → delete DeviceToken record
In-App Notification Flow¶
Event or admin action occurs
│
▼
Notification::create([
'title' => 'Payment of PKR 2,500 received',
'body' => 'Customer Ali Hassan paid invoice #INV-2026-004.',
'type' => 'payment',
'target' => 'dealer',
'dealer_id' => $customer->dealer_id,
'is_push' => false,
])
│
▼
Dealer opens dashboard → NotificationDashboardController::index()
returns unread notifications via Inertia props
│
▼
Bell icon badge shows unread count
User clicks notification → NotificationRead record created → marked read
NotificationDashboardController¶
Class: App\Http\Controllers\NotificationDashboardController
Route: GET /admin/notifications/dashboard
index()¶
Returns the notification feed for the currently authenticated user, respecting their role:
- Super Admin: Sees notifications where
target = allORtarget = dealerfor any dealer ORtarget = customer. - Dealer: Sees notifications where
target = allOR (target = dealerANDdealer_id = auth dealer) OR (target = customerAND customer belongs to auth dealer).
public function index(): Response
{
$user = auth()->user();
$notifications = Notification::query()
->forUser($user) // scope defined on Notification model
->orderByDesc('created_at')
->paginate(30);
$unreadCount = Notification::query()
->forUser($user)
->unread($user->id)
->count();
return Inertia::render('Notifications/Index', [
'notifications' => $notifications,
'unreadCount' => $unreadCount,
]);
}
markRead(Notification $notification)¶
Creates or finds a NotificationRead record for the current user and notification.
markAllRead()¶
Bulk-inserts NotificationRead records for all unread notifications visible to the current user.
AdminNotificationController¶
Class: App\Http\Controllers\AdminNotificationController
Routes: Under /admin/notifications
This controller handles the creation of notifications (as opposed to NotificationDashboardController which handles reading them).
Methods¶
index()¶
Lists all notifications in the system with filters for type and target. Super admin only.
create()¶
Shows the notification composition form. Allows selecting target type (all, dealer, customer) and whether to also send FCM push.
store(Request $request)¶
Validates and persists a new notification. If is_push = true, also calls FcmService to dispatch push notifications:
$notification = Notification::create($validated);
if ($validated['is_push']) {
match($notification->target) {
'all' => $this->fcm->sendToAll($notification->title, $notification->body),
'dealer' => $this->fcm->sendToDealer($notification->dealer_id, $notification->title, $notification->body),
'customer' => $this->fcm->sendToCustomer($customer, $notification->title, $notification->body),
};
$notification->update(['sent_at' => now()]);
}
destroy(Notification $notification)¶
Soft-deletes the notification. Associated NotificationRead records are retained for audit.
Notifications/Index.jsx Page¶
Path: resources/js/Pages/Notifications/Index.jsx
Component Structure¶
<NotificationsIndex>
├── <PageHeader title="Notifications" />
├── <UnreadBadge count={unreadCount} />
├── <FilterBar> (type, target, date range)
├── <Button onClick={markAllRead}>Mark All Read</Button>
├── <NotificationList>
│ └── <NotificationItem> × N
│ ├── Icon (based on type)
│ ├── Title + Body
│ ├── Target badge (All / Dealer / Customer)
│ ├── Timestamp (relative, e.g., "2 hours ago")
│ └── Mark Read button (if unread)
└── <Pagination />
Unread Highlighting¶
Notifications not present in the user's NotificationRead records are rendered with a highlighted background. Clicking anywhere on the notification row fires the markRead POST request.
Real-Time Badge Update¶
The unread count badge in the top navigation bar is updated via a polling mechanism (every 60 seconds) using a lightweight fetch to /api/notifications/unread-count. This avoids the need for websocket infrastructure while keeping the badge reasonably current.
Notification Types and Targeting¶
type Values¶
| Type | When Created |
|---|---|
payment |
Payment recorded by engineer or auto |
expiry |
Customer approaching or past expiry |
system |
System health alert (queue down, session dropped) |
campaign |
Campaign send completed |
suspension |
Customer account suspended/reactivated |
registration |
New customer added |
alert |
Manual admin-created alert |
Sending Notifications to Specific Targets¶
// Notify a specific dealer about a new customer
Notification::create([
'title' => 'New Customer Registered',
'body' => "Customer Ahmed Raza has been added under your account.",
'type' => 'registration',
'target' => 'dealer',
'dealer_id' => $customer->dealer_id,
'is_push' => false,
]);
// Notify a specific customer via push
Notification::create([
'title' => 'Account Activated',
'body' => "Your PPPoE account is now active. Enjoy your connection!",
'type' => 'suspension',
'target' => 'customer',
'customer_id' => $customer->id,
'is_push' => true,
'sent_at' => now(),
]);
// ... plus FcmService::sendToCustomer() call
Read Tracking¶
Table: notification_reads
| Column | Type | Description |
|---|---|---|
id |
bigint (PK) | Auto-increment |
notification_id |
bigint (FK) | Notification being marked read |
user_id |
bigint (FK) | Admin or dealer user who read it |
read_at |
timestamp | When it was marked read |
This table is queried using a NOT EXISTS subquery to determine which notifications are unread for a given user:
SELECT n.*
FROM notifications n
WHERE NOT EXISTS (
SELECT 1 FROM notification_reads nr
WHERE nr.notification_id = n.id
AND nr.user_id = :user_id
)
ORDER BY n.created_at DESC;
Interaction with SendWhatsAppNotificationJob¶
The notification system and the WhatsApp job are independent but often triggered together. A controller action may do all of the following:
// 1. Record the event in the DB
$payment = Payment::create([...]);
// 2. Create an in-app notification for the dealer
Notification::create([
'title' => "Payment of PKR {$payment->amount} received",
'body' => "Customer {$customer->name} paid invoice #{$invoice->invoice_number}.",
'type' => 'payment',
'target' => 'dealer',
'dealer_id' => $customer->dealer_id,
'is_push' => false,
]);
// 3. Send a WhatsApp message to the customer (async)
SendWhatsAppNotificationJob::dispatch(
$customer->id,
'payment_received',
[
'customer_name' => $customer->name,
'amount' => number_format($payment->amount, 2),
'payment_info' => $payment->reference_no,
'expiry_date' => $customer->expiry_date->format('d M Y'),
'helpline' => Setting::get('company_helpline'),
]
)->onQueue('whatsapp');
// 4. Send FCM push to the customer's phone (optional, if they have tokens)
if (DeviceToken::where('customer_id', $customer->id)->exists()) {
$this->fcm->sendToCustomer(
$customer,
'Payment Received',
"PKR {$payment->amount} payment confirmed. Thank you!"
);
}
This pattern keeps each channel independently retryable: if WhatsApp fails, it retries via the job queue without affecting the FCM push, and vice versa.
Last updated: 2026-07-13