Реалізація системи сповіщень: Email, SMS, Push, In-App
Система сповіщень інформує користувачів про события через кілька каналів. Користувач обирає, які сповіщення та по яким каналам отримувати. Ключові завдання: централізоване сховище налаштувань, черга відправки, дедубліація.
Архітектура
[Event: OrderShipped]
↓
[NotificationService]
├── Перевірити налаштування користувача
├── Email: у чергу → SendGrid/Mailgun
├── SMS: у чергу → SMSC/Twilio
├── Push: у чергу → Firebase FCM
└── In-App: зберегти в БД → WebSocket push
Структура бази даних
-- Налаштування сповіщень користувача
CREATE TABLE notification_preferences (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notification_type VARCHAR(100) NOT NULL, -- 'order.shipped', 'comment.reply', etc.
email_enabled BOOLEAN NOT NULL DEFAULT true,
sms_enabled BOOLEAN NOT NULL DEFAULT false,
push_enabled BOOLEAN NOT NULL DEFAULT true,
inapp_enabled BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (user_id, notification_type)
);
-- In-App сповіщення
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(100) NOT NULL,
title VARCHAR(255),
body TEXT,
data JSONB NOT NULL DEFAULT '{}',
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON notifications(user_id, read_at, created_at DESC);
-- Push-токени
CREATE TABLE push_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
platform VARCHAR(20) NOT NULL, -- 'web', 'ios', 'android'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Laravel: базова реалізація
class NotificationService
{
public function notify(User $user, string $type, array $payload): void
{
$prefs = NotificationPreference::where('user_id', $user->id)
->where('notification_type', $type)
->first();
// Налаштування за замовчуванням
$defaults = [
'email_enabled' => true,
'sms_enabled' => false,
'push_enabled' => true,
'inapp_enabled' => true,
];
$channels = array_merge($defaults, $prefs?->toArray() ?? []);
// In-App: синхронно (зберегти + WebSocket)
if ($channels['inapp_enabled']) {
$notification = Notification::create([
'user_id' => $user->id,
'type' => $type,
'title' => $payload['title'] ?? null,
'body' => $payload['body'] ?? null,
'data' => $payload['data'] ?? [],
]);
broadcast(new NewNotificationEvent($user, $notification))->toOthers();
}
// Email: у чергу
if ($channels['email_enabled'] && isset($payload['email'])) {
SendEmailNotificationJob::dispatch($user, $type, $payload['email'])->onQueue('notifications');
}
// SMS: у чергу
if ($channels['sms_enabled'] && $user->phone && isset($payload['sms'])) {
SendSmsNotificationJob::dispatch($user, $payload['sms'])->onQueue('notifications');
}
// Push: у чергу
if ($channels['push_enabled'] && isset($payload['push'])) {
SendPushNotificationJob::dispatch($user, $payload['push'])->onQueue('notifications');
}
}
}
// Приклад використання
app(NotificationService::class)->notify($user, 'order.shipped', [
'title' => 'Ваше замовлення відправлено',
'body' => "Замовлення #{$order->number} передане в службу доставки",
'data' => ['order_id' => $order->id, 'url' => route('orders.show', $order)],
'email' => ['order' => $order], // дані для шаблону email
'sms' => "Замовлення #{$order->number} відправлено. Трек: {$order->tracking_number}",
'push' => ['title' => 'Замовлення відправлено', 'body' => "Замовлення #{$order->number}"],
]);
Email: SendGrid
class SendEmailNotificationJob implements ShouldQueue
{
public int $tries = 3;
public int $backoff = 60;
public function __construct(
private User $user,
private string $type,
private array $emailData,
) {}
public function handle(): void
{
$mailable = $this->resolveMailable($this->type, $this->emailData);
Mail::to($this->user->email)->send($mailable);
}
private function resolveMailable(string $type, array $data): Mailable
{
return match ($type) {
'order.shipped' => new OrderShippedMail($data['order']),
'comment.reply' => new CommentReplyMail($data['comment']),
'password.reset' => new PasswordResetMail($data['token']),
default => new GenericNotificationMail($type, $data),
};
}
}
SMS: SMSC.ru (Росія)
class SendSmsNotificationJob implements ShouldQueue
{
public function __construct(private User $user, private string $text) {}
public function handle(): void
{
Http::get('https://smsc.ru/sys/send.php', [
'login' => config('services.smsc.login'),
'psw' => config('services.smsc.password'),
'phones' => $this->user->phone,
'mes' => $this->text,
'charset' => 'utf-8',
'fmt' => 3, // JSON
])->throw();
}
}
Push: Firebase Cloud Messaging
class SendPushNotificationJob implements ShouldQueue
{
public function __construct(private User $user, private array $pushData) {}
public function handle(): void
{
$tokens = PushToken::where('user_id', $this->user->id)->pluck('token')->toArray();
if (empty($tokens)) return;
$response = Http::withToken(config('services.firebase.server_key'))
->post('https://fcm.googleapis.com/fcm/send', [
'registration_ids' => $tokens,
'notification' => [
'title' => $this->pushData['title'],
'body' => $this->pushData['body'],
'icon' => '/icon-192.png',
'click_action' => $this->pushData['url'] ?? '/',
],
'data' => $this->pushData['data'] ?? [],
]);
// Видалити невалідні токени
$results = $response->json('results', []);
foreach ($results as $index => $result) {
if (isset($result['error']) && in_array($result['error'], ['InvalidRegistration', 'NotRegistered'])) {
PushToken::where('token', $tokens[$index])->delete();
}
}
}
}
Web Push: підписка Service Worker
// Підписатися на Web Push
async function subscribeToPush(): Promise<void> {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(import.meta.env.VITE_VAPID_PUBLIC_KEY),
});
await api.post('/api/push-tokens', {
token: JSON.stringify(subscription),
platform: 'web',
});
}
React: центр сповіщень
function NotificationCenter() {
const { data, refetch } = useQuery({ queryKey: ['notifications'], queryFn: fetchNotifications });
const unread = data?.filter(n => !n.read_at).length ?? 0;
// Оновлення в реальному часі через WebSocket
useEffect(() => {
const echo = window.Echo.private(`notifications.${currentUser.id}`)
.listen('NewNotificationEvent', () => refetch());
return () => echo.stopListening('NewNotificationEvent');
}, []);
return (
<div className="notification-center">
<button className="bell" aria-label={`Сповіщення: ${unread} непрочитаних`}>
<BellIcon />
{unread > 0 && <span className="badge">{unread > 99 ? '99+' : unread}</span>}
</button>
<ul className="notification-list">
{data?.map(notification => (
<li key={notification.id} className={notification.read_at ? '' : 'unread'}>
<span>{notification.title}</span>
<time>{timeAgo(notification.created_at)}</time>
</li>
))}
</ul>
</div>
);
}
Строк реалізації
| Завдання | Строк |
|---|---|
| In-App сповіщення + WebSocket | 2–3 дні |
| Email канал з шаблонами | +1–2 дні |
| SMS через SMSC/Twilio | +1 день |
| Firebase Push сповіщення | +1–2 дні |
| Налаштування сповіщень користувачем | +1–2 дні |
| Повна система всі канали + UI | 7–10 днів |







