Комментарии - oyboy/Jora GitHub Wiki

Краткое описание

Это набор текста, который оставляют пользователи к каждой задаче. К ним нельзя добавить какие-либо файлы, но можно посмотреть были ли они и кем когда-то прочитаны. Для этих целей созданы несколько моделей, каждая из которых служит определённой цели

Модели

@Data
public class CreateCommentDTO {
    private String text;
    private Long taskId;
}

Это самый простой набор данных, который необходим для создания основной сущности. Он служит для отправки данных от клиента. В комбинации с Principal создаётся основной объект, который хранит данные как о комментарии, так и о пользователе:

@Data
@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(length = 500)
    @Size(max = 500, message = "Содержание комментария не должно превышать 500 символов")
    private String text;
    @ManyToOne
    @JoinColumn(name = "task_id")
    private Task task;
    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
    private LocalDateTime createdAt;

    CommentDTO convertToDTO(){
        return new CommentDTO(
                this.text,
                this.user.getUsername(),
                this.user.getId(),
                this.createdAt,
                this.id
        );
    }
}

Далее клиенту необходимо вернуть ответ, но Comment не годится для таких целей, так как имеет в себе ссылки на другие сложные модели, которые в свою очередь также имеют ссылки. Это приводит к зацикливанию и переполнению стека. Чтобы избежать такого, создаётся специальный объект для передачи данных (dto - data transfer object):

@Data
@AllArgsConstructor
public class CommentDTO {
    private String text;
    private String username;
    private Long userId;
    private LocalDateTime createdAt;
    private Long commentId;
}

Чтобы добавить функцию прочтения, необходимо где-то хранить данные о том, кто и когда какой комментарий прочитал. Для этого созданы соответствующие модели:

@Data
@Entity
@NoArgsConstructor
public class UserCommentDTO {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long userId;
    private Long commentId;
    private Long taskId;
    private LocalDateTime readAt;
    public UserCommentDTO(Long userId, Long commentId, Long taskId){
        this.userId = userId;
        this.commentId = commentId;
        this.taskId = taskId;
    }
}
@Data
@AllArgsConstructor
public class CommentReader {
    private String username;
    private String email;
    private Long userId;
    private LocalDateTime readAt;
}

Контроллер

@MessageMapping("/projects/{project_hash}/tasks/{task_id}/comment")
    @SendTo("/topic/projects/{project_hash}/tasks/{task_id}/comment")
    public CommentDTO sendCommentWebSocket(@Payload String jsonPayload,
                                     Principal principal) { 
        User currentUser = (User) ((Authentication) principal).getPrincipal();
        ObjectMapper objectMapper = new ObjectMapper();
        CreateCommentDTO createCommentDTO = null;
        try {
            createCommentDTO = objectMapper.readValue(jsonPayload, CreateCommentDTO.class);
        } catch (JsonProcessingException e) {
            System.out.println("Problem with converting json: " + e.getMessage());
        }

        Comment savedComment = commentService.saveComment(createCommentDTO, currentUser);
        commentService.saveUnreadComments(savedComment, currentUser);

        return new CommentDTO(
                savedComment.getText(),
                savedComment.getUser().getUsername(),
                savedComment.getUser().getId(),
                savedComment.getCreatedAt(),
                savedComment.getId()
        );
    }

Это метод для отправки сообщений по сокету. Первым делом на сервер приходит json-объект, который затем конвертируется в CreateCommentDTO. Стоит обратить внимание на то, что _taskId_передаётся именно в json, а не извлекается из пути при помощи @PathVariable, т.к. это приводит к конфликту с @Payload и выбросу ряда неприятных исключений. Вероятно конфликта можно избежать, если @PathVariable будет извлекаться объект типа String, поскольку проблема возникала именно с taskId, имевшем тип Long. Также вместо @AuthenticationPrincipal нужно использовать именно Principal, т.к. первое не поддерживается в работе с WebSocket. После успешной конвертации необходимо сохранить Comment в базе, сохранить для остальных пользователей этот комментарий как непрочитанный и вернуть ответ в виде dto.

@GetMapping
    public ResponseEntity<List<CommentDTO>> getCommentsByTaskId(@PathVariable("task_id") Long task_id) {
        List<CommentDTO> comments = commentService.getCommentsByTaskId(task_id).stream()
                .map(Comment::convertToDTO).
                collect(Collectors.toList());
        return ResponseEntity.ok(comments);
    }

Метод подтягивает список комментариев из базы и конвертирует их в dto.

@PostMapping("/read")
    public ResponseEntity<UserCommentDTO> markAsRead(@RequestBody UserCommentDTO userCommentDTO){
        try{
            commentService.markAsRead(userCommentDTO);
        } catch (TransactionRequiredException ex){} //Если записи нет, то ничего не делать
        return ResponseEntity.ok().body(userCommentDTO);
    }

Пост-запросом пользователь помечает комментарий как прочтённый. Для этого на сервер поступает уже собранный объект, у которого потом в сервисе изменяется одно поле. После изменения он обратно возвращается клиенту.

@GetMapping("/unreadCount")
    public Long getUncreadCount(@AuthenticationPrincipal User user,
                                @PathVariable("task_id") Long task_id){
        return commentService.getUnreadCommentsCount(user, task_id);
    }

Возвращает количество непрочитанных сообщений для пользователя в определённой задаче. Это необходимо для того, чтобы тот видел, где у него есть непрочитанные записи.

@GetMapping("{commentId}/readers")
    public List<CommentReader> getReadersForComment(@PathVariable("commentId") Long commentId){
        return commentService.getReadersForComment(commentId);
    }

При клике на комментарий создаётся get-запрос, который возвращает список, кто и когда прочитал сообщение, в представлении CommentReader

Сервис

@Service
@Slf4j
public class CommentService {
    @Autowired
    private CommentRepository commentRepository;
    @Autowired
    UserCommentRepository userCommentRepository;
    @Autowired
    private TaskRepository taskRepository;
    @Autowired
    private UserTaskRepository userTaskRepository;
    public List<Comment> getCommentsByTaskId(Long taskId) {
        return commentRepository.findAllByTaskId(taskId);
    }

    public Comment saveComment(CreateCommentDTO createCommentDTO, User user) {
        log.info("Received commentData: {}", createCommentDTO);
        Task task = taskRepository.findById(createCommentDTO.getTaskId()).orElse(null);

        Comment comment = new Comment();
        comment.setText(createCommentDTO.getText());
        comment.setTask(task);
        comment.setUser(user);
        comment.setCreatedAt(LocalDateTime.now());

        log.info("Saving new comment: {}", comment);
        commentRepository.save(comment);
        return comment;
    }
    @Transactional
    public void markAsRead(UserCommentDTO userCommentDTO){
        log.info("Marking comment ID {} as read.", userCommentDTO.getCommentId());
        userCommentRepository.updateCommentAsRead(
                userCommentDTO.getUserId(),
                userCommentDTO.getTaskId(),
                userCommentDTO.getCommentId()
        );
    }
    public void saveUnreadComments(Comment savedComment, User currentUser){
        log.info("Saving unread comments");
        List<User> usersForTask = userTaskRepository.getUsersByTaskId(savedComment.getTask().getId());
        for (User u : usersForTask){
            if (u.equals(currentUser)) continue; //Для отправителя не нужно сохранять непрочитанное сообщение
            UserCommentDTO userCommentDTO = new UserCommentDTO(u.getId(), savedComment.getId(), savedComment.getTask().getId());
            log.info("Saving user-comment dto: {}", userCommentDTO);
            userCommentRepository.save(userCommentDTO);
        }
    }
    public Long getUnreadCommentsCount(User user, Long taskId){
        return userCommentRepository.getUnreadCommentsCount(user.getId(), taskId);
    }
    public List<CommentReader> getReadersForComment(Long commentId){
        log.info("Finding readers in {} comment", commentId);
        List<CommentReader> foundUsers = userCommentRepository.getReadersForComment(commentId);
        log.info("Found: {}", foundUsers);
        return foundUsers;
    }
    //Автоудаление прочитанных комментариев
    @Transactional
    @Scheduled(fixedRate = 172_800_000) //1 секунда = 1000 / 172_800_800 = 48 часов
    public void cleanReadComments() {
        log.info("removing read comments from db");
        userCommentRepository.deleteReadComments();
    }
}

Здесь стоит обратить внимание на два метода, помеченных как @Transactional. Это необходимо, поскольку здесь идёт прямое изменение записи в базе без загрузки из неё. Более того, это должно ускорить работу с данными, поскольку данные о прочтении приходят регулярно.

Репозиторий

public interface UserCommentRepository extends CrudRepository<UserCommentDTO, Long> {
    @Query("SELECT COUNT(*) " +
            "FROM UserCommentDTO uc " +
            "WHERE uc.userId = :userId AND uc.readAt IS NULL AND uc.taskId = :taskId")
    Long getUnreadCommentsCount(@Param("userId") Long userId, @Param("taskId") Long taskId);
    @Modifying //Прямое обращение к базе без извлечения поля
    @Query("UPDATE UserCommentDTO uc " +
            "SET uc.readAt = CURRENT_TIMESTAMP " +
            "WHERE uc.userId = :userId " +
            "AND uc.taskId = :taskId " +
            "AND uc.commentId = :commentId " +
            "AND uc.readAt IS NULL")
    void updateCommentAsRead(@Param("userId") Long userId,
                             @Param("taskId") Long taskId,
                             @Param("commentId") Long commentId);
    @Query("SELECT new com.main.Jora.comments.CommentReader(u.username, u.email, u.id, uc.readAt) " +
            "FROM User u " +
            "JOIN UserCommentDTO uc " +
            "ON uc.userId = u.id " +
            "WHERE uc.commentId = :commentId AND uc.readAt IS NOT NULL")
    List<CommentReader> getReadersForComment(@Param("commentId") Long commentId);
    @Modifying
    @Query(value = """
    WITH temp_id_table AS (
            SELECT cr1.comment_id
            FROM user_commentdto AS cr1
            WHERE cr1.read_at IS NOT NULL
            GROUP BY cr1.comment_id, cr1.task_id
            HAVING COUNT(cr1.user_id) = (
                SELECT COUNT(cr2.user_id)
                FROM user_commentdto AS cr2
                WHERE cr1.comment_id = cr2.comment_id
                GROUP BY cr2.comment_id
            )
        )
    DELETE FROM user_commentdto
    WHERE comment_id IN (SELECT * FROM temp_id_table);
""", nativeQuery = true) //nativeQuery - определяет, что запрос написан на нативном SQL, поскольку HQL вызывал ряд ошибок
    void deleteReadComments();
}

Скрипт

Поскольку для комментариев реализован RestController, необходима отдельная логика для отправки ресурсов по http-запросам в json-формате. Этот скрипт предназначен для динамического взаимодействия со страницей задач. Он обеспечивает функционал для:

  • Отображения комментариев к задачам в реальном времени, используя WebSocket.
  • Добавления новых комментариев к задачам через асинхронное взаимодействие.
  • Проверки на наличие новых комментариев для задач при загрузке страницы.
  • Показа пользователей, прочитавших комментарий

Конкретные компоненты:

Объявление переменных:

  • let stompClient = null;: Объявляет переменную для клиента STOMP (WebSocket).
  • const projectHash = $("#projectHash").val();: Сохраняет уникальный идентификатор проекта.
  • const currentUserId = Number($('#currentUserId').val());: Сохраняет ID текущего пользователя (преобразуя его в число).
  • const csrfToken = $('input[name="_csrf"]').val();: Получает токен CSRF для защиты от атак.

Проверка на непрочитанные комментарии:

  • $('.task').each(...): Перебирает все задачи и для каждой задачи вызывает функцию checkForUnreadComments(taskId), которая проверяет, есть ли непрочитанные комментарии. Если да, добавляется класс для визуального выделения.

Подписка на комментарии через WebSocket:

  • $(".showCommentsButton").on("click", function() {...});: Обработчик клика по кнопке для показа комментариев.
    • Когда кнопка нажата, создается WebSocket соединение с сервером, и клиент STOMP подключается.
    • Подписывается на получение комментариев для данной задачи через stompClient.subscribe.
    • При получении нового комментария вызывается createCommentElement для добавления комментария в интерфейс.
    • Запускается функция loadComments(taskId), чтобы загрузить существующие комментарии при открытии.

Отправка комментариев на сервер:

  • $(".addCommentButton").on("click", function() {...});: Обработчик клика для кнопки добавления комментария.
    • Проверяет введенный текст, формирует объект комментария и отправляет его на сервер через STOMP.
    • Очищает поле ввода после успешной отправки.

loadComments(taskId):

Выполняет AJAX-запрос для загрузки всех комментариев по заданной задаче. При успешном ответе добавляет все комментарии на страницу.

createCommentElement(comment, taskId):

Создает и возвращает HTML-элемент для отображения комментария. Настраивает обработчик клика для отображения пользователей, прочитавших комментарий (если пользователь является автором комментария).

getReaders(commentId, taskId):

Выполняет AJAX-запрос для получения списка пользователей, прочитавших определенный комментарий, и вызывает showReaders.

showReaders(readByUsers):

Отображает в модальном окне список пользователей, которые прочитали комментарий. Прежде чем открыть модальное окно, проверяется, есть ли прочитавшие пользователи.

markCommentAsRead(commentId, userId, taskId):

Отправляет запрос, помечая комментарий как прочитанный для данного пользователя. Проверяет статус ответа и обновляет количество непрочитанных комментариев.

checkForUnreadComments(taskId):

Выполняет AJAX-запрос для получения количества непрочитанных комментариев для указанной задачи. Обновляет стили кнопки в зависимости от наличия непрочитанных комментариев.

⚠️ **GitHub.com Fallback** ⚠️