diff --git a/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java b/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java index d97624b7..5d26e42f 100644 --- a/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java +++ b/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java @@ -5,6 +5,7 @@ import com.amcamp.domain.feedback.dto.request.OriginalFeedbackRequest; import com.amcamp.domain.feedback.dto.response.FeedbackInfoResponse; import com.amcamp.domain.feedback.dto.response.FeedbackRefineResponse; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -22,6 +23,21 @@ public class FeedbackController { private final FeedbackService feedbackService; + @Operation(summary = "스프린트별 동료평가 여부 확인", description = "스프린트별/팀원별 동료평가 여부를 확인합니다. ") + @GetMapping("/{sprintId}/participants") + public Slice feedbackStatusFind( + @PathVariable Long sprintId, + @Parameter(description = "프로젝트 아이디") @RequestParam Long projectId, + @Parameter(description = "이전 페이지의 마지막 프로젝트 참가자 ID (첫 페이지는 비워두세요)") + @RequestParam(required = false) + Long lastProjectParticipantId, + @Parameter(description = "페이지당 프로젝트 참여자 수", example = "5") @RequestParam(value = "size") + int pageSize) { + + return feedbackService.findFeedbackStatusBySprint( + projectId, sprintId, lastProjectParticipantId, pageSize); + } + @Operation( summary = "OpenAI 기반 피드백 메시지 개선", description = "사용자가 입력한 피드백을 AI가 분석하여 부드럽고 명확하게 개선합니다.") diff --git a/src/main/java/com/amcamp/domain/feedback/application/FeedbackService.java b/src/main/java/com/amcamp/domain/feedback/application/FeedbackService.java index ad027c5b..b8e42edc 100644 --- a/src/main/java/com/amcamp/domain/feedback/application/FeedbackService.java +++ b/src/main/java/com/amcamp/domain/feedback/application/FeedbackService.java @@ -12,6 +12,7 @@ import com.amcamp.domain.project.domain.Project; import com.amcamp.domain.project.domain.ProjectParticipant; import com.amcamp.domain.project.domain.ProjectParticipantStatus; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; import com.amcamp.domain.sprint.dao.SprintRepository; import com.amcamp.domain.sprint.domain.Sprint; import com.amcamp.domain.team.dao.TeamParticipantRepository; @@ -81,6 +82,20 @@ public Slice findSprintFeedbacksByParticipant( projectParticipant.getId(), sprintId, lastFeedbackId, pageSize); } + @Transactional(readOnly = true) + public Slice findFeedbackStatusBySprint( + Long projectId, Long sprintId, Long lastProjectParticipantId, int pageSize) { + final Member currentMember = memberUtil.getCurrentMember(); + final Project project = findByProjectId(projectId); + final Sprint sprint = findBySprintId(sprintId); + + ProjectParticipant projectParticipant = validateProjectParticipant(currentMember, project); + validateProjectSprintMismatch(project, sprint); + + return feedbackRepository.findSprintFeedbackStatusByParticipant( + projectParticipant, sprintId, lastProjectParticipantId, pageSize); + } + private Project findByProjectId(Long projectId) { return projectRepository .findById(projectId) diff --git a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryCustom.java b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryCustom.java index ddae7097..bf9d5af8 100644 --- a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryCustom.java +++ b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryCustom.java @@ -1,9 +1,17 @@ package com.amcamp.domain.feedback.dao; import com.amcamp.domain.feedback.dto.response.FeedbackInfoResponse; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; import org.springframework.data.domain.Slice; public interface FeedbackRepositoryCustom { Slice findSprintFeedbacksByParticipant( Long projectParticipantId, Long sprintId, Long lastFeedbackId, int pageSize); + + Slice findSprintFeedbackStatusByParticipant( + ProjectParticipant projectParticipant, + Long sprintId, + Long lastProjectParticipantId, + int pageSize); } diff --git a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java index 7529523c..83b56849 100644 --- a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java +++ b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java @@ -1,12 +1,19 @@ package com.amcamp.domain.feedback.dao; import static com.amcamp.domain.feedback.domain.QFeedback.feedback; +import static com.amcamp.domain.member.domain.QMember.member; +import static com.amcamp.domain.project.domain.QProjectParticipant.projectParticipant; +import static com.amcamp.domain.team.domain.QTeamParticipant.teamParticipant; import com.amcamp.domain.feedback.dto.response.FeedbackInfoResponse; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; import com.amcamp.global.exception.CommonException; import com.amcamp.global.exception.errorcode.FeedbackErrorCode; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import lombok.RequiredArgsConstructor; @@ -48,6 +55,52 @@ public Slice findSprintFeedbacksByParticipant( return checkLastPage(pageSize, results); } + @Override + public Slice findSprintFeedbackStatusByParticipant( + ProjectParticipant sender, Long sprintId, Long lastProjectParticipantId, int pageSize) { + + // 1. sender가 이 sprint에서 피드백한 receiver ID 목록 먼저 뽑기 + List feedbackGivenReceiverIds = + jpaQueryFactory + .select(feedback.receiver.id) + .from(feedback) + .where(feedback.sender.eq(sender), feedback.sprint.id.eq(sprintId)) + .fetch(); + + // 2. 프로젝트 참여자 목록 뽑고, 해당 ID가 feedbackGivenReceiverIds 안에 있는지 체크해서 status 판단 + List results = + jpaQueryFactory + .select( + Projections.constructor( + ProjectParticipantFeedbackInfoResponse.class, + projectParticipant.id, + member.nickname, + member.profileImageUrl, + projectParticipant.projectRole, + projectParticipant.status, + new CaseBuilder() + .when( + projectParticipant.id.in( + feedbackGivenReceiverIds)) + .then("COMPLETED") + .otherwise("PENDING"))) + .from(projectParticipant) + .leftJoin(projectParticipant.teamParticipant, teamParticipant) + .leftJoin(teamParticipant.member, member) + .where( + projectParticipant.project.eq(sender.getProject()), + lastProjectParticipantId(lastProjectParticipantId)) + .orderBy(projectParticipant.id.asc()) + .limit(pageSize + 1) + .fetch(); + + if (results.isEmpty()) { + throw new CommonException(ProjectErrorCode.PROJECT_PARTICIPANT_NOT_EXISTS); + } + + return checkLastPage(pageSize, results); + } + private BooleanExpression lastFeedbackId(Long feedbackId) { if (feedbackId == null) { return null; @@ -56,8 +109,15 @@ private BooleanExpression lastFeedbackId(Long feedbackId) { return feedback.id.lt(feedbackId); } - private Slice checkLastPage( - int pageSize, List results) { + private BooleanExpression lastProjectParticipantId(Long projectParticipantId) { + if (projectParticipantId == null) { + return null; + } + + return projectParticipant.id.gt(projectParticipantId); + } + + private Slice checkLastPage(int pageSize, List results) { boolean hasNext = false; if (results.size() > pageSize) { diff --git a/src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantFeedbackInfoResponse.java b/src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantFeedbackInfoResponse.java new file mode 100644 index 00000000..b52b25bf --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantFeedbackInfoResponse.java @@ -0,0 +1,13 @@ +package com.amcamp.domain.project.dto.response; + +import com.amcamp.domain.project.domain.ProjectParticipantRole; +import com.amcamp.domain.project.domain.ProjectParticipantStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ProjectParticipantFeedbackInfoResponse( + @Schema(description = "프로젝트 참여자 아이디", example = "1") Long projectParticipantId, + @Schema(description = "프로젝트 참여자 이름", example = "정선우") String nickname, + @Schema(description = "프로젝트 참여자 이미지 url", example = "PreSigned URL") String profileImageUrl, + @Schema(description = "프로젝트 참여자 권한", example = "PROJECT_ADMIN") ProjectParticipantRole role, + @Schema(description = "프로젝트 참여자 참여 상태", example = "ACTIVE") ProjectParticipantStatus status, + @Schema(description = "동료평가 완료 여부", example = "COMPLETED") String feedbackStatus) {} diff --git a/src/test/java/com/amcamp/domain/feedback/application/FeedbackServiceTest.java b/src/test/java/com/amcamp/domain/feedback/application/FeedbackServiceTest.java index c7d8fc50..72678705 100644 --- a/src/test/java/com/amcamp/domain/feedback/application/FeedbackServiceTest.java +++ b/src/test/java/com/amcamp/domain/feedback/application/FeedbackServiceTest.java @@ -17,6 +17,7 @@ import com.amcamp.domain.project.domain.ProjectParticipant; import com.amcamp.domain.project.domain.ProjectParticipantRole; import com.amcamp.domain.project.domain.ProjectParticipantStatus; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; import com.amcamp.domain.sprint.dao.SprintRepository; import com.amcamp.domain.sprint.domain.Sprint; import com.amcamp.domain.team.dao.TeamParticipantRepository; @@ -57,6 +58,7 @@ public class FeedbackServiceTest extends IntegrationTest { private ProjectParticipant anotherSender; private ProjectParticipant receiver; private ProjectParticipant anotherReceiver; + private ProjectParticipant notReceiver; private ProjectParticipant unknownReceiver; private Sprint sprint; private Sprint anotherSprint; @@ -78,6 +80,13 @@ void setUp() { "testReceiverProfileImageUrl", OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"))); + Member notReceiverMember = + memberRepository.save( + Member.createMember( + "testNotReceiverNickname", + "testNotReceiverProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"))); + // 초기에 로그인한 사용자를 sender로 설정 setAuthenticatedUser(senderMember); @@ -92,6 +101,11 @@ void setUp() { TeamParticipant.createParticipant( receiverMember, team, TeamParticipantRole.USER)); + TeamParticipant teamParticipantUserNotReceiver = + teamParticipantRepository.save( + TeamParticipant.createParticipant( + notReceiverMember, team, TeamParticipantRole.USER)); + project = projectRepository.save( Project.createProject( @@ -110,6 +124,12 @@ void setUp() { projectParticipantRepository.save( ProjectParticipant.createProjectParticipant( teamParticipantUser, project, ProjectParticipantRole.MEMBER)); + notReceiver = + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipantUserNotReceiver, + project, + ProjectParticipantRole.MEMBER)); anotherReceiver = projectParticipantRepository.save( @@ -348,6 +368,71 @@ class 피드백_메시지를_조회할_때 { } } + @Nested + class 스프린트별_동료평가_여부를_확인할_때 { + @Test + void 스프린트가_존재하지않으면_예외가_발생한다() { + // when & then + assertThatThrownBy( + () -> + feedbackService.findFeedbackStatusBySprint( + project.getId(), 999L, null, 1)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_NOT_FOUND.getMessage()); + } + + @Test + void 요청한_프로젝트가_로그인된_사용자가_참여한_프로젝트가_아니라면_예외가_발생한다() { + // given + // sender는 project(1)에만 참여 중이고, + // receiver는 project(1)와 anotherProject(2) 둘 다 참여 중이므로 + // 검증을 명확히 하기 위해 로그인한 사용자를 sender로 변경 + setAuthenticatedUser(sender.getTeamParticipant().getMember()); + + // when & then + assertThatThrownBy( + () -> + feedbackService.findFeedbackStatusBySprint( + anotherProject.getId(), sprint.getId(), null, 1)) + .isInstanceOf(CommonException.class) + .hasMessage(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED.getMessage()); + } + + @Test + void 팀원정보와_동료평가여부를_반환한다() { + feedbackRepository.save(Feedback.createFeedback(sender, receiver, sprint, "수고하셨습니다!")); + // when: sender 기준, 해당 스프린트에서의 팀원 평가 상태 조회 + Slice result = + feedbackService.findFeedbackStatusBySprint( + project.getId(), sprint.getId(), 0L, 10); + + // then + assertThat(result.getContent().size()) + .isEqualTo( + 3); // receiver 1명만 ACTIVE 상태로 있는 프로젝트 참가자, 본인과 피드백을 받지 않은 사람은 PENDING + + // (1) 동료평가를 받은 사람은 COMPLETED + ProjectParticipantFeedbackInfoResponse received_response = + result.getContent().stream() + .filter(r -> r.projectParticipantId().equals(receiver.getId())) + .findFirst() + .orElseThrow(() -> new AssertionError("receiver에 대한 응답이 존재하지 않음")); + + assertThat(received_response.nickname()).isEqualTo("testReceiverNickname"); + assertThat(received_response.feedbackStatus()).isEqualTo("COMPLETED"); + + // (1) 동료평가를 받지 사람 & sender는 PENDING + ProjectParticipantFeedbackInfoResponse sender_response = + result.getContent().stream() + .filter(r -> r.projectParticipantId().equals(sender.getId())) + .findFirst() + .orElseThrow(() -> new AssertionError("sender에 대한 응답이 존재하지 않음")); + + assertThat(sender_response.nickname()).isEqualTo("testSenderNickname"); + assertThat(sender_response.feedbackStatus()).isEqualTo("PENDING"); + } + } + private void setAuthenticatedUser(Member member) { UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); UsernamePasswordAuthenticationToken token =