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 e5e12da2..143e6cea 100644 --- a/src/main/java/com/slatto/domain/notification/entity/ActivityLog.java +++ b/src/main/java/com/slatto/domain/notification/entity/ActivityLog.java @@ -1,9 +1,11 @@ package com.slatto.domain.notification.entity; 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.notification.enums.ActivityLogTargetType; import com.slatto.domain.notification.enums.ActivityLogType; +import com.slatto.domain.notification.model.ActivityActor; +import com.slatto.domain.project.entity.Project; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -52,40 +54,35 @@ public class ActivityLog extends BaseEntity { private ActivityLog( Project project, - Long actorUserId, - Long actorGuestId, - ActorType actorType, + ActivityActor actor, ActivityLogType type, String content, - String targetType, + ActivityLogTargetType targetType, Long targetId, String groupKey ) { this.project = project; - this.actorUserId = actorUserId; - this.actorGuestId = actorGuestId; - this.actorType = actorType; + this.actorUserId = actor.actorUserId(); + this.actorGuestId = actor.actorGuestId(); + this.actorType = actor.actorType(); this.type = type; this.content = content; - this.targetType = targetType; + this.targetType = targetType != null ? targetType.name() : null; this.targetId = targetId; this.groupKey = groupKey; } public static ActivityLog create( Project project, - Long actorUserId, - ActorType actorType, + ActivityActor actor, ActivityLogType type, String content, - String targetType, + ActivityLogTargetType targetType, Long targetId ) { return new ActivityLog( project, - actorUserId, - null, - actorType, + actor, type, content, targetType, @@ -93,4 +90,5 @@ public static ActivityLog create( null ); } + } diff --git a/src/main/java/com/slatto/domain/notification/model/ActivityActor.java b/src/main/java/com/slatto/domain/notification/model/ActivityActor.java new file mode 100644 index 00000000..6ef164ba --- /dev/null +++ b/src/main/java/com/slatto/domain/notification/model/ActivityActor.java @@ -0,0 +1,63 @@ +package com.slatto.domain.notification.model; + +import com.slatto.domain.notification.enums.ActorType; + +public record ActivityActor( + ActorType actorType, + Long actorUserId, + Long actorGuestId, + String displayName +) { + + public ActivityActor { + if (actorType == null) { + throw new IllegalArgumentException("최근활동 행위자 타입은 필수입니다."); + } + if (displayName == null || displayName.isBlank()) { + throw new IllegalArgumentException("최근활동 행위자 이름은 필수입니다."); + } + + validateActorIds(actorType, actorUserId, actorGuestId); + } + + public static ActivityActor user(Long userId, String displayName) { + return new ActivityActor(ActorType.USER, userId, null, displayName); + } + + public static ActivityActor clientReviewer(Long guestId, String displayName) { + return new ActivityActor(ActorType.CLIENT_REVIEWER, null, guestId, displayName); + } + + public static ActivityActor system(String displayName) { + return new ActivityActor(ActorType.SYSTEM, null, null, displayName); + } + + private static void validateActorIds(ActorType actorType, Long actorUserId, Long actorGuestId) { + switch (actorType) { + case USER -> { + requirePositiveId(actorUserId, "사용자"); + requireNull(actorGuestId, "회원 행위자는 게스트 ID를 가질 수 없습니다."); + } + case CLIENT_REVIEWER -> { + requireNull(actorUserId, "게스트 행위자는 사용자 ID를 가질 수 없습니다."); + requirePositiveId(actorGuestId, "게스트"); + } + case SYSTEM -> { + requireNull(actorUserId, "시스템 행위자는 사용자 ID를 가질 수 없습니다."); + requireNull(actorGuestId, "시스템 행위자는 게스트 ID를 가질 수 없습니다."); + } + } + } + + private static void requirePositiveId(Long id, String actorName) { + if (id == null || id <= 0) { + throw new IllegalArgumentException(actorName + " ID는 양수여야 합니다."); + } + } + + private static void requireNull(Long id, String message) { + if (id != null) { + throw new IllegalArgumentException(message); + } + } +} diff --git a/src/main/java/com/slatto/domain/notification/service/ActivityLogService.java b/src/main/java/com/slatto/domain/notification/service/ActivityLogService.java index 7d32e772..a2da37de 100644 --- a/src/main/java/com/slatto/domain/notification/service/ActivityLogService.java +++ b/src/main/java/com/slatto/domain/notification/service/ActivityLogService.java @@ -3,10 +3,11 @@ 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.model.ActivityActor; 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.sharelink.entity.Guest; import com.slatto.domain.user.entity.Users; import com.slatto.domain.user.repository.UserRepository; import com.slatto.global.exception.BaseException; @@ -23,6 +24,7 @@ public class ActivityLogService { private final ActivityLogRepository activityLogRepository; private final ProjectRepository projectRepository; private final UserRepository userRepository; + private final ActivityMessageFactory activityMessageFactory; /** * 프로젝트에 새 참여자가 합류했을 때 최근활동을 저장한다. @@ -35,7 +37,7 @@ public void createProjectMemberJoinedLog(Long projectId, Long actorUserId) { projectId, actor, ActivityLogType.PROJECT_MEMBER_JOINED, - createProjectMemberJoinedContent(actor.getNickname()), + activityMessageFactory.projectMemberJoined(actor.getNickname()), ActivityLogTargetType.PROJECT, projectId ); @@ -59,7 +61,7 @@ public void createProjectStatusChangedLog( projectId, actor, ActivityLogType.PROJECT_STATUS_CHANGED, - createProjectStatusChangedContent(actor.getNickname(), previousStatus, changedStatus), + activityMessageFactory.projectStatusChanged(actor.getNickname(), previousStatus, changedStatus), ActivityLogTargetType.PROJECT, projectId ); @@ -76,7 +78,7 @@ public void createProjectUpdatedLog(Long projectId, Long actorUserId) { projectId, actor, ActivityLogType.PROJECT_UPDATED, - createProjectUpdatedContent(actor.getNickname()), + activityMessageFactory.projectUpdated(actor.getNickname()), ActivityLogTargetType.PROJECT, projectId ); @@ -100,7 +102,7 @@ public void createScheduleCreatedLog( projectId, actor, ActivityLogType.SCHEDULE_CREATED, - createScheduleCreatedContent(actor.getNickname(), scheduleTitle), + activityMessageFactory.scheduleCreated(actor.getNickname(), scheduleTitle), ActivityLogTargetType.SCHEDULE, scheduleId ); @@ -124,7 +126,7 @@ public void createScheduleUpdatedLog( projectId, actor, ActivityLogType.SCHEDULE_UPDATED, - createScheduleUpdatedContent(actor.getNickname(), scheduleTitle), + activityMessageFactory.scheduleUpdated(actor.getNickname(), scheduleTitle), ActivityLogTargetType.SCHEDULE, scheduleId ); @@ -146,7 +148,7 @@ public void createNoticeCreatedLog( projectId, actor, ActivityLogType.NOTICE_CREATED, - createNoticeCreatedContent(actor.getNickname()), + activityMessageFactory.noticeCreated(actor.getNickname()), ActivityLogTargetType.NOTICE, noticeId ); @@ -170,7 +172,7 @@ public void createFileUploadedLog( projectId, actor, ActivityLogType.FILE_UPLOADED, - createFileUploadedContent(actor.getNickname(), fileName), + activityMessageFactory.fileUploaded(actor.getNickname(), fileName), ActivityLogTargetType.FILE, fileId ); @@ -194,7 +196,32 @@ public void createVideoFeedbackCommentedLog( projectId, actor, ActivityLogType.VIDEO_FEEDBACK_COMMENTED, - createVideoFeedbackCommentedContent(actor.getNickname(), videoTitle), + activityMessageFactory.videoFeedbackCommented(actor.getNickname(), videoTitle), + ActivityLogTargetType.VIDEO, + videoId + ); + } + + /** + * 공유 링크 게스트가 영상 피드백을 남겼을 때 최근활동을 저장한다. + * 피드백 도메인의 실제 호출 연결은 후속 작업에서 추가한다. + */ + @Transactional + public void createGuestVideoFeedbackCommentedLog( + Long projectId, + Guest guest, + Long videoId, + String videoTitle + ) { + validateRequiredGuest(guest); + validateRequiredId(videoId); + validateRequiredText(videoTitle); + + createActivityLog( + projectId, + ActivityActor.clientReviewer(guest.getId(), guest.getName()), + ActivityLogType.VIDEO_FEEDBACK_COMMENTED, + activityMessageFactory.videoFeedbackCommented(guest.getName(), videoTitle), ActivityLogTargetType.VIDEO, videoId ); @@ -207,56 +234,37 @@ private void createUserActivityLog( String content, ActivityLogTargetType targetType, Long targetId + ) { + createActivityLog( + projectId, + ActivityActor.user(actor.getId(), actor.getNickname()), + type, + content, + targetType, + targetId + ); + } + + private void createActivityLog( + Long projectId, + ActivityActor actor, + ActivityLogType type, + String content, + ActivityLogTargetType targetType, + Long targetId ) { Project project = getActiveProject(projectId); activityLogRepository.save(ActivityLog.create( project, - actor.getId(), - ActorType.USER, + actor, type, content, - getTargetTypeName(targetType), + 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); @@ -283,7 +291,10 @@ private void validateRequiredText(String text) { } } - private String getTargetTypeName(ActivityLogTargetType targetType) { - return targetType != null ? targetType.name() : null; + private void validateRequiredGuest(Guest guest) { + if (guest == null) { + throw new BaseException(CommonErrorCode.BAD_REQUEST); + } } + } diff --git a/src/main/java/com/slatto/domain/notification/service/ActivityMessageFactory.java b/src/main/java/com/slatto/domain/notification/service/ActivityMessageFactory.java new file mode 100644 index 00000000..5588be2f --- /dev/null +++ b/src/main/java/com/slatto/domain/notification/service/ActivityMessageFactory.java @@ -0,0 +1,39 @@ +package com.slatto.domain.notification.service; + +import org.springframework.stereotype.Component; + +@Component +public class ActivityMessageFactory { + + public String projectMemberJoined(String actorName) { + return actorName + "님이 프로젝트에 합류했어요"; + } + + public String projectStatusChanged(String actorName, String previousStatus, String changedStatus) { + return actorName + "님이 프로젝트 단계를 '" + previousStatus + "'에서 '" + changedStatus + "'으로 변경했어요"; + } + + public String projectUpdated(String actorName) { + return actorName + "님이 프로젝트 정보를 수정했어요"; + } + + public String scheduleCreated(String actorName, String scheduleTitle) { + return actorName + "님이 [" + scheduleTitle + "] 일정을 등록했어요"; + } + + public String scheduleUpdated(String actorName, String scheduleTitle) { + return actorName + "님이 [" + scheduleTitle + "] 일정을 수정했어요"; + } + + public String noticeCreated(String actorName) { + return actorName + "님이 새 공지를 등록했어요"; + } + + public String fileUploaded(String actorName, String fileName) { + return actorName + "님이 [" + fileName + "] 파일을 등록했어요"; + } + + public String videoFeedbackCommented(String actorName, String videoTitle) { + return actorName + "님이 [" + videoTitle + "]에 피드백을 남겼어요"; + } +} diff --git a/src/test/java/com/slatto/domain/notification/model/ActivityActorTest.java b/src/test/java/com/slatto/domain/notification/model/ActivityActorTest.java new file mode 100644 index 00000000..96feb2e0 --- /dev/null +++ b/src/test/java/com/slatto/domain/notification/model/ActivityActorTest.java @@ -0,0 +1,39 @@ +package com.slatto.domain.notification.model; + +import com.slatto.domain.notification.enums.ActorType; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ActivityActorTest { + + @Test + void 회원_행위자는_사용자_ID만_저장한다() { + // 회원 활동은 actor_user_id만 채워져야 게스트 활동과 구분할 수 있다. + ActivityActor actor = ActivityActor.user(1L, "그린"); + + assertThat(actor.actorType()).isEqualTo(ActorType.USER); + assertThat(actor.actorUserId()).isEqualTo(1L); + assertThat(actor.actorGuestId()).isNull(); + assertThat(actor.displayName()).isEqualTo("그린"); + } + + @Test + void 게스트_행위자는_게스트_ID만_저장한다() { + // 공유 링크 게스트의 활동은 actor_guest_id만 채워져야 한다. + ActivityActor actor = ActivityActor.clientReviewer(2L, "차태훈"); + + assertThat(actor.actorType()).isEqualTo(ActorType.CLIENT_REVIEWER); + assertThat(actor.actorUserId()).isNull(); + assertThat(actor.actorGuestId()).isEqualTo(2L); + assertThat(actor.displayName()).isEqualTo("차태훈"); + } + + @Test + void 행위자_타입과_ID_조합이_맞지_않으면_생성을_막는다() { + // 회원 활동에 guest ID를 함께 넣는 식의 잘못된 저장을 모델 경계에서 차단한다. + assertThatThrownBy(() -> new ActivityActor(ActorType.USER, 1L, 2L, "그린")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/slatto/domain/notification/service/ActivityLogServiceIntegrationTest.java b/src/test/java/com/slatto/domain/notification/service/ActivityLogServiceIntegrationTest.java new file mode 100644 index 00000000..88a844d9 --- /dev/null +++ b/src/test/java/com/slatto/domain/notification/service/ActivityLogServiceIntegrationTest.java @@ -0,0 +1,105 @@ +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.enums.LengthType; +import com.slatto.domain.project.repository.ProjectRepository; +import com.slatto.domain.sharelink.entity.Guest; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.CategoryName; +import com.slatto.domain.user.enums.Kind; +import com.slatto.domain.user.enums.SocialType; +import com.slatto.domain.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@DataJpaTest +@Import({ActivityLogService.class, ActivityMessageFactory.class}) +class ActivityLogServiceIntegrationTest { + + @Autowired + private ActivityLogService activityLogService; + + @Autowired + private ActivityLogRepository activityLogRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProjectRepository projectRepository; + + @Test + void 회원_파일_등록_활동을_실제_최근활동_테이블에_저장한다() { + // 서비스, 메시지 팩토리, JPA 엔티티를 함께 통과해 기존 회원 활동의 저장값 회귀를 확인한다. + Users user = saveUser("green@example.com", "그린"); + Project project = saveProject(user); + + activityLogService.createFileUploadedLog(project.getId(), user.getId(), 101L, "촬영계획서.pdf"); + + ActivityLog activityLog = activityLogRepository.findAll().getFirst(); + assertThat(activityLog.getActorType()).isEqualTo(ActorType.USER); + assertThat(activityLog.getActorUserId()).isEqualTo(user.getId()); + assertThat(activityLog.getActorGuestId()).isNull(); + assertThat(activityLog.getType()).isEqualTo(ActivityLogType.FILE_UPLOADED); + assertThat(activityLog.getContent()).isEqualTo("그린님이 [촬영계획서.pdf] 파일을 등록했어요"); + assertThat(activityLog.getTargetType()).isEqualTo(ActivityLogTargetType.FILE.name()); + assertThat(activityLog.getTargetId()).isEqualTo(101L); + } + + @Test + void 게스트_피드백_활동을_게스트_행위자로_저장한다() { + // 공유 링크 게스트는 user ID 없이 guest ID와 CLIENT_REVIEWER 타입으로 저장돼야 한다. + Users owner = saveUser("chataehun@example.com", "차태훈"); + Project project = saveProject(owner); + Guest guest = mock(Guest.class); + given(guest.getId()).willReturn(202L); + given(guest.getName()).willReturn("차태훈"); + + activityLogService.createGuestVideoFeedbackCommentedLog(project.getId(), guest, 303L, "버전 1"); + + ActivityLog activityLog = activityLogRepository.findAll().getFirst(); + assertThat(activityLog.getActorType()).isEqualTo(ActorType.CLIENT_REVIEWER); + assertThat(activityLog.getActorUserId()).isNull(); + assertThat(activityLog.getActorGuestId()).isEqualTo(202L); + assertThat(activityLog.getType()).isEqualTo(ActivityLogType.VIDEO_FEEDBACK_COMMENTED); + assertThat(activityLog.getContent()).isEqualTo("차태훈님이 [버전 1]에 피드백을 남겼어요"); + assertThat(activityLog.getTargetType()).isEqualTo(ActivityLogTargetType.VIDEO.name()); + assertThat(activityLog.getTargetId()).isEqualTo(303L); + } + + private Users saveUser(String email, String nickname) { + return userRepository.save(Users.createSocialUser( + email, + nickname, + null, + SocialType.GOOGLE, + "google-" + email + )); + } + + private Project saveProject(Users owner) { + return projectRepository.save(Project.create( + owner, + "최근활동 테스트 프로젝트", + CategoryName.DOCUMENTARY, + LengthType.SHORT_FORM, + "최근활동 저장 검증용 프로젝트입니다.", + LocalDate.now().plusDays(1), + null, + Kind.PERSONAL + )); + } +} diff --git a/src/test/java/com/slatto/domain/notification/service/ActivityMessageFactoryTest.java b/src/test/java/com/slatto/domain/notification/service/ActivityMessageFactoryTest.java new file mode 100644 index 00000000..2d172854 --- /dev/null +++ b/src/test/java/com/slatto/domain/notification/service/ActivityMessageFactoryTest.java @@ -0,0 +1,31 @@ +package com.slatto.domain.notification.service; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ActivityMessageFactoryTest { + + private final ActivityMessageFactory activityMessageFactory = new ActivityMessageFactory(); + + @Test + void 확정된_최근활동_문구를_일관되게_생성한다() { + // 각 도메인이 문구를 직접 조합하지 않아도 PM 합의 문구가 유지되는지 확인한다. + assertThat(activityMessageFactory.projectMemberJoined("그린")) + .isEqualTo("그린님이 프로젝트에 합류했어요"); + assertThat(activityMessageFactory.projectStatusChanged("그린", "준비중", "편집중")) + .isEqualTo("그린님이 프로젝트 단계를 '준비중'에서 '편집중'으로 변경했어요"); + assertThat(activityMessageFactory.projectUpdated("그린")) + .isEqualTo("그린님이 프로젝트 정보를 수정했어요"); + assertThat(activityMessageFactory.scheduleCreated("그린", "촬영 회의")) + .isEqualTo("그린님이 [촬영 회의] 일정을 등록했어요"); + assertThat(activityMessageFactory.scheduleUpdated("그린", "촬영 회의")) + .isEqualTo("그린님이 [촬영 회의] 일정을 수정했어요"); + assertThat(activityMessageFactory.noticeCreated("그린")) + .isEqualTo("그린님이 새 공지를 등록했어요"); + assertThat(activityMessageFactory.fileUploaded("그린", "촬영계획서.pdf")) + .isEqualTo("그린님이 [촬영계획서.pdf] 파일을 등록했어요"); + assertThat(activityMessageFactory.videoFeedbackCommented("그린", "버전 1")) + .isEqualTo("그린님이 [버전 1]에 피드백을 남겼어요"); + } +}