first commit

This commit is contained in:
2024-12-19 04:19:57 +02:00
commit cade33cfb6
23 changed files with 768 additions and 0 deletions
+25
View File
@@ -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
+36
View File
@@ -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
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace LucentNotifications\Collections;
use Illuminate\Support\Collection;
use LucentNotifications\Models\QuietHourSection;
class QuietHoursCollecton extends Collection
{
function __construct($items = [])
{
return collect($items)->map(fn($item) => new QuietHourSection($item));
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
'defaults' => [
'quietHours' => ['18:00-10:00'],
'commented' => 'both',
'assigned' => 'none',
'mentioned' => 'none',
'dailyBriefing' => false
]
];
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace LucentNotifications\Enums;
enum SettingValues: string
{
case NONE = "none";
case BOTH = "both";
case EMAIL = "email";
case BROADCAST = "broadcast";
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace LucentNotifications\Events;
use Exception;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use LucentNotifications\Models\Notification;
class NotificationsDispatcher
{
use Dispatchable, SerializesModels;
function __construct(public array $notifications)
{
collect($notifications)->map(function ($notification) {
if (!$notification instanceof Notification) {
throw new Exception("NotificationsDispatcsher only accepts an array of \LucentNotifications\Models\Notification on its constructor");
}
});
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace LucentNotifications\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ReadNotification{
use Dispatchable, SerializesModels;
function __construct(public string $notificationId)
{
}
}
@@ -0,0 +1,40 @@
<?php
namespace LucentNotifications\Http\Controller;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Session;
use LucentNotifications\NotificationsRepo;
class NotificationsController{
function __construct()
{
ini_set('default_socket_timeout', -1);
}
function index()
{
return response()->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',
]);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use LucentNotifications\Http\Controller\NotificationsController;
Route::group([
'middleware' => ['web'],
], function () {
Route::get('/broadcasting/notifications', [NotificationsController::class, 'index']);
Route::get('/broadcasting/notifications/forUser', [NotificationsController::class, 'forUser']);
});
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace LucentNotifications\Listeners;
use LucentNotifications\Events\NotificationsDispatcher;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Redis;
use Lucent\Account\UserRepo;
use LucentNotifications\Models\Notification;
use LucentNotifications\Repos\NotificationsRepo;
use LucentNotifications\Services\SettingsService;
use LucentNotifications\Models\UserSettings;
use LucentNotifications\Enums\SettingValues;
class PushNotifications implements ShouldQueue
{
use Queueable;
function __construct(private NotificationsRepo $repo, private UserRepo $userRepo, private SettingsService $settingsService) {}
function handle(NotificationsDispatcher $event)
{
collect($event->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;
}
}
@@ -0,0 +1,23 @@
<?php
namespace LucentNotifications\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use LucentNotifications\Events\ReadNotification;
use LucentNotifications\NotificationsRepo;
class UpdateNotificationReadAt implements ShouldQueue
{
use Queueable;
function __construct(private NotificationsRepo $repo)
{
}
function handle(ReadNotification $event)
{
$this->repo->updateReadAt($event->notificationId);
}
}
@@ -0,0 +1,46 @@
<?php
namespace LucentNotifications;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Hive\Hook\HookManager;
use LucentNotifications\Events\NotificationsDispatcher;
use LucentNotifications\Listeners\PushNotifications;
use LucentNotifications\Services\NotificationService;
class LucentNotificationsServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
}
/**
* Bootstrap any package services.
*/
public function boot(): void
{
$hookManager = $this->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");
}
}
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notificationsSettings', function (Blueprint $table) {
$table->uuid('userId')->primary();
$table->text('data');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notificationsSettings');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->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');
}
};
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace LucentNotifications\Models;
use Carbon\Carbon;
use Exception;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Str;
use stdClass;
class Notification
{
public ?string $mailClass = null;
public bool $broadcast = true;
function __construct(
public string $id,
public array $data,
public string $userIdFrom,
public string $userIdTo,
public ?string $type,
public Carbon $created_at,
public Carbon $updated_at,
public ?Carbon $read_at,
) {}
public function toDB()
{
return [
'id' => $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;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace LucentNotifications\Models;
use Illuminate\Support\Collection;
use LucentNotifications\Enums\SettingValues;
class NotificationsSettings
{
function __construct(
public ?SettingValues $mentioned,
public ?SettingValues $commented,
public ?SettingValues $assigned,
) {}
function toDB()
{
return json_encode([
'mentioned' => $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")
);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace LucentNotifications\Models;
use Illuminate\Support\Carbon;
class QuietHourSection{
public Carbon $from;
public Carbon $to;
function __construct(
string $item
)
{
[$from, $to] = explode("-",$item);
$this->from = Carbon::parse($from);
$this->to = Carbon::parse($to);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace LucentNotifications\Models;
use Illuminate\Support\Collection;
use LucentNotifications\Enums\SettingValues;
class UserPreferences
{
function __construct(
public ?bool $dailyBriefing,
public ?Collection $quietHours
) {}
function toDB()
{
return json_encode([
'dailyBriefing' => $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)
);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace LucentNotifications\Models;
class UserSettings
{
function __construct(
public string $userId,
public NotificationsSettings $notificationsSettings,
public UserPreferences $userPreferences
) {}
function toDB()
{
return [
'userId' => $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'])
);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace LucentNotifications\Repos;
use Carbon\Carbon;
use Lucent\Database\Database;
class NotificationsRepo {
private function database(){
return Database::make()->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;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace LucentNotifications\Repos;
use Lucent\Database\Database;
class SettingsRepo
{
private function database()
{
return Database::make()->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']
);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace LucentNotifications\Services;
use Hive\Result\Fail;
use Hive\Result\Result;
use Hive\Result\Success;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Session;
use LucentNotifications\Repos\NotificationsRepo;
use LucentNotifications\Models\Notification;
class NotificationService
{
private $repo;
function __construct()
{
$this->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;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace LucentNotifications\Services;
use Illuminate\Support\Facades\Redis;
use LucentNotifications\Repos\SettingsRepo;
use LucentNotifications\Models\UserSettings;
class SettingsService
{
function __construct(private SettingsRepo $repo) {}
function getForUser(string $userId)
{
$userSettings = UserSettings::fromArray($userId, json_decode(Redis::get("users.$userId.settings"), true));
$userSettings = null;
if (!$userSettings) {
$userSettings = UserSettings::fromDB($this->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));
}
}