Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
96f64fc
Add bookmark endpoints
axlewin Apr 9, 2026
5d303fd
Augment question results with bookmarked status
axlewin Apr 9, 2026
dbc2e45
Store more precise timestamp information for bookmarks
axlewin Apr 10, 2026
3be8bbd
Add bookmarks table migration
axlewin Apr 10, 2026
4ed4a46
Rename method
axlewin Apr 10, 2026
01e6379
Move bookmark content augmentation out of DB layer
axlewin Apr 10, 2026
c87fb8b
Move bookmark content type check out of DB layer
axlewin Apr 10, 2026
fe4dc20
Avoid passing entire user object to bookmarks DB manager
axlewin Apr 10, 2026
2563d3c
Move bookmark content type checking to facade
axlewin Apr 10, 2026
afbf7df
Add pg test data for user_bookmarks
axlewin Apr 20, 2026
638ade5
Add user_bookmarks to DB creation script
axlewin Apr 20, 2026
afd9134
Update bookmarks PG test data to use IDs from ES test data
axlewin Apr 20, 2026
7930013
Correct response code for successful requests
axlewin Apr 20, 2026
495d61a
Add integration tests for bookmarks facade
axlewin Apr 20, 2026
96a92c2
Improve responses for invalid bookmark requests
axlewin Apr 20, 2026
76b8e5c
Update AbstractIsaacIntegrationTest with bookmark objects
axlewin Apr 20, 2026
2f6c3dd
Extend bookmarks integration tests
axlewin Apr 21, 2026
49c760a
Merge branch 'main' into feature/bookmarks
axlewin Apr 21, 2026
6215cd1
Clarify & improve various bookmarks functionality
axlewin Apr 21, 2026
d2b888a
Fix test cleanup
axlewin Apr 21, 2026
c6df3aa
Improve logging & error responses
axlewin Apr 23, 2026
b474152
Remove redundant content type check
axlewin Apr 23, 2026
5de26fb
Use "no content" HTTP response for successful bookmarks deletions
axlewin Apr 23, 2026
d72fabc
Update status code in test
axlewin Apr 23, 2026
b492bff
Move content ID to path param
axlewin Apr 23, 2026
e87e942
Replace more stack traces with log messages
axlewin Apr 23, 2026
d97b489
Augment bookmarks with content info more efficiently
axlewin Apr 24, 2026
26eb621
Fix tests
axlewin Apr 24, 2026
8705404
Make BookmarksFacade extend AbstractIsaacFacade
axlewin Apr 24, 2026
b166b3e
Move max number of bookmarks to constant
axlewin Apr 24, 2026
04f288c
Store all bookmark properties in BookmarkDO
axlewin Apr 24, 2026
19eb56f
Move bookmarks DB layer to DAO package
axlewin Apr 24, 2026
ea2e252
Avoid direct interaction with DB layer from bookmarks facade
axlewin Apr 24, 2026
6ee4c85
Remove redundant content type check
axlewin Apr 24, 2026
df85625
Fix date conversion
axlewin Apr 24, 2026
e412da6
Add logged events for adding/removing bookmarks
axlewin Apr 24, 2026
c8ddd78
Skip content augmentation if bookmarks list is empty
axlewin May 1, 2026
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
195 changes: 195 additions & 0 deletions src/main/java/uk/ac/cam/cl/dtg/isaac/api/BookmarksFacade.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package uk.ac.cam.cl.dtg.isaac.api;

import com.google.common.collect.ImmutableMap;
import com.google.inject.Inject;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import uk.ac.cam.cl.dtg.isaac.api.managers.BookmarksManager;
import uk.ac.cam.cl.dtg.isaac.dos.BookmarkDO;
import uk.ac.cam.cl.dtg.isaac.dto.SegueErrorResponse;
import uk.ac.cam.cl.dtg.isaac.dto.content.ContentDTO;
import uk.ac.cam.cl.dtg.isaac.dto.content.ContentSummaryDTO;
import uk.ac.cam.cl.dtg.isaac.dto.users.RegisteredUserDTO;
import uk.ac.cam.cl.dtg.segue.api.managers.UserAccountManager;
import uk.ac.cam.cl.dtg.segue.auth.exceptions.NoUserLoggedInException;
import uk.ac.cam.cl.dtg.segue.dao.ILogManager;
import uk.ac.cam.cl.dtg.segue.dao.content.ContentManagerException;
import uk.ac.cam.cl.dtg.segue.dao.content.GitContentManager;
import uk.ac.cam.cl.dtg.util.AbstractConfigLoader;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import java.util.Date;
import java.util.List;

import static uk.ac.cam.cl.dtg.isaac.api.Constants.*;

/**
* Bookmarks Facade
*
* Responsible for handling requests related to user bookmarks.
*/
@Path("/bookmarks")
@Tag(name = "BookmarksFacade", description = "/bookmarks")
public class BookmarksFacade extends AbstractIsaacFacade {
private static final Logger log = LoggerFactory.getLogger(BookmarksFacade.class);

private final UserAccountManager userManager;
private final GitContentManager contentManager;
private final BookmarksManager bookmarksManager;

@Inject
public BookmarksFacade(final AbstractConfigLoader propertiesLoader, final ILogManager logManager,
final UserAccountManager userManager, final GitContentManager contentManager,
final BookmarksManager bookmarksManager) {
super(propertiesLoader, logManager);

this.userManager = userManager;
this.contentManager = contentManager;
this.bookmarksManager = bookmarksManager;
}

/**
* Gets a list of content bookmarked by the current user.
*
* @param request
* - so we can find the current user.
* @param contentType
* - the type of content to filter bookmarks by, or null to return all bookmarks.
* @return the list of content that the user has bookmarked.
*/
@GET
@Path("/")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get bookmarks for the current user.")
public final Response getCurrentUserBookmarks(@Context final HttpServletRequest request,
@QueryParam("content_type") final String contentType) {
RegisteredUserDTO user;
try {
user = userManager.getCurrentRegisteredUser(request);
} catch (final NoUserLoggedInException e) {
return SegueErrorResponse.getNotLoggedInResponse();
}

if (null != contentType && !contentType.isEmpty()
&& !(contentType.equals("isaacQuestionPage") || contentType.equals("isaacConceptPage"))) {
log.warn("Invalid content type provided for bookmarks query: {}", contentType);
Comment thread
jsharkey13 marked this conversation as resolved.
Dismissed
return new SegueErrorResponse(Status.BAD_REQUEST, "Only question and concept pages can be bookmarked!").toResponse();
}

List<ContentSummaryDTO> bookmarks = bookmarksManager.getAugmentedBookmarksForUser(user.getId(), contentType);
return Response.ok(bookmarks).build();
}

/**
* Adds a bookmark for the current user.
*
* @param request
* - so we can find the current user.
* @param contentId
* - the id of the content to bookmark.
*/
@POST
@Path("/{content_id}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Add a bookmark for the current user.")
public final Response addCurrentUserBookmark(@Context final HttpServletRequest request,
@PathParam("content_id") final String contentId) {
RegisteredUserDTO user;
try {
user = userManager.getCurrentRegisteredUser(request);
} catch (final NoUserLoggedInException e) {
return SegueErrorResponse.getNotLoggedInResponse();
}

if (null == contentId || contentId.isEmpty()) {
return new SegueErrorResponse(Status.BAD_REQUEST, "Cannot create bookmark without content ID.").toResponse();
}

List<BookmarkDO> currentBookmarks = bookmarksManager.getBookmarksForUser(user.getId(), null);

if (currentBookmarks.size() >= MAXIMUM_BOOKMARKS) {
return new SegueErrorResponse(Status.BAD_REQUEST, "You already have the maximum number of bookmarks!.").toResponse();
}

if (currentBookmarks.stream().anyMatch(b -> b.contentId().equals(contentId))) {
return new SegueErrorResponse(Status.BAD_REQUEST, "You have already bookmarked this content.").toResponse();
}

String contentType;
try {
ContentDTO content = this.contentManager.getContentById(contentId);
contentType = content.getType();
if (null == contentType || !(contentType.equals("isaacQuestionPage") || contentType.equals("isaacConceptPage"))) {
log.warn("Invalid content type provided for bookmarks query: {}", contentType);
return new SegueErrorResponse(Status.BAD_REQUEST, "Only question and concept pages can be bookmarked!").toResponse();
}

BookmarkDO bookmarkToAdd = new BookmarkDO(user.getId(), contentId, contentType, new Date());
bookmarksManager.addBookmarkForUser(bookmarkToAdd);

} catch (final ContentManagerException | NullPointerException e) {
log.warn("Failed to create bookmark, could not find content with ID: {}", contentId);
Comment thread
jsharkey13 marked this conversation as resolved.
Dismissed
return new SegueErrorResponse(Status.NOT_FOUND, "Unable to find content to bookmark.").toResponse();
}

this.getLogManager().logEvent(user, request, IsaacServerLogType.ADD_BOOKMARK,
ImmutableMap.of(BOOKMARK_USER_ID_LOG_FIELDNAME, user.getId(),
BOOKMARK_CONTENT_ID_LOG_FIELDNAME, contentId,
BOOKMARK_CONTENT_TYPE_LOG_FIELDNAME, contentType));

return Response.ok().build();
}

/**
* Removes a bookmark for the current user.
*
* @param request
* - so we can find the current user.
* @param contentId
* - the id of the content to be removed from the user's bookmarks.
*/
@DELETE
@Path("/{content_id}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Remove a bookmark for the current user.")
public final Response removeCurrentUserBookmark(@Context final HttpServletRequest request,
@PathParam("content_id") final String contentId) {
RegisteredUserDTO user;
try {
user = userManager.getCurrentRegisteredUser(request);
} catch (final NoUserLoggedInException e) {
return SegueErrorResponse.getNotLoggedInResponse();
}

if (null == contentId || contentId.isEmpty()) {
return new SegueErrorResponse(Status.BAD_REQUEST, "Cannot delete bookmark without content ID.").toResponse();
}

List<BookmarkDO> currentBookmarks = bookmarksManager.getBookmarksForUser(user.getId(), null);
if (currentBookmarks.stream().noneMatch(b -> b.contentId().equals(contentId))) {
return new SegueErrorResponse(Status.BAD_REQUEST, "You have not bookmarked this content.").toResponse();
}

BookmarkDO bookmarkToRemove = new BookmarkDO(user.getId(), contentId, null, null);
bookmarksManager.removeBookmarkForUser(bookmarkToRemove);

this.getLogManager().logEvent(user, request, IsaacServerLogType.DELETE_BOOKMARK,
ImmutableMap.of(BOOKMARK_USER_ID_LOG_FIELDNAME, user.getId(),
BOOKMARK_CONTENT_ID_LOG_FIELDNAME, contentId));

return Response.noContent().build();
}
}
11 changes: 11 additions & 0 deletions src/main/java/uk/ac/cam/cl/dtg/isaac/api/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ public enum GameboardState {
public static final String PAGE_ID_LOG_FIELDNAME = "pageId";
public static final String FRAGMENT_ID_LOG_FIELDNAME = "pageFragmentId";
public static final String DOCUMENT_PATH_LOG_FIELDNAME = "path";
public static final String BOOKMARK_USER_ID_LOG_FIELDNAME = "userId";
public static final String BOOKMARK_CONTENT_ID_LOG_FIELDNAME = "contentId";
public static final String BOOKMARK_CONTENT_TYPE_LOG_FIELDNAME = "contentType";


public static final Long DEFAULT_MISUSE_STATISTICS_LIMIT = 5L;

Expand All @@ -146,8 +150,10 @@ public enum GameboardState {
*/
public enum IsaacServerLogType implements LogType {
ADD_BOARD_TO_PROFILE,
ADD_BOOKMARK,
CREATE_GAMEBOARD,
DELETE_ASSIGNMENT,
DELETE_BOOKMARK,
DELETE_QUIZ_ASSIGNMENT,
DELETE_BOARD_FROM_PROFILE,
DOWNLOAD_ASSIGNMENT_PROGRESS_CSV,
Expand Down Expand Up @@ -233,6 +239,11 @@ public enum IsaacUserPreferences {
*/
public static final long QUIZ_VIEW_STUDENT_ANSWERS_RELEASE_TIMESTAMP = Date.UTC(123, Calendar.JUNE, 12, 0, 0, 0); // 12/06/2023

/**
* Bookmarks
*/
public static final int MAXIMUM_BOOKMARKS = 100;

/**
* Feedback messages
*/
Expand Down
11 changes: 10 additions & 1 deletion src/main/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacade.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.jboss.resteasy.annotations.GZIP;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import uk.ac.cam.cl.dtg.isaac.api.managers.BookmarksManager;
import uk.ac.cam.cl.dtg.isaac.api.managers.GameManager;
import uk.ac.cam.cl.dtg.isaac.api.managers.URIManager;
import uk.ac.cam.cl.dtg.isaac.api.managers.UserAttemptManager;
Expand Down Expand Up @@ -109,6 +110,7 @@ public class PagesFacade extends AbstractIsaacFacade {
private final GitContentManager contentManager;
private final UserAttemptManager userAttemptManager;
private final GameManager gameManager;
private final BookmarksManager bookmarksManager;

/**
* Creates an instance of the pages controller which provides the REST endpoints for accessing page content.
Expand All @@ -131,13 +133,15 @@ public class PagesFacade extends AbstractIsaacFacade {
* - So we can look up attempt information.
* @param gameManager
* - For looking up gameboard information.
* @param bookmarksManager
* - For looking up bookmark information.
*/
@Inject
public PagesFacade(final ContentService api, final AbstractConfigLoader propertiesLoader,
final ILogManager logManager, final MainMapper mapper, final GitContentManager contentManager,
final UserAccountManager userManager, final URIManager uriManager,
final QuestionManager questionManager, final GameManager gameManager,
final UserAttemptManager userAttemptManager) {
final UserAttemptManager userAttemptManager, final BookmarksManager bookmarksManager) {
super(propertiesLoader, logManager);
this.api = api;
this.mapper = mapper;
Expand All @@ -147,6 +151,7 @@ public PagesFacade(final ContentService api, final AbstractConfigLoader properti
this.questionManager = questionManager;
this.gameManager = gameManager;
this.userAttemptManager = userAttemptManager;
this.bookmarksManager = bookmarksManager;
}

/**
Expand Down Expand Up @@ -509,6 +514,10 @@ public final Response getQuestionList(@Context final HttpServletRequest httpServ
}
}

if (user instanceof RegisteredUserDTO registeredUser) {
summarizedResults = bookmarksManager.augmentContentSummaryListWithBookmarkInformation(registeredUser.getId(), summarizedResults);
}

if (limit < 0 || combinedResults.size() + summarizedResults.size() <= limit) {
combinedResults.addAll(summarizedResults);
nextSearchStartIndex += unfilteredSummarizedResults.size();
Expand Down
Loading
Loading