Skip to content

The notifications lifecycle

This page is about the in-app notification feed — the bell / updates list an agent sees inside the broker or sales-man app. A domain event (a lead is verified, a sale is invoiced, an EOI changes state) writes a row in the notifications table, and the app polls for it and marks it read. That is a different thing from the outbound push / SMS / email delivery documented on Notifications (integration): outbound channels push a message out to a person, whereas this feed is a stored, pull-based inbox the app queries. Everything here is read from the current backend.

From the sales-man (or broker) app, the agent opens the notifications list — each row is one row from the notifications table, newest first, with a read / unread state:

Sales-man app — the in-app notifications feed: a reverse-chronological list of lead, invoice, and EOI updates, each with a read / unread state.

A feed row is App\Models\Notification (app/Models/Notification.php:10), backed by the standard Laravel notifications table (database/migrations/2023_09_20_152852_create_notifications_table.php:22-34):

  • id — auto-increment integer (the app marks rows read by this id, not a UUID).
  • type — a plain string; Laravel’s database channel stores the notification class name here (e.g. App\Notifications\Broker\VerifiedLead). This is not the business type the app routes on — see the gotcha below.
  • notifiable_type / notifiable_id — the polymorphic recipient (Broker, SalesMan, Shopper, or a base User), written by $table->morphs('notifiable').
  • data — a JSON blob. Its shape is always Notification::createJsonMessage($title, $body, $redirectData) (app/Models/Notification.php:64-76), i.e. { message: { title, body, redirect_data } }.
  • read_at — nullable timestamp. null means unread.
  • deleted_at / timestamps — the model uses SoftDeletes.

Two accessors read back out of data: message returns data['message']['body'] (app/Models/Notification.php:39-42) and redirect_data returns data['message']['redirect_data'] (:55-58). The app uses redirect_data.type — a NotificationTypeEnums value — to decide where a tap deep-links (the lead, the invoice, the EOI).

sequenceDiagram
participant EV as Domain event (CRM webhook / in-app)
participant API as Backend (Laravel)
participant CH as database channel
participant DB as notifications table
participant ML as Mail channel (OrascomMail)
actor APP as Agent app (Broker / Sales-Man)
EV->>API: lead / meeting / invoice / EOI status change
API->>API: recipient calls notify(new XNotification)
Note over API,CH: via() returns database plus OrascomMailNotification
API->>CH: database channel
CH->>DB: INSERT row - data JSON, read_at null, notifiable = recipient
API->>ML: send email (separate outbound channel)
APP->>API: GET user/notifications (poll, per_page 10)
API->>DB: where notifiable_id and notifiable_type, order by created_at desc
DB-->>API: one page of rows
API-->>APP: NotificationResource list with is_read
APP->>API: POST user/notifications/read - ids plus is_read
API->>DB: set read_at = now() for those ids
API-->>APP: Notifications updated successfully
In-app notification — event writes a row, app polls it, app marks it read (verified)
  1. An event fires and a recipient is notified. The most common trigger is a crms-middleware webhook — the CRM pushes a status change and the portal turns it into a feed row: POST brokers/user/notifications (general), POST leads/{leadSourceId}/notifications, POST meetings/{meetingSourceId}/notifications, POST eois/{eoiSourceId}/notifications (routes/api/crms-middleware.php:13-17). The controller resolves the recipient and calls $notifiable->notify(new XNotification(...)) (app/Http/Controllers/Api/CrmsMiddleware/NotificationController.php:23-93). For leads, LeadSubmissionNotification maps the CRM status/decision to the right class — VerifiedLead, NotVerifiedLead, ReservedLead, ContractedLead, RejectedLead (app/Actions/User/Broker/LeadSubmissionNotification.php:16-55). A few events are raised in-app, not from the CRM: a shopper reservation fires SaleStatusDone (app/Http/Controllers/Api/Shopper/UnitSaleController.php:212), a duplicate lead fires DuplicateLead (app/Http/Controllers/Api/SalesMan/LeadController.php:101), and an approved invoice fires SalesInvoiceStatusApproved from an Eloquent observer (app/Observers/SalesInvoiceObserver.php:56).

  2. The database channel writes the row. Because via() includes 'database', Laravel’s built-in database channel persists through the recipient’s notifications() morphMany (app/Models/User.php:64-67, enabled by the Notifiable trait at :11,16). It stores the toArray() payload into data and sets read_at = null. toArray() is always built with Notification::createJsonMessage($title, $body, $redirectData) and stamps redirect_data.type with a NotificationTypeEnums value (e.g. app/Notifications/Broker/VerifiedLead.php:62-77, type at :70).

  3. The app polls the feed. GET user/notifications is registered per persona (routes/api/broker.php:98, routes/api/sales-man.php:39) behind auth:sanctum + user-provider:<persona>. NotificationController@index resolves the session agent and calls GetNotifications::handle($id, <Persona>::class, $filters) (app/Http/Controllers/Api/Broker/NotificationController.php:14-25, app/Http/Controllers/Api/SalesMan/NotificationController.php:14-25). The action queries by notifiable_id + notifiable_type, optionally whereNull('read_at') when unread is set, orders by created_at desc, and paginates (default 10 per page) (app/Actions/Notification/GetNotifications.php:15-28). Query params are unread (bool) and per_page (int) (app/Http/Requests/Notification/IndexRequest.php:14-20). Each row is serialized by NotificationResource to { id, message, redirect_data, created_at, is_read }, where message is the body, created_at is a unix timestamp, and is_read = (bool) read_at (app/Http/Resources/NotificationResource.php).

  4. The app marks rows read. POST user/notifications/read (routes/api/broker.php:99, routes/api/sales-man.php:40) hits NotificationController@update, inherited from NotificationBaseController (app/Http/Controllers/Api/Base/NotificationBaseController.php:12-17). It runs MarkNotificationsAsRead, which does Notification::whereIn('id', $notification_ids)->update(['read_at' => $is_read ? now() : null]) (app/Actions/Notification/MarkNotificationsAsRead.php:10-14). The request body is notification_ids (array) + is_read (bool); MarkAsReadRequest validates that every id exists and belongs to the calling agentnotifiable_type = <caller class> and notifiable_id = <caller id> (app/Http/Requests/Notification/MarkAsReadRequest.php:17-25).

redirect_data.type carries an App\Enums\NotificationTypeEnums value (string-backed, app/Enums/NotificationTypeEnums.php:9-15). This is the value the app switches on to deep-link a tap — it is separate from the type column (which holds the class name). Not every enum case is actually emitted into the feed today:

Enum value Case Emitted into the feed by Actually produced?
lead LEAD VerifiedLead, NotVerifiedLead, ReservedLead, ContractedLead, RejectedLead, AcceptedMeeting, RejectedMeeting Yes
sales_invoice SALES_INVOICE SalesInvoiceStatusApproved Yes
eoi EOI SuccessEOISubmission, ConfirmedEOIPayment, ExpiredEOI, EOIChequeSubmitted, EOIBankTransferSubmitted Yes
sale SALE (none — no notification sets this value) No
unit UNIT (none — UnitCreationNotification is email-only, its via() returns only OrascomMailNotification) No