diff --git a/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/request/ActionItemUpdateRequest.java b/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/request/ActionItemUpdateRequest.java index efdb447a..7b2fc4ba 100644 --- a/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/request/ActionItemUpdateRequest.java +++ b/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/request/ActionItemUpdateRequest.java @@ -11,14 +11,12 @@ public record ActionItemUpdateRequest(@NotNull @Schema(description = "실행 목표 리스트") List actionItems) { - @Getter - public static class ActionItemUpdateElementRequest { - @Schema(description = "실행 목표 id") - @NotNull - Long id; + public record ActionItemUpdateElementRequest( + @Schema(description = "실행 목표 id (신규 생성시 null)") + Long id, @Schema(description = "변경된 실행 목표 내용") @NotNull - String content; - } + String content + ) {} } diff --git a/layer-api/src/main/java/org/layer/domain/actionItem/service/ActionItemService.java b/layer-api/src/main/java/org/layer/domain/actionItem/service/ActionItemService.java index c87645e6..1ebc327f 100644 --- a/layer-api/src/main/java/org/layer/domain/actionItem/service/ActionItemService.java +++ b/layer-api/src/main/java/org/layer/domain/actionItem/service/ActionItemService.java @@ -231,34 +231,51 @@ public MemberActionItemGetResponse getMemberActionItemList(Long currentMemberId) //== 실행 목표 수정 ==// @Transactional public void updateActionItems(Long memberId, Long retrospectId, ActionItemUpdateRequest updateDto) { - // 실행 목표 가져오기 - List actionItems = actionItemRepository.findAllByRetrospectId(retrospectId); - - // 리더인지 검증 + // 1. 리더 및 권한 검증 Retrospect retrospect = retrospectRepository.findByIdOrThrow(retrospectId); Space space = spaceRepository.findByIdOrThrow(retrospect.getSpaceId()); space.isLeaderSpace(memberId); - // 요청 리스트와 DB에 저장된 실행 목표 개수가 다를 때 - if (updateDto.actionItems().size() != actionItems.size()) { - throw new ActionItemException(INVALID_ACTION_ITEM_LIST); - } - - - // O(1) 접근을 위해서 map으로 변경 - Map actionItemMap = actionItems.stream().collect(Collectors.toMap( - ActionItem::getId, - actionItem -> actionItem - )); - - AtomicInteger order = new AtomicInteger(1); - for (ActionItemUpdateRequest.ActionItemUpdateElementRequest updateItem : updateDto.actionItems()) { - ActionItem actionItem = actionItemMap.getOrDefault(updateItem.getId(), null); - if (actionItem == null) { - throw new ActionItemException(INVALID_ACTION_ITEM_ID); + // 2. DB에 저장된 기존 실행 목표 가져오기 + List dbActionItems = actionItemRepository.findAllByRetrospectId(retrospectId); + + // 3. 요청 데이터에서 ID 추출 (Update 대상 식별용) + Set requestIds = updateDto.actionItems().stream() + .map(ActionItemUpdateRequest.ActionItemUpdateElementRequest::id) + .filter(Objects::nonNull) // ID가 있는 것만 (신규 생성 제외) + .collect(Collectors.toSet()); + + // 4. [DELETE] 요청 리스트에 없는 DB 항목 삭제 + // (DB에는 있는데 요청 ID 목록에는 포함되지 않은 것들을 찾아서 삭제) + List itemsToDelete = dbActionItems.stream() + .filter(item -> !requestIds.contains(item.getId())) + .toList(); + actionItemRepository.deleteAll(itemsToDelete); + + // 5. [UPDATE & CREATE] 요청 리스트 순서대로 처리 + // 빠른 접근을 위해 DB 데이터를 Map으로 변환 + Map actionItemMap = dbActionItems.stream() + .collect(Collectors.toMap(ActionItem::getId, item -> item)); + + int order = 1; + + for (ActionItemUpdateRequest.ActionItemUpdateElementRequest requestItem : updateDto.actionItems()) { + if (requestItem.id() != null && actionItemMap.containsKey(requestItem.id())) { + // 5-1. [UPDATE] 기존 아이템 내용 및 순서 갱신 + ActionItem actionItem = actionItemMap.get(requestItem.id()); + actionItem.updateContent(requestItem.content()); + actionItem.updateActionItemOrder(order++); + } else { + // 5-2. [CREATE] ID가 없거나 DB에 없는 ID인 경우 신규 생성 + ActionItem newActionItem = ActionItem.builder() + .retrospectId(retrospectId) + .spaceId(space.getId()) + .memberId(memberId) + .content(requestItem.content()) + .actionItemOrder(order++) + .build(); + actionItemRepository.save(newActionItem); } - actionItem.updateContent(updateItem.getContent()); - actionItem.updateActionItemOrder(order.getAndIncrement()); } } } diff --git a/layer-api/src/test/java/org/layer/domain/actionItem/service/ActionItemServiceTest.java b/layer-api/src/test/java/org/layer/domain/actionItem/service/ActionItemServiceTest.java new file mode 100644 index 00000000..4f1640ec --- /dev/null +++ b/layer-api/src/test/java/org/layer/domain/actionItem/service/ActionItemServiceTest.java @@ -0,0 +1,124 @@ +package org.layer.domain.actionItem.service; + +import java.util.Comparator; +import java.util.List; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.layer.domain.actionItem.controller.dto.request.ActionItemUpdateRequest; +import org.layer.domain.actionItem.entity.ActionItem; +import org.layer.domain.actionItem.repository.ActionItemRepository; +import org.layer.domain.fixture.MemberFixture; +import org.layer.domain.fixture.RetrospectFixture; +import org.layer.domain.fixture.SpaceFixture; +import org.layer.domain.member.entity.Member; +import org.layer.domain.member.repository.MemberRepository; +import org.layer.domain.retrospect.entity.AnalysisStatus; +import org.layer.domain.retrospect.entity.Retrospect; +import org.layer.domain.retrospect.entity.RetrospectStatus; +import org.layer.domain.retrospect.repository.RetrospectRepository; +import org.layer.domain.space.entity.MemberSpaceRelation; +import org.layer.domain.space.entity.Space; +import org.layer.domain.space.repository.MemberSpaceRelationRepository; +import org.layer.domain.space.repository.SpaceRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +public class ActionItemServiceTest { + + @Autowired + private ActionItemService actionItemService; + + @Autowired + private ActionItemRepository actionItemRepository; + + @Autowired + private RetrospectRepository retrospectRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private SpaceRepository spaceRepository; + + @Autowired + private MemberSpaceRelationRepository memberSpaceRelationRepository; + + @Nested + class 실행_목표_수정 { + + @Test + @DisplayName("요청 리스트에 따라 기존 항목 삭제, 내용 수정, 신규 생성이 수행되고 순서가 재정렬된다.") + void updateActionItems_Sync_Test() { + // given + // 1. 리더 멤버 및 스페이스 생성 + Member leader = memberRepository.save(MemberFixture.createFixture("social-leader")); + Space space = spaceRepository.save(SpaceFixture.createFixture(leader.getId(), 1L)); + memberSpaceRelationRepository.save(new MemberSpaceRelation(leader.getId(), space)); + + // 2. 회고 생성 + Retrospect retrospect = retrospectRepository.save(RetrospectFixture.createFixture( + space.getId(), RetrospectStatus.PROCEEDING, AnalysisStatus.NOT_STARTED, null + )); + + // 3. [DB 상태] 기존 실행 목표 3개 저장 (A, B, C) + ActionItem itemA = actionItemRepository.save(createActionItem(retrospect, space, leader, "목표 A", 1)); + ActionItem itemB = actionItemRepository.save(createActionItem(retrospect, space, leader, "목표 B", 2)); + ActionItem itemC = actionItemRepository.save(createActionItem(retrospect, space, leader, "목표 C", 3)); + + // 4. [요청 생성] A, C 삭제 / B 수정 / D 신규 생성 + // 기대 결과 순서: 1. B(수정됨) -> 2. D(신규) + List updateElements = List.of( + // B 수정 (ID 유지) + new ActionItemUpdateRequest.ActionItemUpdateElementRequest(itemB.getId(), "목표 B 수정"), + // D 생성 (ID null) + new ActionItemUpdateRequest.ActionItemUpdateElementRequest(null, "목표 D 신규") + ); + ActionItemUpdateRequest request = new ActionItemUpdateRequest(updateElements); + + // when + // (서비스 메서드 호출) + actionItemService.updateActionItems(leader.getId(), retrospect.getId(), request); + + // then + List results = actionItemRepository.findAllByRetrospectId(retrospect.getId()); + + // 1. 개수 검증 (A, C 삭제, B 유지, D 생성 -> 총 2개) + Assertions.assertThat(results).hasSize(2); + + // 순서대로 정렬하여 검증 + results.sort(Comparator.comparingInt(ActionItem::getActionItemOrder)); + ActionItem firstItem = results.get(0); + ActionItem secondItem = results.get(1); + + // 2. 첫 번째 아이템 검증 (B 수정 확인) + Assertions.assertThat(firstItem.getId()).isEqualTo(itemB.getId()); + Assertions.assertThat(firstItem.getContent()).isEqualTo("목표 B 수정"); + Assertions.assertThat(firstItem.getActionItemOrder()).isEqualTo(1); + + // 3. 두 번째 아이템 검증 (D 생성 확인) + Assertions.assertThat(secondItem.getId()).isNotEqualTo(itemA.getId()); // A가 아님 + Assertions.assertThat(secondItem.getId()).isNotEqualTo(itemC.getId()); // C가 아님 + Assertions.assertThat(secondItem.getContent()).isEqualTo("목표 D 신규"); + Assertions.assertThat(secondItem.getActionItemOrder()).isEqualTo(2); + } + + // 테스트용 헬퍼 메서드 (Fixture가 없다면 간편하게 사용) + private ActionItem createActionItem(Retrospect retrospect, Space space, Member member, String content, int order) { + return ActionItem.builder() + .retrospectId(retrospect.getId()) + .spaceId(space.getId()) + .memberId(member.getId()) + .content(content) + .actionItemOrder(order) + .build(); + } + } +} diff --git a/layer-domain/src/main/java/org/layer/domain/analyze/entity/Analyze.java b/layer-domain/src/main/java/org/layer/domain/analyze/entity/Analyze.java index caa139d9..19249b75 100644 --- a/layer-domain/src/main/java/org/layer/domain/analyze/entity/Analyze.java +++ b/layer-domain/src/main/java/org/layer/domain/analyze/entity/Analyze.java @@ -80,6 +80,16 @@ public AnalyzeDetail getTopCountAnalyzeDetailBy(AnalyzeDetailType analyzeDetailT .orElse(getEmptyAnalyzeDetail()); } + public List getAnalyzeDetailsBy(AnalyzeDetailType analyzeDetailType){ + List filteredDetails = new ArrayList<>(); + for(AnalyzeDetail detail : analyzeDetails){ + if(detail.getAnalyzeDetailType().equals(analyzeDetailType)){ + filteredDetails.add(detail); + } + } + return filteredDetails; + } + private AnalyzeDetail getEmptyAnalyzeDetail(){ return AnalyzeDetail.builder().build(); } diff --git a/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java b/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java index a8d6accb..61feb43c 100644 --- a/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java +++ b/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java @@ -7,6 +7,8 @@ import java.util.List; import java.util.UUID; +import org.layer.domain.actionItem.entity.ActionItem; +import org.layer.domain.actionItem.repository.ActionItemRepository; import org.layer.domain.analyze.entity.Analyze; import org.layer.domain.analyze.entity.AnalyzeDetail; import org.layer.domain.analyze.enums.AnalyzeDetailType; @@ -21,9 +23,11 @@ import org.layer.domain.retrospect.entity.AnalysisStatus; import org.layer.domain.retrospect.entity.Retrospect; import org.layer.domain.retrospect.repository.RetrospectRepository; +import org.layer.domain.space.entity.Space; import org.layer.domain.space.entity.Team; import org.layer.domain.space.repository.MemberSpaceRelationRepository; import org.layer.ai.dto.response.OpenAIResponse; +import org.layer.domain.space.repository.SpaceRepository; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -39,11 +43,13 @@ public class AIAnalyzeService { private static final int FIRST_RANK = 1; private static final String RETROSPECT_LOCK_KEY = "retrospect:lock:"; + private final SpaceRepository spaceRepository; private final RetrospectRepository retrospectRepository; private final AnalyzeRepository analyzeRepository; private final QuestionRepository questionRepository; private final AnswerRepository answerRepository; private final MemberSpaceRelationRepository memberSpaceRelationRepository; + private final ActionItemRepository actionItemRepository; private final OpenAIService openAIService; @@ -74,8 +80,10 @@ public void createAnalyze(Long retrospectId) { retrospect.validateAnalysisStatusIsNotDone(); // 답변 조회 - Questions questions = new Questions(questionRepository.findAllByRetrospectIdOrderByQuestionOrder(retrospectId)); - Answers answers = new Answers(answerRepository.findAllByRetrospectIdAndAnswerStatus(retrospectId, AnswerStatus.DONE)); + Questions questions = new Questions( + questionRepository.findAllByRetrospectIdOrderByQuestionOrder(retrospectId)); + Answers answers = new Answers( + answerRepository.findAllByRetrospectIdAndAnswerStatus(retrospectId, AnswerStatus.DONE)); Long rangeQuestionId = questions.extractEssentialQuestionIdBy(QuestionType.RANGER); Long numberQuestionId = questions.extractEssentialQuestionIdBy(QuestionType.NUMBER); @@ -85,9 +93,20 @@ public void createAnalyze(Long retrospectId) { OpenAIResponse aiResponse = openAIService.createAnalyze(totalAnswer); OpenAIResponse.Content content = aiResponse.parseContent(); - Analyze teamAnalyze = getAnalyzeEntity(retrospectId, answers, rangeQuestionId, numberQuestionId, content, null, AnalyzeType.TEAM); + Analyze teamAnalyze = getAnalyzeEntity(retrospectId, answers, rangeQuestionId, numberQuestionId, content, + null, AnalyzeType.TEAM); analyzeRepository.save(teamAnalyze); + + // 팀 실행 목표 생성 + Space space = spaceRepository.findByIdOrThrow(retrospect.getSpaceId()); + + List actionItems = createActionItemsFromAnalyzeDetails( + teamAnalyze.getAnalyzeDetailsBy(AnalyzeDetailType.IMPROVEMENT), + retrospect.getSpaceId(), + retrospect.getId(), space.getLeaderId()); + actionItemRepository.saveAll(actionItems); + // 팀원 개인마다의 분석 요청 Team team = new Team(memberSpaceRelationRepository.findAllBySpaceId(retrospect.getSpaceId())); @@ -96,7 +115,8 @@ public void createAnalyze(Long retrospectId) { String individualAnswer = answers.getIndividualAnswer(rangeQuestionId, numberQuestionId, memberId); OpenAIResponse aiIndividualResponse = openAIService.createAnalyze(individualAnswer); OpenAIResponse.Content individualContent = aiIndividualResponse.parseContent(); - return getAnalyzeEntity(retrospectId, answers, rangeQuestionId, numberQuestionId, individualContent, memberId, AnalyzeType.INDIVIDUAL); + return getAnalyzeEntity(retrospectId, answers, rangeQuestionId, numberQuestionId, individualContent, + memberId, AnalyzeType.INDIVIDUAL); }) .toList(); analyzeRepository.saveAll(individualAnalyzes); @@ -126,11 +146,31 @@ private Analyze getAnalyzeEntity(Long retrospectId, Answers answers, Long rangeQ List analyzeDetails = createAnalyzeDetails(content); return createAnalyzeEntity(retrospectId, memberId, answers.getScoreCount(numberQuestionId, SCORE_ONE, memberId), - answers.getScoreCount(numberQuestionId, SCORE_TWO, memberId), answers.getScoreCount(numberQuestionId, SCORE_THREE, memberId), - answers.getScoreCount(numberQuestionId, SCORE_FOUR, memberId), answers.getScoreCount(numberQuestionId, SCORE_FIVE, memberId), + answers.getScoreCount(numberQuestionId, SCORE_TWO, memberId), + answers.getScoreCount(numberQuestionId, SCORE_THREE, memberId), + answers.getScoreCount(numberQuestionId, SCORE_FOUR, memberId), + answers.getScoreCount(numberQuestionId, SCORE_FIVE, memberId), answers.getGoalCompletionRate(rangeQuestionId), analyzeType, analyzeDetails); } + private List createActionItemsFromAnalyzeDetails(List analyzeDetails, Long spaceId, + Long retrospectId, Long memberId) { + List actionItems = new ArrayList<>(); + int order = 1; + for (AnalyzeDetail detail : analyzeDetails) { + ActionItem actionItem = ActionItem.builder() + .retrospectId(retrospectId) + .spaceId(spaceId) + .memberId(memberId) + .content(detail.getContent()) + .actionItemOrder(order) + .build(); + actionItems.add(actionItem); + order++; + } + return actionItems; + } + private List createAnalyzeDetails(OpenAIResponse.Content content) { List analyzeDetails = new ArrayList<>(); @@ -162,7 +202,8 @@ private List createAnalyzeDetail(List analyzeDetails) { return Analyze.builder()