Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +23,21 @@ public class FeedbackController {

private final FeedbackService feedbackService;

@Operation(summary = "스프린트별 동료평가 여부 확인", description = "스프린트별/팀원별 동료평가 여부를 확인합니다. ")
@GetMapping("/{sprintId}/participants")
public Slice<ProjectParticipantFeedbackInfoResponse> 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가 분석하여 부드럽고 명확하게 개선합니다.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,6 +82,20 @@ public Slice<FeedbackInfoResponse> findSprintFeedbacksByParticipant(
projectParticipant.getId(), sprintId, lastFeedbackId, pageSize);
}

@Transactional(readOnly = true)
public Slice<ProjectParticipantFeedbackInfoResponse> 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FeedbackInfoResponse> findSprintFeedbacksByParticipant(
Long projectParticipantId, Long sprintId, Long lastFeedbackId, int pageSize);

Slice<ProjectParticipantFeedbackInfoResponse> findSprintFeedbackStatusByParticipant(
ProjectParticipant projectParticipant,
Long sprintId,
Long lastProjectParticipantId,
int pageSize);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -48,6 +55,52 @@ public Slice<FeedbackInfoResponse> findSprintFeedbacksByParticipant(
return checkLastPage(pageSize, results);
}

@Override
public Slice<ProjectParticipantFeedbackInfoResponse> findSprintFeedbackStatusByParticipant(
ProjectParticipant sender, Long sprintId, Long lastProjectParticipantId, int pageSize) {

// 1. sender가 이 sprint에서 피드백한 receiver ID 목록 먼저 뽑기
List<Long> feedbackGivenReceiverIds =
jpaQueryFactory
.select(feedback.receiver.id)
.from(feedback)
.where(feedback.sender.eq(sender), feedback.sprint.id.eq(sprintId))
.fetch();

// 2. 프로젝트 참여자 목록 뽑고, 해당 ID가 feedbackGivenReceiverIds 안에 있는지 체크해서 status 판단
List<ProjectParticipantFeedbackInfoResponse> 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;
Expand All @@ -56,8 +109,15 @@ private BooleanExpression lastFeedbackId(Long feedbackId) {
return feedback.id.lt(feedbackId);
}

private Slice<FeedbackInfoResponse> checkLastPage(
int pageSize, List<FeedbackInfoResponse> results) {
private BooleanExpression lastProjectParticipantId(Long projectParticipantId) {
if (projectParticipantId == null) {
return null;
}

return projectParticipant.id.gt(projectParticipantId);
}

private <T> Slice<T> checkLastPage(int pageSize, List<T> results) {
boolean hasNext = false;

if (results.size() > pageSize) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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<ProjectParticipantFeedbackInfoResponse> 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 =
Expand Down