Notifications: Started core user notification logic

Put together an initial notification.
Started logic to query and identify watchers.
This commit is contained in:
Dan Brown 2023-08-04 12:27:29 +01:00
parent 9d149e4d36
commit 9779c1a357
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
13 changed files with 258 additions and 42 deletions

View File

@ -3,10 +3,11 @@
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;
class CommentCreationNotificationHandler implements NotificationHandler
{
public function handle(string $activityType, Loggable|string $detail): void
public function handle(string $activityType, Loggable|string $detail, User $user): void
{
// TODO
}

View File

@ -3,11 +3,14 @@
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;
interface NotificationHandler
{
/**
* Run this handler.
* Provides the activity type, related activity detail/model
* along with the user that triggered the activity.
*/
public function handle(string $activityType, string|Loggable $detail): void;
public function handle(string $activityType, string|Loggable $detail, User $user): void;
}

View File

@ -3,11 +3,32 @@
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Watch;
use BookStack\Activity\Tools\EntityWatchers;
use BookStack\Activity\WatchLevels;
use BookStack\Users\Models\User;
class PageCreationNotificationHandler implements NotificationHandler
{
public function handle(string $activityType, Loggable|string $detail): void
public function handle(string $activityType, Loggable|string $detail, User $user): void
{
// TODO
// No user-level preferences to care about here.
// Possible Scenarios:
// ✅ User watching parent chapter
// ✅ User watching parent book
// ❌ User ignoring parent book
// ❌ User ignoring parent chapter
// ❌ User watching parent book, ignoring chapter
// ✅ User watching parent book, watching chapter
// ❌ User ignoring parent book, ignoring chapter
// ✅ User ignoring parent book, watching chapter
// Get all relevant watchers
$watchers = new EntityWatchers($detail, WatchLevels::NEW);
// TODO - need to check entity visibility and receive-notifications permissions.
// Maybe abstract this to a generic late-stage filter?
}
}

View File

@ -3,10 +3,11 @@
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;
class PageUpdateNotificationHandler implements NotificationHandler
{
public function handle(string $activityType, Loggable|string $detail): void
public function handle(string $activityType, Loggable|string $detail, User $user): void
{
// TODO
}

View File

@ -0,0 +1,26 @@
<?php
namespace BookStack\Activity\Notifications;
use Illuminate\Contracts\Support\Htmlable;
/**
* A line of text with linked text included, intended for use
* in MailMessages. The line should have a ':link' placeholder for
* where the link should be inserted within the line.
*/
class LinkedMailMessageLine implements Htmlable
{
public function __construct(
protected string $url,
protected string $line,
protected string $linkText,
) {
}
public function toHtml(): string
{
$link = '<a href="' . e($this->url) . '">' . e($this->linkText) . '</a>';
return str_replace(':link', $link, e($this->line));
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
abstract class BaseActivityNotification extends Notification
{
use Queueable;
public function __construct(
protected Loggable|string $detail,
protected User $user,
) {
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
abstract public function toMail(mixed $notifiable): MailMessage;
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
'activity_detail' => $this->detail,
'activity_creator' => $this->user,
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\LinkedMailMessageLine;
use BookStack\Entities\Models\Page;
use Illuminate\Notifications\Messages\MailMessage;
class PageCreationNotification extends BaseActivityNotification
{
public function toMail(mixed $notifiable): MailMessage
{
/** @var Page $page */
$page = $this->detail;
return (new MailMessage())
->subject("New Page: " . $page->getShortName())
->line("A new page has been created in " . setting('app-name') . ':')
->line("Page Name: " . $page->name)
->line("Created By: " . $this->user->name)
->action('View Page', $page->getUrl())
->line(new LinkedMailMessageLine(
url('/preferences/notifications'),
'This notification was sent to you because :link cover this type of activity for this item.',
'your notification preferences',
));
}
}

View File

@ -8,6 +8,7 @@ use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
use BookStack\Users\Models\User;
class NotificationManager
{
@ -16,13 +17,13 @@ class NotificationManager
*/
protected array $handlers = [];
public function handle(string $activityType, string|Loggable $detail): void
public function handle(string $activityType, string|Loggable $detail, User $user): void
{
$handlersToRun = $this->handlers[$activityType] ?? [];
foreach ($handlersToRun as $handlerClass) {
/** @var NotificationHandler $handler */
$handler = app()->make($handlerClass);
$handler->handle($activityType, $detail);
$handler->handle($activityType, $detail, $user);
}
}

View File

@ -40,7 +40,7 @@ class ActivityLogger
$this->setNotification($type);
$this->dispatchWebhooks($type, $detail);
$this->notifications->handle($type, $detail);
$this->notifications->handle($type, $detail, user());
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
}

View File

@ -0,0 +1,55 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Watch;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder;
class EntityWatchers
{
protected array $watchers = [];
protected array $ignorers = [];
public function __construct(
protected Entity $entity,
protected int $watchLevel,
) {
$this->build();
}
protected function build(): void
{
$watches = $this->getRelevantWatches();
// TODO - De-dupe down watches per-user across entity types
// so we end up with [user_id => status] values
// then filter to current watch level, considering ignores,
// then populate the class watchers/ignores with ids.
}
protected function getRelevantWatches(): array
{
/** @var Entity[] $entitiesInvolved */
$entitiesInvolved = array_filter([
$this->entity,
$this->entity instanceof BookChild ? $this->entity->book : null,
$this->entity instanceof Page ? $this->entity->chapter : null,
]);
$query = Watch::query()->where(function (Builder $query) use ($entitiesInvolved) {
foreach ($entitiesInvolved as $entity) {
$query->orWhere(function (Builder $query) use ($entity) {
$query->where('watchable_type', '=', $entity->getMorphClass())
->where('watchable_id', '=', $entity->id);
});
}
});
return $query->get([
'level', 'watchable_id', 'watchable_type', 'user_id'
])->all();
}
}

View File

@ -3,20 +3,13 @@
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Watch;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Entity;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
class UserWatchOptions
{
protected static array $levelByName = [
'default' => -1,
'ignore' => 0,
'new' => 1,
'updates' => 2,
'comments' => 3,
];
public function __construct(
protected User $user,
) {
@ -30,7 +23,7 @@ class UserWatchOptions
public function getEntityWatchLevel(Entity $entity): string
{
$levelValue = $this->entityQuery($entity)->first(['level'])->level ?? -1;
return $this->levelValueToName($levelValue);
return WatchLevels::levelValueToName($levelValue);
}
public function isWatching(Entity $entity): bool
@ -40,7 +33,7 @@ class UserWatchOptions
public function updateEntityWatchLevel(Entity $entity, string $level): void
{
$levelValue = $this->levelNameToValue($level);
$levelValue = WatchLevels::levelNameToValue($level);
if ($levelValue < 0) {
$this->removeForEntity($entity);
return;
@ -71,28 +64,4 @@ class UserWatchOptions
->where('watchable_type', '=', $entity->getMorphClass())
->where('user_id', '=', $this->user->id);
}
/**
* @return string[]
*/
public static function getAvailableLevelNames(): array
{
return array_keys(static::$levelByName);
}
protected static function levelNameToValue(string $level): int
{
return static::$levelByName[$level] ?? -1;
}
protected static function levelValueToName(int $level): string
{
foreach (static::$levelByName as $name => $value) {
if ($level === $value) {
return $name;
}
}
return 'default';
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace BookStack\Activity;
class WatchLevels
{
/**
* Default level, No specific option set
* Typically not a stored status
*/
const DEFAULT = -1;
/**
* Ignore all notifications.
*/
const IGNORE = 0;
/**
* Watch for new content.
*/
const NEW = 1;
/**
* Watch for updates and new content
*/
const UPDATES = 2;
/**
* Watch for comments, updates and new content.
*/
const COMMENTS = 3;
/**
* Get all the possible values as an option_name => value array.
*/
public static function all(): array
{
$options = [];
foreach ((new \ReflectionClass(static::class))->getConstants() as $name => $value) {
$options[strtolower($name)] = $value;
}
return $options;
}
public static function levelNameToValue(string $level): int
{
return static::all()[$level] ?? -1;
}
public static function levelValueToName(int $level): string
{
foreach (static::all() as $name => $value) {
if ($level === $value) {
return $name;
}
}
return 'default';
}
}

View File

@ -5,7 +5,7 @@
<input type="hidden" name="id" value="{{ $entity->id }}">
<ul refs="dropdown@menu" class="dropdown-menu xl-limited anchor-left pb-none">
@foreach(\BookStack\Activity\Tools\UserWatchOptions::getAvailableLevelNames() as $option)
@foreach(\BookStack\Activity\WatchLevels::all() as $option)
<li>
<button name="level" value="{{ $option }}" class="icon-item">
@if($watchLevel === $option)