src/Service/FirebaseNotificationService.php line 202

Open in your IDE?
  1. <?php
  2. namespace App\Service;
  3. use Kreait\Firebase\Factory;
  4. use Kreait\Firebase\Messaging\CloudMessage;
  5. use Kreait\Firebase\Messaging\Notification;
  6. use Kreait\Firebase\Messaging\AndroidConfig;
  7. use Kreait\Firebase\Messaging\ApnsConfig;
  8. use Psr\Log\LoggerInterface;
  9. use Kreait\Firebase\Contract\Firestore;
  10. use Kreait\Firebase\Exception\FirebaseException;
  11. use Kreait\Firebase\Exception\MessagingException;
  12. use Google\Cloud\Core\Timestamp;
  13. /**
  14. * Service to send Firebase push notifications to users
  15. */
  16. class FirebaseNotificationService
  17. {
  18. private $firestore;
  19. private $messaging;
  20. private $logger;
  21. private $firebaseCredentialsPath;
  22. public function __construct(LoggerInterface $logger)
  23. {
  24. $this->logger = $logger;
  25. $this->firebaseCredentialsPath = $_ENV['FIREBASE_CREDENTIALS_PATH'] ?? null;
  26. if (empty($this->firebaseCredentialsPath)) {
  27. $this->logger->warning('Firebase credentials path not configured. Set FIREBASE_CREDENTIALS_PATH in .env file.');
  28. return;
  29. }
  30. try {
  31. $factory = (new Factory)->withServiceAccount($this->firebaseCredentialsPath);
  32. $this->firestore = $factory->createFirestore();
  33. $this->messaging = $factory->createMessaging();
  34. } catch (\Exception $e) {
  35. $this->logger->error('Failed to initialize Firebase', [
  36. 'error' => $e->getMessage(),
  37. ]);
  38. }
  39. }
  40. /**
  41. * Get FCM token for a user by email
  42. * Matches your Firestore structure: users.where('email', '==', email)
  43. *
  44. * @param string $email User email address
  45. * @return string|null FCM token or null if not found
  46. */
  47. public function getFcmTokenByEmail(string $email): ?string
  48. {
  49. if (!$this->firestore) {
  50. $this->logger->warning('Firestore not initialized');
  51. return null;
  52. }
  53. try {
  54. $database = $this->firestore->database();
  55. $usersRef = $database->collection('users');
  56. // Query: users.where('email', '==', email)
  57. $query = $usersRef->where('email', '==', $email);
  58. $documents = $query->documents();
  59. // Get first result: users[0]
  60. foreach ($documents as $document) {
  61. if ($document->exists()) {
  62. $data = $document->data();
  63. // Extract: users[0]['fcm_token']
  64. if (isset($data['fcm_token']) && !empty($data['fcm_token'])) {
  65. $token = $data['fcm_token'];
  66. $this->logger->debug('FCM token found for email', [
  67. 'email' => $email,
  68. 'user_id' => $document->id(),
  69. ]);
  70. return $token;
  71. }
  72. }
  73. }
  74. $this->logger->warning('No FCM token found for email', ['email' => $email]);
  75. return null;
  76. } catch (\Exception $e) {
  77. $this->logger->error('Error fetching FCM token from Firestore', [
  78. 'email' => $email,
  79. 'error' => $e->getMessage(),
  80. ]);
  81. return null;
  82. }
  83. }
  84. /**
  85. * Get FCM tokens for a user by email (supports multiple tokens)
  86. *
  87. * @param string $email User email address
  88. * @return array Array of FCM tokens
  89. */
  90. public function getFcmTokensByEmail(string $email): array
  91. {
  92. $token = $this->getFcmTokenByEmail($email);
  93. return $token ? [$token] : [];
  94. }
  95. /**
  96. * Get user ID by email from Firestore
  97. *
  98. * @param string $email User email address
  99. * @return string|null User ID or null if not found
  100. */
  101. public function getUserIdByEmail(string $email): ?string
  102. {
  103. if (!$this->firestore) {
  104. return null;
  105. }
  106. try {
  107. $database = $this->firestore->database();
  108. $usersRef = $database->collection('users');
  109. $query = $usersRef->where('email', '==', $email);
  110. $documents = $query->documents();
  111. foreach ($documents as $document) {
  112. if ($document->exists()) {
  113. return $document->id();
  114. }
  115. }
  116. return null;
  117. } catch (\Exception $e) {
  118. $this->logger->error('Error fetching user ID from Firestore', [
  119. 'email' => $email,
  120. 'error' => $e->getMessage(),
  121. ]);
  122. return null;
  123. }
  124. }
  125. /**
  126. * Write notification data to Firestore
  127. * Creates: notifications/{auto-id} with all notification fields
  128. *
  129. * @param array $notificationData Notification data
  130. * @return string|null Document ID or null on failure
  131. */
  132. public function writeNotificationData(array $notificationData): ?string
  133. {
  134. if (!$this->firestore) {
  135. $this->logger->warning('Firestore not initialized');
  136. return null;
  137. }
  138. try {
  139. $database = $this->firestore->database();
  140. $notificationsRef = $database->collection('notifications');
  141. // Convert DateTime to Firestore Timestamp
  142. if (isset($notificationData['sent_at']) && $notificationData['sent_at'] instanceof \DateTime) {
  143. $notificationData['sent_at'] = new Timestamp($notificationData['sent_at']);
  144. }
  145. // Auto-generate document ID and create document
  146. $docRef = $notificationsRef->newDocument();
  147. $docRef->set($notificationData);
  148. $docId = $docRef->id();
  149. $this->logger->info('Notification saved to Firestore', [
  150. 'document_id' => $docId,
  151. 'user_id' => $notificationData['user_id'] ?? null,
  152. ]);
  153. return $docId;
  154. } catch (\Exception $e) {
  155. $this->logger->error('Error writing notification to Firestore', [
  156. 'error' => $e->getMessage(),
  157. 'data' => $notificationData,
  158. ]);
  159. return null;
  160. }
  161. }
  162. /**
  163. * Send push notification to user by email
  164. * Step 1: Save notification to Firestore
  165. * Step 2: Send FCM push notification
  166. *
  167. * @param string $email User email address
  168. * @param string $title Notification title
  169. * @param string $description Plain text description
  170. * @param string $preview Quill Delta JSON string (optional)
  171. * @param string $topic Notification topic (default: "user")
  172. * @param array $data Additional data payload for FCM
  173. * @return bool Success status
  174. */
  175. public function sendNotificationByEmail(
  176. string $email,
  177. string $title,
  178. string $description,
  179. string $preview = null,
  180. string $topic = 'user',
  181. array $data = []
  182. ): bool {
  183. // Step 1: Get user ID by email
  184. $userId = $this->getUserIdByEmail($email);
  185. if (empty($userId)) {
  186. $this->logger->warning('User ID not found for email', ['email' => $email]);
  187. return false;
  188. }
  189. // Step 2: Get FCM token
  190. $token = $this->getFcmTokenByEmail($email);
  191. if (empty($token)) {
  192. $this->logger->warning('No FCM token found for email', ['email' => $email]);
  193. // Still save notification even if no token (for in-app notifications)
  194. }
  195. // Step 3: Prepare notification data for Firestore
  196. $notificationData = [
  197. 'user_id' => $userId,
  198. 'topic' => $topic,
  199. 'title' => $title,
  200. 'preview' => $preview ?? json_encode([['insert' => $description . "\n"]]), // Quill Delta JSON format
  201. 'description' => $description,
  202. 'sent_at' => new \DateTime(), // Will be converted to Firestore Timestamp
  203. ];
  204. // Step 4: Write notification to Firestore (notifications/{auto-id})
  205. $docId = $this->writeNotificationData($notificationData);
  206. if (!$docId) {
  207. $this->logger->error('Failed to save notification to Firestore', ['email' => $email]);
  208. return false;
  209. }
  210. // Step 5: Send FCM push notification (if token exists)
  211. if (empty($token)) {
  212. $this->logger->info('Notification saved but no FCM token, skipping push', [
  213. 'email' => $email,
  214. 'notification_id' => $docId,
  215. ]);
  216. return true; // Notification saved successfully
  217. }
  218. try {
  219. $notification = Notification::create($title, $description);
  220. // Add notification ID and content to data payload
  221. $data['notification_id'] = $docId;
  222. $data['user_id'] = $userId;
  223. $data['title'] = $title;
  224. $data['description'] = $description;
  225. $data['preview'] = $notificationData['preview']; // Quill Delta JSON format
  226. $data['topic'] = $topic;
  227. $message = CloudMessage::withTarget('token', $token)
  228. ->withNotification($notification)
  229. ->withData($data);
  230. // Configure Android and iOS specific settings
  231. $androidConfig = AndroidConfig::fromArray([
  232. 'priority' => 'high',
  233. 'notification' => [
  234. 'sound' => 'default',
  235. 'channel_id' => 'ticket_notifications',
  236. ],
  237. ]);
  238. $message = $message->withAndroidConfig($androidConfig);
  239. $apnsConfig = ApnsConfig::fromArray([
  240. 'headers' => [
  241. 'apns-priority' => '10',
  242. ],
  243. 'payload' => [
  244. 'aps' => [
  245. 'sound' => 'default',
  246. 'badge' => 1,
  247. ],
  248. ],
  249. ]);
  250. $message = $message->withApnsConfig($apnsConfig);
  251. $this->messaging->send($message);
  252. $this->logger->info('Push notification sent successfully', [
  253. 'email' => $email,
  254. 'user_id' => $userId,
  255. 'notification_id' => $docId,
  256. 'token_preview' => substr($token, 0, 20) . '...',
  257. ]);
  258. return true;
  259. } catch (MessagingException $e) {
  260. $this->logger->error('Failed to send push notification', [
  261. 'email' => $email,
  262. 'user_id' => $userId,
  263. 'notification_id' => $docId,
  264. 'token_preview' => substr($token, 0, 20) . '...',
  265. 'error' => $e->getMessage(),
  266. ]);
  267. // If token is invalid, remove it from Firestore
  268. if (strpos($e->getMessage(), 'invalid') !== false ||
  269. strpos($e->getMessage(), 'not found') !== false ||
  270. strpos($e->getMessage(), 'NotFound') !== false) {
  271. $this->removeInvalidToken($email, $token);
  272. }
  273. // Notification was saved, so return true even if push failed
  274. return true;
  275. } catch (\Exception $e) {
  276. $this->logger->error('Unexpected error sending push notification', [
  277. 'email' => $email,
  278. 'user_id' => $userId,
  279. 'notification_id' => $docId,
  280. 'error' => $e->getMessage(),
  281. ]);
  282. // Notification was saved, so return true
  283. return true;
  284. }
  285. }
  286. /**
  287. * Remove invalid FCM token from Firestore
  288. * Query by email and clear the fcm_token field
  289. *
  290. * @param string $email User email
  291. * @param string $token Invalid token to remove
  292. */
  293. private function removeInvalidToken(string $email, string $token): void
  294. {
  295. if (!$this->firestore) {
  296. return;
  297. }
  298. try {
  299. $database = $this->firestore->database();
  300. $usersRef = $database->collection('users');
  301. // Query: users.where('email', '==', email)
  302. $query = $usersRef->where('email', '==', $email);
  303. $documents = $query->documents();
  304. foreach ($documents as $document) {
  305. if ($document->exists()) {
  306. $data = $document->data();
  307. // If fcm_token matches, clear it
  308. if (isset($data['fcm_token']) && $data['fcm_token'] === $token) {
  309. $document->reference()->update([
  310. 'fcm_token' => null
  311. ]);
  312. $this->logger->info('Removed invalid FCM token from Firestore', [
  313. 'email' => $email,
  314. 'user_id' => $document->id(),
  315. ]);
  316. }
  317. }
  318. }
  319. } catch (\Exception $e) {
  320. $this->logger->error('Failed to remove invalid token', [
  321. 'email' => $email,
  322. 'error' => $e->getMessage(),
  323. ]);
  324. }
  325. }
  326. }