diff --git a/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java b/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java index c04feac2..e0c036a5 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java @@ -71,10 +71,12 @@ public ResponseEntity> deleteFeedback( @GetMapping("/videos/{videoId}/feedbacks") public ResponseEntity> getFeedbackList( @PathVariable Long videoId, + @AuthenticationPrincipal Long userId, + @RequestParam(required = false) Long guestId, @RequestParam(required = false) String cursor, @RequestParam(required = false) Integer size ) { - FeedbackListResDTO result = feedbackService.getFeedbackList(videoId, cursor, size); + FeedbackListResDTO result = feedbackService.getFeedbackList(videoId, userId, guestId, cursor, size); return ResponseEntity .ok(ApiResponse.success(CommonSuccessCode.OK, result)); diff --git a/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java b/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java index dbb5abba..278620ef 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java @@ -19,7 +19,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Reply", description = "답글 API") +@Tag(name = "Feedback Reply", description = "피드백 답글 API") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -45,10 +45,12 @@ public ResponseEntity> createReply( @GetMapping("/feedbacks/{feedbackId}/replies") public ResponseEntity> getReplyList( @PathVariable Long feedbackId, + @AuthenticationPrincipal Long userId, + @RequestParam(required = false) Long guestId, @RequestParam(required = false) Long cursor, @RequestParam(required = false) Integer size ) { - ReplyListResDTO result = feedbackDetailService.getReplyList(feedbackId, cursor, size); + ReplyListResDTO result = feedbackDetailService.getReplyList(feedbackId, userId, guestId, cursor, size); return ResponseEntity .ok(ApiResponse.success(CommonSuccessCode.OK, result)); diff --git a/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java b/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java index 7d812c72..396e7a1b 100644 --- a/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java +++ b/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java @@ -17,6 +17,8 @@ import com.slatto.domain.feedback.repository.FeedbackDetailRepository; import com.slatto.domain.feedback.repository.FeedbackRepository; import com.slatto.domain.sharelink.entity.Guest; +import com.slatto.domain.sharelink.entity.ShareLink; +import com.slatto.domain.sharelink.exception.ShareLinkErrorCode; import com.slatto.domain.sharelink.repository.GuestRepository; import com.slatto.domain.user.entity.Users; import com.slatto.domain.user.repository.UserRepository; @@ -59,8 +61,8 @@ public ReplyCreateResDTO createReply(Long feedbackId, Long userId, ReplyCreateRe user = userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); } else { - guest = guestRepository.findById(req.guestId()) - .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); + // 게스트: 원 피드백의 영상에 접근할 자격이 있는지 검증 후 Guest 확보 + guest = validateGuestAccess(req.guestId(), feedback.getVideo().getId()); } // 4. 저장 @@ -78,32 +80,62 @@ private void validateWriter(Long userId, Long guestId) { } } + // 게스트가 해당 영상에 접근할 자격이 있는지 검증하고, 검증된 Guest를 반환 + // Guest → ShareLink → Video 체인으로 소유 여부 확인 + private Guest validateGuestAccess(Long guestId, Long videoId) { + Guest guest = guestRepository.findById(guestId) + .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); + + ShareLink shareLink = guest.getShareLink(); + + // 1. 링크가 살아있는지 (활성 + 미만료) + if (!shareLink.isUsable()) { + throw new BaseException(ShareLinkErrorCode.SHARE_LINK_UNAVAILABLE); + } + + // 2. 게스트의 링크 영상 == 요청 영상인지 + if (!shareLink.getVideo().getId().equals(videoId)) { + throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED); + } + + return guest; + } + @Transactional(readOnly = true) - public ReplyListResDTO getReplyList(Long feedbackId, Long cursor, Integer size) { + public ReplyListResDTO getReplyList(Long feedbackId, Long userId, Long guestId, Long cursor, Integer size) { // 1. 원 피드백 존재 확인 - feedbackRepository.findById(feedbackId) + Feedback feedback = feedbackRepository.findById(feedbackId) .filter(f -> f.getDeletedAt() == null) .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); - // 2. size 기본값 + 상한 처리 + // 2. 게스트가 조회하는 경우 원 피드백의 영상에 접근 자격이 있는지 검증 + // 회원이 아니면 guestId 필수 — 익명(둘 다 null) 조회 차단 + if (userId == null) { + if (guestId == null) { + throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED); + } + validateGuestAccess(guestId, feedback.getVideo().getId()); + } + + // 3. size 기본값 + 상한 처리 int pageSize = (size == null || size <= 0) ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); Pageable pageable = PageRequest.of(0, pageSize + 1); // hasNext 판단용 +1 - // 3. 조회 + // 4. 조회 List replies = (cursor == null) ? feedbackDetailRepository.findFirstPage(feedbackId, pageable) : feedbackDetailRepository.findNextPage(feedbackId, cursor, pageable); - // 4. hasNext 판단 + 초과분 제거 + // 5. hasNext 판단 + 초과분 제거 boolean hasNext = replies.size() > pageSize; if (hasNext) { replies = replies.subList(0, pageSize); } - // 5. nextCursor + // 6. nextCursor Long nextCursor = (hasNext && !replies.isEmpty()) ? replies.getLast().getId() : null; @@ -122,15 +154,20 @@ public ReplyUpdateResDTO updateReply(Long replyId, Long userId, ReplyUpdateReqDT // 2. 작성자 검증 validateWriter(userId, req.guestId()); - // 3. 본인 확인 + // 3. 게스트면 이 답글의 영상에 접근 자격이 있는지 검증 (답글 → 피드백 → 영상) + if (userId == null) { + validateGuestAccess(req.guestId(), reply.getFeedback().getVideo().getId()); + } + + // 4. 본인 확인 if (!reply.isWriter(userId, req.guestId())) { throw new BaseException(CommonErrorCode.FORBIDDEN); } - // 4. 수정 (더티 체킹) + // 5. 수정 (더티 체킹) reply.update(req.content()); - // 5. updatedAt 갱신 반영 + // 6. updatedAt 갱신 반영 feedbackDetailRepository.flush(); return feedbackDetailConverter.toUpdateResponse(reply); @@ -147,12 +184,17 @@ public void deleteReply(Long replyId, Long userId, Long guestId) { // 2. 작성자 검증 validateWriter(userId, guestId); - // 3. 본인 확인 + // 3. 게스트면 이 답글의 영상에 접근 자격이 있는지 검증 (답글 → 피드백 → 영상) + if (userId == null) { + validateGuestAccess(guestId, reply.getFeedback().getVideo().getId()); + } + + // 4. 본인 확인 if (!reply.isWriter(userId, guestId)) { throw new BaseException(CommonErrorCode.FORBIDDEN); } - // 4. soft delete (더티 체킹) + // 5. soft delete (더티 체킹) reply.softDelete(); } diff --git a/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java b/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java index 46c50be6..53278538 100644 --- a/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java +++ b/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java @@ -20,6 +20,8 @@ import com.slatto.domain.feedback.entity.Feedback; import com.slatto.domain.feedback.repository.FeedbackRepository; import com.slatto.domain.sharelink.entity.Guest; +import com.slatto.domain.sharelink.entity.ShareLink; +import com.slatto.domain.sharelink.exception.ShareLinkErrorCode; import com.slatto.domain.sharelink.repository.GuestRepository; import com.slatto.domain.user.entity.Users; import com.slatto.domain.user.repository.UserRepository; @@ -70,8 +72,8 @@ public FeedbackCreateResDTO createFeedback(Long videoId, Long userId, FeedbackCr user = userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); } else { - guest = guestRepository.findById(req.guestId()) - .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); + // 게스트: 이 영상에 접근할 자격이 있는지 검증 후 Guest 확보 + guest = validateGuestAccess(req.guestId(), videoId); } // 4. 저장 @@ -92,15 +94,20 @@ public FeedbackUpdateResDTO updateFeedback(Long feedbackId, Long userId, Feedbac // 2. 작성자 검증 validateWriter(userId, req.guestId()); - // 3. 본인 확인 + // 3. 게스트면 이 피드백의 영상에 접근 자격이 있는지 검증 + if (userId == null) { + validateGuestAccess(req.guestId(), feedback.getVideo().getId()); + } + + // 4. 본인 확인 if (!feedback.isWriter(userId, req.guestId())) { throw new BaseException(CommonErrorCode.FORBIDDEN); } - // 4. 수정 (status 전달 안 함 — 해결 상태는 전용 API에서만 변경) + // 5. 수정 (status 전달 안 함 — 해결 상태는 전용 API에서만 변경) feedback.update(req.content(), req.startTime(), req.endTime()); - // 5. updatedAt 갱신을 응답에 반영하기 위해 flush + // 6. updatedAt 갱신을 응답에 반영하기 위해 flush feedbackRepository.flush(); return feedbackConverter.toUpdateResponse(feedback); @@ -115,6 +122,27 @@ private void validateWriter(Long userId, Long guestId) { } } + // 게스트가 해당 영상에 접근할 자격이 있는지 검증하고, 검증된 Guest를 반환 + // Guest → ShareLink → Video 체인으로 소유 여부 확인 + private Guest validateGuestAccess(Long guestId, Long videoId) { + Guest guest = guestRepository.findById(guestId) + .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); + + ShareLink shareLink = guest.getShareLink(); + + // 1. 링크가 살아있는지 (활성 + 미만료) + if (!shareLink.isUsable()) { + throw new BaseException(ShareLinkErrorCode.SHARE_LINK_UNAVAILABLE); + } + + // 2. 게스트의 링크 영상 == 요청 영상인지 + if (!shareLink.getVideo().getId().equals(videoId)) { + throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED); + } + + return guest; + } + @Transactional public void deleteFeedback(Long feedbackId, Long userId, Long guestId) { @@ -126,17 +154,22 @@ public void deleteFeedback(Long feedbackId, Long userId, Long guestId) { // 2. 작성자 검증 validateWriter(userId, guestId); - // 3. 본인 확인 + // 3. 게스트면 이 피드백의 영상에 접근 자격이 있는지 검증 + if (userId == null) { + validateGuestAccess(guestId, feedback.getVideo().getId()); + } + + // 4. 본인 확인 if (!feedback.isWriter(userId, guestId)) { throw new BaseException(CommonErrorCode.FORBIDDEN); } - // 4. soft delete (더티 체킹으로 자동 반영) + // 5. soft delete (더티 체킹으로 자동 반영) feedback.softDelete(); } @Transactional(readOnly = true) - public FeedbackListResDTO getFeedbackList(Long videoId, String cursor, Integer size) { + public FeedbackListResDTO getFeedbackList(Long videoId, Long userId, Long guestId, String cursor, Integer size) { // 1. 영상 존재 확인 boolean videoExists = entityManagerProvider.getObject().createQuery(""" @@ -149,13 +182,22 @@ select count(v) from Video v where v.id = :videoId throw new BaseException(CommonErrorCode.NOT_FOUND); } - // 2. size 기본값 + 상한 처리 + // 2. 게스트가 조회하는 경우 이 영상에 접근 자격이 있는지 검증 + // 회원이 아니면 guestId 필수 — 익명(둘 다 null) 조회 차단 + if (userId == null) { + if (guestId == null) { + throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED); + } + validateGuestAccess(guestId, videoId); + } + + // 3. size 기본값 + 상한 처리 int pageSize = (size == null || size <= 0) ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); Pageable pageable = PageRequest.of(0, pageSize + 1); // hasNext 판단용으로 1개 더 - // 3. 커서에 따라 조회 + // 4. 커서에 따라 조회 List feedbacks; if (cursor == null || cursor.isBlank()) { @@ -180,13 +222,13 @@ select count(v) from Video v where v.id = :videoId } } - // 4. hasNext 판단 + 초과분 제거 + // 5. hasNext 판단 + 초과분 제거 boolean hasNext = feedbacks.size() > pageSize; if (hasNext) { feedbacks = feedbacks.subList(0, pageSize); } - // 5. nextCursor 조립 + // 6. nextCursor 조립 String nextCursor = null; if (hasNext && !feedbacks.isEmpty()) { Feedback last = feedbacks.getLast(); @@ -194,7 +236,7 @@ select count(v) from Video v where v.id = :videoId nextCursor = timePart + "_" + last.getId(); } - // 6. 답글 개수 한 번에 조회 + // 7. 답글 개수 한 번에 조회 Map replyCountMap = new HashMap<>(); if (!feedbacks.isEmpty()) { diff --git a/src/main/java/com/slatto/domain/notification/dto/NotificationCreateCommand.java b/src/main/java/com/slatto/domain/notification/dto/NotificationCreateCommand.java index 98b94b44..88b803c4 100644 --- a/src/main/java/com/slatto/domain/notification/dto/NotificationCreateCommand.java +++ b/src/main/java/com/slatto/domain/notification/dto/NotificationCreateCommand.java @@ -20,6 +20,9 @@ public class NotificationCreateCommand { /** 알림센터에서 구분할 알림 종류 */ private NotificationType type; + /** 알림센터에 표시할 제목 */ + private String title; + /** 알림센터에 표시할 문구 */ private String content; diff --git a/src/main/java/com/slatto/domain/notification/dto/NotificationListResponse.java b/src/main/java/com/slatto/domain/notification/dto/NotificationListResponse.java index a9c8eab1..f29e02c8 100644 --- a/src/main/java/com/slatto/domain/notification/dto/NotificationListResponse.java +++ b/src/main/java/com/slatto/domain/notification/dto/NotificationListResponse.java @@ -27,8 +27,12 @@ public static class NotificationSummary { private NotificationType type; + private String title; + private String content; + private Integer groupCount; + private String targetType; private Long targetId; diff --git a/src/main/java/com/slatto/domain/notification/entity/ActivityLog.java b/src/main/java/com/slatto/domain/notification/entity/ActivityLog.java index 39ba5b6f..e5e12da2 100644 --- a/src/main/java/com/slatto/domain/notification/entity/ActivityLog.java +++ b/src/main/java/com/slatto/domain/notification/entity/ActivityLog.java @@ -3,7 +3,7 @@ import com.slatto.domain.common.entity.BaseEntity; import com.slatto.domain.project.entity.Project; import com.slatto.domain.notification.enums.ActorType; -import com.slatto.domain.user.enums.RoleName; +import com.slatto.domain.notification.enums.ActivityLogType; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -36,7 +36,7 @@ public class ActivityLog extends BaseEntity { @Enumerated(EnumType.STRING) @Column(name = "type", nullable = false) - private RoleName type; + private ActivityLogType type; @Column(name = "content", nullable = false, length = 500) private String content; @@ -49,4 +49,48 @@ public class ActivityLog extends BaseEntity { @Column(name = "group_key", nullable = true, length = 255) private String groupKey; -} \ No newline at end of file + + private ActivityLog( + Project project, + Long actorUserId, + Long actorGuestId, + ActorType actorType, + ActivityLogType type, + String content, + String targetType, + Long targetId, + String groupKey + ) { + this.project = project; + this.actorUserId = actorUserId; + this.actorGuestId = actorGuestId; + this.actorType = actorType; + this.type = type; + this.content = content; + this.targetType = targetType; + this.targetId = targetId; + this.groupKey = groupKey; + } + + public static ActivityLog create( + Project project, + Long actorUserId, + ActorType actorType, + ActivityLogType type, + String content, + String targetType, + Long targetId + ) { + return new ActivityLog( + project, + actorUserId, + null, + actorType, + type, + content, + targetType, + targetId, + null + ); + } +} diff --git a/src/main/java/com/slatto/domain/notification/entity/Notification.java b/src/main/java/com/slatto/domain/notification/entity/Notification.java index 171760a5..a0704408 100644 --- a/src/main/java/com/slatto/domain/notification/entity/Notification.java +++ b/src/main/java/com/slatto/domain/notification/entity/Notification.java @@ -18,6 +18,7 @@ public class Notification extends BaseEntity { private static final boolean DEFAULT_READ_STATUS = false; + private static final int DEFAULT_GROUP_COUNT = 1; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -36,6 +37,9 @@ public class Notification extends BaseEntity { @Column(name = "type", nullable = false) private NotificationType type; + @Column(name = "title", nullable = true, length = 255) + private String title; + @Column(name = "content", nullable = false, length = 500) private String content; @@ -45,6 +49,10 @@ public class Notification extends BaseEntity { @Column(name = "target_id", nullable = true) private Long targetId; + // 그룹핑 알림에서 누적된 이벤트 개수를 저장한다. 일반 알림은 기본값 1을 사용한다. + @Column(name = "group_count", nullable = false) + private Integer groupCount = DEFAULT_GROUP_COUNT; + @Column(name = "is_read", nullable = false) private Boolean isRead = false; @@ -58,6 +66,7 @@ public static Notification create( Users user, Project project, NotificationType type, + String title, String content, String targetType, Long targetId @@ -66,9 +75,11 @@ public static Notification create( notification.user = user; notification.project = project; notification.type = type; + notification.title = title; notification.content = content; notification.targetType = targetType; notification.targetId = targetId; + notification.groupCount = DEFAULT_GROUP_COUNT; notification.isRead = DEFAULT_READ_STATUS; return notification; @@ -82,4 +93,10 @@ public void markAsRead() { this.isRead = true; this.readAt = LocalDateTime.now(); } + + public void updateGroupedNotification(String title, String content) { + this.title = title; + this.content = content; + this.groupCount += 1; + } } diff --git a/src/main/java/com/slatto/domain/notification/enums/ActivityLogTargetType.java b/src/main/java/com/slatto/domain/notification/enums/ActivityLogTargetType.java new file mode 100644 index 00000000..06b7b333 --- /dev/null +++ b/src/main/java/com/slatto/domain/notification/enums/ActivityLogTargetType.java @@ -0,0 +1,9 @@ +package com.slatto.domain.notification.enums; + +public enum ActivityLogTargetType { + PROJECT, + SCHEDULE, + NOTICE, + FILE, + VIDEO +} diff --git a/src/main/java/com/slatto/domain/notification/enums/ActivityLogType.java b/src/main/java/com/slatto/domain/notification/enums/ActivityLogType.java new file mode 100644 index 00000000..d2a13977 --- /dev/null +++ b/src/main/java/com/slatto/domain/notification/enums/ActivityLogType.java @@ -0,0 +1,12 @@ +package com.slatto.domain.notification.enums; + +public enum ActivityLogType { + PROJECT_MEMBER_JOINED, + PROJECT_STATUS_CHANGED, + PROJECT_UPDATED, + SCHEDULE_CREATED, + SCHEDULE_UPDATED, + NOTICE_CREATED, + FILE_UPLOADED, + VIDEO_FEEDBACK_COMMENTED +} diff --git a/src/main/java/com/slatto/domain/notification/repository/ActivityLogRepository.java b/src/main/java/com/slatto/domain/notification/repository/ActivityLogRepository.java new file mode 100644 index 00000000..d869cc9e --- /dev/null +++ b/src/main/java/com/slatto/domain/notification/repository/ActivityLogRepository.java @@ -0,0 +1,7 @@ +package com.slatto.domain.notification.repository; + +import com.slatto.domain.notification.entity.ActivityLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActivityLogRepository extends JpaRepository { +} diff --git a/src/main/java/com/slatto/domain/notification/repository/NotificationRepository.java b/src/main/java/com/slatto/domain/notification/repository/NotificationRepository.java index 71cedd47..b2bf22b4 100644 --- a/src/main/java/com/slatto/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/slatto/domain/notification/repository/NotificationRepository.java @@ -2,8 +2,10 @@ import com.slatto.domain.notification.entity.Notification; import com.slatto.domain.notification.enums.NotificationType; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -68,25 +70,43 @@ int markAsReadByIdAndUserId( @Param("readAt") LocalDateTime readAt ); - @Modifying(clearAutomatically = true, flushAutomatically = true) + // 동일 대상의 미읽음 그룹 알림을 잠금 조회해 누적 개수와 문구를 같은 엔티티 상태 기준으로 갱신한다. + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(""" - update Notification n - set n.content = :content, - n.updatedAt = :updatedAt + select n + from Notification n where n.user.id = :userId and n.type = :type and n.targetType = :targetType and n.targetId = :targetId and n.isRead = false and n.deletedAt is null + order by n.updatedAt desc, n.id desc """) - int updateUnreadGroupedNotificationContent( + List findUnreadGroupedNotificationsForUpdate( @Param("userId") Long userId, @Param("type") NotificationType type, @Param("targetType") String targetType, @Param("targetId") Long targetId, - @Param("content") String content, - @Param("updatedAt") LocalDateTime updatedAt + Pageable pageable + ); + + // 미읽음 그룹 알림이 없으면 누적 개수는 0이다. + @Query(""" + select coalesce(max(n.groupCount), 0) + from Notification n + where n.user.id = :userId + and n.type = :type + and n.targetType = :targetType + and n.targetId = :targetId + and n.isRead = false + and n.deletedAt is null + """) + int findUnreadGroupedNotificationCount( + @Param("userId") Long userId, + @Param("type") NotificationType type, + @Param("targetType") String targetType, + @Param("targetId") Long targetId ); @Modifying(clearAutomatically = true, flushAutomatically = true) diff --git a/src/main/java/com/slatto/domain/notification/service/ActivityLogService.java b/src/main/java/com/slatto/domain/notification/service/ActivityLogService.java new file mode 100644 index 00000000..7d32e772 --- /dev/null +++ b/src/main/java/com/slatto/domain/notification/service/ActivityLogService.java @@ -0,0 +1,289 @@ +package com.slatto.domain.notification.service; + +import com.slatto.domain.notification.entity.ActivityLog; +import com.slatto.domain.notification.enums.ActivityLogTargetType; +import com.slatto.domain.notification.enums.ActivityLogType; +import com.slatto.domain.notification.enums.ActorType; +import com.slatto.domain.notification.repository.ActivityLogRepository; +import com.slatto.domain.project.entity.Project; +import com.slatto.domain.project.repository.ProjectRepository; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.repository.UserRepository; +import com.slatto.global.exception.BaseException; +import com.slatto.global.response.code.CommonErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ActivityLogService { + + private final ActivityLogRepository activityLogRepository; + private final ProjectRepository projectRepository; + private final UserRepository userRepository; + + /** + * 프로젝트에 새 참여자가 합류했을 때 최근활동을 저장한다. + */ + @Transactional + public void createProjectMemberJoinedLog(Long projectId, Long actorUserId) { + Users actor = getActiveUser(actorUserId); + + createUserActivityLog( + projectId, + actor, + ActivityLogType.PROJECT_MEMBER_JOINED, + createProjectMemberJoinedContent(actor.getNickname()), + ActivityLogTargetType.PROJECT, + projectId + ); + } + + /** + * 프로젝트 진행 단계가 변경되었을 때 최근활동을 저장한다. + */ + @Transactional + public void createProjectStatusChangedLog( + Long projectId, + Long actorUserId, + String previousStatus, + String changedStatus + ) { + validateRequiredText(previousStatus); + validateRequiredText(changedStatus); + Users actor = getActiveUser(actorUserId); + + createUserActivityLog( + projectId, + actor, + ActivityLogType.PROJECT_STATUS_CHANGED, + createProjectStatusChangedContent(actor.getNickname(), previousStatus, changedStatus), + ActivityLogTargetType.PROJECT, + projectId + ); + } + + /** + * 프로젝트 기본 정보가 수정되었을 때 최근활동을 저장한다. + */ + @Transactional + public void createProjectUpdatedLog(Long projectId, Long actorUserId) { + Users actor = getActiveUser(actorUserId); + + createUserActivityLog( + projectId, + actor, + ActivityLogType.PROJECT_UPDATED, + createProjectUpdatedContent(actor.getNickname()), + ActivityLogTargetType.PROJECT, + projectId + ); + } + + /** + * 프로젝트 일정이 등록되었을 때 최근활동을 저장한다. + */ + @Transactional + public void createScheduleCreatedLog( + Long projectId, + Long actorUserId, + Long scheduleId, + String scheduleTitle + ) { + validateRequiredId(scheduleId); + validateRequiredText(scheduleTitle); + Users actor = getActiveUser(actorUserId); + + createUserActivityLog( + projectId, + actor, + ActivityLogType.SCHEDULE_CREATED, + createScheduleCreatedContent(actor.getNickname(), scheduleTitle), + ActivityLogTargetType.SCHEDULE, + scheduleId + ); + } + + /** + * 프로젝트 일정이 수정되었을 때 최근활동을 저장한다. + */ + @Transactional + public void createScheduleUpdatedLog( + Long projectId, + Long actorUserId, + Long scheduleId, + String scheduleTitle + ) { + validateRequiredId(scheduleId); + validateRequiredText(scheduleTitle); + Users actor = getActiveUser(actorUserId); + + createUserActivityLog( + projectId, + actor, + ActivityLogType.SCHEDULE_UPDATED, + createScheduleUpdatedContent(actor.getNickname(), scheduleTitle), + ActivityLogTargetType.SCHEDULE, + scheduleId + ); + } + + /** + * 프로젝트 공지가 등록되었을 때 최근활동을 저장한다. + */ + @Transactional + public void createNoticeCreatedLog( + Long projectId, + Long actorUserId, + Long noticeId + ) { + validateRequiredId(noticeId); + Users actor = getActiveUser(actorUserId); + + createUserActivityLog( + projectId, + actor, + ActivityLogType.NOTICE_CREATED, + createNoticeCreatedContent(actor.getNickname()), + ActivityLogTargetType.NOTICE, + noticeId + ); + } + + /** + * 프로젝트 파일이 등록되었을 때 최근활동을 저장한다. + */ + @Transactional + public void createFileUploadedLog( + Long projectId, + Long actorUserId, + Long fileId, + String fileName + ) { + validateRequiredId(fileId); + validateRequiredText(fileName); + Users actor = getActiveUser(actorUserId); + + createUserActivityLog( + projectId, + actor, + ActivityLogType.FILE_UPLOADED, + createFileUploadedContent(actor.getNickname(), fileName), + ActivityLogTargetType.FILE, + fileId + ); + } + + /** + * 영상에 피드백 댓글이 등록되었을 때 최근활동을 저장한다. + */ + @Transactional + public void createVideoFeedbackCommentedLog( + Long projectId, + Long actorUserId, + Long videoId, + String videoTitle + ) { + validateRequiredId(videoId); + validateRequiredText(videoTitle); + Users actor = getActiveUser(actorUserId); + + createUserActivityLog( + projectId, + actor, + ActivityLogType.VIDEO_FEEDBACK_COMMENTED, + createVideoFeedbackCommentedContent(actor.getNickname(), videoTitle), + ActivityLogTargetType.VIDEO, + videoId + ); + } + + private void createUserActivityLog( + Long projectId, + Users actor, + ActivityLogType type, + String content, + ActivityLogTargetType targetType, + Long targetId + ) { + Project project = getActiveProject(projectId); + + activityLogRepository.save(ActivityLog.create( + project, + actor.getId(), + ActorType.USER, + type, + content, + getTargetTypeName(targetType), + targetId + )); + } + + private String createProjectMemberJoinedContent(String actorName) { + return actorName + "님이 프로젝트에 합류했어요"; + } + + private String createProjectStatusChangedContent( + String actorName, + String previousStatus, + String changedStatus + ) { + return actorName + "님이 프로젝트 단계를 '" + previousStatus + "'에서 '" + changedStatus + "'으로 변경했어요"; + } + + private String createProjectUpdatedContent(String actorName) { + return actorName + "님이 프로젝트 정보를 수정했어요"; + } + + private String createScheduleCreatedContent(String actorName, String scheduleTitle) { + return actorName + "님이 [" + scheduleTitle + "] 일정을 등록했어요"; + } + + private String createScheduleUpdatedContent(String actorName, String scheduleTitle) { + return actorName + "님이 [" + scheduleTitle + "] 일정을 수정했어요"; + } + + private String createNoticeCreatedContent(String actorName) { + return actorName + "님이 새 공지를 등록했어요"; + } + + private String createFileUploadedContent(String actorName, String fileName) { + return actorName + "님이 [" + fileName + "] 파일을 등록했어요"; + } + + private String createVideoFeedbackCommentedContent(String actorName, String videoTitle) { + return actorName + "님이 [" + videoTitle + "]에 피드백을 남겼어요"; + } + + private Project getActiveProject(Long projectId) { + validateRequiredId(projectId); + + return projectRepository.findByIdAndDeletedAtIsNull(projectId) + .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); + } + + private Users getActiveUser(Long userId) { + validateRequiredId(userId); + + return userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); + } + + private void validateRequiredId(Long id) { + if (id == null) { + throw new BaseException(CommonErrorCode.BAD_REQUEST); + } + } + + private void validateRequiredText(String text) { + if (text == null || text.isBlank()) { + throw new BaseException(CommonErrorCode.BAD_REQUEST); + } + } + + private String getTargetTypeName(ActivityLogTargetType targetType) { + return targetType != null ? targetType.name() : null; + } +} diff --git a/src/main/java/com/slatto/domain/notification/service/NotificationService.java b/src/main/java/com/slatto/domain/notification/service/NotificationService.java index e327cd1c..6badcc6c 100644 --- a/src/main/java/com/slatto/domain/notification/service/NotificationService.java +++ b/src/main/java/com/slatto/domain/notification/service/NotificationService.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.function.IntFunction; @Service @RequiredArgsConstructor @@ -99,6 +100,21 @@ public void markAllNotificationsAsRead(Long currentUserId) { notificationRepository.markAllAsReadByUserId(currentUserId, LocalDateTime.now()); } + /** + * 영상 카드의 미읽은 피드백 개수에 사용할 그룹 알림 누적 개수를 조회한다. + */ + public int getUnreadVideoFeedbackCount(Long currentUserId, Long videoId) { + validateActiveUser(currentUserId); + validateRequiredId(videoId); + + return notificationRepository.findUnreadGroupedNotificationCount( + currentUserId, + NotificationType.VIDEO_FEEDBACK_COMMENTED, + NotificationTargetType.VIDEO.name(), + videoId + ); + } + @Transactional public void createNotification(NotificationCreateCommand command) { createNotifications(command); @@ -124,6 +140,7 @@ public void createNotifications(NotificationCreateCommand command) { recipient, project, command.getType(), + command.getTitle(), command.getContent(), getTargetTypeName(command.getTargetType()), command.getTargetId() @@ -137,7 +154,7 @@ public void createNotifications(NotificationCreateCommand command) { /** * 동일 사용자, 알림 타입, targetType, targetId 기준으로 미읽음 알림을 그룹핑한다. - * 기존 미읽음 알림이 있으면 content만 갱신하고, 없으면 새 알림을 생성한다. + * 기존 미읽음 알림이 있으면 제목, 문구, 누적 개수를 갱신하고, 없으면 새 알림을 생성한다. */ @Transactional public void createOrUpdateGroupedNotifications(NotificationCreateCommand command) { @@ -173,6 +190,7 @@ public void createScheduleAssignedNotifications( .toList()) .projectId(project != null ? project.getId() : null) .type(NotificationType.SCHEDULE_ASSIGNED) + .title(createScheduleAssignedTitle(project != null ? project.getTitle() : null)) .content(createScheduleAssignedContent(scheduleTitle)) .targetType(NotificationTargetType.SCHEDULE) .targetId(scheduleId) @@ -183,7 +201,10 @@ public void createScheduleAssignedNotifications( /** * 프로젝트 초대 알림을 생성한다. * 클릭 대상은 프로젝트 상세 화면이다. + * + * @deprecated 알림 문구는 알림 도메인에서 관리하므로 projectTitle을 받는 메서드를 사용한다. */ + @Deprecated @Transactional public void createProjectInvitationNotification( Long recipientId, @@ -197,16 +218,46 @@ public void createProjectInvitationNotification( .recipientIds(List.of(recipientId)) .projectId(projectId) .type(NotificationType.PROJECT_INVITED) + .title(createFallbackTitle(NotificationType.PROJECT_INVITED)) .content(content) .targetType(NotificationTargetType.PROJECT) .targetId(projectId) .build()); } + /** + * 프로젝트 초대 알림 문구를 정책에 맞춰 생성한다. + */ + @Transactional + public void createProjectInvitationNotification( + Long recipientId, + Long projectId, + String projectTitle, + String inviterName + ) { + validateRequiredId(projectId); + validateRequiredId(recipientId); + validateRequiredText(projectTitle); + validateRequiredText(inviterName); + + createNotification(NotificationCreateCommand.builder() + .recipientIds(List.of(recipientId)) + .projectId(projectId) + .type(NotificationType.PROJECT_INVITED) + .title(createProjectInvitationTitle(projectTitle)) + .content(createProjectInvitationContent(projectTitle, inviterName)) + .targetType(NotificationTargetType.PROJECT) + .targetId(projectId) + .build()); + } + /** * 영상 피드백 댓글 알림을 생성하거나 기존 미읽음 알림을 갱신한다. * 동일 영상 기준으로 그룹핑하므로 targetId는 feedbackId가 아니라 videoId를 사용한다. + * + * @deprecated 알림 문구는 알림 도메인에서 관리하므로 videoTitle과 commenterName을 받는 메서드를 사용한다. */ + @Deprecated @Transactional public void createVideoFeedbackCommentedNotifications( Long projectId, @@ -222,6 +273,7 @@ public void createVideoFeedbackCommentedNotifications( .recipientIds(recipientIds) .projectId(projectId) .type(NotificationType.VIDEO_FEEDBACK_COMMENTED) + .title(createFallbackTitle(NotificationType.VIDEO_FEEDBACK_COMMENTED)) .content(content) .targetType(NotificationTargetType.VIDEO) .targetId(videoId) @@ -229,10 +281,48 @@ public void createVideoFeedbackCommentedNotifications( .build()); } + /** + * 영상 피드백 알림 문구를 정책에 맞춰 생성하고, 동일 영상 기준으로 그룹핑한다. + */ + @Transactional + public void createVideoFeedbackCommentedNotifications( + Long projectId, + Long videoId, + String videoTitle, + String commenterName, + List recipientIds, + Long actorUserId + ) { + validateRequiredId(projectId); + validateRequiredId(videoId); + validateRequiredText(videoTitle); + validateRequiredText(commenterName); + Project project = getProjectOrNull(projectId); + + NotificationCreateCommand command = NotificationCreateCommand.builder() + .recipientIds(recipientIds) + .projectId(projectId) + .type(NotificationType.VIDEO_FEEDBACK_COMMENTED) + .title(createVideoFeedbackCommentedTitle(getProjectTitle(project))) + .content(createVideoFeedbackCommentedContent(videoTitle, commenterName, 1)) + .targetType(NotificationTargetType.VIDEO) + .targetId(videoId) + .excludeUserId(actorUserId) + .build(); + + createOrUpdateGroupedNotifications( + command, + groupCount -> createVideoFeedbackCommentedContent(videoTitle, commenterName, groupCount) + ); + } + /** * 새로운 지원자 발생 알림을 생성하거나 기존 미읽음 알림을 갱신한다. * 동일 공고 기준으로 그룹핑하므로 targetId는 recruitmentId를 사용한다. + * + * @deprecated 알림 문구는 알림 도메인에서 관리하므로 recruitmentTitle과 applicantName을 받는 메서드를 사용한다. */ + @Deprecated @Transactional public void createRecruitmentAppliedNotification( Long recipientId, @@ -245,12 +335,43 @@ public void createRecruitmentAppliedNotification( createOrUpdateGroupedNotifications(NotificationCreateCommand.builder() .recipientIds(List.of(recipientId)) .type(NotificationType.RECRUITMENT_APPLIED) + .title(createFallbackTitle(NotificationType.RECRUITMENT_APPLIED)) .content(content) .targetType(NotificationTargetType.RECRUITMENT) .targetId(recruitmentId) .build()); } + /** + * 지원자 알림 문구를 정책에 맞춰 생성하고, 동일 공고 기준으로 그룹핑한다. + */ + @Transactional + public void createRecruitmentAppliedNotification( + Long recipientId, + Long recruitmentId, + String recruitmentTitle, + String applicantName + ) { + validateRequiredId(recipientId); + validateRequiredId(recruitmentId); + validateRequiredText(recruitmentTitle); + validateRequiredText(applicantName); + + NotificationCreateCommand command = NotificationCreateCommand.builder() + .recipientIds(List.of(recipientId)) + .type(NotificationType.RECRUITMENT_APPLIED) + .title(createRecruitmentAppliedTitle(recruitmentTitle)) + .content(createRecruitmentAppliedContent(recruitmentTitle, applicantName, 1)) + .targetType(NotificationTargetType.RECRUITMENT) + .targetId(recruitmentId) + .build(); + + createOrUpdateGroupedNotifications( + command, + groupCount -> createRecruitmentAppliedContent(recruitmentTitle, applicantName, groupCount) + ); + } + private void validateActiveUser(Long currentUserId) { if (!userRepository.existsByIdAndDeletedAtIsNull(currentUserId)) { throw new BaseException(CommonErrorCode.NOT_FOUND); @@ -310,10 +431,30 @@ private void validateRequiredId(Long id) { } } + private void validateRequiredText(String text) { + if (text == null || text.isBlank()) { + throw new BaseException(CommonErrorCode.BAD_REQUEST); + } + } + private void createOrUpdateGroupedNotification( Users recipient, Project project, NotificationCreateCommand command + ) { + createOrUpdateGroupedNotification( + recipient, + project, + command, + groupCount -> command.getContent() + ); + } + + private void createOrUpdateGroupedNotification( + Users recipient, + Project project, + NotificationCreateCommand command, + IntFunction contentFactory ) { String targetType = getTargetTypeName(command.getTargetType()); Object groupingLock = getGroupingLock( @@ -324,30 +465,52 @@ private void createOrUpdateGroupedNotification( ); synchronized (groupingLock) { - // 먼저 기존 미읽음 그룹 알림 갱신을 시도하고, 없을 때만 새 알림을 생성한다. - int updatedCount = notificationRepository.updateUnreadGroupedNotificationContent( + List existingNotifications = notificationRepository.findUnreadGroupedNotificationsForUpdate( recipient.getId(), command.getType(), targetType, command.getTargetId(), - command.getContent(), - LocalDateTime.now() + PageRequest.of(0, 1) ); - if (updatedCount > 0) { + + if (!existingNotifications.isEmpty()) { + Notification existingNotification = existingNotifications.get(0); + String content = contentFactory.apply(existingNotification.getGroupCount() + 1); + existingNotification.updateGroupedNotification(command.getTitle(), content); return; } + String content = contentFactory.apply(1); notificationRepository.save(Notification.create( recipient, project, command.getType(), - command.getContent(), + command.getTitle(), + content, targetType, command.getTargetId() )); } } + private void createOrUpdateGroupedNotifications( + NotificationCreateCommand command, + IntFunction contentFactory + ) { + validateCreateCommand(command); + validateGroupingCommand(command); + + Project project = getProjectOrNull(command.getProjectId()); + List recipientIds = getRecipientIds(command); + if (recipientIds.isEmpty()) { + return; + } + + recipientIds.stream() + .map(this::getActiveUser) + .forEach(recipient -> createOrUpdateGroupedNotification(recipient, project, command, contentFactory)); + } + private List getRecipientIds(NotificationCreateCommand command) { // 중복 수신자와 제외 대상 사용자를 정리해 실제 저장 대상만 남긴다. Set recipientIds = new HashSet<>(command.getRecipientIds()); @@ -381,7 +544,9 @@ private NotificationListResponse.NotificationSummary toSummary(Notification noti .notificationId(notification.getId()) .projectId(projectId) .type(notification.getType()) + .title(notification.getTitle()) .content(notification.getContent()) + .groupCount(notification.getGroupCount()) .targetType(notification.getTargetType()) .targetId(notification.getTargetId()) .isRead(notification.getIsRead()) @@ -394,6 +559,72 @@ private String createScheduleAssignedContent(String scheduleTitle) { return "'" + scheduleTitle + "' 일정 담당자로 지정되었습니다."; } + private String createScheduleAssignedTitle(String projectTitle) { + return joinTitle(projectTitle, "일정 담당자 지정"); + } + + private String createProjectInvitationTitle(String projectTitle) { + return joinTitle(projectTitle, "프로젝트 초대"); + } + + private String createProjectInvitationContent(String projectTitle, String inviterName) { + return inviterName + "님이 [" + projectTitle + "] 프로젝트에 초대했어요"; + } + + private String createVideoFeedbackCommentedTitle(String projectTitle) { + return joinTitle(projectTitle, "새로운 피드백"); + } + + private String createVideoFeedbackCommentedContent( + String videoTitle, + String commenterName, + int groupCount + ) { + if (groupCount <= 1) { + return commenterName + "님이 [" + videoTitle + "]에 새로운 피드백을 남겼어요"; + } + + return "[" + videoTitle + "]에 새로운 피드백 " + groupCount + "건이 등록되었어요"; + } + + private String createRecruitmentAppliedTitle(String recruitmentTitle) { + return joinTitle(recruitmentTitle, "새로운 지원자"); + } + + private String createRecruitmentAppliedContent( + String recruitmentTitle, + String applicantName, + int groupCount + ) { + if (groupCount <= 1) { + return applicantName + "님이 [" + recruitmentTitle + "]에 지원했어요"; + } + + return "[" + recruitmentTitle + "]에 새로운 지원자 " + groupCount + "명이 지원했어요"; + } + + private String createFallbackTitle(NotificationType type) { + return switch (type) { + case SCHEDULE_ASSIGNED -> "일정 담당자 지정"; + case PROJECT_INVITED -> "프로젝트 초대"; + case VIDEO_FEEDBACK_COMMENTED -> "새로운 피드백"; + case RECRUITMENT_APPLIED -> "새로운 지원자"; + case DEADLINE_REMINDER -> "마감 알림"; + }; + } + + private String joinTitle(String prefix, String suffix) { + if (prefix == null || prefix.isBlank()) { + return suffix; + } + + return prefix + " · " + suffix; + } + + private String getProjectTitle(Project project) { + return project != null ? project.getTitle() : null; + } + private String getTargetTypeName(NotificationTargetType targetType) { return targetType != null ? targetType.name() : null; } diff --git a/src/main/java/com/slatto/domain/sharelink/exception/ShareLinkErrorCode.java b/src/main/java/com/slatto/domain/sharelink/exception/ShareLinkErrorCode.java index bcfd0bb7..e6d637d9 100644 --- a/src/main/java/com/slatto/domain/sharelink/exception/ShareLinkErrorCode.java +++ b/src/main/java/com/slatto/domain/sharelink/exception/ShareLinkErrorCode.java @@ -12,7 +12,8 @@ public enum ShareLinkErrorCode implements BaseCode { SHARE_LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "SHARELINK404", "공유 링크를 찾을 수 없습니다."), SHARE_LINK_ALREADY_EXISTS(HttpStatus.CONFLICT, "SHARELINK409", "이미 이 영상의 공유 링크가 존재합니다."), INVALID_EXPIRED_AT(HttpStatus.BAD_REQUEST, "SHARELINK400", "만료 일시는 현재 시각보다 이후여야 합니다."), - SHARE_LINK_UNAVAILABLE(HttpStatus.GONE, "SHARELINK410", "비활성화되었거나 만료된 링크입니다."); + SHARE_LINK_UNAVAILABLE(HttpStatus.GONE, "SHARELINK410", "비활성화되었거나 만료된 링크입니다."), + GUEST_ACCESS_DENIED(HttpStatus.FORBIDDEN, "SHARELINK403", "해당 영상에 접근 권한이 없는 게스트입니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/slatto/global/config/SecurityConfig.java b/src/main/java/com/slatto/global/config/SecurityConfig.java index 15857512..28d68c55 100644 --- a/src/main/java/com/slatto/global/config/SecurityConfig.java +++ b/src/main/java/com/slatto/global/config/SecurityConfig.java @@ -48,11 +48,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // 게스트 등록 .requestMatchers(HttpMethod.POST, "/api/v1/share-links/*/guests").permitAll() - // 게스트 피드백/답글 참여 — 작성/수정/삭제만 (조회는 인가 검증 이슈에서 처리) + // 게스트 피드백/답글 참여 (조회 포함 — 게스트 소유 검증은 서비스단에서 처리) .requestMatchers(HttpMethod.POST, "/api/v1/videos/*/feedbacks").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/videos/*/feedbacks").permitAll() .requestMatchers(HttpMethod.PATCH, "/api/v1/feedbacks/*").permitAll() .requestMatchers(HttpMethod.DELETE, "/api/v1/feedbacks/*").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/feedbacks/*/replies").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/feedbacks/*/replies").permitAll() .requestMatchers(HttpMethod.PATCH, "/api/v1/replies/*").permitAll() .requestMatchers(HttpMethod.DELETE, "/api/v1/replies/*").permitAll() diff --git a/src/main/resources/db/migration/004-notification-group-count.sql b/src/main/resources/db/migration/004-notification-group-count.sql new file mode 100644 index 00000000..be431527 --- /dev/null +++ b/src/main/resources/db/migration/004-notification-group-count.sql @@ -0,0 +1,8 @@ +-- 알림 제목과 그룹핑 알림의 누적 이벤트 개수를 관리한다. + +ALTER TABLE notification + ADD COLUMN title VARCHAR(255) NULL, + ADD COLUMN group_count INT NOT NULL DEFAULT 1; + +CREATE INDEX idx_notification_group_lookup + ON notification (user_id, type, target_type, target_id, is_read);