From 83b780f66a70df180d4e608b872a093262fbc4a8 Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 13 Jun 2026 13:11:12 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20FCM=20push=20not=20sending=20=E2=80=94?= =?UTF-8?q?=20add=20v1=20API=20+=20better=20diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FCMService now supports both HTTP v1 API (service account) and legacy API (server key) - v1 API uses OAuth2 JWT auth with Firebase service account; tries this first - Falls back to legacy HTTP API if only server key is configured - Removes invalid tokens from DB automatically (NotRegistered, INVALID_ARGUMENT, etc.) - AdminController now logs push result status and shows in UI response - .env updated with FCM_SERVER_KEY placeholder and docs - Migration 010 run in dev --- src/Controllers/AdminController.php | 15 +- src/Services/FCMService.php | 229 ++++++++++++++++++++++++---- 2 files changed, 214 insertions(+), 30 deletions(-) diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php index ecb8975..f86965b 100755 --- a/src/Controllers/AdminController.php +++ b/src/Controllers/AdminController.php @@ -289,14 +289,21 @@ class AdminController ]); $fcm = new FCMService(); + $pushResult = ''; if ($userId) { - $fcm->sendToUser($userId, $title, $message, $type, $noteId); + $sent = $fcm->sendToUser($userId, $title, $message, $type, $noteId); + $pushResult = $sent ? ' (push sent)' : ($fcm->isConfigured() ? ' (push failed)' : ''); } else { - $fcm->sendToAll($title, $message, $type, $noteId); + $count = $fcm->sendToAll($title, $message, $type, $noteId); + $pushResult = $fcm->isConfigured() ? " (push sent to {$count} devices)" : ''; } - $this->auditService->log('notification_sent', 'notification', null, ['title' => $title, 'target' => $userId ? "user #{$userId}" : 'all users']); + $this->auditService->log('notification_sent', 'notification', null, [ + 'title' => $title, + 'target' => $userId ? "user #{$userId}" : 'all users', + 'push' => $fcm->isConfigured() ? 'sent' : 'not_configured', + ]); - $this->view->json(['success' => true, 'message' => 'Notification sent successfully.']); + $this->view->json(['success' => true, 'message' => 'Notification sent successfully.' . $pushResult]); } } diff --git a/src/Services/FCMService.php b/src/Services/FCMService.php index 4d15c9b..bb3a60b 100644 --- a/src/Services/FCMService.php +++ b/src/Services/FCMService.php @@ -12,66 +12,172 @@ class FCMService { private Database $db; private Logger $logger; - private string $serverKey; + private string $mode = 'none'; + private string $serverKey = ''; + private array $serviceAccount = []; + private string $accessToken = ''; + private int $tokenExpiry = 0; public function __construct() { $this->db = Database::getInstance(); $this->logger = Logger::getInstance(); - $config = App::getConfig(); + $keyFile = dirname(__DIR__, 2) . '/config/firebase-service-account.json'; + if (file_exists($keyFile)) { + $account = json_decode(file_get_contents($keyFile), true); + if ($account && !empty($account['client_email']) && !empty($account['private_key'])) { + $this->serviceAccount = $account; + $this->mode = 'v1'; + $this->logger->info('FCM mode: HTTP v1 (service account)'); + return; + } + } + + $config = App::getConfig(); $this->serverKey = $config['fcm']['server_key'] ?? $_ENV['FCM_SERVER_KEY'] ?? ''; + + if (!empty($this->serverKey)) { + $this->mode = 'legacy'; + $this->logger->info('FCM mode: legacy HTTP API (server key)'); + } else { + $this->logger->warning('FCM not configured — no service account or server key'); + } } public function isConfigured(): bool { - return !empty($this->serverKey); + return $this->mode !== 'none'; } public function sendToUser(int $userId, string $title, string $body, string $type = 'info', ?int $notificationId = null): bool { - if (!$this->isConfigured()) { - return false; - } + if (!$this->isConfigured()) return false; $tokens = $this->db->fetchAll( - 'SELECT token FROM user_fcm_tokens WHERE user_id = ?', + 'SELECT id, token FROM user_fcm_tokens WHERE user_id = ?', [$userId] ); - if (empty($tokens)) { - return false; - } + if (empty($tokens)) return false; $success = true; foreach ($tokens as $row) { $result = $this->sendToDevice($row['token'], $title, $body, $type, $notificationId); - if (!$result) $success = false; + if ($result === false) { + $success = false; + } elseif ($result === 'invalid_token') { + $this->db->delete('user_fcm_tokens', 'id = ?', [(int) $row['id']]); + $this->logger->info('FCM: removed invalid token', ['user_id' => $userId]); + } } - return $success; } public function sendToAll(string $title, string $body, string $type = 'info', ?int $notificationId = null): int { - if (!$this->isConfigured()) { - return 0; - } + if (!$this->isConfigured()) return 0; - $tokens = $this->db->fetchAll('SELECT DISTINCT token FROM user_fcm_tokens'); + $tokens = $this->db->fetchAll('SELECT id, token FROM user_fcm_tokens'); if (empty($tokens)) return 0; $sent = 0; foreach ($tokens as $row) { - if ($this->sendToDevice($row['token'], $title, $body, $type, $notificationId)) { + $result = $this->sendToDevice($row['token'], $title, $body, $type, $notificationId); + if ($result === true) { $sent++; + } elseif ($result === 'invalid_token') { + $this->db->delete('user_fcm_tokens', 'id = ?', [(int) $row['id']]); } } - return $sent; } - private function sendToDevice(string $token, string $title, string $body, string $type, ?int $notificationId): bool + private function sendToDevice(string $token, string $title, string $body, string $type, ?int $notificationId): bool|string + { + if ($this->mode === 'v1') { + return $this->sendV1($token, $title, $body, $type, $notificationId); + } + return $this->sendLegacy($token, $title, $body, $type, $notificationId); + } + + private function sendV1(string $token, string $title, string $body, string $type, ?int $notificationId): bool|string + { + $accessToken = $this->getAccessToken(); + if (!$accessToken) return false; + + $projectId = $this->serviceAccount['project_id'] ?? ''; + if (empty($projectId)) return false; + + $payload = [ + 'message' => [ + 'token' => $token, + 'notification' => [ + 'title' => $title, + 'body' => $body, + ], + 'data' => [ + 'type' => $type, + 'notification_id' => (string) ($notificationId ?? 0), + 'click_action' => 'OPEN_NOTIFICATIONS', + ], + 'android' => [ + 'priority' => 'HIGH', + 'notification' => [ + 'channel_id' => match ($type) { + 'error', 'warning' => 'notifications_alerts', + default => 'notifications_general', + }, + 'priority' => match ($type) { + 'error' => 'HIGH', + default => 'NORMAL', + }, + 'sound' => 'default', + ], + ], + ], + ]; + + $ch = curl_init("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send"); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $accessToken, + ], + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode === 200) { + $this->logger->info('FCM v1: push sent', ['notification_id' => $notificationId]); + return true; + } + + if ($httpCode === 404 || $httpCode === 400) { + $respData = json_decode($response, true); + $errorMsg = $respData['error']['message'] ?? $response; + if (str_contains($errorMsg, 'registration-token-not-registered') || + str_contains($errorMsg, 'NOT_FOUND') || + str_contains($errorMsg, 'INVALID_ARGUMENT')) { + $this->logger->warning('FCM v1: invalid token, will remove', ['error' => substr($errorMsg, 0, 200)]); + return 'invalid_token'; + } + } + + $this->logger->error('FCM v1: send failed', [ + 'http_code' => $httpCode, + 'response' => substr($response, 0, 500), + ]); + return false; + } + + private function sendLegacy(string $token, string $title, string $body, string $type, ?int $notificationId): bool|string { $payload = [ 'to' => $token, @@ -118,14 +224,85 @@ class FCMService $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); - if ($httpCode !== 200) { - $this->logger->error('FCM send failed', [ - 'http_code' => $httpCode, - 'response' => substr($response, 0, 500), - ]); - return false; + if ($httpCode === 200) { + $this->logger->info('FCM legacy: push sent', ['notification_id' => $notificationId]); + return true; } - return true; + $respData = json_decode($response, true); + $fcmError = $respData['results'][0]['error'] ?? ''; + + if ($fcmError === 'NotRegistered' || $fcmError === 'InvalidRegistration') { + $this->logger->warning('FCM legacy: invalid token, will remove', ['error' => $fcmError]); + return 'invalid_token'; + } + + $this->logger->error('FCM legacy: send failed', [ + 'http_code' => $httpCode, + 'fcm_error' => $fcmError, + 'response' => substr($response, 0, 500), + ]); + return false; + } + + private function getAccessToken(): string + { + if ($this->accessToken && time() < $this->tokenExpiry) { + return $this->accessToken; + } + + $jwt = $this->createJWT(); + if (!$jwt) return ''; + + $ch = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + ]); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + $this->logger->error('FCM OAuth token request failed', ['http_code' => $httpCode, 'response' => substr($response, 0, 500)]); + return ''; + } + + $data = json_decode($response, true); + $this->accessToken = $data['access_token'] ?? ''; + $this->tokenExpiry = time() + (int) ($data['expires_in'] ?? 3600) - 60; + return $this->accessToken; + } + + private function createJWT(): string + { + if (empty($this->serviceAccount['client_email']) || empty($this->serviceAccount['private_key'])) { + return ''; + } + + $now = time(); + $header = self::base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $payload = self::base64UrlEncode(json_encode([ + 'iss' => $this->serviceAccount['client_email'], + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => 'https://oauth2.googleapis.com/token', + 'exp' => $now + 3600, + 'iat' => $now, + ])); + + $signature = ''; + openssl_sign("$header.$payload", $signature, $this->serviceAccount['private_key'], 'sha256WithRSAEncryption'); + + return "$header.$payload." . self::base64UrlEncode($signature); + } + + private static function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } }