From bfaa231580e493fd3ef990640e92549976149e16 Mon Sep 17 00:00:00 2001 From: mtrznadel24 Date: Fri, 8 May 2026 11:16:25 +0200 Subject: [PATCH 1/4] feature(notification): Create interfaces for notificationSender and SseSubscriber and create their implementation in SseNotificationSender --- .../notification/SseNotificationSender.java | 65 +++++++++++++++++++ .../notification/SseSubscriber.java | 9 +++ .../notification/NotificationSender.java | 7 ++ 3 files changed, 81 insertions(+) create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/infrastructure/notification/SseNotificationSender.java create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/infrastructure/notification/SseSubscriber.java create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationSender.java diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/infrastructure/notification/SseNotificationSender.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/infrastructure/notification/SseNotificationSender.java new file mode 100644 index 00000000..e6029ced --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/infrastructure/notification/SseNotificationSender.java @@ -0,0 +1,65 @@ +package pl.edu.agh.project_manager.infrastructure.notification; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import pl.edu.agh.project_manager.controller.dto.notification.NotificationResponse; +import pl.edu.agh.project_manager.service.notification.NotificationSender; + +import java.io.IOException; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Component +public class SseNotificationSender implements NotificationSender, SseSubscriber { + + private final Map emitters = new ConcurrentHashMap<>(); + + @Override + public SseEmitter subscribe(UUID userId) { + SseEmitter emitter = new SseEmitter(3600000L); + + emitters.put(userId, emitter); + log.info("User {} subscribed to SSE notifications", userId); + + emitter.onCompletion(() -> { + emitters.remove(userId); + log.info("SSE connection completed for user {}", userId); + }); + emitter.onTimeout(() -> { + emitters.remove(userId); + log.info("SSE connection timed out for user {}", userId); + }); + emitter.onError((e) -> { + emitters.remove(userId); + log.error("SSE connection error for user {}: {}", userId, e.getMessage()); + }); + + try { + emitter.send(SseEmitter.event().name("INIT").data("Connected")); + } catch (IOException e) { + emitters.remove(userId); + } + + return emitter; + } + + @Override + public void send(NotificationResponse notification, UUID recipientId) { + SseEmitter emitter = emitters.get(recipientId); + + if (emitter != null) { + try { + emitter.send(SseEmitter.event() + .name("NOTIFICATION") + .data(notification)); + log.info("Push notification sent to user {}", recipientId); + } catch (IOException e) { + log.warn("Failed to send notification to user {}. Removing dead emitter.", recipientId); + emitters.remove(recipientId); + } + } + } +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/infrastructure/notification/SseSubscriber.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/infrastructure/notification/SseSubscriber.java new file mode 100644 index 00000000..715c2680 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/infrastructure/notification/SseSubscriber.java @@ -0,0 +1,9 @@ +package pl.edu.agh.project_manager.infrastructure.notification; + +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.UUID; + +public interface SseSubscriber { + SseEmitter subscribe(UUID userId); +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationSender.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationSender.java new file mode 100644 index 00000000..92f8926f --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationSender.java @@ -0,0 +1,7 @@ +package pl.edu.agh.project_manager.service.notification; + +import java.util.UUID; + +public interface NotificationSender { + void send(NotificationResponse notification, UUID recipientId); +} From 31fb83f1ba305fd98035b9716fbeaaca0af6ccd8 Mon Sep 17 00:00:00 2001 From: mtrznadel24 Date: Sun, 10 May 2026 16:46:54 +0200 Subject: [PATCH 2/4] feat(notification): integrate SSE stream endpoint and real-time pushing --- .../notification/NotificationController.java | 10 ++++++++++ .../service/notification/NotificationSender.java | 2 ++ .../service/notification/NotificationService.java | 7 ++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java index 6f32a483..b96f8ae4 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java @@ -3,12 +3,15 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import pl.edu.agh.project_manager.controller.dto.PagedResponse; import pl.edu.agh.project_manager.controller.dto.notification.NotificationResponse; +import pl.edu.agh.project_manager.infrastructure.notification.SseSubscriber; import pl.edu.agh.project_manager.security.UserPrincipal; import pl.edu.agh.project_manager.service.notification.NotificationService; @@ -20,6 +23,13 @@ public class NotificationController { private final NotificationService notificationService; + private final SseSubscriber sseSubscriber; + + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity streamNotifications(@AuthenticationPrincipal UserPrincipal userPrincipal) { + SseEmitter emitter = sseSubscriber.subscribe(userPrincipal.userId()); + return ResponseEntity.ok(emitter); + } @GetMapping public ResponseEntity> getUserNotifications( diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationSender.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationSender.java index 92f8926f..2bb6da14 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationSender.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationSender.java @@ -1,5 +1,7 @@ package pl.edu.agh.project_manager.service.notification; +import pl.edu.agh.project_manager.controller.dto.notification.NotificationResponse; + import java.util.UUID; public interface NotificationSender { diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java index d0385e83..afc6e605 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java @@ -26,6 +26,7 @@ public class NotificationService { private final NotificationRepository notificationRepository; + private final NotificationSender notificationSender; @TransactionalEventListener @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -40,7 +41,11 @@ private void create(User recipient, NotificationType type, String message, UUID .message(message) .referenceId(referenceId) .build(); - notificationRepository.save(notification); + + Notification savedNotification = notificationRepository.save(notification); + + NotificationResponse response = NotificationResponse.from(savedNotification); + notificationSender.send(response, recipient.getId()); } @Transactional(readOnly = true) From ffc1b255644e654ca1da96bc960c9920dd9ae7cc Mon Sep 17 00:00:00 2001 From: mtrznadel24 Date: Sun, 10 May 2026 19:05:16 +0200 Subject: [PATCH 3/4] feat(notification): create scheduler to clean up old, read notifications --- .../ProjectManagerApplication.java | 2 ++ .../NotificationCleanupScheduler.java | 21 +++++++++++++++++++ .../notification/NotificationRepository.java | 5 +++++ .../notification/NotificationService.java | 14 +++++++++++++ 4 files changed, 42 insertions(+) create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/infrastructure/scheduler/NotificationCleanupScheduler.java diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/ProjectManagerApplication.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/ProjectManagerApplication.java index 081c69ba..e5068d24 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/ProjectManagerApplication.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/ProjectManagerApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class ProjectManagerApplication { public static void main(String[] args) { diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/infrastructure/scheduler/NotificationCleanupScheduler.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/infrastructure/scheduler/NotificationCleanupScheduler.java new file mode 100644 index 00000000..82c41090 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/infrastructure/scheduler/NotificationCleanupScheduler.java @@ -0,0 +1,21 @@ +package pl.edu.agh.project_manager.infrastructure.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import pl.edu.agh.project_manager.service.notification.NotificationService; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationCleanupScheduler { + + private final NotificationService notificationService; + + @Scheduled(cron = "0 0 3 * * *") + public void scheduleNotificationCleanup() { + log.info("Czyszczenia starych powiadomień..."); + notificationService.cleanupOldReadNotifications(); + } +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java index 0d4282db..b5dc4515 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.repository.query.Param; import pl.edu.agh.project_manager.domain.entity.notification.Notification; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -22,4 +23,8 @@ public interface NotificationRepository extends JpaRepository 0) { + log.info("Zakończono czyszczenie powiadomień. Usunięto {} starych, przeczytanych rekordów.", deletedCount); + } + } } \ No newline at end of file From 81a0f251ee5bfed4238228da3c28478e7cd0e1df Mon Sep 17 00:00:00 2001 From: mtrznadel24 Date: Sun, 10 May 2026 19:32:54 +0200 Subject: [PATCH 4/4] feat(notification): create tests for new notification domain service and repository methods --- .../notification/NotificationRepository.java | 4 +- .../NotificationRepositoryTest.java | 115 ++++++++++++++++++ .../notification/NotificationServiceTest.java | 44 +++++-- 3 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 backend/project-manager/src/test/java/pl/edu/agh/project_manager/repository/notification/NotificationRepositoryTest.java diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java index b5dc4515..e3cf3ed8 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java @@ -20,11 +20,11 @@ public interface NotificationRepository extends JpaRepository findAllByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(UUID recipientId, Pageable pageable); - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.id = :userId AND n.isRead = false") void markAllAsReadByUserId(@Param("userId") UUID userId); - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("DELETE FROM Notification n WHERE n.isRead = true AND n.createdAt < :thresholdDate") int deleteReadAndOlderThan(@Param("thresholdDate") LocalDateTime thresholdDate); } \ No newline at end of file diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/repository/notification/NotificationRepositoryTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/repository/notification/NotificationRepositoryTest.java new file mode 100644 index 00000000..f141edda --- /dev/null +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/repository/notification/NotificationRepositoryTest.java @@ -0,0 +1,115 @@ +package pl.edu.agh.project_manager.repository.notification; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.jdbc.core.JdbcTemplate; +import pl.edu.agh.project_manager.domain.entity.notification.Notification; +import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.NotificationType; +import pl.edu.agh.project_manager.domain.enums.UserRole; +import pl.edu.agh.project_manager.domain.enums.UserStatus; +import pl.edu.agh.project_manager.repository.user.UserRepository; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class NotificationRepositoryTest { + + @Autowired + private NotificationRepository notificationRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private User testUser1; + private User testUser2; + + @BeforeEach + void setUp() { + testUser1 = userRepository.saveAndFlush(User.builder() + .email("user1@agh.edu.pl") + .name("Jan") + .surname("Testowy") + .password("hash") + .userRole(UserRole.COMMON) + .userStatus(UserStatus.ACTIVE) + .build()); + + testUser2 = userRepository.saveAndFlush(User.builder() + .email("user2@agh.edu.pl") + .name("Anna") + .surname("Inna") + .password("hash") + .userRole(UserRole.COMMON) + .userStatus(UserStatus.ACTIVE) + .build()); + } + + @Test + void shouldMarkAllUnreadNotificationsAsReadForSpecificUser() { + Notification n1 = saveNotification(testUser1, false); + Notification n2 = saveNotification(testUser1, false); + Notification n3 = saveNotification(testUser1, true); + + Notification n4 = saveNotification(testUser2, false); + + // When + notificationRepository.markAllAsReadByUserId(testUser1.getId()); + + // Then + assertTrue(notificationRepository.findById(n1.getId()).orElseThrow().isRead()); + assertTrue(notificationRepository.findById(n2.getId()).orElseThrow().isRead()); + assertTrue(notificationRepository.findById(n3.getId()).orElseThrow().isRead()); + + assertFalse(notificationRepository.findById(n4.getId()).orElseThrow().isRead()); + } + + @Test + void shouldDeleteOnlyReadAndOlderThanThreshold() { + // Given + LocalDateTime now = LocalDateTime.now(); + LocalDateTime threshold = now.minusDays(30); + + Notification readAndOld = saveNotification(testUser1, true); + ageNotificationInDatabase(readAndOld.getId(), now.minusDays(40)); + + Notification readAndNew = saveNotification(testUser1, true); + ageNotificationInDatabase(readAndNew.getId(), now.minusDays(10)); + + Notification unreadAndOld = saveNotification(testUser1, false); + ageNotificationInDatabase(unreadAndOld.getId(), now.minusDays(40)); + + // When + int deletedRecordsCount = notificationRepository.deleteReadAndOlderThan(threshold); + + // Then + assertEquals(1, deletedRecordsCount); + assertFalse(notificationRepository.existsById(readAndOld.getId())); + assertTrue(notificationRepository.existsById(readAndNew.getId())); + assertTrue(notificationRepository.existsById(unreadAndOld.getId())); + } + + + private Notification saveNotification(User recipient, boolean isRead) { + return notificationRepository.saveAndFlush(Notification.builder() + .recipient(recipient) + .type(NotificationType.SYSTEM_NEW_EMPLOYEE) + .message("Test") + .isRead(isRead) + .build()); + } + + private void ageNotificationInDatabase(UUID notificationId, LocalDateTime newDate) { + jdbcTemplate.update("UPDATE notifications SET created_at = ? WHERE id = ?", newDate, notificationId); + } +} \ No newline at end of file diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java index 765fd4cf..a325062e 100644 --- a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java @@ -34,11 +34,14 @@ class NotificationServiceTest { @Mock private NotificationRepository notificationRepository; + @Mock + private NotificationSender notificationSender; + @InjectMocks private NotificationService notificationService; @Test - void shouldCreateAndSaveNotificationWhenNotificationEventIsPublished() { + void shouldCreateSaveAndSendNotificationWhenNotificationEventIsPublished() { // Given User recipient = new User(); recipient.setId(UUID.randomUUID()); @@ -49,18 +52,26 @@ void shouldCreateAndSaveNotificationWhenNotificationEventIsPublished() { ArgumentCaptor notificationCaptor = ArgumentCaptor.forClass(Notification.class); + Notification savedMock = Notification.builder() + .id(UUID.randomUUID()) + .recipient(recipient) + .type(NotificationType.ASSIGNMENT_REQUESTED) + .message(message) + .referenceId(assignmentId) + .isRead(false) + .createdAt(LocalDateTime.now()) + .build(); + when(notificationRepository.save(any(Notification.class))).thenReturn(savedMock); + // When notificationService.onNotificationEvent(event); // Then verify(notificationRepository).save(notificationCaptor.capture()); - Notification savedNotification = notificationCaptor.getValue(); + Notification capturedNotification = notificationCaptor.getValue(); + assertEquals(NotificationType.ASSIGNMENT_REQUESTED, capturedNotification.getType()); - assertEquals(recipient, savedNotification.getRecipient()); - assertEquals(NotificationType.ASSIGNMENT_REQUESTED, savedNotification.getType()); - assertEquals(message, savedNotification.getMessage()); - assertEquals(assignmentId, savedNotification.getReferenceId()); - assertFalse(savedNotification.isRead()); + verify(notificationSender).send(any(NotificationResponse.class), eq(recipient.getId())); } @Test @@ -174,4 +185,23 @@ void shouldMarkAllAsRead() { // Then verify(notificationRepository).markAllAsReadByUserId(userId); } + + @Test + void shouldCleanupOldReadNotifications() { + // Given + ArgumentCaptor dateCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + + when(notificationRepository.deleteReadAndOlderThan(any(LocalDateTime.class))).thenReturn(5); + + // When + notificationService.cleanupOldReadNotifications(); + + // Then + verify(notificationRepository).deleteReadAndOlderThan(dateCaptor.capture()); + LocalDateTime capturedDate = dateCaptor.getValue(); + + LocalDateTime thirtyDaysAgo = LocalDateTime.now().minusDays(30); + assertTrue(capturedDate.isBefore(thirtyDaysAgo.plusMinutes(1))); + assertTrue(capturedDate.isAfter(thirtyDaysAgo.minusMinutes(1))); + } } \ No newline at end of file