Уведомления - oyboy/Jora GitHub Wiki

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

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

Модель

Само уведомление, которое передаётся по http-запросам:

@Entity
@Data
public class Notification {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String message;
}

А это уже связь уведомления с пользователем. Здесь хранится информация о том, кем оно прочитано:

@Entity
@Data
public class UserNotification {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @ToString.Exclude
    @EqualsAndHashCode.Exclude
    @JsonBackReference
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne
    @JoinColumn(name = "notification_id")
    private Notification notification;

    private boolean is_read;
}

Контроллер

Работа с запросами сама по себе предполагает создание уже не Controller, а RestContoller. Как можно заметить, в контроллерах не передаются атрибуты в модель, такую задачу на себя берёт отдельный скрипт notification_script.js.

@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
    @Autowired
    NotificationService notificationService;
    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;
    @GetMapping("/unread/{userId}")
    public ResponseEntity<List<Notification>> getUnreadNotifications(@PathVariable Long userId) {
        List<Notification> notifications = notificationService.getUnreadNotificationsForUser(userId);
        return ResponseEntity.ok(notifications);
    }
    @PostMapping("/read/{id}")
    public ResponseEntity<Void> markAsRead(@PathVariable("id") Long id,
                                           @AuthenticationPrincipal User user){
        Notification notification = notificationService.getNotificationById(id);
        /*if (notification == null || !Objects.equals(notification.getUser().getId(), user.getId())) {
            return ResponseEntity.notFound().build();
        }*/
        if (notification == null ) return ResponseEntity.notFound().build();
        notificationService.markNotificationAsRead(notification, user);
        simpMessagingTemplate.convertAndSendToUser(user.getId().toString(), "/topic/notifications", notification);
        return ResponseEntity.ok().build();
    }
}

Сервис

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

@Service
@Slf4j
public class NotificationService {
    @Autowired
    private NotificationRepository notificationRepository;
    @Autowired
    private UserNotificationRepository userNotificationRepository;
    @Autowired
    ProjectRepository projectRepository;
    @Autowired
    UserProjectRoleReposirory userProjectRoleReposirory;
    @Autowired
    private SimpMessagingTemplate messagingTemplate;
    public void sendNotificationToAll(String project_hash, String title, String message){
        Long project_id = projectRepository.findIdByHash(project_hash);
        Project project = projectRepository.findById(project_id).orElse(null);
        List<User> users = userProjectRoleReposirory.findUsersByProjectId(project_id);

        Notification notification = new Notification();
        notification.setTitle(title);
        notification.setMessage(message);
        project.getNotifications().add(notification);

        log.info("Saving new notification {}", notification);
        notificationRepository.save(notification);

        log.info("Trying to send all notification {}", users);
        for (User user : users){
            UserNotification userNotification = new UserNotification();
            userNotification.setUser(user);
            userNotification.setNotification(notification);
            userNotificationRepository.save(userNotification);
            messagingTemplate.convertAndSendToUser(user.getId().toString(), "/topic/notifications", notification);
        }
    }

    public void sendNotificationTo(User user, String title, String message) throws
            CustomException.ObjectExistsException{
        if (userNotificationRepository.existsByUserIdAndMessage(user.getId(), message))
            throw new CustomException.ObjectExistsException("Запрос уже существует");

        Notification notification = new Notification();
        notification.setTitle(title);
        notification.setMessage(message);

        UserNotification userNotification = new UserNotification();
        userNotification.setUser(user);
        userNotification.setNotification(notification);

        log.info("Saving new notification {}", notification);
        notificationRepository.save(notification);
        userNotificationRepository.save(userNotification);
        log.info("trying to send notif to {}", user);
        messagingTemplate.convertAndSendToUser(user.getId().toString(), "/topic/notifications", notification);
    }

    public List<Notification> getUnreadNotificationsForUser(Long user_id) {
        return userNotificationRepository.findByUserIdAndReadIsFalse(user_id);
    }

    public void markNotificationAsRead(Notification n, User user) {
        UserNotification notification = userNotificationRepository.findByNotificationIdAndUserId(n.getId(), user.getId());
        log.info("Marking as read notification id {}", n.getId());
        notification.set_read(true);
        userNotificationRepository.save(notification);
    }
    public Notification getNotificationById(Long id){
        return notificationRepository.findById(id).orElse(null);
    }
}

Репозитории

Интерес представляет только связь уведомлений с пользователем:

public interface UserNotificationRepository extends CrudRepository<UserNotification, Long> {
    //Получить список непрочитанных сообщений для определённого пользователя
    @Query("SELECT n " +
            "FROM UserNotification un " +
            "JOIN Notification n " +
            "ON n.id = un.notification.id " +
            "WHERE un.user.id = :userId AND un.is_read = false")
    List<Notification> findByUserIdAndReadIsFalse(@Param("userId") Long userId);
    //Используется для пометки сообщения как прочитанного (обновления записи)
    @Query("SELECT un " +
            "FROM UserNotification un " +
            "JOIN Notification n " +
            "ON n.id = un.notification.id " +
            "WHERE n.id = :id AND un.user.id = :userId")
    UserNotification findByNotificationIdAndUserId(@Param("id") Long id,
                                                   @Param("userId") Long userId);
    //Проверка на наличие уже отправленных уведомлений. 
    //В случае истинности в сервисе выбрасывался ObjectExistsException
    @Query("SELECT COUNT(*) > 0 " +
            "FROM UserNotification un " +
            "JOIN Notification n " +
            "ON n.id = un.notification.id " +
            "WHERE un.user.id = :userId AND n.message = :message")
    boolean existsByUserIdAndMessage(@Param("userId") Long userId,
                                          @Param("message") String message);
}

Отображение

<div class="notification-container">
    <div class="bell" id="notification-bell">
        <span class="notification-count" id="notification-count" style="display:none;"></span>
        🔔
    </div>
    <div class="dropdown" id="notification-dropdown" style="display:none;">
        <ul id="notification-list"></ul>
    </div>
    <script src="/static/scripts/notification_script.js"></script>
    <input type="hidden" id="currentUserId" value="${user.id}">
    <input type="hidden" name="_csrf" value="${_csrf.token}">
</div>

Скрипт

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

  • const userId = $("#currentUserId").val();: Сохраняет ID текущего пользователя, полученный из скрытого поля на странице.
  • const csrfToken = $('input[name="_csrf"]').val();: Получает токен CSRF для защиты от атак. Этот токен добавляется в заголовки AJAX-запросов.
  • let isDropdownOpen = false;: Переменная для отслеживания состояния выпадающего меню уведомлений (открыто или закрыто).

Функция fetchNotifications:

  • function fetchNotifications() {...}: Загружает непрочитанные уведомления для текущего пользователя с сервера путем выполнения AJAX-запроса через метод $.get().
    • Если уведомления существуют, обновляет количество уведомлений в интерфейсе.
    • Для каждого уведомления добавляется HTML-элемент <li> в список уведомлений, отображая заголовок и сообщение.

Автоматическое обновление уведомлений:

  • setInterval(fetchNotifications, 3000);: Устанавливает интервал, каждые 3000 миллисекунд (или 3 секунды) выполняется функция fetchNotifications, чтобы проверять наличие новых уведомлений.

Обработчик клика на иконку уведомлений:

  • $('#notification-bell').click(function() {...});: Обработчик события клика по значку колокольчика, который открывает или закрывает меню уведомлений и также вызывает fetchNotifications, чтобы обновить список уведомлений.

Пометка уведомлений как прочитанные:

  • $(document).on('click', '#notification-list li', function() {...});: Обработчик клика на отдельном уведомлении, который помечает его как прочитанное, отправляя POST-запрос на сервер с использованием AJAX.
    • Если запрос успешен, уведомление удаляется из списка, и если больше нет уведомлений, скрывается счетчик.

Закрытие выпадающего меню:

  • $(document).click(function(event) {...});: Обработчик клика по документу, который закрывает выпадающее меню уведомлений, если клик происходит вне иконки колокольчика и самого меню.

Обработка ошибок:

  • Обработчик ошибок в AJAX-запросе: error: function(xhr, status, error) {...}. Выводит сообщение об ошибке в консоль в случае неудачного запроса при пометке уведомления как прочитанное.
⚠️ **GitHub.com Fallback** ⚠️