Skip to content

19 — Notification System

Table of Contents

  1. Overview
  2. Two Notification Channels
  3. notifications Table
  4. device_tokens Table
  5. DeviceToken Model
  6. FcmService
  7. Push Notification Flow
  8. In-App Notification Flow
  9. NotificationDashboardController
  10. AdminNotificationController
  11. Notifications/Index.jsx Page
  12. Notification Types and Targeting
  13. Read Tracking
  14. 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

public function customer(): BelongsTo
{
    return $this->belongsTo(Customer::class);
}

Registration Endpoint

When a customer logs into the portal or mobile app, the frontend sends the FCM token to:

POST /api/device-tokens
Body: { "token": "fcm_token_string", "platform": "android" }

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.

php artisan tokens:prune

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 = all OR target = dealer for any dealer OR target = customer.
  • Dealer: Sees notifications where target = all OR (target = dealer AND dealer_id = auth dealer) OR (target = customer AND 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.

POST /admin/notifications/{id}/read

markAllRead()

Bulk-inserts NotificationRead records for all unread notifications visible to the current user.

POST /admin/notifications/read-all

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