277 lines
9.5 KiB
PHP
277 lines
9.5 KiB
PHP
<?php
|
|
/**
|
|
* Firebase Notification Service (FCM HTTP v1)
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Core\Database;
|
|
use App\Core\Security;
|
|
|
|
class NotificationService
|
|
{
|
|
private string $projectId;
|
|
private string $serviceAccountPath;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->serviceAccountPath = env('FIREBASE_SERVICE_ACCOUNT_PATH', APP_PATH . '/config/firebase-service-account.json');
|
|
|
|
// Auto-detect Project ID from Service Account JSON to prevent RESOURCE_PROJECT_INVALID
|
|
if (file_exists($this->serviceAccountPath)) {
|
|
$sa = json_decode(file_get_contents($this->serviceAccountPath), true);
|
|
$this->projectId = $sa['project_id'] ?? env('FIREBASE_PROJECT_ID', '');
|
|
} else {
|
|
$this->projectId = env('FIREBASE_PROJECT_ID', '');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|