commit cade33cfb6e11abb2d759bd2bb5ce5136715a973 Author: arvanitakis95 Date: Thu Dec 19 04:19:57 2024 +0200 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c3e9da --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Lucent Notifications + +This package is responsible for creating and pushing notifications. Notifications can be either sent via mail, broadcasted using SSE and they are stored inside the database. + +## Creating Notifications +Every notification is an instance of `LucentNotifications\Models\Notification` model. There is a static function caled ::create which facilitates the creation of the notification. + +### Mail notifications +The static::create function accepts a Mailable class as its mail parameter. Lucent Notifications will instanciate the new Mailable class with the following parameters: + - Notification $notification, + - array $userFrom and + - array $userTo +so keep that in mind when defining your own Mailable class + + +## Dispatching Notifications +Notifications are Dispatched using the `LucentNotifications\Events\NotificationsDispatcher` event in order to be dispatched. The dispatcher accepts an array of Notification objects and dispatches them to the default queue. + + +## Listening for broadcasted notifications +Notifications are broadcasteded on the `broadcast/notifications` route. Yoy can easily listen for messages using an EventSource in js. + +## Retrieving notifications +Notifications can be retrieved using the HookManager's get-notifications function. The function accepts an array of filters. +When the string 'unread' is passed, it will fetch al unread notifications. Otherwise you can filter based on key => values for the following fields: userIdFrom, type \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e2ccb93 --- /dev/null +++ b/composer.json @@ -0,0 +1,36 @@ +{ + "name": "radical/lucent-notifications", + "description": "A notifications package for Lucent", + "license": "MIT", + "authors": [ + { + "name": "Arvanitakis Konstantinos", + "email": "arvanitakis@radical-elements.com" + } + ], + "require": { + "php": "^8.3", + "guzzlehttp/guzzle": "^7.2", + "laravel/prompts": "^0.1.18", + "hashids/hashids": "^5.0.2" + + }, + "require-dev": { + "phpstan/phpstan": "^1.8", + "laravel/framework": "^10.10" + }, + "autoload": { + "psr-4": { + "LucentNotifications\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "LucentNotifications\\LucentNotificationsServiceProvider" + ] + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/src/Collections/QuietHoursCollection.php b/src/Collections/QuietHoursCollection.php new file mode 100644 index 0000000..3f6b761 --- /dev/null +++ b/src/Collections/QuietHoursCollection.php @@ -0,0 +1,14 @@ +map(fn($item) => new QuietHourSection($item)); + } +} \ No newline at end of file diff --git a/src/Config/notifications.php b/src/Config/notifications.php new file mode 100644 index 0000000..7412bce --- /dev/null +++ b/src/Config/notifications.php @@ -0,0 +1,11 @@ + [ + 'quietHours' => ['18:00-10:00'], + 'commented' => 'both', + 'assigned' => 'none', + 'mentioned' => 'none', + 'dailyBriefing' => false + ] +]; \ No newline at end of file diff --git a/src/Enums/SettingValues.php b/src/Enums/SettingValues.php new file mode 100644 index 0000000..08e20de --- /dev/null +++ b/src/Enums/SettingValues.php @@ -0,0 +1,11 @@ +map(function ($notification) { + if (!$notification instanceof Notification) { + throw new Exception("NotificationsDispatcsher only accepts an array of \LucentNotifications\Models\Notification on its constructor"); + } + }); + } +} diff --git a/src/Events/ReadNotification.php b/src/Events/ReadNotification.php new file mode 100644 index 0000000..89a1026 --- /dev/null +++ b/src/Events/ReadNotification.php @@ -0,0 +1,15 @@ +stream(function () { + while (true) { + Redis::subscribe(['user-channel-' . Session::get('user.id')], function (string $message) { + + // Flush the output buffer + $data = json_encode(['message' => $message]); + + echo "data: $data\n\n"; + ob_flush(); + + flush(); + + }); + + } + }, 200, [ + 'Cache-Control' => 'no-cache', + 'X-Accel-Buffering' => 'no', + 'Content-Type' => 'text/event-stream', + ]); + } +} \ No newline at end of file diff --git a/src/Http/web.php b/src/Http/web.php new file mode 100644 index 0000000..24bd72e --- /dev/null +++ b/src/Http/web.php @@ -0,0 +1,13 @@ + ['web'], +], function () { + + Route::get('/broadcasting/notifications', [NotificationsController::class, 'index']); + Route::get('/broadcasting/notifications/forUser', [NotificationsController::class, 'forUser']); + +}); diff --git a/src/Listeners/PushNotifications.php b/src/Listeners/PushNotifications.php new file mode 100644 index 0000000..53f4ee8 --- /dev/null +++ b/src/Listeners/PushNotifications.php @@ -0,0 +1,61 @@ +notifications)->map(function (Notification $notification) { + $this->repo->create($notification->toDB()); + $userSettings = $this->settingsService->getForUser($notification->userIdTo); + $actions = $this->calculateActions($userSettings, $notification); + foreach ($actions as $action){ + $action(); + } + }); + } + + function calculateActions(UserSettings $userSettings, Notification $notification) + { + $actions = []; + $type = $notification->type; + if ($notification->broadcast && $this->shouldBroadcast($type, $userSettings->notificationsSettings)) { + $actions[] = (fn() => Redis::publish('user-channel-' . $notification->userIdTo, json_encode($notification))); + } + if ($notification->mailClass && $this->shouldSendEmail($type, $userSettings->notificationsSettings)) { + $actions[] = (function () use ($notification) { + $userFrom = $this->userRepo->findById($notification->userIdFrom)->get()->safe(); + $userTo = $this->userRepo->findById($notification->userIdTo)->get()->safe(); + Mail::to($userTo['email'])->send(new $notification->mailClass($notification, $userFrom, $userTo)); + }); + } + return $actions; + } + + function shouldBroadcast($type, $userSettings) + { + return $userSettings->$type == SettingValues::BROADCAST || $userSettings->$type == SettingValues::BOTH; + } + + function shouldSendEmail($type, $userSettings) + { + return $userSettings->$type == SettingValues::EMAIL || $userSettings->$type == SettingValues::BOTH; + } +} diff --git a/src/Listeners/UpdateNotificationReadAt.php b/src/Listeners/UpdateNotificationReadAt.php new file mode 100644 index 0000000..74d7c42 --- /dev/null +++ b/src/Listeners/UpdateNotificationReadAt.php @@ -0,0 +1,23 @@ +repo->updateReadAt($event->notificationId); + } +} diff --git a/src/LucentNotificationsServiceProvider.php b/src/LucentNotificationsServiceProvider.php new file mode 100644 index 0000000..b7e9b3b --- /dev/null +++ b/src/LucentNotificationsServiceProvider.php @@ -0,0 +1,46 @@ +app->make(HookManager::class); + $hookManager->push("get-notifications", function (array $filters) { + $notificationsService = new NotificationService; + return $notificationsService->getForUser($filters); + }); + + Event::listen(NotificationsDispatcher::class, PushNotifications::class); + + $this->loadRoutesFrom(__DIR__ . '/Http/web.php'); + + $this->publishes([ + __DIR__ . '/Migrations/' => database_path('migrations'), + ]); + + $this->publishes([ + __DIR__ . '/Config/notifications.php' => config_path('notifications.php'), + ],"notifications"); + + } +} diff --git a/src/Migrations/create_notifications_settings.php b/src/Migrations/create_notifications_settings.php new file mode 100644 index 0000000..4a2f507 --- /dev/null +++ b/src/Migrations/create_notifications_settings.php @@ -0,0 +1,27 @@ +uuid('userId')->primary(); + $table->text('data'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notificationsSettings'); + } +}; diff --git a/src/Migrations/create_notifications_table.php b/src/Migrations/create_notifications_table.php new file mode 100644 index 0000000..0514ec1 --- /dev/null +++ b/src/Migrations/create_notifications_table.php @@ -0,0 +1,32 @@ +uuid('id')->primary(); + $table->string('type'); + $table->string('userIdFrom'); + $table->string('userIdTo'); + $table->text('data'); + $table->timestamp('readAt')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/src/Models/Notification.php b/src/Models/Notification.php new file mode 100644 index 0000000..bd5c429 --- /dev/null +++ b/src/Models/Notification.php @@ -0,0 +1,81 @@ + $this->id, + 'data' => json_encode($this->data), + 'userIdFrom' => $this->userIdFrom, + 'userIdTo' => $this->userIdTo, + 'type' => $this->type, + "created_at" => $this->created_at, + "updated_at" => $this->updated_at, + "read_at" => $this->read_at + ]; + } + + static function fromDB(stdClass $data) + { + return new Notification( + id: $data->id, + data: json_decode($data->data, true), + userIdFrom: $data->userIdFrom, + userIdTo: $data->userIdTo, + type: $data->type, + created_at: Carbon::parse($data->created_at), + updated_at: Carbon::parse($data->updated_at), + read_at: Carbon::parse($data->read_at) + ); + } + + static function create(array $data, string $userIdFrom, string $userIdTo, string $type, ?string $mailClass = null, ?bool $broadcast = true) + { + $notification = new Notification( + id: (string) Str::uuid(), + data: $data, + userIdFrom: $userIdFrom, + userIdTo: $userIdTo, + type: $type, + created_at: Carbon::now(), + updated_at: Carbon::now(), + read_at: null + ); + if ($mailClass) { + if (!class_exists($mailClass)){ + throw new Exception("\$mailClass accepts only mailable class-strings"); + } + $notification->mailClass = $mailClass; + } + if (!$broadcast) { + $notification->broadcast = false; + } + return $notification; + } +} diff --git a/src/Models/NotificationsSettings.php b/src/Models/NotificationsSettings.php new file mode 100644 index 0000000..79dc969 --- /dev/null +++ b/src/Models/NotificationsSettings.php @@ -0,0 +1,44 @@ + $this->mentioned, + 'commented' => $this->commented, + 'assigned' => $this->assigned, + ]); + } + + static function fromDB($data) + { + $decoded = json_decode($data, true); + return new NotificationsSettings( + mentioned: SettingValues::from($decoded['mentioned'] ?? 'none'), + commented: SettingValues::from($decoded['commented'] ?? 'none'), + assigned: SettingValues::from($decoded['assigned'] ?? 'none') + ); + } + + static function fromArray($data) + { + return new NotificationsSettings( + mentioned: SettingValues::from($data['mentioned'] ?? "none") , + commented: SettingValues::from($data['commented'] ?? "none"), + assigned: SettingValues::from($data['assigned'] ?? "none") + ); + } +} diff --git a/src/Models/QuietHourSection.php b/src/Models/QuietHourSection.php new file mode 100644 index 0000000..4fd429b --- /dev/null +++ b/src/Models/QuietHourSection.php @@ -0,0 +1,18 @@ +from = Carbon::parse($from); + $this->to = Carbon::parse($to); + } +} \ No newline at end of file diff --git a/src/Models/UserPreferences.php b/src/Models/UserPreferences.php new file mode 100644 index 0000000..737065c --- /dev/null +++ b/src/Models/UserPreferences.php @@ -0,0 +1,41 @@ + $this->dailyBriefing, + 'quietHours' => $this->quietHours + ]); + } + + static function fromDB($data) + { + $decoded = json_decode($data, true); + return new UserPreferences( + dailyBriefing: $decoded['dailyBriefing'], + quietHours: new Collection($decoded['quietHours']) + ); + } + + static function fromArray($data) + { + return new UserPreferences( + dailyBriefing: $data['dailyBriefing'] ?? null, + quietHours: new Collection($data['quietHours'] ?? null) + ); + } +} diff --git a/src/Models/UserSettings.php b/src/Models/UserSettings.php new file mode 100644 index 0000000..c2479dc --- /dev/null +++ b/src/Models/UserSettings.php @@ -0,0 +1,44 @@ + $this->userId, + 'notificationsSettings' => $this->notificationsSettings->toDB(), + 'userPreferences' => $this->userPreferences->toDB() + ]; + } + + static function fromDB($data) + { + if (!$data){ + return null; + } + return new UserSettings( + userId: $data->userId, + notificationsSettings: NotificationsSettings::fromDB($data->notificationsSettings), + userPreferences: UserPreferences::fromDB($data->userPreferences) + ); + } + + static function fromArray($userId, $data) + { + return new UserSettings( + userId: $userId, + notificationsSettings: NotificationsSettings::fromArray($data['notificationsSettings']), + userPreferences: UserPreferences::fromArray($data['userPreferences']) + ); + } +} diff --git a/src/Repos/NotificationsRepo.php b/src/Repos/NotificationsRepo.php new file mode 100644 index 0000000..4206909 --- /dev/null +++ b/src/Repos/NotificationsRepo.php @@ -0,0 +1,48 @@ +table('notifications'); + } + + public function getById($id){ + return $this->database()->where('id', $id)->get(); + } + + public function getForUser($userId, $filters){ + $query = $this->database()->where('userIdTo', $userId)->orderBy('created_at', 'desc')->get(); + $query = $this->parseFilters($query, $filters); + return $query->get(); + } + + public function getUnreadForUser($userId, $filters){ + $query = $this->database()->where('userIdTo', $userId)->orderBy('created_at', 'desc')->where('read_at', null); + $query = $this->parseFilters($query, $filters); + return $query->get(); + } + + public function create(array $data) + { + $this->database()->insert($data); + } + + public function updateReadAt(string $notificationId) + { + $this->database()->where('id', $notificationId)->update(['read_at' => Carbon::now()]); + } + + private function parseFilters($query, $filters) + { + foreach ($filters as $key => $value) { + $query = $query->where($key, $value); + } + return $query; + } +} \ No newline at end of file diff --git a/src/Repos/SettingsRepo.php b/src/Repos/SettingsRepo.php new file mode 100644 index 0000000..b99f4a7 --- /dev/null +++ b/src/Repos/SettingsRepo.php @@ -0,0 +1,32 @@ +table('userSettings'); + } + + public function getForUser($userId) + { + return $this->database()->where('userId', $userId)->get()->first(); + } + + public function upsert(string $userId, array $data) + { + $this->database()->upsert( + [ + 'userId' => $userId, + 'notificationsSettings' => json_encode($data['notificationsSettings']), + 'userPreferences' => json_encode($data['userPreferences']) + ], + ['userId'], + ['notificationsSettings', 'userPreferences'] + ); + } +} diff --git a/src/Services/NotificationService.php b/src/Services/NotificationService.php new file mode 100644 index 0000000..420fe40 --- /dev/null +++ b/src/Services/NotificationService.php @@ -0,0 +1,51 @@ +repo = App::make(NotificationsRepo::class); + } + + public function getForUser($filters): Result + { + if (in_array("unread", $filters)) { + unset($filters['unread']); + $filters = $this->enforceFilters($filters); + $results = $this->repo->getUnreadForUser(Session::get('user.id'), $filters); + } else { + $filters = $this->enforceFilters($filters); + $results = $this->repo->getForUser(Session::get('user.id')); + } + if (!$results) { + return new Fail; + } + $results = collect($results)->map(fn($notification) => Notification::fromDB($notification)); + return new Success($results); + } + + private function enforceFilters($filters) + { + $acceptableFilters = ['type', 'userIdFrom']; + $returnedFilters = []; + foreach ($filters as $key => $value){ + if (in_array($key, $acceptableFilters)){ + $returnedFilters[$key] = $value ; + } + } + return $returnedFilters; + } +} diff --git a/src/Services/SettingsService.php b/src/Services/SettingsService.php new file mode 100644 index 0000000..860f9f7 --- /dev/null +++ b/src/Services/SettingsService.php @@ -0,0 +1,32 @@ +repo->getForUser($userId)) ?? null; + if (!$userSettings) { + $userSettings = UserSettings::fromArray($userId, config('notifications')); + } + Redis::set("users.$userId.settings", json_encode($userSettings)); + } + return $userSettings; + } + + function updateForUser(string $userId, array $settings) + { + $this->repo->upsert($userId, $settings); + Redis::set("users.$userId.settings", json_encode($settings)); + } +}