feat: FCM push notification wali (HTTP v1 + service account) — otomatis saat pelanggaran/SP, platform web didukung, command fcm:test
This commit is contained in:
@@ -31,3 +31,7 @@ REVERB_PORT=8080
|
|||||||
REVERB_SCHEME=http
|
REVERB_SCHEME=http
|
||||||
|
|
||||||
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||||
|
|
||||||
|
# ===== Firebase Cloud Messaging (push notification aplikasi wali) =====
|
||||||
|
FCM_SERVICE_ACCOUNT=
|
||||||
|
FCM_PROJECT_ID=
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ParentDevice;
|
||||||
|
use App\Services\FcmService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class TestFcmPush extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'fcm:test {--token= : Token FCM perangkat (opsional, default: token web pertama)}';
|
||||||
|
|
||||||
|
protected $description = 'Kirim push notification uji coba via FCM';
|
||||||
|
|
||||||
|
public function handle(FcmService $fcm): int
|
||||||
|
{
|
||||||
|
if (! $fcm->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ class ParentNotificationController extends Controller
|
|||||||
public function registerDevice(Request $request): JsonResponse
|
public function registerDevice(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'platform' => ['required', Rule::in(['android', 'ios'])],
|
'platform' => ['required', Rule::in(['android', 'ios', 'web'])],
|
||||||
'fcm_token' => ['required', 'string', 'max:255'],
|
'fcm_token' => ['required', 'string', 'max:255'],
|
||||||
'device_name' => ['nullable', 'string', 'max:100'],
|
'device_name' => ['nullable', 'string', 'max:100'],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FCM HTTP v1 API — kirim push notification via Firebase Cloud Messaging.
|
||||||
|
*
|
||||||
|
* Konfigurasi di .env:
|
||||||
|
* FCM_SERVICE_ACCOUNT=/path/ke/service-account.json (Firebase console → Project settings → Service accounts)
|
||||||
|
* FCM_PROJECT_ID=xxx (opsional, otomatis dibaca dari JSON bila kosong)
|
||||||
|
*
|
||||||
|
* Jika belum dikonfigurasi, semua method jadi no-op (log debug) — aplikasi tetap berjalan normal.
|
||||||
|
*/
|
||||||
|
class FcmService
|
||||||
|
{
|
||||||
|
protected ?array $account = null;
|
||||||
|
|
||||||
|
protected ?string $projectId = null;
|
||||||
|
|
||||||
|
protected ?string $accessToken = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$path = env('FCM_SERVICE_ACCOUNT');
|
||||||
|
if ($path && is_file($path)) {
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
$this->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), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,15 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Events\ViolationRecorded;
|
use App\Events\ViolationRecorded;
|
||||||
|
use App\Models\AppNotification;
|
||||||
|
use App\Models\ParentDevice;
|
||||||
|
use App\Models\ParentStudent;
|
||||||
use App\Models\PointAuditLog;
|
use App\Models\PointAuditLog;
|
||||||
use App\Models\SpLetter;
|
use App\Models\SpLetter;
|
||||||
use App\Models\SpThreshold;
|
use App\Models\SpThreshold;
|
||||||
use App\Models\Student;
|
use App\Models\Student;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Violation;
|
use App\Models\Violation;
|
||||||
use App\Models\AppNotification;
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
@@ -117,9 +119,112 @@ class ViolationService
|
|||||||
'status' => 'draft',
|
'status' => 'draft',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Notifikasi + FCM push ke wali murid (SP baru)
|
||||||
|
$this->notifyParentsOfSp($letter);
|
||||||
|
|
||||||
return $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
|
protected function notifyRealtime(Violation $violation): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -149,6 +254,9 @@ class ViolationService
|
|||||||
|
|
||||||
// Broadcast via Reverb
|
// Broadcast via Reverb
|
||||||
broadcast(new ViolationRecorded($violation));
|
broadcast(new ViolationRecorded($violation));
|
||||||
|
|
||||||
|
// Notifikasi + FCM push ke wali murid
|
||||||
|
$this->notifyParents($violation);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Illuminate\Support\Facades\Log::error('Notify realtime failed: ' . $e->getMessage());
|
\Illuminate\Support\Facades\Log::error('Notify realtime failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,5 +214,19 @@ class ParentApiTest extends TestCase
|
|||||||
$this->assertDatabaseHas('parent_devices', [
|
$this->assertDatabaseHas('parent_devices', [
|
||||||
'fcm_token' => 'fcm-abc-123',
|
'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',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user