fix: FCM push not sending — add v1 API + better diagnostics #62

Merged
rafaga21 merged 1 commits from feat/dashboard-aggregated-chart into master 2026-06-13 13:15:37 -04:00
2 changed files with 214 additions and 30 deletions

View File

@@ -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]);
}
}

View File

@@ -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), '+/', '-_'), '=');
}
}