Commit 0b2c6122 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Mobile API Phase 2: Push notifications via Firebase Cloud Messaging

- Install kreait/firebase-php SDK
- Create PushNotificationService with 3-tier credential resolution
  (system_settings → storage file → env var, graceful degradation)
- Auto-deactivate invalid/unregistered FCM tokens on send failure
- Add Push case to NotificationChannel enum
- Create 6 event listeners for push notifications:
  - AttendanceMarked → notify guardians (present/absent)
  - PaymentReceived → confirm payment to guardians
  - InvoiceCreated → alert guardians of new invoice
  - SessionCancelled → notify all group participants
  - EnrollmentCreated → confirm enrollment to guardians
  - EvaluationShared → notify guardians of new evaluation
- Register all listeners in EventServiceProvider (queued)
- Create NotificationController API endpoints:
  - GET notifications (paginated + unread count)
  - PATCH notifications/{id}/read
  - POST notifications/read-all
  - GET/POST notifications/preferences
- Create scheduled push commands:
  - push:session-reminder (every minute, 30min before session)
  - push:installment-due (daily, 1 and 3 days before due)
- Add channel_push column to notification_preferences
- Total: 20 API endpoints now live
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 7a8a5594
<?php
namespace App\Console\Commands;
use App\Domain\Financial\Models\Installment;
use App\Domain\Notification\Services\PushNotificationService;
use App\Domain\Participant\Models\Participant;
use Illuminate\Console\Command;
class SendInstallmentDuePush extends Command
{
protected $signature = 'push:installment-due {--days=1 : Days before due date to send reminder}';
protected $description = 'Send push notification for upcoming installment due dates';
public function handle(PushNotificationService $pushService): int
{
$days = (int) $this->option('days');
$targetDate = now()->addDays($days)->toDateString();
$installments = Installment::where('due_date', $targetDate)
->where('status', 'pending')
->with(['paymentPlan.invoice'])
->get();
$count = 0;
foreach ($installments as $installment) {
$invoice = $installment->paymentPlan?->invoice;
if (!$invoice) {
continue;
}
if ($invoice->billable_type !== 'App\\Domain\\Participant\\Models\\Participant') {
continue;
}
$participant = Participant::find($invoice->billable_id);
if (!$participant) {
continue;
}
$amount = number_format($installment->amount / 100, 2);
$dueLabel = $days === 1 ? 'غداً' : "خلال {$days} أيام";
$pushService->sendToParticipantGuardians(
$participant,
'تذكير بالقسط',
"قسط بقيمة {$amount} ج.م مستحق {$dueLabel}",
[
'type' => 'installment_due',
'participant_uuid' => $participant->uuid,
'invoice_uuid' => $invoice->uuid,
'amount' => $installment->amount,
'due_date' => $targetDate,
],
'installment.due_reminder'
);
$count++;
}
$this->info("Sent installment reminders for {$count} installments.");
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Domain\Notification\Services\PushNotificationService;
use App\Domain\Training\Models\TrainingSession;
use Illuminate\Console\Command;
class SendSessionReminderPush extends Command
{
protected $signature = 'push:session-reminder {--minutes=30 : Minutes before session to send reminder}';
protected $description = 'Send push notification to participants about upcoming session';
public function handle(PushNotificationService $pushService): int
{
$minutes = (int) $this->option('minutes');
$targetTime = now()->addMinutes($minutes);
$sessions = TrainingSession::where('session_date', now()->toDateString())
->where('start_time', '>=', $targetTime->format('H:i:00'))
->where('start_time', '<=', $targetTime->addMinute()->format('H:i:00'))
->where('status', 'scheduled')
->with('group')
->get();
$count = 0;
foreach ($sessions as $session) {
$group = $session->group;
if (!$group) {
continue;
}
$groupName = $group->name_ar ?? 'المجموعة';
$time = $session->start_time;
$pushService->sendToGroupParticipants(
$group,
'تذكير بالجلسة',
"جلسة {$groupName} تبدأ خلال {$minutes} دقيقة ({$time})",
[
'type' => 'session_reminder',
'session_id' => $session->id,
'group_id' => $group->id,
'start_time' => $time,
],
'session.reminder'
);
$count++;
}
$this->info("Sent reminders for {$count} sessions.");
return self::SUCCESS;
}
}
......@@ -8,6 +8,7 @@
case Email = 'email';
case Sms = 'sms';
case Whatsapp = 'whatsapp';
case Push = 'push';
public function label(): string
{
......@@ -16,6 +17,7 @@ public function label(): string
self::Email => 'بريد إلكتروني',
self::Sms => 'رسالة نصية',
self::Whatsapp => 'واتساب',
self::Push => 'إشعار الهاتف',
};
}
}
<?php
namespace App\Domain\Notification\Listeners;
use App\Domain\Attendance\Events\AttendanceMarked;
use App\Domain\Notification\Services\PushNotificationService;
use App\Domain\Participant\Models\Participant;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendAttendancePush implements ShouldQueue
{
public function __construct(private PushNotificationService $pushService) {}
public function handle(AttendanceMarked $event): void
{
$record = $event->record;
if ($record->subject_type !== 'App\\Domain\\Participant\\Models\\Participant') {
return;
}
$participant = Participant::with('person')->find($record->subject_id);
if (!$participant) {
return;
}
$name = $participant->person?->name_ar ?? 'المشترك';
$status = $record->status->value ?? $record->status;
if (in_array($status, ['present', 'late'])) {
$title = 'تسجيل حضور ✓';
$body = "تم تسجيل حضور {$name} في جلسة اليوم";
} elseif (in_array($status, ['absent', 'no_show'])) {
$title = 'تسجيل غياب';
$body = "تم تسجيل غياب {$name} في جلسة اليوم";
} else {
return;
}
$this->pushService->sendToParticipantGuardians(
$participant,
$title,
$body,
['type' => 'attendance', 'participant_uuid' => $participant->uuid, 'status' => $status],
'attendance.marked'
);
}
}
<?php
namespace App\Domain\Notification\Listeners;
use App\Domain\Notification\Services\PushNotificationService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Events\EnrollmentCreated;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendEnrollmentCreatedPush implements ShouldQueue
{
public function __construct(private PushNotificationService $pushService) {}
public function handle(EnrollmentCreated $event): void
{
$enrollment = $event->enrollment;
$participant = Participant::with('person')->find($enrollment->participant_id);
if (!$participant) {
return;
}
$name = $participant->person?->name_ar ?? 'المشترك';
$group = $enrollment->group;
$programName = $group?->program?->name_ar ?? 'البرنامج';
$this->pushService->sendToParticipantGuardians(
$participant,
'تأكيد الاشتراك',
"تم تسجيل {$name} في {$programName}",
[
'type' => 'enrollment',
'participant_uuid' => $participant->uuid,
'enrollment_id' => $enrollment->id,
],
'enrollment.created'
);
}
}
<?php
namespace App\Domain\Notification\Listeners;
use App\Domain\Notification\Services\PushNotificationService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Events\EvaluationShared;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendEvaluationSharedPush implements ShouldQueue
{
public function __construct(private PushNotificationService $pushService) {}
public function handle(EvaluationShared $event): void
{
$evaluation = $event->evaluation;
$participant = Participant::with('person')->find($evaluation->participant_id);
if (!$participant) {
return;
}
$name = $participant->person?->name_ar ?? 'المشترك';
$this->pushService->sendToParticipantGuardians(
$participant,
'تقييم جديد',
"تم مشاركة تقييم جديد لـ {$name}",
[
'type' => 'evaluation',
'participant_uuid' => $participant->uuid,
'evaluation_id' => $evaluation->id,
],
'evaluation.shared'
);
}
}
<?php
namespace App\Domain\Notification\Listeners;
use App\Domain\Financial\Events\InvoiceCreated;
use App\Domain\Notification\Services\PushNotificationService;
use App\Domain\Participant\Models\Participant;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendInvoiceCreatedPush implements ShouldQueue
{
public function __construct(private PushNotificationService $pushService) {}
public function handle(InvoiceCreated $event): void
{
$invoice = $event->invoice;
if ($invoice->billable_type !== 'App\\Domain\\Participant\\Models\\Participant') {
return;
}
$participant = Participant::find($invoice->billable_id);
if (!$participant) {
return;
}
$amount = number_format($invoice->total_amount / 100, 2);
$this->pushService->sendToParticipantGuardians(
$participant,
'فاتورة جديدة',
"فاتورة بقيمة {$amount} ج.م بانتظار الدفع",
[
'type' => 'invoice',
'participant_uuid' => $participant->uuid,
'invoice_uuid' => $invoice->uuid,
'amount' => $invoice->total_amount,
],
'invoice.created'
);
}
}
<?php
namespace App\Domain\Notification\Listeners;
use App\Domain\Financial\Events\PaymentReceived;
use App\Domain\Notification\Services\PushNotificationService;
use App\Domain\Participant\Models\Participant;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendPaymentPush implements ShouldQueue
{
public function __construct(private PushNotificationService $pushService) {}
public function handle(PaymentReceived $event): void
{
$payment = $event->payment;
$invoice = $payment->invoice;
if (!$invoice) {
return;
}
$amount = number_format($payment->amount / 100, 2);
if ($invoice->billable_type === 'App\\Domain\\Participant\\Models\\Participant') {
$participant = Participant::find($invoice->billable_id);
if (!$participant) {
return;
}
$this->pushService->sendToParticipantGuardians(
$participant,
'تأكيد الدفع',
"تم تأكيد دفع {$amount} ج.م بنجاح",
[
'type' => 'payment',
'participant_uuid' => $participant->uuid,
'invoice_uuid' => $invoice->uuid,
'amount' => $payment->amount,
],
'payment.confirmed'
);
}
}
}
<?php
namespace App\Domain\Notification\Listeners;
use App\Domain\Notification\Services\PushNotificationService;
use App\Domain\Training\Events\SessionCancelled;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendSessionCancelledPush implements ShouldQueue
{
public function __construct(private PushNotificationService $pushService) {}
public function handle(SessionCancelled $event): void
{
$session = $event->session;
$group = $session->group;
if (!$group) {
return;
}
$groupName = $group->name_ar ?? 'المجموعة';
$date = $session->session_date?->format('Y-m-d') ?? '';
$this->pushService->sendToGroupParticipants(
$group,
'إلغاء جلسة',
"تم إلغاء جلسة {$groupName} يوم {$date}",
[
'type' => 'session_cancelled',
'session_id' => $session->id,
'group_id' => $group->id,
'date' => $date,
],
'session.cancelled'
);
}
}
......@@ -15,6 +15,7 @@ class NotificationPreference extends Model
'channel_email',
'channel_sms',
'channel_in_app',
'channel_push',
'digest_mode',
'digest_time',
];
......@@ -23,6 +24,7 @@ class NotificationPreference extends Model
'channel_email' => 'boolean',
'channel_sms' => 'boolean',
'channel_in_app' => 'boolean',
'channel_push' => 'boolean',
'digest_mode' => 'boolean',
];
......
<?php
namespace App\Domain\Notification\Services;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Notification\Enums\NotificationChannel;
use App\Domain\Notification\Enums\NotificationStatus;
use App\Domain\Notification\Models\NotificationLog;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Models\DeviceToken;
use App\Domain\Shared\Models\SystemSetting;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Kreait\Firebase\Factory;
use Kreait\Firebase\Messaging\CloudMessage;
use Kreait\Firebase\Messaging\Notification;
class PushNotificationService
{
private ?\Kreait\Firebase\Contract\Messaging $messaging = null;
private bool $initialized = false;
public function sendToUser(User $user, string $title, string $body, array $data = [], ?string $eventType = null): void
{
$tokens = DeviceToken::withoutGlobalScope('academy')
->where('user_id', $user->id)
->where('is_active', true)
->pluck('device_token')
->toArray();
if (empty($tokens)) {
return;
}
$this->sendToTokens($tokens, $title, $body, $data, $user->academy_id, $user->id, $eventType);
}
public function sendToParticipantGuardians(Participant $participant, string $title, string $body, array $data = [], ?string $eventType = null): void
{
$userIds = $this->getGuardianUserIds($participant);
if ($userIds->isEmpty()) {
return;
}
$tokens = DeviceToken::withoutGlobalScope('academy')
->whereIn('user_id', $userIds)
->where('is_active', true)
->pluck('device_token')
->toArray();
if (empty($tokens)) {
return;
}
$this->sendToTokens($tokens, $title, $body, $data, $participant->academy_id, $userIds->first(), $eventType);
}
public function sendToUsers(Collection $userIds, string $title, string $body, array $data = [], ?int $academyId = null, ?string $eventType = null): void
{
$tokens = DeviceToken::withoutGlobalScope('academy')
->whereIn('user_id', $userIds)
->where('is_active', true)
->pluck('device_token')
->toArray();
if (empty($tokens)) {
return;
}
$this->sendToTokens($tokens, $title, $body, $data, $academyId, $userIds->first(), $eventType);
}
public function sendToGroupParticipants(\App\Domain\Training\Models\TrainingGroup $group, string $title, string $body, array $data = [], ?string $eventType = null): void
{
$participantIds = $group->enrollments()
->where('status', 'active')
->pluck('participant_id');
$participants = Participant::whereIn('id', $participantIds)->get();
$allUserIds = collect();
foreach ($participants as $participant) {
$allUserIds = $allUserIds->merge($this->getGuardianUserIds($participant));
}
$allUserIds = $allUserIds->unique();
if ($allUserIds->isEmpty()) {
return;
}
$tokens = DeviceToken::withoutGlobalScope('academy')
->whereIn('user_id', $allUserIds)
->where('is_active', true)
->pluck('device_token')
->toArray();
if (empty($tokens)) {
return;
}
$this->sendToTokens($tokens, $title, $body, $data, $group->academy_id, null, $eventType);
}
private function sendToTokens(array $tokens, string $title, string $body, array $data, ?int $academyId, ?int $recipientUserId, ?string $eventType): void
{
$messaging = $this->getMessaging();
if (!$messaging) {
Log::info('[Push] Firebase not configured — skipping push notification', [
'title' => $title,
'tokens_count' => count($tokens),
]);
return;
}
$notification = Notification::create($title, $body);
$message = CloudMessage::new()
->withNotification($notification)
->withData(array_merge($data, ['click_action' => 'FLUTTER_NOTIFICATION_CLICK']));
try {
$report = $messaging->sendMulticast($message, $tokens);
$successCount = $report->successes()->count();
$failureCount = $report->failures()->count();
// Deactivate invalid tokens
if ($failureCount > 0) {
$invalidTokens = [];
foreach ($report->failures()->getItems() as $failure) {
$error = $failure->error();
if ($error && in_array($error->value, ['UNREGISTERED', 'INVALID_ARGUMENT'])) {
$invalidTokens[] = $failure->target()->value();
}
}
if (!empty($invalidTokens)) {
DeviceToken::withoutGlobalScope('academy')
->whereIn('device_token', $invalidTokens)
->update(['is_active' => false]);
}
}
// Log the notification
if ($academyId && $recipientUserId) {
NotificationLog::create([
'academy_id' => $academyId,
'recipient_type' => 'user',
'recipient_id' => $recipientUserId,
'event_type' => $eventType ?? 'push_notification',
'channel' => NotificationChannel::Push->value,
'subject' => $title,
'body' => $body,
'status' => $successCount > 0 ? NotificationStatus::Sent->value : NotificationStatus::Failed->value,
'error_message' => $failureCount > 0 ? "{$failureCount} failed out of " . count($tokens) : null,
'sent_at' => $successCount > 0 ? now() : null,
'metadata' => [
'tokens_sent' => count($tokens),
'success_count' => $successCount,
'failure_count' => $failureCount,
'data' => $data,
],
]);
}
Log::info('[Push] Sent', [
'title' => $title,
'success' => $successCount,
'failed' => $failureCount,
]);
} catch (\Throwable $e) {
Log::error('[Push] Failed to send', [
'title' => $title,
'error' => $e->getMessage(),
]);
if ($academyId && $recipientUserId) {
NotificationLog::create([
'academy_id' => $academyId,
'recipient_type' => 'user',
'recipient_id' => $recipientUserId,
'event_type' => $eventType ?? 'push_notification',
'channel' => NotificationChannel::Push->value,
'subject' => $title,
'body' => $body,
'status' => NotificationStatus::Failed->value,
'error_message' => $e->getMessage(),
'metadata' => ['data' => $data],
]);
}
}
}
private function getGuardianUserIds(Participant $participant): Collection
{
$guardians = $participant->guardians()->with('user')->get();
return $guardians
->map(fn ($g) => $g->user?->id)
->filter()
->values();
}
private function getMessaging(): ?\Kreait\Firebase\Contract\Messaging
{
if ($this->initialized) {
return $this->messaging;
}
$this->initialized = true;
try {
$credentials = $this->resolveCredentials();
if (!$credentials) {
return null;
}
$factory = (new Factory)->withServiceAccount($credentials);
$this->messaging = $factory->createMessaging();
return $this->messaging;
} catch (\Throwable $e) {
Log::warning('[Push] Failed to initialize Firebase', ['error' => $e->getMessage()]);
return null;
}
}
private function resolveCredentials(): ?array
{
// 1. System settings (per-academy JSON)
$json = SystemSetting::get('firebase_service_account_json');
if ($json) {
$decoded = is_string($json) ? json_decode($json, true) : $json;
if (is_array($decoded) && isset($decoded['project_id'])) {
return $decoded;
}
}
// 2. File in storage
$filePath = storage_path('app/firebase/service-account.json');
if (file_exists($filePath)) {
$decoded = json_decode(file_get_contents($filePath), true);
if (is_array($decoded) && isset($decoded['project_id'])) {
return $decoded;
}
}
// 3. Environment variable (path to file)
$envPath = env('FIREBASE_CREDENTIALS');
if ($envPath && file_exists($envPath)) {
$decoded = json_decode(file_get_contents($envPath), true);
if (is_array($decoded) && isset($decoded['project_id'])) {
return $decoded;
}
}
return null;
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Notification\Enums\NotificationChannel;
use App\Domain\Notification\Enums\NotificationStatus;
use App\Domain\Notification\Models\NotificationLog;
use App\Domain\Notification\Models\NotificationPreference;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(Request $request): JsonResponse
{
$user = $request->user();
$notifications = NotificationLog::where('recipient_type', 'user')
->where('recipient_id', $user->id)
->whereIn('channel', [
NotificationChannel::InApp->value,
NotificationChannel::Push->value,
])
->orderByDesc('created_at')
->paginate(20);
return response()->json([
'data' => $notifications->map(fn ($n) => [
'id' => $n->id,
'event_type' => $n->event_type,
'title' => $n->subject,
'body' => $n->body,
'is_read' => $n->read_at !== null,
'read_at' => $n->read_at?->toIso8601String(),
'sent_at' => $n->sent_at?->toIso8601String(),
'created_at' => $n->created_at?->toIso8601String(),
'metadata' => $n->metadata,
]),
'unread_count' => NotificationLog::where('recipient_type', 'user')
->where('recipient_id', $user->id)
->whereIn('channel', [
NotificationChannel::InApp->value,
NotificationChannel::Push->value,
])
->where('status', NotificationStatus::Sent)
->whereNull('read_at')
->count(),
'meta' => [
'current_page' => $notifications->currentPage(),
'last_page' => $notifications->lastPage(),
'per_page' => $notifications->perPage(),
'total' => $notifications->total(),
],
]);
}
public function markAsRead(int $id, Request $request): JsonResponse
{
$user = $request->user();
$notification = NotificationLog::where('id', $id)
->where('recipient_type', 'user')
->where('recipient_id', $user->id)
->firstOrFail();
if (!$notification->read_at) {
$notification->update(['read_at' => now()]);
}
return response()->json(['success' => true]);
}
public function markAllAsRead(Request $request): JsonResponse
{
$user = $request->user();
NotificationLog::where('recipient_type', 'user')
->where('recipient_id', $user->id)
->whereIn('channel', [
NotificationChannel::InApp->value,
NotificationChannel::Push->value,
])
->whereNull('read_at')
->update(['read_at' => now()]);
return response()->json(['success' => true]);
}
public function getPreferences(Request $request): JsonResponse
{
$user = $request->user();
$prefs = NotificationPreference::where('user_id', $user->id)->get();
return response()->json([
'data' => $prefs->map(fn ($p) => [
'event_type' => $p->event_type,
'channel_email' => (bool) $p->channel_email,
'channel_sms' => (bool) $p->channel_sms,
'channel_push' => (bool) ($p->channel_push ?? true),
'digest_mode' => (bool) $p->digest_mode,
]),
]);
}
public function updatePreferences(Request $request): JsonResponse
{
$request->validate([
'preferences' => 'required|array',
'preferences.*.event_type' => 'required|string',
'preferences.*.channel_push' => 'boolean',
'preferences.*.channel_email' => 'boolean',
'preferences.*.channel_sms' => 'boolean',
]);
$user = $request->user();
foreach ($request->preferences as $pref) {
NotificationPreference::updateOrCreate(
['user_id' => $user->id, 'event_type' => $pref['event_type']],
[
'channel_email' => $pref['channel_email'] ?? true,
'channel_sms' => $pref['channel_sms'] ?? true,
'channel_push' => $pref['channel_push'] ?? true,
]
);
}
return response()->json(['success' => true]);
}
}
......@@ -10,6 +10,7 @@ class EventServiceProvider extends ServiceProvider
// Financial Events
\App\Domain\Financial\Events\InvoiceCreated::class => [
\App\Domain\Financial\Listeners\SendInvoiceNotification::class,
\App\Domain\Notification\Listeners\SendInvoiceCreatedPush::class,
],
\App\Domain\Financial\Events\InvoicePaid::class => [
\App\Domain\Financial\Listeners\ActivateEnrollmentOnPayment::class,
......@@ -30,6 +31,7 @@ class EventServiceProvider extends ServiceProvider
\App\Domain\Financial\Listeners\SendPaymentConfirmation::class,
\App\Domain\Financial\Listeners\SendPaymentNotification::class,
\App\Domain\Financial\Listeners\UpdateCashSessionTotals::class,
\App\Domain\Notification\Listeners\SendPaymentPush::class,
],
\App\Domain\Financial\Events\PaymentPlanDefaulted::class => [
\App\Domain\Financial\Listeners\SendPaymentPlanDefaultedNotification::class,
......@@ -48,6 +50,7 @@ class EventServiceProvider extends ServiceProvider
\App\Domain\Training\Events\EnrollmentCreated::class => [
\App\Domain\Training\Listeners\UpdateGroupCount::class,
\App\Domain\Training\Listeners\GenerateAttendanceRecords::class,
\App\Domain\Notification\Listeners\SendEnrollmentCreatedPush::class,
],
\App\Domain\Training\Events\EnrollmentCancelled::class => [
\App\Domain\Training\Listeners\UpdateGroupCount::class,
......@@ -67,6 +70,7 @@ class EventServiceProvider extends ServiceProvider
\App\Domain\Training\Listeners\NotifySessionCancellation::class,
\App\Domain\Training\Listeners\CancelLinkedReservation::class,
\App\Domain\HR\Listeners\HandleSessionCancelled::class,
\App\Domain\Notification\Listeners\SendSessionCancelledPush::class,
],
\App\Domain\Training\Events\SessionCompleted::class => [
\App\Domain\Training\Listeners\SendSessionCompletedNotification::class,
......@@ -82,11 +86,13 @@ class EventServiceProvider extends ServiceProvider
],
\App\Domain\Training\Events\EvaluationShared::class => [
\App\Domain\Training\Listeners\SendEvaluationSharedNotification::class,
\App\Domain\Notification\Listeners\SendEvaluationSharedPush::class,
],
// Attendance Events
\App\Domain\Attendance\Events\AttendanceMarked::class => [
\App\Domain\HR\Listeners\GenerateTrainerCompensation::class,
\App\Domain\Notification\Listeners\SendAttendancePush::class,
],
\App\Domain\Attendance\Events\ParticipantAbsent::class => [
\App\Domain\Attendance\Listeners\NotifyGuardianOfAbsence::class,
......
......@@ -7,6 +7,7 @@
"license": "MIT",
"require": {
"php": "^8.4",
"kreait/firebase-php": "^8.3",
"laravel/framework": "^13.8",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0",
......
This diff is collapsed.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasColumn('notification_preferences', 'channel_push')) {
Schema::table('notification_preferences', function (Blueprint $table) {
$table->boolean('channel_push')->default(true)->after('channel_sms');
});
}
}
public function down(): void
{
Schema::table('notification_preferences', function (Blueprint $table) {
$table->dropColumn('channel_push');
});
}
};
......@@ -3,6 +3,7 @@
use App\Http\Controllers\Api\V1\AppConfigController;
use App\Http\Controllers\Api\V1\AuthOtpController;
use App\Http\Controllers\Api\V1\DeviceController;
use App\Http\Controllers\Api\V1\NotificationController;
use App\Http\Controllers\Api\V1\ParticipantController;
use Illuminate\Support\Facades\Route;
......@@ -39,5 +40,12 @@
Route::get('invoices', [ParticipantController::class, 'invoices']);
Route::get('enrollments', [ParticipantController::class, 'enrollments']);
});
// Notifications
Route::get('notifications', [NotificationController::class, 'index']);
Route::patch('notifications/{id}/read', [NotificationController::class, 'markAsRead']);
Route::post('notifications/read-all', [NotificationController::class, 'markAllAsRead']);
Route::get('notifications/preferences', [NotificationController::class, 'getPreferences']);
Route::post('notifications/preferences', [NotificationController::class, 'updatePreferences']);
});
});
......@@ -25,3 +25,8 @@
Schedule::command('reports:parent-weekly')->weeklyOn(6, '12:00');
Schedule::command('enrollments:generate-renewals')->dailyAt('07:00');
Schedule::command('groups:alert-capacity --threshold=90')->dailyAt('08:00');
// Push notifications
Schedule::command('push:session-reminder --minutes=30')->everyMinute();
Schedule::command('push:installment-due --days=1')->dailyAt('09:00');
Schedule::command('push:installment-due --days=3')->dailyAt('09:30');
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment