diff --git a/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php b/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php index 5f2e1c770..67c304339 100644 --- a/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php +++ b/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php @@ -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 } diff --git a/app/Activity/Notifications/Handlers/NotificationHandler.php b/app/Activity/Notifications/Handlers/NotificationHandler.php index fdf97eb79..fecca2181 100644 --- a/app/Activity/Notifications/Handlers/NotificationHandler.php +++ b/app/Activity/Notifications/Handlers/NotificationHandler.php @@ -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; } diff --git a/app/Activity/Notifications/Handlers/PageCreationNotificationHandler.php b/app/Activity/Notifications/Handlers/PageCreationNotificationHandler.php index 20189fc1e..a61df48ae 100644 --- a/app/Activity/Notifications/Handlers/PageCreationNotificationHandler.php +++ b/app/Activity/Notifications/Handlers/PageCreationNotificationHandler.php @@ -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? } } diff --git a/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php b/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php index ef71dccbf..bbd189d52 100644 --- a/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php +++ b/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php @@ -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 } diff --git a/app/Activity/Notifications/LinkedMailMessageLine.php b/app/Activity/Notifications/LinkedMailMessageLine.php new file mode 100644 index 000000000..224d8e87c --- /dev/null +++ b/app/Activity/Notifications/LinkedMailMessageLine.php @@ -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)); + } +} diff --git a/app/Activity/Notifications/Messages/BaseActivityNotification.php b/app/Activity/Notifications/Messages/BaseActivityNotification.php new file mode 100644 index 000000000..285e2803e --- /dev/null +++ b/app/Activity/Notifications/Messages/BaseActivityNotification.php @@ -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, + ]; + } +} diff --git a/app/Activity/Notifications/Messages/PageCreationNotification.php b/app/Activity/Notifications/Messages/PageCreationNotification.php new file mode 100644 index 000000000..2e9a6debc --- /dev/null +++ b/app/Activity/Notifications/Messages/PageCreationNotification.php @@ -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', + )); + } +} diff --git a/app/Activity/Notifications/NotificationManager.php b/app/Activity/Notifications/NotificationManager.php index 5864b8456..01361c1ee 100644 --- a/app/Activity/Notifications/NotificationManager.php +++ b/app/Activity/Notifications/NotificationManager.php @@ -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); } } diff --git a/app/Activity/Tools/ActivityLogger.php b/app/Activity/Tools/ActivityLogger.php index 3135f57a7..e8ea7c293 100644 --- a/app/Activity/Tools/ActivityLogger.php +++ b/app/Activity/Tools/ActivityLogger.php @@ -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); } diff --git a/app/Activity/Tools/EntityWatchers.php b/app/Activity/Tools/EntityWatchers.php new file mode 100644 index 000000000..20375ef45 --- /dev/null +++ b/app/Activity/Tools/EntityWatchers.php @@ -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(); + } +} diff --git a/app/Activity/Tools/UserWatchOptions.php b/app/Activity/Tools/UserWatchOptions.php index 0607d60a3..64c5f317f 100644 --- a/app/Activity/Tools/UserWatchOptions.php +++ b/app/Activity/Tools/UserWatchOptions.php @@ -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'; - } } diff --git a/app/Activity/WatchLevels.php b/app/Activity/WatchLevels.php new file mode 100644 index 000000000..2951bc7a8 --- /dev/null +++ b/app/Activity/WatchLevels.php @@ -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'; + } +} diff --git a/resources/views/entities/watch-controls.blade.php b/resources/views/entities/watch-controls.blade.php index e02db5800..adde98998 100644 --- a/resources/views/entities/watch-controls.blade.php +++ b/resources/views/entities/watch-controls.blade.php @@ -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)