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 to data payload
  221. $data['notification_id'] = $docId;
  222. $data['user_id'] = $userId;
  223. $message = CloudMessage::withTarget('token', $token)
  224. ->withNotification($notification)
  225. ->withData($data);
  226. // Configure Android and iOS specific settings
  227. $androidConfig = AndroidConfig::fromArray([
  228. 'priority' => 'high',
  229. 'notification' => [
  230. 'sound' => 'default',
  231. 'channel_id' => 'ticket_notifications',
  232. ],
  233. ]);
  234. $message = $message->withAndroidConfig($androidConfig);
  235. $apnsConfig = ApnsConfig::fromArray([
  236. 'headers' => [
  237. 'apns-priority' => '10',
  238. ],
  239. 'payload' => [
  240. 'aps' => [
  241. 'sound' => 'default',
  242. 'badge' => 1,
  243. ],
  244. ],
  245. ]);
  246. $message = $message->withApnsConfig($apnsConfig);
  247. $this->messaging->send($message);
  248. $this->logger->info('Push notification sent successfully', [
  249. 'email' => $email,
  250. 'user_id' => $userId,
  251. 'notification_id' => $docId,
  252. 'token_preview' => substr($token, 0, 20) . '...',
  253. ]);
  254. return true;
  255. } catch (MessagingException $e) {
  256. $this->logger->error('Failed to send push notification', [
  257. 'email' => $email,
  258. 'user_id' => $userId,
  259. 'notification_id' => $docId,
  260. 'token_preview' => substr($token, 0, 20) . '...',
  261. 'error' => $e->getMessage(),
  262. ]);
  263. // If token is invalid, remove it from Firestore
  264. if (strpos($e->getMessage(), 'invalid') !== false ||
  265. strpos($e->getMessage(), 'not found') !== false ||
  266. strpos($e->getMessage(), 'NotFound') !== false) {
  267. $this->removeInvalidToken($email, $token);
  268. }
  269. // Notification was saved, so return true even if push failed
  270. return true;
  271. } catch (\Exception $e) {
  272. $this->logger->error('Unexpected error sending push notification', [
  273. 'email' => $email,
  274. 'user_id' => $userId,
  275. 'notification_id' => $docId,
  276. 'error' => $e->getMessage(),
  277. ]);
  278. // Notification was saved, so return true
  279. return true;
  280. }
  281. }
  282. /**
  283. * Remove invalid FCM token from Firestore
  284. * Query by email and clear the fcm_token field
  285. *
  286. * @param string $email User email
  287. * @param string $token Invalid token to remove
  288. */
  289. private function removeInvalidToken(string $email, string $token): void
  290. {
  291. if (!$this->firestore) {
  292. return;
  293. }
  294. try {
  295. $database = $this->firestore->database();
  296. $usersRef = $database->collection('users');
  297. // Query: users.where('email', '==', email)
  298. $query = $usersRef->where('email', '==', $email);
  299. $documents = $query->documents();
  300. foreach ($documents as $document) {
  301. if ($document->exists()) {
  302. $data = $document->data();
  303. // If fcm_token matches, clear it
  304. if (isset($data['fcm_token']) && $data['fcm_token'] === $token) {
  305. $document->reference()->update([
  306. 'fcm_token' => null
  307. ]);
  308. $this->logger->info('Removed invalid FCM token from Firestore', [
  309. 'email' => $email,
  310. 'user_id' => $document->id(),
  311. ]);
  312. }
  313. }
  314. }
  315. } catch (\Exception $e) {
  316. $this->logger->error('Failed to remove invalid token', [
  317. 'email' => $email,
  318. 'error' => $e->getMessage(),
  319. ]);
  320. }
  321. }
  322. }