<?php
namespace App\Service;
use Kreait\Firebase\Factory;
use Kreait\Firebase\Messaging\CloudMessage;
use Kreait\Firebase\Messaging\Notification;
use Kreait\Firebase\Messaging\AndroidConfig;
use Kreait\Firebase\Messaging\ApnsConfig;
use Psr\Log\LoggerInterface;
use Kreait\Firebase\Contract\Firestore;
use Kreait\Firebase\Exception\FirebaseException;
use Kreait\Firebase\Exception\MessagingException;
use Google\Cloud\Core\Timestamp;
/**
* Service to send Firebase push notifications to users
*/
class FirebaseNotificationService
{
private $firestore;
private $messaging;
private $logger;
private $firebaseCredentialsPath;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
$this->firebaseCredentialsPath = $_ENV['FIREBASE_CREDENTIALS_PATH'] ?? null;
if (empty($this->firebaseCredentialsPath)) {
$this->logger->warning('Firebase credentials path not configured. Set FIREBASE_CREDENTIALS_PATH in .env file.');
return;
}
try {
$factory = (new Factory)->withServiceAccount($this->firebaseCredentialsPath);
$this->firestore = $factory->createFirestore();
$this->messaging = $factory->createMessaging();
} catch (\Exception $e) {
$this->logger->error('Failed to initialize Firebase', [
'error' => $e->getMessage(),
]);
}
}
/**
* Get FCM token for a user by email
* Matches your Firestore structure: users.where('email', '==', email)
*
* @param string $email User email address
* @return string|null FCM token or null if not found
*/
public function getFcmTokenByEmail(string $email): ?string
{
if (!$this->firestore) {
$this->logger->warning('Firestore not initialized');
return null;
}
try {
$database = $this->firestore->database();
$usersRef = $database->collection('users');
// Query: users.where('email', '==', email)
$query = $usersRef->where('email', '==', $email);
$documents = $query->documents();
// Get first result: users[0]
foreach ($documents as $document) {
if ($document->exists()) {
$data = $document->data();
// Extract: users[0]['fcm_token']
if (isset($data['fcm_token']) && !empty($data['fcm_token'])) {
$token = $data['fcm_token'];
$this->logger->debug('FCM token found for email', [
'email' => $email,
'user_id' => $document->id(),
]);
return $token;
}
}
}
$this->logger->warning('No FCM token found for email', ['email' => $email]);
return null;
} catch (\Exception $e) {
$this->logger->error('Error fetching FCM token from Firestore', [
'email' => $email,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Get FCM tokens for a user by email (supports multiple tokens)
*
* @param string $email User email address
* @return array Array of FCM tokens
*/
public function getFcmTokensByEmail(string $email): array
{
$token = $this->getFcmTokenByEmail($email);
return $token ? [$token] : [];
}
/**
* Get user ID by email from Firestore
*
* @param string $email User email address
* @return string|null User ID or null if not found
*/
public function getUserIdByEmail(string $email): ?string
{
if (!$this->firestore) {
return null;
}
try {
$database = $this->firestore->database();
$usersRef = $database->collection('users');
$query = $usersRef->where('email', '==', $email);
$documents = $query->documents();
foreach ($documents as $document) {
if ($document->exists()) {
return $document->id();
}
}
return null;
} catch (\Exception $e) {
$this->logger->error('Error fetching user ID from Firestore', [
'email' => $email,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Write notification data to Firestore
* Creates: notifications/{auto-id} with all notification fields
*
* @param array $notificationData Notification data
* @return string|null Document ID or null on failure
*/
public function writeNotificationData(array $notificationData): ?string
{
if (!$this->firestore) {
$this->logger->warning('Firestore not initialized');
return null;
}
try {
$database = $this->firestore->database();
$notificationsRef = $database->collection('notifications');
// Convert DateTime to Firestore Timestamp
if (isset($notificationData['sent_at']) && $notificationData['sent_at'] instanceof \DateTime) {
$notificationData['sent_at'] = new Timestamp($notificationData['sent_at']);
}
// Auto-generate document ID and create document
$docRef = $notificationsRef->newDocument();
$docRef->set($notificationData);
$docId = $docRef->id();
$this->logger->info('Notification saved to Firestore', [
'document_id' => $docId,
'user_id' => $notificationData['user_id'] ?? null,
]);
return $docId;
} catch (\Exception $e) {
$this->logger->error('Error writing notification to Firestore', [
'error' => $e->getMessage(),
'data' => $notificationData,
]);
return null;
}
}
/**
* Send push notification to user by email
* Step 1: Save notification to Firestore
* Step 2: Send FCM push notification
*
* @param string $email User email address
* @param string $title Notification title
* @param string $description Plain text description
* @param string $preview Quill Delta JSON string (optional)
* @param string $topic Notification topic (default: "user")
* @param array $data Additional data payload for FCM
* @return bool Success status
*/
public function sendNotificationByEmail(
string $email,
string $title,
string $description,
string $preview = null,
string $topic = 'user',
array $data = []
): bool {
// Step 1: Get user ID by email
$userId = $this->getUserIdByEmail($email);
if (empty($userId)) {
$this->logger->warning('User ID not found for email', ['email' => $email]);
return false;
}
// Step 2: Get FCM token
$token = $this->getFcmTokenByEmail($email);
if (empty($token)) {
$this->logger->warning('No FCM token found for email', ['email' => $email]);
// Still save notification even if no token (for in-app notifications)
}
// Step 3: Prepare notification data for Firestore
$notificationData = [
'user_id' => $userId,
'topic' => $topic,
'title' => $title,
'preview' => $preview ?? json_encode([['insert' => $description . "\n"]]), // Quill Delta JSON format
'description' => $description,
'sent_at' => new \DateTime(), // Will be converted to Firestore Timestamp
];
// Step 4: Write notification to Firestore (notifications/{auto-id})
$docId = $this->writeNotificationData($notificationData);
if (!$docId) {
$this->logger->error('Failed to save notification to Firestore', ['email' => $email]);
return false;
}
// Step 5: Send FCM push notification (if token exists)
if (empty($token)) {
$this->logger->info('Notification saved but no FCM token, skipping push', [
'email' => $email,
'notification_id' => $docId,
]);
return true; // Notification saved successfully
}
try {
$notification = Notification::create($title, $description);
// Add notification ID to data payload
$data['notification_id'] = $docId;
$data['user_id'] = $userId;
$message = CloudMessage::withTarget('token', $token)
->withNotification($notification)
->withData($data);
// Configure Android and iOS specific settings
$androidConfig = AndroidConfig::fromArray([
'priority' => 'high',
'notification' => [
'sound' => 'default',
'channel_id' => 'ticket_notifications',
],
]);
$message = $message->withAndroidConfig($androidConfig);
$apnsConfig = ApnsConfig::fromArray([
'headers' => [
'apns-priority' => '10',
],
'payload' => [
'aps' => [
'sound' => 'default',
'badge' => 1,
],
],
]);
$message = $message->withApnsConfig($apnsConfig);
$this->messaging->send($message);
$this->logger->info('Push notification sent successfully', [
'email' => $email,
'user_id' => $userId,
'notification_id' => $docId,
'token_preview' => substr($token, 0, 20) . '...',
]);
return true;
} catch (MessagingException $e) {
$this->logger->error('Failed to send push notification', [
'email' => $email,
'user_id' => $userId,
'notification_id' => $docId,
'token_preview' => substr($token, 0, 20) . '...',
'error' => $e->getMessage(),
]);
// If token is invalid, remove it from Firestore
if (strpos($e->getMessage(), 'invalid') !== false ||
strpos($e->getMessage(), 'not found') !== false ||
strpos($e->getMessage(), 'NotFound') !== false) {
$this->removeInvalidToken($email, $token);
}
// Notification was saved, so return true even if push failed
return true;
} catch (\Exception $e) {
$this->logger->error('Unexpected error sending push notification', [
'email' => $email,
'user_id' => $userId,
'notification_id' => $docId,
'error' => $e->getMessage(),
]);
// Notification was saved, so return true
return true;
}
}
/**
* Remove invalid FCM token from Firestore
* Query by email and clear the fcm_token field
*
* @param string $email User email
* @param string $token Invalid token to remove
*/
private function removeInvalidToken(string $email, string $token): void
{
if (!$this->firestore) {
return;
}
try {
$database = $this->firestore->database();
$usersRef = $database->collection('users');
// Query: users.where('email', '==', email)
$query = $usersRef->where('email', '==', $email);
$documents = $query->documents();
foreach ($documents as $document) {
if ($document->exists()) {
$data = $document->data();
// If fcm_token matches, clear it
if (isset($data['fcm_token']) && $data['fcm_token'] === $token) {
$document->reference()->update([
'fcm_token' => null
]);
$this->logger->info('Removed invalid FCM token from Firestore', [
'email' => $email,
'user_id' => $document->id(),
]);
}
}
}
} catch (\Exception $e) {
$this->logger->error('Failed to remove invalid token', [
'email' => $email,
'error' => $e->getMessage(),
]);
}
}
}