Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Content user notifications #4390

Merged
merged 20 commits into from
Aug 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
45e75ed
Notifications: Started activity->notification core framework
ssddanbrown Jul 19, 2023
100b287
Notifications: added user preference UI & logic
ssddanbrown Jul 25, 2023
ff2674c
Notifications: Added role receive-notifications permission
ssddanbrown Jul 25, 2023
730f539
Notifications: Started entity watch UI
ssddanbrown Jul 27, 2023
6100b99
Notifications: Extracted watch options, updated UI further
ssddanbrown Jul 31, 2023
8cdf320
Notifications: Started back-end for watch system
ssddanbrown Jul 31, 2023
9d149e4
Notifications: Linked watch functionality to UI
ssddanbrown Aug 2, 2023
9779c1a
Notifications: Started core user notification logic
ssddanbrown Aug 4, 2023
18ae67a
Notifications: Got core notification logic working for new pages
ssddanbrown Aug 4, 2023
ecab2c8
Notifications: Added logic and classes for remaining notification types
ssddanbrown Aug 5, 2023
c47b3f8
Notifications: Updated watch control to show parent status
ssddanbrown Aug 9, 2023
d9fdecd
Notifications: User watch list and differnt page watch options
ssddanbrown Aug 14, 2023
3717792
Notifications: Added new preferences view and access control
ssddanbrown Aug 14, 2023
615741a
Notifications: Cleaned up mails, added debounce for updates
ssddanbrown Aug 15, 2023
bc6e19b
Notifications: Added testing to cover controls
ssddanbrown Aug 15, 2023
565908e
Notifications: Add phpunit test for notification sending
ssddanbrown Aug 16, 2023
79470ea
Notifications: Made improvements from manual testing
ssddanbrown Aug 16, 2023
ee9e342
Notifications: Fixed issues causing failing tests
ssddanbrown Aug 17, 2023
38829f8
Notifications: Fixed send content permission checking
ssddanbrown Aug 17, 2023
e709caa
Notifications: Switched testing from string to reference levels
ssddanbrown Aug 17, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions app/Activity/Controllers/WatchController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace BookStack\Activity\Controllers;

use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\App\Model;
use BookStack\Entities\Models\Entity;
use BookStack\Http\Controller;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

class WatchController extends Controller
{
public function update(Request $request)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();

$requestData = $this->validate($request, [
'level' => ['required', 'string'],
]);

$watchable = $this->getValidatedModelFromRequest($request);
$watchOptions = new UserEntityWatchOptions(user(), $watchable);
$watchOptions->updateLevelByName($requestData['level']);

$this->showSuccessNotification(trans('activities.watch_update_level_notification'));

return redirect()->back();
}

/**
* @throws ValidationException
* @throws Exception
*/
protected function getValidatedModelFromRequest(Request $request): Entity
{
$modelInfo = $this->validate($request, [
'type' => ['required', 'string'],
'id' => ['required', 'integer'],
]);

if (!class_exists($modelInfo['type'])) {
throw new Exception('Model not found');
}

/** @var Model $model */
$model = new $modelInfo['type']();
if (!$model instanceof Entity) {
throw new Exception('Model not an entity');
}

$modelInstance = $model->newQuery()
->where('id', '=', $modelInfo['id'])
->first(['id', 'name', 'owned_by']);

$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
if (is_null($modelInstance) || $inaccessibleEntity) {
throw new Exception('Model instance not found');
}

return $modelInstance;
}
}
17 changes: 11 additions & 6 deletions app/Activity/Models/Comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use BookStack\App\Model;
use BookStack\Users\Models\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;

/**
Expand Down Expand Up @@ -32,6 +33,14 @@ public function entity(): MorphTo
return $this->morphTo('entity');
}

/**
* Get the parent comment this is in reply to (if existing).
*/
public function parent(): BelongsTo
{
return $this->belongsTo(Comment::class);
}

/**
* Check if a comment has been updated since creation.
*/
Expand All @@ -42,20 +51,16 @@ public function isUpdated(): bool

/**
* Get created date as a relative diff.
*
* @return mixed
*/
public function getCreatedAttribute()
public function getCreatedAttribute(): string
{
return $this->created_at->diffForHumans();
}

/**
* Get updated date as a relative diff.
*
* @return mixed
*/
public function getUpdatedAttribute()
public function getUpdatedAttribute(): string
{
return $this->updated_at->diffForHumans();
}
Expand Down
45 changes: 45 additions & 0 deletions app/Activity/Models/Watch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace BookStack\Activity\Models;

use BookStack\Activity\WatchLevels;
use BookStack\Permissions\Models\JointPermission;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;

/**
* @property int $id
* @property int $user_id
* @property int $watchable_id
* @property string $watchable_type
* @property int $level
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Watch extends Model
{
protected $guarded = [];

public function watchable(): MorphTo
{
return $this->morphTo();
}

public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'watchable_id')
->whereColumn('watches.watchable_type', '=', 'joint_permissions.entity_type');
}

public function getLevelName(): string
{
return WatchLevels::levelValueToName($this->level);
}

public function ignoring(): bool
{
return $this->level === WatchLevels::IGNORE;
}
}
42 changes: 42 additions & 0 deletions app/Activity/Notifications/Handlers/BaseNotificationHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace BookStack\Activity\Notifications\Handlers;

use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
use BookStack\Entities\Models\Entity;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User;

abstract class BaseNotificationHandler implements NotificationHandler
{
/**
* @param class-string<BaseActivityNotification> $notification
* @param int[] $userIds
*/
protected function sendNotificationToUserIds(string $notification, array $userIds, User $initiator, string|Loggable $detail, Entity $relatedModel): void
{
$users = User::query()->whereIn('id', array_unique($userIds))->get();

foreach ($users as $user) {
// Prevent sending to the user that initiated the activity
if ($user->id === $initiator->id) {
continue;
}

// Prevent sending of the user does not have notification permissions
if (!$user->can('receive-notifications')) {
continue;
}

// Prevent sending if the user does not have access to the related content
$permissions = new PermissionApplicator($user);
if (!$permissions->checkOwnableUserAccess($relatedModel, 'view')) {
continue;
}

// Send the notification
$user->notify(new $notification($detail, $initiator));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace BookStack\Activity\Notifications\Handlers;

use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\CommentCreationNotification;
use BookStack\Activity\Tools\EntityWatchers;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;

class CommentCreationNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Comment)) {
throw new \InvalidArgumentException("Detail for comment creation notifications must be a comment");
}

// Main watchers
/** @var Page $page */
$page = $detail->entity;
$watchers = new EntityWatchers($page, WatchLevels::COMMENTS);
$watcherIds = $watchers->getWatcherUserIds();

// Page owner if user preferences allow
if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageComments()) {
$watcherIds[] = $page->owned_by;
}
}

// Parent comment creator if preferences allow
$parentComment = $detail->parent()->first();
if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
$watcherIds[] = $parentComment->created_by;
}
}

$this->sendNotificationToUserIds(CommentCreationNotification::class, $watcherIds, $user, $detail, $page);
}
}
17 changes: 17 additions & 0 deletions app/Activity/Notifications/Handlers/NotificationHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace BookStack\Activity\Notifications\Handlers;

use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;

interface NotificationHandler
{
/**
* Run this handler.
* Provides the activity, related activity detail/model
* along with the user that triggered the activity.
*/
public function handle(Activity $activity, string|Loggable $detail, User $user): void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace BookStack\Activity\Notifications\Handlers;

use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\PageCreationNotification;
use BookStack\Activity\Tools\EntityWatchers;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;

class PageCreationNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Page)) {
throw new \InvalidArgumentException("Detail for page create notifications must be a page");
}

$watchers = new EntityWatchers($detail, WatchLevels::NEW);
$this->sendNotificationToUserIds(PageCreationNotification::class, $watchers->getWatcherUserIds(), $user, $detail, $detail);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace BookStack\Activity\Notifications\Handlers;

use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\PageUpdateNotification;
use BookStack\Activity\Tools\EntityWatchers;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;

class PageUpdateNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Page)) {
throw new \InvalidArgumentException("Detail for page update notifications must be a page");
}

// Get last update from activity
$lastUpdate = $detail->activity()
->where('type', '=', ActivityType::PAGE_UPDATE)
->where('id', '!=', $activity->id)
->latest('created_at')
->first();

// Return if the same user has already updated the page in the last 15 mins
if ($lastUpdate && $lastUpdate->user_id === $user->id) {
if ($lastUpdate->created_at->gt(now()->subMinutes(15))) {
return;
}
}

// Get active watchers
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
$watcherIds = $watchers->getWatcherUserIds();

// Add page owner if preferences allow
if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
$watcherIds[] = $detail->owned_by;
}
}

$this->sendNotificationToUserIds(PageUpdateNotification::class, $watcherIds, $user, $detail, $detail);
}
}
26 changes: 26 additions & 0 deletions app/Activity/Notifications/MessageParts/LinkedMailMessageLine.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace BookStack\Activity\Notifications\MessageParts;

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));
}
}
26 changes: 26 additions & 0 deletions app/Activity/Notifications/MessageParts/ListMessageLine.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace BookStack\Activity\Notifications\MessageParts;

use Illuminate\Contracts\Support\Htmlable;

/**
* A bullet point list of content, where the keys of the given list array
* are bolded header elements, and the values follow.
*/
class ListMessageLine implements Htmlable
{
public function __construct(
protected array $list
) {
}

public function toHtml(): string
{
$list = [];
foreach ($this->list as $header => $content) {
$list[] = '<strong>' . e($header) . '</strong> ' . e($content);
}
return implode("<br>\n", $list);
}
}
Loading