Merge pull request #52 from nagisa77/codex/add-subscription-functionality

Implement subscriptions
This commit is contained in:
Tim
2025-07-02 21:14:41 +08:00
committed by GitHub
12 changed files with 337 additions and 2 deletions

View File

@@ -0,0 +1,44 @@
package com.openisle.controller;
import com.openisle.service.SubscriptionService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
/** Endpoints for subscribing to posts, comments and users. */
@RestController
@RequestMapping("/api/subscriptions")
@RequiredArgsConstructor
public class SubscriptionController {
private final SubscriptionService subscriptionService;
@PostMapping("/posts/{postId}")
public void subscribePost(@PathVariable Long postId, Authentication auth) {
subscriptionService.subscribePost(auth.getName(), postId);
}
@DeleteMapping("/posts/{postId}")
public void unsubscribePost(@PathVariable Long postId, Authentication auth) {
subscriptionService.unsubscribePost(auth.getName(), postId);
}
@PostMapping("/comments/{commentId}")
public void subscribeComment(@PathVariable Long commentId, Authentication auth) {
subscriptionService.subscribeComment(auth.getName(), commentId);
}
@DeleteMapping("/comments/{commentId}")
public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) {
subscriptionService.unsubscribeComment(auth.getName(), commentId);
}
@PostMapping("/users/{username}")
public void subscribeUser(@PathVariable String username, Authentication auth) {
subscriptionService.subscribeUser(auth.getName(), username);
}
@DeleteMapping("/users/{username}")
public void unsubscribeUser(@PathVariable String username, Authentication auth) {
subscriptionService.unsubscribeUser(auth.getName(), username);
}
}

View File

@@ -5,6 +5,7 @@ import com.openisle.service.ImageUploader;
import com.openisle.service.UserService;
import com.openisle.service.PostService;
import com.openisle.service.CommentService;
import com.openisle.service.SubscriptionService;
import org.springframework.beans.factory.annotation.Value;
import lombok.Data;
import lombok.RequiredArgsConstructor;
@@ -24,6 +25,7 @@ public class UserController {
private final ImageUploader imageUploader;
private final PostService postService;
private final CommentService commentService;
private final SubscriptionService subscriptionService;
@Value("${app.upload.check-type:true}")
private boolean checkImageType;
@@ -86,6 +88,20 @@ public class UserController {
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{username}/following")
public java.util.List<UserDto> following(@PathVariable String username) {
return subscriptionService.getSubscribedUsers(username).stream()
.map(this::toDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{username}/followers")
public java.util.List<UserDto> followers(@PathVariable String username) {
return subscriptionService.getSubscribers(username).stream()
.map(this::toDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{username}/all")
public ResponseEntity<UserAggregateDto> userAggregate(@PathVariable String username,
@RequestParam(value = "postsLimit", required = false) Integer postsLimit,
@@ -112,6 +128,8 @@ public class UserController {
dto.setUsername(user.getUsername());
dto.setEmail(user.getEmail());
dto.setAvatar(user.getAvatar());
dto.setFollowers(subscriptionService.countSubscribers(user.getUsername()));
dto.setFollowing(subscriptionService.countSubscribed(user.getUsername()));
return dto;
}
@@ -140,6 +158,8 @@ public class UserController {
private String username;
private String email;
private String avatar;
private long followers;
private long following;
}
@Data

View File

@@ -0,0 +1,27 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Subscription to a comment for replies notifications. */
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "comment_subscriptions",
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "comment_id"}))
public class CommentSubscription {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "comment_id")
private Comment comment;
}

View File

@@ -11,5 +11,9 @@ public enum NotificationType {
/** Someone reacted to your post or comment */
REACTION,
/** Your post under review was approved or rejected */
POST_REVIEWED
POST_REVIEWED,
/** A subscribed post received a new comment */
POST_UPDATED,
/** A user you subscribe to created a post or comment */
USER_ACTIVITY
}

View File

@@ -0,0 +1,27 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Subscription to a post for update notifications. */
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "post_subscriptions",
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "post_id"}))
public class PostSubscription {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "post_id")
private Post post;
}

View File

@@ -0,0 +1,27 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Following relationship between users. */
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "user_subscriptions",
uniqueConstraints = @UniqueConstraint(columnNames = {"subscriber_id", "target_id"}))
public class UserSubscription {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "subscriber_id")
private User subscriber;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "target_id")
private User target;
}

View File

@@ -0,0 +1,15 @@
package com.openisle.repository;
import com.openisle.model.Comment;
import com.openisle.model.CommentSubscription;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface CommentSubscriptionRepository extends JpaRepository<CommentSubscription, Long> {
List<CommentSubscription> findByComment(Comment comment);
List<CommentSubscription> findByUser(User user);
Optional<CommentSubscription> findByUserAndComment(User user, Comment comment);
}

View File

@@ -0,0 +1,15 @@
package com.openisle.repository;
import com.openisle.model.Post;
import com.openisle.model.PostSubscription;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface PostSubscriptionRepository extends JpaRepository<PostSubscription, Long> {
List<PostSubscription> findByPost(Post post);
List<PostSubscription> findByUser(User user);
Optional<PostSubscription> findByUserAndPost(User user, Post post);
}

View File

@@ -0,0 +1,16 @@
package com.openisle.repository;
import com.openisle.model.User;
import com.openisle.model.UserSubscription;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface UserSubscriptionRepository extends JpaRepository<UserSubscription, Long> {
List<UserSubscription> findBySubscriber(User subscriber);
List<UserSubscription> findByTarget(User target);
Optional<UserSubscription> findBySubscriberAndTarget(User subscriber, User target);
long countByTarget(User target);
long countBySubscriber(User subscriber);
}

View File

@@ -8,6 +8,7 @@ import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import com.openisle.service.NotificationService;
import com.openisle.service.SubscriptionService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -22,6 +23,7 @@ public class CommentService {
private final PostRepository postRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
private final SubscriptionService subscriptionService;
public Comment addComment(String username, Long postId, String content) {
User author = userRepository.findByUsername(username)
@@ -36,6 +38,16 @@ public class CommentService {
if (!author.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null);
}
for (User u : subscriptionService.getPostSubscribers(postId)) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null);
}
}
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null);
}
}
return comment;
}
@@ -53,6 +65,21 @@ public class CommentService {
if (!author.getId().equals(parent.getAuthor().getId())) {
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), comment, null);
}
for (User u : subscriptionService.getCommentSubscribers(parentId)) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment, null);
}
}
for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment, null);
}
}
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null);
}
}
return comment;
}

View File

@@ -10,6 +10,7 @@ import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.TagRepository;
import com.openisle.service.SubscriptionService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -25,6 +26,7 @@ public class PostService {
private final TagRepository tagRepository;
private final PublishMode publishMode;
private final NotificationService notificationService;
private final SubscriptionService subscriptionService;
@org.springframework.beans.factory.annotation.Autowired
public PostService(PostRepository postRepository,
@@ -32,12 +34,14 @@ public class PostService {
CategoryRepository categoryRepository,
TagRepository tagRepository,
NotificationService notificationService,
SubscriptionService subscriptionService,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
this.postRepository = postRepository;
this.userRepository = userRepository;
this.categoryRepository = categoryRepository;
this.tagRepository = tagRepository;
this.notificationService = notificationService;
this.subscriptionService = subscriptionService;
this.publishMode = publishMode;
}
@@ -64,7 +68,14 @@ public class PostService {
post.setCategory(category);
post.setTags(new java.util.HashSet<>(tags));
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
return postRepository.save(post);
post = postRepository.save(post);
// notify followers of author
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, null, null);
}
}
return post;
}
public Post viewPost(Long id, String viewer) {

View File

@@ -0,0 +1,102 @@
package com.openisle.service;
import com.openisle.model.*;
import com.openisle.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class SubscriptionService {
private final PostSubscriptionRepository postSubRepo;
private final CommentSubscriptionRepository commentSubRepo;
private final UserSubscriptionRepository userSubRepo;
private final UserRepository userRepo;
private final PostRepository postRepo;
private final CommentRepository commentRepo;
public void subscribePost(String username, Long postId) {
User user = userRepo.findByUsername(username).orElseThrow();
Post post = postRepo.findById(postId).orElseThrow();
postSubRepo.findByUserAndPost(user, post).orElseGet(() -> {
PostSubscription ps = new PostSubscription();
ps.setUser(user);
ps.setPost(post);
return postSubRepo.save(ps);
});
}
public void unsubscribePost(String username, Long postId) {
User user = userRepo.findByUsername(username).orElseThrow();
Post post = postRepo.findById(postId).orElseThrow();
postSubRepo.findByUserAndPost(user, post).ifPresent(postSubRepo::delete);
}
public void subscribeComment(String username, Long commentId) {
User user = userRepo.findByUsername(username).orElseThrow();
Comment comment = commentRepo.findById(commentId).orElseThrow();
commentSubRepo.findByUserAndComment(user, comment).orElseGet(() -> {
CommentSubscription cs = new CommentSubscription();
cs.setUser(user);
cs.setComment(comment);
return commentSubRepo.save(cs);
});
}
public void unsubscribeComment(String username, Long commentId) {
User user = userRepo.findByUsername(username).orElseThrow();
Comment comment = commentRepo.findById(commentId).orElseThrow();
commentSubRepo.findByUserAndComment(user, comment).ifPresent(commentSubRepo::delete);
}
public void subscribeUser(String username, String targetName) {
if (username.equals(targetName)) return;
User subscriber = userRepo.findByUsername(username).orElseThrow();
User target = userRepo.findByUsername(targetName).orElseThrow();
userSubRepo.findBySubscriberAndTarget(subscriber, target).orElseGet(() -> {
UserSubscription us = new UserSubscription();
us.setSubscriber(subscriber);
us.setTarget(target);
return userSubRepo.save(us);
});
}
public void unsubscribeUser(String username, String targetName) {
User subscriber = userRepo.findByUsername(username).orElseThrow();
User target = userRepo.findByUsername(targetName).orElseThrow();
userSubRepo.findBySubscriberAndTarget(subscriber, target).ifPresent(userSubRepo::delete);
}
public List<User> getSubscribedUsers(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return userSubRepo.findBySubscriber(user).stream().map(UserSubscription::getTarget).toList();
}
public List<User> getSubscribers(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return userSubRepo.findByTarget(user).stream().map(UserSubscription::getSubscriber).toList();
}
public List<User> getPostSubscribers(Long postId) {
Post post = postRepo.findById(postId).orElseThrow();
return postSubRepo.findByPost(post).stream().map(PostSubscription::getUser).toList();
}
public List<User> getCommentSubscribers(Long commentId) {
Comment c = commentRepo.findById(commentId).orElseThrow();
return commentSubRepo.findByComment(c).stream().map(CommentSubscription::getUser).toList();
}
public long countSubscribers(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return userSubRepo.countByTarget(user);
}
public long countSubscribed(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return userSubRepo.countBySubscriber(user);
}
}