* Fetch all core entity types as an associated array
* with their basic names as the keys.
*
- * @return array<Entity>
+ * @return array<string, Entity>
*/
public function all(): array
{
use BookStack\Activity\Models\Tag;
use BookStack\Activity\Models\View;
use BookStack\Activity\Models\Viewable;
+use BookStack\Activity\Models\Watch;
use BookStack\App\Model;
use BookStack\App\Sluggable;
use BookStack\Entities\Tools\SlugGenerator;
->exists();
}
+ /**
+ * Get the related watches for this entity.
+ */
+ public function watches(): MorphMany
+ {
+ return $this->morphMany(Watch::class, 'watchable');
+ }
+
/**
* {@inheritdoc}
*/
$entity->searchTerms()->delete();
$entity->deletions()->delete();
$entity->favourites()->delete();
+ $entity->watches()->delete();
$entity->referencesTo()->delete();
$entity->referencesFrom()->delete();
namespace BookStack\Permissions;
use BookStack\App\Model;
+use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\Models\EntityPermission;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
+use Illuminate\Database\Query\JoinClause;
use InvalidArgumentException;
class PermissionApplicator
});
}
+ /**
+ * Filter out items that have related entity relations where
+ * the entity is marked as deleted.
+ */
+ public function filterDeletedFromEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder
+ {
+ $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
+ $entityProvider = new EntityProvider();
+
+ $joinQuery = function ($query) use ($entityProvider) {
+ $first = true;
+ /** @var Builder $query */
+ foreach ($entityProvider->all() as $entity) {
+ $entityQuery = function ($query) use ($entity) {
+ /** @var Builder $query */
+ $query->select(['id', 'deleted_at'])
+ ->selectRaw("'{$entity->getMorphClass()}' as type")
+ ->from($entity->getTable())
+ ->whereNotNull('deleted_at');
+ };
+
+ if ($first) {
+ $entityQuery($query);
+ $first = false;
+ } else {
+ $query->union($entityQuery);
+ }
+ }
+ };
+
+ return $query->leftJoinSub($joinQuery, 'deletions', function (JoinClause $join) use ($tableDetails) {
+ $join->on($tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'], '=', 'deletions.id')
+ ->on($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', 'deletions.type');
+ })->whereNull('deletions.deleted_at');
+ }
+
/**
* Add conditions to a query for a model that's a relation of a page, so only the model results
* on visible pages are returned by the query.
namespace BookStack\Users\Controllers;
-use BookStack\Activity\Models\Watch;
use BookStack\Http\Controller;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\UserNotificationPreferences;
$preferences = (new UserNotificationPreferences(user()));
- $query = Watch::query()->where('user_id', '=', user()->id);
+ $query = user()->watches()->getQuery();
$query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
+ $query = $permissions->filterDeletedFromEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
$watches = $query->with('watchable')->paginate(20);
$this->setPageTitle(trans('preferences.notifications'));
use BookStack\Access\SocialAccount;
use BookStack\Activity\Models\Favourite;
use BookStack\Activity\Models\Loggable;
+use BookStack\Activity\Models\Watch;
use BookStack\Api\ApiToken;
use BookStack\App\Model;
use BookStack\App\Sluggable;
return $this->hasMany(MfaValue::class);
}
+ /**
+ * Get the tracked entity watches for this user.
+ */
+ public function watches(): HasMany
+ {
+ return $this->hasMany(Watch::class);
+ }
+
/**
* Get the last activity time for this user.
*/
class UserRepo
{
- protected UserAvatars $userAvatar;
- protected UserInviteService $inviteService;
-
- /**
- * UserRepo constructor.
- */
- public function __construct(UserAvatars $userAvatar, UserInviteService $inviteService)
- {
- $this->userAvatar = $userAvatar;
- $this->inviteService = $inviteService;
+ public function __construct(
+ protected UserAvatars $userAvatar,
+ protected UserInviteService $inviteService
+ ) {
}
+
/**
* Get a user by their email address.
*/
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
+ $user->watches()->delete();
$user->delete();
// Delete user profile images
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Tools\TrashCan;
use BookStack\Settings\UserNotificationPreferences;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
$notifications->assertNothingSentTo($editor);
}
+
+ public function test_watches_deleted_on_user_delete()
+ {
+ $editor = $this->users->editor();
+ $page = $this->entities->page();
+
+ $watches = new UserEntityWatchOptions($editor, $page);
+ $watches->updateLevelByValue(WatchLevels::COMMENTS);
+ $this->assertDatabaseHas('watches', ['user_id' => $editor->id]);
+
+ $this->asAdmin()->delete($editor->getEditUrl());
+
+ $this->assertDatabaseMissing('watches', ['user_id' => $editor->id]);
+ }
+
+ public function test_watches_deleted_on_item_delete()
+ {
+ $editor = $this->users->editor();
+ $page = $this->entities->page();
+
+ $watches = new UserEntityWatchOptions($editor, $page);
+ $watches->updateLevelByValue(WatchLevels::COMMENTS);
+ $this->assertDatabaseHas('watches', ['watchable_type' => 'page', 'watchable_id' => $page->id]);
+
+ $this->entities->destroy($page);
+
+ $this->assertDatabaseMissing('watches', ['watchable_type' => 'page', 'watchable_id' => $page->id]);
+ }
}
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo;
+use BookStack\Entities\Tools\TrashCan;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
return $draftPage;
}
+ /**
+ * Fully destroy the given entity from the system, bypassing the recycle bin
+ * stage. Still runs through main app deletion logic.
+ */
+ public function destroy(Entity $entity)
+ {
+ $trash = app()->make(TrashCan::class);
+ $trash->destroyEntity($entity);
+ }
+
/**
* @param Entity|Entity[] $entities
*/
$resp->assertDontSee('All Page Updates & Comments');
}
+ public function test_notification_preferences_dont_error_on_deleted_items()
+ {
+ $editor = $this->users->editor();
+ $book = $this->entities->book();
+
+ $options = new UserEntityWatchOptions($editor, $book);
+ $options->updateLevelByValue(WatchLevels::COMMENTS);
+
+ $this->actingAs($editor)->delete($book->getUrl());
+ $book->refresh();
+ $this->assertNotNull($book->deleted_at);
+
+ $resp = $this->actingAs($editor)->get('/preferences/notifications');
+ $resp->assertOk();
+ $resp->assertDontSee($book->name);
+ }
+
public function test_notification_preferences_not_accessible_to_guest()
{
$this->setSettings(['app-public' => 'true']);