From b4d212ad0eaf044ffaf0d47956c2484e15aa4f8b Mon Sep 17 00:00:00 2001 From: Novy San87 Date: Thu, 6 Aug 2026 07:19:36 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20FCM=20push=20notification=20wali=20(HTT?= =?UTF-8?q?P=20v1=20+=20service=20account)=20=E2=80=94=20otomatis=20saat?= =?UTF-8?q?=20pelanggaran/SP,=20platform=20web=20didukung,=20command=20fcm?= =?UTF-8?q?:test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + app/Console/Commands/TestFcmPush.php | 42 ++++++ .../Api/ParentNotificationController.php | 2 +- app/Services/FcmService.php | 141 ++++++++++++++++++ app/Services/ViolationService.php | 110 +++++++++++++- tests/Feature/ParentApiTest.php | 14 ++ 6 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 app/Console/Commands/TestFcmPush.php create mode 100644 app/Services/FcmService.php diff --git a/.env.example b/.env.example index 25e7053..71ef872 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,7 @@ REVERB_PORT=8080 REVERB_SCHEME=http VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" + +# ===== Firebase Cloud Messaging (push notification aplikasi wali) ===== +FCM_SERVICE_ACCOUNT= +FCM_PROJECT_ID= diff --git a/app/Console/Commands/TestFcmPush.php b/app/Console/Commands/TestFcmPush.php new file mode 100644 index 0000000..f003961 --- /dev/null +++ b/app/Console/Commands/TestFcmPush.php @@ -0,0 +1,42 @@ +isEnabled()) { + $this->error('FCM belum dikonfigurasi. Isi FCM_SERVICE_ACCOUNT & FCM_PROJECT_ID di .env'); + + return self::FAILURE; + } + + $token = $this->option('token'); + if (! $token) { + $token = ParentDevice::where('platform', 'web')->value('fcm_token'); + } + if (! $token) { + $this->error('Tidak ada token perangkat. Daftarkan dulu lewat API /parent/devices atau pakai --token='); + + return self::FAILURE; + } + + $ok = $fcm->sendToToken($token, [ + 'title' => '🔔 Uji Coba S1WON DIGI', + 'body' => 'Push notification berhasil! Ini notifikasi dari server.', + ], ['type' => 'test']); + + $this->info($ok ? 'Notifikasi terkirim ✅' : 'Gagal mengirim ❌ (cek log)'); + + return $ok ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Http/Controllers/Api/ParentNotificationController.php b/app/Http/Controllers/Api/ParentNotificationController.php index d1cb0d7..e12b877 100644 --- a/app/Http/Controllers/Api/ParentNotificationController.php +++ b/app/Http/Controllers/Api/ParentNotificationController.php @@ -60,7 +60,7 @@ class ParentNotificationController extends Controller public function registerDevice(Request $request): JsonResponse { $validated = $request->validate([ - 'platform' => ['required', Rule::in(['android', 'ios'])], + 'platform' => ['required', Rule::in(['android', 'ios', 'web'])], 'fcm_token' => ['required', 'string', 'max:255'], 'device_name' => ['nullable', 'string', 'max:100'], ]); diff --git a/app/Services/FcmService.php b/app/Services/FcmService.php new file mode 100644 index 0000000..8dd614c --- /dev/null +++ b/app/Services/FcmService.php @@ -0,0 +1,141 @@ +account = $raw ? json_decode($raw, true) : null; + $this->projectId = env('FCM_PROJECT_ID') + ?: ($this->account['project_id'] ?? null); + } + } + + public function isEnabled(): bool + { + return $this->account !== null + && $this->projectId !== null + && isset($this->account['client_email']) + && isset($this->account['private_key']); + } + + /** + * Kirim push notification ke satu token perangkat. + */ + public function sendToToken(string $token, array $notification, ?array $data = null): bool + { + if (! $this->isEnabled()) { + Log::debug('FCM disabled — isi FCM_SERVICE_ACCOUNT & FCM_PROJECT_ID di .env'); + + return false; + } + + $title = $notification['title'] ?? 'S1WON DIGI'; + $body = $notification['body'] ?? ''; + + $message = [ + 'message' => [ + 'token' => $token, + 'notification' => [ + 'title' => $title, + 'body' => $body, + ], + 'data' => $data ?: new \stdClass(), + 'webpush' => [ + 'headers' => ['TTL' => '3600'], + 'notification' => [ + 'title' => $title, + 'body' => $body, + 'icon' => 'https://tatib.smkn1-wonorejo.sch.id/aplikasi-wali/icons/Icon-192.png', + 'badge' => 'https://tatib.smkn1-wonorejo.sch.id/aplikasi-wali/icons/Icon-192.png', + ], + ], + ], + ]; + + try { + $response = Http::withToken($this->getAccessToken()) + ->timeout(10) + ->post("https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send", $message); + + if (! $response->successful()) { + Log::warning('FCM send failed: '.$response->status().' '.$response->body()); + + return false; + } + + return true; + } catch (\Throwable $e) { + Log::warning('FCM send error: '.$e->getMessage()); + + return false; + } + } + + /** + * Ambil OAuth2 access token dari service account (JWT RS256 → token endpoint). + */ + protected function getAccessToken(): string + { + if ($this->accessToken) { + return $this->accessToken; + } + + $now = time(); + $payload = [ + 'iss' => $this->account['client_email'], + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => 'https://oauth2.googleapis.com/token', + 'iat' => $now, + 'exp' => $now + 3600, + ]; + + $jwt = $this->signJwt($payload); + + $response = Http::asForm() + ->timeout(10) + ->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]); + + $this->accessToken = $response->json('access_token'); + + return $this->accessToken; + } + + protected function signJwt(array $payload): string + { + $b64 = fn (array $data) => rtrim(strtr(base64_encode(json_encode($data)), '+/', '-_'), '='); + + $header = $b64(['alg' => 'RS256', 'typ' => 'JWT']); + $body = $b64($payload); + $signingInput = $header.'.'.$body; + + openssl_sign($signingInput, $signature, $this->account['private_key'], OPENSSL_ALGO_SHA256); + + return $signingInput.'.'.rtrim(strtr(base64_encode($signature), '+/', '-_'), '='); + } +} diff --git a/app/Services/ViolationService.php b/app/Services/ViolationService.php index 22b41c9..7975f0f 100644 --- a/app/Services/ViolationService.php +++ b/app/Services/ViolationService.php @@ -3,13 +3,15 @@ namespace App\Services; use App\Events\ViolationRecorded; +use App\Models\AppNotification; +use App\Models\ParentDevice; +use App\Models\ParentStudent; use App\Models\PointAuditLog; use App\Models\SpLetter; use App\Models\SpThreshold; use App\Models\Student; use App\Models\User; use App\Models\Violation; -use App\Models\AppNotification; use App\Models\Setting; use Illuminate\Support\Facades\DB; @@ -117,9 +119,112 @@ class ViolationService 'status' => 'draft', ]); + // Notifikasi + FCM push ke wali murid (SP baru) + $this->notifyParentsOfSp($letter); + return $letter; } + protected function notifyParents(Violation $violation): void + { + try { + $student = $violation->student; + $links = ParentStudent::where('student_id', $student->id) + ->where('status', 'active') + ->get(); + + if ($links->isEmpty()) { + return; + } + + $title = 'Pelanggaran baru: '.$student->full_name; + $body = $violation->violationType?->name.' (+'.$violation->points.' poin)'; + $data = [ + 'violation_id' => (string) $violation->id, + 'student_name' => $student->full_name, + 'points' => (string) $violation->points, + 'type' => 'violation_recorded', + ]; + + foreach ($links as $link) { + // Catat di daftar notifikasi dalam aplikasi wali (channel push) + $violation->notifications()->create([ + 'student_id' => $student->id, + 'channel' => 'push', + 'recipient' => $link->user?->name ?? 'Wali', + 'message' => $title.' — '.$body, + 'status' => 'sent', + 'user_id' => $link->user_id, + 'created_at' => now(), + ]); + + // FCM push ke semua perangkat wali + $tokens = ParentDevice::where('user_id', $link->user_id)->pluck('fcm_token'); + foreach ($tokens as $token) { + app(FcmService::class)->sendToToken($token, [ + 'title' => $title, + 'body' => $body, + ], $data); + } + } + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error('Notify parents (violation) failed: '.$e->getMessage()); + } + } + + protected function notifyParentsOfSp(SpLetter $letter): void + { + try { + $student = $letter->student; + $links = ParentStudent::where('student_id', $student->id) + ->where('status', 'active') + ->get(); + + if ($links->isEmpty()) { + return; + } + + $title = 'Surat Peringatan untuk '.$student->full_name; + $body = $letter->title.' ('.$letter->letter_number.')'; + $data = [ + 'sp_letter_id' => (string) $letter->id, + 'student_name' => $student->full_name, + 'type' => 'sp_generated', + ]; + + foreach ($links as $link) { + // Ambil id pelanggaran pertama dari data JSON violations_included (untuk tautan notifikasi) + $included = $letter->violations_included ?? []; + $firstViolationId = is_array($included) && ! empty($included) + ? ($included[0]['id'] ?? null) + : null; + $notification = new \App\Models\ViolationNotification([ + 'student_id' => $student->id, + 'channel' => 'push', + 'recipient' => $link->user?->name ?? 'Wali', + 'message' => $title.' — '.$body, + 'status' => 'sent', + 'user_id' => $link->user_id, + 'created_at' => now(), + ]); + if ($firstViolationId) { + $notification->violation_id = $firstViolationId; + } + $notification->save(); + + $tokens = ParentDevice::where('user_id', $link->user_id)->pluck('fcm_token'); + foreach ($tokens as $token) { + app(FcmService::class)->sendToToken($token, [ + 'title' => $title, + 'body' => $body, + ], $data); + } + } + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error('Notify parents (SP) failed: '.$e->getMessage()); + } + } + protected function notifyRealtime(Violation $violation): void { try { @@ -149,6 +254,9 @@ class ViolationService // Broadcast via Reverb broadcast(new ViolationRecorded($violation)); + + // Notifikasi + FCM push ke wali murid + $this->notifyParents($violation); } catch (\Exception $e) { \Illuminate\Support\Facades\Log::error('Notify realtime failed: ' . $e->getMessage()); } diff --git a/tests/Feature/ParentApiTest.php b/tests/Feature/ParentApiTest.php index 94f5a76..5c72cfe 100644 --- a/tests/Feature/ParentApiTest.php +++ b/tests/Feature/ParentApiTest.php @@ -214,5 +214,19 @@ class ParentApiTest extends TestCase $this->assertDatabaseHas('parent_devices', [ 'fcm_token' => 'fcm-abc-123', ]); + + // Platform web (preview browser) juga harus diterima + $this->withToken($token) + ->postJson('/api/v1/parent/devices', [ + 'platform' => 'web', + 'fcm_token' => 'fcm-web-456', + 'device_name' => 'Chrome preview', + ]) + ->assertStatus(201); + + $this->assertDatabaseHas('parent_devices', [ + 'fcm_token' => 'fcm-web-456', + 'platform' => 'web', + ]); } }