Назад в блог
Разработка

Система отправки Firebase уведомлений через админ панель Admiral

13 минут
Preview
Содержание

Данная статья описывает пошаговый процесс создания системы отправки push-уведомлений через Firebase Cloud Messaging (FCM) в Laravel приложении с админ панелью Admiral. 

Администраторы получат возможность отправлять индивидуальные сообщения пользователям, прикреплять изображения и указывать целевые страницы – всё это прямо из админ-панели, без обращения к разработчикам. 

В статье рассмотрим: подготовку окружения, интеграцию FCM с Laravel, подключение модуля Admiral, настройку интерфейса и примеры кода для полноценной реализации.

Все материалы из этой статьи можно найти в нашем репозитории

🎯 Что мы создадим

Backend (Laravel)

  1. Модель NotificationBag для хранения уведомлений
  2. FormRequest для валидации данных
  3. Resources для структурированных API ответов
  4. Job для асинхронной отправки уведомлений
  5. FCM Service для интеграции с Firebase
  6. Controller с CRUD операциями

Frontend (Admiral админ панель)

  1. Страница списка уведомлений с фильтрами
  2. Форма создания уведомлений
  3. Страница просмотра деталей и статуса отправки
  4. Ajax поиск пользователей

Функциональность

  1. Отправка уведомлений всем пользователям или персонально
  2. Поддержка изображений в уведомлениях
  3. Настройка таргетов уведомлений
  4. Отслеживание статусов отправки
  5. Фильтрация по статусу, типу получателя, заголовку
  6. Обработка и отображение ошибок

Подготовка модели

Создаем миграцию для таблицы уведомлений:

Schema::create('notification_bags', function (Blueprint $table) {
    $table->id();
    $table->text('title');
    $table->text('body')->nullable();
    $table->string('status')->index();
    $table->text('recipient_type')->index();
    $table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnDelete();
    $table->text('error')->nullable();
    $table->jsonb('target')->nullable();
    $table->timestamps();
});

Далее подготавливливаем необходимые енамы:

Статусы отправки - NotificationBagStatus:

* PENDING - Ожидание обработки

* SENT - Успешно отправлено

* FAILED - Ошибка при отправке

Тип получателя - NotificationBagRecipientType:

* ALL - Всем пользователям

* PERSONAL - Одному выбранному пользователю

Тип таргета - MessageTargetType:

* PAGE - Опр. Страница приложения

* POPUP - Всплывающее окно

Экраны приложения - MessageTargetPage (примеры):

* JOURNEYS - экран листинга путешествий

* FEEDBACK - экран обратной связи

Всплывающие окна - MessageTargetPopup (примеры):

* SHARE - окно “поделитесь приложением”

* SUBSCRIPTION - окно вариантов подписки

После прописывания всех енамов нужно написать класс для jsonb поля target

class NotificationBagTargetValue
{
    public function __construct(
        public ?MessageTargetType $type = null,
        public MessageTargetPage|MessageTargetPopup|null $slug = null,
    ) {
    }

    public static function fromArray(array $data): static
    {
        $type = MessageTargetType::tryFrom(Arr::get($data, 'type'));
        $slug = match ($type) {
            MessageTargetType::PAGE => MessageTargetPage::tryFrom(Arr::get($data, 'slug')),
            MessageTargetType::POPUP => MessageTargetPopup::tryFrom(Arr::get($data, 'slug')),
            default => null
        };

        return new static(
            $type,
            $slug,
        );
    }

    public function toJson(): string
    {
        return json_encode([
            'type' => $this->type,
            'slug' => $this->slug,
        ]);
    }
}

Поле Target вынесено в jsonb для обеспечения гибкой конфигурации. Класс можно легко расширить для направления пользователей на экраны с динамическими параметрами, такие как отдельные сущности с id или страницы каталога с предустановленными фильтрами.

Далее добавляем каст для модели:

class NotificationBagTarget implements CastsAttributes
{

    public function get(Model $model, string $key, mixed $value, array $attributes)
    {
        if (!$value) {
            $value = [];
        }
        if (!is_array($value)) {
            $value = json_decode($value, true, flags: JSON_THROW_ON_ERROR);
        }

        return NotificationBagTargetValue::fromArray($value ?: []);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes)
    {
        if (!$value) {
            return null;
        }
        if (!$value instanceof NotificationBagTargetValue) {
            $value = NotificationBagTargetValue::fromArray(is_array($value) ? $value : []);
        }

        return $value->toJson();
    }
}

И саму NotificationBag модель

class NotificationBag extends Model implements HasMedia
{
    use InteractsWithMedia;

    public const IMAGE_COLLECTION = 'image';

    protected $table = 'notification_bags';

    protected $fillable = [
        'status',
        'recipient_type',
        'title',
        'body',
        'user_id',
        'target',
    ];

    protected $casts = [
        'status'         => NotificationBagStatus::class,
        'recipient_type' => NotificationBagRecipientType::class,
        'target'         => NotificationBagTarget::class,
    ];

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

    public function registerMediaCollections(): void
    {
        $this->addMediaCollection(static::IMAGE_COLLECTION)->singleFile();
    }

    public function image(): MorphOne
    {
        return $this->morphOne(Media::class, 'model')
            ->where('collection_name', static::IMAGE_COLLECTION);
    }
}

Обратите внимание: в данном примере для работы с изображениями и файлами используется библиотека laravel-medialibrary. По умолчанию в прикрепленном репозитории настроена работа с локальным хранилищем (Storage), что приведет к неработоспособности функции прикрепления изображений к уведомлениям. FCM не сможет загрузить изображение из локальной сети. Для успешной отправки уведомлений с изображениями необходимо настроить medialibrary для работы с публично доступным хранилищем.

Создание CRUD контроллера и обработчика уведомлений

Вначале создадим форму для создания новых уведомлений с валидацией:

class NotificationBagRequest extends FormRequest
{

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['nullable', 'string'],
            'recipient_type' => ['required', new Enum(NotificationBagRecipientType::class)],
            'user_id' => [
                'nullable',
                'integer',
                'exists:users,id',
                'required_if:recipient_type,' . NotificationBagRecipientType::PERSONAL->value
            ],
            'target_type' => ['nullable', new Enum(MessageTargetType::class)],
            'target_slug_page' => ['nullable', 'string', 'required_if:target_type,page'],
            'target_slug_popup' => ['nullable', 'string', 'required_if:target_type,popup'],
            'image' => ['nullable', 'image', 'max:2048'],
        ];
    }

    protected function prepareForValidation(): void
    {
        if ($this->recipient_type !== NotificationBagRecipientType::PERSONAL->value) {
            $this->merge(['user_id' => null]);
        }
    }
}

Сразу создадим ресурсы отдачи данныхДля индекса:

class NotificationBagIndexResource extends JsonResource
{

    public function toArray(Request $request): array
    {
        return [
            'id'                => $this->id,
            'title'             => $this->title,
            'body'              => $this->body,
            'status'            => $this->status->description(),
            'recipient_type'    => $this->recipient_type->description(),
            'target_type'       => $this->target?->type?->description(),
            'target_slug_page'  => MessageTargetType::PAGE->eq($this->target?->type) ? $this->target->slug : null,
            'target_slug_popup' => MessageTargetType::POPUP->eq($this->target?->type) ? $this->target->slug : null,
            'target_enum'       => $this->target?->slug?->description(),
            'user_id'           => $this->user_id,
            'image'         => $this->image ? [
                'uid'  => $this->image->uuid,
                'url'  => $this->image->getUrl(),
                'type' => $this->image->mime_type,
                'name' => $this->image->file_name,
            ] : null,
            'error'             => $this->error,
            'created_at'        => $this->created_at,
        ];
    }
}

Для просмотра одного уведомления:

class NotificationBagShowResource extends JsonResource
{

    public function toArray(Request $request): array
    {
        /** @var NotificationBag $this */
        return [
            'id'                => $this->id,
            'title'             => $this->title,
            'body'              => $this->body,
            'status'            => $this->status,
            'recipient_type'    => $this->recipient_type,
            'target_type'       => $this->target?->type,
            'target_slug_page'  => MessageTargetType::PAGE->eq($this->target?->type) ? $this->target->slug : null,
            'target_slug_popup' => MessageTargetType::POPUP->eq($this->target->type) ? $this->target->slug : null,
            'target_enum'       => $this->target?->slug,
            'user_id'           => $this->user_id,
            'image'             => $this->image ? [
                'uid'  => $this->image->uuid,
                'url'  => $this->image->getUrl(),
                'type' => $this->image->mime_type,
                'name' => $this->image->file_name,
            ] : null,
            'error'             => $this->error,
            'created_at'        => $this->created_at,
        ];
    }

}

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

class NotificationBagValues
{
    public function create(?NotificationBag $model = null): array
    {
        return [
            'status'            => NotificationBagStatus::options(),
            'target_type'       => MessageTargetType::options(),
            'target_slug_page'  => MessageTargetPage::options(),
            'target_slug_popup' => MessageTargetPopup::options(),
            'recipient_type'    => NotificationBagRecipientType::options(),
            'user_id'           => User::query()
                ->when($model, fn ($query) => $query->orderByRaw("id = ? desc", [$model->user_id]))
                ->orderByDesc('id')
                ->limit(10)
                ->get()
                ->map(fn (User $user) => [
                    'value' => $user->id,
                    'label' => $user->name,
                ]),
        ];
    }

    public function update(NotificationBag $model): array
    {
        return $this->create($model);
    }

    public function filters(): array
    {
        return $this->create();
    }
}

И сам класс контроллера, содержащий логику фильтрации, создания, создания задачи в очереди для асинхронной обработки уведомления.

class NotificationBagController extends Controller
{
    public function index(Request $request)
    {
        $notificationBags = NotificationBag::query()
            ->with(['user'])
            ->tap(Filter::make($request->input('filter'))
                ->scopes(
                    new TextFilter('title'),
                    new EqFilter('status'),
                    new EqFilter('recipient_type'),
                    new EqFilter('user_id'),
                )
            )
            ->tap(Sort::make($request->input('sort')))
            ->paginate($request->input('perPage'));

        return [
            'items' => NotificationBagIndexResource::collection($notificationBags->items()),
            'meta'  => MetaResource::make($notificationBags),
        ];
    }

    public function createForm(NotificationBagValues $values)
    {
        return [
            'data'   => [
                'recipient_type' => NotificationBagRecipientType::ALL,
            ],
            'values' => $values->create(),
        ];
    }

    public function create(NotificationBagRequest $request)
    {
        $data = $request->validated();
        $notificationBag = new NotificationBag();
        $notificationBag->fill([
            'title'          => $data['title'],
            'body'           => $data['body'] ?? null,
            'status'         => NotificationBagStatus::PENDING,
            'recipient_type' => NotificationBagRecipientType::from($data['recipient_type']),
            'user_id'        => $data['user_id'] ?? null,
            'target'         => Arr::get($data, 'target_type') ? [
                'type' => Arr::get($data, 'target_type'),
                'slug' => match (MessageTargetType::tryFrom(Arr::get($data, 'target_type'))) {
                    MessageTargetType::PAGE => Arr::get($data, 'target_slug_page'),
                    MessageTargetType::POPUP => Arr::get($data, 'target_slug_popup'),
                    default => null
                },
            ] : null,
        ]);

        if (!NotificationBagRecipientType::PERSONAL->eq($notificationBag->recipient_type)) {
            $notificationBag->user_id = null;
        }

        $notificationBag->save();

        if (isset($data['image']) && $data['image'] instanceof UploadedFile) {
            $notificationBag
                ->addMediaFromRequest('image')
                ->toMediaCollection(NotificationBag::IMAGE_COLLECTION);
        }

        NotifyFromAdmin::dispatch($notificationBag);
    }

    public function show(int $id, NotificationBagValues $values)
    {
        $notificationBag = NotificationBag::with(['user'])->findOrFail($id);

        return [
            'data'   => new NotificationBagShowResource($notificationBag),
            'values' => $values->update($notificationBag),
        ];
    }

    public function destroy(int $id)
    {
        $notificationBag = NotificationBag::findOrFail($id);
        $notificationBag->delete();
    }

    public function filters(NotificationBagValues $values)
    {
        return [
            'options' => $values->filters(),
        ];
    }

    public function ajaxSelect(string $field, Request $request)
    {
        $query = $request->input('query');

        return match ($field) {
            'user_id' => User::query()
                ->when($query, fn ($builder) => $builder->where('name', 'ilike', '%' . $query . '%'))
                ->limit(10)
                ->get()
                ->map(fn (User $user) => [
                    'value' => $user->id,
                    'label' => $user->name,
                ]),
            default => []
        };
    }
}

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

class NotifyFromAdmin implements ShouldQueue
{
    use Queueable, Dispatchable, SerializesModels, InteractsWithQueue;

    public function __construct(
        private NotificationBag $notificationBag
    ) {}

    public function handle(FCMService $fcmService): void
    {
        try {
            $recipients = $this->getRecipients();
            $message = [
                'title' => $this->notificationBag->title,
                'body' => $this->notificationBag->body,
                'image' => $this->notificationBag->getFirstMediaUrl(NotificationBag::IMAGE_COLLECTION),
            ];
            if ($this->notificationBag->target) {
                $message['data'] = [
                    'target_type' => $this->notificationBag->target->type->value,
                    'target_slug' => $this->notificationBag->target->slug->value,
                ];
            }
            foreach ($recipients as $users) {
                $tokens = $users->pluck('fcm_token')->toArray();
                $fcmService->sendMessage($tokens, $message);
            }

            $this->notificationBag->update([
                'status' => NotificationBagStatus::SENT,
                'error' => null,
            ]);

        } catch (\Exception $e) {
            $this->notificationBag->update([
                'status' => NotificationBagStatus::FAILED,
                'error' => $e->getMessage(),
            ]);

            Log::error('Failed to send notification', [
                'notification_id' => $this->notificationBag->id,
                'error' => $e->getMessage(),
            ]);
        }
    }

    private function getRecipients(): LazyCollection
    {
        $query = User::query()
            ->whereNotNull('fcm_token');
        if ($this->notificationBag->recipient_type->eq(NotificationBagRecipientType::PERSONAL)) {
            $query->where('id', $this->notificationBag->user_id);
        }

        return $query->lazy()->chunk(2000);
    }
}

Для создания сервиса отправки пуш уведомлений (FCMService) необходимо подключить и настроить библиотеку kreait/firebase-php в соответствии с ее документацией.

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

class FCMService
{

    public function __construct(
        protected Messaging $messaging
    ) {
    }

    public function sendMessage(array $tokens, array $messageData): void
    {
        $notification = Notification::create(
            $messageData['title'],
            $messageData['body'],
            $messageData['image'] ?? null,
        );

        $message = CloudMessage::new()
            ->withNotification($notification);

        if (isset($messageData['data'])) {
            $message = $message->withData($messageData['data']);
        }

        $messages = [];
        foreach ($tokens as $registrationToken) {
            $messages[] = $message->toToken($registrationToken);
        }

        $this->messaging->sendAll($messages);
    }
}

Последнее, что стоит сделать, это добавить роуты в файл роутинга admin.php:

Route::prefix('notifications/notification-bag')->group(function () {
    Route::get('', [Controllers\NotificationBagController::class, 'index']);
    Route::get('filters', [Controllers\NotificationBagController::class, 'filters']);
    Route::get('create', [Controllers\NotificationBagController::class, 'createForm']);
    Route::post('', [Controllers\NotificationBagController::class, 'create']);
    Route::get('{id}/update', [Controllers\NotificationBagController::class, 'show']);
    Route::delete('{id}', [Controllers\NotificationBagController::class, 'destroy']);
    Route::get('/ajax-select/{field}', [Controllers\NotificationBagController::class, 'ajaxSelect']);
});

Наш бэкенд готов, можно переходить к созданию страниц в админ панели.

Создание страниц в админ панели Admiral

После создания backend части, необходимо создать интерфейс для управления уведомлениями в админ панели.

Структура файлов админ панели

admin/
├── pages/notifications/notification-bag/
│ ├── index.tsx # Страница списка уведомлений
│ ├── create.tsx # Страница создания уведомления
│ └── [id].tsx # Страница просмотра/редактирования
└── src/crud/notifications/
└── notification-bag.tsx # CRUD компонент

Создание CRUD компонента

Создаем базовый CRUD-компонент, включающий формы и таблицу для управления уведомлениями. Этот компонент:

  • Поддерживает создание, просмотр и удаление уведомлений
  • Содержит зависимые поля: тип получателя с идентификатором пользователя, и тип цели с выбором экрана или всплывающего окна. 
  • При просмотре уведомления все поля формы доступны только для чтения
  • При просмотре отображается информация об ошибке отправки, если таковая имеется.
// admin/src/crud/notifications/notification-bag.tsx

import React, {useEffect} from 'react'
import { AjaxSelectInput, BackButton, Button, createCRUD, DeleteAction, FilePictureInput, Form, SelectInput, TextInput, useForm } from '@devfamily/admiral'
import api from '@/src/config/api'
import * as Icons from "react-icons/fi";
import {FileField, DateField} from "../components/Fields";

const resource = 'notifications/notification-bag'
const path = '/notifications/notification-bag'


const TargetInput = ({isUpdate = false, disabled}: { isUpdate?: boolean, disabled?: boolean }) => {
    const {values, setValues} = useForm()

    useEffect(() => {
        if (values.target_type === 'page') {
            setValues((prev: any) => {
                return {...prev, target_slug_popup: null}
            })
        } else if (values.target_type === 'popup') {
            setValues((prev: any) => {
                return {...prev, target_slug_page: null}
            })
        }
    }, [values.target_type]);

    return (
        <>
            <SelectInput disabled={disabled || isUpdate} name={'target_type'} label={'Target type'}
                         columnSpan={!values.target_type ? 2 : 1}/>
            {values.target_type === 'page' &&
                <SelectInput disabled={disabled || isUpdate} name={'target_slug_page'} label={'Target Page'}/>}
            {values.target_type === 'popup' &&
                <SelectInput disabled={disabled || isUpdate} name={'target_slug_popup'} label={'Target Popup'}/>}
        </>
    )
}

const CreateFields = () => {
    const {values} = useForm()

    return (
        <>
            <FilePictureInput maxCount={1} label='Image' name='image' columnSpan={2}
                              accept='image/*'/>
            <TextInput label='Title' name='title' required columnSpan={2}/>
            <TextInput label='Body' name='body' columnSpan={2}/>
            <SelectInput name='recipient_type' label='Recipients' columnSpan={2}/>
            <TargetInput/>
            {values.recipient_type === 'personal' &&
                <AjaxSelectInput

                    fetchOptions={(field, query) => api.getAjaxSelectOptions(resource, field, query)}
                    label='User' name='user_id' columnSpan={2}/>
            }
        </>
    )
}

const UpdateFields = () => {
    const {values} = useForm()
    return (
        <>
            <FilePictureInput disabled maxCount={1} label='Image' name='image' columnSpan={2}
                              accept='image/*'/>
            <TextInput disabled label='Title' name='title' required columnSpan={2}/>
            <TextInput disabled label='Body' name='body' columnSpan={2}/>
            <SelectInput disabled name='recipient_type' label='Recipients' columnSpan={2}/>
            <TargetInput isUpdate={true}/>
            {values.recipient_type === 'personal' &&
                <AjaxSelectInput disabled
                                 fetchOptions={(field, query) => api.getAjaxSelectOptions(resource, field, query)}
                                 label='User' name='user_id' columnSpan={2}/>
            }

            {values.error &&
                <div style={{color: "red", fontWeight: "bold", gridColumn: "1 / -1"}}>
                    <h2>Error</h2>
                    <div>{values.error}</div>
                </div>
            }

            <Form.Footer>
                <BackButton basePath={resource}>Back</BackButton>
            </Form.Footer>
        </>
    )
}

export const CRUD = createCRUD({
    path: path,
    resource: resource,
    index: {
        title: 'Notifications',
        newButtonText: 'Add',
        tableActions: {
            title: 'Actions',
            key: 'actions',
            fixed: 'right',
            width: 140,
            render: (_value, record: any) => {
                return (
                    <div style={{display: 'flex'}}>
                        <Button
                            type="button"
                            view="clear"
                            size="S"
                            onClick={() => window.location.href = `${path}/${record.id}`}
                            iconLeft={<Icons.FiEye/>}
                        />
                        <DeleteAction resource={`${resource}`} id={record.id}/>
                    </div>
                )
            },
        },
        tableColumns: [
            {
                sorter: true,
                title: 'ID',
                dataIndex: 'id',
                key: 'id',
                width: 90,
            },
            {
                sorter: false,
                title: 'Image',
                dataIndex: 'image',
                key: 'image',
                width: 150,
                render: (value) => <FileField file={value} />
            },
            {
                sorter: false,
                title: 'Status',
                dataIndex: 'status',
                key: 'status',
                width: 150,
            },
            {
                sorter: false,
                title: 'Recipients',
                dataIndex: 'recipient_type',
                key: 'recipient_type',
            },
            {
                sorter: false,
                title: 'Target',
                dataIndex: 'target_enum',
                key: 'target_enum',
            },
            {
                sorter: false,
                title: 'Title',
                dataIndex: 'title',
                key: 'title',
            },
            {
                sorter: false,
                title: 'Body',
                dataIndex: 'body',
                key: 'body',
            },
            {
                sorter: true,
                title: 'Created at',
                dataIndex: 'created_at',
                key: 'created_at',
                render: (value, record) => <DateField value={value} />,
            },
        ],
    },
    form: {
        create: {
            fields: <CreateFields/>,
        },
        edit: {
            children: <UpdateFields/>,
        },
    },
    create: {
        title: 'Send new notification'
    },
    filter: {
        topToolbarButtonText: 'Filter',
        fields: (
            <>
                <TextInput label='Title' name='title'/>
                <SelectInput label='Status' name='status'/>
                <SelectInput label='Recipient Type' name='recipient_type'/>
            </>
        ),
    },
    update: {
        title: (id: string) => `Notification #${id}`,
    },
})

Создание страниц

1. Страница списка (Index)

// admin/pages/notifications/notification-bag/index.tsx

import {CRUD} from '@/src/crud/notifications/notification-bag'

export default CRUD.IndexPage

2. Страница создания

// admin/pages/notifications/notification-bag/create.tsx

import {CRUD} from '@/src/crud/notifications/notification-bag'

export default CRUD.CreatePage

3. Страница просмотра/редактирования

// admin/pages/notifications/notification-bag/[id].tsx

import {CRUD} from '@/src/crud/notifications/notification-bag'

export default CRUD.UpdatePage

Настройка навигации

Добавляем пункт меню в навигацию админ панели:

// admin/src/config/menu.tsx

const CustomMenu = () => {
    return <Menu>
/// ... Предыдущие пункты меню
        <MenuItemLink icon={'FiSend'} name='Notifications' to='/notifications/notification-bag' />
    </Menu>
}

export default CustomMenu

Итоговая страница уведомлений выглядит так:

case image

Итоговая отправка уведомлений будет выглядеть следующим образом:

Еще раз напомним, что все материалы этой статьи можно найти в репозитории по ссылке
Читайте также