From 5b444d63c1f0e535e61058e65b951550b1ff97e5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:39:58 +0000 Subject: [PATCH 1/5] feat: add bookmark article feature - Domain entity: ArticleBookmark (articleId, userId, createdAt) - Flyway migration V3 for article_bookmarks table - MyBatis mapper + XML for bookmark CRUD - Repository implementation: MyBatisArticleBookmarkRepository - REST API: POST/DELETE /articles/{slug}/bookmark - GraphQL mutations: bookmarkArticle / unbookmarkArticle - Unit tests: API layer (4 tests) + repository layer (3 tests) --- .../io/spring/api/ArticleBookmarkApi.java | 62 +++++++++ .../spring/core/bookmark/ArticleBookmark.java | 21 +++ .../bookmark/ArticleBookmarkRepository.java | 11 ++ .../io/spring/graphql/BookmarkMutation.java | 54 ++++++++ .../mybatis/mapper/ArticleBookmarkMapper.java | 14 ++ .../MyBatisArticleBookmarkRepository.java | 35 +++++ .../V3__create_article_bookmarks.sql | 6 + .../mapper/ArticleBookmarkMapper.xml | 23 ++++ src/main/resources/schema/schema.graphqls | 2 + .../io/spring/api/ArticleBookmarkApiTest.java | 125 ++++++++++++++++++ .../MyBatisArticleBookmarkRepositoryTest.java | 44 ++++++ .../resources/create_article_bookmarks.sql | 6 + 12 files changed, 403 insertions(+) create mode 100644 src/main/java/io/spring/api/ArticleBookmarkApi.java create mode 100644 src/main/java/io/spring/core/bookmark/ArticleBookmark.java create mode 100644 src/main/java/io/spring/core/bookmark/ArticleBookmarkRepository.java create mode 100644 src/main/java/io/spring/graphql/BookmarkMutation.java create mode 100644 src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleBookmarkMapper.java create mode 100644 src/main/java/io/spring/infrastructure/repository/MyBatisArticleBookmarkRepository.java create mode 100644 src/main/resources/db/migration/V3__create_article_bookmarks.sql create mode 100644 src/main/resources/mapper/ArticleBookmarkMapper.xml create mode 100644 src/test/java/io/spring/api/ArticleBookmarkApiTest.java create mode 100644 src/test/java/io/spring/infrastructure/bookmark/MyBatisArticleBookmarkRepositoryTest.java create mode 100644 src/test/resources/create_article_bookmarks.sql diff --git a/src/main/java/io/spring/api/ArticleBookmarkApi.java b/src/main/java/io/spring/api/ArticleBookmarkApi.java new file mode 100644 index 000000000..49a572982 --- /dev/null +++ b/src/main/java/io/spring/api/ArticleBookmarkApi.java @@ -0,0 +1,62 @@ +package io.spring.api; + +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.ArticleQueryService; +import io.spring.application.data.ArticleData; +import io.spring.core.article.Article; +import io.spring.core.article.ArticleRepository; +import io.spring.core.bookmark.ArticleBookmark; +import io.spring.core.bookmark.ArticleBookmarkRepository; +import io.spring.core.user.User; +import java.util.HashMap; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "articles/{slug}/bookmark") +@AllArgsConstructor +public class ArticleBookmarkApi { + private ArticleBookmarkRepository articleBookmarkRepository; + private ArticleRepository articleRepository; + private ArticleQueryService articleQueryService; + + @PostMapping + public ResponseEntity bookmarkArticle( + @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + ArticleBookmark articleBookmark = new ArticleBookmark(article.getId(), user.getId()); + articleBookmarkRepository.save(articleBookmark); + return responseArticleData(articleQueryService.findBySlug(slug, user).get()); + } + + @DeleteMapping + public ResponseEntity unbookmarkArticle( + @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + articleBookmarkRepository + .find(article.getId(), user.getId()) + .ifPresent( + bookmark -> { + articleBookmarkRepository.remove(bookmark); + }); + return responseArticleData(articleQueryService.findBySlug(slug, user).get()); + } + + private ResponseEntity> responseArticleData( + final ArticleData articleData) { + return ResponseEntity.ok( + new HashMap() { + { + put("article", articleData); + } + }); + } +} diff --git a/src/main/java/io/spring/core/bookmark/ArticleBookmark.java b/src/main/java/io/spring/core/bookmark/ArticleBookmark.java new file mode 100644 index 000000000..4d3674e1c --- /dev/null +++ b/src/main/java/io/spring/core/bookmark/ArticleBookmark.java @@ -0,0 +1,21 @@ +package io.spring.core.bookmark; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.joda.time.DateTime; + +@NoArgsConstructor +@Getter +@EqualsAndHashCode(exclude = "createdAt") +public class ArticleBookmark { + private String articleId; + private String userId; + private DateTime createdAt; + + public ArticleBookmark(String articleId, String userId) { + this.articleId = articleId; + this.userId = userId; + this.createdAt = new DateTime(); + } +} diff --git a/src/main/java/io/spring/core/bookmark/ArticleBookmarkRepository.java b/src/main/java/io/spring/core/bookmark/ArticleBookmarkRepository.java new file mode 100644 index 000000000..d5d432fd1 --- /dev/null +++ b/src/main/java/io/spring/core/bookmark/ArticleBookmarkRepository.java @@ -0,0 +1,11 @@ +package io.spring.core.bookmark; + +import java.util.Optional; + +public interface ArticleBookmarkRepository { + void save(ArticleBookmark articleBookmark); + + Optional find(String articleId, String userId); + + void remove(ArticleBookmark bookmark); +} diff --git a/src/main/java/io/spring/graphql/BookmarkMutation.java b/src/main/java/io/spring/graphql/BookmarkMutation.java new file mode 100644 index 000000000..cd1001245 --- /dev/null +++ b/src/main/java/io/spring/graphql/BookmarkMutation.java @@ -0,0 +1,54 @@ +package io.spring.graphql; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsMutation; +import com.netflix.graphql.dgs.InputArgument; +import graphql.execution.DataFetcherResult; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.core.article.Article; +import io.spring.core.article.ArticleRepository; +import io.spring.core.bookmark.ArticleBookmark; +import io.spring.core.bookmark.ArticleBookmarkRepository; +import io.spring.core.user.User; +import io.spring.graphql.DgsConstants.MUTATION; +import io.spring.graphql.exception.AuthenticationException; +import io.spring.graphql.types.ArticlePayload; +import lombok.AllArgsConstructor; + +@DgsComponent +@AllArgsConstructor +public class BookmarkMutation { + + private ArticleBookmarkRepository articleBookmarkRepository; + private ArticleRepository articleRepository; + + @DgsMutation(field = MUTATION.BookmarkArticle) + public DataFetcherResult bookmarkArticle(@InputArgument("slug") String slug) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + ArticleBookmark articleBookmark = new ArticleBookmark(article.getId(), user.getId()); + articleBookmarkRepository.save(articleBookmark); + return DataFetcherResult.newResult() + .data(ArticlePayload.newBuilder().build()) + .localContext(article) + .build(); + } + + @DgsMutation(field = MUTATION.UnbookmarkArticle) + public DataFetcherResult unbookmarkArticle(@InputArgument("slug") String slug) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + articleBookmarkRepository + .find(article.getId(), user.getId()) + .ifPresent( + bookmark -> { + articleBookmarkRepository.remove(bookmark); + }); + return DataFetcherResult.newResult() + .data(ArticlePayload.newBuilder().build()) + .localContext(article) + .build(); + } +} diff --git a/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleBookmarkMapper.java b/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleBookmarkMapper.java new file mode 100644 index 000000000..739d33bf3 --- /dev/null +++ b/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleBookmarkMapper.java @@ -0,0 +1,14 @@ +package io.spring.infrastructure.mybatis.mapper; + +import io.spring.core.bookmark.ArticleBookmark; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ArticleBookmarkMapper { + ArticleBookmark find(@Param("articleId") String articleId, @Param("userId") String userId); + + void insert(@Param("articleBookmark") ArticleBookmark articleBookmark); + + void delete(@Param("bookmark") ArticleBookmark bookmark); +} diff --git a/src/main/java/io/spring/infrastructure/repository/MyBatisArticleBookmarkRepository.java b/src/main/java/io/spring/infrastructure/repository/MyBatisArticleBookmarkRepository.java new file mode 100644 index 000000000..3a6339a8d --- /dev/null +++ b/src/main/java/io/spring/infrastructure/repository/MyBatisArticleBookmarkRepository.java @@ -0,0 +1,35 @@ +package io.spring.infrastructure.repository; + +import io.spring.core.bookmark.ArticleBookmark; +import io.spring.core.bookmark.ArticleBookmarkRepository; +import io.spring.infrastructure.mybatis.mapper.ArticleBookmarkMapper; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +@Repository +public class MyBatisArticleBookmarkRepository implements ArticleBookmarkRepository { + private ArticleBookmarkMapper mapper; + + @Autowired + public MyBatisArticleBookmarkRepository(ArticleBookmarkMapper mapper) { + this.mapper = mapper; + } + + @Override + public void save(ArticleBookmark articleBookmark) { + if (mapper.find(articleBookmark.getArticleId(), articleBookmark.getUserId()) == null) { + mapper.insert(articleBookmark); + } + } + + @Override + public Optional find(String articleId, String userId) { + return Optional.ofNullable(mapper.find(articleId, userId)); + } + + @Override + public void remove(ArticleBookmark bookmark) { + mapper.delete(bookmark); + } +} diff --git a/src/main/resources/db/migration/V3__create_article_bookmarks.sql b/src/main/resources/db/migration/V3__create_article_bookmarks.sql new file mode 100644 index 000000000..8c56c9a3d --- /dev/null +++ b/src/main/resources/db/migration/V3__create_article_bookmarks.sql @@ -0,0 +1,6 @@ +create table article_bookmarks ( + article_id varchar(255) not null, + user_id varchar(255) not null, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + primary key(article_id, user_id) +); diff --git a/src/main/resources/mapper/ArticleBookmarkMapper.xml b/src/main/resources/mapper/ArticleBookmarkMapper.xml new file mode 100644 index 000000000..85c74194b --- /dev/null +++ b/src/main/resources/mapper/ArticleBookmarkMapper.xml @@ -0,0 +1,23 @@ + + + + + insert into article_bookmarks (article_id, user_id, created_at) values (#{articleBookmark.articleId}, #{articleBookmark.userId}, #{articleBookmark.createdAt}) + + + delete from article_bookmarks where article_id = #{bookmark.articleId} and user_id = #{bookmark.userId} + + + + + + + + diff --git a/src/main/resources/schema/schema.graphqls b/src/main/resources/schema/schema.graphqls index a3f6be557..25fe6248d 100644 --- a/src/main/resources/schema/schema.graphqls +++ b/src/main/resources/schema/schema.graphqls @@ -32,6 +32,8 @@ type Mutation { favoriteArticle(slug: String!): ArticlePayload unfavoriteArticle(slug: String!): ArticlePayload deleteArticle(slug: String!): DeletionStatus + bookmarkArticle(slug: String!): ArticlePayload + unbookmarkArticle(slug: String!): ArticlePayload ### Comment addComment(slug: String!, body: String!): CommentPayload diff --git a/src/test/java/io/spring/api/ArticleBookmarkApiTest.java b/src/test/java/io/spring/api/ArticleBookmarkApiTest.java new file mode 100644 index 000000000..712040055 --- /dev/null +++ b/src/test/java/io/spring/api/ArticleBookmarkApiTest.java @@ -0,0 +1,125 @@ +package io.spring.api; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.spring.JacksonCustomizations; +import io.spring.api.security.WebSecurityConfig; +import io.spring.application.ArticleQueryService; +import io.spring.application.data.ArticleData; +import io.spring.application.data.ProfileData; +import io.spring.core.article.Article; +import io.spring.core.article.ArticleRepository; +import io.spring.core.article.Tag; +import io.spring.core.bookmark.ArticleBookmark; +import io.spring.core.bookmark.ArticleBookmarkRepository; +import io.spring.core.user.User; +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ArticleBookmarkApi.class) +@Import({WebSecurityConfig.class, JacksonCustomizations.class}) +public class ArticleBookmarkApiTest extends TestWithCurrentUser { + @Autowired private MockMvc mvc; + + @MockBean private ArticleBookmarkRepository articleBookmarkRepository; + + @MockBean private ArticleRepository articleRepository; + + @MockBean private ArticleQueryService articleQueryService; + + private Article article; + + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + RestAssuredMockMvc.mockMvc(mvc); + User anotherUser = new User("other@test.com", "other", "123", "", ""); + article = new Article("title", "desc", "body", Arrays.asList("java"), anotherUser.getId()); + when(articleRepository.findBySlug(eq(article.getSlug()))).thenReturn(Optional.of(article)); + ArticleData articleData = + new ArticleData( + article.getId(), + article.getSlug(), + article.getTitle(), + article.getDescription(), + article.getBody(), + true, + 1, + article.getCreatedAt(), + article.getUpdatedAt(), + article.getTags().stream().map(Tag::getName).collect(Collectors.toList()), + new ProfileData( + anotherUser.getId(), + anotherUser.getUsername(), + anotherUser.getBio(), + anotherUser.getImage(), + false)); + when(articleQueryService.findBySlug(eq(articleData.getSlug()), eq(user))) + .thenReturn(Optional.of(articleData)); + } + + @Test + public void should_bookmark_an_article_success() throws Exception { + given() + .header("Authorization", "Token " + token) + .when() + .post("/articles/{slug}/bookmark", article.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("article.id", equalTo(article.getId())); + + verify(articleBookmarkRepository).save(any()); + } + + @Test + public void should_unbookmark_an_article_success() throws Exception { + when(articleBookmarkRepository.find(eq(article.getId()), eq(user.getId()))) + .thenReturn(Optional.of(new ArticleBookmark(article.getId(), user.getId()))); + given() + .header("Authorization", "Token " + token) + .when() + .delete("/articles/{slug}/bookmark", article.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("article.id", equalTo(article.getId())); + verify(articleBookmarkRepository).remove(new ArticleBookmark(article.getId(), user.getId())); + } + + @Test + public void should_fail_bookmark_with_no_auth() throws Exception { + given() + .when() + .post("/articles/{slug}/bookmark", article.getSlug()) + .prettyPeek() + .then() + .statusCode(401); + } + + @Test + public void should_fail_bookmark_article_not_found() throws Exception { + when(articleRepository.findBySlug(eq("nonexistent"))).thenReturn(Optional.empty()); + given() + .header("Authorization", "Token " + token) + .when() + .post("/articles/{slug}/bookmark", "nonexistent") + .prettyPeek() + .then() + .statusCode(404); + } +} diff --git a/src/test/java/io/spring/infrastructure/bookmark/MyBatisArticleBookmarkRepositoryTest.java b/src/test/java/io/spring/infrastructure/bookmark/MyBatisArticleBookmarkRepositoryTest.java new file mode 100644 index 000000000..faa7d8291 --- /dev/null +++ b/src/test/java/io/spring/infrastructure/bookmark/MyBatisArticleBookmarkRepositoryTest.java @@ -0,0 +1,44 @@ +package io.spring.infrastructure.bookmark; + +import io.spring.core.bookmark.ArticleBookmark; +import io.spring.core.bookmark.ArticleBookmarkRepository; +import io.spring.infrastructure.DbTestBase; +import io.spring.infrastructure.repository.MyBatisArticleBookmarkRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; + +@Import({MyBatisArticleBookmarkRepository.class}) +@Sql("classpath:create_article_bookmarks.sql") +public class MyBatisArticleBookmarkRepositoryTest extends DbTestBase { + @Autowired private ArticleBookmarkRepository articleBookmarkRepository; + + @Autowired + private io.spring.infrastructure.mybatis.mapper.ArticleBookmarkMapper articleBookmarkMapper; + + @Test + public void should_save_and_fetch_articleBookmark_success() { + ArticleBookmark articleBookmark = new ArticleBookmark("123", "456"); + articleBookmarkRepository.save(articleBookmark); + Assertions.assertNotNull( + articleBookmarkMapper.find(articleBookmark.getArticleId(), articleBookmark.getUserId())); + } + + @Test + public void should_remove_bookmark_success() { + ArticleBookmark articleBookmark = new ArticleBookmark("123", "456"); + articleBookmarkRepository.save(articleBookmark); + articleBookmarkRepository.remove(articleBookmark); + Assertions.assertFalse(articleBookmarkRepository.find("123", "456").isPresent()); + } + + @Test + public void should_not_duplicate_bookmark_on_double_save() { + ArticleBookmark articleBookmark = new ArticleBookmark("123", "456"); + articleBookmarkRepository.save(articleBookmark); + articleBookmarkRepository.save(articleBookmark); + Assertions.assertTrue(articleBookmarkRepository.find("123", "456").isPresent()); + } +} diff --git a/src/test/resources/create_article_bookmarks.sql b/src/test/resources/create_article_bookmarks.sql new file mode 100644 index 000000000..355ced783 --- /dev/null +++ b/src/test/resources/create_article_bookmarks.sql @@ -0,0 +1,6 @@ +create table if not exists article_bookmarks ( + article_id varchar(255) not null, + user_id varchar(255) not null, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + primary key(article_id, user_id) +); From e99e6fe62641406ad0ce9284f3a3911f85e33f9b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:47:24 +0000 Subject: [PATCH 2/5] feat: add read-side bookmark integration - Add bookmarked field to ArticleData - Add ArticleBookmarksReadService (MyBatis mapper for bookmark queries) - Integrate into ArticleQueryService.fillExtraInfo() (single + batch) - Add bookmarked field to GraphQL Article type - Wire bookmarked into ArticleDatafetcher.buildArticleResult() - Update all test constructor calls for new ArticleData field --- .../application/ArticleQueryService.java | 17 +++++++++++++++++ .../spring/application/data/ArticleData.java | 1 + .../io/spring/graphql/ArticleDatafetcher.java | 1 + .../ArticleBookmarksReadService.java | 14 ++++++++++++++ .../mapper/ArticleBookmarksReadService.xml | 18 ++++++++++++++++++ src/main/resources/schema/schema.graphqls | 1 + src/test/java/io/spring/TestHelper.java | 2 ++ .../java/io/spring/api/ArticleApiTest.java | 1 + .../io/spring/api/ArticleBookmarkApiTest.java | 1 + .../io/spring/api/ArticleFavoriteApiTest.java | 1 + .../java/io/spring/api/ArticlesApiTest.java | 2 ++ .../article/ArticleQueryServiceTest.java | 2 ++ 12 files changed, 61 insertions(+) create mode 100644 src/main/java/io/spring/infrastructure/mybatis/readservice/ArticleBookmarksReadService.java create mode 100644 src/main/resources/mapper/ArticleBookmarksReadService.xml diff --git a/src/main/java/io/spring/application/ArticleQueryService.java b/src/main/java/io/spring/application/ArticleQueryService.java index 959e8c638..2ba32c5c0 100644 --- a/src/main/java/io/spring/application/ArticleQueryService.java +++ b/src/main/java/io/spring/application/ArticleQueryService.java @@ -6,6 +6,7 @@ import io.spring.application.data.ArticleDataList; import io.spring.application.data.ArticleFavoriteCount; import io.spring.core.user.User; +import io.spring.infrastructure.mybatis.readservice.ArticleBookmarksReadService; import io.spring.infrastructure.mybatis.readservice.ArticleFavoritesReadService; import io.spring.infrastructure.mybatis.readservice.ArticleReadService; import io.spring.infrastructure.mybatis.readservice.UserRelationshipQueryService; @@ -26,6 +27,7 @@ public class ArticleQueryService { private ArticleReadService articleReadService; private UserRelationshipQueryService userRelationshipQueryService; private ArticleFavoritesReadService articleFavoritesReadService; + private ArticleBookmarksReadService articleBookmarksReadService; public Optional findById(String id, User user) { ArticleData articleData = articleReadService.findById(id); @@ -126,6 +128,7 @@ private void fillExtraInfo(List articles, User currentUser) { setFavoriteCount(articles); if (currentUser != null) { setIsFavorite(articles, currentUser); + setIsBookmarked(articles, currentUser); setIsFollowingAuthor(articles, currentUser); } } @@ -172,9 +175,23 @@ private void setIsFavorite(List articles, User currentUser) { }); } + private void setIsBookmarked(List articles, User currentUser) { + Set bookmarkedArticles = + articleBookmarksReadService.userBookmarks( + articles.stream().map(ArticleData::getId).collect(toList()), currentUser); + + articles.forEach( + articleData -> { + if (bookmarkedArticles.contains(articleData.getId())) { + articleData.setBookmarked(true); + } + }); + } + private void fillExtraInfo(String id, User user, ArticleData articleData) { articleData.setFavorited(articleFavoritesReadService.isUserFavorite(user.getId(), id)); articleData.setFavoritesCount(articleFavoritesReadService.articleFavoriteCount(id)); + articleData.setBookmarked(articleBookmarksReadService.isUserBookmark(user.getId(), id)); articleData .getProfileData() .setFollowing( diff --git a/src/main/java/io/spring/application/data/ArticleData.java b/src/main/java/io/spring/application/data/ArticleData.java index 3d3c947e2..a328f6c79 100644 --- a/src/main/java/io/spring/application/data/ArticleData.java +++ b/src/main/java/io/spring/application/data/ArticleData.java @@ -19,6 +19,7 @@ public class ArticleData implements io.spring.application.Node { private String body; private boolean favorited; private int favoritesCount; + private boolean bookmarked; private DateTime createdAt; private DateTime updatedAt; private List tagList; diff --git a/src/main/java/io/spring/graphql/ArticleDatafetcher.java b/src/main/java/io/spring/graphql/ArticleDatafetcher.java index 37c82939a..8e7f7f015 100644 --- a/src/main/java/io/spring/graphql/ArticleDatafetcher.java +++ b/src/main/java/io/spring/graphql/ArticleDatafetcher.java @@ -375,6 +375,7 @@ private Article buildArticleResult(ArticleData articleData) { .description(articleData.getDescription()) .favorited(articleData.isFavorited()) .favoritesCount(articleData.getFavoritesCount()) + .bookmarked(articleData.isBookmarked()) .slug(articleData.getSlug()) .tagList(articleData.getTagList()) .title(articleData.getTitle()) diff --git a/src/main/java/io/spring/infrastructure/mybatis/readservice/ArticleBookmarksReadService.java b/src/main/java/io/spring/infrastructure/mybatis/readservice/ArticleBookmarksReadService.java new file mode 100644 index 000000000..af3e80645 --- /dev/null +++ b/src/main/java/io/spring/infrastructure/mybatis/readservice/ArticleBookmarksReadService.java @@ -0,0 +1,14 @@ +package io.spring.infrastructure.mybatis.readservice; + +import io.spring.core.user.User; +import java.util.List; +import java.util.Set; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ArticleBookmarksReadService { + boolean isUserBookmark(@Param("userId") String userId, @Param("articleId") String articleId); + + Set userBookmarks(@Param("ids") List ids, @Param("currentUser") User currentUser); +} diff --git a/src/main/resources/mapper/ArticleBookmarksReadService.xml b/src/main/resources/mapper/ArticleBookmarksReadService.xml new file mode 100644 index 000000000..bb81cf473 --- /dev/null +++ b/src/main/resources/mapper/ArticleBookmarksReadService.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/src/main/resources/schema/schema.graphqls b/src/main/resources/schema/schema.graphqls index 25fe6248d..e23dce00a 100644 --- a/src/main/resources/schema/schema.graphqls +++ b/src/main/resources/schema/schema.graphqls @@ -54,6 +54,7 @@ type Article { description: String! favorited: Boolean! favoritesCount: Int! + bookmarked: Boolean! slug: String! tagList: [String], title: String! diff --git a/src/test/java/io/spring/TestHelper.java b/src/test/java/io/spring/TestHelper.java index dcd57071c..6f7ada4f3 100644 --- a/src/test/java/io/spring/TestHelper.java +++ b/src/test/java/io/spring/TestHelper.java @@ -19,6 +19,7 @@ public static ArticleData articleDataFixture(String seed, User user) { "body " + seed, false, 0, + false, now, now, new ArrayList<>(), @@ -34,6 +35,7 @@ public static ArticleData getArticleDataFromArticleAndUser(Article article, User article.getBody(), false, 0, + false, article.getCreatedAt(), article.getUpdatedAt(), Arrays.asList("joda"), diff --git a/src/test/java/io/spring/api/ArticleApiTest.java b/src/test/java/io/spring/api/ArticleApiTest.java index df2ebe755..b6d6e34c1 100644 --- a/src/test/java/io/spring/api/ArticleApiTest.java +++ b/src/test/java/io/spring/api/ArticleApiTest.java @@ -141,6 +141,7 @@ public void should_get_403_if_not_author_to_update_article() throws Exception { article.getBody(), false, 0, + false, time, time, Arrays.asList("joda"), diff --git a/src/test/java/io/spring/api/ArticleBookmarkApiTest.java b/src/test/java/io/spring/api/ArticleBookmarkApiTest.java index 712040055..6cb123522 100644 --- a/src/test/java/io/spring/api/ArticleBookmarkApiTest.java +++ b/src/test/java/io/spring/api/ArticleBookmarkApiTest.java @@ -59,6 +59,7 @@ public void setUp() throws Exception { article.getBody(), true, 1, + false, article.getCreatedAt(), article.getUpdatedAt(), article.getTags().stream().map(Tag::getName).collect(Collectors.toList()), diff --git a/src/test/java/io/spring/api/ArticleFavoriteApiTest.java b/src/test/java/io/spring/api/ArticleFavoriteApiTest.java index 7a609a255..45bdb6ee1 100644 --- a/src/test/java/io/spring/api/ArticleFavoriteApiTest.java +++ b/src/test/java/io/spring/api/ArticleFavoriteApiTest.java @@ -59,6 +59,7 @@ public void setUp() throws Exception { article.getBody(), true, 1, + false, article.getCreatedAt(), article.getUpdatedAt(), article.getTags().stream().map(Tag::getName).collect(Collectors.toList()), diff --git a/src/test/java/io/spring/api/ArticlesApiTest.java b/src/test/java/io/spring/api/ArticlesApiTest.java index 18948417d..f7467eb44 100644 --- a/src/test/java/io/spring/api/ArticlesApiTest.java +++ b/src/test/java/io/spring/api/ArticlesApiTest.java @@ -63,6 +63,7 @@ public void should_create_article_success() throws Exception { body, false, 0, + false, new DateTime(), new DateTime(), tagList, @@ -132,6 +133,7 @@ public void should_get_error_message_with_duplicated_title() { body, false, 0, + false, new DateTime(), new DateTime(), asList(tagList), diff --git a/src/test/java/io/spring/application/article/ArticleQueryServiceTest.java b/src/test/java/io/spring/application/article/ArticleQueryServiceTest.java index 96229376c..056056536 100644 --- a/src/test/java/io/spring/application/article/ArticleQueryServiceTest.java +++ b/src/test/java/io/spring/application/article/ArticleQueryServiceTest.java @@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; @Import({ ArticleQueryService.class, @@ -34,6 +35,7 @@ MyBatisArticleRepository.class, MyBatisArticleFavoriteRepository.class }) +@Sql("classpath:create_article_bookmarks.sql") public class ArticleQueryServiceTest extends DbTestBase { @Autowired private ArticleQueryService queryService; From e9ada97f5629db50af6cff6d1f19d055526ac38b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:03:55 +0000 Subject: [PATCH 3/5] feat: add bookmark UI to frontend article pages - Add bookmark/unbookmark API methods to ArticleAPI - Add bookmark button (ion-bookmark icon) to ArticleMeta on detail page Shows 'Bookmark'/'Bookmarked' text with toggle on click - Add bookmark icon button to ArticlePreview cards on article list Sits alongside existing favorite heart button - Both buttons use optimistic UI updates with error rollback --- frontend/components/article/ArticleMeta.tsx | 46 +++++++++++++++++++ .../components/article/ArticlePreview.tsx | 37 +++++++++++++++ frontend/lib/api/article.ts | 18 ++++++++ 3 files changed, 101 insertions(+) diff --git a/frontend/components/article/ArticleMeta.tsx b/frontend/components/article/ArticleMeta.tsx index 450795e86..152227497 100644 --- a/frontend/components/article/ArticleMeta.tsx +++ b/frontend/components/article/ArticleMeta.tsx @@ -1,10 +1,48 @@ import React from "react"; +import Router from "next/router"; +import useSWR, { trigger } from "swr"; import ArticleActions from "./ArticleActions"; import CustomImage from "../common/CustomImage"; import CustomLink from "../common/CustomLink"; +import ArticleAPI from "../../lib/api/article"; +import checkLogin from "../../lib/utils/checkLogin"; +import { SERVER_BASE_URL } from "../../lib/utils/constant"; +import storage from "../../lib/utils/storage"; + +const BOOKMARKED_CLASS = "btn btn-sm btn-primary"; +const NOT_BOOKMARKED_CLASS = "btn btn-sm btn-outline-primary"; const ArticleMeta = ({ article }) => { + const { data: currentUser } = useSWR("user", storage); + const isLoggedIn = checkLogin(currentUser); + + const [bookmarked, setBookmarked] = React.useState(article?.bookmarked || false); + + React.useEffect(() => { + setBookmarked(article?.bookmarked || false); + }, [article?.bookmarked]); + + const handleBookmark = async () => { + if (!isLoggedIn) { + Router.push(`/user/login`); + return; + } + + try { + if (bookmarked) { + setBookmarked(false); + await ArticleAPI.unbookmark(article.slug, currentUser?.token); + } else { + setBookmarked(true); + await ArticleAPI.bookmark(article.slug, currentUser?.token); + } + trigger(`${SERVER_BASE_URL}/articles/${article.slug}`); + } catch (error) { + setBookmarked(!bookmarked); + } + }; + if (!article) return; return ( @@ -30,6 +68,14 @@ const ArticleMeta = ({ article }) => { + + ); }; diff --git a/frontend/components/article/ArticlePreview.tsx b/frontend/components/article/ArticlePreview.tsx index 266853203..8f75d9940 100644 --- a/frontend/components/article/ArticlePreview.tsx +++ b/frontend/components/article/ArticlePreview.tsx @@ -8,11 +8,14 @@ import CustomLink from "../common/CustomLink"; import CustomImage from "../common/CustomImage"; import { usePageDispatch } from "../../lib/context/PageContext"; import checkLogin from "../../lib/utils/checkLogin"; +import ArticleAPI from "../../lib/api/article"; import { SERVER_BASE_URL } from "../../lib/utils/constant"; import storage from "../../lib/utils/storage"; const FAVORITED_CLASS = "btn btn-sm btn-primary"; const NOT_FAVORITED_CLASS = "btn btn-sm btn-outline-primary"; +const BOOKMARKED_CLASS = "btn btn-sm btn-primary"; +const NOT_BOOKMARKED_CLASS = "btn btn-sm btn-outline-primary"; const ArticlePreview = ({ article }) => { const setPage = usePageDispatch(); @@ -24,6 +27,31 @@ const ArticlePreview = ({ article }) => { const { data: currentUser } = useSWR("user", storage); const isLoggedIn = checkLogin(currentUser); + const handleClickBookmark = async (slug) => { + if (!isLoggedIn) { + Router.push(`/user/login`); + return; + } + + setPreview({ + ...preview, + bookmarked: !preview.bookmarked, + }); + + try { + if (preview.bookmarked) { + await ArticleAPI.unbookmark(slug, currentUser?.token); + } else { + await ArticleAPI.bookmark(slug, currentUser?.token); + } + } catch (error) { + setPreview({ + ...preview, + bookmarked: !preview.bookmarked, + }); + } + }; + const handleClickFavorite = async (slug) => { if (!isLoggedIn) { Router.push(`/user/login`); @@ -96,6 +124,15 @@ const ArticlePreview = ({ article }) => {
+