Поддержите проект сделав пожертвование.

Как выжать максимум из Database Notifications

Все мы, конечно, знаем, что такое Laravel Database Notifications. Но я совсем не уверен, что все понимают, какую мощь они за собой скрывают.

Обычно, все уроки показывают нам, как можно отправить уведомление в database, как его можно потом достать из базы данных, как показать пользователю, как отметить прочитанным. И на этом, как правило, урок заканчивается.

И всякий раз так и хочется спросить: «А зачем? Зачем это всё нужно?». Зачем, если есть уведомления на почту, в телегу? Зачем отправлять уведомления ещё и в базу данных? Чтобы пользователь ещё в одном месте, ругаясь, отмечал уведомления как прочитанные? Или чтобы они просто лежали?

Философия уведомлений

Очень простой вопрос — зачем нужны уведомления? Очевидно, чтобы сообщить какую-то информацию; привлечь внимание к какой-то проблеме или задаче.

Что это означает с точки зрения бизнес-приложения? Что получатель уведомления должен куда-то зайти и что-то сделать.

Куда зайти? Очевидно, туда, куда ведет ссылка, которая пришла ему в уведомлении. Прочти, перейди, сделай. Всё просто.

Значит внутри Database Notification тоже должна храниться информация о том, куда идти и что сделать.

А что если мы используем эту информацию, чтобы разместить внутри пользовательского интерфейса красивые красные кружочки с числами внутри, переходя по которым пользователь сможет дойти до того места, куда указывает уведомление?

Что внутри?

Внутри Database Notification есть поле data, в которое мы можем записать всё что угодно. Но что именно? В каком формате?

К счастью, у нас есть Web Notification API, который идеально подходит для уведомлений в контексте браузера.

К слову, если вы используете Broadcast Notifications, то есть большой резон сделать их пейлоад в формате Web Notification. У него есть title и options, внутри которого много полезного, в том числе и data, куда можно положить всё что угодно.

Отлично, значит наш Database Notification может выглядеть как-то так:

use Illuminate\Notifications\Messages\DatabaseMessage;
use Illuminate\Notifications\Notification;

class NewOrderNotification extends Notification
{
    public function __construct(public Order $order)
    {
        //
    }
	
    public function toDatabase(object $notifiable): DatabaseMessage
    {
        return new DatabaseMessage([
            'title'   => 'Новый заказ',
            'options' => [
                'body' => 'Поступил новый заказ, необходимо оформить',
                'data' => [
                    'url' => url("/orders/{$this->order->id}")
                ]
            ]
        ]);
    }
}

И давайте сразу договоримся: мы поменяли формат поля notifactions.data. Теперь это не TEXT, теперь это JSON, и нам будет проще писать запросы.

Что снаружи?

Итак, в нашей базе данных лежит уведомление, которое указывает на конкретный заказ. И мы хотим показать в пользовательском интерфейсе счетчики непрочитанных уведомлений, которые будут находиться в главном меню на пункте «Заказы» и в списке заказов напротив именно этого заказа.

Чтобы нарисовать пункт меню «Заказы» и показать на нем счетчик непрочитанных уведомлений, мы сделаем нечто такое:

use App\Http\Controllers\Controller;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Notifications\DatabaseNotification;

class MainMenu extends Controller
{
    public function __invoke(#[CurrentUser] User $user) 
    {
        $menu = [];
	
        $menu[] = [
            'title' => 'Заказы',
            'notifications_count' => $user->notifications()
                ->unread()
                ->where(
                    'data->options->data->url',
                    'like', 
                    url('/orders/').'%'
                )
                ->count()
        ];
		
        return $menu;
    }
}

Выглядит пока что ужасно, но это работает. Мы знаем количество непрочитанных уведомлений и сможем нарисовать циферку в красном кружочке, которая подскажет менеджеру, что в этом пункте меню его ждет работа.

Следующим шагом будет отображение списка заказов, где для каждого заказа мы должны вычислить количество непрочитанных уведомлений. И тут мы упираемся в проблему N+1 и понимаем, что нам нужен полноценный Relation.

Mention

Начиная с этого момента мы предлагаем следующую терминологию:

Notification — это то, что мы отправляем пользователю. Mention — это упоминание объекта в Notification.

Если мы хотим, чтобы у нас был полноценный Relation между уведомлениями и объектами, которые в нём упоминаются, нам, определённо, нужна pivot таблица, в которой, с одной стороны, будет идентификатор уведомления, а с другой — morph, указывающий на объект.

Без лишних подробностей — писать миграции и делать пивоты вы умеете и сами. Просто постулируем, что теперь у нашей модели Order есть Relation по имени mentions, который возвращает связанные с заказом DatabaseNotification.

Можем ли мы добавить запись в эту таблицу непосредственно в момент отправки Database уведомления? Не думаю. У нас нет такой возможности.

Вместо этого, нам придется отправить уведомление, затем перехватить событие \Illuminate\Notifications\Events\NotificationSent, проанализировать поступившее уведомление и, если нужно, создать связь.

Давайте сперва доработаем уведомление, укажем связанные объекты явным образом:

use Illuminate\Notifications\Messages\DatabaseMessage;
use Illuminate\Notifications\Notification;

class NewOrderNotification extends Notification
{
    public function __construct(public Order $order)
    {
        //
    }
	
    public function toDatabase(object $notifiable): DatabaseMessage
    {
        return new DatabaseMessage([
            'title'   => 'Новый заказ',
            'options' => [
                'body' => 'Поступил новый заказ, необходимо оформить',
                'data' => [
                    'url' => url("/orders/{$this->order->id}")
                ]
            ],
            'mentions' => [
                "{$this->order->getMorphClass()}#{$this->order->id}"
            ]
        ]);
    }
}

Да, всё верно. Одно уведомление может упоминать сразу несколько объектов. Но об этом поговорим позже.

Теперь слушатель:

use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\Events\NotificationSent;

class RegisterMention
{
    public function handle(NotificationSent $event): void
    {
        if ($event->channel == 'database') {
  	
            $notification = DatabaseNotification::query()
                ->find($event->notification->id);

            $mentions = $notification?->data['mentions'] ?? [];

            array_map(function(string $mention) {

                list($morph, $key) = explode('#', $mention);

                $model = Relation::getMorphedModel($morph) ?? $morph;

                if (class_exists($model)) {
                    $model = $model::query()->find($key);
                    if ($model && method_exists($model, 'mentions')) {
                        $model->mentions()->attach($notification);
                    }
                }

            }, $mentions);
        }
    }
}

Что снаружи? Часть вторая

Теперь, когда у нас есть Relation по имени mentions, мы можем продолжить строить наш пользовательский интерфейс.

Покажем список заказов:

use App\Http\Controllers\Controller;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    public function index(Request $request) 
    {
        return Order::query()
            ->withCount([
                'mentions' => fn(Builder $builder) => $builder
                    ->unread()
                    ->whereMorphedTo('notifiable', $request->user())
            ])
            ->paginate();
    }
}

В таком ответе вместе с каждым заказом нам будет доступно mentions_count — количество непрочитанных сообщений, адресованных аутентифицированному пользователю и относящихся к этому заказу.

Этого достаточно, чтобы нарисовать указатель напротив заказа, и пользователь будет знать, какой именно заказ надо обработать.

Пользователь заходит в заказ, и…

Что делать?

Да, подскажем пользователю, что делать. Покажем ему то самое непрочитанное Database уведомление.

use App\Http\Controllers\Controller;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    public function show(Request $request, Order $order) 
    {
        return $order
            ->load([
                'mentions' => fn(Builder $builder) => $builder
                    ->unread()
                    ->whereMorphedTo('notifiable', $request->user())
            ]);
    }
}

В этом ответе будут присутствовать все непрочитанные пользователем уведомления, относящиеся к данному заказу, и их можно показать в карточке заказа во всей красе.

Там будет написано, зачем пользователь пришел сюда, и что он должен сделать.

И можно даже нарисовать крестик, нажав на который, пользователь отметит уведомление как прочитанное.

Промежуточные итоги

Мы продемонстрировали рецепт, как можно использовать Database уведомления, чтобы построить пользовательский интерфейс, который сам ведет пользователя туда, где его ждут новости.

При таком подходе, как можно заметить, Database уведомления сполна заменяют Mail (или другие, подобные) уведомления, при условии, что пользователь ходит в приложение как на работу (а мы как раз про бизнес-приложения и говорим).

Можно и вовсе отключить уведомления на почту или в telegram. Если пользователь постоянно находится в приложении, то связка Broadcast и Database уведомлений не оставят ему шанса пропустить что-то важное.

Сложное уведомление

Ранее мы обещали рассмотреть ситуацию, когда уведомление упоминает сразу несколько объектов. Вот пример.

Допустим, в нашем приложении заказы отображаются не только в разделе «Заказы». Допустим, в карточке «Клиента» тоже есть список заказов, его заказов.

Когда мы создаем Database уведомление для такого случая, мы в этом уведомлении оставим ссылки и на заказ, и на заказчика:

use Illuminate\Notifications\Messages\DatabaseMessage;
use Illuminate\Notifications\Notification;

class NewOrderNotification extends Notification
{
    public function __construct(public Order $order)
    {
        //
    }

    public function toDatabase(object $notifiable): DatabaseMessage
    {
        return new DatabaseMessage([
            'title'   => 'Новый заказ',
            'options' => [
                'body' => 'Поступил новый заказ, необходимо оформить',
                'data' => [
                    'url' => url("/orders/{$this->order->id}")
                ]
            ],
            'mentions' => [
                "{$this->order->getMorphClass()}#{$this->order->id}",
                "{$this->order->customer->getMorphClass()}#{$this->order->customer->id}",
            ]
        ]);
    }
}

Если вы все правильно поняли, то видите, что такое уведомление будет порождать счетчик непрочитанных сообщений как для заказа, так и для заказчика; а как правильно это отобразить внутри карточки заказчика — уже дело техники. Просто у менеджера будет не один, а несколько путей навигации, которые все равно приведут его к одному и тому же заказу.

Постоянное уведомление

Это ещё один интересный сценарий, вызванный к жизни реальными потребностями: уведомление, которое нельзя проигнорировать.

Представим ситуацию. Есть бизнес-процесс высокой важности. Работа должна быть выполнена быстро и отговорки не принимаются. Но люди небезгрешны. Уведомление улетело в корзину, пользователь отвлекся на другие дела и просто… забыл.

Решение — не позволим пользователю отметить уведомление как прочитанное. Просто добавим в DatabaseMessage какой-то признак, по которому будем это запрещать.

А когда пользователь выполнит необходимую работу, например, изменит статус заказа, приложение само отметит это уведомление как прочитанное и оно перестанет мозолить глаза.

Ну, например, так:

use Illuminate\Notifications\DatabaseNotification;

class OrderObserver
{
    public function updated(Order $order): void
    {
        if ($order->isComplete()) {
            $order->mentions()
                ->unread()
                ->where('type', \App\Notifications\NewOrderNotification::class)
                ->each(fn(DatabaseNotification $notification) => $notification
                    ->markAsRead()
                )
        }
    }
}

И тут неважно, кто именно был получателем уведомления, или даже получателей было много, да хоть бы и все менеджеры. Работа выполнена, все свободны.

Тихое уведомление

Нередко может возникнуть необходимость уведомить пользователя, но сделать это «с уважением» (с), не отвлекая его от текущих дел звуками и вибрациями.

Такую возможность предоставляет Telegram (флаг disable_notification), такую возможность предоставляет Web Notification API (флаг silent). Такое же поведение мы можем сделать и для Database уведомлений.

Создадим Database уведомление с флагом silent (коль скоро мы договорились, что тело уведомления соответствует формату Web Notification):

use Illuminate\Notifications\Messages\DatabaseMessage;
use Illuminate\Notifications\Notification;

class OrderCompleteNotification extends Notification
{
    public function __construct(public Order $order)
    {
        //
    }
	
    public function toDatabase(object $notifiable): DatabaseMessage
    {
        return new DatabaseMessage([
            'title'   => 'Заказ оформлен',
            'options' => [
                'body' => 'Заказ успешно оформлен',
                'silent' => true
            ]
        ]);
    }
}

Теперь нам осталось написать слушателя события \Illuminate\Notifications\Events\NotificationSent в которым мы отметим такое уведомление как прочитанное:

use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\Events\NotificationSent;

class MarkSilentNotificationAsRead
{
    public function handle(NotificationSent $event): void
    {
        if ($event->channel == 'database') {

            $notification = DatabaseNotification::query()
                ->find($event->notification->id);

            if ($notification?->data['options']['silent'] ?? false) {
                $notification->markAsRead();
            }
        }
    }
}

Пользователь увидит такое уведомление в общем списке, но оно будет уже прочитанным.

Вместо послесловия

Представленные примеры не готовый рецепт, это лишь концепция.

По хорошему, надо наследовать и расширять DatabaseNotification, в который надо добавлять удобные local scopes, или вообще сделать Custom Builder со всеми нужными методами.

Надо наследовать и расширять DatabaseMessage, сделав к нему удобный интерфейс управления свойствами атрибута data в формате Web Notification.

Более-менее всё, что описано выше, реализовано в composer-пакете https://packagist.org/packages/codewiser/laravel-notifications

0

Вакансии

Партнёры и друзья

Помощь в разработке вашего проекта на Laravel

Независимо от сложности проекта эти кампании помогают сообществу и всем его участникам воплощать идеи в элегантные приложения.

Присоединиться

Инструменты для управления эмоциями, которые помогают людям контролировать свою жизнь и лучше понимать себя.

Перейти

Подкасты c зажигательными эпизодами, которые заставят задуматься и приведут к новым перспективам.

Перейти

Делятся опытом, находят друзей и обсуждают разработку и сопровождение любых бэкендов на PHP.

Перейти