File "SendFcmNotification.php"

Full Path: /home/trinadezambia/public_html/admin_panel/app/Jobs/SendFcmNotification.php
File size: 13.53 KB
MIME-type: text/x-php
Charset: utf-8

<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\FcmToken;
use App\Models\School;
use App\Services\CachingService;
use App\Services\FcmTokenService;
use Google\Client;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;

final class SendFcmNotification implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    /**
     * The number of times the job may be attempted.
     *
     * @var int
     */
    public int $tries = 3;

    /**
     * The number of seconds the job can run before timing out.
     *
     * @var int
     */
    public int $timeout = 60;

    /**
     * The number of seconds to wait before retrying the job.
     *
     * @var int
     */
    public int $backoff = 30;

    /**
     * Create a new job instance.
     *
     * @param int $schoolId The school ID from the main database
     * @param int $fcmTokenId The FCM token ID in the school database
     * @param string $title
     * @param string $body
     * @param string $type
     * @param array<string, mixed> $customData
     */
    public function __construct(
        private readonly int $schoolId,
        private readonly int $fcmTokenId,
        private readonly string $title,
        private readonly string $body,
        private readonly string $type,
        private readonly array $customData = []
    ) {
    }

    /**
     * Execute the job.
     *
     * @param CachingService $cache
     * @param FcmTokenService $fcmTokenService
     * @return void
     */
    public function handle(CachingService $cache, FcmTokenService $fcmTokenService): void
    {
        // Ensure we start with main database connection
        DB::setDefaultConnection('mysql');

        try {
            // Step 1: Get school information from main database
            $school = School::on('mysql')->find($this->schoolId);

            if (!$school) {
                Log::error("School not found for ID: {$this->schoolId}");
                return;
            }

            // Step 2: Switch to school-specific database
            DB::setDefaultConnection('school');
            Config::set('database.connections.school.database', $school->database_name);
            DB::purge('school');
            DB::connection('school')->reconnect();
            DB::setDefaultConnection('school');

            // Step 3: Fetch FCM token from school database
            $fcmToken = FcmToken::find($this->fcmTokenId);

            if (!$fcmToken) {
                Log::info('FCM token not found or deleted', [
                    'token_id' => $this->fcmTokenId,
                    'school_id' => $this->schoolId,
                ]);
                return;
            }

            $projectId = $cache->getSystemSettings('firebase_project_id');

            if (!$projectId) {
                Log::warning('Firebase project ID not configured');
                return;
            }

            $url = 'https://fcm.googleapis.com/v1/projects/' . $projectId . '/messages:send';
            $accessToken = $this->getAccessToken($cache);

            if (!$accessToken) {
                Log::error('Failed to get Firebase access token');
                return;
            }

            // Convert custom data values to strings (FCM requires string values in data payload)
            $customDataStrings = array_map(function ($value) {
                if (is_array($value)) {
                    return json_encode($value);
                }
                return (string) $value;
            }, $this->customData);

            // Build payload based on device type
            $data = $this->buildPayload($fcmToken, $customDataStrings);

            // Send notification
            $response = $this->sendFcmNotification($url, $accessToken, $data);

            // Handle response
            $this->handleResponse($response, $fcmToken, $fcmTokenService);
        } catch (Throwable $e) {
            Log::error('SendFcmNotification job failed', [
                'school_id' => $this->schoolId,
                'token_id' => $this->fcmTokenId,
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]);
            throw $e;
        } finally {
            // Switch back to main database connection
            $this->switchBackToMainDatabase();
        }
    }

    /**
     * Build FCM payload based on device type.
     *
     * @param FcmToken $fcmToken
     * @param array<string, string> $customDataStrings
     * @return array<string, mixed>
     */
    private function buildPayload(FcmToken $fcmToken, array $customDataStrings): array
    {
        $basePayload = [
            'message' => [
                'token' => $fcmToken->fcm_token,
                'data' => array_merge([
                    'title' => $this->title,
                    'body' => $this->body,
                    'type' => $this->type,
                ], $customDataStrings),
            ],
        ];

        if ($fcmToken->device_type === 'web') {
            return $this->buildWebPayload($basePayload, $customDataStrings);
        }

        return $this->buildMobilePayload($basePayload, $customDataStrings);
    }

    /**
     * Build payload for mobile devices (Android & iOS).
     *
     * @param array<string, mixed> $basePayload
     * @param array<string, string> $customDataStrings
     * @return array<string, mixed>
     */
    private function buildMobilePayload(array $basePayload, array $customDataStrings): array
    {
        $basePayload['message']['notification'] = [
            'title' => $this->title,
            'body' => $this->body,
        ];

        if (isset($this->customData['image'])) {
            $basePayload['message']['notification']['image'] = $this->customData['image'];
        }

        $basePayload['message']['android'] = [
            'notification' => [
                'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
                'sound' => 'default',
            ],
            'priority' => 'high',
        ];

        $basePayload['message']['apns'] = [
            'headers' => [
                'apns-priority' => '10',
            ],
            'payload' => array_merge([
                'aps' => [
                    'alert' => [
                        'title' => $this->title,
                        'body' => $this->body,
                    ],
                    'sound' => 'default',
                    'mutable-content' => 1,
                    'content-available' => 1,
                ],
                'type' => $this->type,
            ], $customDataStrings),
        ];

        return $basePayload;
    }

    /**
     * Build payload for web devices.
     *
     * @param array<string, mixed> $basePayload
     * @param array<string, string> $customDataStrings
     * @return array<string, mixed>
     */
    private function buildWebPayload(array $basePayload, array $customDataStrings): array
    {
        $iconUrl = $this->getWebNotificationIcon();

        $webNotification = [
            'title' => $this->title,
            'body' => $this->body,
            'icon' => $iconUrl,
            'badge' => $iconUrl,
            'requireInteraction' => false,
            'silent' => false,
            'sound' => 'default',
        ];

        if (isset($this->customData['image']) && !empty($this->customData['image'])) {
            $imageUrl = $this->customData['image'];
            if (!filter_var($imageUrl, FILTER_VALIDATE_URL)) {
                $imageUrl = url($imageUrl);
            }
            $webNotification['image'] = $imageUrl;
        }

        $basePayload['message']['webpush'] = [
            'notification' => $webNotification,
        ];

        return $basePayload;
    }

    /**
     * Get web notification icon URL.
     *
     * @return string
     */
    private function getWebNotificationIcon(): string
    {
        $defaultIcon = asset('assets/images/favicon.png');

        if (isset($this->customData['image']) && !empty($this->customData['image'])) {
            $image = $this->customData['image'];
            if (filter_var($image, FILTER_VALIDATE_URL)) {
                return $image;
            }
            return url($image);
        }

        return $defaultIcon;
    }

    /**
     * Send FCM notification via cURL.
     *
     * @param string $url
     * @param string $accessToken
     * @param array<string, mixed> $data
     * @return array<string, mixed>|null
     */
    private function sendFcmNotification(string $url, string $accessToken, array $data): ?array
    {
        $encodedData = json_encode($data);

        $headers = [
            'Authorization: Bearer ' . $accessToken,
            'Content-Type: application/json',
        ];

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $encodedData);

        $result = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        if ($result === false) {
            Log::error('FCM notification cURL error', [
                'error' => $error,
                'token_id' => $this->fcmTokenId,
            ]);
            return null;
        }

        $response = json_decode($result, true);

        if ($httpCode !== 200 || (isset($response['error']) && !empty($response['error']))) {
            Log::warning('FCM notification error', [
                'http_code' => $httpCode,
                'response' => $response,
                'token_id' => $this->fcmTokenId,
            ]);
        }

        return $response;
    }

    /**
     * Handle FCM response and remove invalid tokens.
     *
     * @param array<string, mixed>|null $response
     * @param FcmToken $fcmToken
     * @param FcmTokenService $fcmTokenService
     * @return void
     */
    private function handleResponse(?array $response, FcmToken $fcmToken, FcmTokenService $fcmTokenService): void
    {
        if (!$response) {
            return;
        }

        // Check for invalid token errors
        if (isset($response['error'])) {
            $errorCode = $response['error']['code'] ?? null;
            $errorMessage = $response['error']['message'] ?? '';

            // FCM error codes that indicate invalid token
            $invalidTokenCodes = [
                'NOT_FOUND',
                'INVALID_ARGUMENT',
                'UNREGISTERED',
                'INVALID_REGISTRATION',
            ];

            if (
                in_array($errorCode, $invalidTokenCodes, true) ||
                stripos($errorMessage, 'invalid') !== false ||
                stripos($errorMessage, 'not found') !== false ||
                stripos($errorMessage, 'unregistered') !== false
            ) {
                // Remove invalid token
                $fcmTokenService->removeInvalidToken($fcmToken->fcm_token);
                Log::info('Removed invalid FCM token', [
                    'token_id' => $fcmToken->id,
                    'error_code' => $errorCode,
                ]);
            }
        } else {
            // Success - update last_used_at
            $fcmToken->touchLastUsed();
        }
    }

    /**
     * Get Firebase access token.
     *
     * @param CachingService $cache
     * @return string|null
     */
    private function getAccessToken(CachingService $cache): ?string
    {
        try {
            $fileName = $cache->getSystemSettings('firebase_service_file');
            $data = explode('storage/', $fileName ?? '');
            $fileName = end($data);

            $filePath = base_path('public/storage/' . $fileName);

            if (!file_exists($filePath)) {
                Log::error('Firebase service file not found', ['path' => $filePath]);
                return null;
            }

            $client = new Client();
            $client->setAuthConfig($filePath);
            $client->setScopes(['https://www.googleapis.com/auth/firebase.messaging']);
            $accessToken = $client->fetchAccessTokenWithAssertion()['access_token'] ?? null;

            return $accessToken;
        } catch (Throwable $e) {
            Log::error('Failed to get Firebase access token', [
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]);
            return null;
        }
    }

    /**
     * Switch back to main database connection.
     *
     * @return void
     */
    private function switchBackToMainDatabase(): void
    {
        try {
            DB::setDefaultConnection('mysql');
        } catch (Throwable $e) {
            Log::error('Failed to switch back to main database: ' . $e->getMessage());
        }
    }

    /**
     * Handle a job failure.
     *
     * @param Throwable $exception
     * @return void
     */
    public function failed(Throwable $exception): void
    {
        Log::error('SendFcmNotification job failed', [
            'school_id' => $this->schoolId,
            'token_id' => $this->fcmTokenId,
            'error' => $exception->getMessage(),
            'trace' => $exception->getTraceAsString(),
        ]);

        // Ensure we're back on main database
        $this->switchBackToMainDatabase();
    }
}