diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/notification/NotificationResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/notification/NotificationResponse.java new file mode 100644 index 00000000..c0e5e6af --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/notification/NotificationResponse.java @@ -0,0 +1,27 @@ +package pl.edu.agh.project_manager.controller.dto.notification; + +import pl.edu.agh.project_manager.domain.entity.notification.Notification; +import pl.edu.agh.project_manager.domain.enums.NotificationType; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record NotificationResponse( + UUID id, + NotificationType type, + String message, + boolean isRead, + UUID referenceId, + LocalDateTime createdAt +) { + public static NotificationResponse from(Notification notification) { + return new NotificationResponse( + notification.getId(), + notification.getType(), + notification.getMessage(), + notification.isRead(), + notification.getReferenceId(), + notification.getCreatedAt() + ); + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..6f32a483 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java @@ -0,0 +1,58 @@ +package pl.edu.agh.project_manager.controller.notification; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +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 pl.edu.agh.project_manager.controller.dto.PagedResponse; +import pl.edu.agh.project_manager.controller.dto.notification.NotificationResponse; +import pl.edu.agh.project_manager.security.UserPrincipal; +import pl.edu.agh.project_manager.service.notification.NotificationService; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping + public ResponseEntity> getUserNotifications( + @RequestParam(defaultValue = "true") boolean unreadOnly, + @PageableDefault(size = 20) Pageable pageable, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + return ResponseEntity.ok( + notificationService.getNotificationsForUser( + userPrincipal.userId(), + pageable.getPageNumber(), + pageable.getPageSize(), + unreadOnly + ) + ); + } + + @PatchMapping("/{notificationId}/mark-as-read") + @PreAuthorize("@notificationAccess.canMarkAsRead(#notificationId, authentication.principal)") + public ResponseEntity markAsRead( + @PathVariable UUID notificationId, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + notificationService.markAsRead(notificationId, userPrincipal.userId()); + return ResponseEntity.noContent().build(); + } + + @PatchMapping("/mark-all-as-read") + public ResponseEntity markAllAsRead( + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + notificationService.markAllAsRead(userPrincipal.userId()); + return ResponseEntity.noContent().build(); + } + +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/notification/Notification.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/notification/Notification.java new file mode 100644 index 00000000..987df093 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/notification/Notification.java @@ -0,0 +1,49 @@ +package pl.edu.agh.project_manager.domain.entity.notification; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.NotificationType; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "notifications", indexes = { + @Index(name = "idx_notification_recipient_created", columnList = "recipient_id, created_at") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "recipient_id", nullable = false) + private User recipient; + + @Enumerated(EnumType.STRING) + @Column(name = "type", length = 50, nullable = false) + private NotificationType type; + + @Column(name = "message", length = 255, nullable = false) + private String message; + + @Column(name = "is_read", nullable = false) + @Builder.Default + private boolean isRead = false; + + @Column(name = "reference_id") + private UUID referenceId; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/user/User.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/user/User.java index c4f21694..e1dda77c 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/user/User.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/user/User.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import lombok.*; +import pl.edu.agh.project_manager.domain.entity.notification.Notification; import pl.edu.agh.project_manager.domain.entity.project.Project; import pl.edu.agh.project_manager.domain.entity.projectgroup.ProjectGroup; import pl.edu.agh.project_manager.domain.enums.UserRole; @@ -67,8 +68,16 @@ public class User { @Builder.Default private List qualifications = new ArrayList<>(); + @OneToMany(mappedBy = "recipient", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List notifications = new ArrayList<>(); + public void addQualification(Qualification qualification) { qualifications.add(qualification); qualification.setUser(this); } + + public String getFullName() { + return String.format("%s %s", this.name, this.surname); + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/enums/NotificationType.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/enums/NotificationType.java new file mode 100644 index 00000000..bcfb8d0f --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/enums/NotificationType.java @@ -0,0 +1,15 @@ +package pl.edu.agh.project_manager.domain.enums; + +public enum NotificationType { + + ASSIGNMENT_REQUESTED, + ASSIGNMENT_ACCEPTED, + ASSIGNMENT_REJECTED, + + QUALIFICATION_REQUESTED, + QUALIFICATION_ACCEPTED, + QUALIFICATION_REJECTED, + + SYSTEM_NEW_EMPLOYEE + +} \ No newline at end of file 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 new file mode 100644 index 00000000..741850df --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java @@ -0,0 +1,23 @@ +package pl.edu.agh.project_manager.domain.event; + +import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.NotificationType; + +import java.util.UUID; + +public record AssignmentAcceptedEvent( + UUID assignmentId, + User recipient, String + message +) implements NotificationEvent { + + @Override + public UUID referenceId() { + return assignmentId; + } + + @Override + public NotificationType type() { + return NotificationType.ASSIGNMENT_ACCEPTED; + } +} \ 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 new file mode 100644 index 00000000..97eccd2b --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java @@ -0,0 +1,23 @@ +package pl.edu.agh.project_manager.domain.event; + +import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.NotificationType; + +import java.util.UUID; + +public record AssignmentRejectedEvent( + UUID assignmentId, + User recipient, + String message +) implements NotificationEvent { + + @Override + public UUID referenceId() { + return assignmentId; + } + + @Override + public NotificationType type() { + return NotificationType.ASSIGNMENT_REJECTED; + } +} \ 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 new file mode 100644 index 00000000..0ecfd997 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java @@ -0,0 +1,23 @@ +package pl.edu.agh.project_manager.domain.event; + +import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.NotificationType; + +import java.util.UUID; + +public record AssignmentRequestedEvent( + UUID assignmentId, + User recipient, + String message +) implements NotificationEvent { + + @Override + public UUID referenceId() { + return assignmentId; + } + + @Override + public NotificationType type() { + return NotificationType.ASSIGNMENT_REQUESTED; + } +} \ 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 new file mode 100644 index 00000000..958ef54d --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java @@ -0,0 +1,13 @@ +package pl.edu.agh.project_manager.domain.event; + +import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.NotificationType; + +import java.util.UUID; + +public interface NotificationEvent { + User recipient(); + String message(); + UUID referenceId(); + NotificationType type(); +} \ 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 new file mode 100644 index 00000000..ddb54a78 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java @@ -0,0 +1,23 @@ +package pl.edu.agh.project_manager.domain.event; + +import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.NotificationType; + +import java.util.UUID; + +public record QualificationAcceptedEvent( + UUID qualificationId, + User recipient, + String message +) implements NotificationEvent { + + @Override + public UUID referenceId() { + return qualificationId; + } + + @Override + public NotificationType type() { + return NotificationType.QUALIFICATION_ACCEPTED; + } +} \ 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 new file mode 100644 index 00000000..5b5ee14e --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java @@ -0,0 +1,23 @@ +package pl.edu.agh.project_manager.domain.event; + +import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.NotificationType; + +import java.util.UUID; + +public record QualificationRejectedEvent( + UUID qualificationId, + User recipient, + String message +) implements NotificationEvent { + + @Override + public UUID referenceId() { + return qualificationId; + } + + @Override + public NotificationType type() { + return NotificationType.QUALIFICATION_REJECTED; + } +} \ 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 new file mode 100644 index 00000000..e0530085 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java @@ -0,0 +1,23 @@ +package pl.edu.agh.project_manager.domain.event; + +import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.NotificationType; + +import java.util.UUID; + +public record QualificationRequestedEvent( + UUID qualificationId, + User recipient, + String message +) implements NotificationEvent { + + @Override + public UUID referenceId() { + return qualificationId; + } + + @Override + public NotificationType type() { + return NotificationType.QUALIFICATION_REQUESTED; + } +} \ 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 new file mode 100644 index 00000000..3bc8f816 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java @@ -0,0 +1,23 @@ +package pl.edu.agh.project_manager.domain.event; + +import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.NotificationType; + +import java.util.UUID; + +public record SystemNewEmployeeEvent( + UUID employeeId, + User recipient, + String message +) implements NotificationEvent { + + @Override + public UUID referenceId() { + return employeeId; + } + + @Override + public NotificationType type() { + return NotificationType.SYSTEM_NEW_EMPLOYEE; + } +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/exception/ApiErrorCode.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/exception/ApiErrorCode.java index b5aa4cbd..22d6412a 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/exception/ApiErrorCode.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/exception/ApiErrorCode.java @@ -32,6 +32,8 @@ public enum ApiErrorCode { QUALIFICATION_NOT_FOUND("QUAL_001", HttpStatus.NOT_FOUND, "Qualification not found"), + NOTIFICATION_NOT_FOUND("NOTIF_001", HttpStatus.NOT_FOUND, "Notification not found"), + ROLE_NOT_IN_PROJECT("REQ_001", HttpStatus.BAD_REQUEST, "Role must exist within the provided project"), USER_ALREADY_IN_PROJECT("REQ_002", HttpStatus.CONFLICT, "User is already a member of this project"), USER_HAS_ONGOING_REQUEST("REQ_003", HttpStatus.CONFLICT, "User already has a pending request for this project"), 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 new file mode 100644 index 00000000..0d4282db --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java @@ -0,0 +1,25 @@ +package pl.edu.agh.project_manager.repository.notification; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import pl.edu.agh.project_manager.domain.entity.notification.Notification; + +import java.util.List; +import java.util.UUID; + +public interface NotificationRepository extends JpaRepository { + + boolean existsByIdAndRecipientId(UUID notificationId, UUID recipientId); + + Page findAllByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable); + + Page findAllByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(UUID recipientId, Pageable pageable); + + @Modifying + @Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.id = :userId AND n.isRead = false") + void markAllAsReadByUserId(@Param("userId") UUID userId); +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/security/access/NotificationAccess.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/security/access/NotificationAccess.java new file mode 100644 index 00000000..1fe8f85f --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/security/access/NotificationAccess.java @@ -0,0 +1,25 @@ +package pl.edu.agh.project_manager.security.access; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import pl.edu.agh.project_manager.repository.notification.NotificationRepository; +import pl.edu.agh.project_manager.security.UserPrincipal; + +import java.util.UUID; + +@Component("notificationAccess") +@RequiredArgsConstructor +public class NotificationAccess { + + private final NotificationRepository notificationRepository; + + public boolean canMarkAsRead(UUID notificationId, UserPrincipal currentUser) { + if (currentUser == null) { + return false; + } + + UUID userId = currentUser.userId(); + + return notificationRepository.existsByIdAndRecipientId(notificationId, userId); + } +} 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 new file mode 100644 index 00000000..d0385e83 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java @@ -0,0 +1,76 @@ +package pl.edu.agh.project_manager.service.notification; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; +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.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.event.*; +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.notification.NotificationRepository; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationRepository notificationRepository; + + @TransactionalEventListener + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void onNotificationEvent(NotificationEvent event) { + create(event.recipient(), event.type(), event.message(), event.referenceId()); + } + + private void create(User recipient, NotificationType type, String message, UUID referenceId) { + Notification notification = Notification.builder() + .recipient(recipient) + .type(type) + .message(message) + .referenceId(referenceId) + .build(); + notificationRepository.save(notification); + } + + @Transactional(readOnly = true) + public PagedResponse getNotificationsForUser(UUID userId, int page, int size, boolean unreadOnly) { + Pageable pageable = PageRequest.of(page, size); + + Page notificationPage; + + if (unreadOnly) { + notificationPage = notificationRepository.findAllByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(userId, pageable); + } else { + notificationPage = notificationRepository.findAllByRecipientIdOrderByCreatedAtDesc(userId, pageable); + } + + return PagedResponse.from(notificationPage, NotificationResponse::from); + } + + @Transactional + public void markAsRead(UUID notificationId, UUID userId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new ApplicationException( + ApiErrorCode.NOTIFICATION_NOT_FOUND, + "Cannot find notification: " + notificationId) + ); + + notification.setRead(true); + } + + @Transactional + public void markAllAsRead(UUID userId) { + notificationRepository.markAllAsReadByUserId(userId); + } +} \ No newline at end of file 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 3a6a0f40..18e1e038 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 @@ -1,6 +1,7 @@ package pl.edu.agh.project_manager.service.project; 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.project.AssignmentResponse; @@ -8,6 +9,9 @@ import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.entity.project.ProjectAssignment; import pl.edu.agh.project_manager.domain.enums.AssignmentStatus; +import pl.edu.agh.project_manager.domain.event.AssignmentAcceptedEvent; +import pl.edu.agh.project_manager.domain.event.AssignmentRejectedEvent; +import pl.edu.agh.project_manager.domain.event.AssignmentRequestedEvent; 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.project.ProjectAssignmentRepository; @@ -25,6 +29,7 @@ public class ProjectAssignmentService { private final ProjectService projectService; private final UserService userService; private final ProjectAssignmentRepository assignmentRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional public AssignmentResponse createAssignment(UUID projectId, AssignmentCommand command) { @@ -45,6 +50,14 @@ public AssignmentResponse createAssignment(UUID projectId, AssignmentCommand com ProjectAssignment savedAssignment = assignmentRepository.save(assignment); + if (user.getSupervisor() != null) { + eventPublisher.publishEvent(new AssignmentRequestedEvent( + savedAssignment.getId(), + user.getSupervisor(), + "Kierownik projektu " + project.getTitle() + " prosi o alokację pracownika " + user.getFullName() + )); + } + return AssignmentResponse.from(savedAssignment); } @@ -108,6 +121,12 @@ public void acceptAssignment(UUID assignmentId) { if (!isAlreadyMember) { project.addMember(user); } + + eventPublisher.publishEvent(new AssignmentAcceptedEvent( + assignment.getId(), + project.getProjectManager(), + "Zaakceptowano przypisanie pracownika " + user.getFullName() + " do projektu " + project.getTitle() + )); } @Transactional @@ -117,6 +136,12 @@ public void rejectAssignment(UUID assignmentId) { validatePendingStatus(assignment); assignment.setStatus(AssignmentStatus.REJECTED); + + eventPublisher.publishEvent(new AssignmentRejectedEvent( + assignment.getId(), + assignment.getProject().getProjectManager(), + "Odrzucono przypisanie pracownika " + assignment.getUser().getName() + " do projektu " + assignment.getProject().getTitle() + )); } public ProjectAssignment getAssignmentOrThrow(UUID assignmentId) { 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 f523ec1f..db8dc756 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 @@ -1,6 +1,7 @@ package pl.edu.agh.project_manager.service.user; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -10,6 +11,7 @@ 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.QualificationRequestedEvent; 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; @@ -26,6 +28,7 @@ public class QualificationService { private final QualificationRepository qualificationRepository; private final SkillRepository skillRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional public List addQualificationsToUser(UUID userId, List skillNames, List skillIds) { @@ -67,7 +70,18 @@ private Qualification saveQualification(User user, Skill skill) { .status(QualificationStatus.WAITING) .build(); user.addQualification(qualification); - return qualificationRepository.save(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; } public List searchSkills(String query) { 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 7018586c..16ee3f33 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 @@ -2,14 +2,15 @@ import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; 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.infrastructure.email.ConsoleEmailSender; import pl.edu.agh.project_manager.service.command.invitation.AdminInviteUserCommand; import pl.edu.agh.project_manager.repository.user.ActivationTokenRepository; import pl.edu.agh.project_manager.repository.user.UserRepository; @@ -26,6 +27,7 @@ public class UserInvitationService { private final UserRepository userRepository; private final ActivationTokenRepository tokenRepository; private final EmailSender emailSender; + private final ApplicationEventPublisher eventPublisher; @Transactional public void inviteUser(AdminInviteUserCommand command) { @@ -47,6 +49,14 @@ 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/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 new file mode 100644 index 00000000..765fd4cf --- /dev/null +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java @@ -0,0 +1,177 @@ +package pl.edu.agh.project_manager.service.notification; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +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.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.event.AssignmentRequestedEvent; +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.notification.NotificationRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private NotificationRepository notificationRepository; + + @InjectMocks + private NotificationService notificationService; + + @Test + void shouldCreateAndSaveNotificationWhenNotificationEventIsPublished() { + // Given + 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); + + ArgumentCaptor notificationCaptor = ArgumentCaptor.forClass(Notification.class); + + // When + notificationService.onNotificationEvent(event); + + // Then + verify(notificationRepository).save(notificationCaptor.capture()); + Notification savedNotification = notificationCaptor.getValue(); + + assertEquals(recipient, savedNotification.getRecipient()); + assertEquals(NotificationType.ASSIGNMENT_REQUESTED, savedNotification.getType()); + assertEquals(message, savedNotification.getMessage()); + assertEquals(assignmentId, savedNotification.getReferenceId()); + assertFalse(savedNotification.isRead()); + } + + @Test + void shouldReturnPagedNotificationsForUserWhenUnreadOnlyIsFalse() { + // Given + UUID userId = UUID.randomUUID(); + int page = 0; + int size = 10; + Pageable expectedPageable = PageRequest.of(page, size); + + Notification notification = Notification.builder() + .id(UUID.randomUUID()) + .type(NotificationType.SYSTEM_NEW_EMPLOYEE) + .message("Test All") + .isRead(true) + .createdAt(LocalDateTime.now()) + .build(); + + Page notificationPage = new PageImpl<>(List.of(notification), expectedPageable, 1); + + when(notificationRepository.findAllByRecipientIdOrderByCreatedAtDesc(userId, expectedPageable)) + .thenReturn(notificationPage); + + // When + PagedResponse result = notificationService.getNotificationsForUser(userId, page, size, false); + + // Then + assertEquals(1, result.items().size()); + assertEquals(notification.getId(), result.items().get(0).id()); + assertEquals("Test All", result.items().get(0).message()); + assertEquals(1, result.totalCount()); + assertEquals(0, result.pageNumber()); + } + + @Test + void shouldReturnPagedNotificationsForUserWhenUnreadOnlyIsTrue() { + // Given + UUID userId = UUID.randomUUID(); + int page = 0; + int size = 10; + Pageable expectedPageable = PageRequest.of(page, size); + + Notification notification = Notification.builder() + .id(UUID.randomUUID()) + .type(NotificationType.ASSIGNMENT_REQUESTED) + .message("Test Unread") + .isRead(false) + .createdAt(LocalDateTime.now()) + .build(); + + Page notificationPage = new PageImpl<>(List.of(notification), expectedPageable, 1); + + when(notificationRepository.findAllByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(userId, expectedPageable)) + .thenReturn(notificationPage); + + // When + PagedResponse result = notificationService.getNotificationsForUser(userId, page, size, true); + + // Then + assertEquals(1, result.items().size()); + assertEquals(notification.getId(), result.items().get(0).id()); + assertEquals("Test Unread", result.items().get(0).message()); + assertFalse(result.items().get(0).isRead()); + assertEquals(1, result.totalPages()); + } + + @Test + void shouldMarkNotificationAsRead() { + // Given + UUID notificationId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + Notification notification = new Notification(); + notification.setId(notificationId); + notification.setRead(false); + + when(notificationRepository.findById(notificationId)).thenReturn(Optional.of(notification)); + + // When + notificationService.markAsRead(notificationId, userId); + + // Then + assertTrue(notification.isRead()); + } + + @Test + void shouldThrowExceptionWhenMarkingNonExistentNotificationAsRead() { + // Given + UUID notificationId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + when(notificationRepository.findById(notificationId)).thenReturn(Optional.empty()); + + // When & Then + ApplicationException exception = assertThrows( + ApplicationException.class, + () -> notificationService.markAsRead(notificationId, userId) + ); + + assertEquals(ApiErrorCode.NOTIFICATION_NOT_FOUND, exception.getErrorCode()); + } + + @Test + void shouldMarkAllAsRead() { + // Given + UUID userId = UUID.randomUUID(); + + // When + notificationService.markAllAsRead(userId); + + // Then + verify(notificationRepository).markAllAsReadByUserId(userId); + } +} \ No newline at end of file