Skip to content

MTM-3: view reading list (most-recent-bookmarked first)#184

Merged
devin-ai-integration[bot] merged 4 commits into
feat/reading-listfrom
devin/1782442947-mtm-3-reading-list
Jun 26, 2026
Merged

MTM-3: view reading list (most-recent-bookmarked first)#184
devin-ai-integration[bot] merged 4 commits into
feat/reading-listfrom
devin/1782442947-mtm-3-reading-list

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 26, 2026

Copy link
Copy Markdown

Summary

Implements MTM-3 — a signed-in user can view their reading list, newest‑bookmarked first — on top of the MTM-6 bookmark persistence foundation. All additive: a query‑service method, a REST path, a GraphQL Query field, a WebSecurityConfig matcher, and two read models. No changes to the per‑article bookmarked field (owned by MTM-4, per D5).

Ordering and cursoring are anchored on article_bookmarks.created_at (D1), independent of the article's own create/update time.

Subtasks

  • MTM-15 ArticleQueryService
    • findUserBookmarks(User, Page) → offset/limit, returns ArticleDataList (REST).
    • findUserBookmarksWithCursor(User, CursorPageParameter<DateTime>) → relay cursor (GraphQL), mirrors findRecentArticlesWithCursor (hasExtra trim + reverse‑on‑PREV).
  • MTM-16 REST GET /articles/bookmarked (auth required), offset default 0 / limit default 20, envelope { "articles": [...], "articlesCount": <int> }; explicit authenticated() matcher in WebSecurityConfig (since GET /articles/** is otherwise permitAll).
  • MTM-17 GraphQL top‑level bookmarkedArticles(first, after, last, before): ArticlesConnection; datafetcher builds the connection exactly like userFavorites (edges/cursors/pageInfo + slug→ArticleData localContext); getCurrentUser().orElseThrow(AuthenticationException::new).
  • MTM-18 tests (see below).

Key implementation note — cursor vs. storage format

articles.created_at is persisted as INTEGER epoch‑millis (written via the joda DateTimeHandler), but article_bookmarks.created_at is persisted as TEXT (the column's CURRENT_TIMESTAMP default from MTM-6). Feeding the MTM-6 cursor primitive a DateTime cursor binds it as millis, which can't compare correctly against the TEXT column under SQLite affinity. To keep the client‑facing cursor consistent (DateTimeCursor = millis) while comparing correctly, the cursor is converted to SQLite text form only at the read boundary — MTM-6's mapper and write path are untouched:

// ArticleQueryService.findUserBookmarksWithCursor
ids = articleBookmarksReadService.findUserBookmarkedArticleIdsWithCursor(
        userId, toTextCursor(page));   // DateTime -> "yyyy-MM-dd HH:mm:ss" (UTC)
// edges still expose DateTimeCursor(bookmarkedAt) via findBookmarkDates(...)

BookmarkedArticleData implements Node carries the bookmark DateTime so getCursor() returns DateTimeCursor(bookmarkedAt).

Robustness (from review)

  • Skip fillExtraInfo when the page's articles are all deleted — otherwise the favorites/bookmarks mappers render where id in () (invalid SQL → 500). Applies to both offset and cursor paths; covered by a new test.
  • Filter out an article whose bookmark was concurrently removed between the id query and the timestamp query (null bookmark time) — avoids an NPE in DateTimeCursor and correctly drops the entry.

Files

  • Added: application/data/BookmarkedArticleData.java, application/data/ArticleBookmarkDate.java.
  • Changed (additive): application/ArticleQueryService.java, api/ArticlesApi.java, api/security/WebSecurityConfig.java, graphql/ArticleDatafetcher.java (+ widened buildArticlePageInfo to CursorPager<? extends Node>), mybatis/readservice/ArticleBookmarksReadService.java (+3 methods), mapper/ArticleBookmarksReadService.xml, schema/schema.graphqls.
  • Tests: ArticleBookmarkQueryServiceTest, BookmarkedArticlesApiTest, ArticleBookmarkDatafetcherTest, additions to ArticleBookmarksReadServiceTest.
  • Merged latest feat/reading-list (MTM-2 / MTM-4) so this branch builds against the shared bookmarked flag.

Tests (MTM-18)

  • Functional: newest‑bookmarked‑first with controlled created_at; REST {articles, articlesCount}; GraphQL edges/cursors/pageInfo + localContext.
  • Pagination: REST offset/limit slices; GraphQL first/after + last/before stable, non‑overlapping; hasNextPage/hasPreviousPage correct.
  • Privacy/security: user A never sees user B's bookmarks; anonymous → 401 (REST) / AuthenticationException (GraphQL).
  • Edge: empty reading list; bookmark of a missing article filtered; whole page of deleted articles → empty, no crash; ordering anchored on bookmark time even when article create order is reversed.
  • Per D5, no assertions on the bookmarked flag.

Verification gate

./gradlew clean build -x test     # BUILD SUCCESSFUL
./gradlew clean test              # all tests pass (see note)
./gradlew spotlessCheck           # BUILD SUCCESSFUL

JaCoCo on new code = 98.8% (340/344 instructions; every new method 100%). CI build + Devin Review checks are green.

Note: ./gradlew clean test is finalized by the project‑wide jacocoTestCoverageVerification rule (bundle min 0.80), which the repo does not meet today — base feat/reading-list is 0.33. That rule is preexisting‑failing and the repo's own configured test command runs -x jacocoTestCoverageVerification. The test task itself is green: run ./gradlew clean test -x jacocoTestCoverageVerification.

Link to Devin session: https://partner-workshops.devinenterprise.com/sessions/d1d174cc645441b9b27945249d17f070


Open in Devin Review

Add current-user reading-list read paths over the MTM-6 bookmark
primitive, newest-bookmarked-first with bookmark-time cursoring (D1):

- ArticleQueryService.findUserBookmarks (offset/limit) and
  findUserBookmarksWithCursor (relay cursor); cursor anchored on
  article_bookmarks.created_at.
- REST GET /articles/bookmarked (auth required) + WebSecurityConfig matcher.
- GraphQL Query.bookmarkedArticles datafetcher + SDL.
- New read-service methods (offset/limit ids, count, bookmark dates)
  + ArticleBookmarkDate / BookmarkedArticleData read models.
@mbatchelor81 mbatchelor81 self-assigned this Jun 26, 2026
@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Open in Devin Review

Comment thread src/main/java/io/spring/application/ArticleQueryService.java Outdated
Comment thread src/main/java/io/spring/application/ArticleQueryService.java Outdated
Comment on lines +134 to +144
public ArticleDataList findUserBookmarks(User currentUser, Page page) {
List<String> ids =
articleBookmarksReadService.findUserBookmarkedArticleIds(currentUser.getId(), page);
int count = articleBookmarksReadService.countUserBookmarks(currentUser.getId());
if (ids.size() == 0) {
return new ArticleDataList(new ArrayList<>(), count);
}
List<ArticleData> articles = findArticlesInIdOrder(ids);
fillExtraInfo(articles, currentUser);
return new ArticleDataList(articles, count);
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Bookmark count includes orphaned bookmarks, creating a mismatch with returned articles

In findUserBookmarks at src/main/java/io/spring/application/ArticleQueryService.java:137, the total count comes from countUserBookmarks which counts raw bookmark rows, while findArticlesInIdOrder at line 141 filters out bookmarks whose articles no longer exist. This means articlesCount in the response can be higher than the number of articles actually returned. The test should_drop_bookmarks_for_deleted_articles at src/test/java/io/spring/application/article/ArticleBookmarkQueryServiceTest.java:237-240 explicitly documents and asserts this behavior (count=2 but only 1 article returned), so it appears intentional. However, clients paginating based on articlesCount may observe gaps or confusion, especially if many bookmarked articles are deleted.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional, as the test documents. articlesCount is the count of the user's bookmark rows (countUserBookmarks), independent of the page slice — matching the REST envelope semantics used elsewhere (articlesCount = total matching, not the page size). Orphaned bookmarks only arise because article_bookmarks has no FK to articles (MTM-6 foundation); in normal operation a deleted article wouldn't leave a dangling bookmark, so the count and result set agree. Reconciling orphan cleanup / FK semantics is a foundation concern outside MTM-3's additive scope, so I've left the count as the true bookmark total here rather than silently under-reporting.

When every article on a bookmark page has been deleted, findArticlesInIdOrder
returns empty while bookmark ids are non-empty; calling fillExtraInfo then
renders an invalid 'where id in ()' query. Skip fillExtraInfo for the empty
case in both the offset/limit and cursor paths; add a regression test.
devin-ai-integration[bot]

This comment was marked as resolved.

…MTM-4)

- Merge updated base so this branch builds against MTM-4's bookmarked flag.
- Skip articles whose bookmark was concurrently removed (null bookmark
  timestamp) to avoid an NPE when building the cursor.
@devin-ai-integration devin-ai-integration Bot merged commit 3300487 into feat/reading-list Jun 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant