diff --git a/build.gradle b/build.gradle index fbbe5b2..da46c99 100644 --- a/build.gradle +++ b/build.gradle @@ -103,6 +103,9 @@ dependencies { // Reactive 웹 프레임워크 WebFlux implementation 'org.springframework.boot:spring-boot-starter-webflux' + // Email + implementation 'org.springframework.boot:spring-boot-starter-mail' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java b/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java index 7eae685..69773b8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java @@ -1,6 +1,6 @@ package com.teamEWSN.gitdeun.codereference.entity; -import com.teamEWSN.gitdeun.mindmapnode.entity.MindmapNode; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -17,8 +17,8 @@ public class CodeReference { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "node_id", nullable = false) - private MindmapNode node; + @JoinColumn(name = "mindmap_id", nullable = false) + private Mindmap mindmap; @Column(name = "file_path", columnDefinition = "TEXT", nullable = false) private String filePath; diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java index ab58460..afd47e2 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java @@ -1,7 +1,7 @@ package com.teamEWSN.gitdeun.codereview.entity; import com.teamEWSN.gitdeun.common.util.AuditedEntity; -import com.teamEWSN.gitdeun.mindmapnode.entity.MindmapNode; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import com.teamEWSN.gitdeun.user.entity.User; import jakarta.persistence.*; import lombok.AccessLevel; @@ -23,9 +23,9 @@ public class CodeReview extends AuditedEntity { @JoinColumn(name = "author_id", nullable = false) private User author; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "node_id", nullable = false) - private MindmapNode node; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mindmap_id", nullable = false) + private Mindmap mindmap; @Column(name = "ref_id") private Long refId; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 7a7feff..8a775c9 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -51,7 +51,11 @@ public enum ErrorCode { INVITATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "INVITE-003", "이미 초대 대기 중인 사용자입니다."), CANNOT_INVITE_SELF(HttpStatus.BAD_REQUEST, "INVITE-004", "자기 자신을 초대할 수 없습니다."), INVITATION_REJECTED_USER(HttpStatus.FORBIDDEN, "INVITE-005", "초대를 거절한 사용자이므로 초대할 수 없습니다."), - + + // 알림 관련 + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION-001", "알림을 찾을 수 없습니다."), + CANNOT_ACCESS_NOTIFICATION(HttpStatus.FORBIDDEN, "NOTIFICATION-002", "해당 알림에 접근할 권한이 없습니다."), + // 방문기록 관련 HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "VISITHISTORY-001", "방문 기록을 찾을 수 없습니다."), @@ -59,6 +63,7 @@ public enum ErrorCode { USER_NOT_FOUND_FIX_PIN(HttpStatus.NOT_FOUND, "PINNEDHISTORY-001", "핀 고정한 유저를 찾을 수 없습니다."), PINNEDHISTORY_ALREADY_EXISTS(HttpStatus.CONFLICT, "PINNEDHISTORY-002", "이미 핀 고정한 기록입니다."), PINNEDHISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "PINNEDHISTORY-003", "핀 고정 기록을 찾을 수 없습니다."), + PINNED_HISTORY_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "PINNEDHISTORY-004", "최대 8개까지만 핀으로 고정할 수 있습니다."), // S3 파일 관련 // Client Errors (4xx) diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java b/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java index 9c231e4..b2e9d46 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java @@ -16,6 +16,7 @@ import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; import com.teamEWSN.gitdeun.mindmapmember.repository.MindmapMemberRepository; import com.teamEWSN.gitdeun.mindmapmember.service.MindmapAuthService; +import com.teamEWSN.gitdeun.notification.service.NotificationService; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -36,7 +37,7 @@ public class InvitationService { private final MindmapRepository mindmapRepository; private final MindmapMemberRepository mindmapMemberRepository; private final MindmapAuthService mindmapAuthService; - // private final NotificationService notificationService; + private final NotificationService notificationService; private final InvitationMapper invitationMapper; private static final String INVITATION_BASE_URL = "http://localhost:8080/invitations/"; @@ -90,7 +91,7 @@ public void inviteUserByEmail(Long mapId, InviteRequestDto requestDto, Long invi invitationRepository.save(invitation); // 알림 전송 + 이메일 전송 - // notificationService.sendInvitation(invitation); + notificationService.notifyInvitation(invitation); } // 초대한 목록 조회(member) @@ -125,7 +126,7 @@ public void acceptInvitation(Long invitationId, Long userId) { MindmapMember newMember = MindmapMember.of(newInvitation.getMindmap(), newInvitation.getInvitee(), newInvitation.getRole()); mindmapMemberRepository.save(newMember); - // notificationService.sendAcceptance(invitation); + notificationService.notifyAcceptance(invitation); } // 초대 거절 @@ -144,7 +145,6 @@ public void rejectInvitation(Long invitationId, Long userId) { } invitation.reject(); - // notificationService.sendRejection(invitation); } // 초대 링크 생성(owner) @@ -197,7 +197,7 @@ public void acceptInvitationByLink(String token, Long userId) { .build(); invitationRepository.save(updatedInvitation); - // notificationService.sendLinkApprovalRequest(invitation); + notificationService.notifyLinkApprovalRequest(invitation); } // 초대 링크 수락(owner) diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java index 7f58fe4..f928722 100644 --- a/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java @@ -1,7 +1,7 @@ package com.teamEWSN.gitdeun.meeting.entity; import com.teamEWSN.gitdeun.common.util.CreatedEntity; -import com.teamEWSN.gitdeun.mindmapnode.entity.MindmapNode; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -21,8 +21,8 @@ public class Meeting extends CreatedEntity { private Long id; @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "node_id", nullable = false) - private MindmapNode node; + @JoinColumn(name = "mindmap_id", nullable = false) + private Mindmap mindmap; @Column(name = "room_name", length = 255, nullable = false) private String roomName; diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/controller/MindmapEdgeController.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/controller/MindmapEdgeController.java deleted file mode 100644 index f9cc934..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/controller/MindmapEdgeController.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.mindmapedge.controller; - -public class MindmapEdgeController { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/dto/MindmapEdgeDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/dto/MindmapEdgeDto.java deleted file mode 100644 index f7b9ab7..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/dto/MindmapEdgeDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.mindmapedge.dto; - -public class MindmapEdgeDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/EdgeType.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/EdgeType.java deleted file mode 100644 index 0807276..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/EdgeType.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.teamEWSN.gitdeun.mindmapedge.entity; - -public enum EdgeType { - CROSS, - PARENT, - CHILD -} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java deleted file mode 100644 index 8b96998..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.teamEWSN.gitdeun.mindmapedge.entity; - -import com.teamEWSN.gitdeun.common.util.AuditedEntity; -import com.teamEWSN.gitdeun.mindmapnode.entity.MindmapNode; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.ColumnDefault; - -import java.math.BigDecimal; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "mindmap_edge") -public class MindmapEdge extends AuditedEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "from_node_id", nullable = false) - private MindmapNode fromNode; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "to_node_id", nullable = false) - private MindmapNode toNode; - - @Enumerated(EnumType.STRING) - @Column(name = "type", nullable = false) - private EdgeType type; - - @Column(nullable = false) - @ColumnDefault("0") - private BigDecimal strength; - - @Column(name = "arango_key") - private Long arangoKey; - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/repository/MindmapEdgeRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/repository/MindmapEdgeRepository.java deleted file mode 100644 index 73abaf4..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/repository/MindmapEdgeRepository.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.mindmapedge.repository; - -public class MindmapEdgeRepository { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/service/MindmapEdgeService.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/service/MindmapEdgeService.java deleted file mode 100644 index ab345d5..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/service/MindmapEdgeService.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.mindmapedge.service; - -public class MindmapEdgeService { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/controller/MindmapNodeController.java b/src/main/java/com/teamEWSN/gitdeun/mindmapnode/controller/MindmapNodeController.java deleted file mode 100644 index 7082a2d..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/controller/MindmapNodeController.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.mindmapnode.controller; - -public class MindmapNodeController { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/dto/MindmapNodeDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmapnode/dto/MindmapNodeDto.java deleted file mode 100644 index ce12c4b..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/dto/MindmapNodeDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.mindmapnode.dto; - -public class MindmapNodeDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java b/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java deleted file mode 100644 index 73a946c..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.teamEWSN.gitdeun.mindmapnode.entity; - -import com.teamEWSN.gitdeun.common.util.AuditedEntity; -import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.ColumnDefault; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "mindmap_node") -public class MindmapNode extends AuditedEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "mindmap_id", nullable = false) - private Mindmap mindmap; - - @Column(length = 100, nullable = false) - private String label; - - @Column(columnDefinition = "TEXT", nullable = false) - private String path; - - @Column(nullable = false) - @ColumnDefault("1") - private Integer depth; - - @Column(name = "arango_key", length = 64) - private String arangoKey; - - @Column(name = "Importance", nullable = false) - @ColumnDefault("0") - private Double importance; -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/repository/MindmapNodeRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmapnode/repository/MindmapNodeRepository.java deleted file mode 100644 index b2092a5..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/repository/MindmapNodeRepository.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.mindmapnode.repository; - -public class MindmapNodeRepository { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/service/MindmapNodeService.java b/src/main/java/com/teamEWSN/gitdeun/mindmapnode/service/MindmapNodeService.java deleted file mode 100644 index c7eba60..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/service/MindmapNodeService.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.mindmapnode.service; - -public class MindmapNodeService { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationController.java b/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationController.java new file mode 100644 index 0000000..0920563 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationController.java @@ -0,0 +1,56 @@ +package com.teamEWSN.gitdeun.notification.controller; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.notification.dto.NotificationResponseDto; +import com.teamEWSN.gitdeun.notification.dto.UnreadNotificationCountDto; +import com.teamEWSN.gitdeun.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + // 현재 사용자의 모든 알림 조회 + @GetMapping + public ResponseEntity> getMyNotifications( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PageableDefault(size = 10, sort = "createdAt,desc") Pageable pageable) { + Page notifications = notificationService.getNotifications(userDetails.getId(), pageable); + return ResponseEntity.ok(notifications); + } + + // 읽지 않은 알림 개수 조회 + @GetMapping("/unread-count") + public ResponseEntity getUnreadNotificationCount( + @AuthenticationPrincipal CustomUserDetails userDetails) { + UnreadNotificationCountDto countDto = notificationService.getUnreadNotificationCount(userDetails.getId()); + return ResponseEntity.ok(countDto); + } + + // 알림 읽음 처리 + @PatchMapping("/{notificationId}/read") + public ResponseEntity markNotificationAsRead( + @PathVariable Long notificationId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + notificationService.markAsRead(notificationId, userDetails.getId()); + return ResponseEntity.noContent().build(); + } + + // 알림 삭제 + @DeleteMapping("/{notificationId}") + public ResponseEntity deleteNotification( + @PathVariable Long notificationId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + notificationService.deleteNotification(notificationId, userDetails.getId()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationSseController.java b/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationSseController.java new file mode 100644 index 0000000..e0aaa2c --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationSseController.java @@ -0,0 +1,25 @@ +package com.teamEWSN.gitdeun.notification.controller; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.notification.service.NotificationSseService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationSseController { + + private final NotificationSseService notificationSseService; + + // 클라이언트의 알림 구독 + @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter subscribe(@AuthenticationPrincipal CustomUserDetails userDetails) { + return notificationSseService.subscribe(userDetails.getId()); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationResponseDto.java new file mode 100644 index 0000000..c447c83 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationResponseDto.java @@ -0,0 +1,17 @@ +package com.teamEWSN.gitdeun.notification.dto; + +import com.teamEWSN.gitdeun.notification.entity.NotificationType; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class NotificationResponseDto { + private Long notificationId; + private String message; + private boolean read; + private NotificationType notificationType; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/dto/UnreadNotificationCountDto.java b/src/main/java/com/teamEWSN/gitdeun/notification/dto/UnreadNotificationCountDto.java new file mode 100644 index 0000000..d1bf6d0 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/notification/dto/UnreadNotificationCountDto.java @@ -0,0 +1,10 @@ +package com.teamEWSN.gitdeun.notification.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UnreadNotificationCountDto { + private int unreadCount; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java b/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java index 1a1edcd..bf92f71 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java @@ -1,4 +1,53 @@ package com.teamEWSN.gitdeun.notification.entity; -public class Notification { +import com.teamEWSN.gitdeun.common.util.CreatedEntity; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "notification", indexes = { + // 사용자별 알림을 최신순으로 조회하는 쿼리 최적화용 인덱스 + @Index(name = "idx_notification_user_created_at", columnList = "user_id, createdAt DESC"), + // 사용자의 읽지 않은 알림을 조회하는 쿼리 최적화용 인덱스 + @Index(name = "idx_notification_user_read", columnList = "user_id, read") +}) +public class Notification extends CreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; // 알림을 받는 사용자 + + @Column(nullable = false, columnDefinition = "TEXT") + private String message; // 알림 메시지 내용 + + @Column(nullable = false) + private boolean read; // 읽음 여부 (기본값: false) + + @Enumerated(EnumType.STRING) + @Column(name = "notification_type", nullable = false) + private NotificationType notificationType; // 알림 종류 + + @Builder + public Notification(User user, String message, NotificationType notificationType) { + this.user = user; + this.message = message; + this.notificationType = notificationType; + this.read = false; + } + + // 읽음 처리 + public void markAsRead() { + this.read = true; + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java b/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java new file mode 100644 index 0000000..bf5bbc9 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java @@ -0,0 +1,7 @@ +package com.teamEWSN.gitdeun.notification.entity; + +public enum NotificationType { + INVITE_MINDMAP, // 마인드맵 초대 + MENTION_COMMENT, // 댓글에서 맨션 + SYSTEM_UPDATE; // 시스템 업데이트(webhook) +} diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/mapper/NotificationMapper.java b/src/main/java/com/teamEWSN/gitdeun/notification/mapper/NotificationMapper.java new file mode 100644 index 0000000..04390c8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/notification/mapper/NotificationMapper.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.notification.mapper; + +import com.teamEWSN.gitdeun.notification.dto.NotificationResponseDto; +import com.teamEWSN.gitdeun.notification.entity.Notification; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface NotificationMapper { + + @Mapping(source = "id", target = "notificationId") + NotificationResponseDto toResponseDto(Notification notification); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/repository/NotificationRepository.java b/src/main/java/com/teamEWSN/gitdeun/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..bfd820f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/notification/repository/NotificationRepository.java @@ -0,0 +1,23 @@ +package com.teamEWSN.gitdeun.notification.repository; + +import com.teamEWSN.gitdeun.notification.entity.Notification; +import com.teamEWSN.gitdeun.user.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface NotificationRepository extends JpaRepository { + + // 사용자의 모든 알림을 최신순으로 페이징하여 조회 + Page findByUserOrderByCreatedAtDesc(User user, Pageable pageable); + + // 사용자의 읽지 않은 알림 개수 조회 + int countByUserAndReadFalse(User user); + + // 특정 알림이 해당 사용자의 소유인지 확인하며 조회 + Optional findByIdAndUser(Long id, User user); +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java new file mode 100644 index 0000000..3253907 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java @@ -0,0 +1,170 @@ +package com.teamEWSN.gitdeun.notification.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.invitation.entity.Invitation; +import com.teamEWSN.gitdeun.notification.dto.NotificationResponseDto; +import com.teamEWSN.gitdeun.notification.dto.UnreadNotificationCountDto; +import com.teamEWSN.gitdeun.notification.entity.Notification; +import com.teamEWSN.gitdeun.notification.entity.NotificationType; +import com.teamEWSN.gitdeun.notification.mapper.NotificationMapper; +import com.teamEWSN.gitdeun.notification.repository.NotificationRepository; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationSseService notificationSseService; + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + private final NotificationMapper notificationMapper; + private final JavaMailSender mailSender; + + /** + * 이메일 초대 알림 + */ + @Transactional + public void notifyInvitation(Invitation invitation) { + User invitee = invitation.getInvitee(); + String message = String.format("'%s'님이 '%s' 마인드맵으로 초대했습니다.", + invitation.getInviter().getName(), + invitation.getMindmap().getField()); + createAndSendNotification(invitee, NotificationType.INVITE_MINDMAP, message); + } + + /** + * 초대 수락 알림 (초대한 사람에게 전송) + */ + @Transactional + public void notifyAcceptance(Invitation invitation) { + User inviter = invitation.getInviter(); + String message = String.format("'%s'님이 '%s' 마인드맵 초대를 수락했습니다.", + invitation.getInvitee().getName(), + invitation.getMindmap().getField()); + createAndSendNotification(inviter, NotificationType.INVITE_MINDMAP, message); + } + + /** + * 링크 초대 승인 요청 알림 (마인드맵 소유자에게 전송) + */ + @Transactional + public void notifyLinkApprovalRequest(Invitation invitation) { + User owner = invitation.getMindmap().getUser(); + String message = String.format("'%s'님이 링크를 통해 '%s' 마인드맵 참여를 요청했습니다.", + invitation.getInvitee().getName(), + invitation.getMindmap().getField()); + createAndSendNotification(owner, NotificationType.INVITE_MINDMAP, message); + } + + + /** + * 알림 생성 및 발송 (다른 서비스에서 호출) + */ + @Transactional + public void createAndSendNotification(User user, NotificationType type, String message) { + Notification notification = Notification.builder() + .user(user) + .notificationType(type) + .message(message) + .build(); + notificationRepository.save(notification); + + // 이메일 발송 (비동기 처리) + sendEmailNotification(user.getEmail(), "[Gitdeun] 새로운 알림이 도착했습니다.", message); + + int unreadCount = notificationRepository.countByUserAndReadFalse(user); + notificationSseService.sendUnreadCount(user.getId(), unreadCount); + } + + /** + * 사용자의 모든 알림 조회 (페이징, 최신순) + */ + @Transactional(readOnly = true) + public Page getNotifications(Long userId, Pageable pageable) { + User user = getUserById(userId); + Page notifications = notificationRepository.findByUserOrderByCreatedAtDesc(user, pageable); + return notifications.map(notificationMapper::toResponseDto); + } + + /** + * 읽지 않은 알림 개수 조회 + */ + @Transactional(readOnly = true) + public UnreadNotificationCountDto getUnreadNotificationCount(Long userId) { + User user = getUserById(userId); + int count = notificationRepository.countByUserAndReadFalse(user); + return new UnreadNotificationCountDto(count); + } + + /** + * 알림 읽음 처리 + */ + @Transactional + public void markAsRead(Long notificationId, Long userId) { + User user = getUserById(userId); + Notification notification = notificationRepository.findByIdAndUser(notificationId, user) + .orElseThrow(() -> new GlobalException(ErrorCode.NOTIFICATION_NOT_FOUND)); + + // 아직 읽지 않은 알림일 경우에만 처리 + if (!notification.isRead()) { + notification.markAsRead(); + + // 읽음 처리 후, 변경된 '읽지 않은 알림 개수'를 실시간으로 전송 + int unreadCount = notificationRepository.countByUserAndReadFalse(user); + notificationSseService.sendUnreadCount(user.getId(), unreadCount); + } + } + + /** + * 알림 삭제 + */ + @Transactional + public void deleteNotification(Long notificationId, Long userId) { + User user = getUserById(userId); + Notification notification = notificationRepository.findByIdAndUser(notificationId, user) + .orElseThrow(() -> new GlobalException(ErrorCode.NOTIFICATION_NOT_FOUND)); + + boolean wasUnread = !notification.isRead(); + + notificationRepository.delete(notification); + + // 만약 삭제된 알림이 '읽지 않은' 상태인 경우, 개수 조정 후전송 + if (wasUnread) { + int unreadCount = notificationRepository.countByUserAndReadFalse(user); + notificationSseService.sendUnreadCount(user.getId(), unreadCount); + } + } + + // 사용자 조회 편의 메서드 + private User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + } + + // 이메일 발송 비동기 처리 + @Async + public void sendEmailNotification(String to, String subject, String text) { + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject(subject); + message.setText(text); + mailSender.send(message); + log.info("Email sent to {}", to); + } catch (Exception e) { + log.error("Failed to send email to {}", to, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java new file mode 100644 index 0000000..937d2a9 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java @@ -0,0 +1,55 @@ +package com.teamEWSN.gitdeun.notification.service; + +import com.teamEWSN.gitdeun.notification.dto.UnreadNotificationCountDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Service +public class NotificationSseService { + + // 스레드 안전한 자료구조를 사용하여 사용자별 Emitter를 관리 (Key: userId, Value: SseEmitter) + private final Map emitters = new ConcurrentHashMap<>(); + + // 클라이언트가 구독을 요청할 때 호출 + public SseEmitter subscribe(Long userId) { + SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); // 타임아웃을 매우 길게 설정 + emitters.put(userId, emitter); + + // 연결 종료 시 Emitter 제거 + emitter.onCompletion(() -> emitters.remove(userId)); + emitter.onTimeout(() -> emitters.remove(userId)); + emitter.onError(e -> emitters.remove(userId)); + + + // 연결 성공을 알리는 더미 이벤트 전송 + try { + emitter.send(SseEmitter.event().name("connect").data("Connected to notification stream.")); + } catch (IOException e) { + log.error("SSE 연결 중 오류 발생, userId={}", userId, e); + } + + return emitter; + } + + // 특정 사용자에게 읽지 않은 알림 개수 전송 + public void sendUnreadCount(Long userId, int count) { + SseEmitter emitter = emitters.get(userId); + if (emitter != null) { + try { + emitter.send(SseEmitter.event() + .name("unread-count") // 이벤트 이름 지정 + .data(new UnreadNotificationCountDto(count))); // 데이터 전송 + } catch (IOException e) { + log.error("SSE 데이터 전송 중 오류 발생, userId={}", userId, e); + // 오류 발생 시 해당 Emitter 제거 + emitters.remove(userId); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java index 260ee09..164625e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java @@ -5,12 +5,16 @@ import com.teamEWSN.gitdeun.visithistory.service.VisitHistoryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; + @Slf4j @RestController @RequestMapping("/api/history") @@ -21,10 +25,11 @@ public class VisitHistoryController { // 핀 고정되지 않은 방문 기록 조회 @GetMapping("/visits") - public ResponseEntity> getVisitHistories( - @AuthenticationPrincipal CustomUserDetails userDetails + public ResponseEntity> getVisitHistories( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PageableDefault(size = 10, sort = "lastVisitedAt,desc") Pageable pageable ) { - List histories = visitHistoryService.getVisitHistories(userDetails.getId()); + Page histories = visitHistoryService.getVisitHistories(userDetails.getId(), pageable); return ResponseEntity.ok(histories); } diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java index ff47d84..e105e87 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java @@ -2,6 +2,8 @@ import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.visithistory.entity.PinnedHistory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -13,8 +15,10 @@ public interface PinnedHistoryRepository extends JpaRepository findByUserIdAndVisitHistoryId(Long userId, Long historyId); // 사용자의 핀 고정 기록 최신순 조회 - List findByUserOrderByCreatedAtDesc(User user); + List findTop8ByUserOrderByCreatedAtDesc(User user); } diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java index ee928c6..803814e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java @@ -2,6 +2,8 @@ import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.visithistory.entity.VisitHistory; +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.Query; import org.springframework.data.repository.query.Param; @@ -16,5 +18,5 @@ public interface VisitHistoryRepository extends JpaRepository findUnpinnedHistoriesByUser(@Param("user") User user); + Page findUnpinnedHistoriesByUser(@Param("user") User user, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java index 69c9df7..5f0bc2f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java @@ -29,6 +29,12 @@ public void fixPinned(Long historyId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new GlobalException(USER_NOT_FOUND_FIX_PIN)); + // 현재 사용자의 핀 개수를 확인 + long currentPinCount = pinnedHistoryRepository.countByUser(user); + if (currentPinCount >= 8) { + throw new GlobalException(PINNED_HISTORY_LIMIT_EXCEEDED); + } + VisitHistory visitHistory = visitHistoryRepository.findById(historyId) .orElseThrow(() -> new GlobalException(HISTORY_NOT_FOUND)); diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java index 30a914f..f56c69d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java @@ -12,6 +12,8 @@ import com.teamEWSN.gitdeun.visithistory.repository.PinnedHistoryRepository; import com.teamEWSN.gitdeun.visithistory.repository.VisitHistoryRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -41,19 +43,20 @@ public void createVisitHistory(User user, Mindmap mindmap) { // 핀 고정되지 않은 방문 기록 조회 @Transactional(readOnly = true) - public List getVisitHistories(Long userId) { + public Page getVisitHistories(Long userId, Pageable pageable) { User user = userService.findById(userId); - List histories = visitHistoryRepository.findUnpinnedHistoriesByUser(user); - return histories.stream() - .map(visitHistoryMapper::toResponseDto) - .collect(Collectors.toList()); + Page histories = visitHistoryRepository.findUnpinnedHistoriesByUser(user, pageable); + return histories.map(visitHistoryMapper::toResponseDto); } - // 핀 고정된 방문 기록 조회 + // 핀 고정된 방문 기록 조회(8개 제한) @Transactional(readOnly = true) public List getPinnedHistories(Long userId) { User user = userService.findById(userId); - List pinnedHistories = pinnedHistoryRepository.findByUserOrderByCreatedAtDesc(user); + // 핀 고정 횟수에 제한이 있지만, 명시적으로 상위 8개만 조회 + List pinnedHistories = pinnedHistoryRepository.findTop8ByUserOrderByCreatedAtDesc(user); + + // List를 스트림으로 변환하여 매핑 return pinnedHistories.stream() .map(pinned -> visitHistoryMapper.toResponseDto(pinned.getVisitHistory())) .collect(Collectors.toList()); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fff3e2f..c62d9e7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,6 +35,17 @@ spring: scope: openid, email, profile github: scope: user:email, repo + mail: + host: smtp.gmail.com + port: 587 + username: ${GMAIL_USERNAME} # 실제 Gmail 계정 + password: ${GMAIL_APP_PASSWORD} # Gmail 앱 비밀번호 + properties: + mail: + smtp: + auth: true + starttls: + enable: true profiles: active: dev, s3Bucket # logback-spring SpringProfile 설정 및 AWS S3 Bucket 설정