From 6d5d90e29006d81490edb6a1a45e513f42f14466 Mon Sep 17 00:00:00 2001
From: Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de>
Date: Wed, 17 Nov 2021 10:14:24 +0100
Subject: [PATCH] Add more bookmark routes to users.

---
 app/controllers/contents/courseware.php       |   2 +-
 lib/classes/JsonApi/RouteMap.php              |   8 +
 .../JsonApi/Routes/Courseware/Authority.php   |  15 ++
 .../BookmarkedStructuralElementsIndex.php     |   2 +-
 .../Courseware/CoursewareInstancesHelper.php  |   5 +-
 .../Rel/BookmarkedStructuralElements.php      |   3 +
 .../Rel/UsersBookmarkedStructuralElements.php | 180 ++++++++++++++++++
 ...UsersBookmarkedStructuralElementsIndex.php |  55 ++++++
 lib/classes/JsonApi/Schemas/User.php          |  18 ++
 lib/models/Courseware/Bookmark.php            |   6 +-
 10 files changed, 288 insertions(+), 6 deletions(-)
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/Rel/UsersBookmarkedStructuralElements.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/UsersBookmarkedStructuralElementsIndex.php

diff --git a/app/controllers/contents/courseware.php b/app/controllers/contents/courseware.php
index e509a6bd1ec..4df0b49996b 100755
--- a/app/controllers/contents/courseware.php
+++ b/app/controllers/contents/courseware.php
@@ -139,7 +139,7 @@ class Contents_CoursewareController extends AuthenticatedController
     {
         Navigation::activateItem('/contents/courseware/bookmarks');
         $this->bookmarks = array();
-        $cw_bookmarks =  Courseware\Bookmark::findUsersBookmarks($this->user->id);
+        $cw_bookmarks =  Courseware\Bookmark::findUsersBookmarks($this->user);
         foreach($cw_bookmarks as $bookmark) {
             $bm = array();
             $bm['bookmark'] = $bookmark;
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index a258b10457d..c8ba716509a 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -307,6 +307,14 @@ class RouteMap
         );
         $group->get('/courseware-instances/{id}/bookmarks', Routes\Courseware\BookmarkedStructuralElementsIndex::class);
 
+        $group->get('/users/{id}/courseware-bookmarks', Routes\Courseware\UsersBookmarkedStructuralElementsIndex::class);
+        $this->addRelationship(
+            $group,
+            '/users/{id}/relationships/courseware-bookmarks',
+            Routes\Courseware\Rel\UsersBookmarkedStructuralElements::class
+        );
+
+
         $group->get('/courseware-blocks/{id}', Routes\Courseware\BlocksShow::class);
         $group->post('/courseware-blocks', Routes\Courseware\BlocksCreate::class);
         $group->patch('/courseware-blocks/{id}', Routes\Courseware\BlocksUpdate::class);
diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php
index 63b32b496a9..e83d9aaab9d 100755
--- a/lib/classes/JsonApi/Routes/Courseware/Authority.php
+++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php
@@ -189,6 +189,21 @@ class Authority
         return self::canShowCoursewareInstance($user, $resource);
     }
 
+    public static function canAddBookmarkToAUser(User $actor, User $user)
+    {
+        return $actor->id === $user->id;
+    }
+
+    public static function canModifyBookmarksOfAUser(User $actor, User $user)
+    {
+        return $actor->id === $user->id;
+    }
+
+    public static function canIndexBookmarksOfAUser(User $actor, User $user)
+    {
+        return $actor->id === $user->id;
+    }
+
     /**
      * @SuppressWarnings(PHPMD.Superglobals)
      */
diff --git a/lib/classes/JsonApi/Routes/Courseware/BookmarkedStructuralElementsIndex.php b/lib/classes/JsonApi/Routes/Courseware/BookmarkedStructuralElementsIndex.php
index 3ea220ae5dc..8ab546d387e 100755
--- a/lib/classes/JsonApi/Routes/Courseware/BookmarkedStructuralElementsIndex.php
+++ b/lib/classes/JsonApi/Routes/Courseware/BookmarkedStructuralElementsIndex.php
@@ -48,6 +48,6 @@ class BookmarkedStructuralElementsIndex extends JsonApiController
         $total = count($resources);
         list($offset, $limit) = $this->getOffsetAndLimit();
 
-        return $this->getPaginatedResponse(array_slice($resources, $offset, $limit), $total);
+        return $this->getPaginatedContentResponse(array_slice($resources, $offset, $limit), $total);
     }
 }
diff --git a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php
index d22d9e3f17b..843f7c2a902 100755
--- a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php
+++ b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php
@@ -11,7 +11,10 @@ trait CoursewareInstancesHelper
 {
     private function findInstance(string $instanceId): Instance
     {
-        list($rangeType, $rangeId) = explode('_', $instanceId);
+        [$rangeType, $rangeId] = explode('_', $instanceId);
+        if (!is_string($rangeType) || !is_string($rangeId)) {
+            throw new BadRequestException('Invalid instance id: "' . $instanceId . '".');
+        }
 
         return $this->findInstanceWithRange($rangeType, $rangeId);
     }
diff --git a/lib/classes/JsonApi/Routes/Courseware/Rel/BookmarkedStructuralElements.php b/lib/classes/JsonApi/Routes/Courseware/Rel/BookmarkedStructuralElements.php
index 7d191f53ccf..df3ede6f5a1 100755
--- a/lib/classes/JsonApi/Routes/Courseware/Rel/BookmarkedStructuralElements.php
+++ b/lib/classes/JsonApi/Routes/Courseware/Rel/BookmarkedStructuralElements.php
@@ -169,6 +169,9 @@ class BookmarkedStructuralElements extends RelationshipsController
     private function addBookmarks(\User $user, array $newIds): void
     {
         foreach ($newIds as $structuralElementId) {
+            if (Bookmark::countBySQL('user_id = ? AND element_id = ?', [$user->id, $structuralElementId])) {
+                continue;
+            }
             Bookmark::create(['user_id' => $user->id, 'element_id' => $structuralElementId]);
         }
     }
diff --git a/lib/classes/JsonApi/Routes/Courseware/Rel/UsersBookmarkedStructuralElements.php b/lib/classes/JsonApi/Routes/Courseware/Rel/UsersBookmarkedStructuralElements.php
new file mode 100644
index 00000000000..7f05c66a648
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/Rel/UsersBookmarkedStructuralElements.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\Rel;
+
+use Courseware\Bookmark;
+use Courseware\Instance;
+use Courseware\StructuralElement;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\Errors\ConflictException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Routes\Courseware\CoursewareInstancesHelper;
+use JsonApi\Routes\RelationshipsController;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+class UsersBookmarkedStructuralElements extends RelationshipsController
+{
+    use CoursewareInstancesHelper;
+
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    protected function fetchRelationship(Request $request, $related)
+    {
+        $bookmarks = array_column(Bookmark::findUsersBookmarks($related), 'element');
+        $total = count($bookmarks);
+        list($offset, $limit) = $this->getOffsetAndLimit();
+        $page = array_slice($bookmarks, $offset, $limit);
+
+        return $this->getPaginatedIdentifiersResponse($page, $total);
+    }
+
+    protected function replaceRelationship(Request $request, $related)
+    {
+        $json = $this->validate($request);
+        $structuralElements = $this->validateStructuralElements($user = $this->getUser($request), $json, $related);
+        $this->replaceBookmarks($related, $structuralElements);
+
+        return $this->getCodeResponse(204);
+    }
+
+    protected function addToRelationship(Request $request, $related)
+    {
+        $json = $this->validate($request);
+        $structuralElements = $this->validateStructuralElements($user = $this->getUser($request), $json, $related);
+        $this->addBookmarks($related, $structuralElements);
+
+        return $this->getCodeResponse(204);
+    }
+
+    protected function removeFromRelationship(Request $request, $related)
+    {
+        $json = $this->validate($request);
+        $structuralElements = $this->validateStructuralElements($user = $this->getUser($request), $json, $related);
+        $this->removeBookmarks($user, $structuralElements);
+
+        return $this->getCodeResponse(204);
+    }
+
+    protected function findRelated(array $args)
+    {
+        if (!($related = \User::find($args['id']))) {
+            throw new RecordNotFoundException();
+        }
+
+        return $related;
+    }
+
+    protected function authorize(Request $request, $resource)
+    {
+        $observer = $this->getUser($request);
+        $observed = $resource;
+        switch ($request->getMethod()) {
+            case 'GET':
+                return Authority::canIndexBookmarksOfAUser($observer, $observed);
+
+            case 'DELETE':
+            case 'PATCH':
+            case 'POST':
+                return Authority::canModifyBookmarksOfAUser($observer, $observed);
+
+            default:
+                return false;
+        }
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    protected function getRelationshipSelfLink($resource, $schema, $userData)
+    {
+        return $schema->getRelationshipSelfLink($resource, \JsonApi\Schemas\User::REL_COURSEWARE_BOOKMARKS);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    protected function getRelationshipRelatedLink($resource, $schema, $userData)
+    {
+        return $schema->getRelationshipRelatedLink($resource, \JsonApi\Schemas\User::REL_COURSEWARE_BOOKMARKS);
+    }
+
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+
+        $data = self::arrayGet($json, 'data');
+
+        if (!is_array($data)) {
+            return 'Document´s ´data´ must be an array.';
+        }
+
+        foreach ($data as $item) {
+            if (\JsonApi\Schemas\Courseware\StructuralElement::TYPE !== self::arrayGet($item, 'type')) {
+                return 'Wrong `type` in document´s `data`.';
+            }
+
+            if (!self::arrayGet($item, 'id')) {
+                return 'Missing `id` of document´s `data`.';
+            }
+        }
+
+        if (self::arrayHas($json, 'data.attributes')) {
+            return 'Document must not have `attributes`.';
+        }
+    }
+
+    private function validateStructuralElements(\User $actor, $json, \User $user)
+    {
+        $structuralElements = [];
+
+        foreach (self::arrayGet($json, 'data') as $structuralElementResource) {
+            if (!($structuralElement = StructuralElement::find($structuralElementResource['id']))) {
+                throw new RecordNotFoundException();
+            }
+
+            if (!Authority::canModifyBookmarksOfAUser($actor, $user)) {
+                throw new AuthorizationFailedException();
+            }
+
+            if (!Authority::canShowStructuralElement($user, $structuralElement)) {
+                throw new RecordNotFoundException();
+            }
+
+            $structuralElements[] = $structuralElement->id;
+        }
+
+        return $structuralElements;
+    }
+
+    private function replaceBookmarks(\User $user, array $newIds)
+    {
+        $oldIds = array_column(Bookmark::findUsersBookmarks($user), 'element_id');
+        $onlyInOld = array_diff($oldIds, $newIds);
+        $onlyInNew = array_diff($newIds, $oldIds);
+
+        $this->removeBookmarks($user, $onlyInOld);
+        $this->addBookmarks($user, $onlyInNew);
+    }
+
+    private function addBookmarks(\User $user, array $newIds): void
+    {
+        foreach ($newIds as $structuralElementId) {
+            if (Bookmark::countBySQL('user_id = ? AND element_id = ?', [$user->id, $structuralElementId])) {
+                continue;
+            }
+            Bookmark::create(['user_id' => $user->id, 'element_id' => $structuralElementId]);
+        }
+    }
+
+    private function removeBookmarks(\User $user, array $oldIds): void
+    {
+        Bookmark::deleteBySQL('user_id = ? AND element_id IN (?)', [$user->id, $oldIds]);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/UsersBookmarkedStructuralElementsIndex.php b/lib/classes/JsonApi/Routes/Courseware/UsersBookmarkedStructuralElementsIndex.php
new file mode 100644
index 00000000000..618dc2e38e2
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/UsersBookmarkedStructuralElementsIndex.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\Bookmark;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Displays the user's bookmarked structural elements.
+ */
+class UsersBookmarkedStructuralElementsIndex extends JsonApiController
+{
+    use CoursewareInstancesHelper;
+
+    protected $allowedIncludePaths = [
+        'ancestors',
+        'containers',
+        'containers.blocks',
+        'containers.blocks.edit-blocker',
+        'containers.blocks.editor',
+        'containers.blocks.owner',
+        'containers.blocks.user-data-field',
+        'containers.blocks.user-progress',
+        'course',
+        'editor',
+        'owner',
+        'parent',
+    ];
+
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        if (!($user = \User::find($args['id']))) {
+            throw new RecordNotFoundException();
+        }
+        $actor = $this->getUser($request);
+        if (!Authority::canIndexBookmarksOfAUser($actor, $user)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $resources = array_column(Bookmark::findUsersBookmarks($user), 'element');
+        $total = count($resources);
+        [$offset, $limit] = $this->getOffsetAndLimit();
+
+        return $this->getPaginatedContentResponse(array_slice($resources, $offset, $limit), $total);
+    }
+}
diff --git a/lib/classes/JsonApi/Schemas/User.php b/lib/classes/JsonApi/Schemas/User.php
index 657f8f9c105..069e09f6c7a 100644
--- a/lib/classes/JsonApi/Schemas/User.php
+++ b/lib/classes/JsonApi/Schemas/User.php
@@ -16,6 +16,7 @@ class User extends SchemaProvider
     const REL_CONTACTS = 'contacts';
     const REL_COURSES = 'courses';
     const REL_COURSE_MEMBERSHIPS = 'course-memberships';
+    const REL_COURSEWARE_BOOKMARKS = 'courseware-bookmarks';
     const REL_EVENTS = 'events';
     const REL_FILES = 'file-refs';
     const REL_FOLDERS = 'folders';
@@ -165,6 +166,8 @@ class User extends SchemaProvider
             $relationships = $this->getNewsRelationship($relationships, $user, $this->shouldInclude($context, self::REL_NEWS));
             $relationships = $this->getOutboxRelationship($relationships, $user, $this->shouldInclude($context, self::REL_OUTBOX));
             $relationships = $this->getScheduleRelationship($relationships, $user, $this->shouldInclude($context, self::REL_SCHEDULE));
+
+            $relationships = $this->getCoursewareBookmarksRelationship($relationships, $user, $this->shouldInclude($context, self::REL_COURSEWARE_BOOKMARKS));
         }
 
         return $relationships;
@@ -256,6 +259,21 @@ class User extends SchemaProvider
         return $relationships;
     }
 
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    private function getCoursewareBookmarksRelationship(array $relationships, \User $user, $includeData)
+    {
+        $relationships[self::REL_COURSEWARE_BOOKMARKS] = [
+            self::RELATIONSHIP_LINKS_SELF => true,
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($user, self::REL_COURSEWARE_BOOKMARKS),
+            ],
+        ];
+
+        return $relationships;
+    }
+
     /**
      * @SuppressWarnings(PHPMD.UnusedFormalParameter)
      */
diff --git a/lib/models/Courseware/Bookmark.php b/lib/models/Courseware/Bookmark.php
index d6e5910521d..e11954825cc 100755
--- a/lib/models/Courseware/Bookmark.php
+++ b/lib/models/Courseware/Bookmark.php
@@ -54,12 +54,12 @@ class Bookmark extends \SimpleORMap
     /**
      * Returns all bookmarks of a user.
      *
-     * @param string $userId the user's ID for whom to search for bookmarks
+     * @param \User $user the user for whom to search for bookmarks
      *
      * @return Bookmark[] the list of bookmarks
      */
-    public function findUsersBookmarks(string $userId): array
+    public static function findUsersBookmarks($user): array
     {
-        return self::findBySQL('user_id = ?', [$userId]);
+        return self::findBySQL('user_id = ? ORDER BY chdate', [$user->id]);
     }
 }
-- 
GitLab