diff --git a/.env.example b/.env.example index 17819e9..f58b85e 100644 --- a/.env.example +++ b/.env.example @@ -32,9 +32,11 @@ SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ -SESSION_DOMAIN=null +SESSION_DOMAIN=guacpanel-official.test +SANCTUM_STATEFUL_DOMAINS=guacpanel-official.test,localhost,127.0.0.1 -BROADCAST_CONNECTION=log +BROADCAST_DRIVER=reverb +BROADCAST_CONNECTION=reverb FILESYSTEM_DISK=local QUEUE_CONNECTION=database @@ -91,6 +93,13 @@ APP_DEMO_ENABLED=false APP_DEMO_LOGIN_USERNAME=ota@example.com APP_DEMO_LOGIN_PASSWORD=password +APP_NOTIFICATIONS_ENABLED=true +APP_NOTIFICATIONS_IN_DEMO_MODE=true +APP_NOTIFICATIONS_AUTO_CLEANUP_DELETED_ENABLED=false +APP_NOTIFICATIONS_AUTO_CLEANUP_DELETED_DAYS=60 +APP_NOTIFICATIONS_AUTO_CLEANUP_SEND_EMAIL=true +APP_NOTIFICATIONS_AUTO_CLEANUP_SEND_EMAIL_TO="${APP_HELP_EMAIL}" + SEED_SUPER_ADMIN_USER_ENABLED=true SEED_SUPER_ADMIN_USER_NAME=Ota SEED_SUPER_ADMIN_USER_EMAIL=ota@example.com @@ -130,3 +139,20 @@ LINKEDIN_ENABLED=false LINKEDIN_CLIENT_ID=your-linkedin-client-id LINKEDIN_SECRET=your-linkedin-client-secret LINKEDIN_REDIRECT=${APP_URL}/auth/social/linkedin/callback + +REVERB_APP_ID= +REVERB_APP_KEY= +REVERB_APP_SECRET= +REVERB_HOST="localhost" +REVERB_PORT=8080 +REVERB_SCHEME=https +REVERB_SERVER_HOST=0.0.0.0 +REVERB_SERVER_PORT=8080 +REVERB_TLS_CERT= +REVERB_TLS_KEY= + +VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" +VITE_REVERB_HOST="${REVERB_HOST}" +VITE_REVERB_PORT="${REVERB_PORT}" +VITE_REVERB_SCHEME="${REVERB_SCHEME}" +VITE_APP_NOTIFICATIONS_ENABLED="${APP_NOTIFICATIONS_ENABLED}" diff --git a/README.md b/README.md index 8090fb3..eedc0f6 100644 --- a/README.md +++ b/README.md @@ -24,65 +24,69 @@ An opinionated Laravel starter kit built with Vue.js, Inertia.js, and Tailwind C - ## Features - 🔐 **Authentication & Security** - - Secure login with [Laravel Fortify](https://laravel.com/docs/fortify) - - Passwordless magic link authentication - - Social Authentication with [Laravel Socialite](https://laravel.com/docs/socialite) - - [Google](https://console.developers.google.com/) - - [GitHub](https://github.com/settings/applications/new) (Will work with local dev callback) - - [Facebook](https://developers.facebook.com/) (Will work with local dev callback) - - [LinkedIn](https://www.linkedin.com/developers/apps/) (Will work with local dev callback) - - Two-factor authentication (2FA) via [Laravel Fortify](https://laravel.com/docs/fortify#two-factor-authentication) - - Role-based permissions with [Spatie Permission](https://spatie.be/docs/laravel-permission) - - Visual role and permission management - - User role assignment interface - - Session and security management - - Active sessions overview - - Login history tracking - - Password policies enforcement + - Secure login with [Laravel Fortify](https://laravel.com/docs/fortify) + - Passwordless magic link authentication + - Social Authentication with [Laravel Socialite](https://laravel.com/docs/socialite) + - [Google](https://console.developers.google.com/) + - [GitHub](https://github.com/settings/applications/new) (Will work with local dev callback) + - [Facebook](https://developers.facebook.com/) (Will work with local dev callback) + - [LinkedIn](https://www.linkedin.com/developers/apps/) (Will work with local dev callback) + - Two-factor authentication (2FA) via [Laravel Fortify](https://laravel.com/docs/fortify#two-factor-authentication) + - Role-based permissions with [Spatie Permission](https://spatie.be/docs/laravel-permission) + - Visual role and permission management + - User role assignment interface + - Session and security management + - Active sessions overview + - Login history tracking + - Password policies enforcement - 🎨 **Interface & Design** - - Dark/Light mode with system preference detection - - Responsive design with [Tailwind CSS v4](https://tailwindcss.com/docs) - - Auto-generated avatars via [Laravel Avatar](https://github.com/laravolt/avatar) - - Local Google Fonts via [Spatie Laravel Google Fonts](https://github.com/spatie/laravel-google-fonts) - - Customizable theme settings + - Dark/Light mode with system preference detection + - Responsive design with [Tailwind CSS v4](https://tailwindcss.com/docs) + - Auto-generated avatars via [Laravel Avatar](https://github.com/laravolt/avatar) + - Local Google Fonts via [Spatie Laravel Google Fonts](https://github.com/spatie/laravel-google-fonts) + - Customizable theme settings - 📊 **Data Visualization** - - Interactive charts with [ApexCharts v3](https://apexcharts.com) - - Line, Area, Bar, and Donut charts - - Responsive and mobile-friendly - - Export capabilities - - Automatic data formatting - - Dynamic chart resizing + - Interactive charts with [ApexCharts v3](https://apexcharts.com) + - Line, Area, Bar, and Donut charts + - Responsive and mobile-friendly + - Export capabilities + - Automatic data formatting + - Dynamic chart resizing - 📊 **Data Tables** - - Modern tables with [@tanstack/vue-table v8](https://tanstack.com/table/v8/docs) - - Server-side pagination - - Column sorting - - Search functionality - - Data export with [Laravel Excel](https://docs.laravel-excel.com) - - Action buttons with confirmation dialogs + - Modern tables with [@tanstack/vue-table v8](https://tanstack.com/table/v8/docs) + - Server-side pagination + - Column sorting + - Search functionality + - Data export with [Laravel Excel](https://docs.laravel-excel.com) + - Action buttons with confirmation dialogs - 📁 **File Management** - - Drag & drop uploads with [FilePond v4](https://pqina.nl/filepond/docs/) - - Image preview - - File type validation - - Size restrictions - - Multiple file selection + - Drag & drop uploads with [FilePond v4](https://pqina.nl/filepond/docs/) + - Image preview + - File type validation + - Size restrictions + - Multiple file selection - 🔄 **System Features** - - Backup management via [Spatie Backup](https://spatie.be/docs/laravel-backup) - - User-friendly dashboard - - One-click backup creation - - Backup download - - Activity logging with [Laravel Auditing](https://laravel-auditing.com) - - User action tracking - - Data change history - - Security event monitoring + - Backup management via [Spatie Backup](https://spatie.be/docs/laravel-backup) + - User-friendly dashboard + - One-click backup creation + - Backup download + - Activity logging with [Laravel Auditing](https://laravel-auditing.com) + - User action tracking + - Data change history + - Security event monitoring + +- 🔔 **Real-time Notifications (Laravel Reverb)** + - Live, in-app notifications via WebSockets (Reverb) + - Demo mode toggle via `APP_NOTIFICATIONS_IN_DEMO_MODE` + - Run locally with `php artisan reverb:start` ## Quick Start @@ -95,24 +99,28 @@ An opinionated Laravel starter kit built with Vue.js, Inertia.js, and Tailwind C ### Installation 1. Clone the repository + ```bash git clone https://github.com/otatechie/guacpanel-tailwind.git cd guacpanel-tailwind ``` 2. Install dependencies + ```bash composer install npm install ``` 3. Set up environment + ```bash cp .env.example .env php artisan key:generate ``` 4. Configure your database in `.env` + ``` DB_CONNECTION=mysql DB_HOST=127.0.0.1 @@ -123,17 +131,59 @@ DB_PASSWORD=your_password ``` 5. Run migrations and seed + ```bash php artisan migrate php artisan db:seed ``` 6. Start development servers + ```bash npm run dev php artisan serve ``` +### Real-time Notifications (Laravel Reverb) + +GuacPanel ships with an optional real-time notifications system powered by **Laravel Reverb** (WebSockets). + +1. Enable notifications in `.env` + +```dotenv +APP_NOTIFICATIONS_ENABLED=true +APP_NOTIFICATIONS_IN_DEMO_MODE=true # set this to false to enable live reverb notifications. +``` + +2. Install broadcasting + Reverb (generates credentials) + +```bash +php artisan install:broadcasting +``` + +This will populate the following in your `.env` (you can override these as needed): + +```dotenv +REVERB_APP_ID= +REVERB_APP_KEY= +REVERB_APP_SECRET= +REVERB_HOST="localhost" +REVERB_PORT=8080 +REVERB_SCHEME=https +REVERB_SERVER_HOST=0.0.0.0 +REVERB_SERVER_PORT=8080 +REVERB_TLS_CERT= +REVERB_TLS_KEY= +``` + +3. Start the Reverb server (separate terminal) + +```bash +php artisan reverb:start +``` + +> If you're using Herd / Valet secure domains, you can point `REVERB_TLS_CERT` and `REVERB_TLS_KEY` at the generated certificate + key to use `wss://` in local development. + **🎉 That's it!** Visit `http://localhost:8000` to see the app in action. **🔗 External Resources** diff --git a/app/Console/Commands/CleanupDeletedAppNotificationsCommand.php b/app/Console/Commands/CleanupDeletedAppNotificationsCommand.php new file mode 100644 index 0000000..19827ce --- /dev/null +++ b/app/Console/Commands/CleanupDeletedAppNotificationsCommand.php @@ -0,0 +1,31 @@ +argument('days'); + + if ($days === null || $days === '') { + $days = (int) config('guacpanel.notifications.auto_cleanup_deleted_days', 30); + } + + $days = max(1, (int) $days); + + $result = $cleanup->cleanupDeleted($days); + + $this->info('Deleted notifications: '.$result['deleted']); + $this->info('Cutoff date: '.$result['cutoff']->toDateTimeString().' ('.$result['days'].' days)'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SendScheduledAppNotificationsCommand.php b/app/Console/Commands/SendScheduledAppNotificationsCommand.php new file mode 100644 index 0000000..56e83c7 --- /dev/null +++ b/app/Console/Commands/SendScheduledAppNotificationsCommand.php @@ -0,0 +1,30 @@ +option('dry-run'); + + $count = $service->sendDue($dryRun); + + if ($dryRun) { + $this->info("Dry run: {$count} scheduled notifications are due."); + + return self::SUCCESS; + } + + $this->info("Sent {$count} scheduled notifications."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SoftDeleteExpiredAppNotificationsCommand.php b/app/Console/Commands/SoftDeleteExpiredAppNotificationsCommand.php new file mode 100644 index 0000000..6df1986 --- /dev/null +++ b/app/Console/Commands/SoftDeleteExpiredAppNotificationsCommand.php @@ -0,0 +1,23 @@ +softDeleteExpired(); + + $this->info('Soft-deleted notifications: '.$result['soft_deleted']); + $this->info('Cutoff (now): '.$result['cutoff']->toDateTimeString()); + + return self::SUCCESS; + } +} diff --git a/app/Events/AppNotificationCreated.php b/app/Events/AppNotificationCreated.php new file mode 100644 index 0000000..cdd50a1 --- /dev/null +++ b/app/Events/AppNotificationCreated.php @@ -0,0 +1,53 @@ +notification->scope === 'system' || $this->notification->scope === 'release') { + return new PrivateChannel('system'); + } + + return new PrivateChannel('users.'.$this->notification->user_id); + } + + public function broadcastAs(): string + { + return 'app-notification.created'; + } + + public function broadcastWith(): array + { + $readAt = null; + + return [ + 'id' => $this->notification->id, + 'user_id' => $this->notification->user_id, + 'scope' => $this->notification->scope, + 'type' => $this->notification->type, + 'title' => $this->notification->title, + 'message' => $this->notification->message, + 'data' => $this->notification->data, + 'read_at' => $readAt, + 'created_at' => optional($this->notification->created_at)?->toISOString(), + ]; + } +} diff --git a/app/Events/AppNotificationRequested.php b/app/Events/AppNotificationRequested.php new file mode 100644 index 0000000..ca10569 --- /dev/null +++ b/app/Events/AppNotificationRequested.php @@ -0,0 +1,22 @@ +userId); + } + + public function broadcastAs(): string + { + return 'app-notification.state'; + } + + public function broadcastWith(): array + { + return [ + 'id' => $this->notificationId, + 'scope' => $this->scope, + 'read_at' => $this->readAt, + 'dismissed_at' => $this->dismissedAt, + 'action' => $this->action, + ]; + } +} diff --git a/app/Events/AppNotificationsBulkChanged.php b/app/Events/AppNotificationsBulkChanged.php new file mode 100644 index 0000000..0cbb532 --- /dev/null +++ b/app/Events/AppNotificationsBulkChanged.php @@ -0,0 +1,46 @@ +userId === 'system') { + return new PrivateChannel('system'); + } + + return new PrivateChannel("users.{$this->userId}"); + } + + public function broadcastAs(): string + { + return 'app-notification.bulk'; + } + + public function broadcastWith(): array + { + return [ + 'action' => $this->action, + 'ids' => $this->ids, + ]; + } +} diff --git a/app/Http/Controllers/Admin/AdminAppNotificationsController.php b/app/Http/Controllers/Admin/AdminAppNotificationsController.php new file mode 100644 index 0000000..58fb146 --- /dev/null +++ b/app/Http/Controllers/Admin/AdminAppNotificationsController.php @@ -0,0 +1,221 @@ +middleware('permission:manage-notifications'); + } + + public function index(Request $request) + { + $perPage = $this->pagination->resolvePerPageWithDefaults($request); + + $notifications = AppNotification::query() + ->select([ + 'id', + 'user_id', + 'scope', + 'type', + 'title', + 'message', + 'scheduled_on', + 'auto_expire_on', + 'sent_as_scheduled', + 'created_at', + ]) + ->withCount([ + 'reads as read_count' => function ($q) { + $q->whereNotNull('read_at'); + }, + 'reads as dismissed_count' => function ($q) { + $q->whereNotNull('dismissed_at'); + }, + 'reads as deleted_count' => function ($q) { + $q->whereNotNull('u_del_notif_at'); + }, + ]) + ->with(['user:id,name,email']) + ->latest('created_at') + ->paginate($perPage) + ->withQueryString() + ->through(function ($item) { + $item->created_at_diff = $item->created_at?->diffForHumans(); + $item->scheduled_on_diff = $item->scheduled_on?->diffForHumans(); + $item->auto_expire_on_diff = $item->auto_expire_on?->diffForHumans(); + $item->username = $item->user?->name; + $item->user_email = $item->user?->email; + + return $item; + }); + + return Inertia::render('Admin/Notifications/AdminNotificationsIndex', [ + 'notifications' => $notifications, + 'filters' => $this->pagination->buildFilters($request), + ]); + } + + public function create() + { + $users = User::query() + ->select(['id', 'name', 'email']) + ->orderBy('name') + ->limit(250) + ->get(); + + return Inertia::render('Admin/Notifications/AdminCreateNotificaion', [ + 'users' => $users, + ]); + } + + public function store(StoreAdminAppNotificationRequest $request) + { + $data = $request->validated(); + + $scope = in_array($data['scope'] ?? 'user', ['user', 'system', 'release'], true) + ? $data['scope'] + : 'user'; + + $userId = $scope === 'user' ? ($data['user_id'] ?? null) : null; + + $scheduledOn = !empty($data['scheduled_on']) ? Carbon::parse($data['scheduled_on']) : null; + $autoExpireOn = !empty($data['auto_expire_on']) ? Carbon::parse($data['auto_expire_on']) : null; + + $notification = AppNotification::create([ + 'user_id' => $userId, + 'scope' => $scope, + 'type' => $data['type'], + 'title' => $data['title'], + 'message' => $data['message'], + 'data' => null, + 'scheduled_on' => $scheduledOn, + 'auto_expire_on' => $autoExpireOn, + 'sent_as_scheduled' => false, + ]); + + if (empty($scheduledOn) || $scheduledOn->isPast() || $scheduledOn->isNow()) { + event(new AppNotificationCreated($notification)); + } + + return redirect()->route('admin.notifications.index') + ->with('success', 'Notification created.'); + } + + public function edit(string $id) + { + $notification = AppNotification::query()->whereKey($id)->firstOrFail(); + + $users = User::query() + ->select(['id', 'name', 'email']) + ->orderBy('name') + ->limit(250) + ->get(); + + return Inertia::render('Admin/Notifications/AdminEditNotificaion', [ + 'notification' => $notification, + 'users' => $users, + ]); + } + + public function update(UpdateAdminAppNotificationRequest $request, string $id) + { + $notification = AppNotification::query()->whereKey($id)->firstOrFail(); + $data = $request->validated(); + + $scope = in_array($data['scope'] ?? 'user', ['user', 'system', 'release'], true) + ? $data['scope'] + : 'user'; + + $userId = $scope === 'user' ? ($data['user_id'] ?? null) : null; + + $scheduledOn = !empty($data['scheduled_on']) ? Carbon::parse($data['scheduled_on']) : null; + $autoExpireOn = !empty($data['auto_expire_on']) ? Carbon::parse($data['auto_expire_on']) : null; + + $notification->forceFill([ + 'user_id' => $userId, + 'scope' => $scope, + 'type' => $data['type'], + 'title' => $data['title'], + 'message' => $data['message'], + 'data' => null, + 'scheduled_on' => $scheduledOn, + 'auto_expire_on' => $autoExpireOn, + ])->save(); + + $broadcastTarget = $notification->user_id ?: 'system'; + event(new AppNotificationsBulkChanged($broadcastTarget, 'bulk', [$notification->id])); + + return redirect()->route('admin.notifications.index') + ->with('success', 'Notification updated.'); + } + + public function destroy(string $id) + { + $notification = AppNotification::query()->whereKey($id)->firstOrFail(); + + $ids = [$notification->id]; + $broadcastTarget = $notification->user_id ?: 'system'; + + $notification->delete(); + + event(new AppNotificationsBulkChanged($broadcastTarget, 'bulk', $ids)); + + return redirect()->route('admin.notifications.index') + ->with('success', 'Notification deleted.'); + } + + public function bulkDestroy(Request $request) + { + $data = $request->validate([ + 'ids' => ['required', 'array', 'min:1'], + 'ids.*' => ['string'], + ]); + + $ids = array_values(array_unique(array_filter($data['ids']))); + + if (!count($ids)) { + return redirect()->route('admin.notifications.index'); + } + + $items = AppNotification::query() + ->select(['id', 'user_id']) + ->whereIn('id', $ids) + ->get(); + + $idsByTarget = []; + + foreach ($items as $item) { + $target = $item->user_id ?: 'system'; + $idsByTarget[$target] ??= []; + $idsByTarget[$target][] = $item->id; + } + + AppNotification::query()->whereIn('id', $items->pluck('id')->all())->delete(); + + foreach ($idsByTarget as $target => $targetIds) { + event(new AppNotificationsBulkChanged((string) $target, 'bulk', array_values($targetIds))); + } + + return redirect()->route('admin.notifications.index') + ->with('success', 'Notifications deleted.'); + } + + public function deleted(Request $request) + { + return Inertia::render('Admin/Notifications/AdminDeletedNotificaionsIndex'); + } +} diff --git a/app/Http/Controllers/Admin/AdminPermissionRoleController.php b/app/Http/Controllers/Admin/AdminPermissionRoleController.php index 6166e01..2a75ebc 100644 --- a/app/Http/Controllers/Admin/AdminPermissionRoleController.php +++ b/app/Http/Controllers/Admin/AdminPermissionRoleController.php @@ -24,7 +24,8 @@ public function __construct(private DataTablePaginationService $pagination) public function index(Request $request) { - $perPage = $this->pagination->resolvePerPageWithDefaults($request); + // $perPage = $this->pagination->resolvePerPageWithDefaults($request); + $perPage = 10000000; // Just get them all so can fully update a role. $permissions = Permission::query() ->paginate($perPage) diff --git a/app/Http/Controllers/Admin/AdminSettingController.php b/app/Http/Controllers/Admin/AdminSettingController.php index 81ec792..e7cbee8 100644 --- a/app/Http/Controllers/Admin/AdminSettingController.php +++ b/app/Http/Controllers/Admin/AdminSettingController.php @@ -22,10 +22,10 @@ public function index() public function show() { - $settings = Setting::first() ?? new Setting(); + $systemSettings = Setting::first() ?? new Setting(); return Inertia::render('Admin/IndexManageSettingPage', [ - 'settings' => $settings, + 'systemSettings' => $systemSettings, 'canResetPassword' => Features::enabled(Features::resetPasswords()), 'canRegister' => Features::enabled(Features::registration()), 'twoFactorEnabled' => Features::enabled(Features::twoFactorAuthentication()), diff --git a/app/Http/Controllers/Auth/SocialiteController.php b/app/Http/Controllers/Auth/SocialiteController.php index 584f959..24d19a2 100644 --- a/app/Http/Controllers/Auth/SocialiteController.php +++ b/app/Http/Controllers/Auth/SocialiteController.php @@ -59,6 +59,7 @@ public function handleSocialCallback(Request $request, string $provider) $newUser->markEmailAsVerified(); + $newUser->assignRole(config('seeders.users.regular.role')); event(new Registered($newUser)); event(new Verified($newUser)); diff --git a/app/Http/Controllers/Notifications/AppNotificationController.php b/app/Http/Controllers/Notifications/AppNotificationController.php new file mode 100644 index 0000000..990faa2 --- /dev/null +++ b/app/Http/Controllers/Notifications/AppNotificationController.php @@ -0,0 +1,105 @@ +middleware('permission:view-notifications')->only(['index']); + + $this->middleware('permission:edit-notifications')->only([ + 'markRead', + 'markUnread', + 'markAllRead', + 'dismiss', + 'undismiss', + 'dismissAll', + 'bulk', + ]); + + $this->middleware('permission:delete-notifications|manage-notifications')->only(['destroy']); + } + + public function index(ListNotificationsRequest $request): JsonResponse + { + return response()->json($this->resolveNotifications($request, 100)); + } + + public function markRead(Request $request, AppNotification $notification): JsonResponse + { + $this->markNotificationReadForUser($request, $notification); + + return response()->json(['ok' => true]); + } + + public function markUnread(Request $request, AppNotification $notification): JsonResponse + { + $this->markNotificationUnreadForUser($request, $notification); + + return response()->json(['ok' => true]); + } + + public function markAllRead(Request $request): JsonResponse + { + $this->markAllNotificationsReadForUser($request); + + return response()->json(['ok' => true]); + } + + public function dismiss(Request $request, AppNotification $notification): JsonResponse + { + $this->dismissNotificationForUser($request, $notification); + + return response()->json(['ok' => true]); + } + + public function undismiss(Request $request, AppNotification $notification): JsonResponse + { + $this->undismissNotificationForUser($request, $notification); + + return response()->json(['ok' => true]); + } + + public function dismissAll(Request $request): JsonResponse + { + $this->dismissAllNotificationsForUser($request); + + return response()->json(['ok' => true]); + } + + public function bulk(BulkNotificationsRequest $request): JsonResponse + { + $action = (string) $request->validated('action'); + $user = $request->user(); + + if ($action === 'delete') { + abort_unless($user?->can('delete-notifications') || $user?->can('manage-notifications'), 403); + } else { + abort_unless($user?->can('edit-notifications') || $user?->can('manage-notifications'), 403); + } + + $this->bulkNotificationsForUser($request); + + return response()->json(['ok' => true]); + } + + public function destroy(Request $request, AppNotification $notification): JsonResponse + { + $canDelete = false; + + $this->deleteNotificationForUser($request, $notification, $canDelete); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Notifications/AppNotificationPageController.php b/app/Http/Controllers/Notifications/AppNotificationPageController.php new file mode 100644 index 0000000..b3fc311 --- /dev/null +++ b/app/Http/Controllers/Notifications/AppNotificationPageController.php @@ -0,0 +1,36 @@ +query('per_page', 25); + $perPage = $perPageRaw === 'all' ? 'all' : (int) $perPageRaw; + $perPage = $perPage === 'all' ? 'all' : ($perPage > 0 ? $perPage : 25); + + $filters = [ + 'scope' => (string) $request->query('scope', 'all'), + 'read' => (string) $request->query('read', 'all'), + 'dismissed' => (string) $request->query('dismissed', 'all'), + 'type' => (string) $request->query('type', 'all'), + 'search' => (string) $request->query('search', ''), + 'sort' => (string) $request->query('sort', 'newest'), + 'per_page' => $perPage, + ]; + + return Inertia::render('Notifications/NotificationsIndex', [ + 'filters' => $filters, + 'notifications' => fn () => $this->resolveNotifications($request, $filters['per_page'], $filters), + ]); + } +} diff --git a/app/Http/Middleware/EnsureIsLocalTesting.php b/app/Http/Middleware/EnsureIsLocalTesting.php new file mode 100644 index 0000000..8849394 --- /dev/null +++ b/app/Http/Middleware/EnsureIsLocalTesting.php @@ -0,0 +1,27 @@ +environment([ + 'local', + 'testing', + ]), $abortTo); + + $user = $request->user(); + abort_unless($user, $abortTo); + // abort_unless($user->isSuperUser, $abortTo); + // abort_unless($user?->can('manage-notifications'), $abortTo); + + return $next($request); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index fbd485a..78eafeb 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Traits\AppNotificationsHelperTrait; use App\Traits\PersonalisationsHelper; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -10,34 +11,16 @@ class HandleInertiaRequests extends Middleware { + use AppNotificationsHelperTrait; use PersonalisationsHelper; - /** - * The root template that's loaded on the first page visit. - * - * @see https://inertiajs.com/server-side-setup#root-template - * - * @var string - */ protected $rootView = 'app'; - /** - * Determines the current asset version. - * - * @see https://inertiajs.com/asset-versioning - */ public function version(Request $request): ?string { return parent::version($request); } - /** - * Define the props that are shared by default. - * - * @see https://inertiajs.com/shared-data - * - * @return array - */ public function share(Request $request): array { $user = $request->user(); @@ -81,8 +64,19 @@ public function share(Request $request): array ], 'settings' => [ + 'appName' => config('app.name'), 'passwordlessLogin' => DB::table('settings')->value('passwordless_login') ?? true, 'emailVerificationEnabled' => config('guacpanel.email_verification_enabled'), + 'notificationEnabled' => config('guacpanel.notifications.enabled'), + 'notificationInDemoMode' => config('guacpanel.notifications.in_demo'), + ], + + 'notifications' => fn () => $this->resolveNotifications($request, 1000, [ + 'dismissed' => 'undismissed', + ]), + + 'demo' => [ + 'notifications_enabled' => config('guacpanel.demo.notifications.enabled'), ], ]); } diff --git a/app/Http/Requests/Admin/Notifications/StoreAdminAppNotificationRequest.php b/app/Http/Requests/Admin/Notifications/StoreAdminAppNotificationRequest.php new file mode 100644 index 0000000..1783dcc --- /dev/null +++ b/app/Http/Requests/Admin/Notifications/StoreAdminAppNotificationRequest.php @@ -0,0 +1,35 @@ +user(); + + return (bool) $user && $user->can('manage-notifications'); + } + + public function rules(): array + { + return [ + 'scope' => ['required', 'in:user,system,release'], + 'user_id' => ['nullable', 'string', 'required_if:scope,user'], + 'type' => ['required', 'in:success,info,warning,danger'], + 'title' => ['required', 'string', 'max:255'], + 'message' => ['required', 'string'], + 'scheduled_on' => ['nullable', 'date'], + 'auto_expire_on' => ['nullable', 'date'], + ]; + } + + public function messages(): array + { + return [ + 'user_id.required_if' => 'A user is required when scope is set to user.', + ]; + } +} diff --git a/app/Http/Requests/Admin/Notifications/UpdateAdminAppNotificationRequest.php b/app/Http/Requests/Admin/Notifications/UpdateAdminAppNotificationRequest.php new file mode 100644 index 0000000..ca7f49e --- /dev/null +++ b/app/Http/Requests/Admin/Notifications/UpdateAdminAppNotificationRequest.php @@ -0,0 +1,36 @@ +user(); + + return (bool) $user && $user->can('manage-notifications'); + } + + public function rules(): array + { + return [ + 'scope' => ['required', 'in:user,system,release'], + 'user_id' => ['nullable', 'string', 'required_if:scope,user'], + 'type' => ['required', 'in:success,info,warning,danger'], + 'title' => ['required', 'string', 'max:255'], + 'message' => ['required', 'string'], + 'data' => ['nullable', 'array'], + 'scheduled_on' => ['nullable', 'date'], + 'auto_expire_on' => ['nullable', 'date'], + ]; + } + + public function messages(): array + { + return [ + 'user_id.required_if' => 'A user is required when scope is set to user.', + ]; + } +} diff --git a/app/Http/Requests/Notifications/BulkNotificationsRequest.php b/app/Http/Requests/Notifications/BulkNotificationsRequest.php new file mode 100644 index 0000000..b0b638a --- /dev/null +++ b/app/Http/Requests/Notifications/BulkNotificationsRequest.php @@ -0,0 +1,28 @@ +user(); + + return (bool) $user && ( + $user->can('edit-notifications') || + $user->can('delete-notifications') + ); + } + + public function rules(): array + { + return [ + 'action' => ['required', 'string', Rule::in(['read', 'unread', 'dismiss', 'undismiss', 'delete'])], + 'ids' => ['required', 'array', 'min:1', 'max:500'], + 'ids.*' => ['string'], + ]; + } +} diff --git a/app/Http/Requests/Notifications/ExpireNotificationsRequest.php b/app/Http/Requests/Notifications/ExpireNotificationsRequest.php new file mode 100644 index 0000000..a5dbf87 --- /dev/null +++ b/app/Http/Requests/Notifications/ExpireNotificationsRequest.php @@ -0,0 +1,23 @@ +user(); + + return (bool) $user && $user->can('manage-notifications'); + } + + public function rules(): array + { + return [ + 'ids' => ['required', 'array', 'min:1', 'max:500'], + 'ids.*' => ['string'], + ]; + } +} diff --git a/app/Http/Requests/Notifications/ListNotificationsRequest.php b/app/Http/Requests/Notifications/ListNotificationsRequest.php new file mode 100644 index 0000000..8eba270 --- /dev/null +++ b/app/Http/Requests/Notifications/ListNotificationsRequest.php @@ -0,0 +1,47 @@ +user(); + + return (bool) $user && $user->can('view-notifications'); + } + + public function rules(): array + { + return [ + 'scope' => ['sometimes', 'string', 'in:all,user,system,release'], + 'read' => ['sometimes', 'string', 'in:all,read,unread'], + 'dismissed' => ['sometimes', 'string', 'in:all,dismissed,undismissed'], + 'type' => ['sometimes', 'string', 'in:all,info,success,warning,error'], + 'search' => ['sometimes', 'nullable', 'string', 'max:255'], + 'sort' => ['sometimes', 'string', 'in:newest,oldest'], + 'per_page' => [ + 'sometimes', + function ($attribute, $value, $fail) { + if ($value === 'all') { + return; + } + + if (!is_numeric($value)) { + $fail('The per_page must be an integer or "all".'); + + return; + } + + $n = (int) $value; + + if ($n < 1 || $n > 1000) { + $fail('The per_page must be between 1 and 1000, or "all".'); + } + }, + ], + ]; + } +} diff --git a/app/Jobs/CleanupDeletedAppNotificationsJob.php b/app/Jobs/CleanupDeletedAppNotificationsJob.php new file mode 100644 index 0000000..528872c --- /dev/null +++ b/app/Jobs/CleanupDeletedAppNotificationsJob.php @@ -0,0 +1,42 @@ +cleanupDeleted($days); + + if (config('guacpanel.notifications.auto_clean_send_email')) { + $to = (string) config('guacpanel.notifications.auto_clean_send_email_to', ''); + + if ($to !== '') { + Mail::to($to)->send(new NotificationsCleanupReport( + deleted: (int) $result['deleted'], + cutoff: $result['cutoff'], + days: (int) $result['days'], + )); + } + } + + return (int) $result['deleted']; + } +} diff --git a/app/Jobs/SendScheduledAppNotificationsJob.php b/app/Jobs/SendScheduledAppNotificationsJob.php new file mode 100644 index 0000000..db23eb9 --- /dev/null +++ b/app/Jobs/SendScheduledAppNotificationsJob.php @@ -0,0 +1,23 @@ +sendDue(); + } +} diff --git a/app/Jobs/SoftDeleteExpiredAppNotificationsJob.php b/app/Jobs/SoftDeleteExpiredAppNotificationsJob.php new file mode 100644 index 0000000..3bc8a00 --- /dev/null +++ b/app/Jobs/SoftDeleteExpiredAppNotificationsJob.php @@ -0,0 +1,25 @@ +softDeleteExpired(); + + return (int) $result['soft_deleted']; + } +} diff --git a/app/Listeners/CreateAppNotification.php b/app/Listeners/CreateAppNotification.php new file mode 100644 index 0000000..761cd84 --- /dev/null +++ b/app/Listeners/CreateAppNotification.php @@ -0,0 +1,32 @@ +scope, ['user', 'system', 'release'], true) + ? $event->scope + : 'user'; + + $userId = $scope === 'user' + ? $event->userId + : null; + + $notification = AppNotification::create([ + 'user_id' => $userId, + 'scope' => $scope, + 'type' => $event->type, + 'title' => $event->title, + 'message' => $event->message, + 'data' => $event->data ?: null, + ]); + + AppNotificationCreated::dispatch($notification); + } +} diff --git a/app/Mail/NotificationsCleanupReport.php b/app/Mail/NotificationsCleanupReport.php new file mode 100644 index 0000000..d8d27bd --- /dev/null +++ b/app/Mail/NotificationsCleanupReport.php @@ -0,0 +1,36 @@ +deleted = $deleted; + $this->cutoff = $cutoff; + $this->days = $days; + } + + public function build(): self + { + return $this + ->subject(trans('emails.notifications_cleanup.subject', [ + 'appname' => config('app.name'), + ])) + ->markdown('emails.notifications.cleanup-report'); + } +} diff --git a/app/Models/AppNotification.php b/app/Models/AppNotification.php new file mode 100644 index 0000000..25d1497 --- /dev/null +++ b/app/Models/AppNotification.php @@ -0,0 +1,54 @@ + 'array', + 'sent_as_scheduled' => 'boolean', + 'scheduled_on' => 'datetime', + 'auto_expire_on' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function reads() + { + return $this->hasMany(AppNotificationRead::class); + } +} diff --git a/app/Models/AppNotificationRead.php b/app/Models/AppNotificationRead.php new file mode 100644 index 0000000..21b2d43 --- /dev/null +++ b/app/Models/AppNotificationRead.php @@ -0,0 +1,42 @@ + 'datetime', + 'dismissed_at' => 'datetime', + 'u_del_notif_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + public function notification(): BelongsTo + { + return $this->belongsTo(AppNotification::class, 'app_notification_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Services/DataTablePaginationService.php b/app/Services/DataTablePaginationService.php index e19cb2e..310222f 100644 --- a/app/Services/DataTablePaginationService.php +++ b/app/Services/DataTablePaginationService.php @@ -7,7 +7,7 @@ class DataTablePaginationService { public const DEFAULT_PAGE_SIZE = 10; - public const ALLOWED_PAGE_SIZES = [10, 25, 50]; + public const ALLOWED_PAGE_SIZES = [10, 25, 50, 100]; public const ALLOW_ALL_OPTION = true; public const MAX_ROWS_WHEN_ALL = 1000; diff --git a/app/Services/EventListenerRegistrar.php b/app/Services/EventListenerRegistrar.php index f5115cd..ca6e175 100644 --- a/app/Services/EventListenerRegistrar.php +++ b/app/Services/EventListenerRegistrar.php @@ -2,8 +2,10 @@ namespace App\Services; +use App\Events\AppNotificationRequested; use App\Events\UserDeleted; use App\Events\UserRestored; +use App\Listeners\CreateAppNotification; use App\Listeners\LogFailedLogin; use App\Listeners\LogSuccessfulLogin; use App\Listeners\LogSuccessfulLogout; @@ -61,5 +63,10 @@ public function register(): void UserRestored::class, SendRestoredEmail::class, ); + + $this->events->listen( + AppNotificationRequested::class, + CreateAppNotification::class, + ); } } diff --git a/app/Services/Notifications/AppNotificationAutoExpireService.php b/app/Services/Notifications/AppNotificationAutoExpireService.php new file mode 100644 index 0000000..9b7f7e3 --- /dev/null +++ b/app/Services/Notifications/AppNotificationAutoExpireService.php @@ -0,0 +1,30 @@ +whereNull('deleted_at') + ->whereNotNull('auto_expire_on') + ->where('auto_expire_on', '<=', $cutoff) + ->delete(); + + return [ + 'soft_deleted' => (int) $softDeleted, + 'cutoff' => $cutoff, + ]; + } +} diff --git a/app/Services/Notifications/AppNotificationCleanupService.php b/app/Services/Notifications/AppNotificationCleanupService.php new file mode 100644 index 0000000..36c408b --- /dev/null +++ b/app/Services/Notifications/AppNotificationCleanupService.php @@ -0,0 +1,31 @@ +subDays($days); + + $deleted = AppNotification::query() + ->onlyTrashed() + ->where('deleted_at', '<=', $cutoff) + ->forceDelete(); + + return [ + 'deleted' => (int) $deleted, + 'cutoff' => $cutoff, + 'days' => $days, + ]; + } +} diff --git a/app/Services/Notifications/AppNotificationScheduledSendService.php b/app/Services/Notifications/AppNotificationScheduledSendService.php new file mode 100644 index 0000000..07d8f81 --- /dev/null +++ b/app/Services/Notifications/AppNotificationScheduledSendService.php @@ -0,0 +1,45 @@ +whereNotNull('scheduled_on') + ->where('scheduled_on', '<=', $now) + ->where('sent_as_scheduled', false) + ->whereNull('deleted_at') + ->where(function ($q) use ($now) { + $q->whereNull('auto_expire_on') + ->orWhere('auto_expire_on', '>', $now); + }) + ->orderBy('scheduled_on') + ->chunkById(200, function ($chunk) use (&$sent, $dryRun) { + foreach ($chunk as $notification) { + if ($dryRun) { + $sent++; + continue; + } + + event(new AppNotificationCreated($notification)); + + $notification->forceFill([ + 'sent_as_scheduled' => true, + ])->save(); + + $sent++; + } + }); + + return $sent; + } +} diff --git a/app/Traits/AppNotificationsHelperTrait.php b/app/Traits/AppNotificationsHelperTrait.php new file mode 100644 index 0000000..5989459 --- /dev/null +++ b/app/Traits/AppNotificationsHelperTrait.php @@ -0,0 +1,715 @@ +user(); + + if (!$user) { + return [ + 'data' => [], + 'links' => [], + 'meta' => [ + 'total' => 0, + 'per_page' => 0, + 'current_page' => 1, + 'last_page' => 1, + 'from' => null, + 'to' => null, + 'total_all' => 0, + ], + ]; + } + + $userId = (string) $user->id; + + $totalAll = $this->countAllNotificationsForUser($userId); + + $requestFilters = $request->only([ + 'scope', + 'read', + 'dismissed', + 'type', + 'search', + 'sort', + 'per_page', + ]); + + $filters = array_merge($requestFilters, $filters); + + $perPageRaw = $filters['per_page'] ?? $limit; + + if ($perPageRaw === 'all') { + $limit = max(1, $totalAll); + } else { + $perPage = (int) $perPageRaw; + $limit = $perPage > 0 ? $perPage : $limit; + $limit = max(1, min($limit, 1000)); + } + + $scope = (string) ($filters['scope'] ?? 'all'); + $read = (string) ($filters['read'] ?? 'all'); + $dismissed = (string) ($filters['dismissed'] ?? 'all'); + $type = (string) ($filters['type'] ?? 'all'); + $search = trim((string) ($filters['search'] ?? '')); + $sort = (string) ($filters['sort'] ?? 'newest'); + + $query = AppNotification::query() + ->withoutGlobalScope(SoftDeletingScope::class) + ->from('app_notifications as an') + ->leftJoin('app_notification_reads as anr', function ($join) use ($userId) { + $join->on('anr.app_notification_id', '=', 'an.id') + ->where('anr.user_id', '=', $userId); + }) + ->whereNull('an.deleted_at') + ->where(function ($q) use ($userId) { + $q->where(function ($q) use ($userId) { + $q->where('an.scope', '=', 'user') + ->where('an.user_id', '=', $userId) + ->whereNull('anr.u_del_notif_at'); + })->orWhere(function ($q) { + $q->whereIn('an.scope', ['system', 'release']) + ->whereNull('an.user_id') + ->whereNull('anr.u_del_notif_at'); + }); + }); + + if ($scope !== 'all' && in_array($scope, ['user', 'system', 'release'], true)) { + if ($scope === 'user') { + $query->where('an.scope', '=', 'user') + ->where('an.user_id', '=', $userId) + ->whereNull('anr.u_del_notif_at'); + } else { + $query->where('an.scope', '=', $scope) + ->whereNull('an.user_id') + ->whereNull('anr.u_del_notif_at'); + } + } + + if ($type !== 'all' && in_array($type, ['info', 'success', 'warning', 'error'], true)) { + $query->where('an.type', '=', $type); + } + + if ($search !== '') { + $like = '%'.str_replace('%', '\\%', $search).'%'; + $query->where(function ($q) use ($like) { + $q->where('an.title', 'like', $like) + ->orWhere('an.message', 'like', $like); + }); + } + + if ($dismissed === 'undismissed') { + $query->where(function ($q) { + $q->where(function ($q) { + $q->where('an.scope', '=', 'user') + ->whereNull('anr.dismissed_at'); + })->orWhere(function ($q) { + $q->whereIn('an.scope', ['system', 'release']) + ->whereNull('anr.dismissed_at'); + }); + }); + } elseif ($dismissed === 'dismissed') { + $query->where(function ($q) { + $q->where(function ($q) { + $q->where('an.scope', '=', 'user') + ->whereNotNull('anr.dismissed_at'); + })->orWhere(function ($q) { + $q->whereIn('an.scope', ['system', 'release']) + ->whereNotNull('anr.dismissed_at'); + }); + }); + } + + if ($read === 'unread') { + $query->where(function ($q) { + $q->where(function ($q) { + $q->where('an.scope', '=', 'user') + ->whereNull('anr.read_at'); + })->orWhere(function ($q) { + $q->whereIn('an.scope', ['system', 'release']) + ->whereNull('anr.read_at'); + }); + }); + } elseif ($read === 'read') { + $query->where(function ($q) { + $q->where(function ($q) { + $q->where('an.scope', '=', 'user') + ->whereNotNull('anr.read_at'); + })->orWhere(function ($q) { + $q->whereIn('an.scope', ['system', 'release']) + ->whereNotNull('anr.read_at'); + }); + }); + } + + if ($sort === 'oldest') { + $query->orderBy('an.created_at'); + } else { + $query->orderByDesc('an.created_at'); + } + + $paginator = $query->paginate($limit, [ + 'an.id', + 'an.scope', + 'an.type', + 'an.title', + 'an.message', + 'an.data', + 'an.created_at', + 'anr.read_at as read_at', + 'anr.dismissed_at as dismissed_at', + ])->withQueryString(); + + $paginator->setCollection( + $paginator->getCollection()->map(function ($row) { + $readAt = $row->read_at; + $dismissedAt = $row->dismissed_at; + + return [ + 'id' => (string) $row->id, + 'scope' => $row->scope, + 'type' => $row->type, + 'title' => $row->title, + 'message' => $row->message, + 'data' => $row->data, + 'is_read' => (bool) $readAt, + 'read_at' => $readAt ? Carbon::parse($readAt)->toISOString() : null, + 'is_dismissed' => (bool) $dismissedAt, + 'dismissed_at' => $dismissedAt ? Carbon::parse($dismissedAt)->toISOString() : null, + 'created_at' => optional($row->created_at)?->toISOString(), + ]; + }), + ); + + return [ + 'data' => $paginator->items(), + 'links' => [ + 'first' => $paginator->url(1), + 'last' => $paginator->url($paginator->lastPage()), + 'prev' => $paginator->previousPageUrl(), + 'next' => $paginator->nextPageUrl(), + ], + 'meta' => [ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'from' => $paginator->firstItem(), + 'to' => $paginator->lastItem(), + 'total_all' => $totalAll, + ], + ]; + } + + protected function countAllNotificationsForUser(string $userId): int + { + return (int) AppNotification::query() + ->withoutGlobalScope(SoftDeletingScope::class) + ->from('app_notifications as an') + ->leftJoin('app_notification_reads as anr', function ($join) use ($userId) { + $join->on('anr.app_notification_id', '=', 'an.id') + ->where('anr.user_id', '=', $userId); + }) + ->whereNull('an.deleted_at') + ->where(function ($q) use ($userId) { + $q->where(function ($q) use ($userId) { + $q->where('an.scope', '=', 'user') + ->where('an.user_id', '=', $userId) + ->whereNull('anr.u_del_notif_at'); + })->orWhere(function ($q) { + $q->whereIn('an.scope', ['system', 'release']) + ->whereNull('an.user_id') + ->whereNull('anr.u_del_notif_at'); + }); + }) + ->count(); + } + + protected function setNotificationReadStateForUser(Request $request, AppNotification $notification, bool $isRead): void + { + if ($isRead) { + $this->markNotificationReadForUser($request, $notification); + + return; + } + + $this->markNotificationUnreadForUser($request, $notification); + } + + protected function setNotificationDismissedStateForUser(Request $request, AppNotification $notification, bool $isDismissed): void + { + if ($isDismissed) { + $this->dismissNotificationForUser($request, $notification); + + return; + } + + $this->undismissNotificationForUser($request, $notification); + } + + protected function bulkActionForUser(Request $request, array $validated): void + { + $request->merge([ + 'action' => $validated['action'] ?? null, + 'ids' => $validated['ids'] ?? [], + ]); + + $this->bulkNotificationsForUser($request); + } + + protected function broadcastNotificationState( + string $userId, + string $notificationId, + string $scope, + $readAt, + $dismissedAt, + string $action, + ): void { + AppNotificationStateChanged::dispatch( + $userId, + $notificationId, + $scope, + optional($readAt)?->toISOString(), + optional($dismissedAt)?->toISOString(), + $action, + ); + } + + protected function broadcastBulk(string $userId, string $action, array $ids = []): void + { + AppNotificationsBulkChanged::dispatch($userId, $action, $ids); + } + + protected function markNotificationReadForUser(Request $request, AppNotification $notification): void + { + $user = $request->user(); + $now = Carbon::now(); + $userId = (string) $user->id; + + if ($notification->scope === 'user') { + abort_unless((string) $notification->user_id === $userId, 403); + } else { + abort_unless(in_array($notification->scope, ['system', 'release'], true) && $notification->user_id === null, 403); + } + + AppNotificationRead::updateOrCreate( + [ + 'app_notification_id' => (string) $notification->id, + 'user_id' => $userId, + ], + [ + 'read_at' => $now, + 'u_del_notif_at'=> null, + 'deleted_at' => null, + ], + ); + + $readRow = AppNotificationRead::query() + ->where('app_notification_id', (string) $notification->id) + ->where('user_id', $userId) + ->first(); + + $this->broadcastNotificationState( + $userId, + (string) $notification->id, + $notification->scope, + $readRow?->read_at, + $readRow?->dismissed_at, + 'read', + ); + } + + protected function markNotificationUnreadForUser(Request $request, AppNotification $notification): void + { + $user = $request->user(); + $userId = (string) $user->id; + + if ($notification->scope === 'user') { + abort_unless((string) $notification->user_id === $userId, 403); + } else { + abort_unless(in_array($notification->scope, ['system', 'release'], true) && $notification->user_id === null, 403); + } + + AppNotificationRead::updateOrCreate( + [ + 'app_notification_id' => (string) $notification->id, + 'user_id' => $userId, + ], + [ + 'read_at' => null, + 'u_del_notif_at'=> null, + 'deleted_at' => null, + ], + ); + + $readRow = AppNotificationRead::query() + ->where('app_notification_id', (string) $notification->id) + ->where('user_id', $userId) + ->first(); + + $this->broadcastNotificationState( + $userId, + (string) $notification->id, + $notification->scope, + $readRow?->read_at, + $readRow?->dismissed_at, + 'unread', + ); + } + + protected function markAllNotificationsReadForUser(Request $request): void + { + $user = $request->user(); + $now = Carbon::now(); + $userId = (string) $user->id; + + $ids = AppNotification::query() + ->withoutGlobalScope(SoftDeletingScope::class) + ->from('app_notifications as an') + ->leftJoin('app_notification_reads as anr', function ($join) use ($userId) { + $join->on('anr.app_notification_id', '=', 'an.id') + ->where('anr.user_id', '=', $userId); + }) + ->whereNull('an.deleted_at') + ->where(function ($q) use ($userId) { + $q->where(function ($q) use ($userId) { + $q->where('an.scope', '=', 'user') + ->where('an.user_id', '=', $userId) + ->whereNull('anr.u_del_notif_at'); + })->orWhere(function ($q) { + $q->whereIn('an.scope', ['system', 'release']) + ->whereNull('an.user_id') + ->whereNull('anr.u_del_notif_at'); + }); + }) + ->whereNull('anr.dismissed_at') + ->whereNull('anr.read_at') + ->pluck('an.id') + ->map(fn ($id) => (string) $id); + + if ($ids->isEmpty()) { + $this->broadcastBulk($userId, 'read-all'); + + return; + } + + $rows = $ids->map(fn ($id) => [ + 'app_notification_id' => (string) $id, + 'user_id' => $userId, + 'read_at' => $now, + 'u_del_notif_at' => null, + 'deleted_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ])->all(); + + AppNotificationRead::upsert( + $rows, + ['app_notification_id', 'user_id'], + ['read_at', 'u_del_notif_at', 'deleted_at', 'updated_at'], + ); + + $this->broadcastBulk($userId, 'read-all'); + } + + protected function dismissNotificationForUser(Request $request, AppNotification $notification): void + { + $user = $request->user(); + $now = Carbon::now(); + $userId = (string) $user->id; + + if ($notification->scope === 'user') { + abort_unless((string) $notification->user_id === $userId, 403); + } else { + abort_unless(in_array($notification->scope, ['system', 'release'], true) && $notification->user_id === null, 403); + } + + AppNotificationRead::updateOrCreate( + [ + 'app_notification_id' => (string) $notification->id, + 'user_id' => $userId, + ], + [ + 'dismissed_at' => $now, + 'deleted_at' => null, + ], + ); + + $readRow = AppNotificationRead::query() + ->where('app_notification_id', (string) $notification->id) + ->where('user_id', $userId) + ->first(); + + $this->broadcastNotificationState( + $userId, + (string) $notification->id, + $notification->scope, + $readRow?->read_at, + $readRow?->dismissed_at, + 'dismissed', + ); + } + + protected function undismissNotificationForUser(Request $request, AppNotification $notification): void + { + $user = $request->user(); + $userId = (string) $user->id; + + if ($notification->scope === 'user') { + abort_unless((string) $notification->user_id === $userId, 403); + } else { + abort_unless(in_array($notification->scope, ['system', 'release'], true) && $notification->user_id === null, 403); + } + + AppNotificationRead::updateOrCreate( + [ + 'app_notification_id' => (string) $notification->id, + 'user_id' => $userId, + ], + [ + 'dismissed_at' => null, + 'deleted_at' => null, + ], + ); + + $readRow = AppNotificationRead::query() + ->where('app_notification_id', (string) $notification->id) + ->where('user_id', $userId) + ->first(); + + $this->broadcastNotificationState( + $userId, + (string) $notification->id, + $notification->scope, + $readRow?->read_at, + $readRow?->dismissed_at, + 'undismissed', + ); + } + + protected function dismissAllNotificationsForUser(Request $request): void + { + $user = $request->user(); + $now = Carbon::now(); + $userId = (string) $user->id; + + $ids = AppNotification::query() + ->withoutGlobalScope(SoftDeletingScope::class) + ->from('app_notifications as an') + ->leftJoin('app_notification_reads as anr', function ($join) use ($userId) { + $join->on('anr.app_notification_id', '=', 'an.id') + ->where('anr.user_id', '=', $userId); + }) + ->whereNull('an.deleted_at') + ->where(function ($q) use ($userId) { + $q->where(function ($q) use ($userId) { + $q->where('an.scope', '=', 'user') + ->where('an.user_id', '=', $userId) + ->whereNull('anr.u_del_notif_at'); + })->orWhere(function ($q) { + $q->whereIn('an.scope', ['system', 'release']) + ->whereNull('an.user_id') + ->whereNull('anr.u_del_notif_at'); + }); + }) + ->whereNull('anr.dismissed_at') + ->pluck('an.id') + ->map(fn ($id) => (string) $id); + + if ($ids->isEmpty()) { + $this->broadcastBulk($userId, 'dismiss-all'); + + return; + } + + $rows = $ids->map(fn ($id) => [ + 'app_notification_id' => (string) $id, + 'user_id' => $userId, + 'dismissed_at' => $now, + 'u_del_notif_at' => null, + 'deleted_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ])->all(); + + AppNotificationRead::upsert( + $rows, + ['app_notification_id', 'user_id'], + ['dismissed_at', 'u_del_notif_at', 'deleted_at', 'updated_at'], + ); + + $this->broadcastBulk($userId, 'dismiss-all'); + } + + protected function bulkNotificationsForUser(Request $request): void + { + $user = $request->user(); + $now = Carbon::now(); + $userId = (string) $user->id; + + $action = (string) $request->input('action'); + + $ids = collect((array) $request->input('ids', [])) + ->map(fn ($id) => (string) $id) + ->unique() + ->values(); + + if ($ids->isEmpty()) { + return; + } + + abort_unless(in_array($action, ['read', 'unread', 'dismiss', 'undismiss', 'delete'], true), 422); + + $rows = AppNotification::query() + ->withoutGlobalScope(SoftDeletingScope::class) + ->from('app_notifications as an') + ->leftJoin('app_notification_reads as anr', function ($join) use ($userId) { + $join->on('anr.app_notification_id', '=', 'an.id') + ->where('anr.user_id', '=', $userId); + }) + ->whereNull('an.deleted_at') + ->whereIn('an.id', $ids->all()) + ->where(function ($q) use ($userId) { + $q->where(function ($q) use ($userId) { + $q->where('an.scope', '=', 'user') + ->where('an.user_id', '=', $userId); + })->orWhere(function ($q) { + $q->whereIn('an.scope', ['system', 'release']) + ->whereNull('an.user_id'); + }); + }) + ->get(['an.id', 'an.scope', 'an.user_id']); + + if ($rows->isEmpty()) { + return; + } + + $upsertRows = $rows->map(function ($r) use ($action, $now, $userId) { + $payload = [ + 'app_notification_id' => (string) $r->id, + 'user_id' => $userId, + 'deleted_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]; + + if ($action === 'read') { + $payload['read_at'] = $now; + $payload['u_del_notif_at'] = null; + } elseif ($action === 'unread') { + $payload['read_at'] = null; + $payload['u_del_notif_at'] = null; + } elseif ($action === 'dismiss') { + $payload['dismissed_at'] = $now; + } elseif ($action === 'undismiss') { + $payload['dismissed_at'] = null; + } elseif ($action === 'delete') { + $payload['u_del_notif_at'] = $now; + } + + return $payload; + })->all(); + + if ($action === 'read' || $action === 'unread') { + AppNotificationRead::upsert( + $upsertRows, + ['app_notification_id', 'user_id'], + ['read_at', 'u_del_notif_at', 'deleted_at', 'updated_at'], + ); + } elseif ($action === 'dismiss' || $action === 'undismiss') { + AppNotificationRead::upsert( + $upsertRows, + ['app_notification_id', 'user_id'], + ['dismissed_at', 'deleted_at', 'updated_at'], + ); + } elseif ($action === 'delete') { + AppNotificationRead::upsert( + $upsertRows, + ['app_notification_id', 'user_id'], + ['u_del_notif_at', 'updated_at'], + ); + } + + $this->broadcastBulk($userId, 'bulk', $ids->all()); + } + + protected function deleteNotificationForUser(Request $request, AppNotification $notification, bool $canManage = false): void + { + $user = $request->user(); + $now = Carbon::now(); + $userId = (string) $user->id; + + if ($notification->scope === 'user') { + abort_unless((string) $notification->user_id === $userId, 403); + + AppNotificationRead::updateOrCreate( + [ + 'app_notification_id' => (string) $notification->id, + 'user_id' => $userId, + ], + [ + 'u_del_notif_at' => $now, + ], + ); + + $this->broadcastNotificationState( + $userId, + (string) $notification->id, + 'user', + null, + null, + 'deleted', + ); + + return; + } + + abort_unless(in_array($notification->scope, ['system', 'release'], true) && $notification->user_id === null, 403); + + if ($canManage) { + $notificationId = (string) $notification->id; + $notification->delete(); + + $this->broadcastNotificationState( + $userId, + $notificationId, + $notification->scope, + null, + null, + 'deleted', + ); + + return; + } + + AppNotificationRead::updateOrCreate( + [ + 'app_notification_id' => (string) $notification->id, + 'user_id' => $userId, + ], + [ + 'u_del_notif_at' => $now, + ], + ); + + $this->broadcastNotificationState( + $userId, + (string) $notification->id, + $notification->scope, + null, + null, + 'deleted', + ); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 39f4e28..67684fa 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -5,6 +5,7 @@ use App\Http\Middleware\DisableAccount; use App\Http\Middleware\EmailVerificationCheck; use App\Http\Middleware\EnsureAccountNotLocked; +use App\Http\Middleware\EnsureIsLocalTesting; use App\Http\Middleware\ForcePasswordChange; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; @@ -13,7 +14,12 @@ use App\Http\Middleware\RequireAuthForVerification; use App\Http\Middleware\RequireTwoFactor; use App\Http\Middleware\ValidateSignature; +use App\Jobs\CleanupDeletedAppNotificationsJob; +use App\Jobs\DestroySoftDeletedUsersJob; +use App\Jobs\SendScheduledAppNotificationsJob; +use App\Jobs\SoftDeleteExpiredAppNotificationsJob; use App\Mail\ExceptionOccurred; +use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -58,6 +64,7 @@ 'permission' => PermissionMiddleware::class, 'role_or_permission' => RoleOrPermissionMiddleware::class, 'socialite.providers' => HandleSocialiteProviders::class, + 'ensure-local-testing' => EnsureIsLocalTesting::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { @@ -92,5 +99,11 @@ ->with('error', __('notifications.errors.sm_session_invalid')); }); }) + ->withSchedule(function (Schedule $schedule) { + $schedule->job(new CleanupDeletedAppNotificationsJob())->daily()->withoutOverlapping()->onOneServer(); + $schedule->job(new DestroySoftDeletedUsersJob())->daily()->withoutOverlapping()->onOneServer(); + $schedule->job(new SendScheduledAppNotificationsJob())->everyFifteenMinutes()->withoutOverlapping()->onOneServer(); + $schedule->job(new SoftDeleteExpiredAppNotificationsJob())->daily()->withoutOverlapping()->onOneServer(); + }) ->withEvents(false) // turn off folder auto scanning, manually define events outide of Laravel's default. ->create(); diff --git a/composer.json b/composer.json index 9858f48..02fbc21 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "jeremykenedy/laravel-exception-notifier": "^4.1", "laravel/fortify": "^1.32.1", "laravel/framework": "^12.10.1", + "laravel/reverb": "^1.0", "laravel/sanctum": "^4.0", "laravel/scout": "^10.15", "laravel/socialite": "^5.23", diff --git a/composer.lock b/composer.lock index e533588..b23d1e4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a58d2a482c33e53d1993fdb02adb087c", + "content-hash": "77cb8bd598da3890e0465d07e3df925f", "packages": [ { "name": "bacon/bacon-qr-code", @@ -190,6 +190,136 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "clue/redis-protocol", + "version": "v0.3.2", + "source": { + "type": "git", + "url": "https://github.com/clue/redis-protocol.git", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/redis-protocol/zipball/6f565332f5531b7722d1e9c445314b91862f6d6c", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\Redis\\Protocol\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.", + "homepage": "https://github.com/clue/redis-protocol", + "keywords": [ + "parser", + "protocol", + "redis", + "resp", + "serializer", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/redis-protocol/issues", + "source": "https://github.com/clue/redis-protocol/tree/v0.3.2" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2024-08-07T11:06:28+00:00" + }, + { + "name": "clue/redis-react", + "version": "v2.8.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-redis.git", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca", + "shasum": "" + }, + "require": { + "clue/redis-protocol": "^0.3.2", + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.0 || ^1.1", + "react/promise-timer": "^1.11", + "react/socket": "^1.16" + }, + "require-dev": { + "clue/block-react": "^1.5", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\Redis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Async Redis client implementation, built on top of ReactPHP.", + "homepage": "https://github.com/clue/reactphp-redis", + "keywords": [ + "async", + "client", + "database", + "reactphp", + "redis" + ], + "support": { + "issues": "https://github.com/clue/reactphp-redis/issues", + "source": "https://github.com/clue/reactphp-redis/tree/v2.8.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2025-01-03T16:18:33+00:00" + }, { "name": "clue/stream-filter", "version": "v1.7.0", @@ -679,6 +809,53 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, { "name": "firebase/php-jwt", "version": "v6.11.1", @@ -1346,16 +1523,16 @@ }, { "name": "inertiajs/inertia-laravel", - "version": "v2.0.12", + "version": "v2.0.14", "source": { "type": "git", "url": "https://github.com/inertiajs/inertia-laravel.git", - "reference": "9170201299d741bb7f5f3baf37d8f1299a321753" + "reference": "c4a7bbefbfb9995ce2189f1665ba73276cb3cb6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/9170201299d741bb7f5f3baf37d8f1299a321753", - "reference": "9170201299d741bb7f5f3baf37d8f1299a321753", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/c4a7bbefbfb9995ce2189f1665ba73276cb3cb6f", + "reference": "c4a7bbefbfb9995ce2189f1665ba73276cb3cb6f", "shasum": "" }, "require": { @@ -1410,9 +1587,9 @@ ], "support": { "issues": "https://github.com/inertiajs/inertia-laravel/issues", - "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.12" + "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.14" }, - "time": "2025-12-09T08:56:23+00:00" + "time": "2025-12-10T13:29:20+00:00" }, { "name": "intervention/gif", @@ -2110,6 +2287,88 @@ }, "time": "2025-11-21T20:52:52+00:00" }, + { + "name": "laravel/reverb", + "version": "v1.6.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/reverb.git", + "reference": "b97d21650bcfaa462dfa4735048dbc33359514e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/reverb/zipball/b97d21650bcfaa462dfa4735048dbc33359514e1", + "reference": "b97d21650bcfaa462dfa4735048dbc33359514e1", + "shasum": "" + }, + "require": { + "clue/redis-react": "^2.6", + "guzzlehttp/psr7": "^2.6", + "illuminate/console": "^10.47|^11.0|^12.0", + "illuminate/contracts": "^10.47|^11.0|^12.0", + "illuminate/http": "^10.47|^11.0|^12.0", + "illuminate/support": "^10.47|^11.0|^12.0", + "laravel/prompts": "^0.1.15|^0.2.0|^0.3.0", + "php": "^8.2", + "pusher/pusher-php-server": "^7.2", + "ratchet/rfc6455": "^0.4", + "react/promise-timer": "^1.10", + "react/socket": "^1.14", + "symfony/console": "^6.0|^7.0", + "symfony/http-foundation": "^6.3|^7.0" + }, + "require-dev": { + "orchestra/testbench": "^8.36|^9.15|^10.8", + "pestphp/pest": "^2.0|^3.0|^4.0", + "phpstan/phpstan": "^1.10", + "ratchet/pawl": "^0.4.1", + "react/async": "^4.2", + "react/http": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Output": "Laravel\\Reverb\\Output" + }, + "providers": [ + "Laravel\\Reverb\\ApplicationManagerServiceProvider", + "Laravel\\Reverb\\ReverbServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Reverb\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Joe Dixon", + "email": "joe@laravel.com" + } + ], + "description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.", + "keywords": [ + "WebSockets", + "laravel", + "real-time", + "websocket" + ], + "support": { + "issues": "https://github.com/laravel/reverb/issues", + "source": "https://github.com/laravel/reverb/tree/v1.6.3" + }, + "time": "2025-11-28T20:12:49+00:00" + }, { "name": "laravel/sanctum", "version": "v4.2.1", @@ -4028,6 +4287,102 @@ }, "time": "2020-10-15T08:29:30+00:00" }, + { + "name": "paragonie/sodium_compat", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/sodium_compat.git", + "reference": "547e2dc4d45107440e76c17ab5a46e4252460158" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/547e2dc4d45107440e76c17ab5a46e4252460158", + "reference": "547e2dc4d45107440e76c17ab5a46e4252460158", + "shasum": "" + }, + "require": { + "php": "^8.1", + "php-64bit": "*" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^7|^8|^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "suggest": { + "ext-sodium": "Better performance, password hashing (Argon2i), secure memory management (memzero), and better security." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "ParagonIE\\Sodium\\": "namespaced/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com" + }, + { + "name": "Frank Denis", + "email": "jedisct1@pureftpd.org" + } + ], + "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists", + "keywords": [ + "Authentication", + "BLAKE2b", + "ChaCha20", + "ChaCha20-Poly1305", + "Chapoly", + "Curve25519", + "Ed25519", + "EdDSA", + "Edwards-curve Digital Signature Algorithm", + "Elliptic Curve Diffie-Hellman", + "Poly1305", + "Pure-PHP cryptography", + "RFC 7748", + "RFC 8032", + "Salpoly", + "Salsa20", + "X25519", + "XChaCha20-Poly1305", + "XSalsa20-Poly1305", + "Xchacha20", + "Xsalsa20", + "aead", + "cryptography", + "ecdh", + "elliptic curve", + "elliptic curve cryptography", + "encryption", + "libsodium", + "php", + "public-key cryptography", + "secret-key cryptography", + "side-channel resistant" + ], + "support": { + "issues": "https://github.com/paragonie/sodium_compat/issues", + "source": "https://github.com/paragonie/sodium_compat/tree/v2.4.0" + }, + "time": "2025-10-06T08:47:40+00:00" + }, { "name": "php-http/client-common", "version": "2.7.3", @@ -5145,6 +5500,67 @@ }, "time": "2025-12-07T03:39:01+00:00" }, + { + "name": "pusher/pusher-php-server", + "version": "7.2.7", + "source": { + "type": "git", + "url": "https://github.com/pusher/pusher-http-php.git", + "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/148b0b5100d000ed57195acdf548a2b1b38ee3f7", + "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.2", + "paragonie/sodium_compat": "^1.6|^2.0", + "php": "^7.3|^8.0", + "psr/log": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "overtrue/phplint": "^2.3", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Pusher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Library for interacting with the Pusher REST API", + "keywords": [ + "events", + "messaging", + "php-pusher-server", + "publish", + "push", + "pusher", + "real time", + "real-time", + "realtime", + "rest", + "trigger" + ], + "support": { + "issues": "https://github.com/pusher/pusher-http-php/issues", + "source": "https://github.com/pusher/pusher-http-php/tree/7.2.7" + }, + "time": "2025-01-06T10:56:20+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -5267,20 +5683,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -5339,21 +5755,610 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.1" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "time": "2025-09-04T20:59:21+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { - "name": "rinvex/countries", - "version": "v9.1.0", + "name": "ratchet/rfc6455", + "version": "v0.4.0", "source": { "type": "git", - "url": "https://github.com/rinvex/countries.git", - "reference": "7ece458023997be0a1ea2b547b3919818b70a6c1" + "url": "https://github.com/ratchetphp/RFC6455.git", + "reference": "859d95f85dda0912c6d5b936d036d044e3af47ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rinvex/countries/zipball/7ece458023997be0a1ea2b547b3919818b70a6c1", + "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/859d95f85dda0912c6d5b936d036d044e3af47ef", + "reference": "859d95f85dda0912c6d5b936d036d044e3af47ef", + "shasum": "" + }, + "require": { + "php": ">=7.4", + "psr/http-factory-implementation": "^1.0", + "symfony/polyfill-php80": "^1.15" + }, + "require-dev": { + "guzzlehttp/psr7": "^2.7", + "phpunit/phpunit": "^9.5", + "react/socket": "^1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\RFC6455\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], + "description": "RFC6455 WebSocket protocol handler", + "homepage": "http://socketo.me", + "keywords": [ + "WebSockets", + "rfc6455", + "websocket" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/ratchetphp/RFC6455/issues", + "source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.0" + }, + "time": "2025-02-24T01:18:22+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/promise-timer", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise-timer.git", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7.0 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\Timer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.", + "homepage": "https://github.com/reactphp/promise-timer", + "keywords": [ + "async", + "event-loop", + "promise", + "reactphp", + "timeout", + "timer" + ], + "support": { + "issues": "https://github.com/reactphp/promise-timer/issues", + "source": "https://github.com/reactphp/promise-timer/tree/v1.11.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-04T14:27:45+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "rinvex/countries", + "version": "v9.1.0", + "source": { + "type": "git", + "url": "https://github.com/rinvex/countries.git", + "reference": "7ece458023997be0a1ea2b547b3919818b70a6c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rinvex/countries/zipball/7ece458023997be0a1ea2b547b3919818b70a6c1", "reference": "7ece458023997be0a1ea2b547b3919818b70a6c1", "shasum": "" }, @@ -6202,16 +7207,16 @@ }, { "name": "spatie/laravel-permission", - "version": "6.23.0", + "version": "6.24.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-permission.git", - "reference": "9e41247bd512b1e6c229afbc1eb528f7565ae3bb" + "reference": "76adb1fc8d07c16a0721c35c4cc330b7a12598d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/9e41247bd512b1e6c229afbc1eb528f7565ae3bb", - "reference": "9e41247bd512b1e6c229afbc1eb528f7565ae3bb", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/76adb1fc8d07c16a0721c35c4cc330b7a12598d7", + "reference": "76adb1fc8d07c16a0721c35c4cc330b7a12598d7", "shasum": "" }, "require": { @@ -6273,7 +7278,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-permission/issues", - "source": "https://github.com/spatie/laravel-permission/tree/6.23.0" + "source": "https://github.com/spatie/laravel-permission/tree/6.24.0" }, "funding": [ { @@ -6281,7 +7286,7 @@ "type": "github" } ], - "time": "2025-11-03T20:16:13+00:00" + "time": "2025-12-13T21:45:21+00:00" }, { "name": "spatie/laravel-signal-aware-command", @@ -6659,20 +7664,20 @@ }, { "name": "symfony/css-selector", - "version": "v7.4.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135" + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/ab862f478513e7ca2fe9ec117a6f01a8da6e1135", - "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "type": "library", "autoload": { @@ -6704,7 +7709,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.0" + "source": "https://github.com/symfony/css-selector/tree/v8.0.0" }, "funding": [ { @@ -6724,7 +7729,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T13:39:42+00:00" + "time": "2025-10-30T14:17:19+00:00" }, { "name": "symfony/deprecation-contracts", @@ -9117,23 +10122,23 @@ }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "v2.3.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^2.0", @@ -9166,9 +10171,9 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" }, - "time": "2024-12-21T16:25:41+00:00" + "time": "2025-12-02T11:56:42+00:00" }, { "name": "typesense/typesense-php", diff --git a/config/broadcasting.php b/config/broadcasting.php index f683704..74ad9b7 100644 --- a/config/broadcasting.php +++ b/config/broadcasting.php @@ -15,7 +15,7 @@ | */ - 'default' => env('BROADCAST_DRIVER', 'null'), + 'default' => env('BROADCAST_CONNECTION', env('BROADCAST_DRIVER', 'null')), /* |-------------------------------------------------------------------------- diff --git a/config/guacpanel.php b/config/guacpanel.php index bdc3bed..7623a2e 100644 --- a/config/guacpanel.php +++ b/config/guacpanel.php @@ -80,4 +80,18 @@ 'password' => env('APP_DEMO_LOGIN_PASSWORD', null), ], ], + + /* + |-------------------------------------------------------------------------- + | Notification Settings + |-------------------------------------------------------------------------- + */ + 'notifications' => [ + 'enabled' => env('APP_NOTIFICATIONS_ENABLED', true), + 'in_demo' => env('APP_NOTIFICATIONS_IN_DEMO_MODE', true), + 'auto_cleanup_deleted' => env('APP_NOTIFICATIONS_AUTO_CLEANUP_DELETED_ENABLED', true), + 'auto_cleanup_deleted_days' => env('APP_NOTIFICATIONS_AUTO_CLEANUP_DELETED_DAYS', 60), + 'auto_clean_send_email' => env('APP_NOTIFICATIONS_AUTO_CLEANUP_SEND_EMAIL', false), + 'auto_clean_send_email_to' => env('APP_NOTIFICATIONS_AUTO_CLEANUP_SEND_EMAIL_TO', null), + ], ]; diff --git a/config/reverb.php b/config/reverb.php new file mode 100644 index 0000000..3c545fa --- /dev/null +++ b/config/reverb.php @@ -0,0 +1,99 @@ + env('REVERB_SERVER', 'reverb'), + + /* + |-------------------------------------------------------------------------- + | Reverb Servers + |-------------------------------------------------------------------------- + | + | Here you may define details for each of the supported Reverb servers. + | Each server has its own configuration options that are defined in + | the array below. You should ensure all the options are present. + | + */ + + 'servers' => [ + + 'reverb' => [ + 'host' => env('REVERB_SERVER_HOST', '0.0.0.0'), + 'port' => env('REVERB_SERVER_PORT', 8080), + 'path' => env('REVERB_SERVER_PATH', ''), + 'hostname' => env('REVERB_HOST'), + 'options' => [ + 'tls' => [ + 'local_cert' => env('REVERB_TLS_CERT'), + 'local_pk' => env('REVERB_TLS_KEY'), + 'verify_peer' => false, + ], + ], + 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), + 'scaling' => [ + 'enabled' => env('REVERB_SCALING_ENABLED', false), + 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), + 'server' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', '6379'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'database' => env('REDIS_DB', '0'), + 'timeout' => env('REDIS_TIMEOUT', 60), + ], + ], + 'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15), + 'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Reverb Applications + |-------------------------------------------------------------------------- + | + | Here you may define how Reverb applications are managed. If you choose + | to use the "config" provider, you may define an array of apps which + | your server will support, including their connection credentials. + | + */ + + 'apps' => [ + + 'provider' => 'config', + + 'apps' => [ + [ + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'allowed_origins' => ['*'], + 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), + 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), + 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), + 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + ], + ], + + ], + +]; diff --git a/database/migrations/2025_12_12_084613_create_app_notifications_table.php b/database/migrations/2025_12_12_084613_create_app_notifications_table.php new file mode 100644 index 0000000..8be24e9 --- /dev/null +++ b/database/migrations/2025_12_12_084613_create_app_notifications_table.php @@ -0,0 +1,41 @@ +ulid('id')->primary(); + + $table->foreignUlid('user_id') + ->nullable() + ->constrained('users') + ->nullOnDelete(); + + $table->string('scope')->default('user'); // user|system|release + $table->string('type')->default('info'); // info|success|warning|error + $table->string('title')->nullable(); + $table->text('message'); + $table->json('data')->nullable(); + + $table->boolean('sent_as_scheduled')->default(false)->index(); + $table->timestamp('scheduled_on')->nullable()->index(); + $table->timestamp('auto_expire_on')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['sent_as_scheduled', 'scheduled_on'], 'app_notifications_sched_send_idx'); + $table->index(['scope', 'created_at']); + $table->index('auto_expire_on'); + $table->index('deleted_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('app_notifications'); + } +}; diff --git a/database/migrations/2025_12_12_092732_create_app_notification_reads_table.php b/database/migrations/2025_12_12_092732_create_app_notification_reads_table.php new file mode 100644 index 0000000..c998dc0 --- /dev/null +++ b/database/migrations/2025_12_12_092732_create_app_notification_reads_table.php @@ -0,0 +1,40 @@ +ulid('id')->primary(); + + $table->foreignUlid('app_notification_id') + ->constrained('app_notifications') + ->cascadeOnDelete(); + + $table->foreignUlid('user_id') + ->constrained('users') + ->cascadeOnDelete(); + + $table->timestamp('read_at')->nullable(); + $table->timestamp('dismissed_at')->nullable(); + $table->timestamp('u_del_notif_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + + $table->unique(['app_notification_id', 'user_id']); + + $table->index(['user_id', 'read_at']); + $table->index(['user_id', 'dismissed_at']); + $table->index(['user_id', 'u_del_notif_at'], 'anr_u_d_notif_at_idx'); + $table->index(['user_id', 'deleted_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('app_notification_reads'); + } +}; diff --git a/database/seeders/PermissionRoleSeeder.php b/database/seeders/PermissionRoleSeeder.php index 4032671..3e3e7ad 100644 --- a/database/seeders/PermissionRoleSeeder.php +++ b/database/seeders/PermissionRoleSeeder.php @@ -56,6 +56,14 @@ private function createPermissions(): array // Login History 'view-login-history' => 'View user login history', + + // App Notifications + 'view-notifications' => 'User Can View Notifications', + 'edit-notifications' => 'User Can Edit, Mark as Read, and Dismiss Notifications', + 'create-notifications' => 'User Can Create Notifications', + 'delete-notifications' => 'User Can Delete Notifications', + + 'manage-notifications' => 'Admin Can Manage Notifications', ]; $permissions = []; @@ -98,6 +106,10 @@ private function assignPermissions(array $roles, array $permissions): void $roles['user']->syncPermissions([ $permissions['edit-profile'], $permissions['dashboard-view'], + $permissions['view-notifications'], + $permissions['edit-notifications'], + $permissions['create-notifications'], + $permissions['delete-notifications'], ]); } } diff --git a/package-lock.json b/package-lock.json index e7820b8..3085de9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "prettier-plugin-blade": "^2.1.21", "typesense-instantsearch-adapter": "^2.9.0", "vue": "^3.5.13", + "vue-dompurify-html": "^5.3.0", "vue-filepond": "^7.0.4", "vue-instantsearch": "^4.21.0", "vue-prism-component": "^2.0.0", @@ -34,6 +35,7 @@ "ziggy-js": "^2.3.0" }, "devDependencies": { + "@laravel/echo-vue": "^2.2.6", "@prettier/plugin-php": "^0.24.0", "@rushstack/eslint-patch": "^1.12.0", "@tailwindcss/forms": "^0.5.10", @@ -47,11 +49,13 @@ "eslint-config-prettier": "^10.1.8", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-vue": "^10.6.2", + "laravel-echo": "^2.2.6", "laravel-vite-plugin": "^2.0.0", "postcss": "^8.5.1", "prettier": "^3.7.4", "prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-tailwindcss": "^0.7.2", + "pusher-js": "^8.4.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.1", "terser": "^5.39.2", @@ -61,13 +65,13 @@ } }, "node_modules/@ai-sdk/gateway": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.18.tgz", - "integrity": "sha512-sDQcW+6ck2m0pTIHW6BPHD7S125WD3qNkx/B8sEzJp/hurocmJ5Cni0ybExg6sQMGo+fr/GWOwpHF1cmCdg5rQ==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.21.tgz", + "integrity": "sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.18", + "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "engines": { @@ -90,9 +94,9 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "3.0.18", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.18.tgz", - "integrity": "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==", + "version": "3.0.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.19.tgz", + "integrity": "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "2.0.0", @@ -925,9 +929,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -1023,25 +1027,27 @@ } }, "node_modules/@inertiajs/core": { - "version": "2.2.20", - "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.2.20.tgz", - "integrity": "sha512-sfMlbutO2/bP9e5swUFo/shu/+QsDisXXXyRIQ1dJeBAawcDZGT7rSTBGFuzE2fdF6UYDBvY9QHMAbzAOlVf0A==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.1.tgz", + "integrity": "sha512-K/v/e+HDDkC3FFLbi6GePU49cvYtRKx+KexslotudR3k/JwpKuwseo+sp4tSVPr3FkEfIP7VcC8kHOXSrOmobQ==", "license": "MIT", "dependencies": { "@types/lodash-es": "^4.17.12", "axios": "^1.13.2", + "laravel-precognition": "^1.0.0", "lodash-es": "^4.17.21", "qs": "^6.14.0" } }, "node_modules/@inertiajs/vue3": { - "version": "2.2.20", - "resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.2.20.tgz", - "integrity": "sha512-KK4cbNNfu7rZqG8Q+u2HmbKTNub7a2oJvPsScOXYDYBf+8dEagJXZ69HY3GCVNrWv6E5u2hTZsNOjVCw8b6Lkw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.3.1.tgz", + "integrity": "sha512-UwLL7bITpw4jupgcs5TK+INxK1XLwhy69qQufiBxyE8lfDuhsJ/fvZYkZEYchvy24rpqhM5RVU69JabaGaspMQ==", "license": "MIT", "dependencies": { - "@inertiajs/core": "2.2.20", + "@inertiajs/core": "2.3.1", "@types/lodash-es": "^4.17.12", + "laravel-precognition": "^1.0.0", "lodash-es": "^4.17.21" }, "peerDependencies": { @@ -1108,6 +1114,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@laravel/echo-vue": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@laravel/echo-vue/-/echo-vue-2.2.6.tgz", + "integrity": "sha512-nznzN1BYVYR+DQ/JLHvl4qKLioBhzQjGsscWeXKLLDNUgOkbZNpx8/tF+ycUtzEPB4fR88BNqBMXs5P60TvBrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "pusher-js": "*", + "socket.io-client": "*", + "vue": "^3.0.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -1132,9 +1153,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz", - "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", "dev": true, "license": "MIT" }, @@ -1453,6 +1474,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -1529,9 +1558,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", - "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1541,37 +1570,37 @@ "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.17" + "tailwindcss": "4.1.18" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", - "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", "dev": true, "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-x64": "4.1.17", - "@tailwindcss/oxide-freebsd-x64": "4.1.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-x64-musl": "4.1.17", - "@tailwindcss/oxide-wasm32-wasi": "4.1.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", - "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", "cpu": [ "arm64" ], @@ -1586,9 +1615,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", - "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", "cpu": [ "arm64" ], @@ -1603,9 +1632,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", - "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", "cpu": [ "x64" ], @@ -1620,9 +1649,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", - "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", "cpu": [ "x64" ], @@ -1637,9 +1666,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", - "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", "cpu": [ "arm" ], @@ -1654,9 +1683,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", - "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", "cpu": [ "arm64" ], @@ -1671,9 +1700,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", - "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", "cpu": [ "arm64" ], @@ -1688,9 +1717,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", - "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", "cpu": [ "x64" ], @@ -1705,9 +1734,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", - "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", "cpu": [ "x64" ], @@ -1722,9 +1751,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", - "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1740,10 +1769,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.6.0", - "@emnapi/runtime": "^1.6.0", + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", + "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, @@ -1752,9 +1781,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", - "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", "cpu": [ "arm64" ], @@ -1769,9 +1798,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", - "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", "cpu": [ "x64" ], @@ -1786,29 +1815,29 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz", - "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", - "tailwindcss": "4.1.17" + "tailwindcss": "4.1.18" } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", - "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "tailwindcss": "4.1.17" + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" @@ -1915,6 +1944,13 @@ "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1931,19 +1967,19 @@ } }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz", - "integrity": "sha512-iHmwV3QcVGGvSC1BG5bZ4z6iwa1SOpAPWmnjOErd4Ske+lZua5K9TtAVdx0gMBClJ28DViCbSmZitjWZsWO3LA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", + "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.50" + "@rolldown/pluginutils": "1.0.0-beta.53" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, @@ -2083,14 +2119,14 @@ } }, "node_modules/ai": { - "version": "5.0.108", - "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.108.tgz", - "integrity": "sha512-Jex3Lb7V41NNpuqJHKgrwoU6BCLHdI1Pg4qb4GJH4jRIDRXUBySJErHjyN4oTCwbiYCeb/8II9EnqSRPq9EifA==", + "version": "5.0.113", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.113.tgz", + "integrity": "sha512-26vivpSO/mzZj0k1Si2IpsFspp26ttQICHRySQiMrtWcRd5mnJMX2a8sG28vmZ38C+JUn1cWmfZrsLMxkSMw9g==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "2.0.18", + "@ai-sdk/gateway": "2.0.21", "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.18", + "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "engines": { @@ -2254,9 +2290,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz", - "integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==", + "version": "2.9.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", + "integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2569,6 +2605,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -2613,10 +2658,55 @@ "dev": true, "license": "ISC" }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2750,9 +2840,9 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "dependencies": { @@ -2762,7 +2852,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3477,9 +3567,9 @@ } }, "node_modules/instantsearch.js": { - "version": "4.85.1", - "resolved": "https://registry.npmjs.org/instantsearch.js/-/instantsearch.js-4.85.1.tgz", - "integrity": "sha512-fmFbTUrONO/1V7VdI4Ykcof4HOD2S86vuvwfvdaZFgI11+JXT18G5RjjpGLMEtrypDOwZFSNxkvWXnNmB8zz/g==", + "version": "4.85.2", + "resolved": "https://registry.npmjs.org/instantsearch.js/-/instantsearch.js-4.85.2.tgz", + "integrity": "sha512-GNrsiD7JRaXOBQ+fT0zPjBKk9hJaZMe1Cr80ZcW+ZgRcGCZF+DRT/DStDcVqc3BrWnOzbebS/d+Ffle4gZ3gNQ==", "license": "MIT", "dependencies": { "@algolia/events": "^4.0.1", @@ -3605,6 +3695,30 @@ "json-buffer": "3.0.1" } }, + "node_modules/laravel-echo": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.2.6.tgz", + "integrity": "sha512-KuCldOrE8qbm0CVDBgc6FiX3VuReDu1C1xaS891KqwEUg9NT/Op03iiZqTWeVd0/WJ4H95q2pe9QEDJlwb/FPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "pusher-js": "*", + "socket.io-client": "*" + } + }, + "node_modules/laravel-precognition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-1.0.0.tgz", + "integrity": "sha512-hvXPT7dayCQAidxnsY0hab9Q+Y2rsh7xRpH9uiFtXN8Dekc3tIZt+NrxrOZ9N5SwHBmRBze/Bv+ElfXac0kD6g==", + "license": "MIT", + "dependencies": { + "axios": "^1.4.0", + "lodash-es": "^4.17.21" + } + }, "node_modules/laravel-vite-plugin": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz", @@ -4533,6 +4647,16 @@ "node": ">=6" } }, + "node_modules/pusher-js": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz", + "integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tweetnacl": "^1.0.3" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -4561,9 +4685,9 @@ } }, "node_modules/react": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", - "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4745,6 +4869,76 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4813,9 +5007,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "dev": true, "license": "MIT" }, @@ -4885,6 +5079,13 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "dev": true, + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5137,6 +5338,18 @@ } } }, + "node_modules/vue-dompurify-html": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/vue-dompurify-html/-/vue-dompurify-html-5.3.0.tgz", + "integrity": "sha512-HJQGBHbfSPcb6Mu97McdKbX7TqRHZa6Ji8OCpCNyuHca5QvQZ8IiuwghFPSO8OkSQfqXPNPKFMZdCOrnGGmOSQ==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.2.5" + }, + "peerDependencies": { + "vue": "^3.4.36" + } + }, "node_modules/vue-eslint-parser": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz", @@ -5173,13 +5386,13 @@ } }, "node_modules/vue-instantsearch": { - "version": "4.22.5", - "resolved": "https://registry.npmjs.org/vue-instantsearch/-/vue-instantsearch-4.22.5.tgz", - "integrity": "sha512-I5P/EPf1xQw/828AUsJxIYvTOojJBf5EHm31mUD2U3A8hb1MpzUOoD5W1np/Qb8ABtcG76vk1Hzw3JDdYGUprw==", + "version": "4.22.6", + "resolved": "https://registry.npmjs.org/vue-instantsearch/-/vue-instantsearch-4.22.6.tgz", + "integrity": "sha512-jbUjCsvUgXRY1vGHdAdArHjZMaehqzD1AvIRRKR8NB6Eu3hkLlYnWkSRbuZDMirWtchIpZUQKjQd+v7lOfZxkQ==", "license": "MIT", "dependencies": { "instantsearch-ui-components": "0.15.2", - "instantsearch.js": "4.85.1", + "instantsearch.js": "4.85.2", "mitt": "^2.1.0" }, "peerDependencies": { @@ -5239,6 +5452,29 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -5249,6 +5485,16 @@ "node": ">=12" } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 4d8856f..19710af 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "clean": "prettier --write \"resources/**/*.{js,vue,css,blade.php}\"" }, "devDependencies": { + "@laravel/echo-vue": "^2.2.6", "@prettier/plugin-php": "^0.24.0", "@rushstack/eslint-patch": "^1.12.0", "@tailwindcss/forms": "^0.5.10", @@ -24,11 +25,13 @@ "eslint-config-prettier": "^10.1.8", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-vue": "^10.6.2", + "laravel-echo": "^2.2.6", "laravel-vite-plugin": "^2.0.0", "postcss": "^8.5.1", "prettier": "^3.7.4", "prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-tailwindcss": "^0.7.2", + "pusher-js": "^8.4.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.1", "terser": "^5.39.2", @@ -59,6 +62,7 @@ "prettier-plugin-blade": "^2.1.21", "typesense-instantsearch-adapter": "^2.9.0", "vue": "^3.5.13", + "vue-dompurify-html": "^5.3.0", "vue-filepond": "^7.0.4", "vue-instantsearch": "^4.21.0", "vue-prism-component": "^2.0.0", diff --git a/resources/css/app.css b/resources/css/app.css index f68c5fd..bd92f13 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,6 +1,11 @@ @import 'tailwindcss'; @import 'tw-animate-css'; +@theme { + --breakpoint-xxs: 360px; + --breakpoint-xs: 480px; +} + @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; @source '../views'; @@ -18,4 +23,5 @@ @import '@css/partials/typography.css'; @import '@css/partials/wells.css'; @import '@css/partials/cards.css'; +@import '@css/partials/notifications.css'; @import '~/nprogress/nprogress.css'; diff --git a/resources/css/datatable.css b/resources/css/datatable.css deleted file mode 100644 index e26be1f..0000000 --- a/resources/css/datatable.css +++ /dev/null @@ -1,149 +0,0 @@ -/* Modern DataTable Styling */ -.datatable-card { - background-color: white; - border-radius: 0.75rem; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); - padding: 1.5rem; -} - -/* Table Container */ -.datatable-modern { - width: 100%; - border-collapse: separate; - border-spacing: 0; - margin: 0; -} - -/* Header Styling */ -.datatable-modern thead th { - background-color: #f8fafc; - color: #334155; - font-weight: 600; - padding: 1rem 1.5rem; - border-bottom: 2px solid #e2e8f0; - text-align: left; - font-size: 0.875rem; - transition: background-color 0.2s; -} - -/* Sorting Icons */ -.datatable-modern thead th.sorting:after, -.datatable-modern thead th.sorting_asc:after, -.datatable-modern thead th.sorting_desc:after { - opacity: 0.5; - margin-left: 0.5rem; -} - -/* Body Styling */ -.datatable-modern tbody td { - padding: 1rem 1.5rem; - border-bottom: 1px solid #e2e8f0; - color: #475569; - font-size: 0.875rem; -} - -/* Row Hover Effect */ -.datatable-modern tbody tr:hover { - background-color: #f8fafc; -} - -/* Search Input */ -.dataTables_filter input { - border: 1px solid #e2e8f0; - border-radius: 0.5rem; - padding: 0.5rem 1rem; - margin-left: 0.5rem; - outline: none; - transition: all 0.2s; -} - -.dataTables_filter input:focus { - border-color: #2563eb; - box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); -} - -/* Length Selection */ -.dataTables_length select { - border: 1px solid #e2e8f0; - border-radius: 0.5rem; - padding: 0.5rem 2rem 0.5rem 1rem; - margin: 0 0.5rem; - outline: none; - appearance: none; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); - background-position: right 0.5rem center; - background-repeat: no-repeat; - background-size: 1.5em 1.5em; -} - -/* Pagination Buttons */ -.dataTables_paginate { - margin-top: 1rem; -} - -.dataTables_paginate .paginate_button { - padding: 0.5rem 1rem; - margin: 0 0.25rem; - border-radius: 0.5rem; - border: 1px solid #e2e8f0; - color: #64748b; - transition: all 0.2s; -} - -.dataTables_paginate .paginate_button:hover:not(.disabled):not(.current) { - background-color: #f8fafc; - color: #334155; - border-color: #cbd5e1; -} - -.dataTables_paginate .paginate_button.current { - background-color: #2563eb; - color: white; - border-color: #2563eb; -} - -.dataTables_paginate .paginate_button.disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Info Text */ -.dataTables_info { - color: #64748b; - font-size: 0.875rem; - margin-top: 1rem; -} - -/* Processing Message */ -.dataTables_processing { - background-color: rgba(255, 255, 255, 0.9); - border-radius: 0.5rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - padding: 1rem; -} - -/* Responsive Design */ -@media (max-width: 768px) { - .dataTables_wrapper { - padding: 0; - } - - .dataTables_filter input { - width: 100%; - margin-left: 0; - margin-top: 0.5rem; - } - - .dataTables_length, - .dataTables_filter { - text-align: left; - margin-bottom: 1rem; - } - - .dataTables_paginate { - display: flex; - justify-content: center; - flex-wrap: wrap; - gap: 0.5rem; - } -} diff --git a/resources/css/partials/badges.css b/resources/css/partials/badges.css index becea9a..3607e61 100644 --- a/resources/css/partials/badges.css +++ b/resources/css/partials/badges.css @@ -29,3 +29,43 @@ background-color: var(--error-color); } } + +@layer components { + .badge { + @apply inline-flex items-center rounded-full font-medium; + } + .badge-xxs { + @apply text-xxs px-1.5 py-0.5; + } + .badge-xs { + @apply px-2 py-0.5 text-xs; + } + .badge-sm { + @apply px-2.5 py-1 text-sm; + } +} + +.notification-badge { + @apply inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium; + + &.notification-badge-success { + @apply border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400; + } + + &.notification-badge-info { + @apply border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400; + } + + &.notification-badge-warning { + @apply border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400; + } + + &.notification-badge-error, + &.notification-badge-danger { + @apply border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400; + } + + &.notification-badge-default { + @apply border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-900/50 dark:bg-gray-900/20 dark:text-gray-300; + } +} diff --git a/resources/css/partials/buttons.css b/resources/css/partials/buttons.css index 7060bf7..75ff5bc 100644 --- a/resources/css/partials/buttons.css +++ b/resources/css/partials/buttons.css @@ -151,7 +151,7 @@ /******************** *** Sizes ********** ********************/ - + /* &.btn-sm { padding: 0.5rem 1rem; font-size: 0.875rem; @@ -166,5 +166,37 @@ padding: 0.1rem 0.5rem; font-size: 0.65rem; } + */ + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center rounded-lg font-medium transition select-none disabled:pointer-events-none disabled:opacity-50; + } + .btn-xxs { + @apply text-xxs h-6 px-2; + } + .btn-xs { + @apply h-7 px-2.5 text-xs; + } + .btn-sm { + @apply h-8 px-3 text-sm; + } + .btn-md { + @apply h-9 px-3.5 text-sm; + } + .btn-lg { + @apply h-10 px-4 text-base; + } +} + +a, +.btn, +button { + @apply cursor-pointer; + &:disabled, + &.disabled { + @apply cursor-not-allowed; } } diff --git a/resources/css/partials/notifications.css b/resources/css/partials/notifications.css new file mode 100644 index 0000000..ec7d4cd --- /dev/null +++ b/resources/css/partials/notifications.css @@ -0,0 +1,15 @@ +.notification-badge-error { + @apply border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-300; +} + +.notification-badge-warning { + @apply border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-300; +} + +.notification-badge-success { + @apply border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-900/20 dark:text-emerald-300; +} + +.notification-badge-default { + @apply border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-300; +} diff --git a/resources/css/partials/tables.css b/resources/css/partials/tables.css index 22bf3d2..2d649f8 100644 --- a/resources/css/partials/tables.css +++ b/resources/css/partials/tables.css @@ -21,3 +21,12 @@ text-transform: uppercase; letter-spacing: 0.05em; } + +.notifications-data-table { + th, + td { + &:nth-child(6) { + @apply min-w-80; + } + } +} diff --git a/resources/css/partials/theme.css b/resources/css/partials/theme.css index b48ca6a..ffa4d07 100644 --- a/resources/css/partials/theme.css +++ b/resources/css/partials/theme.css @@ -54,6 +54,9 @@ } @theme { + --breakpoint-xxs: 360px; + --breakpoint-xs: 480px; + --font-sans: 'Inter', sans-serif; --primary-gradient-from: #0d9488; @@ -74,6 +77,19 @@ --success-color: #48bb78; --info-color: #54b4d3; --warning-color: #eab308; + + --text-xxs: 0.625rem; /* 10px */ + --text-xxs--line-height: 0.875rem; /* 14px */ + + --text-xs: 0.75rem; /* Tailwind default is 12px, included already */ + --text-xs--line-height: 1rem; + + --spacing-1.25: 0.3125rem; /* 5px */ + --spacing-1.5: 0.375rem; /* 6px */ + --spacing-2.5: 0.625rem; /* 10px */ + + --radius-xs: 0.25rem; + --radius-sm: 0.375rem; /* default-ish */ } /* diff --git a/resources/css/partials/typography.css b/resources/css/partials/typography.css index 91e6205..0f42245 100644 --- a/resources/css/partials/typography.css +++ b/resources/css/partials/typography.css @@ -1,18 +1,31 @@ .text-primary { color: var(--primary-color); } + .text-secondary { color: var(--secondary-color); } + .text-info { color: var(--info-color); } + .text-success { color: var(--success-color); } + .text-warning { color: var(--warning-color); } + .text-danger { color: var(--error-color); } + +.text-xxs { + font-size: 0.65em; +} + +.text-xxxs { + font-size: 0.5em; +} diff --git a/resources/js/Components/Common/Datatable.vue b/resources/js/Components/Common/Datatable.vue index 31a630c..18fbe6f 100644 --- a/resources/js/Components/Common/Datatable.vue +++ b/resources/js/Components/Common/Datatable.vue @@ -229,6 +229,14 @@ const getColumnHeader = column => { return '' } +const getExportValueForColumn = (column, row) => { + if (column?.meta?.excludeFromExport) return null + if (typeof column?.meta?.exportValue === 'function') return column.meta.exportValue(row) + if (typeof column?.accessorFn === 'function') return column.accessorFn(row) + if (column?.accessorKey) return row?.[column.accessorKey] + return null +} + const exportToCSV = () => { const rowsToExport = hasSelection.value ? table.getSelectedRowModel().rows @@ -240,29 +248,26 @@ const exportToCSV = () => { } const rowData = {} + props.columns.forEach(column => { - if (column.accessorKey) { - const header = getColumnHeader(column) - const value = column.accessorFn - ? column.accessorFn(row.original) - : row.original[column.accessorKey] - rowData[header] = formatValueForCSV(value) - } else if (column.id && !column.id.startsWith('_')) { - const header = getColumnHeader(column) - const cell = row.getVisibleCells().find(c => c.column.id === column.id) - if (cell?.getValue) { - rowData[header] = formatValueForCSV(cell.getValue()) - } - } + const header = getColumnHeader(column) + if (!header) return + + const raw = getExportValueForColumn(column, row.original) + if (raw === null || raw === undefined) return + + rowData[header] = formatValueForCSV(raw) }) + return rowData }) - if (!dataToExport.length) return + const nonEmpty = dataToExport.filter(r => r && Object.keys(r).length) + if (!nonEmpty.length) return const csvContent = [ - Object.keys(dataToExport[0]).join(','), - ...dataToExport.map(row => Object.values(row).join(',')), + Object.keys(nonEmpty[0]).join(','), + ...nonEmpty.map(row => Object.values(row).join(',')), ].join('\n') const blob = new window.Blob([csvContent], { @@ -380,7 +385,6 @@ watch( diff --git a/resources/js/Components/Common/NotificationTypeBadge.vue b/resources/js/Components/Common/NotificationTypeBadge.vue new file mode 100644 index 0000000..f5489ca --- /dev/null +++ b/resources/js/Components/Common/NotificationTypeBadge.vue @@ -0,0 +1,45 @@ + + + diff --git a/resources/js/Components/Common/PageHeader.vue b/resources/js/Components/Common/PageHeader.vue index ed4cd89..f800539 100644 --- a/resources/js/Components/Common/PageHeader.vue +++ b/resources/js/Components/Common/PageHeader.vue @@ -47,12 +47,12 @@ defineProps({ class="border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-4 sm:px-6 sm:py-5">
-
+

- {{ title }} +

- {{ description }} +

+ diff --git a/resources/js/Components/Notifications/DemoNotifications.vue b/resources/js/Components/Notifications/DemoNotifications.vue new file mode 100644 index 0000000..d8eac6a --- /dev/null +++ b/resources/js/Components/Notifications/DemoNotifications.vue @@ -0,0 +1,157 @@ + + + diff --git a/resources/js/Components/Notifications/Notification.vue b/resources/js/Components/Notifications/Notification.vue index d8eac6a..432199c 100644 --- a/resources/js/Components/Notifications/Notification.vue +++ b/resources/js/Components/Notifications/Notification.vue @@ -1,85 +1,396 @@