Files
musadaq-saas/app/Services/NotificationService.php
T

407 lines
15 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();
// Fetch the Live Activity token alongside the FCM one. They are different
// tokens with different delivery semantics: the FCM registration token
// wakes the app, while an ActivityKit token is the only thing that can
// update a Live Activity on the lock screen.
$sql = "SELECT push_token, live_activity_token, platform
FROM user_devices
WHERE user_id = ? AND push_token IS NOT NULL";
$params = [$userId];
if ($deviceId) {
$sql .= " AND device_fingerprint = ?";
$params[] = $deviceId;
}
try {
$stmt = $db->prepare($sql);
$stmt->execute($params);
$devices = $stmt->fetchAll();
} catch (\PDOException $e) {
// Deployment has not run the live_activity_token migration yet.
$fallbackSql = str_replace(', live_activity_token', '', $sql);
$stmt = $db->prepare($fallbackSql);
$stmt->execute($params);
$devices = $stmt->fetchAll();
}
if (empty($devices)) return false;
$isLiveActivityUpdate = ($data['type'] ?? '') === 'batch_progress';
$successCount = 0;
foreach ($devices as $device) {
// 1. Silent data push to the app itself (drives in-app progress and
// the Android ongoing notification).
if ($this->dispatchToFcm($device['push_token'], null, null, $data)) {
$successCount++;
}
// 2. Separate ActivityKit push for the iOS Live Activity.
$activityToken = $device['live_activity_token'] ?? null;
if ($isLiveActivityUpdate && !empty($activityToken)) {
if ($this->dispatchLiveActivityUpdate($activityToken, $data)) {
$successCount++;
}
}
}
return $successCount > 0;
}
/**
* Push an ActivityKit content-state update to a running Live Activity.
*
* The content-state keys must match InvoiceBatchAttributes.ContentState in
* the iOS widget extension exactly, or iOS discards the update.
*/
private function dispatchLiveActivityUpdate(string $activityToken, array $data): bool
{
$accessToken = $this->getAccessToken();
if (!$accessToken) return false;
$processed = (int)($data['processed'] ?? 0);
$total = max(1, (int)($data['total'] ?? 1));
$failed = (int)($data['failed'] ?? 0);
$isDone = !empty($data['is_done']);
$message = [
'apns' => [
// The token that identifies the ACTIVITY, not the app install.
'live_activity_token' => $activityToken,
'headers' => [
'apns-push-type' => 'liveactivity',
'apns-priority' => '10',
'apns-topic' => env('IOS_BUNDLE_ID', 'com.musadaq.app') . '.push-type.liveactivity',
],
'payload' => [
'aps' => [
'timestamp' => time(),
'event' => $isDone ? 'end' : 'update',
'content-state' => [
'current' => $processed,
'total' => $total,
'failed' => $failed,
'isDone' => $isDone,
],
// Give iOS a moment to show the final state before it
// clears a finished activity.
'dismissal-date' => $isDone ? (time() + 30) : null,
],
],
],
];
// Strip nulls: APNs rejects a null dismissal-date.
$message['apns']['payload']['aps'] = array_filter(
$message['apns']['payload']['aps'],
static fn($v) => $v !== null
);
return $this->postToFcm(['message' => $message], 'live-activity');
}
/**
* 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;
}
$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 data push: wakes the app so it can update its own progress
// UI. Live Activity updates are a SEPARATE push that must target the
// ActivityKit token - see dispatchLiveActivityUpdate(). Sending
// apns-push-type: liveactivity to an app registration token, as this
// used to do, never reaches the activity.
$message['android'] = [
'priority' => 'high'
];
$message['apns'] = [
'headers' => [
'apns-priority' => '5',
'apns-push-type' => 'background'
],
'payload' => [
'aps' => [
'content-available' => 1
]
]
];
}
return $this->postToFcm(['message' => $message], 'message');
}
/**
* POST a prepared FCM v1 payload. Shared by the notification, silent-data and
* Live Activity paths so auth/error handling lives in one place.
*/
private function postToFcm(array $payload, string $kind): 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;
if (empty($this->projectId)) {
error_log('[NotificationService] Firebase project id is empty');
return false;
}
$url = "https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send";
$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_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log("[NotificationService] FCM cURL error ($kind): $curlError");
return false;
}
if ($httpCode !== 200) {
error_log("[NotificationService] FCM Send Error [$kind] ($httpCode): " . $response);
// A token the server rejected as unregistered will never work again;
// clear it so we stop paying for the round trip on every batch.
if (in_array($httpCode, [400, 404], true) && str_contains((string)$response, 'UNREGISTERED')) {
$this->pruneToken($payload);
}
return false;
}
return true;
}
/**
* Drop a token FCM reported as permanently invalid.
*/
private function pruneToken(array $payload): void
{
$token = $payload['message']['token'] ?? null;
$activityToken = $payload['message']['apns']['live_activity_token'] ?? null;
try {
$db = Database::getInstance();
if ($token) {
$db->prepare("UPDATE user_devices SET push_token = NULL WHERE push_token = ?")
->execute([$token]);
error_log('[NotificationService] Pruned unregistered push token');
}
if ($activityToken) {
$db->prepare("UPDATE user_devices SET live_activity_token = NULL WHERE live_activity_token = ?")
->execute([$activityToken]);
}
} catch (\Throwable $e) {
error_log('[NotificationService] pruneToken failed: ' . $e->getMessage());
}
}
/**
* 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;
}
}