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 b96f8ae4..7e075e9e 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 @@ -15,6 +15,7 @@ import pl.edu.agh.project_manager.security.UserPrincipal; import pl.edu.agh.project_manager.service.notification.NotificationService; +import java.util.Map; import java.util.UUID; @RestController @@ -31,6 +32,12 @@ public ResponseEntity streamNotifications(@AuthenticationPrincipal U return ResponseEntity.ok(emitter); } + @GetMapping("/unread-count") + public ResponseEntity> getUnreadCount(@AuthenticationPrincipal UserPrincipal userPrincipal) { + int count = notificationService.getUnreadCount(userPrincipal.userId()); + return ResponseEntity.ok(Map.of("count", count)); + } + @GetMapping public ResponseEntity> getUserNotifications( @RequestParam(defaultValue = "true") boolean unreadOnly, diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java index 741850df..480413b0 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java @@ -7,8 +7,9 @@ public record AssignmentAcceptedEvent( UUID assignmentId, - User recipient, String - message + User recipient, + String projectName, + String employeeFullName ) implements NotificationEvent { @Override @@ -20,4 +21,12 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.ASSIGNMENT_ACCEPTED; } + + @Override + public String buildMessage() { + if (employeeFullName != null) { + return "Zaakceptowano przypisanie pracownika " + employeeFullName + " do projektu " + projectName; + } + return "Zostałeś dodany do projektu: " + projectName; + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java index 97eccd2b..100e31fc 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java @@ -8,7 +8,8 @@ public record AssignmentRejectedEvent( UUID assignmentId, User recipient, - String message + String employeeName, + String projectName ) implements NotificationEvent { @Override @@ -20,4 +21,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.ASSIGNMENT_REJECTED; } + + @Override + public String buildMessage() { + return "Odrzucono przypisanie pracownika " + employeeName + " do projektu " + projectName; + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java index 0ecfd997..2bda749c 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java @@ -8,7 +8,8 @@ public record AssignmentRequestedEvent( UUID assignmentId, User recipient, - String message + String projectName, + String employeeFullName ) implements NotificationEvent { @Override @@ -20,4 +21,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.ASSIGNMENT_REQUESTED; } + + @Override + public String buildMessage() { + return "Kierownik projektu " + projectName + " prosi o alokację pracownika " + employeeFullName; + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java index 958ef54d..48bbd453 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java @@ -7,7 +7,7 @@ public interface NotificationEvent { User recipient(); - String message(); UUID referenceId(); NotificationType type(); + String buildMessage(); } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java index ddb54a78..8088eb13 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java @@ -3,12 +3,13 @@ import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.NotificationType; +import java.util.List; import java.util.UUID; public record QualificationAcceptedEvent( UUID qualificationId, User recipient, - String message + List skills ) implements NotificationEvent { @Override @@ -20,4 +21,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.QUALIFICATION_ACCEPTED; } + + @Override + public String buildMessage() { + return "Zatwierdzono Twoje kwalifikacje: " + String.join(", ", skills); + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java index 5b5ee14e..da0df94f 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java @@ -3,12 +3,13 @@ import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.NotificationType; +import java.util.List; import java.util.UUID; public record QualificationRejectedEvent( UUID qualificationId, User recipient, - String message + List skills ) implements NotificationEvent { @Override @@ -20,4 +21,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.QUALIFICATION_REJECTED; } + + @Override + public String buildMessage() { + return "Odrzucono wnioski o kwalifikacje: " + String.join(", ", skills); + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java index e0530085..ba585196 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java @@ -3,12 +3,14 @@ import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.NotificationType; +import java.util.List; import java.util.UUID; public record QualificationRequestedEvent( UUID qualificationId, User recipient, - String message + String employeeFullName, + List skills ) implements NotificationEvent { @Override @@ -20,4 +22,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.QUALIFICATION_REQUESTED; } + + @Override + public String buildMessage() { + return employeeFullName + " zgłasza nowe umiejętności: " + String.join(", ", skills); + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java index 3bc8f816..add2cc0c 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java @@ -8,16 +8,17 @@ public record SystemNewEmployeeEvent( UUID employeeId, User recipient, - String message + String employeeFullName ) implements NotificationEvent { @Override - public UUID referenceId() { - return employeeId; - } + public UUID referenceId() { return employeeId; } + + @Override + public NotificationType type() { return NotificationType.SYSTEM_NEW_EMPLOYEE; } @Override - public NotificationType type() { - return NotificationType.SYSTEM_NEW_EMPLOYEE; + public String buildMessage() { + return String.format("Nowy pracownik %s aktywował swoje konto i dołączył do Twojego zespołu.", employeeFullName); } } \ 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 e3cf3ed8..71b13e97 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,6 +20,8 @@ public interface NotificationRepository extends JpaRepository findAllByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(UUID recipientId, Pageable pageable); + Integer countAllByRecipientIdAndIsReadFalse(UUID recipientId); + @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); diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java index 1442b2ab..edc9cf9f 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java @@ -1,6 +1,7 @@ package pl.edu.agh.project_manager.service.approval; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import pl.edu.agh.project_manager.controller.dto.approvals.QualificationDetailsResponse; @@ -11,13 +12,16 @@ import pl.edu.agh.project_manager.domain.entity.user.Skill; import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.QualificationStatus; +import pl.edu.agh.project_manager.domain.event.NotificationEvent; +import pl.edu.agh.project_manager.domain.event.QualificationAcceptedEvent; +import pl.edu.agh.project_manager.domain.event.QualificationRejectedEvent; import pl.edu.agh.project_manager.domain.exception.ApiErrorCode; import pl.edu.agh.project_manager.domain.exception.ApplicationException; import pl.edu.agh.project_manager.repository.user.QualificationRepository; import pl.edu.agh.project_manager.repository.user.UserRepository; +import pl.edu.agh.project_manager.service.notification.NotificationSender; -import java.util.List; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; @Service @@ -25,6 +29,7 @@ public class QualificationManagementService { private final QualificationRepository qualificationRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public List getRecordsForManager(UUID managerId) { @@ -62,16 +67,43 @@ public void updateRequests(UUID managerId, List requ throw new ApplicationException(ApiErrorCode.QUALIFICATION_OWNER_NOT_SUBORDINATE); } + Map> acceptedSkillsByUser = new HashMap<>(); + Map> rejectedSkillsByUser = new HashMap<>(); + for (Qualification qualification : allowedQualifications) { validateWaitingStatus(qualification); + User owner = qualification.getUser(); var action = activeRequestsMap.get(qualification.getId()).action(); + String skillName = qualification.getSkill().getName(); switch (action) { - case QualificationUpdateAction.ACCEPT -> qualification.accept(); - case QualificationUpdateAction.REJECT -> qualification.reject(); + case ACCEPT -> { + qualification.accept(); + acceptedSkillsByUser.computeIfAbsent(owner, k -> new ArrayList<>()).add(skillName); + } + case REJECT -> { + qualification.reject(); + rejectedSkillsByUser.computeIfAbsent(owner, k -> new ArrayList<>()).add(skillName); + } } } + + acceptedSkillsByUser.forEach((user, skills) -> { + eventPublisher.publishEvent(new QualificationAcceptedEvent( + user.getId(), + user, + skills + )); + }); + + rejectedSkillsByUser.forEach((user, skills) -> { + eventPublisher.publishEvent(new QualificationRejectedEvent( + user.getId(), + user, + skills + )); + }); } @Transactional(readOnly = true) diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java index 36672239..4f7f94dc 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java @@ -1,6 +1,7 @@ package pl.edu.agh.project_manager.service.auth; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -11,7 +12,9 @@ import org.springframework.transaction.annotation.Transactional; import pl.edu.agh.project_manager.domain.entity.user.ActivationToken; import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.UserRole; import pl.edu.agh.project_manager.domain.enums.UserStatus; +import pl.edu.agh.project_manager.domain.event.SystemNewEmployeeEvent; import pl.edu.agh.project_manager.domain.exception.ApiErrorCode; import pl.edu.agh.project_manager.domain.exception.ApplicationException; import pl.edu.agh.project_manager.repository.user.ActivationTokenRepository; @@ -34,6 +37,7 @@ public class AuthService { private final JwtService jwtService; private final AuthenticationManager authenticationManager; private final UserDetailsService userDetailsService; + private final ApplicationEventPublisher eventPublisher; @Transactional public TokenPair register(RegisterCommand command) { @@ -56,6 +60,14 @@ public TokenPair register(RegisterCommand command) { tokenRepository.delete(activationToken); + if (user.getUserRole() == UserRole.COMMON && user.getSupervisor() != null) { + eventPublisher.publishEvent(new SystemNewEmployeeEvent( + user.getId(), + user.getSupervisor(), + user.getFullName() + )); + } + UserDetails userDetails = new UserPrincipal( user.getId(), user.getEmail(), 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 6217a84d..7fe58050 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 @@ -34,7 +34,7 @@ public class NotificationService { @TransactionalEventListener @Transactional(propagation = Propagation.REQUIRES_NEW) public void onNotificationEvent(NotificationEvent event) { - create(event.recipient(), event.type(), event.message(), event.referenceId()); + create(event.recipient(), event.type(), event.buildMessage(), event.referenceId()); } private void create(User recipient, NotificationType type, String message, UUID referenceId) { @@ -51,6 +51,11 @@ private void create(User recipient, NotificationType type, String message, UUID notificationSender.send(response, recipient.getId()); } + @Transactional(readOnly = true) + public Integer getUnreadCount(UUID userId) { + return notificationRepository.countAllByRecipientIdAndIsReadFalse(userId); + } + @Transactional(readOnly = true) public PagedResponse getNotificationsForUser(UUID userId, int page, int size, boolean unreadOnly) { Pageable pageable = PageRequest.of(page, size); diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java index 06665fd7..1ae6a2e8 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java @@ -54,7 +54,8 @@ public AssignmentResponse createAssignment(UUID projectId, AssignmentCommand com eventPublisher.publishEvent(new AssignmentRequestedEvent( savedAssignment.getId(), user.getSupervisor(), - "Kierownik projektu " + project.getTitle() + " prosi o alokację pracownika " + user.getFullName() + project.getTitle(), + user.getFullName() )); } @@ -123,9 +124,17 @@ public void acceptAssignment(UUID assignmentId) { } eventPublisher.publishEvent(new AssignmentAcceptedEvent( - assignment.getId(), + project.getId(), project.getProjectManager(), - "Zaakceptowano przypisanie pracownika " + user.getFullName() + " do projektu " + project.getTitle() + project.getTitle(), + user.getFullName() + )); + + eventPublisher.publishEvent(new AssignmentAcceptedEvent( + project.getId(), + user, + project.getTitle(), + null )); } @@ -138,9 +147,10 @@ public void rejectAssignment(UUID assignmentId) { assignment.setStatus(AssignmentStatus.REJECTED); eventPublisher.publishEvent(new AssignmentRejectedEvent( - assignment.getId(), + assignment.getProject().getId(), assignment.getProject().getProjectManager(), - "Odrzucono przypisanie pracownika " + assignment.getUser().getName() + " do projektu " + assignment.getProject().getTitle() + assignment.getUser().getName(), + assignment.getProject().getTitle() )); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java index db8dc756..75ed424c 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -58,6 +59,19 @@ public List addQualificationsToUser(UUID userId, List skillsList = savedQualifications.stream() + .map(q -> q.getSkill().getName()) + .toList(); + + eventPublisher.publishEvent(new QualificationRequestedEvent( + user.getId(), + user.getSupervisor(), + user.getFullName(), + skillsList + )); + } + return savedQualifications.stream() .map(q -> new QualificationResponse(q.getId(), q.getSkill().getName(), q.getStatus())) .toList(); @@ -72,15 +86,6 @@ private Qualification saveQualification(User user, Skill skill) { user.addQualification(qualification); Qualification saved = qualificationRepository.save(qualification); - // FIXME: na ten moment przy jednym zgłoszeniu 5 skilli przychodziłoby 5 powiadomień, raczej tak nie chcemy, ale na razie nie wiem jak będzie wyglądało to na froncie, więc zostawiam zakomentowane -// if (user.getSupervisor() != null) { -// eventPublisher.publishEvent(new QualificationRequestedEvent( -// saved.getId(), -// user.getSupervisor(), -// user.getFullName() + " zgłasza umiejętność: " + skill.getName() -// )); -// } - return saved; } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserInvitationService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserInvitationService.java index 3c9d794b..264dfd1a 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserInvitationService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserInvitationService.java @@ -43,14 +43,6 @@ private void processInvitation(String email, UserRole role, UUID supervisorId) { var newUser = createInvitedUser(email, role, supervisor); userRepository.save(newUser); - if (supervisor != null) { - eventPublisher.publishEvent(new SystemNewEmployeeEvent( - newUser.getId(), - supervisor, - "Do twojego zespołu zaproszono nowego pracownika: " + email - )); - } - var activationToken = createTokenForUser(newUser); sendInvitationEmail(email, activationToken); diff --git a/backend/project-manager/src/main/resources/application.properties b/backend/project-manager/src/main/resources/application.properties index baacaeb9..7a4af714 100644 --- a/backend/project-manager/src/main/resources/application.properties +++ b/backend/project-manager/src/main/resources/application.properties @@ -5,6 +5,8 @@ springdoc.swagger-ui.path=/swagger springdoc.api-docs.enabled=true springdoc.swagger-ui.enabled=true application.security.swagger.enabled=true +logging.level.org.springframework.security.web.access.ExceptionTranslationFilter=FATAL +logging.level.org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/]=FATAL # Jwt 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 a325062e..a4f9b707 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 @@ -46,9 +46,16 @@ void shouldCreateSaveAndSendNotificationWhenNotificationEventIsPublished() { User recipient = new User(); recipient.setId(UUID.randomUUID()); UUID assignmentId = UUID.randomUUID(); - String message = "Prośba o przypisanie"; - AssignmentRequestedEvent event = new AssignmentRequestedEvent(assignmentId, recipient, message); + String projectName = "Projekt Apollo"; + String employeeName = "Jan Kowalski"; + + AssignmentRequestedEvent event = new AssignmentRequestedEvent( + assignmentId, + recipient, + projectName, + employeeName + ); ArgumentCaptor notificationCaptor = ArgumentCaptor.forClass(Notification.class); @@ -56,11 +63,12 @@ void shouldCreateSaveAndSendNotificationWhenNotificationEventIsPublished() { .id(UUID.randomUUID()) .recipient(recipient) .type(NotificationType.ASSIGNMENT_REQUESTED) - .message(message) + .message(event.buildMessage()) .referenceId(assignmentId) .isRead(false) .createdAt(LocalDateTime.now()) .build(); + when(notificationRepository.save(any(Notification.class))).thenReturn(savedMock); // When @@ -69,8 +77,11 @@ void shouldCreateSaveAndSendNotificationWhenNotificationEventIsPublished() { // Then verify(notificationRepository).save(notificationCaptor.capture()); Notification capturedNotification = notificationCaptor.getValue(); + assertEquals(NotificationType.ASSIGNMENT_REQUESTED, capturedNotification.getType()); + assertEquals("Kierownik projektu Projekt Apollo prosi o alokację pracownika Jan Kowalski", capturedNotification.getMessage()); + verify(notificationSender).send(any(NotificationResponse.class), eq(recipient.getId())); } diff --git a/frontend/index.html b/frontend/index.html index 0fca6f04..4f5da590 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - frontend + IO Project Manager
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2aa0474d..b83f434e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@fontsource-variable/geist": "^5.2.8", "@hookform/resolvers": "^5.2.2", "@lukemorales/query-key-factory": "^1.3.4", + "@microsoft/fetch-event-source": "^2.0.1", "@tanstack/react-query": "^5.99.0", "@tanstack/react-query-devtools": "^5.99.0", "@tanstack/react-table": "^8.21.3", @@ -1087,6 +1088,12 @@ "@tanstack/react-query": ">= 4.0.0" } }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -4185,12 +4192,12 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } @@ -5463,12 +5470,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -5578,9 +5585,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -6100,9 +6107,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -6227,9 +6234,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -7531,9 +7538,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", diff --git a/frontend/package.json b/frontend/package.json index 019021d9..442fc3b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@fontsource-variable/geist": "^5.2.8", "@hookform/resolvers": "^5.2.2", "@lukemorales/query-key-factory": "^1.3.4", + "@microsoft/fetch-event-source": "^2.0.1", "@tanstack/react-query": "^5.99.0", "@tanstack/react-query-devtools": "^5.99.0", "@tanstack/react-table": "^8.21.3", diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 00000000..15944c14 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 57a68d0c..d433c69c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,17 +4,37 @@ import {ENDPOINTS} from "./endpoints.ts"; let currentAccessToken: string | null = null; export const setAccessToken = (token: string | null) => { - currentAccessToken = token; + currentAccessToken = token; } +export const getAccessToken = () => currentAccessToken; + +export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api'; + const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api', + baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, withCredentials: true, }); +export const refreshAccessToken = async () => { + try { + const refreshResponse = await axios.post( + `${API_BASE_URL}${ENDPOINTS.AUTH.REFRESH}`, + {}, + { withCredentials: true } + ); + const newAccessToken = refreshResponse.data.accessToken; + setAccessToken(newAccessToken); + return newAccessToken; + } catch (error) { + setAccessToken(null); + window.dispatchEvent(new Event('auth-logout')); + throw error; + } +}; api.interceptors.request.use( (config) => { @@ -42,24 +62,10 @@ api.interceptors.response.use( originalRequest._retry = true; try { - const refreshResponse = await axios.post( - `${api.defaults.baseURL}${ENDPOINTS.AUTH.REFRESH}`, - {}, - { withCredentials: true } - ); - - const newAccessToken = refreshResponse.data.accessToken; - setAccessToken(newAccessToken) - + const newAccessToken = await refreshAccessToken(); originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - return api(originalRequest); - } catch (refreshError) { - setAccessToken(null); - - window.dispatchEvent(new Event('auth-logout')); - return Promise.reject(refreshError); } } diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 528452e8..db5d9d57 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -45,5 +45,12 @@ export const ENDPOINTS = { QUALIFICATIONS: '/approvals/qualifications', QUALIFICATION_DETAILS: '/approvals/qualifications/details', QUALIFICATIONS_BULK_UPDATE: '/approvals/qualifications/bulk-update' + }, + NOTIFICATIONS: { + BASE: '/notifications', + STREAM: '/notifications/stream', + UNREAD_COUNT: 'notifications/unread-count', + MARK_READ: (id: string) => `/notifications/${id}/mark-as-read`, + MARK_ALL_READ: '/notifications/mark-all-as-read', } } as const; \ No newline at end of file diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index c7c39c6d..221fe9bb 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -15,5 +15,10 @@ export const approvalsKeys = createQueryKeys('approvals', { details: (userId: string) => [userId], }); +export const notificationsKeys = createQueryKeys('notifications', { + feed: (unreadOnly: boolean, page: number) => [{ unreadOnly, page }], + unreadCount: () => ['count'], + infinite: (unreadOnly: boolean) => [{ unreadOnly, type: 'infinite' }], +}); -export const queryKeys = mergeQueryKeys(employeeAssignmentsKeys, qualificationsKeys, approvalsKeys); \ No newline at end of file +export const queryKeys = mergeQueryKeys(employeeAssignmentsKeys, qualificationsKeys, approvalsKeys, notificationsKeys) \ No newline at end of file diff --git a/frontend/src/api/sseClient.ts b/frontend/src/api/sseClient.ts new file mode 100644 index 00000000..d2d34a69 --- /dev/null +++ b/frontend/src/api/sseClient.ts @@ -0,0 +1,70 @@ +import { fetchEventSource } from '@microsoft/fetch-event-source'; +import { getAccessToken, refreshAccessToken } from './client'; + +interface SSEConnectionOptions { + url: string; + onOpen?: () => void; + onMessage?: (event: { event: string; data: string }) => void; + onError?: (error: unknown) => void; +} + +export const connectSSE = ({ url, onOpen, onMessage, onError }: SSEConnectionOptions) => { + let controller = new AbortController(); + + const connect = async () => { + const token = getAccessToken(); + if (!token) return; + + try { + await fetchEventSource(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'text/event-stream, application/json', + }, + signal: controller.signal, + + async onopen(response) { + if (response.ok && onOpen) { + onOpen(); + } + + if (response.status === 401) { + try { + await refreshAccessToken(); + controller.abort(); + controller = new AbortController(); + setTimeout(connect, 100); + return; + } catch (err) { + throw new Error('Refresh failed - stop retrying'); + } + } + + if (response.status >= 400 && response.status !== 401) { + throw new Error('Server Error - Stop retrying'); + } + }, + + onmessage(ev) { + if (onMessage) { + onMessage(ev); + } + }, + + onerror(err) { + if (onError) onError(err); + throw err; + } + }); + } catch (error) { + console.error('SSE Connection failed', error); + } + }; + + connect(); + + return () => { + controller.abort(); + }; +}; \ No newline at end of file diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx index ed36ba5c..56348f0f 100644 --- a/frontend/src/components/layout/MainLayout.tsx +++ b/frontend/src/components/layout/MainLayout.tsx @@ -1,7 +1,10 @@ import { Outlet } from 'react-router-dom'; import { Navbar } from './Navbar'; +import { useNotificationStream } from '@/features/notification/notification.hooks'; export const MainLayout = () => { + useNotificationStream(); + return (
diff --git a/frontend/src/components/layout/Navbar.tsx b/frontend/src/components/layout/Navbar.tsx index 78b432b6..b953585b 100644 --- a/frontend/src/components/layout/Navbar.tsx +++ b/frontend/src/components/layout/Navbar.tsx @@ -3,7 +3,6 @@ import { useAuth } from '@/providers/AuthContext'; import { useAuthActions } from '@/features/auth/auth.hooks'; import { PATHS } from '@/routes/paths'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { DropdownMenu, DropdownMenuContent, @@ -17,11 +16,11 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { LogOut, Briefcase, - Bell, UserCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import { NAV_ITEMS } from '@/routes/navigation'; +import {NotificationBell} from "@/features/notification/components/NotificationBell.tsx"; export const Navbar = () => { const { user } = useAuth(); @@ -31,9 +30,6 @@ export const Navbar = () => { const isActive = (path: string) => location.pathname.startsWith(path); - // TODO: powiadomienia - const unreadNotifications = 3; - const initials = `${user?.firstName?.charAt(0) || ''}${user?.lastName?.charAt(0) || ''}`.toUpperCase(); const visibleNavItems = NAV_ITEMS.filter((item) => { @@ -80,14 +76,7 @@ export const Navbar = () => {
- + diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 00000000..877dbc82 --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,31 @@ +import * as React from "react" +import { Switch as SwitchPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } diff --git a/frontend/src/features/notification/components/NotificationBell.tsx b/frontend/src/features/notification/components/NotificationBell.tsx new file mode 100644 index 00000000..6faa868f --- /dev/null +++ b/frontend/src/features/notification/components/NotificationBell.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Bell, Check, Loader2, Filter } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + useNotifications, + useUnreadCount, + useMarkNotificationAsRead, + useMarkAllNotificationsAsRead +} from '../notification.hooks'; +import { getNotificationUrl } from '../notification.utils'; +import type { NotificationType } from '../notification.types'; +import {NotificationBellItem} from "@/features/notification/components/NotificationBellItem.tsx"; + +export const NotificationBell = () => { + const navigate = useNavigate(); + const [isOpen, setIsOpen] = useState(false); + const [unreadOnly, setUnreadOnly] = useState(true); + + const { data: unreadCount = 0 } = useUnreadCount(); + + const { data, isLoading } = useNotifications(unreadOnly, isOpen); + + const { mutate: markAsRead } = useMarkNotificationAsRead(); + const { mutate: markAllAsRead, isPending: isMarkingAll } = useMarkAllNotificationsAsRead(); + + const notifications = data?.items || []; + + const handleNotificationClick = (type: NotificationType, referenceId: string, id: string, isRead: boolean) => { + if (!isRead) markAsRead(id); + setIsOpen(false); + navigate(getNotificationUrl(type, referenceId)); + }; + + const handleMarkAsReadOnly = (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + markAsRead(id); + }; + + return ( + + + + + + +
+
+ Powiadomienia + {unreadCount > 0 && ( + + )} +
+ +
+
+ + +
+ +
+
+ +
+ {isLoading ? ( +
+ +
+ ) : notifications.length === 0 ? ( +
+ Brak {unreadOnly ? 'nieprzeczytanych' : 'nowych'} powiadomień +
+ ) : ( + notifications.map((notification) => ( + + )) + )} +
+ +
+ +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/features/notification/components/NotificationBellItem.tsx b/frontend/src/features/notification/components/NotificationBellItem.tsx new file mode 100644 index 00000000..b352b4cb --- /dev/null +++ b/frontend/src/features/notification/components/NotificationBellItem.tsx @@ -0,0 +1,93 @@ +import React, { memo, useState } from "react"; +import type { MouseEvent } from "react"; +import { CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { NotificationResponse } from '../notification.types'; + +interface NotificationBellItemProps { + notification: NotificationResponse; + onClick: (type: NotificationResponse['type'], referenceId: string, id: string, isRead: boolean) => void; + onMarkAsRead: (e: MouseEvent, id: string) => void; +} + +export const NotificationBellItem: React.FC = memo(({ + notification, + onClick, + onMarkAsRead + }) => { + const [isExpanded, setIsExpanded] = useState(false); + + const toggleExpand = (e: MouseEvent) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }; + + return ( +
onClick(notification.type, notification.referenceId, notification.id, notification.isRead)} + > +
+
+ + Aktywność + + + {new Date(notification.createdAt).toLocaleDateString()} + +
+ +
+

+ {notification.message} +

+ + {notification.message.length > 60 && ( +
+ {isExpanded ? ( + <>Zwiń + ) : ( + <>Rozwiń + )} +
+ )} +
+
+ + {!notification.isRead && ( +
+ +
+ )} +
+ ); +}); \ No newline at end of file diff --git a/frontend/src/features/notification/components/NotificationFeed.tsx b/frontend/src/features/notification/components/NotificationFeed.tsx new file mode 100644 index 00000000..04058880 --- /dev/null +++ b/frontend/src/features/notification/components/NotificationFeed.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Bell, Check, Loader2, Filter } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { + useInfiniteNotifications, + useMarkAllNotificationsAsRead, + useMarkNotificationAsRead +} from '../notification.hooks'; +import { getNotificationUrl } from '../notification.utils'; +import type { NotificationResponse } from '../notification.types'; +import { NotificationListItem } from './NotificationListItem'; + +export const NotificationFeed = () => { + const navigate = useNavigate(); + const [unreadOnly, setUnreadOnly] = useState(false); + + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage + } = useInfiniteNotifications(unreadOnly); + + const { mutate: markAllAsRead, isPending: isMarkingAll } = useMarkAllNotificationsAsRead(); + const { mutate: markAsRead } = useMarkNotificationAsRead(); + + const notifications = data?.pages.flatMap(page => page.items) || []; + const totalNotifications = data?.pages[0]?.totalCount || 0; + + const handleNotificationClick = (notification: NotificationResponse) => { + if (!notification.isRead) { + markAsRead(notification.id); + } + navigate(getNotificationUrl(notification.type, notification.referenceId)); + }; + + return ( +
+
+
+

+ + Twoje powiadomienia +

+

+ Zarządzaj swoimi alertami i prośbami systemowymi. +

+
+ +
+
+ + + +
+
+ +
+
+ +
+ {isLoading ? ( +
+ +

Ładowanie powiadomień...

+
+ ) : notifications.length === 0 ? ( +
+ +

Brak powiadomień

+

Nie masz żadnych {unreadOnly ? 'nieprzeczytanych' : 'nowych'} wiadomości.

+
+ ) : ( +
+ {notifications.map((notification) => ( + + ))} +
+ )} + + {hasNextPage && ( +
+ +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/features/notification/components/NotificationListItem.tsx b/frontend/src/features/notification/components/NotificationListItem.tsx new file mode 100644 index 00000000..59b9f9da --- /dev/null +++ b/frontend/src/features/notification/components/NotificationListItem.tsx @@ -0,0 +1,77 @@ +import { CheckCircle2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { NotificationResponse } from '../notification.types'; + +interface NotificationListItemProps { + notification: NotificationResponse; + onClick: (notification: NotificationResponse) => void; + onMarkAsRead: (id: string) => void; +} + +export const NotificationListItem = ({ + notification, + onClick, + onMarkAsRead + }: NotificationListItemProps) => { + + const handleMarkAsReadOnly = (e: React.MouseEvent) => { + e.stopPropagation(); + onMarkAsRead(notification.id); + }; + + return ( +
+ + + {!notification.isRead && ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.hooks.ts b/frontend/src/features/notification/notification.hooks.ts new file mode 100644 index 00000000..75027a3c --- /dev/null +++ b/frontend/src/features/notification/notification.hooks.ts @@ -0,0 +1,140 @@ +import { useEffect } from 'react'; +import {useQuery, useMutation, useQueryClient, useInfiniteQuery} from '@tanstack/react-query'; +import type { InfiniteData } from '@tanstack/react-query'; +import { connectSSE } from '@/api/sseClient'; +import { notificationService } from './notification.service'; +import { queryKeys } from '@/api'; +import { ENDPOINTS } from '@/api/endpoints'; +import { API_BASE_URL } from '@/api/client'; +import type { NotificationResponse } from './notification.types'; +import type { PagedResponse } from '@/api/api.types'; + +export const useUnreadCount = () => { + return useQuery({ + queryKey: queryKeys.notifications.unreadCount().queryKey, + queryFn: notificationService.getUnreadCount, + staleTime: Infinity, + }); +}; + +export const useNotifications = (unreadOnly: boolean, isOpen: boolean) => { + return useQuery({ + queryKey: queryKeys.notifications.feed(unreadOnly, 0).queryKey, + queryFn: () => notificationService.getNotifications(unreadOnly, 0, 10), + enabled: isOpen, + staleTime: Infinity, + }); +}; + +export const useMarkNotificationAsRead = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: notificationService.markAsRead, + onMutate: async (notificationId) => { + queryClient.setQueryData(queryKeys.notifications.unreadCount().queryKey, (old) => Math.max(0, (old || 0) - 1)); + + const allFeedKey = queryKeys.notifications.feed(false, 0).queryKey; + queryClient.setQueryData>(allFeedKey, (old) => { + if (!old) return old; + return { + ...old, + items: old.items.map(n => n.id === notificationId ? { ...n, isRead: true } : n) + }; + }); + + const unreadFeedKey = queryKeys.notifications.feed(true, 0).queryKey; + queryClient.setQueryData>(unreadFeedKey, (old) => { + if (!old) return old; + return { + ...old, + items: old.items.filter(n => n.id !== notificationId), + totalCount: Math.max(0, old.totalCount - 1) + }; + }); + + const infiniteAllKey = queryKeys.notifications.infinite(false).queryKey; + queryClient.setQueryData>>(infiniteAllKey, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + pages: oldData.pages.map(page => ({ + ...page, + items: page.items.map(n => n.id === notificationId ? { ...n, isRead: true } : n) + })) + }; + }); + + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.infinite(true).queryKey }); + }, + }); +}; + +export const useMarkAllNotificationsAsRead = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: notificationService.markAllAsRead, + onMutate: async () => { + queryClient.setQueryData(queryKeys.notifications.unreadCount().queryKey, 0); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.notifications._def }); + } + }); +}; + +export const useInfiniteNotifications = (unreadOnly: boolean) => { + return useInfiniteQuery({ + queryKey: queryKeys.notifications.infinite(unreadOnly).queryKey, + queryFn: ({ pageParam = 0 }) => notificationService.getNotifications(unreadOnly, pageParam, 20), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + if (lastPage.pageNumber < lastPage.totalPages - 1) { + return lastPage.pageNumber + 1; + } + return undefined; + }, + }); +}; + +export const useNotificationStream = () => { + const queryClient = useQueryClient(); + + useEffect(() => { + const url = `${API_BASE_URL}${ENDPOINTS.NOTIFICATIONS.STREAM}`; + + const disconnect = connectSSE({ + url, + onOpen: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.unreadCount().queryKey }); + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed(true, 0).queryKey }); + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed(false, 0).queryKey }); + }, + onMessage: (ev) => { + if (ev.event === 'NOTIFICATION') { + const newNotification: NotificationResponse = JSON.parse(ev.data); + + queryClient.setQueryData(queryKeys.notifications.unreadCount().queryKey, (old) => (old || 0) + 1); + + [true, false].forEach(unreadOnly => { + const feedKey = queryKeys.notifications.feed(unreadOnly, 0).queryKey; + queryClient.setQueryData>(feedKey, (oldData) => { + if (!oldData) { + queryClient.invalidateQueries({ queryKey: feedKey }); + return oldData; + } + + return { + ...oldData, + items: [newNotification, ...oldData.items].slice(0, 10), + totalCount: oldData.totalCount + 1, + }; + }); + }); + } + } + }); + + return () => disconnect(); + }, [queryClient]); +}; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.service.ts b/frontend/src/features/notification/notification.service.ts new file mode 100644 index 00000000..f88faa8c --- /dev/null +++ b/frontend/src/features/notification/notification.service.ts @@ -0,0 +1,26 @@ +import api from '@/api/client'; +import { ENDPOINTS } from '@/api/endpoints'; +import type {NotificationResponse, UnreadCountResponse} from './notification.types'; +import type { PagedResponse } from '@/api/api.types'; + +export const notificationService = { + getNotifications: async (unreadOnly: boolean = true, page: number = 0, size: number = 20): Promise> => { + const { data } = await api.get>(ENDPOINTS.NOTIFICATIONS.BASE, { + params: { unreadOnly, page, size } + }); + return data; + }, + + markAsRead: async (id: string): Promise => { + await api.patch(ENDPOINTS.NOTIFICATIONS.MARK_READ(id)); + }, + + markAllAsRead: async (): Promise => { + await api.patch(ENDPOINTS.NOTIFICATIONS.MARK_ALL_READ); + }, + + getUnreadCount: async (): Promise => { + const { data } = await api.get(ENDPOINTS.NOTIFICATIONS.UNREAD_COUNT); + return data.count; + }, +}; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.types.ts b/frontend/src/features/notification/notification.types.ts new file mode 100644 index 00000000..9f8dd03f --- /dev/null +++ b/frontend/src/features/notification/notification.types.ts @@ -0,0 +1,21 @@ +export type NotificationType = + | 'ASSIGNMENT_REQUESTED' + | 'ASSIGNMENT_ACCEPTED' + | 'ASSIGNMENT_REJECTED' + | 'QUALIFICATION_REQUESTED' + | 'QUALIFICATION_ACCEPTED' + | 'QUALIFICATION_REJECTED' + | 'SYSTEM_NEW_EMPLOYEE'; + +export interface NotificationResponse { + id: string; + type: NotificationType; + message: string; + isRead: boolean; + referenceId: string; + createdAt: string; +} + +export interface UnreadCountResponse { + count: number; +} \ No newline at end of file diff --git a/frontend/src/features/notification/notification.utils.ts b/frontend/src/features/notification/notification.utils.ts new file mode 100644 index 00000000..4306e76d --- /dev/null +++ b/frontend/src/features/notification/notification.utils.ts @@ -0,0 +1,27 @@ +import type { NotificationType } from "@/features/notification/notification.types.ts"; +import {PATHS, QUERY_PARAMS} from "@/routes/paths.ts"; + +export const getNotificationUrl = (type: NotificationType, referenceId: string): string => { + switch (type) { + case 'ASSIGNMENT_REQUESTED': + return `${PATHS.PROJECT_REQUESTS}?${QUERY_PARAMS.REQUEST_ID}=${referenceId}`; + + case 'ASSIGNMENT_ACCEPTED': + return PATHS.PROJECT(referenceId); + case 'ASSIGNMENT_REJECTED': + return PATHS.PROJECT(referenceId); + + case 'QUALIFICATION_REQUESTED': + return `${PATHS.QUALIFICATION_REQUESTS}?${QUERY_PARAMS.USER_ID}=${referenceId}`; + + case 'QUALIFICATION_ACCEPTED': + case 'QUALIFICATION_REJECTED': + return PATHS.PROFILE; + + case 'SYSTEM_NEW_EMPLOYEE': + return PATHS.PROFILE; // TODO: Powinno przenosic na profil pracownika, ale nie ma obecnie takiej mozliwosci + + default: + return PATHS.ROOT; + } +}; \ No newline at end of file diff --git a/frontend/src/pages/EmployeeAssignmentsPage.tsx b/frontend/src/pages/EmployeeAssignmentsPage.tsx index f67f00e0..7f5d8dad 100644 --- a/frontend/src/pages/EmployeeAssignmentsPage.tsx +++ b/frontend/src/pages/EmployeeAssignmentsPage.tsx @@ -1,29 +1,42 @@ import { useEmployeeAssignments, useEmployeeAssignmentsActions } from "@/features/employee-assignments/employee-assignments.hooks"; import { getColumns } from "@/features/employee-assignments/employee-assignments.columns"; -import { useState, useCallback, useMemo } from "react"; +import { useCallback, useMemo} from "react"; import type { EmployeeAssignment } from "@/features/employee-assignments/employee-assignments.types"; import { EmployeeAssignmentDetails } from "@/features/employee-assignments/components/EmployeeAssignmentDetails"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { DataTable } from "@/components/ui/data-table"; import { TablePageShell } from "@/components/layout/TablePageShell"; +import { useSearchParams } from "react-router-dom"; export const EmployeeAssignmentsPage = () => { const { employeeAssignments, areAssignmentsLoading } = useEmployeeAssignments(); const { rejectRequest } = useEmployeeAssignmentsActions(); + const [searchParams, setSearchParams] = useSearchParams(); + const requestIdFromUrl = searchParams.get('requestId'); - const [selectedAssignment, setSelectedAssignment] = useState(null); + const selectedAssignment = useMemo(() => { + if (!requestIdFromUrl || !employeeAssignments) return null; + return employeeAssignments.find(a => a.id === requestIdFromUrl) || null; + }, [requestIdFromUrl, employeeAssignments]); const handleReject = useCallback((assignment: EmployeeAssignment) => { rejectRequest(assignment.id); }, [rejectRequest]); + const handleOpenModal = useCallback((assignment: EmployeeAssignment) => { + setSearchParams({ requestId: assignment.id }); + }, [setSearchParams]); + + const handleCloseModal = useCallback(() => { + searchParams.delete('requestId'); + setSearchParams(searchParams, { replace: true }); + }, [searchParams, setSearchParams]); + const columns = useMemo( - () => getColumns({ onVerify: setSelectedAssignment, onReject: handleReject }), - [handleReject] + () => getColumns({ onVerify: handleOpenModal, onReject: handleReject }), + [handleOpenModal, handleReject] ); - const handleCloseModal = () => setSelectedAssignment(null); - return ( { > - !open && handleCloseModal()}> - + !open && handleCloseModal()}> + Szczegóły weryfikacji diff --git a/frontend/src/pages/NotificationPage.tsx b/frontend/src/pages/NotificationPage.tsx new file mode 100644 index 00000000..686c023f --- /dev/null +++ b/frontend/src/pages/NotificationPage.tsx @@ -0,0 +1,9 @@ +import { NotificationFeed } from '@/features/notification/components/NotificationFeed.tsx'; + +export const NotificationPage = () => { + return ( +
+ +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/QualificationRequestsPage.tsx b/frontend/src/pages/QualificationRequestsPage.tsx index 702c2e6f..87d65b0b 100644 --- a/frontend/src/pages/QualificationRequestsPage.tsx +++ b/frontend/src/pages/QualificationRequestsPage.tsx @@ -5,17 +5,31 @@ import { QualificationRequestDetails } from "@/features/qualifications/component import { getColumns } from "@/features/qualifications/qualifications.columns"; import { useWaitingQualificationsSummaryQuery } from "@/features/qualifications/qualifications.hooks"; import type { QualificationRequestResponse } from "@/features/qualifications/qualifications.types"; -import { useMemo, useState } from "react"; +import {useCallback, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; export const QualificationRequestsPage = () => { const { waitingQualificationsSummary, isLoading } = useWaitingQualificationsSummaryQuery(); - const [selectedRequest, setselectedRequest] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + const userIdFromUrl = searchParams.get('userId'); - const columns = useMemo(() => getColumns({ - onVerify: (user) => setselectedRequest(user) - }), []); + const selectedRequest = useMemo(() => { + if (!userIdFromUrl || !waitingQualificationsSummary) return null; + return waitingQualificationsSummary.find(req => req.userId === userIdFromUrl) || null; + }, [userIdFromUrl, waitingQualificationsSummary]); + + const handleOpenModal = useCallback((user: QualificationRequestResponse) => { + setSearchParams({ userId: user.userId }); + }, [setSearchParams]); - const handleCloseModal = () => setselectedRequest(null); + const handleCloseModal = useCallback(() => { + searchParams.delete('userId'); + setSearchParams(searchParams, { replace: true }); + }, [searchParams, setSearchParams]); + + const columns = useMemo(() => getColumns({ + onVerify: handleOpenModal + }), [handleOpenModal]); return ( { } /> } /> } /> + } /> {/* LINEAR MANAGER ROUTES */} }> diff --git a/frontend/src/routes/paths.ts b/frontend/src/routes/paths.ts index c81f7345..b7e14c17 100644 --- a/frontend/src/routes/paths.ts +++ b/frontend/src/routes/paths.ts @@ -5,7 +5,9 @@ export const ROUTE_PARAMS = { export const QUERY_PARAMS = { ACTIVATION_TOKEN: 'token', - EMAIL: 'email' + EMAIL: 'email', + REQUEST_ID: 'requestId', + USER_ID: 'userId' } as const; export const PATHS = { @@ -19,5 +21,6 @@ export const PATHS = { CREATE_PROJECT: `/create-project`, PROJECT: (id: string) => `project/${id}`, QUALIFICATION_REQUESTS: '/qualification-requests', - CREATE_PROJECT_GROUP: `/create-project-group` + CREATE_PROJECT_GROUP: `/create-project-group`, + NOTIFICATION: `notifications` } as const; \ No newline at end of file