projectId = env('FIREBASE_PROJECT_ID', ''); $this->serviceAccountPath = env('FIREBASE_SERVICE_ACCOUNT_PATH', APP_PATH . '/config/firebase-service-account.json'); } /** * Send a push notification to a specific user or device */ public function sendNotification(string $userId, string $title, string $body, array $data = [], ?string $deviceId = null): bool { $db = Database::getInstance(); // 1. Get push tokens for the user if ($deviceId) { $stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND device_fingerprint = ? AND push_token IS NOT NULL"); $stmt->execute([$userId, $deviceId]); } else { $stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND push_token IS NOT NULL"); $stmt->execute([$userId]); } $tokens = $stmt->fetchAll(\PDO::FETCH_COLUMN); if (empty($tokens)) { return false; } // 2. Save notification to database (Single direct insert) $stmt = $db->prepare("SELECT tenant_id FROM users WHERE id = ? LIMIT 1"); $stmt->execute([$userId]); $tenantId = $stmt->fetchColumn(); if ($tenantId) { $stmt = $db->prepare("INSERT INTO notifications (id, tenant_id, user_id, type, title, body, data, created_at) VALUES (UUID(), ?, ?, 'system', ?, ?, ?, NOW())"); $stmt->execute([$tenantId, $userId, $title, $body, json_encode($data)]); } // 3. Send to each token $successCount = 0; foreach ($tokens as $token) { if ($this->dispatchToFcm($token, $title, $body, $data)) { $successCount++; } } return $successCount > 0; } /** * Send a data-only (silent) notification to update background state (e.g., progress) */ public function sendDataNotification(string $userId, array $data, ?string $deviceId = null): bool { $db = Database::getInstance(); if ($deviceId) { $stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND device_fingerprint = ? AND push_token IS NOT NULL"); $stmt->execute([$userId, $deviceId]); } else { $stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND push_token IS NOT NULL"); $stmt->execute([$userId]); } $tokens = $stmt->fetchAll(\PDO::FETCH_COLUMN); if (empty($tokens)) return false; $successCount = 0; foreach ($tokens as $token) { if ($this->dispatchToFcm($token, null, null, $data)) { $successCount++; } } return $successCount > 0; } /** * Dispatch notification to Firebase via HTTP v1 API */ private function dispatchToFcm(string $token, ?string $title, ?string $body, array $data): bool { if (!file_exists($this->serviceAccountPath)) { error_log("[NotificationService] Firebase service account file missing: {$this->serviceAccountPath}"); return false; } $accessToken = $this->getAccessToken(); if (!$accessToken) return false; $url = "https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send"; $message = [ 'token' => $token, 'data' => array_map('strval', $data), ]; if ($title || $body) { $message['notification'] = [ 'title' => $title, 'body' => $body, ]; $message['android'] = [ 'priority' => 'high', 'notification' => [ 'sound' => 'default', 'channel_id' => 'high_importance_channel' ] ]; $message['apns'] = [ 'payload' => [ 'aps' => [ 'sound' => 'default', ], ], ]; } else { // Silent push / Live Activity Update $message['android'] = [ 'priority' => 'high' ]; $message['apns'] = [ 'headers' => [ 'apns-priority' => '5', 'apns-push-type' => 'background' ], 'payload' => [ 'aps' => [ 'content-available' => 1 ] ] ]; // If the data contains live activity update markers, adjust headers for iOS ActivityKit if (isset($data['type']) && $data['type'] === 'batch_progress') { $message['apns']['headers']['apns-push-type'] = 'liveactivity'; $message['apns']['headers']['apns-priority'] = '10'; $message['apns']['payload']['aps']['content-state'] = $data; $message['apns']['payload']['aps']['timestamp'] = time(); $message['apns']['payload']['aps']['event'] = 'update'; } } $payload = ['message' => $message]; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer ' . $accessToken, 'Content-Type: application/json', ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { error_log("[NotificationService] FCM Send Error ($httpCode): " . $response); return false; } return true; } /** * Get OAuth2 Access Token for Firebase using Service Account JWT * Self-contained: no external libraries needed. */ private function getAccessToken(): ?string { // Check cache first (token is valid for 1 hour, we cache for 50 min) $cacheFile = STORAGE_PATH . '/cache/fcm_token.json'; if (file_exists($cacheFile)) { $cached = json_decode(file_get_contents($cacheFile), true); if ($cached && ($cached['expires_at'] ?? 0) > time()) { return $cached['access_token']; } } if (!file_exists($this->serviceAccountPath)) { error_log("[NotificationService] Firebase service account file missing"); return null; } $sa = json_decode(file_get_contents($this->serviceAccountPath), true); if (!$sa || empty($sa['private_key']) || empty($sa['client_email'])) { error_log("[NotificationService] Invalid service account JSON"); return null; } // Build JWT $now = time(); $header = json_encode(['alg' => 'RS256', 'typ' => 'JWT']); $payload = json_encode([ 'iss' => $sa['client_email'], 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', 'aud' => 'https://oauth2.googleapis.com/token', 'iat' => $now, 'exp' => $now + 3600, ]); $b64Header = rtrim(strtr(base64_encode($header), '+/', '-_'), '='); $b64Payload = rtrim(strtr(base64_encode($payload), '+/', '-_'), '='); $signingInput = $b64Header . '.' . $b64Payload; $privateKey = openssl_pkey_get_private($sa['private_key']); if (!$privateKey) { error_log("[NotificationService] Failed to parse private key"); return null; } openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256); $b64Signature = rtrim(strtr(base64_encode($signature), '+/', '-_'), '='); $jwt = $signingInput . '.' . $b64Signature; // Exchange JWT for access token $ch = curl_init('https://oauth2.googleapis.com/token'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => http_build_query([ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ]), ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { error_log("[NotificationService] Token exchange failed ($httpCode): $response"); return null; } $tokenData = json_decode($response, true); $accessToken = $tokenData['access_token'] ?? null; if ($accessToken) { // Cache for 50 minutes @file_put_contents($cacheFile, json_encode([ 'access_token' => $accessToken, 'expires_at' => $now + 3000, ])); } return $accessToken; } }