Commit 8e5171fa authored by Mahmoud Aglan's avatar Mahmoud Aglan

Mobile API Phase 9: Full push notification coverage + broadcast + analytics

- 5 new push listeners: invoice overdue, installment overdue, attendance threshold, waitlist spot, service request resolved
- Deep-link route field added to all push payloads for Flutter navigation
- Announcement broadcast system: send push to all/group/branch/program parents
- Push analytics tracking: delivered/opened/dismissed events from client
- Badge count endpoint for silent background refresh
- Device heartbeat for activity tracking
- Scheduled announcement sender command (runs every minute)
- ServiceRequestResolved event + push notification
- 59 total API routes, 24 tests passing
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 11ad998e
<?php
namespace App\Console\Commands;
use App\Domain\Notification\Models\PushAnnouncement;
use App\Domain\Notification\Services\AnnouncementBroadcastService;
use Illuminate\Console\Command;
class SendScheduledAnnouncements extends Command
{
protected $signature = 'push:send-scheduled';
protected $description = 'Send push announcements that are scheduled and ready';
public function handle(AnnouncementBroadcastService $broadcastService): int
{
$announcements = PushAnnouncement::scheduledAndReady()->get();
if ($announcements->isEmpty()) {
$this->info('No scheduled announcements ready to send.');
return self::SUCCESS;
}
$count = 0;
foreach ($announcements as $announcement) {
try {
$broadcastService->send($announcement);
$count++;
$this->info("Sent: {$announcement->uuid} ({$announcement->sent_count} devices)");
} catch (\Throwable $e) {
$this->error("Failed: {$announcement->uuid}{$e->getMessage()}");
}
}
$this->info("Completed. Sent {$count}/{$announcements->count()} announcements.");
return self::SUCCESS;
}
}
......@@ -41,7 +41,12 @@ public function handle(AttendanceMarked $event): void
$participant,
$title,
$body,
['type' => 'attendance', 'participant_uuid' => $participant->uuid, 'status' => $status],
[
'type' => 'attendance',
'route' => '/participants/{participant_uuid}/attendance',
'participant_uuid' => $participant->uuid,
'status' => $status,
],
'attendance.marked'
);
}
......
<?php
namespace App\Domain\Notification\Listeners;
use App\Domain\Attendance\Events\AttendanceThresholdBreached;
use App\Domain\Notification\Services\PushNotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendAttendanceThresholdPush implements ShouldQueue
{
public function __construct(private PushNotificationService $pushService) {}
public function handle(AttendanceThresholdBreached $event): void
{
$participant = $event->participant;
$rate = round($event->rate, 1);
$threshold = round($event->threshold, 1);
$name = $participant->person?->name_ar ?? 'المشترك';
$this->pushService->sendToParticipantGuardians(
$participant,
'تنبيه نسبة الحضور',
"نسبة حضور {$name} ({$rate}%) أقل من الحد الأدنى ({$threshold}%)",
[
'type' => 'attendance_threshold',
'route' => '/participants/{participant_uuid}/attendance',
'participant_uuid' => $participant->uuid,
'rate' => $rate,
'threshold' => $threshold,
],
'attendance.threshold_breached'
);
}
}
......@@ -30,8 +30,9 @@ public function handle(EnrollmentCreated $event): void
"تم تسجيل {$name} في {$programName}",
[
'type' => 'enrollment',
'route' => '/participants/{participant_uuid}/enrollments',
'participant_uuid' => $participant->uuid,
'enrollment_id' => $enrollment->id,
'enrollment_id' => (string) $enrollment->id,
],
'enrollment.created'
);
......
......@@ -28,8 +28,9 @@ public function handle(EvaluationShared $event): void
"تم مشاركة تقييم جديد لـ {$name}",
[
'type' => 'evaluation',
'route' => '/participants/{participant_uuid}/evaluations/{evaluation_id}',
'participant_uuid' => $participant->uuid,
'evaluation_id' => $evaluation->id,
'evaluation_id' => (string) $evaluation->id,
],
'evaluation.shared'
);
......
<?php
namespace App\Domain\Notification\Listeners;
use App\Domain\Financial\Events\InstallmentOverdue;
use App\Domain\Notification\Services\PushNotificationService;
use App\Domain\Participant\Models\Participant;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendInstallmentOverduePush implements ShouldQueue
{
public function __construct(private PushNotificationService $pushService) {}
public function handle(InstallmentOverdue $event): void
{
$installment = $event->installment;
$invoice = $installment->paymentPlan?->invoice;
if (!$invoice) {
return;
}
if ($invoice->billable_type !== 'App\\Domain\\Participant\\Models\\Participant') {
return;
}
$participant = Participant::find($invoice->billable_id);
if (!$participant) {
return;
}
$amount = number_format($installment->amount / 100, 2);
$this->pushService->sendToParticipantGuardians(
$participant,
'قسط متأخر',
"قسط بقيمة {$amount} ج.م تجاوز تاريخ الاستحقاق",
[
'type' => 'installment_overdue',
'route' => '/participants/{participant_uuid}/installments',
'participant_uuid' => $participant->uuid,
'invoice_uuid' => $invoice->uuid,
'amount' => $installment->amount,
],
'installment.overdue'
);
}
}
......@@ -32,9 +32,10 @@ public function handle(InvoiceCreated $event): void
"فاتورة بقيمة {$amount} ج.م بانتظار الدفع",
[
'type' => 'invoice',
'route' => '/participants/{participant_uuid}/invoices/{invoice_uuid}',
'participant_uuid' => $participant->uuid,
'invoice_uuid' => $invoice->uuid,
'amount' => $invoice->total_amount,
'amount' => (string) $invoice->total_amount,
],
'invoice.created'
);
......
<?php
namespace App\Domain\Notification\Listeners;
use App\Domain\Financial\Events\InvoiceOverdue;
use App\Domain\Notification\Services\PushNotificationService;
use App\Domain\Participant\Models\Participant;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendInvoiceOverduePush implements ShouldQueue
{
public function __construct(private PushNotificationService $pushService) {}
public function handle(InvoiceOverdue $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->due_amount / 100, 2);
$this->pushService->sendToParticipantGuardians(
$participant,
'فاتورة متأخرة',
"فاتورة بقيمة {$amount} ج.م تجاوزت تاريخ الاستحقاق",
[
'type' => 'invoice_overdue',
'route' => '/participants/{participant_uuid}/invoices/{invoice_uuid}',
'participant_uuid' => $participant->uuid,
'invoice_uuid' => $invoice->uuid,
'amount' => $invoice->due_amount,
],
'invoice.overdue'
);
}
}
......@@ -34,9 +34,10 @@ public function handle(PaymentReceived $event): void
"تم تأكيد دفع {$amount} ج.م بنجاح",
[
'type' => 'payment',
'route' => '/participants/{participant_uuid}/invoices/{invoice_uuid}',
'participant_uuid' => $participant->uuid,
'invoice_uuid' => $invoice->uuid,
'amount' => $payment->amount,
'amount' => (string) $payment->amount,
],
'payment.confirmed'
);
......
<?php
namespace App\Domain\Notification\Listeners;
use App\Domain\Notification\Services\PushNotificationService;
use App\Domain\Shared\Events\ServiceRequestResolved;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendServiceRequestResolvedPush implements ShouldQueue
{
public function __construct(private PushNotificationService $pushService) {}
public function handle(ServiceRequestResolved $event): void
{
$request = $event->serviceRequest;
$user = User::find($request->user_id);
if (!$user) {
return;
}
$typeLabels = [
'freeze' => 'تجميد',
'unfreeze' => 'إلغاء تجميد',
'transfer' => 'نقل',
'cancellation' => 'إلغاء اشتراك',
'other' => 'طلب',
];
$typeLabel = $typeLabels[$request->type] ?? 'طلب';
$statusLabel = $request->status === 'approved' ? 'تمت الموافقة' : 'تم الرفض';
$this->pushService->sendToUser(
$user,
"نتيجة طلب {$typeLabel}",
"{$statusLabel} على طلب {$typeLabel} الخاص بك",
[
'type' => 'service_request_resolved',
'route' => '/service-requests',
'request_uuid' => $request->uuid,
'request_type' => $request->type,
'request_status' => $request->status,
],
'service_request.resolved'
);
}
}
......@@ -28,8 +28,9 @@ public function handle(SessionCancelled $event): void
"تم إلغاء جلسة {$groupName} يوم {$date}",
[
'type' => 'session_cancelled',
'session_id' => $session->id,
'group_id' => $group->id,
'route' => '/participants/{participant_uuid}/schedule',
'session_id' => (string) $session->id,
'group_id' => (string) $group->id,
'date' => $date,
],
'session.cancelled'
......
<?php
namespace App\Domain\Notification\Listeners;
use App\Domain\Notification\Services\PushNotificationService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Events\WaitlistSpotAvailable;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendWaitlistSpotPush implements ShouldQueue
{
public function __construct(private PushNotificationService $pushService) {}
public function handle(WaitlistSpotAvailable $event): void
{
$waitlist = $event->waitlist;
$group = $event->group;
$participant = Participant::find($waitlist->participant_id);
if (!$participant) {
return;
}
$groupName = $group->name_ar ?? 'المجموعة';
$this->pushService->sendToParticipantGuardians(
$participant,
'مكان متاح!',
"تم توفر مكان في {$groupName} — يمكنك الآن إتمام التسجيل",
[
'type' => 'waitlist_spot',
'route' => '/participants/{participant_uuid}/enrollments',
'participant_uuid' => $participant->uuid,
'group_id' => $group->id,
],
'waitlist.spot_available'
);
}
}
<?php
namespace App\Domain\Notification\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PushAnalytic extends Model
{
public $timestamps = false;
protected $fillable = [
'academy_id',
'user_id',
'notification_id',
'event_type',
'action',
'metadata',
'created_at',
];
protected $casts = [
'metadata' => 'array',
'created_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
<?php
namespace App\Domain\Notification\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PushAnnouncement extends Model
{
use BelongsToAcademy, HasUuid;
protected $fillable = [
'academy_id',
'title',
'body',
'target_type',
'target_ids',
'push_data',
'status',
'sent_count',
'failed_count',
'scheduled_at',
'sent_at',
'created_by',
];
protected $casts = [
'target_ids' => 'array',
'push_data' => 'array',
'scheduled_at' => 'datetime',
'sent_at' => 'datetime',
];
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function scopePending($query)
{
return $query->where('status', 'pending');
}
public function scopeScheduledAndReady($query)
{
return $query->where('status', 'scheduled')
->where('scheduled_at', '<=', now());
}
}
<?php
namespace App\Domain\Notification\Services;
use App\Domain\Notification\Models\PushAnnouncement;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Models\DeviceToken;
use App\Domain\Training\Models\TrainingGroup;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class AnnouncementBroadcastService
{
public function __construct(private PushNotificationService $pushService) {}
public function create(array $data, User $actor): PushAnnouncement
{
return DB::transaction(function () use ($data, $actor) {
return PushAnnouncement::create([
'academy_id' => $actor->academy_id,
'title' => $data['title'],
'body' => $data['body'],
'target_type' => $data['target_type'],
'target_ids' => $data['target_ids'] ?? [],
'push_data' => $data['push_data'] ?? [],
'status' => isset($data['scheduled_at']) ? 'scheduled' : 'pending',
'scheduled_at' => $data['scheduled_at'] ?? null,
'created_by' => $actor->id,
]);
});
}
public function send(PushAnnouncement $announcement): void
{
$announcement->update(['status' => 'sending']);
try {
$tokens = $this->resolveTargetTokens($announcement);
if ($tokens->isEmpty()) {
$announcement->update([
'status' => 'sent',
'sent_count' => 0,
'sent_at' => now(),
]);
return;
}
$chunks = $tokens->chunk(500);
$totalSent = 0;
$totalFailed = 0;
foreach ($chunks as $chunk) {
$result = $this->pushService->sendBroadcast(
$chunk->toArray(),
$announcement->title,
$announcement->body,
array_merge($announcement->push_data, [
'type' => 'announcement',
'announcement_uuid' => $announcement->uuid,
]),
$announcement->academy_id,
'announcement.broadcast'
);
$totalSent += $result['success'];
$totalFailed += $result['failed'];
}
$announcement->update([
'status' => 'sent',
'sent_count' => $totalSent,
'failed_count' => $totalFailed,
'sent_at' => now(),
]);
Log::info('[Broadcast] Sent announcement', [
'uuid' => $announcement->uuid,
'sent' => $totalSent,
'failed' => $totalFailed,
]);
} catch (\Throwable $e) {
$announcement->update(['status' => 'failed']);
Log::error('[Broadcast] Failed', ['uuid' => $announcement->uuid, 'error' => $e->getMessage()]);
throw $e;
}
}
public function sendImmediate(array $data, User $actor): PushAnnouncement
{
$announcement = $this->create($data, $actor);
$this->send($announcement);
return $announcement->refresh();
}
private function resolveTargetTokens(PushAnnouncement $announcement): Collection
{
$userIds = $this->resolveTargetUserIds($announcement);
if ($userIds->isEmpty()) {
return collect();
}
return DeviceToken::withoutGlobalScope('academy')
->where('academy_id', $announcement->academy_id)
->whereIn('user_id', $userIds)
->where('is_active', true)
->pluck('device_token');
}
private function resolveTargetUserIds(PushAnnouncement $announcement): Collection
{
return match ($announcement->target_type) {
'all' => $this->getAllParentUserIds($announcement->academy_id),
'group' => $this->getGroupParentUserIds($announcement->target_ids, $announcement->academy_id),
'branch' => $this->getBranchParentUserIds($announcement->target_ids, $announcement->academy_id),
'program' => $this->getProgramParentUserIds($announcement->target_ids, $announcement->academy_id),
default => collect(),
};
}
private function getAllParentUserIds(int $academyId): Collection
{
return DB::table('guardians')
->where('academy_id', $academyId)
->whereNotNull('user_id')
->pluck('user_id')
->unique();
}
private function getGroupParentUserIds(array $groupIds, int $academyId): Collection
{
$participantIds = DB::table('enrollments')
->where('academy_id', $academyId)
->whereIn('training_group_id', $groupIds)
->where('status', 'active')
->pluck('participant_id');
return $this->getUserIdsFromParticipants($participantIds, $academyId);
}
private function getBranchParentUserIds(array $branchIds, int $academyId): Collection
{
$participantIds = DB::table('participants')
->where('academy_id', $academyId)
->whereIn('branch_id', $branchIds)
->where('status', 'active')
->pluck('id');
return $this->getUserIdsFromParticipants($participantIds, $academyId);
}
private function getProgramParentUserIds(array $programIds, int $academyId): Collection
{
$groupIds = DB::table('training_groups')
->where('academy_id', $academyId)
->whereIn('training_program_id', $programIds)
->pluck('id');
return $this->getGroupParentUserIds($groupIds->toArray(), $academyId);
}
private function getUserIdsFromParticipants(Collection $participantIds, int $academyId): Collection
{
if ($participantIds->isEmpty()) {
return collect();
}
return DB::table('guardian_participant')
->join('guardians', 'guardians.id', '=', 'guardian_participant.guardian_id')
->whereIn('guardian_participant.participant_id', $participantIds)
->where('guardians.academy_id', $academyId)
->whereNotNull('guardians.user_id')
->pluck('guardians.user_id')
->unique();
}
}
......@@ -104,6 +104,71 @@ public function sendToGroupParticipants(\App\Domain\Training\Models\TrainingGrou
$this->sendToTokens($tokens, $title, $body, $data, $group->academy_id, null, $eventType);
}
public function sendBroadcast(array $tokens, string $title, string $body, array $data, ?int $academyId, ?string $eventType): array
{
$messaging = $this->getMessaging();
if (!$messaging) {
Log::info('[Push] Firebase not configured — skipping broadcast', [
'title' => $title,
'tokens_count' => count($tokens),
]);
return ['success' => 0, 'failed' => 0];
}
$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();
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]);
}
}
return ['success' => $successCount, 'failed' => $failureCount];
} catch (\Throwable $e) {
Log::error('[Push] Broadcast failed', ['title' => $title, 'error' => $e->getMessage()]);
return ['success' => 0, 'failed' => count($tokens)];
}
}
public function sendSilentData(array $tokens, array $data): void
{
$messaging = $this->getMessaging();
if (!$messaging || empty($tokens)) {
return;
}
$message = CloudMessage::new()
->withData(array_merge($data, ['content_available' => '1']));
try {
$messaging->sendMulticast($message, $tokens);
} catch (\Throwable $e) {
Log::warning('[Push] Silent data push failed', ['error' => $e->getMessage()]);
}
}
private function sendToTokens(array $tokens, string $title, string $body, array $data, ?int $academyId, ?int $recipientUserId, ?string $eventType): void
{
$messaging = $this->getMessaging();
......
<?php
namespace App\Domain\Shared\Events;
use App\Domain\Shared\Models\ServiceRequest;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ServiceRequestResolved implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public ServiceRequest $serviceRequest,
public User $actor,
) {}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Notification\Models\PushAnnouncement;
use App\Domain\Notification\Services\AnnouncementBroadcastService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BroadcastController extends Controller
{
public function __construct(private AnnouncementBroadcastService $broadcastService) {}
public function send(Request $request): JsonResponse
{
$request->validate([
'title' => 'required|string|max:200',
'body' => 'required|string|max:1000',
'target_type' => 'required|in:all,group,branch,program',
'target_ids' => 'required_unless:target_type,all|array',
'target_ids.*' => 'integer',
'push_data' => 'nullable|array',
'scheduled_at' => 'nullable|date|after:now',
]);
$user = $request->user();
$data = $request->only(['title', 'body', 'target_type', 'target_ids', 'push_data', 'scheduled_at']);
if ($request->scheduled_at) {
$announcement = $this->broadcastService->create($data, $user);
return response()->json([
'success' => true,
'message' => 'تم جدولة الإشعار بنجاح',
'announcement' => [
'uuid' => $announcement->uuid,
'status' => $announcement->status,
'scheduled_at' => $announcement->scheduled_at->toIso8601String(),
],
], 201);
}
$announcement = $this->broadcastService->sendImmediate($data, $user);
return response()->json([
'success' => true,
'message' => 'تم إرسال الإشعار بنجاح',
'announcement' => [
'uuid' => $announcement->uuid,
'status' => $announcement->status,
'sent_count' => $announcement->sent_count,
'failed_count' => $announcement->failed_count,
'sent_at' => $announcement->sent_at?->toIso8601String(),
],
]);
}
public function index(Request $request): JsonResponse
{
$announcements = PushAnnouncement::where('academy_id', $request->user()->academy_id)
->orderByDesc('created_at')
->paginate(20);
return response()->json([
'data' => $announcements->map(fn ($a) => [
'uuid' => $a->uuid,
'title' => $a->title,
'body' => $a->body,
'target_type' => $a->target_type,
'status' => $a->status,
'sent_count' => $a->sent_count,
'failed_count' => $a->failed_count,
'scheduled_at' => $a->scheduled_at?->toIso8601String(),
'sent_at' => $a->sent_at?->toIso8601String(),
'created_at' => $a->created_at->toIso8601String(),
]),
'meta' => [
'current_page' => $announcements->currentPage(),
'last_page' => $announcements->lastPage(),
'total' => $announcements->total(),
],
]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class DeepLinkController extends Controller
{
public function routes(): JsonResponse
{
return response()->json([
'routes' => [
'enrollment' => '/participants/{participant_uuid}/enrollments',
'invoice' => '/participants/{participant_uuid}/invoices/{invoice_uuid}',
'invoice_overdue' => '/participants/{participant_uuid}/invoices/{invoice_uuid}',
'payment' => '/participants/{participant_uuid}/invoices/{invoice_uuid}',
'attendance' => '/participants/{participant_uuid}/attendance',
'attendance_threshold' => '/participants/{participant_uuid}/attendance',
'evaluation' => '/participants/{participant_uuid}/evaluations/{evaluation_id}',
'session_cancelled' => '/participants/{participant_uuid}/schedule',
'session_reminder' => '/participants/{participant_uuid}/schedule',
'installment_due' => '/participants/{participant_uuid}/installments',
'installment_overdue' => '/participants/{participant_uuid}/installments',
'waitlist_spot' => '/participants/{participant_uuid}/enrollments',
'service_request_resolved' => '/service-requests',
'announcement' => '/notifications',
'message' => '/messages',
],
]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Notification\Models\PushAnalytic;
use App\Domain\Shared\Models\DeviceToken;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PushAnalyticsController extends Controller
{
public function track(Request $request): JsonResponse
{
$request->validate([
'events' => 'required|array|min:1|max:50',
'events.*.notification_id' => 'nullable|string|max:100',
'events.*.event_type' => 'required|string|max:50',
'events.*.action' => 'required|in:delivered,opened,dismissed,action_clicked',
'events.*.metadata' => 'nullable|array',
]);
$user = $request->user();
$now = now();
$records = array_map(fn ($event) => [
'academy_id' => $user->academy_id,
'user_id' => $user->id,
'notification_id' => $event['notification_id'] ?? null,
'event_type' => $event['event_type'],
'action' => $event['action'],
'metadata' => json_encode($event['metadata'] ?? []),
'created_at' => $now,
], $request->events);
PushAnalytic::insert($records);
return response()->json(['tracked' => count($records)]);
}
public function badge(Request $request): JsonResponse
{
$user = $request->user();
$unread = \App\Domain\Notification\Models\NotificationLog::where('recipient_type', 'user')
->where('recipient_id', $user->id)
->whereIn('channel', ['in_app', 'push'])
->whereNull('read_at')
->where('status', 'sent')
->count();
return response()->json([
'badge_count' => $unread,
]);
}
public function heartbeat(Request $request): JsonResponse
{
$request->validate([
'token' => 'required|string|max:255',
]);
DeviceToken::withoutGlobalScope('academy')
->where('device_token', $request->token)
->where('user_id', $request->user()->id)
->update(['last_used_at' => now()]);
return response()->json(['ok' => true]);
}
}
......@@ -17,6 +17,7 @@ class EventServiceProvider extends ServiceProvider
],
\App\Domain\Financial\Events\InvoiceOverdue::class => [
\App\Domain\Financial\Listeners\SendOverdueReminder::class,
\App\Domain\Notification\Listeners\SendInvoiceOverduePush::class,
],
\App\Domain\Financial\Events\InvoiceSent::class => [
\App\Domain\Financial\Listeners\SendInvoiceSentNotification::class,
......@@ -26,6 +27,7 @@ class EventServiceProvider extends ServiceProvider
],
\App\Domain\Financial\Events\InstallmentOverdue::class => [
\App\Domain\Financial\Listeners\NotifyInstallmentDue::class,
\App\Domain\Notification\Listeners\SendInstallmentOverduePush::class,
],
\App\Domain\Financial\Events\PaymentReceived::class => [
\App\Domain\Financial\Listeners\SendPaymentConfirmation::class,
......@@ -77,6 +79,7 @@ class EventServiceProvider extends ServiceProvider
],
\App\Domain\Training\Events\WaitlistSpotAvailable::class => [
\App\Domain\Training\Listeners\NotifyWaitlistSpot::class,
\App\Domain\Notification\Listeners\SendWaitlistSpotPush::class,
],
\App\Domain\Training\Events\ParticipantRegistered::class => [
\App\Domain\Training\Listeners\SendParticipantRegisteredNotification::class,
......@@ -100,6 +103,7 @@ class EventServiceProvider extends ServiceProvider
\App\Domain\Attendance\Events\AttendanceThresholdBreached::class => [
\App\Domain\Attendance\Listeners\NotifyThresholdBreach::class,
\App\Domain\Attendance\Listeners\SuspendOnThreshold::class,
\App\Domain\Notification\Listeners\SendAttendanceThresholdPush::class,
],
// Facility Events
......@@ -141,6 +145,11 @@ class EventServiceProvider extends ServiceProvider
\App\Domain\Participant\Listeners\SendParticipantFrozenNotification::class,
],
// Service Request Events
\App\Domain\Shared\Events\ServiceRequestResolved::class => [
\App\Domain\Notification\Listeners\SendServiceRequestResolvedPush::class,
],
// POS Events
\App\Domain\POS\Events\POSTransactionCompleted::class => [
\App\Domain\POS\Listeners\SendPOSReceiptNotification::class,
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('push_announcements', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies');
$table->uuid('uuid')->unique();
$table->string('title', 200);
$table->text('body');
$table->string('target_type', 30);
$table->jsonb('target_ids')->default('[]');
$table->jsonb('push_data')->default('{}');
$table->string('status', 20)->default('pending');
$table->integer('sent_count')->default(0);
$table->integer('failed_count')->default(0);
$table->timestamp('scheduled_at')->nullable();
$table->timestamp('sent_at')->nullable();
$table->foreignId('created_by')->constrained('users');
$table->timestamps();
$table->index(['academy_id', 'status']);
$table->index(['academy_id', 'scheduled_at']);
});
DB::statement("ALTER TABLE push_announcements ADD CONSTRAINT push_announcements_target_type_check CHECK (target_type IN ('all', 'group', 'branch', 'program'))");
DB::statement("ALTER TABLE push_announcements ADD CONSTRAINT push_announcements_status_check CHECK (status IN ('pending', 'scheduled', 'sending', 'sent', 'failed'))");
}
public function down(): void
{
Schema::dropIfExists('push_announcements');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('push_analytics', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('user_id')->constrained('users');
$table->string('notification_id', 100)->nullable();
$table->string('event_type', 50);
$table->string('action', 20);
$table->jsonb('metadata')->default('{}');
$table->timestamp('created_at');
$table->index(['academy_id', 'event_type', 'created_at']);
$table->index(['user_id', 'created_at']);
});
DB::statement("ALTER TABLE push_analytics ADD CONSTRAINT push_analytics_action_check CHECK (action IN ('delivered', 'opened', 'dismissed', 'action_clicked'))");
}
public function down(): void
{
Schema::dropIfExists('push_analytics');
}
};
......@@ -5,7 +5,9 @@
use App\Http\Controllers\Api\V1\AppConfigController;
use App\Http\Controllers\Api\V1\AuthOtpController;
use App\Http\Controllers\Api\V1\BranchController;
use App\Http\Controllers\Api\V1\BroadcastController;
use App\Http\Controllers\Api\V1\DashboardController;
use App\Http\Controllers\Api\V1\DeepLinkController;
use App\Http\Controllers\Api\V1\DeviceController;
use App\Http\Controllers\Api\V1\DocumentController;
use App\Http\Controllers\Api\V1\EvaluationController;
......@@ -17,6 +19,7 @@
use App\Http\Controllers\Api\V1\ParticipantController;
use App\Http\Controllers\Api\V1\PaymentController;
use App\Http\Controllers\Api\V1\ProfileController;
use App\Http\Controllers\Api\V1\PushAnalyticsController;
use App\Http\Controllers\Api\V1\ReceiptController;
use App\Http\Controllers\Api\V1\ServiceRequestController;
use App\Http\Controllers\Api\V1\ShopController;
......@@ -127,6 +130,18 @@
// Messages (guardian ↔ academy)
Route::post('messages/send', [MessageController::class, 'send']);
Route::get('messages', [MessageController::class, 'index']);
// Push analytics & badge
Route::post('push/track', [PushAnalyticsController::class, 'track']);
Route::get('push/badge', [PushAnalyticsController::class, 'badge']);
Route::post('push/heartbeat', [PushAnalyticsController::class, 'heartbeat']);
// Broadcast announcements (admin only)
Route::post('broadcast/send', [BroadcastController::class, 'send']);
Route::get('broadcast/history', [BroadcastController::class, 'index']);
// Deep link routing map
Route::get('deeplinks/routes', [DeepLinkController::class, 'routes']);
});
// Payment gateway webhook (NO auth — Paymob sends server-to-server)
......
......@@ -30,3 +30,4 @@
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');
Schedule::command('push:send-scheduled')->everyMinute();
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
class PushNotificationTest extends TestCase
{
public function test_push_badge_requires_auth(): void
{
$response = $this->getJson('/api/v1/push/badge');
$response->assertUnauthorized();
}
public function test_push_track_requires_auth(): void
{
$response = $this->postJson('/api/v1/push/track', [
'events' => [['event_type' => 'test', 'action' => 'opened']],
]);
$response->assertUnauthorized();
}
public function test_push_heartbeat_requires_auth(): void
{
$response = $this->postJson('/api/v1/push/heartbeat', ['token' => 'abc']);
$response->assertUnauthorized();
}
public function test_broadcast_send_requires_auth(): void
{
$response = $this->postJson('/api/v1/broadcast/send', []);
$response->assertUnauthorized();
}
public function test_broadcast_history_requires_auth(): void
{
$response = $this->getJson('/api/v1/broadcast/history');
$response->assertUnauthorized();
}
public function test_deeplinks_routes_requires_auth(): void
{
$response = $this->getJson('/api/v1/deeplinks/routes');
$response->assertUnauthorized();
}
public function test_push_track_validates_events_array(): void
{
// Without auth it's 401, so we just verify the endpoint exists
$response = $this->postJson('/api/v1/push/track', []);
$this->assertContains($response->status(), [401, 422]);
}
}
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