diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/AnswerPost.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/AnswerPost.java index aaedef17af0a..9469d9e6d818 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/AnswerPost.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/AnswerPost.java @@ -10,14 +10,18 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.Transient; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.SQLRestriction; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIncludeProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; import de.tum.cit.aet.artemis.core.domain.Course; /** @@ -35,10 +39,20 @@ public class AnswerPost extends Posting { @OneToMany(mappedBy = "answerPost", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.EAGER) private Set reactions = new HashSet<>(); + /*** + * The value 1 represents an answer post, given by the enum {{@link PostingType}} + */ + @OneToMany(mappedBy = "postId", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) + @SQLRestriction("post_type = 1") + private Set savedPosts = new HashSet<>(); + @ManyToOne @JsonIncludeProperties({ "id", "exercise", "lecture", "course", "courseWideContext", "conversation", "author" }) private Post post; + @Transient + private boolean isSaved = false; + @JsonProperty("resolvesPost") public Boolean doesResolvePost() { return resolvesPost; @@ -76,6 +90,25 @@ public void setPost(Post post) { this.post = post; } + @JsonIgnore + public Set getSavedPosts() { + return savedPosts; + } + + @JsonProperty("isSaved") + public boolean getIsSaved() { + return isSaved; + } + + public void setIsSaved(boolean isSaved) { + this.isSaved = isSaved; + } + + @JsonIgnore + public Conversation getConversation() { + return getPost().getConversation(); + } + /** * Helper method to extract the course an AnswerPost belongs to, which is found in different locations based on the parent Post's context * diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java index 4ff2d48fedf5..3bb92cb6a540 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java @@ -16,14 +16,17 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; +import jakarta.persistence.Transient; import jakarta.validation.constraints.Size; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.SQLRestriction; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIncludeProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; import de.tum.cit.aet.artemis.core.domain.Course; @@ -54,6 +57,13 @@ public class Post extends Posting { @OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.EAGER) private Set answers = new HashSet<>(); + /*** + * The value 0 represents a post, given by the enum {{@link PostingType}} + */ + @OneToMany(mappedBy = "postId", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) + @SQLRestriction("post_type = 0") + private Set savedPosts = new HashSet<>(); + @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "post_tag", joinColumns = @JoinColumn(name = "post_id")) @Column(name = "text") @@ -96,6 +106,9 @@ public class Post extends Posting { @Column(name = "vote_count") private int voteCount; + @Transient + private boolean isSaved = false; + public Post() { } @@ -222,6 +235,20 @@ public void setVoteCount(Integer voteCount) { this.voteCount = voteCount != null ? voteCount : 0; } + @JsonIgnore + public Set getSavedPosts() { + return savedPosts; + } + + @JsonProperty("isSaved") + public boolean getIsSaved() { + return isSaved; + } + + public void setIsSaved(boolean isSaved) { + this.isSaved = isSaved; + } + /** * Helper method to extract the course a Post belongs to, which is found in different locations based on the Post's context * diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Posting.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Posting.java index ad60a1130916..4ae7c6fe800e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Posting.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Posting.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonIncludeProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.DomainObject; import de.tum.cit.aet.artemis.core.domain.User; @@ -118,4 +119,6 @@ public void setAuthorRole(UserRole authorRole) { @Transient public abstract Course getCoursePostingBelongsTo(); + + public abstract Conversation getConversation(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/PostingType.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/PostingType.java new file mode 100644 index 000000000000..aedad4d1b55c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/PostingType.java @@ -0,0 +1,23 @@ +package de.tum.cit.aet.artemis.communication.domain; + +import java.util.Arrays; + +public enum PostingType { + + POST((short) 0), ANSWER((short) 1); + + private final short databaseKey; + + PostingType(short databaseKey) { + this.databaseKey = databaseKey; + } + + public short getDatabaseKey() { + return databaseKey; + } + + public static PostingType fromDatabaseKey(short databaseKey) { + return Arrays.stream(PostingType.values()).filter(type -> type.getDatabaseKey() == databaseKey).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown database key: " + databaseKey)); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPost.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPost.java new file mode 100644 index 000000000000..88d1c79b96c4 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPost.java @@ -0,0 +1,83 @@ +package de.tum.cit.aet.artemis.communication.domain; + +import java.time.ZonedDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import de.tum.cit.aet.artemis.core.domain.DomainObject; +import de.tum.cit.aet.artemis.core.domain.User; + +@Entity +@Table(name = "saved_post") +public class SavedPost extends DomainObject { + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "post_id", nullable = false) + private Long postId; + + @Enumerated + @Column(name = "post_type", nullable = false) + private PostingType postType; + + @Enumerated + @Column(name = "status", nullable = false) + private SavedPostStatus status; + + @Column(name = "completed_at") + private ZonedDateTime completedAt; + + public SavedPost() { + } + + public SavedPost(User user, Long postId, PostingType postType, SavedPostStatus status, ZonedDateTime completedAt) { + this.user = user; + this.postId = postId; + this.postType = postType; + this.status = status; + this.completedAt = completedAt; + } + + public Long getPostId() { + return postId; + } + + public void setPostId(Long postId) { + this.postId = postId; + } + + public void setStatus(SavedPostStatus status) { + this.status = status; + } + + public User getUser() { + return user; + } + + public SavedPostStatus getStatus() { + return status; + } + + public void setCompletedAt(ZonedDateTime completedAt) { + this.completedAt = completedAt; + } + + public void setPostType(PostingType postType) { + this.postType = postType; + } + + public PostingType getPostType() { + return postType; + } + + public ZonedDateTime getCompletedAt() { + return completedAt; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPostStatus.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPostStatus.java new file mode 100644 index 000000000000..b2fd523277be --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPostStatus.java @@ -0,0 +1,23 @@ +package de.tum.cit.aet.artemis.communication.domain; + +import java.util.Arrays; + +public enum SavedPostStatus { + + IN_PROGRESS((short) 0), COMPLETED((short) 1), ARCHIVED((short) 2); + + private final short databaseKey; + + SavedPostStatus(short databaseKey) { + this.databaseKey = databaseKey; + } + + public short getDatabaseKey() { + return databaseKey; + } + + public static SavedPostStatus fromDatabaseKey(short databaseKey) { + return Arrays.stream(SavedPostStatus.values()).filter(type -> type.getDatabaseKey() == databaseKey).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown database key: " + databaseKey)); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/AuthorDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/AuthorDTO.java new file mode 100644 index 000000000000..8feb1dd746c1 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/AuthorDTO.java @@ -0,0 +1,13 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.User; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record AuthorDTO(Long id, String name, String imageUrl) { + + public AuthorDTO(User user) { + this(user.getId(), user.getName(), user.getImageUrl()); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingConversationDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingConversationDTO.java new file mode 100644 index 000000000000..9c93cd4d47e5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingConversationDTO.java @@ -0,0 +1,34 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import de.tum.cit.aet.artemis.communication.domain.ConversationType; +import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; +import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; +import de.tum.cit.aet.artemis.communication.domain.conversation.GroupChat; + +public record PostingConversationDTO(Long id, String title, ConversationType type) { + + public PostingConversationDTO(Conversation conversation) { + this(conversation.getId(), determineTitle(conversation), determineType(conversation)); + } + + private static String determineTitle(Conversation conversation) { + if (conversation instanceof Channel) { + return ((Channel) conversation).getName(); + } + else if (conversation instanceof GroupChat) { + return ((GroupChat) conversation).getName(); + } + else { + return "Chat"; + } + } + + private static ConversationType determineType(Conversation conversation) { + if (conversation instanceof Channel) { + return ConversationType.CHANNEL; + } + else { + return ConversationType.DIRECT; + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingDTO.java new file mode 100644 index 000000000000..a394237230c0 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingDTO.java @@ -0,0 +1,40 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import java.time.ZonedDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.communication.domain.AnswerPost; +import de.tum.cit.aet.artemis.communication.domain.Posting; +import de.tum.cit.aet.artemis.communication.domain.PostingType; +import de.tum.cit.aet.artemis.communication.domain.UserRole; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PostingDTO(Long id, AuthorDTO author, UserRole role, ZonedDateTime creationDate, ZonedDateTime updatedDate, String content, boolean isSaved, short savedPostStatus, + List reactions, PostingConversationDTO conversation, short postingType, Long referencePostId) { + + public PostingDTO(Posting post, boolean isSaved, short savedPostStatus) { + this(post.getId(), new AuthorDTO(post.getAuthor()), post.getAuthorRole(), post.getCreationDate(), post.getUpdatedDate(), post.getContent(), isSaved, savedPostStatus, + post.getReactions().stream().map(ReactionDTO::new).toList(), new PostingConversationDTO(post.getConversation()), getSavedPostType(post).getDatabaseKey(), + getReferencePostId(post)); + } + + static PostingType getSavedPostType(Posting posting) { + if (posting instanceof AnswerPost) { + return PostingType.ANSWER; + } + else { + return PostingType.POST; + } + } + + static Long getReferencePostId(Posting posting) { + if (posting instanceof AnswerPost) { + return ((AnswerPost) posting).getPost().getId(); + } + else { + return posting.getId(); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/ReactionDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/ReactionDTO.java new file mode 100644 index 000000000000..a81a00799ece --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/ReactionDTO.java @@ -0,0 +1,12 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import java.time.ZonedDateTime; + +import de.tum.cit.aet.artemis.communication.domain.Reaction; + +public record ReactionDTO(Long id, AuthorDTO user, ZonedDateTime creationDate, String emojiId) { + + public ReactionDTO(Reaction reaction) { + this(reaction.getId(), new AuthorDTO(reaction.getUser()), reaction.getCreationDate(), reaction.getEmojiId()); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java index db61138b3a73..43ac921f2d8a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java @@ -31,5 +31,12 @@ default AnswerPost findAnswerMessageByIdElseThrow(Long answerPostId) { return getValueElseThrow(findById(answerPostId).filter(answerPost -> answerPost.getPost().getConversation() != null), answerPostId); } + @NotNull + default AnswerPost findAnswerPostOrMessageByIdElseThrow(Long answerPostId) { + return getValueElseThrow(findById(answerPostId), answerPostId); + } + long countAnswerPostsByPostIdIn(List postIds); + + List findByIdIn(List idList); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java index 2952c5213432..16c5be3aedc8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java @@ -114,6 +114,7 @@ private PageImpl findPostsWithSpecification(Pageable pageable, Specificati LEFT JOIN FETCH p.conversation LEFT JOIN FETCH p.reactions LEFT JOIN FETCH p.tags + LEFT JOIN FETCH p.savedPosts LEFT JOIN FETCH p.answers a LEFT JOIN FETCH a.reactions LEFT JOIN FETCH a.post diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java index aacfbc33d179..1ea95f1d6657 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java @@ -49,4 +49,6 @@ default Post findPostOrMessagePostByIdElseThrow(Long postId) throws EntityNotFou List findAllByConversationId(Long conversationId); List findAllByCourseId(Long courseId); + + List findByIdIn(List idList); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/SavedPostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/SavedPostRepository.java new file mode 100644 index 000000000000..e0a00a5896aa --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/SavedPostRepository.java @@ -0,0 +1,143 @@ +package de.tum.cit.aet.artemis.communication.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import de.tum.cit.aet.artemis.communication.domain.PostingType; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.domain.SavedPostStatus; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; + +@Profile(PROFILE_CORE) +@Repository +@CacheConfig(cacheNames = "savedPosts") +public interface SavedPostRepository extends ArtemisJpaRepository { + + /*** + * Get the amount of saved posts of a user. E.g. for checking if maximum allowed bookmarks are reached. + * Cached by user id. + * + * @param userId to query for + * + * @return The amount of bookmarks of the user. + */ + @Cacheable(key = "'saved_post_count_' + #userId") + Long countByUserId(Long userId); + + /*** + * Get a single saved post by user id, connected post/answer post id and posting type. Not cached. + * + * @param userId of the bookmark + * @param postId of the bookmark + * @param postType of the bookmark + * + * @return The saved post if exists, null otherwise. + */ + SavedPost findSavedPostByUserIdAndPostIdAndPostType(Long userId, Long postId, PostingType postType); + + /*** + * Query all post ids that a user has saved by a certain posting type. Cached by user id and post type. + * + * @param userId of the bookmarks + * @param postType of the bookmarks + * + * @return List of ids of posts/answer posts of the given user, filtered by the given post type. + */ + @Query(""" + SELECT s.postId + FROM SavedPost s + WHERE s.user.id = :userId AND s.postType = :postType + """) + @Cacheable(key = "'saved_post_type_' + #postType.getDatabaseKey() + '_' + #userId") + List findSavedPostIdsByUserIdAndPostType(@Param("userId") Long userId, @Param("postType") PostingType postType); + + /*** + * Query all saved posts of a user by status. E.g. for displaying the saved posts. Cached by user id and status. + * + * @param userId of the bookmarks + * @param status of the bookmarks + * + * @return List of saved posts of the given user, filtered by the given status. + */ + @Cacheable(key = "'saved_post_status_' + #status.getDatabaseKey() + '_' + #userId") + List findSavedPostsByUserIdAndStatusOrderByCompletedAtDescIdDesc(Long userId, SavedPostStatus status); + + /*** + * Query all SavedPosts for a certain user. Not cached. + * + * @param userId of the bookmarks + * + * @return List of saved posts of the given user. + */ + List findSavedPostsByUserId(Long userId); + + /*** + * Query to get all SavedPosts that are completed before a certain cutoff date. E.g. for cleanup. + * + * @param cutoffDate the date from where to query the saved posts + * + * @return List of saved posts which were completed before the given date + */ + List findByCompletedAtBefore(ZonedDateTime cutoffDate); + + /*** + * Saving should clear the cached queries for a given user + * The value "saved_post_type_0" represents a post, given by the enum {{@link PostingType}} + * The value "saved_post_type_1" represents an answer post, given by the enum {{@link PostingType}} + * The value "saved_post_status_0" represents in progress, given by the enum {{@link SavedPostStatus}} + * The value "saved_post_status_1" represents in completed, given by the enum {{@link SavedPostStatus}} + * The value "saved_post_status_2" represents in archived, given by the enum {{@link SavedPostStatus}} + * + * @param savedPost to create / update + * + * @return Newly stored saved post + */ + @Caching(evict = { @CacheEvict(key = "'saved_post_type_0_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_type_1_' + #savedPost.user.id"), + @CacheEvict(key = "'saved_post_status_0_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_status_1_' + #savedPost.user.id"), + @CacheEvict(key = "'saved_post_status_2_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_count_' + #savedPost.user.id"), }) + @Override + S save(S savedPost); + + /*** + * Deleting should clear the cached queries for a given user + * The value "saved_post_type_0" represents a post, given by the enum {{@link PostingType}} + * The value "saved_post_type_1" represents an answer post, given by the enum {{@link PostingType}} + * The value "saved_post_status_0" represents in progress, given by the enum {{@link SavedPostStatus}} + * The value "saved_post_status_1" represents in completed, given by the enum {{@link SavedPostStatus}} + * The value "saved_post_status_2" represents in archived, given by the enum {{@link SavedPostStatus}} + * + * @param savedPost to delete + */ + @Caching(evict = { @CacheEvict(key = "'saved_post_type_0_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_type_1_' + #savedPost.user.id"), + @CacheEvict(key = "'saved_post_status_0_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_status_1_' + #savedPost.user.id"), + @CacheEvict(key = "'saved_post_status_2_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_count_' + #savedPost.user.id"), }) + @Override + void delete(SavedPost savedPost); + + /*** + * The value "sp.postType = 0" represents a post, given by the enum {{@link PostingType}} + * + * @return List of saved posts that do not have a post entity connected to them + */ + @Query("SELECT sp FROM SavedPost sp " + "LEFT JOIN Post p ON sp.postId = p.id " + "WHERE sp.postType = 0 AND p.id IS NULL") + List findOrphanedPostReferences(); + + /*** + * The value "sp.postType = 1" represents an answer post, given by the enum {{@link PostingType}} + * + * @return List of saved posts that do not have an answer post entity connected to them + */ + @Query("SELECT sp FROM SavedPost sp " + "LEFT JOIN AnswerPost ap ON sp.postId = ap.id " + "WHERE sp.postType = 1 AND ap.id IS NULL") + List findOrphanedAnswerReferences(); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/AnswerMessageService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/AnswerMessageService.java index fa370edc0737..f7645c202f63 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/AnswerMessageService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/AnswerMessageService.java @@ -21,6 +21,7 @@ import de.tum.cit.aet.artemis.communication.repository.ConversationMessageRepository; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; import de.tum.cit.aet.artemis.communication.repository.PostRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.communication.repository.conversation.ConversationRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ConversationService; import de.tum.cit.aet.artemis.communication.service.conversation.auth.ChannelAuthorizationService; @@ -59,10 +60,11 @@ public class AnswerMessageService extends PostingService { @SuppressWarnings("PMD.ExcessiveParameterList") public AnswerMessageService(SingleUserNotificationService singleUserNotificationService, CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, UserRepository userRepository, AnswerPostRepository answerPostRepository, ConversationMessageRepository conversationMessageRepository, - ConversationService conversationService, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, + ConversationService conversationService, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, SavedPostRepository savedPostRepository, WebsocketMessagingService websocketMessagingService, ConversationParticipantRepository conversationParticipantRepository, ChannelAuthorizationService channelAuthorizationService, PostRepository postRepository, ConversationRepository conversationRepository) { - super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); + super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository, + savedPostRepository); this.answerPostRepository = answerPostRepository; this.conversationMessageRepository = conversationMessageRepository; this.conversationService = conversationService; @@ -205,6 +207,7 @@ public void deleteAnswerMessageById(Long courseId, Long answerMessageId) { // delete answerPostRepository.deleteById(answerMessageId); + preparePostForBroadcast(updatedMessage); broadcastForPost(new PostDTO(updatedMessage, MetisCrudAction.UPDATE), course.getId(), null, null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java index a54058431b76..06f9409bddc0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java @@ -40,6 +40,7 @@ import de.tum.cit.aet.artemis.communication.dto.PostDTO; import de.tum.cit.aet.artemis.communication.repository.ConversationMessageRepository; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.communication.repository.SingleUserNotificationRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ConversationService; import de.tum.cit.aet.artemis.communication.service.conversation.auth.ChannelAuthorizationService; @@ -78,9 +79,10 @@ public class ConversationMessagingService extends PostingService { protected ConversationMessagingService(CourseRepository courseRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, ConversationMessageRepository conversationMessageRepository, AuthorizationCheckService authorizationCheckService, WebsocketMessagingService websocketMessagingService, UserRepository userRepository, ConversationService conversationService, ConversationParticipantRepository conversationParticipantRepository, - ConversationNotificationService conversationNotificationService, ChannelAuthorizationService channelAuthorizationService, + ConversationNotificationService conversationNotificationService, ChannelAuthorizationService channelAuthorizationService, SavedPostRepository savedPostRepository, GroupNotificationService groupNotificationService, SingleUserNotificationRepository singleUserNotificationRepository) { - super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); + super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository, + savedPostRepository); this.conversationService = conversationService; this.conversationMessageRepository = conversationMessageRepository; this.conversationNotificationService = conversationNotificationService; @@ -154,6 +156,7 @@ public void notifyAboutMessageCreation(CreatedConversationMessage createdConvers Set recipientSummaries; ConversationNotification notification = conversationNotificationService.createNotification(createdMessage, conversation, course, createdConversationMessage.mentionedUsers()); + preparePostForBroadcast(createdMessage); PostDTO postDTO = new PostDTO(createdMessage, MetisCrudAction.CREATE, notification); createdMessage.getConversation().hideDetails(); if (createdConversationMessage.completeConversation() instanceof Channel channel && channel.getIsCourseWide()) { @@ -284,7 +287,6 @@ private Set filterNotificationRecipients(User author, Conversation convers public Page getMessages(Pageable pageable, @Valid PostContextFilterDTO postContextFilter, User requestingUser, Long courseId) { conversationService.isMemberOrCreateForCourseWideElseThrow(postContextFilter.conversationId(), requestingUser, Optional.of(ZonedDateTime.now())); - // The following query loads posts, answerPosts and reactions to avoid too many database calls (due to eager references) Page conversationPosts = conversationMessageRepository.findMessages(postContextFilter, pageable, requestingUser.getId()); setAuthorRoleOfPostings(conversationPosts.getContent(), courseId); @@ -342,6 +344,7 @@ public Post updateMessage(Long courseId, Long postId, Post messagePost) { updatedPost.setConversation(conversation); // emit a post update via websocket + preparePostForBroadcast(updatedPost); broadcastForPost(new PostDTO(updatedPost, MetisCrudAction.UPDATE), course.getId(), null, null); return updatedPost; @@ -369,7 +372,7 @@ public void deleteMessageById(Long courseId, Long postId) { conversation = conversationService.getConversationById(conversation.getId()); conversationService.notifyAllConversationMembersAboutUpdate(conversation); - + preparePostForBroadcast(post); broadcastForPost(new PostDTO(post, MetisCrudAction.DELETE), course.getId(), null, null); } @@ -400,6 +403,8 @@ public Post changeDisplayPriority(Long courseId, Long postId, DisplayPriority di Post updatedMessage = conversationMessageRepository.save(message); message.getConversation().hideDetails(); + preparePostForBroadcast(message); + preparePostForBroadcast(updatedMessage); broadcastForPost(new PostDTO(message, MetisCrudAction.UPDATE), course.getId(), null, null); return updatedMessage; } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/PostingService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/PostingService.java index f3a01dab6ba6..786675a38986 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/PostingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/PostingService.java @@ -21,6 +21,7 @@ import de.tum.cit.aet.artemis.communication.domain.ConversationNotificationRecipientSummary; import de.tum.cit.aet.artemis.communication.domain.Post; import de.tum.cit.aet.artemis.communication.domain.Posting; +import de.tum.cit.aet.artemis.communication.domain.PostingType; import de.tum.cit.aet.artemis.communication.domain.UserRole; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; @@ -29,6 +30,7 @@ import de.tum.cit.aet.artemis.communication.dto.MetisCrudAction; import de.tum.cit.aet.artemis.communication.dto.PostDTO; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.CourseInformationSharingConfiguration; import de.tum.cit.aet.artemis.core.domain.User; @@ -53,6 +55,8 @@ public abstract class PostingService { protected final LectureRepository lectureRepository; + protected final SavedPostRepository savedPostRepository; + protected final ConversationParticipantRepository conversationParticipantRepository; protected final AuthorizationCheckService authorizationCheckService; @@ -65,7 +69,7 @@ public abstract class PostingService { protected PostingService(CourseRepository courseRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, AuthorizationCheckService authorizationCheckService, WebsocketMessagingService websocketMessagingService, - ConversationParticipantRepository conversationParticipantRepository) { + ConversationParticipantRepository conversationParticipantRepository, SavedPostRepository savedPostRepository) { this.courseRepository = courseRepository; this.userRepository = userRepository; this.exerciseRepository = exerciseRepository; @@ -73,6 +77,28 @@ protected PostingService(CourseRepository courseRepository, UserRepository userR this.authorizationCheckService = authorizationCheckService; this.websocketMessagingService = websocketMessagingService; this.conversationParticipantRepository = conversationParticipantRepository; + this.savedPostRepository = savedPostRepository; + } + + /** + * Helper method to prepare the post included in the websocket message and initiate the broadcasting + * + * @param post post that should be broadcast + */ + public void preparePostForBroadcast(Post post) { + try { + var user = userRepository.getUser(); + var savedPostIds = savedPostRepository.findSavedPostIdsByUserIdAndPostType(user.getId(), PostingType.POST); + post.setIsSaved(savedPostIds.contains(post.getId())); + var savedAnswerIds = savedPostRepository.findSavedPostIdsByUserIdAndPostType(user.getId(), PostingType.ANSWER); + post.getAnswers().forEach(answer -> answer.setIsSaved(savedAnswerIds.contains(answer.getId()))); + } + catch (Exception e) { + post.setIsSaved(false); + post.getAnswers().forEach(answer -> { + answer.setIsSaved(false); + }); + } } /** @@ -89,6 +115,7 @@ protected void preparePostAndBroadcast(AnswerPost updatedAnswerPost, Course cour // we need to remove the existing AnswerPost (based on unchanged id in updatedAnswerPost) and add the updatedAnswerPost afterwards updatedPost.removeAnswerPost(updatedAnswerPost); updatedPost.addAnswerPost(updatedAnswerPost); + preparePostForBroadcast(updatedPost); broadcastForPost(new PostDTO(updatedPost, MetisCrudAction.UPDATE, notification), course.getId(), null, null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/ReactionService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/ReactionService.java index a1b9b2b71ec8..562e30dfd48b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/ReactionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/ReactionService.java @@ -138,6 +138,7 @@ public void deleteReactionById(Long reactionId, Long courseId) { updatedPost.removeAnswerPost(updatedAnswerPost); updatedPost.addAnswerPost(updatedAnswerPost); } + plagiarismPostService.preparePostForBroadcast(updatedPost); plagiarismPostService.broadcastForPost(new PostDTO(updatedPost, MetisCrudAction.UPDATE), course.getId(), null, null); reactionRepository.deleteById(reactionId); } @@ -201,6 +202,7 @@ private Reaction createReactionForPost(Reaction reaction, Post posting, User use Post updatedPost = postRepository.save(post); updatedPost.setConversation(post.getConversation()); + plagiarismPostService.preparePostForBroadcast(post); plagiarismPostService.broadcastForPost(new PostDTO(post, MetisCrudAction.UPDATE), course.getId(), null, null); return savedReaction; } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostScheduleService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostScheduleService.java new file mode 100644 index 000000000000..25e5922031f5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostScheduleService.java @@ -0,0 +1,68 @@ +package de.tum.cit.aet.artemis.communication.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_SCHEDULING; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; + +@Service +@Profile(PROFILE_SCHEDULING) +public class SavedPostScheduleService { + + private static final int DAYS_UNTIL_ARCHIVED_ARE_DELETED = 100; + + private static final Logger log = LoggerFactory.getLogger(SavedPostScheduleService.class); + + private final SavedPostRepository savedPostRepository; + + public SavedPostScheduleService(SavedPostRepository savedPostRepository) { + this.savedPostRepository = savedPostRepository; + } + + /** + * Cleans up all archived/completed posts that are older than specified cutoff date + */ + @Scheduled(cron = "0 0 0 * * *") + public void cleanupArchivedSavedPosts() { + ZonedDateTime cutoffDate = ZonedDateTime.now().minusDays(DAYS_UNTIL_ARCHIVED_ARE_DELETED); + + List oldPosts = savedPostRepository.findByCompletedAtBefore(cutoffDate); + if (!oldPosts.isEmpty()) { + savedPostRepository.deleteAll(oldPosts); + log.info("Deleted {} archived saved posts", oldPosts.size()); + } + } + + /** + * Cleans up all saved posts where the post entity does not exist anymore + */ + @Scheduled(cron = "0 0 0 * * *") + public void cleanupOrphanedSavedPosts() { + List orphanedPosts = savedPostRepository.findOrphanedPostReferences(); + if (!orphanedPosts.isEmpty()) { + savedPostRepository.deleteAll(orphanedPosts); + log.info("Deleted {} orphaned post references", orphanedPosts.size()); + } + } + + /** + * Cleans up all saved posts where the answer post entity does not exist anymore + */ + @Scheduled(cron = "0 0 0 * * *") + public void cleanupOrphanedSavedAnswerPosts() { + List orphanedPosts = savedPostRepository.findOrphanedAnswerReferences(); + if (!orphanedPosts.isEmpty()) { + savedPostRepository.deleteAll(orphanedPosts); + log.info("Deleted {} orphaned answer post references", orphanedPosts.size()); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostService.java new file mode 100644 index 000000000000..14172c6d3d05 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostService.java @@ -0,0 +1,123 @@ +package de.tum.cit.aet.artemis.communication.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.communication.domain.Post; +import de.tum.cit.aet.artemis.communication.domain.Posting; +import de.tum.cit.aet.artemis.communication.domain.PostingType; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.domain.SavedPostStatus; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; +import de.tum.cit.aet.artemis.core.repository.UserRepository; + +@Profile(PROFILE_CORE) +@Service +public class SavedPostService { + + private static final int MAX_SAVED_POSTS_PER_USER = 100; + + private final SavedPostRepository savedPostRepository; + + private final UserRepository userRepository; + + public SavedPostService(SavedPostRepository savedPostRepository, UserRepository userRepository) { + this.savedPostRepository = savedPostRepository; + this.userRepository = userRepository; + } + + /** + * Saves a post for the currently logged-in user, if post is already saved it returns + * + * @param post post to save + */ + public void savePostForCurrentUser(Posting post) { + var existingSavedPost = this.getSavedPostForCurrentUser(post); + + if (existingSavedPost != null) { + return; + } + + PostingType type = post instanceof Post ? PostingType.POST : PostingType.ANSWER; + var author = userRepository.getUser(); + var savedPost = new SavedPost(author, post.getId(), type, SavedPostStatus.IN_PROGRESS, null); + savedPostRepository.save(savedPost); + } + + /** + * Removes a bookmark of a post for the currently logged-in user, if post is not saved it returns + * + * @param post post to remove from bookmarks + * @return false if the saved post was not found, true if post was found and deleted + */ + public boolean removeSavedPostForCurrentUser(Posting post) { + var existingSavedPost = this.getSavedPostForCurrentUser(post); + + if (existingSavedPost == null) { + return false; + } + + savedPostRepository.delete(existingSavedPost); + + return true; + } + + /** + * Updates the status of a bookmark, will return if no bookmark is present + * + * @param post post to change status + * @param status status to change towards + */ + public void updateStatusOfSavedPostForCurrentUser(Posting post, SavedPostStatus status) { + var existingSavedPost = this.getSavedPostForCurrentUser(post); + + if (existingSavedPost == null) { + return; + } + + existingSavedPost.setStatus(status); + existingSavedPost.setCompletedAt(status == SavedPostStatus.IN_PROGRESS ? null : ZonedDateTime.now()); + savedPostRepository.save(existingSavedPost); + } + + /** + * Retrieve the saved posts for a given status + * + * @param status status to query + * @return a list of all saved posts of the current user with the given status + */ + public List getSavedPostsForCurrentUserByStatus(SavedPostStatus status) { + var currentUser = userRepository.getUser(); + + return savedPostRepository.findSavedPostsByUserIdAndStatusOrderByCompletedAtDescIdDesc(currentUser.getId(), status); + } + + /** + * Checks if maximum amount of saved posts limit is reached + * + * @return true if max saved post it reached, false otherwise + */ + public boolean isMaximumSavedPostsReached() { + var currentUser = userRepository.getUser(); + + return MAX_SAVED_POSTS_PER_USER <= savedPostRepository.countByUserId(currentUser.getId()); + } + + /** + * Helper method to retrieve a bookmark for the current user + * + * @param post post to search bookmark for + * @return The saved post for the given posting if present + */ + private SavedPost getSavedPostForCurrentUser(Posting post) { + PostingType type = post instanceof Post ? PostingType.POST : PostingType.ANSWER; + var author = userRepository.getUser(); + + return savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType(author.getId(), post.getId(), type); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java index bfa04d53cc5f..75c68fbec7a1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java @@ -132,6 +132,8 @@ else if (postContextFilter.courseWideChannelIds() != null) { if (post.getConversation() != null) { post.getConversation().hideDetails(); } + + conversationMessagingService.preparePostForBroadcast(post); }); final var headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), coursePosts); logDuration(coursePosts.getContent(), principal, timeNanoStart); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/SavedPostResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/SavedPostResource.java new file mode 100644 index 000000000000..a7c6ba9c7faa --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/SavedPostResource.java @@ -0,0 +1,218 @@ +package de.tum.cit.aet.artemis.communication.web; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import de.tum.cit.aet.artemis.communication.domain.AnswerPost; +import de.tum.cit.aet.artemis.communication.domain.Post; +import de.tum.cit.aet.artemis.communication.domain.Posting; +import de.tum.cit.aet.artemis.communication.domain.PostingType; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.domain.SavedPostStatus; +import de.tum.cit.aet.artemis.communication.dto.PostingDTO; +import de.tum.cit.aet.artemis.communication.repository.AnswerPostRepository; +import de.tum.cit.aet.artemis.communication.repository.PostRepository; +import de.tum.cit.aet.artemis.communication.service.SavedPostService; +import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; +import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; +import de.tum.cit.aet.artemis.core.util.TimeLogUtil; + +/** + * REST controller for managing Message Posts. + */ +@Profile(PROFILE_CORE) +@RestController +@RequestMapping("api/") +public class SavedPostResource { + + private static final Logger log = LoggerFactory.getLogger(SavedPostResource.class); + + public static final String ENTITY_NAME = "savedPost"; + + private final SavedPostService savedPostService; + + private final PostRepository postRepository; + + private final AnswerPostRepository answerPostRepository; + + public SavedPostResource(SavedPostService savedPostService, PostRepository postRepository, AnswerPostRepository answerPostRepository) { + this.savedPostService = savedPostService; + this.postRepository = postRepository; + this.answerPostRepository = answerPostRepository; + } + + /** + * GET /saved-posts/{courseId}/{status} : Get saved posts of course with specific status + * + * @param courseId id of course to filter posts + * @param status saved post status (progress, completed, archived) + * @return ResponseEntity with status 200 (Success) if course id and status are ok, + * or with status 400 (Bad Request) if the checks on type or post validity fail + */ + @GetMapping("saved-posts/{courseId}/{status}") + @EnforceAtLeastStudent + public ResponseEntity> getSavedPosts(@PathVariable Long courseId, @PathVariable short status) { + log.debug("GET getSavedPosts invoked for course {} and status {}", courseId, status); + long start = System.nanoTime(); + + SavedPostStatus savedPostStatus; + try { + savedPostStatus = SavedPostStatus.fromDatabaseKey(status); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("The provided post status could not be found.", ENTITY_NAME, "savedPostStatusDoesNotExist"); + } + + var savedPosts = savedPostService.getSavedPostsForCurrentUserByStatus(savedPostStatus); + + List posts = postRepository.findByIdIn(savedPosts.stream().filter(savedPost -> savedPost.getPostType() == PostingType.POST).map(SavedPost::getPostId).toList()) + .stream().filter(post -> Objects.equals(post.getCoursePostingBelongsTo().getId(), courseId)).toList(); + List answerPosts = answerPostRepository + .findByIdIn(savedPosts.stream().filter(savedPost -> savedPost.getPostType() == PostingType.ANSWER).map(SavedPost::getPostId).toList()).stream() + .filter(post -> Objects.equals(post.getCoursePostingBelongsTo().getId(), courseId)).toList(); + List postingList = new ArrayList<>(); + + for (SavedPost savedPost : savedPosts) { + Optional posting; + if (savedPost.getPostType() == PostingType.ANSWER) { + posting = answerPosts.stream().filter(answerPost -> answerPost.getId().equals(savedPost.getPostId())).findFirst(); + } + else { + posting = posts.stream().filter(post -> post.getId().equals(savedPost.getPostId())).findFirst(); + } + if (posting.isPresent()) { + postingList.add(new PostingDTO((Posting) posting.get(), true, savedPost.getStatus().getDatabaseKey())); + } + } + + log.info("getSavedPosts took {}", TimeLogUtil.formatDurationFrom(start)); + return new ResponseEntity<>(postingList, null, HttpStatus.OK); + } + + /** + * POST /saved-posts/{postId}/{type} : Create a new saved post + * + * @param postId post to save + * @param type post type (post, answer) + * @return ResponseEntity with status 201 (Created) if successfully saved post, + * or with status 400 (Bad Request) if the checks on type or post validity fail + */ + @PostMapping("saved-posts/{postId}/{type}") + @EnforceAtLeastStudent + public ResponseEntity savePost(@PathVariable Long postId, @PathVariable short type) { + log.debug("POST savePost invoked for post {}", postId); + long start = System.nanoTime(); + + if (savedPostService.isMaximumSavedPostsReached()) { + throw new BadRequestAlertException("The maximum amount of saved posts was reached.", ENTITY_NAME, "savedPostMaxReached"); + } + + var post = retrievePostingElseThrow(postId, type); + + this.savedPostService.savePostForCurrentUser(post); + + log.info("savePost took {}", TimeLogUtil.formatDurationFrom(start)); + return new ResponseEntity<>(null, null, HttpStatus.CREATED); + } + + /** + * DELETE /saved-posts/{postId}/{type} : Remove a saved post + * + * @param postId post to save + * @param type post type (post, answer) + * @return ResponseEntity with status 204 (No content) if successfully deleted post, + * or with status 400 (Bad Request) if the checks on type or post validity fail + */ + @DeleteMapping("saved-posts/{postId}/{type}") + @EnforceAtLeastStudent + public ResponseEntity deleteSavedPost(@PathVariable Long postId, @PathVariable short type) { + log.debug("DELETE deletePost invoked for post {}", postId); + long start = System.nanoTime(); + + var post = retrievePostingElseThrow(postId, type); + + if (!this.savedPostService.removeSavedPostForCurrentUser(post)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You are not allowed to delete this bookmark."); + } + + log.info("deletePost took {}", TimeLogUtil.formatDurationFrom(start)); + return new ResponseEntity<>(null, null, HttpStatus.NO_CONTENT); + } + + /** + * PUT /saved-posts/{postId}/{type} : Update the status of a saved post + * + * @param postId post to save + * @param type post type (post, answer) + * @param status saved post status (progress, answer) + * @return ResponseEntity with status 200 (Success) if successfully updated saved post status, + * or with status 400 (Bad Request) if the checks on type or post validity fail + */ + @PutMapping("saved-posts/{postId}/{type}") + @EnforceAtLeastStudent + public ResponseEntity putSavedPost(@PathVariable Long postId, @PathVariable short type, @RequestParam(name = "status") short status) { + log.debug("DELETE putSavedPost invoked for post {}", postId); + long start = System.nanoTime(); + + var post = retrievePostingElseThrow(postId, type); + + SavedPostStatus savedPostStatus; + try { + savedPostStatus = SavedPostStatus.fromDatabaseKey(status); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("The provided post status could not be found.", ENTITY_NAME, "savedPostStatusDoesNotExist"); + } + + this.savedPostService.updateStatusOfSavedPostForCurrentUser(post, savedPostStatus); + + log.info("putSavedPost took {}", TimeLogUtil.formatDurationFrom(start)); + return new ResponseEntity<>(null, null, HttpStatus.OK); + } + + private Posting retrievePostingElseThrow(long postId, short type) throws BadRequestAlertException { + PostingType postingType; + + try { + postingType = PostingType.fromDatabaseKey(type); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("The provided post type could not be found.", ENTITY_NAME, "savedPostTypeDoesNotExist"); + } + + Posting post; + try { + if (postingType == PostingType.POST) { + post = postRepository.findPostOrMessagePostByIdElseThrow(postId); + } + else { + post = answerPostRepository.findAnswerPostOrMessageByIdElseThrow(postId); + } + } + catch (EntityNotFoundException e) { + throw new BadRequestAlertException("The provided post could not be found.", ENTITY_NAME, "savedPostIdDoesNotExist"); + } + + return post; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java index 6498340f3bc2..2ef2478cf295 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java @@ -40,6 +40,7 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyProgress; import de.tum.cit.aet.artemis.atlas.domain.competency.LearningPath; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; import de.tum.cit.aet.artemis.communication.domain.push_notification.PushNotificationDeviceConfiguration; import de.tum.cit.aet.artemis.core.config.Constants; import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; @@ -180,6 +181,9 @@ public class User extends AbstractAuditingEntity implements Participant { @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) private Set guidedTourSettings = new HashSet<>(); + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true) + private Set savedPosts = new HashSet<>(); + @ManyToMany @JoinTable(name = "jhi_user_authority", joinColumns = { @JoinColumn(name = "user_id", referencedColumnName = "id") }, inverseJoinColumns = { @JoinColumn(name = "authority_name", referencedColumnName = "name") }) diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java index d06ecfec87af..31937ac6a5b6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java @@ -39,6 +39,8 @@ import org.springframework.util.StringUtils; import de.tum.cit.aet.artemis.atlas.repository.ScienceEventRepository; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.core.domain.Authority; import de.tum.cit.aet.artemis.core.domain.GuidedTourSetting; import de.tum.cit.aet.artemis.core.domain.User; @@ -113,11 +115,13 @@ public class UserService { private final ParticipationVcsAccessTokenService participationVCSAccessTokenService; + private final SavedPostRepository savedPostRepository; + public UserService(UserCreationService userCreationService, UserRepository userRepository, AuthorityService authorityService, AuthorityRepository authorityRepository, CacheManager cacheManager, Optional ldapUserService, GuidedTourSettingsRepository guidedTourSettingsRepository, PasswordService passwordService, Optional optionalVcsUserManagementService, Optional optionalCIUserManagementService, InstanceMessageSendService instanceMessageSendService, FileService fileService, ScienceEventRepository scienceEventRepository, - ParticipationVcsAccessTokenService participationVCSAccessTokenService) { + ParticipationVcsAccessTokenService participationVCSAccessTokenService, SavedPostRepository savedPostRepository) { this.userCreationService = userCreationService; this.userRepository = userRepository; this.authorityService = authorityService; @@ -132,6 +136,7 @@ public UserService(UserCreationService userCreationService, UserRepository userR this.fileService = fileService; this.scienceEventRepository = scienceEventRepository; this.participationVCSAccessTokenService = participationVCSAccessTokenService; + this.savedPostRepository = savedPostRepository; } /** @@ -493,6 +498,12 @@ protected void anonymizeUser(User user) { user.setActivated(false); user.setGroups(Collections.emptySet()); + List savedPostsOfUser = savedPostRepository.findSavedPostsByUserId(user.getId()); + + if (!savedPostsOfUser.isEmpty()) { + savedPostRepository.deleteAll(savedPostsOfUser); + } + userRepository.save(user); clearUserCaches(user); userRepository.flush(); diff --git a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismAnswerPostService.java b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismAnswerPostService.java index 0c3ff1beb754..ec2f97befb23 100644 --- a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismAnswerPostService.java +++ b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismAnswerPostService.java @@ -15,6 +15,7 @@ import de.tum.cit.aet.artemis.communication.repository.AnswerPostRepository; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; import de.tum.cit.aet.artemis.communication.repository.PostRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.communication.service.PostingService; import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -40,8 +41,9 @@ public class PlagiarismAnswerPostService extends PostingService { protected PlagiarismAnswerPostService(CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, UserRepository userRepository, AnswerPostRepository answerPostRepository, PostRepository postRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, - WebsocketMessagingService websocketMessagingService, ConversationParticipantRepository conversationParticipantRepository) { - super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); + WebsocketMessagingService websocketMessagingService, ConversationParticipantRepository conversationParticipantRepository, SavedPostRepository savedPostRepository) { + super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository, + savedPostRepository); this.answerPostRepository = answerPostRepository; this.postRepository = postRepository; } @@ -164,7 +166,7 @@ public void deleteAnswerPostById(Long courseId, Long answerPostId) { // delete answerPostRepository.deleteById(answerPostId); - + preparePostForBroadcast(post); broadcastForPost(new PostDTO(post, MetisCrudAction.UPDATE), course.getId(), null, null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismPostService.java b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismPostService.java index fc5bda5882c3..a1034b96e335 100644 --- a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismPostService.java +++ b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismPostService.java @@ -16,6 +16,7 @@ import de.tum.cit.aet.artemis.communication.dto.PostDTO; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; import de.tum.cit.aet.artemis.communication.repository.PostRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.communication.service.PostingService; import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -42,9 +43,11 @@ public class PlagiarismPostService extends PostingService { private final PlagiarismCaseService plagiarismCaseService; protected PlagiarismPostService(CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, UserRepository userRepository, - PostRepository postRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, WebsocketMessagingService websocketMessagingService, - PlagiarismCaseService plagiarismCaseService, PlagiarismCaseRepository plagiarismCaseRepository, ConversationParticipantRepository conversationParticipantRepository) { - super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); + SavedPostRepository savedPostRepository, PostRepository postRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, + WebsocketMessagingService websocketMessagingService, PlagiarismCaseService plagiarismCaseService, PlagiarismCaseRepository plagiarismCaseRepository, + ConversationParticipantRepository conversationParticipantRepository) { + super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository, + savedPostRepository); this.postRepository = postRepository; this.plagiarismCaseRepository = plagiarismCaseRepository; this.plagiarismCaseService = plagiarismCaseService; @@ -132,6 +135,7 @@ public Post updatePost(Long courseId, Long postId, Post post) { Post updatedPost = postRepository.save(existingPost); + preparePostForBroadcast(updatedPost); broadcastForPost(new PostDTO(updatedPost, MetisCrudAction.UPDATE), course.getId(), null, null); return updatedPost; } @@ -184,6 +188,7 @@ public void deletePostById(Long courseId, Long postId) { // delete postRepository.deleteById(postId); + preparePostForBroadcast(post); broadcastForPost(new PostDTO(post, MetisCrudAction.DELETE), course.getId(), null, null); } diff --git a/src/main/resources/config/liquibase/changelog/20241101121000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241101121000_changelog.xml new file mode 100644 index 000000000000..b8e12b77c118 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241101121000_changelog.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 6a06b398b783..e29f09657055 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -33,6 +33,7 @@ + diff --git a/src/main/webapp/app/core/user/account.model.ts b/src/main/webapp/app/core/user/account.model.ts index ad1d36a2a18b..37f98ded0405 100644 --- a/src/main/webapp/app/core/user/account.model.ts +++ b/src/main/webapp/app/core/user/account.model.ts @@ -11,7 +11,7 @@ export class Account { public lastName?: string; public langKey?: string; public imageUrl?: string; - public guidedTourSettings: GuidedTourSetting[]; + public guidedTourSettings?: GuidedTourSetting[]; constructor( activated?: boolean, diff --git a/src/main/webapp/app/entities/metis/conversation/conversation.model.ts b/src/main/webapp/app/entities/metis/conversation/conversation.model.ts index ddb146f12d1f..841a8a5f53de 100644 --- a/src/main/webapp/app/entities/metis/conversation/conversation.model.ts +++ b/src/main/webapp/app/entities/metis/conversation/conversation.model.ts @@ -23,6 +23,7 @@ export abstract class Conversation implements BaseEntity { public creator?: User; public creationDate?: dayjs.Dayjs; public lastMessageDate?: dayjs.Dayjs; + public title?: string; protected constructor(type: ConversationType) { this.type = type; diff --git a/src/main/webapp/app/entities/metis/post.model.ts b/src/main/webapp/app/entities/metis/post.model.ts index 60adfe3c64c0..39950a74af0b 100644 --- a/src/main/webapp/app/entities/metis/post.model.ts +++ b/src/main/webapp/app/entities/metis/post.model.ts @@ -2,7 +2,6 @@ import { AnswerPost } from 'app/entities/metis/answer-post.model'; import { Posting } from 'app/entities/metis/posting.model'; import { DisplayPriority } from 'app/shared/metis/metis.util'; import { PlagiarismCase } from 'app/exercises/shared/plagiarism/types/PlagiarismCase'; -import { Conversation } from 'app/entities/metis/conversation/conversation.model'; export class Post extends Posting { public title?: string; @@ -10,7 +9,6 @@ export class Post extends Posting { public answers?: AnswerPost[]; public tags?: string[]; public plagiarismCase?: PlagiarismCase; - public conversation?: Conversation; public displayPriority?: DisplayPriority; public resolved?: boolean; public isConsecutive?: boolean = false; diff --git a/src/main/webapp/app/entities/metis/posting.model.ts b/src/main/webapp/app/entities/metis/posting.model.ts index fc7d5d206095..dff35c800654 100644 --- a/src/main/webapp/app/entities/metis/posting.model.ts +++ b/src/main/webapp/app/entities/metis/posting.model.ts @@ -3,13 +3,58 @@ import { User } from 'app/core/user/user.model'; import dayjs from 'dayjs/esm'; import { Reaction } from 'app/entities/metis/reaction.model'; import { UserRole } from 'app/shared/metis/metis.util'; +import { Conversation } from 'app/entities/metis/conversation/conversation.model'; + +export enum SavedPostStatus { + PROGRESS = 0, + COMPLETED = 1, + ARCHIVED = 2, +} + +export enum SavedPostStatusMap { + PROGRESS = 'progress', + COMPLETED = 'completed', + ARCHIVED = 'archived', +} + +export enum PostingType { + POST = 0, + ANSWER = 1, +} export abstract class Posting implements BaseEntity { public id?: number; + public referencePostId?: number; public author?: User; public authorRole?: UserRole; public creationDate?: dayjs.Dayjs; public updatedDate?: dayjs.Dayjs; public content?: string; + public isSaved?: boolean; + public savedPostStatus?: number; + public postingType?: number; public reactions?: Reaction[]; + public conversation?: Conversation; + + public static mapToStatus(map: SavedPostStatusMap) { + switch (map) { + case SavedPostStatusMap.COMPLETED: + return SavedPostStatus.COMPLETED; + case SavedPostStatusMap.ARCHIVED: + return SavedPostStatus.ARCHIVED; + default: + return SavedPostStatus.PROGRESS; + } + } + + public static statusToMap(status: SavedPostStatus) { + switch (status) { + case SavedPostStatus.COMPLETED: + return SavedPostStatusMap.COMPLETED; + case SavedPostStatus.ARCHIVED: + return SavedPostStatusMap.ARCHIVED; + default: + return SavedPostStatusMap.PROGRESS; + } + } } diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html index 1e7b44fa6fb7..6b2bac7d8ada 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html @@ -38,7 +38,7 @@ [sidebarItemAlwaysShow]="DEFAULT_SHOW_ALWAYS" [collapseState]="DEFAULT_COLLAPSE_STATE" [inCommunication]="true" - [reEmitNonDistinctSidebarEvents]="isMobile" + [reEmitNonDistinctSidebarEvents]="true" /> @if (course && !activeConversation && isCodeOfConductPresented) { @@ -57,9 +57,15 @@ (openThread)="postInThread = $event" [course]="course" [searchbarCollapsed]="channelSearchCollapsed" + [focusPostId]="focusPostId" + [openThreadOnFocus]="openThreadOnFocus" /> } @else { - + @if (selectedSavedPostStatus === null) { + + } @else { + + } }
diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts index 1b194b218bd3..f65878c794e1 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, EventEmitter, HostListener, OnDestroy, OnInit, ViewChild, ViewEncapsulation, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, OnDestroy, OnInit, ViewChild, ViewEncapsulation, inject } from '@angular/core'; import { ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; import { Post } from 'app/entities/metis/post.model'; import { ActivatedRoute, Router } from '@angular/router'; @@ -10,7 +10,21 @@ import { ChannelDTO, ChannelSubType, getAsChannelDTO } from 'app/entities/metis/ import { MetisService } from 'app/shared/metis/metis.service'; import { Course, isMessagingEnabled } from 'app/entities/course.model'; import { PageType, SortDirection } from 'app/shared/metis/metis.util'; -import { faBan, faComment, faComments, faFile, faFilter, faGraduationCap, faHeart, faList, faMessage, faPlus, faSearch, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { + faBan, + faBookmark, + faComment, + faComments, + faFile, + faFilter, + faGraduationCap, + faHeart, + faList, + faMessage, + faPlus, + faSearch, + faTimes, +} from '@fortawesome/free-solid-svg-icons'; import { ButtonType } from 'app/shared/components/button.component'; import { CourseWideSearchComponent, CourseWideSearchConfig } from 'app/overview/course-conversations/course-wide-search/course-wide-search.component'; import { AccordionGroups, ChannelAccordionShowAdd, ChannelTypeIcons, CollapseState, SidebarCardElement, SidebarData, SidebarItemShowAlways } from 'app/types/sidebar'; @@ -25,6 +39,7 @@ import { ChannelsCreateDialogComponent } from 'app/overview/course-conversations import { CourseSidebarService } from 'app/overview/course-sidebar.service'; import { LayoutService } from 'app/shared/breakpoints/layout.service'; import { CustomBreakpointNames } from 'app/shared/breakpoints/breakpoints.service'; +import { Posting, PostingType, SavedPostStatus, SavedPostStatusMap } from 'app/entities/metis/posting.model'; const DEFAULT_CHANNEL_GROUPS: AccordionGroups = { favoriteChannels: { entityData: [] }, @@ -33,6 +48,7 @@ const DEFAULT_CHANNEL_GROUPS: AccordionGroups = { lectureChannels: { entityData: [] }, examChannels: { entityData: [] }, hiddenChannels: { entityData: [] }, + savedPosts: { entityData: [] }, }; const CHANNEL_TYPE_SHOW_ADD_OPTION: ChannelAccordionShowAdd = { @@ -44,6 +60,7 @@ const CHANNEL_TYPE_SHOW_ADD_OPTION: ChannelAccordionShowAdd = { favoriteChannels: false, lectureChannels: true, hiddenChannels: false, + savedPosts: false, }; const CHANNEL_TYPE_ICON: ChannelTypeIcons = { @@ -55,6 +72,7 @@ const CHANNEL_TYPE_ICON: ChannelTypeIcons = { favoriteChannels: faHeart, lectureChannels: faFile, hiddenChannels: faBan, + savedPosts: faBookmark, }; const DEFAULT_COLLAPSE_STATE: CollapseState = { @@ -66,6 +84,7 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = { favoriteChannels: false, lectureChannels: true, hiddenChannels: true, + savedPosts: true, }; const DEFAULT_SHOW_ALWAYS: SidebarItemShowAlways = { @@ -77,6 +96,7 @@ const DEFAULT_SHOW_ALWAYS: SidebarItemShowAlways = { favoriteChannels: true, lectureChannels: false, hiddenChannels: false, + savedPosts: true, }; @Component({ @@ -110,6 +130,9 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { isProduction = true; isTestServer = false; isMobile = false; + focusPostId: number | undefined = undefined; + openThreadOnFocus = false; + selectedSavedPostStatus: null | SavedPostStatus = null; readonly CHANNEL_TYPE_SHOW_ADD_OPTION = CHANNEL_TYPE_SHOW_ADD_OPTION; readonly CHANNEL_TYPE_ICON = CHANNEL_TYPE_ICON; @@ -140,6 +163,7 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { private courseSidebarService: CourseSidebarService = inject(CourseSidebarService); private layoutService: LayoutService = inject(LayoutService); + private changeDetector: ChangeDetectorRef = inject(ChangeDetectorRef); constructor( private router: Router, @@ -251,9 +275,23 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { subscribeToQueryParameter() { this.activatedRoute.queryParams.pipe(take(1), takeUntil(this.ngUnsubscribe)).subscribe((queryParams) => { if (queryParams.conversationId) { - this.metisConversationService.setActiveConversation(Number(queryParams.conversationId)); - - this.closeSidebarOnMobile(); + if ( + isNaN(Number(queryParams.conversationId)) && + Object.values(SavedPostStatusMap) + .map((s) => s.toString()) + .includes(queryParams.conversationId) + ) { + this.selectedSavedPostStatus = Posting.mapToStatus(queryParams.conversationId as SavedPostStatusMap); + } else { + this.metisConversationService.setActiveConversation(Number(queryParams.conversationId)); + this.closeSidebarOnMobile(); + } + } + if (queryParams.focusPostId) { + this.focusPostId = Number(queryParams.focusPostId); + } + if (queryParams.openThreadOnFocus) { + this.openThreadOnFocus = queryParams.openThreadOnFocus; } if (queryParams.messageId) { this.postInThread = { id: Number(queryParams.messageId) } as Post; @@ -265,11 +303,22 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { }); } + onNavigateToPost(post: Posting) { + if (post.referencePostId === undefined || post.conversation?.id === undefined) { + return; + } + + this.focusPostId = post.referencePostId; + this.openThreadOnFocus = (post.postingType as PostingType) === PostingType.ANSWER; + this.metisConversationService.setActiveConversation(post.conversation!.id!); + this.changeDetector.detectChanges(); + } + updateQueryParameters() { this.router.navigate([], { relativeTo: this.activatedRoute, queryParams: { - conversationId: this.activeConversation?.id, + conversationId: this.activeConversation?.id ?? (this.selectedSavedPostStatus !== null ? Posting.statusToMap(this.selectedSavedPostStatus) : undefined), }, replaceUrl: true, }); @@ -348,6 +397,7 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { this.courseSidebarService.openSidebar(); } } + this.selectedSavedPostStatus = null; this.metisConversationService.setActiveConversation(undefined); this.activeConversation = undefined; this.updateQueryParameters(); @@ -377,9 +427,29 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { }; } - onConversationSelected(conversationId: number) { + onConversationSelected(conversationId: number | string) { this.closeSidebarOnMobile(); - this.metisConversationService.setActiveConversation(conversationId); + this.focusPostId = undefined; + this.openThreadOnFocus = false; + if (typeof conversationId === 'string') { + if ( + Object.values(SavedPostStatusMap) + .map((s) => s.toString()) + .includes(conversationId) + ) { + this.selectedSavedPostStatus = Posting.mapToStatus(conversationId as SavedPostStatusMap); + this.postInThread = undefined; + this.metisConversationService.setActiveConversation(undefined); + this.activeConversation = undefined; + this.updateQueryParameters(); + this.metisService.resetCachedPosts(); + this.changeDetector.detectChanges(); + } + } else { + conversationId = +conversationId; + this.selectedSavedPostStatus = null; + this.metisConversationService.setActiveConversation(conversationId); + } } toggleSidebar() { diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts index 0646118f577f..10d2cea22583 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts @@ -31,6 +31,8 @@ import { CourseConversationsCodeOfConductComponent } from 'app/overview/course-c import { CourseWideSearchComponent } from 'app/overview/course-conversations/course-wide-search/course-wide-search.component'; import { ArtemisSidebarModule } from 'app/shared/sidebar/sidebar.module'; import { ProfilePictureComponent } from 'app/shared/profile-picture/profile-picture.component'; +import { SavedPostsComponent } from 'app/overview/course-conversations/saved-posts/saved-posts.component'; +import { PostingSummaryComponent } from 'app/overview/course-conversations/posting-summary/posting-summary.component'; const routes: Routes = [ { @@ -79,6 +81,8 @@ const routes: Routes = [ OneToOneChatCreateDialogComponent, GroupChatCreateDialogComponent, CourseWideSearchComponent, + SavedPostsComponent, + PostingSummaryComponent, ], }) export class CourseConversationsModule {} diff --git a/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html b/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html index 1295be3edf63..c9641f8aa287 100644 --- a/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html @@ -72,7 +72,7 @@

}
-