From a2dbb8cc568d18e239a6b8f504fd816609585aa5 Mon Sep 17 00:00:00 2001 From: infiniment Date: Mon, 2 Feb 2026 00:05:09 +0900 Subject: [PATCH 01/66] =?UTF-8?q?[Feat]=20=ED=8C=8C=ED=8A=B8=EB=B3=84=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=ED=98=84=ED=99=A9=20=ED=8C=8C=ED=8A=B8?= =?UTF-8?q?=EB=B3=84=20=EC=9E=91=EC=97=85=20=EC=A7=84=ED=96=89=EB=A5=A0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java --- .../team/process/service/ProcessService.java | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index b2c8144b..221fd3eb 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -1517,6 +1517,107 @@ private int rate(long part, long total) { return (int) Math.round(part * 100.0 / total); } + // 파트별 작업 진행률 조회 서비스 + @Transactional(readOnly = true) + public ProcessProgressSummaryResDto getPartProgressSummary(Long projectId, Long userId) { + assertActiveProjectMember(projectId, userId); + + if (!projectRepository.existsById(projectId)) { + throw new ProcessException(ProcessErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId); + } + + List statuses = List.of( + ProcessStatus.PLANNING, + ProcessStatus.IN_PROGRESS, + ProcessStatus.DONE + ); + + RoleField custom = RoleField.CUSTOM; + + List roleRows = + processRepository.countRoleLaneStatusForProgressSummary(projectId, custom, statuses); + + List customRows = + processRepository.countCustomLaneStatusForProgressSummary(projectId, custom, statuses); + + // laneKey -> status -> count + Map> laneCounts = new LinkedHashMap<>(); + + // ROLE lanes + for (var r : roleRows) { + RoleField rf = r.getRoleField(); + if (rf == null) continue; + + String laneKey = "ROLE:" + rf.name(); + laneCounts.computeIfAbsent(laneKey, k -> new EnumMap<>(ProcessStatus.class)) + .put(r.getStatus(), r.getCnt()); + } + + // CUSTOM lanes + for (var r : customRows) { + String name = r.getCustomName(); + if (name == null) continue; + + String trimmed = name.trim(); + if (trimmed.isBlank()) continue; + + String laneKey = "CUSTOM:" + trimmed; + laneCounts.computeIfAbsent(laneKey, k -> new EnumMap<>(ProcessStatus.class)) + .put(r.getStatus(), r.getCnt()); + } + + + // 정렬 : ROLE 먼저, CUSTOM 다음, 이름 오름차순으로 + List sortedKeys = laneCounts.keySet().stream() + .sorted((a, b) -> { + int ta = a.startsWith("ROLE:") ? 0 : 1; + int tb = b.startsWith("CUSTOM:") ? 0 : 1; + if(ta != tb) return Integer.compare(ta, tb); + return a.compareTo(b); + }) + .toList(); + + List lanes = sortedKeys.stream() + .map(laneKey -> { + EnumMap m = laneCounts.get(laneKey); + + long planning = m.getOrDefault(ProcessStatus.PLANNING, 0L); + long inProgress = m.getOrDefault(ProcessStatus.IN_PROGRESS, 0L); + long done = m.getOrDefault(ProcessStatus.DONE, 0L); + long total = planning + inProgress + done; + + int planningRate = rate(planning, total); + int inProgressRate = rate(inProgress, total); + int doneRate = (total == 0) ? 0 : Math.max(0, 100 - planningRate - inProgressRate); + + LaneType laneType = laneKey.startsWith("ROLE:") ? LaneType.ROLE : LaneType.CUSTOM; + String laneName = laneType == LaneType.ROLE + ? laneKey.substring("ROLE:".length()) + : laneKey.substring("CUSTOM:".length()); + + return new LaneProgressResDto( + laneKey, + laneType, + laneName, + planning, + inProgress, + done, + total, + planningRate, + inProgressRate, + doneRate + ); + }) + .toList(); + + return new ProcessProgressSummaryResDto(lanes); + } + + private int rate(long part, long total) { + if (total == 0) return 0; + return (int) Math.round(part * 100.0 / total); + } + // 프로세스 위치 상태 정렬 변경 서비스 @Transactional From 8e6c3d04c135ef6f12028a7977ee583227ae9c6a Mon Sep 17 00:00:00 2001 From: infiniment Date: Thu, 5 Feb 2026 19:40:08 +0900 Subject: [PATCH 02/66] =?UTF-8?q?[Fix]=20=EC=B6=A9=EB=8F=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/process/service/ProcessService.java | 102 ------------------ 1 file changed, 102 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index 221fd3eb..e900947a 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -1517,108 +1517,6 @@ private int rate(long part, long total) { return (int) Math.round(part * 100.0 / total); } - // 파트별 작업 진행률 조회 서비스 - @Transactional(readOnly = true) - public ProcessProgressSummaryResDto getPartProgressSummary(Long projectId, Long userId) { - assertActiveProjectMember(projectId, userId); - - if (!projectRepository.existsById(projectId)) { - throw new ProcessException(ProcessErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId); - } - - List statuses = List.of( - ProcessStatus.PLANNING, - ProcessStatus.IN_PROGRESS, - ProcessStatus.DONE - ); - - RoleField custom = RoleField.CUSTOM; - - List roleRows = - processRepository.countRoleLaneStatusForProgressSummary(projectId, custom, statuses); - - List customRows = - processRepository.countCustomLaneStatusForProgressSummary(projectId, custom, statuses); - - // laneKey -> status -> count - Map> laneCounts = new LinkedHashMap<>(); - - // ROLE lanes - for (var r : roleRows) { - RoleField rf = r.getRoleField(); - if (rf == null) continue; - - String laneKey = "ROLE:" + rf.name(); - laneCounts.computeIfAbsent(laneKey, k -> new EnumMap<>(ProcessStatus.class)) - .put(r.getStatus(), r.getCnt()); - } - - // CUSTOM lanes - for (var r : customRows) { - String name = r.getCustomName(); - if (name == null) continue; - - String trimmed = name.trim(); - if (trimmed.isBlank()) continue; - - String laneKey = "CUSTOM:" + trimmed; - laneCounts.computeIfAbsent(laneKey, k -> new EnumMap<>(ProcessStatus.class)) - .put(r.getStatus(), r.getCnt()); - } - - - // 정렬 : ROLE 먼저, CUSTOM 다음, 이름 오름차순으로 - List sortedKeys = laneCounts.keySet().stream() - .sorted((a, b) -> { - int ta = a.startsWith("ROLE:") ? 0 : 1; - int tb = b.startsWith("CUSTOM:") ? 0 : 1; - if(ta != tb) return Integer.compare(ta, tb); - return a.compareTo(b); - }) - .toList(); - - List lanes = sortedKeys.stream() - .map(laneKey -> { - EnumMap m = laneCounts.get(laneKey); - - long planning = m.getOrDefault(ProcessStatus.PLANNING, 0L); - long inProgress = m.getOrDefault(ProcessStatus.IN_PROGRESS, 0L); - long done = m.getOrDefault(ProcessStatus.DONE, 0L); - long total = planning + inProgress + done; - - int planningRate = rate(planning, total); - int inProgressRate = rate(inProgress, total); - int doneRate = (total == 0) ? 0 : Math.max(0, 100 - planningRate - inProgressRate); - - LaneType laneType = laneKey.startsWith("ROLE:") ? LaneType.ROLE : LaneType.CUSTOM; - String laneName = laneType == LaneType.ROLE - ? laneKey.substring("ROLE:".length()) - : laneKey.substring("CUSTOM:".length()); - - return new LaneProgressResDto( - laneKey, - laneType, - laneName, - planning, - inProgress, - done, - total, - planningRate, - inProgressRate, - doneRate - ); - }) - .toList(); - - return new ProcessProgressSummaryResDto(lanes); - } - - private int rate(long part, long total) { - if (total == 0) return 0; - return (int) Math.round(part * 100.0 / total); - } - - // 프로세스 위치 상태 정렬 변경 서비스 @Transactional public ProcessOrderUpdateResDto updateProcessOrder(Long projectId, Long userId, Long processId, ProcessOrderUpdateReqDto req) { From 487f9b39777b19a03cabc3c9bdc378bd28de8601 Mon Sep 17 00:00:00 2001 From: infiniment Date: Thu, 5 Feb 2026 23:37:18 +0900 Subject: [PATCH 03/66] =?UTF-8?q?[Feat]=20=EC=9C=84=ED=81=AC=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD/=ED=95=A0?= =?UTF-8?q?=EC=9D=BC=20=ED=95=AD=EB=AA=A9=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WeekMissionController.java | 76 +++ .../dto/req/ProcessTaskItemReorderReqDto.java | 10 +- .../req/WeekMissionStatusUpdateReqDto.java | 9 + .../req/WeekMissionTaskItemReorderReqDto.java | 17 + .../req/WeekMissionTaskItemUpdateReqDto.java | 18 + .../dto/res/WeekMissionDetailResDto.java | 74 +++ .../dto/res/WeekMissionWeekResDto.java | 61 +++ .../service/ProcessTaskItemService.java | 231 +++++++- .../process/service/WeekMissionService.java | 513 ++++++++++++++++++ .../team/project/service/ProjectService.java | 2 +- .../team/{process => }/ProjectTeamRole.java | 3 +- .../analysis/ProjectTeamRoleRepository.java | 2 +- .../team/ProjectUserRepository.java | 25 + .../team/process/ProcessRepository.java | 157 +++++- .../process/ProcessTaskItemRepository.java | 31 ++ 15 files changed, 1192 insertions(+), 37 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionStatusUpdateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemReorderReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemUpdateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionWeekResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java rename nect-core/src/main/java/com/nect/core/entity/team/{process => }/ProjectTeamRole.java (91%) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java new file mode 100644 index 00000000..825b4ac2 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java @@ -0,0 +1,76 @@ +package com.nect.api.domain.team.process.controller; + +import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; +import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; +import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; +import com.nect.api.domain.team.process.service.WeekMissionService; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}/week-missions") +public class WeekMissionController { + + private final WeekMissionService weekMissionService; + + // 주차별 위크미션 조회 + @GetMapping("/week") + public ApiResponse getWeekMissions( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(value = "start_date", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(value = "weeks", required = false, defaultValue = "1") Integer weeks + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(weekMissionService.getWeekMissions(projectId, userId, startDate, weeks)); + } + + // 위크미션 상세 조회(체크리스트 포함) + @GetMapping("/{processId}") + public ApiResponse getWeekMissionDetail( + @PathVariable Long projectId, + @PathVariable Long processId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok( + weekMissionService.getDetail(projectId, userDetails.getUserId(), processId) + ); + } + + // 위크미션 상태 변경 + @PatchMapping("/{processId}/status") + public ApiResponse updateWeekMissionStatus( + @PathVariable Long projectId, + @PathVariable Long processId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody WeekMissionStatusUpdateReqDto req + ) { + weekMissionService.updateWeekMissionStatus(projectId, userDetails.getUserId(), processId, req); + return ApiResponse.ok(null); + } + + // 위크미션 TASK 내 항목 내용 수정 + @PatchMapping("/{processId}/task-items/{taskItemId}") + public ApiResponse updateWeekMissionTaskItem( + @PathVariable Long projectId, + @PathVariable Long processId, + @PathVariable Long taskItemId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody WeekMissionTaskItemUpdateReqDto req + ) { + return ApiResponse.ok( + weekMissionService.updateWeekMissionTaskItem(projectId, userDetails.getUserId(), processId, taskItemId, req) + ); + } + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java index ace1b35e..aec05c72 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java @@ -1,10 +1,18 @@ package com.nect.api.domain.team.process.dto.req; import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; import java.util.List; public record ProcessTaskItemReorderReqDto( @JsonProperty("ordered_task_item_ids") - List orderedTaskItemIds + List orderedTaskItemIds, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName + ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionStatusUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionStatusUpdateReqDto.java new file mode 100644 index 00000000..97531f2d --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionStatusUpdateReqDto.java @@ -0,0 +1,9 @@ +package com.nect.api.domain.team.process.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.process.enums.ProcessStatus; + +public record WeekMissionStatusUpdateReqDto( + @JsonProperty("status") + ProcessStatus status +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemReorderReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemReorderReqDto.java new file mode 100644 index 00000000..9e689c22 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemReorderReqDto.java @@ -0,0 +1,17 @@ +package com.nect.api.domain.team.process.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +import java.util.List; + +public record WeekMissionTaskItemReorderReqDto( + @JsonProperty("ordered_task_item_ids") + List orderedTaskItemIds, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemUpdateReqDto.java new file mode 100644 index 00000000..c00df089 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemUpdateReqDto.java @@ -0,0 +1,18 @@ +package com.nect.api.domain.team.process.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +public record WeekMissionTaskItemUpdateReqDto( + @JsonProperty("content") + String content, + + @JsonProperty("is_done") + Boolean isDone, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java new file mode 100644 index 00000000..d758526f --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java @@ -0,0 +1,74 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.nect.core.entity.team.process.enums.ProcessStatus; +import com.nect.core.entity.user.enums.RoleField; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public record WeekMissionDetailResDto( + @JsonProperty("process_id") + Long processId, + + @JsonProperty("mission_number") + Integer missionNumber, + + @JsonProperty("title") + String title, + + @JsonProperty("content") + String content, + + @JsonProperty("status") + ProcessStatus status, + + @JsonProperty("start_date") + LocalDate startDate, + + @JsonProperty("dead_line") + LocalDate deadLine, + + @JsonProperty("assignee") + AssigneeDto assignee, + + @JsonProperty("task_groups") + List taskGroups, + + @JsonProperty("task_items") + List taskItems, + + @JsonProperty("created_at") + LocalDateTime createdAt, + + @JsonProperty("updated_at") + LocalDateTime updatedAt +) { + public record AssigneeDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("name") + String name, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} + + public record TaskGroupResDto( + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_field_name") + String customFieldName, + + @JsonProperty("items") + List items + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionWeekResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionWeekResDto.java new file mode 100644 index 00000000..47b6efbc --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionWeekResDto.java @@ -0,0 +1,61 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.process.enums.ProcessStatus; + +import java.time.LocalDate; +import java.util.List; + +public record WeekMissionWeekResDto( + @JsonProperty("week_start") + LocalDate weekStart, + + @JsonProperty("week_end") + LocalDate weekEnd, + + @JsonProperty("missions") + List missions +) { + public record WeekMissionCardResDto( + @JsonProperty("process_id") + Long processId, + + @JsonProperty("mission_number") + Integer missionNumber, + + @JsonProperty("status") + ProcessStatus status, + + @JsonProperty("title") + String title, + + @JsonProperty("start_date") + LocalDate startDate, + + @JsonProperty("dead_line") + LocalDate deadLine, + + @JsonProperty("left_day") + Integer leftDay, + + @JsonProperty("done_count") + int doneCount, + + @JsonProperty("total_count") + int totalCount, + + @JsonProperty("assignee") + AssigneeProfileDto assignee + ) {} + + public record AssigneeProfileDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java index 6bb71493..01c928d6 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java @@ -1,5 +1,7 @@ package com.nect.api.domain.team.process.service; +import com.nect.api.domain.notifications.command.NotificationCommand; +import com.nect.api.domain.notifications.facade.NotificationFacade; import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; import com.nect.api.domain.team.process.dto.req.ProcessTaskItemReorderReqDto; import com.nect.api.domain.team.process.dto.req.ProcessTaskItemUpsertReqDto; @@ -7,13 +9,23 @@ import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; import com.nect.api.domain.team.process.enums.ProcessErrorCode; import com.nect.api.domain.team.process.exception.ProcessException; +import com.nect.core.entity.notifications.enums.NotificationClassification; +import com.nect.core.entity.notifications.enums.NotificationScope; +import com.nect.core.entity.notifications.enums.NotificationType; +import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.history.enums.HistoryAction; import com.nect.core.entity.team.history.enums.HistoryTargetType; import com.nect.core.entity.team.process.Process; import com.nect.core.entity.team.process.ProcessTaskItem; +import com.nect.core.entity.team.process.enums.ProcessType; +import com.nect.core.entity.user.User; +import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.process.ProcessRepository; import com.nect.core.repository.team.process.ProcessTaskItemRepository; +import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +41,9 @@ public class ProcessTaskItemService { private final ProjectUserRepository projectUserRepository; private final ProjectHistoryPublisher historyPublisher; + private final UserRepository userRepository; + private final NotificationFacade notificationFacade; + // 헬퍼 메서드 private void assertWritableMember(Long projectId, Long userId) { if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { @@ -55,6 +70,7 @@ private ProcessTaskItem getTaskItem(Long processId, Long taskItemId) { )); } + // 전체 정규화 private void normalizeSortOrder(Long processId) { List items = taskItemRepository.findAllByProcessIdAndDeletedAtIsNullOrderBySortOrderAsc(processId); @@ -69,6 +85,44 @@ private void normalizeSortOrder(Long processId) { } } + // 파트별 정규화 + private void normalizeSortOrderByGroup(Long processId, RoleField roleField, String customName) { + List items = taskItemRepository + .findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameOrderBySortOrderAsc( + processId, roleField, customName + ); + + items = items.stream() + .sorted(Comparator.comparing(t -> t.getSortOrder() == null ? Integer.MAX_VALUE : t.getSortOrder())) + .toList(); + + int i = 0; + for (ProcessTaskItem it : items) { + it.updateSortOrder(i++); + } + } + + private void assertWeekMissionLeader(Long projectId, Long userId) { + boolean isLeader = projectUserRepository + .existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus(projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE); + + if (!isLeader) { + throw new ProcessException( + ProcessErrorCode.FORBIDDEN, + "WEEK_MISSION은 프로젝트 리더만 수정할 수 있습니다. projectId=" + projectId + ", userId=" + userId + ); + } + } + + private void assertReorderPermission(Long projectId, Long userId, Process process) { + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + assertWeekMissionLeader(projectId, userId); + return; + } + assertWritableMember(projectId, userId); + } + + // 항목 생성 서비스 @Transactional public ProcessTaskItemResDto create(Long projectId, Long userId, Long processId, ProcessTaskItemUpsertReqDto req) { @@ -76,6 +130,9 @@ public ProcessTaskItemResDto create(Long projectId, Long userId, Long processId, Process process = getActiveProcess(projectId, processId); + // 위크미션 TASK 수정 권한(리더만 가능) + assertReorderPermission(projectId, userId, process); + if (req.content() == null || req.content().isBlank()) { throw new ProcessException(ProcessErrorCode.INVALID_TASK_ITEM_CONTENT); } @@ -106,13 +163,6 @@ public ProcessTaskItemResDto create(Long projectId, Long userId, Long processId, // 최종 정규화 normalizeSortOrder(processId); - // TODO(Notification): - // - 프로젝트 멤버 전체 또는 해당 프로세스 관련자(assignee/mention)에게 "업무 항목 추가" 알림 전송 - // - 유저/멤버십 붙으면 NotificationFacade 주입 후 notify 호출 - // - 권장: AFTER_COMMIT 이벤트로 보내기 - // notifyProjectMembersTodo(projectId, actorUserId, processId, "TASK_ITEM_CREATED"); - // notifyMentionsTodo(projectId, actorUserId, processId, /* process mention ids */); - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("taskItemId", saved.getId()); @@ -221,13 +271,6 @@ public ProcessTaskItemResDto update(Long projectId, Long userId, Long processId, "sortOrder", item.getSortOrder() )); - // TODO(Notification): - // - 유저/멤버십 연동 후 수신자 결정(프로젝트 멤버 / 해당 프로세스 assignee / mention 등) - // - "업무 항목 수정" 알림 전송 - // - meta에 변경 요약 포함 권장(예: done 토글, 내용 변경, 순서 변경) - // - 권장: AFTER_COMMIT 이벤트 리스너로 전송 - // notifyTaskItemUpdatedTodo(projectId, actorUserId, processId, item.getId(), ...); - historyPublisher.publish( projectId, userId, @@ -267,8 +310,6 @@ public void delete(Long projectId, Long userId, Long processId, Long taskItemId) normalizeSortOrder(processId); - // TODO(Notification) - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("taskItemId", taskItemId); @@ -285,15 +326,44 @@ public void delete(Long projectId, Long userId, Long processId, Long taskItemId) meta ); - // TODO(TEAM EVENT FACADE): 추후 ActivityFacade로 통합 } - // 업무 위치 변경 서비스 + private List loadWorkspaceReceivers(Long projectId, Long actorId) { + return projectUserRepository.findAllUsersByProjectId(projectId).stream() + .filter(u -> !u.getUserId().equals(actorId)) + .toList(); + } + + private void notifyWorkspaceWeekMissionUpdated(Process process, Long actorId) { + // WEEK_MISSION만 알림 + if (process.getProcessType() != com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) return; + + Project project = process.getProject(); + User actor = userRepository.findById(actorId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.USER_NOT_FOUND, "userId=" + actorId)); + + List receivers = loadWorkspaceReceivers(project.getId(), actor.getUserId()); + if (receivers.isEmpty()) return; + + NotificationCommand command = new NotificationCommand( + NotificationType.WORKSPACE_MISSION_UPDATED, + NotificationClassification.WORK_STATUS, + NotificationScope.WORKSPACE_ONLY, + process.getId(), + new Object[]{ process.getMissionNumber() }, + new Object[]{ process.getTitle() }, + project + ); + + notificationFacade.notify(receivers, command); + } + + // 업무 위치 변경 서비스 (멤버형, 리더형을 하나로 관리) @Transactional public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long processId, ProcessTaskItemReorderReqDto req) { - assertWritableMember(projectId, userId); + Process process = getActiveProcess(projectId, processId); - getActiveProcess(projectId, processId); + assertReorderPermission(projectId, userId, process); if (req == null || req.orderedTaskItemIds() == null || req.orderedTaskItemIds().isEmpty()) { throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ordered_task_item_ids is empty"); @@ -311,11 +381,118 @@ public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long pr throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ordered_task_item_ids contains duplicates"); } + // 위크미션 TASK내에 필드별 항목 리스트 / 전체(멤버형) + RoleField roleField = req.roleField(); + String customName = req.customRoleFieldName(); + + boolean groupMode = (roleField != null); + + if (groupMode) { + // 분야별 모드 유효성 검사 + if (roleField == RoleField.CUSTOM) { + if (customName == null || customName.isBlank()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "CUSTOM이면 custom_role_field_name 필수"); + } + customName = customName.trim(); + } else { + // CUSTOM이 아니면 null로 고정 + customName = null; + } + + // 분야별 정규화(꼬임 방지) + normalizeSortOrderByGroup(processId, roleField, customName); + + // beforeIds (분야별) + List groupAll = taskItemRepository + .findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameOrderBySortOrderAsc( + processId, roleField, customName + ); + + List beforeIds = groupAll.stream().map(ProcessTaskItem::getId).toList(); + + // 변경 없으면 그대로 반환 + if (beforeIds.equals(orderedIds)) { + List resItems = groupAll.stream() + .map(t -> new ProcessTaskItemResDto(t.getId(), t.getContent(), t.isDone(), t.getSortOrder(), t.getDoneAt())) + .toList(); + return new ProcessTaskItemReorderResDto(processId, resItems); + } + + // 전체 포함 정책(그룹 단위) + if (groupAll.size() != orderedIds.size()) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "ordered_task_item_ids must include all task items of the group" + ); + } + + // 요청 ids가 모두 해당 그룹의 항목인지 검증 + List targets = + taskItemRepository.findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameAndIdIn( + processId, roleField, customName, orderedIds + ); + + if (targets.size() != orderedIds.size()) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "ordered_task_item_ids contains invalid taskItemId(s) for the group" + ); + } + + Map map = targets.stream() + .collect(Collectors.toMap(ProcessTaskItem::getId, t -> t)); + + // 재정렬 반영(분야별 그룹 내부 0..n-1) + int i = 0; + for (Long id : orderedIds) { + ProcessTaskItem item = map.get(id); + item.updateSortOrder(i++); + } + + Map meta = new LinkedHashMap<>(); + meta.put("processId", processId); + meta.put("processType", process.getProcessType() == null ? null : process.getProcessType().name()); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + + meta.put("groupMode", true); + meta.put("roleField", roleField.name()); + meta.put("customRoleFieldName", customName); + + meta.put("beforeOrderedTaskItemIds", beforeIds); + meta.put("afterOrderedTaskItemIds", orderedIds); + + historyPublisher.publish( + projectId, + userId, + HistoryAction.TASK_ITEM_REORDERED, + HistoryTargetType.PROCESS, + processId, + meta + ); + + notifyWorkspaceWeekMissionUpdated(process, userId); + + // 응답(요청 순서대로) + List resItems = orderedIds.stream() + .map(id -> { + ProcessTaskItem t = map.get(id); + return new ProcessTaskItemResDto( + t.getId(), t.getContent(), t.isDone(), t.getSortOrder(), t.getDoneAt() + ); + }) + .toList(); + + return new ProcessTaskItemReorderResDto(processId, resItems); + } + + /* + * 멤버형 프로세스 모달 전용 + * */ + // 꼬임 방지용 정규화 normalizeSortOrder(processId); - - // TODO(HISTORY/NOTI): before orderedIds 스냅샷이 필요하면 여기서 조회 List beforeIds = taskItemRepository .findAllByProcessIdAndDeletedAtIsNullOrderBySortOrderAsc(processId) .stream() @@ -367,9 +544,13 @@ public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long pr item.updateSortOrder(i++); } - // TODO(Notification): Map meta = new LinkedHashMap<>(); meta.put("processId", processId); + meta.put("processType", process.getProcessType() == null ? null : process.getProcessType().name()); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + + meta.put("groupMode", false); meta.put("beforeOrderedTaskItemIds", beforeIds); meta.put("afterOrderedTaskItemIds", orderedIds); @@ -381,7 +562,9 @@ public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long pr processId, meta ); - // TODO(TEAM EVENT FACADE): 추후 ActivityFacade로 통합 + + notifyWorkspaceWeekMissionUpdated(process, userId); + List resItems = orderedIds.stream() .map(id -> { diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java new file mode 100644 index 00000000..5ef99da4 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -0,0 +1,513 @@ +package com.nect.api.domain.team.process.service; + +import com.nect.api.domain.notifications.command.NotificationCommand; +import com.nect.api.domain.notifications.facade.NotificationFacade; +import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; +import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; +import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; +import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; +import com.nect.api.domain.team.process.enums.ProcessErrorCode; +import com.nect.api.domain.team.process.exception.ProcessException; +import com.nect.core.entity.notifications.enums.NotificationClassification; +import com.nect.core.entity.notifications.enums.NotificationScope; +import com.nect.core.entity.notifications.enums.NotificationType; +import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.team.history.enums.HistoryAction; +import com.nect.core.entity.team.history.enums.HistoryTargetType; +import com.nect.core.entity.team.process.Process; +import com.nect.core.entity.team.process.ProcessTaskItem; +import com.nect.core.entity.team.process.enums.ProcessStatus; +import com.nect.core.entity.user.User; +import com.nect.core.entity.user.enums.RoleField; +import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.team.process.ProcessRepository; +import com.nect.core.repository.team.process.ProcessTaskItemRepository; +import com.nect.core.repository.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class WeekMissionService { + + private final ProjectRepository projectRepository; + private final ProjectUserRepository projectUserRepository; + private final ProcessRepository processRepository; + private final ProcessTaskItemRepository processTaskItemRepository; + + private final UserRepository userRepository; + private final NotificationFacade notificationFacade; + private final ProjectHistoryPublisher historyPublisher; + + private void assertActiveProjectMember(Long projectId, Long userId) { + if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { + throw new ProcessException( + ProcessErrorCode.FORBIDDEN, + "프로젝트 멤버가 아닙니다. projectId=" + projectId + ", userId=" + userId + ); + } + } + + private void assertActiveLeader(Long projectId, Long userId) { + boolean ok = projectUserRepository.existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus( + projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE + ); + if (!ok) { + throw new ProcessException(ProcessErrorCode.FORBIDDEN, "WEEK_MISSION 수정은 프로젝트 리더만 가능합니다."); + } + } + + private String normalizeCustom(RoleField roleField, String customName) { + if (roleField == RoleField.CUSTOM) { + if (customName == null || customName.isBlank()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "CUSTOM이면 custom_role_field_name 필수"); + } + return customName.trim(); + } + return null; + } + + private void normalizeGroupOrders(Long processId, RoleField roleField, String customName) { + List items = processTaskItemRepository + .findWeekMissionGroupItemsOrdered(processId, roleField, customName); + + int i = 0; + for (ProcessTaskItem it : items) { + it.updateSortOrder(i++); + } + } + + private void assertProjectExists(Long projectId) { + if (!projectRepository.existsById(projectId)) { + throw new ProcessException(ProcessErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId); + } + } + + private Integer calcLeftDay(LocalDate deadLine) { + if (deadLine == null) return null; + long diff = ChronoUnit.DAYS.between(LocalDate.now(), deadLine); + return (int) Math.max(diff, 0); + } + + + private WeekMissionWeekResDto toWeekRes( + LocalDate start, + LocalDate end, + List rows, + WeekMissionWeekResDto.AssigneeProfileDto leaderFallback + ){ + List cards = rows.stream() + .map(r -> { + long done = (r.getDoneCount() == null) ? 0L : r.getDoneCount(); + long total = (r.getTotalCount() == null) ? 0L : r.getTotalCount(); + + WeekMissionWeekResDto.AssigneeProfileDto assignee = + (r.getLeaderUserId() != null) + ? new WeekMissionWeekResDto.AssigneeProfileDto( + r.getLeaderUserId(), + r.getLeaderNickname(), + r.getLeaderProfileImageUrl() + ) + : leaderFallback; + + return new WeekMissionWeekResDto.WeekMissionCardResDto( + r.getProcessId(), + r.getMissionNumber(), + r.getStatus(), + r.getTitle(), + r.getStartDate(), + r.getDeadLine(), + calcLeftDay(r.getDeadLine()), + (int) done, + (int) total, + assignee + ); + }) + .toList(); + + return new WeekMissionWeekResDto(start, end, cards); + } + + private List loadWorkspaceReceivers(Long projectId, Long actorId) { + return projectUserRepository.findAllUsersByProjectId(projectId).stream() + .filter(u -> !u.getUserId().equals(actorId)) + .toList(); + } + + private void notifyWorkspaceWeekMissionUpdated(Project project, User actor, Process process) { + List receivers = loadWorkspaceReceivers(project.getId(), actor.getUserId()); + if (receivers == null || receivers.isEmpty()) return; + + NotificationCommand command = new NotificationCommand( + NotificationType.WORKSPACE_MISSION_UPDATED, + NotificationClassification.WORK_STATUS, + NotificationScope.WORKSPACE_ONLY, + process.getId(), + new Object[]{ process.getMissionNumber() }, + new Object[]{ process.getTitle() }, + project + ); + + notificationFacade.notify(receivers, command); + } + + private void publishWeekMissionHistory( + Long projectId, Long userId, Long processId, + HistoryAction action, + Map meta + ) { + historyPublisher.publish( + projectId, + userId, + action, + HistoryTargetType.PROCESS, + processId, + meta + ); + } + + /** + * 주차(월~일) 기준 WEEK_MISSION 목록 (정규화 O) + * GET /week-missions/week?start_date=YYYY-MM-DD&weeks=4 + */ + @Transactional(readOnly = true) + public WeekMissionWeekResDto getWeekMissions(Long projectId, Long userId, LocalDate startDate, Integer weeks) { + assertActiveProjectMember(projectId, userId); + assertProjectExists(projectId); + + int w = (weeks == null) ? 1 : weeks; + + // 방어 (원하는 상한 정하면 됨) + if (w <= 0) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "weeks must be >= 1"); + } + if (w > 12) { // 예: 과도 조회 방지 + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "weeks is too large"); + } + + LocalDate fallback = processRepository.findMinWeekMissionStartAt(projectId); + if (fallback == null) { + // 프로젝트에 아직 위크미션이 없다면(혹은 startAt이 전부 null) + fallback = LocalDate.now(); + } + + LocalDate weekStart = resolveWeekStart(startDate, fallback); + LocalDate end = weekStart.plusDays(w * 7L - 1); + + var rows = processRepository.findWeekMissionCardsInRange(projectId, weekStart, end); + + WeekMissionWeekResDto.AssigneeProfileDto leaderFallback = projectUserRepository + .findActiveLeaderProfile(projectId) + .map(r -> new WeekMissionWeekResDto.AssigneeProfileDto( + r.getUserId(), + r.getNickname(), + r.getProfileImageUrl() + )) + .orElse(null); + + return toWeekRes(weekStart, end, rows, leaderFallback); + } + + /** + * WEEK_MISSION 상세 (체크리스트 포함) + * GET /week-missions/{processId} + */ + @Transactional(readOnly = true) + public WeekMissionDetailResDto getDetail(Long projectId, Long userId, Long processId) { + assertActiveProjectMember(projectId, userId); + + Process process = processRepository.findWeekMissionDetail(projectId, processId) + .orElseThrow(() -> new ProcessException( + ProcessErrorCode.PROCESS_NOT_FOUND, + "projectId=" + projectId + ", processId=" + processId + )); + + // 삭제 제외 + 정렬(공통) + List aliveItems = process.getTaskItems().stream() + .filter(t -> t.getDeletedAt() == null) + .sorted(Comparator.comparing(t -> t.getSortOrder() == null ? Integer.MAX_VALUE : t.getSortOrder())) + .toList(); + + // task_items + List taskItems = aliveItems.stream() + .map(t -> new ProcessTaskItemResDto( + t.getId(), + t.getContent(), + t.isDone(), + t.getSortOrder(), + t.getDoneAt() + )) + .toList(); + + // task_groups (리더형: roleField + customRoleFieldName 기준) + record GroupKey(RoleField roleField, String customName) {} + + Map> grouped = aliveItems.stream() + .collect(Collectors.groupingBy( + t -> new GroupKey(t.getRoleField(), t.getCustomRoleFieldName()), + LinkedHashMap::new, + Collectors.toList() + )); + + List taskGroups = grouped.entrySet().stream() + .map(e -> { + GroupKey key = e.getKey(); + + List items = e.getValue().stream() + .sorted(Comparator.comparing(t -> t.getSortOrder() == null ? Integer.MAX_VALUE : t.getSortOrder())) + .map(t -> new ProcessTaskItemResDto( + t.getId(), + t.getContent(), + t.isDone(), + t.getSortOrder(), + t.getDoneAt() + )) + .toList(); + + return new WeekMissionDetailResDto.TaskGroupResDto( + key.roleField(), + key.customName(), + items + ); + }) + // 그룹 순서 정렬 + .sorted((a, b) -> { + // null은 맨 뒤 + int ra = (a.roleField() == null) ? Integer.MAX_VALUE : a.roleField().ordinal(); + int rb = (b.roleField() == null) ? Integer.MAX_VALUE : b.roleField().ordinal(); + + // CUSTOM은 일반 RoleField 뒤로 보내고 싶으면 가중치 + if (a.roleField() == RoleField.CUSTOM) ra += 1000; + if (b.roleField() == RoleField.CUSTOM) rb += 1000; + + int cmp = Integer.compare(ra, rb); + if (cmp != 0) return cmp; + + // 같은 roleField면 customFieldName 알파벳/가나다 순 + String ca = (a.customFieldName() == null) ? "" : a.customFieldName(); + String cb = (b.customFieldName() == null) ? "" : b.customFieldName(); + return ca.compareTo(cb); + }) + .toList(); + + User leader = process.getCreatedBy(); + + WeekMissionDetailResDto.AssigneeDto assignee = new WeekMissionDetailResDto.AssigneeDto( + leader.getUserId(), + leader.getName(), + leader.getNickname(), + leader.getProfileImageUrl() + ); + + // DTO 생성자 인자 순서 주의: (taskGroups, taskItems) 둘 다 넣기 + return new WeekMissionDetailResDto( + process.getId(), + process.getMissionNumber(), + process.getTitle(), + process.getContent(), + process.getStatus(), + process.getStartAt(), + process.getEndAt(), + assignee, + taskGroups, + taskItems, + process.getCreatedAt(), + process.getUpdatedAt() + ); + } + + // 위크미션 TASK 프로세스 상태 변경 서비스 + @Transactional + public void updateWeekMissionStatus(Long projectId, Long userId, Long processId, WeekMissionStatusUpdateReqDto req) { + assertActiveLeader(projectId, userId); + + if (req == null || req.status() == null) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "status is required"); + } + + Process process = processRepository.findWeekMissionDetail(projectId, processId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.PROCESS_NOT_FOUND)); + + + ProcessStatus before = process.getStatus(); + ProcessStatus after = req.status(); + if(before == after) return; + + process.updateStatus(after); + + User actor = userRepository.findById(userId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.USER_NOT_FOUND, "userId=" + userId)); + + Project project = process.getProject(); + + notifyWorkspaceWeekMissionUpdated(project, actor, process); + + Map meta = new LinkedHashMap<>(); + meta.put("processType", "WEEK_MISSION"); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + meta.put("beforeStatus", before.name()); + meta.put("afterStatus", after.name()); + + publishWeekMissionHistory( + projectId, userId, processId, + HistoryAction.PROCESS_STATUS_CHANGED, + meta + ); + } + + // 위크미션 TASK 항목 수정 + @Transactional + public ProcessTaskItemResDto updateWeekMissionTaskItem( + Long projectId, Long userId, Long processId, Long taskItemId, WeekMissionTaskItemUpdateReqDto req + ) { + assertActiveLeader(projectId, userId); + + // 위크미션 존재 검증 + Process process = processRepository.findWeekMissionDetail(projectId, processId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.PROCESS_NOT_FOUND)); + + ProcessTaskItem item = processTaskItemRepository.findByIdAndProcessIdAndDeletedAtIsNull(taskItemId, processId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.TASK_ITEM_NOT_FOUND)); + + if (req == null) throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "request is null"); + + boolean changed = false; + + // Before 스냅샷 + String beforeContent = item.getContent(); + Boolean beforeDone = item.isDone(); + Integer beforeSortOrder = item.getSortOrder(); + RoleField beforeRole = item.getRoleField(); + String beforeCustom = item.getCustomRoleFieldName(); + + + // content + if (req.content() != null) { + if (req.content().isBlank()) throw new ProcessException(ProcessErrorCode.INVALID_TASK_ITEM_CONTENT); + String newContent = req.content().trim(); + if (!newContent.equals(beforeContent)) { + item.updateContent(newContent); + changed = true; + } + } + + // done + if (req.isDone() != null) { + boolean newDone = req.isDone(); + if (newDone != beforeDone) { + item.updateDone(newDone); + changed = true; + } + } + + + // role 변경(원하면 허용 / 싫으면 이 블록 삭제) + if (req.roleField() != null) { + RoleField newRole = req.roleField(); + String newCustom = normalizeCustom(newRole, req.customRoleFieldName()); + + boolean roleChanged = + newRole != beforeRole || + (newRole == RoleField.CUSTOM && !java.util.Objects.equals(newCustom, beforeCustom)); + + if (roleChanged) { + // 이동 전 그룹 정보 저장 + RoleField oldRole = beforeRole; + String oldCustom = beforeCustom; + + try { + item.updateRoleField(newRole, newCustom); + } catch (IllegalArgumentException e) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, e.getMessage()); + } + + // 이전 그룹 정규화 + normalizeGroupOrders(processId, oldRole, oldCustom); + + // 새 그룹 끝으로 보내고 정규화 + List newGroup = processTaskItemRepository + .findWeekMissionGroupItemsOrdered(processId, newRole, newCustom); + + int nextOrder = newGroup.size() - 1; + item.updateSortOrder(Math.max(nextOrder, 0)); + normalizeGroupOrders(processId, newRole, newCustom); + + changed = true; + } + } + + if (!changed) { + return new ProcessTaskItemResDto( + item.getId(), item.getContent(), item.isDone(), item.getSortOrder(), item.getDoneAt() + ); + } + + User actor = userRepository.findById(userId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.USER_NOT_FOUND, "userId=" + userId)); + Project project = process.getProject(); + + notifyWorkspaceWeekMissionUpdated(project, actor, process); + + Map meta = new LinkedHashMap<>(); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + meta.put("taskItemId", item.getId()); + + meta.put("before", Map.of( + "content", beforeContent, + "isDone", beforeDone, + "sortOrder", beforeSortOrder, + "roleField", beforeRole == null ? null : beforeRole.name(), + "customRoleFieldName", beforeCustom + )); + meta.put("after", Map.of( + "content", item.getContent(), + "isDone", item.isDone(), + "sortOrder", item.getSortOrder(), + "roleField", item.getRoleField() == null ? null : item.getRoleField().name(), + "customRoleFieldName", item.getCustomRoleFieldName() + )); + + publishWeekMissionHistory( + projectId, userId, processId, + HistoryAction.TASK_ITEM_UPDATED, + meta + ); + + + + return new ProcessTaskItemResDto( + item.getId(), + item.getContent(), + item.isDone(), + item.getSortOrder(), + item.getDoneAt() + ); + } + + private LocalDate toMonday(LocalDate date) { + return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + } + + private LocalDate resolveWeekStart(LocalDate requested, LocalDate fallbackBaseDate) { + LocalDate base = (requested != null) ? requested : fallbackBaseDate; + return toMonday(base); + } +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java index a2846af9..d461bf8c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java @@ -14,7 +14,7 @@ import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.enums.RecruitmentStatus; import com.nect.core.entity.team.process.ProcessTaskItem; -import com.nect.core.entity.team.process.ProjectTeamRole; +import com.nect.core.entity.team.ProjectTeamRole; import com.nect.core.entity.user.User; import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.analysis.*; diff --git a/nect-core/src/main/java/com/nect/core/entity/team/process/ProjectTeamRole.java b/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java similarity index 91% rename from nect-core/src/main/java/com/nect/core/entity/team/process/ProjectTeamRole.java rename to nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java index 9aa027e1..e48fbd3a 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/process/ProjectTeamRole.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java @@ -1,8 +1,7 @@ -package com.nect.core.entity.team.process; +package com.nect.core.entity.team; import com.nect.core.entity.BaseEntity; -import com.nect.core.entity.team.Project; import com.nect.core.entity.user.enums.RoleField; import jakarta.persistence.*; import lombok.*; diff --git a/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java index 1e34d2c4..f00182b5 100644 --- a/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java @@ -1,5 +1,5 @@ package com.nect.core.repository.analysis; -import com.nect.core.entity.team.process.ProjectTeamRole; +import com.nect.core.entity.team.ProjectTeamRole; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java index 8cbf27de..043a1029 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java @@ -220,4 +220,29 @@ interface MemberBoardRow { } Optional findByProjectIdAndMemberType(Long projectId, ProjectMemberType memberType); + + boolean existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus(Long projectId, Long userId, ProjectMemberType projectMemberType, ProjectMemberStatus projectMemberStatus); + + interface ProjectLeaderProfileRow { + Long getUserId(); + String getNickname(); + String getProfileImageUrl(); + } + + @Query(""" + select + u.userId as userId, + u.nickname as nickname, + u.profileImageUrl as profileImageUrl + from ProjectUser pu + join User u + on u.userId = pu.userId + where pu.project.id = :projectId + and pu.memberStatus = com.nect.core.entity.team.enums.ProjectMemberStatus.ACTIVE + and pu.memberType = com.nect.core.entity.team.enums.ProjectMemberType.LEADER + """) + Optional findActiveLeaderProfile(@Param("projectId") Long projectId); + + + } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index 2ea0a4ba..2d0e63ad 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -17,15 +17,13 @@ public interface ProcessRepository extends JpaRepository { // 소속 검증 + 소프트 delete 제외 Optional findByIdAndProjectIdAndDeletedAtIsNull(Long id, Long projectId); - @EntityGraph(attributePaths = { - "processUsers", - "processUsers.user" - }) + @EntityGraph(attributePaths = { "processUsers", "processUsers.user" }) @Query(""" select p from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and ( (p.startAt is null and p.endAt is null) or (p.startAt is null and p.endAt >= :start) @@ -51,6 +49,7 @@ List findAllInRangeOrdered( from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.id in :ids """) List findAllByIdsInProject( @@ -59,6 +58,7 @@ List findAllByIdsInProject( ); + /** * Team 보드(공통) 조회 * - "모든 팀의 작업들을 전부 확인" => 필드/파트 관계없이 전체 프로세스 조회 @@ -75,6 +75,7 @@ List findAllByIdsInProject( and o.deletedAt is null where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) order by p.status asc, coalesce(o.sortOrder, 999999) asc, @@ -82,13 +83,14 @@ List findAllByIdsInProject( """) List findAllForTeamBoard(@Param("projectId") Long projectId); - // ROLE 레인: 조건에 맞는 Process ID만 (정렬은 굳이 안 해도 됨) + // ROLE 레인: 조건에 맞는 Process ID만 @Query(""" select distinct p.id from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField = :roleField """) @@ -104,6 +106,7 @@ List findRoleLaneIds( join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM and trim(pf.customFieldName) = :customName @@ -124,6 +127,7 @@ List findCustomLaneIds( from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.id in :ids """) List findAllByIdsInProjectWithUsers( @@ -137,6 +141,7 @@ List findAllByIdsInProjectWithUsers( from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.id in :ids """) List findAllByIdsInProjectWithFields( @@ -161,6 +166,7 @@ interface MissionProgressRow { JOIN p.processFields pf WHERE p.project.id = :projectId AND p.deletedAt IS NULL + AND (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) AND pf.deletedAt IS NULL GROUP BY pf.roleField, pf.customFieldName """) @@ -182,6 +188,7 @@ interface MemberProcessCountRow { JOIN p.processUsers pu WHERE p.project.id = :projectId AND p.deletedAt IS NULL + AND (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) AND pu.deletedAt IS NULL GROUP BY pu.user.userId, p.status """) @@ -206,6 +213,7 @@ interface LaneStatusCountRow { join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField is not null and pf.roleField <> :custom @@ -229,6 +237,7 @@ List countRoleLaneStatusForProgressSummary( join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField = :custom and pf.customFieldName is not null @@ -242,12 +251,13 @@ List countCustomLaneStatusForProgressSummary( @Param("statuses") List statuses ); - // status 내 TEAM 전체 + // status 내 TEAM 전체 (GENERAL만) @Query(""" select p from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status """) List findAllByStatusInProject( @@ -255,13 +265,14 @@ List findAllByStatusInProject( @Param("status") ProcessStatus status ); - // status 내 ROLE lane 전체 + // status 내 ROLE lane 전체 (GENERAL만) @Query(""" select distinct p from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status and pf.deletedAt is null and pf.roleField = :roleField @@ -272,13 +283,15 @@ List findAllInRoleLaneByStatus( @Param("roleField") RoleField roleField ); - // status 내 CUSTOM lane 전체 + + // status 내 CUSTOM lane 전체 (GENERAL만) @Query(""" select distinct p from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status and pf.deletedAt is null and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM @@ -304,6 +317,7 @@ interface LaneKeyRow { join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField is not null """) @@ -312,12 +326,30 @@ interface LaneKeyRow { int countByProjectIdAndDeletedAtIsNullAndStatus(Long projectId, ProcessStatus status); + /** + * TEAM lane total (GENERAL만) + */ + @Query(""" + select count(p) + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.status = :status + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) + """) + int countTeamLaneTotalExcludingWeekMission( + @Param("projectId") Long projectId, + @Param("status") ProcessStatus status + ); + + // ROLE lane total @Query(""" select count(distinct p) from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status and pf.deletedAt is null and pf.roleField = :roleField @@ -328,12 +360,14 @@ int countRoleLaneTotal( @Param("roleField") RoleField roleField ); + // CUSTOM lane total @Query(""" select count(distinct p) from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status and pf.deletedAt is null and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM @@ -344,5 +378,112 @@ int countCustomLaneTotal( @Param("status") ProcessStatus status, @Param("customName") String customName ); + + // WEEK_MISSION 상세(체크리스트 포함) + @EntityGraph(attributePaths = { "taskItems", "createdBy" }) + @Query(""" + select p + from Process p + where p.project.id = :projectId + and p.id = :processId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + """) + Optional findWeekMissionDetail( + @Param("projectId") Long projectId, + @Param("processId") Long processId + ); + + + // WEEK_MISSION 주차별 조회 + interface WeekMissionCardRow { + Long getProcessId(); + Integer getMissionNumber(); + ProcessStatus getStatus(); + String getTitle(); + LocalDate getStartDate(); + LocalDate getDeadLine(); + Long getDoneCount(); + Long getTotalCount(); + Long getLeaderUserId(); + String getLeaderNickname(); + String getLeaderProfileImageUrl(); + } + + @Query(""" + select + p.id as processId, + p.missionNumber as missionNumber, + p.status as status, + p.title as title, + p.startAt as startDate, + p.endAt as deadLine, + sum(case when ti.isDone = true then 1 else 0 end) as doneCount, + count(ti.id) as totalCount, + u.userId as leaderUserId, + u.nickname as leaderNickname, + u.profileImageUrl as leaderProfileImageUrl + from Process p + left join p.taskItems ti on ti.deletedAt is null + left join p.processUsers pu + on pu.deletedAt is null + and pu.assignmentRole = com.nect.core.entity.team.process.enums.AssignmentRole.ASSIGNEE + left join pu.user u + where p.project.id = :projectId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + and ( + (p.startAt is null and p.endAt is null) + or (p.startAt is null and p.endAt >= :start) + or (p.endAt is null and p.startAt <= :end) + or (p.startAt <= :end and p.endAt >= :start) + ) + group by p.id, p.missionNumber, p.status, p.title, p.startAt, p.endAt, + u.userId, u.nickname, u.profileImageUrl + order by p.missionNumber asc nulls last, p.startAt asc nulls last, p.id asc + """) + List findWeekMissionCardsInRange( + @Param("projectId") Long projectId, + @Param("start") LocalDate start, + @Param("end") LocalDate end + ); + + + + // WEEK_MISSION 중 가장 이른 startAt + @Query(""" + select min(p.startAt) + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + and p.startAt is not null + """) + LocalDate findMinWeekMissionStartAt(@Param("projectId") Long projectId); + + // 전체 프로세스 중 가장 이른 startAt (GENERAL + WEEK_MISSION 포함) + @Query(""" + select min(p.startAt) + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.startAt is not null + """) + LocalDate findMinProcessStartAt(@Param("projectId") Long projectId); + + @Query(""" + select p + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + and p.startAt <= :date + and p.endAt >= :date + """) + Optional findWeekMissionContainingDate( + @Param("projectId") Long projectId, + @Param("date") LocalDate date + ); + } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java index a7a4bb66..18854e9b 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java @@ -1,7 +1,10 @@ package com.nect.core.repository.team.process; import com.nect.core.entity.team.process.ProcessTaskItem; +import com.nect.core.entity.user.enums.RoleField; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -13,4 +16,32 @@ public interface ProcessTaskItemRepository extends JpaRepository findAllByProcessIdAndDeletedAtIsNullOrderBySortOrderAsc(Long processId); List findAllByProcessIdAndDeletedAtIsNullAndIdIn(Long processId, List ids); + + List findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameOrderBySortOrderAsc( + Long processId, RoleField roleField, String customRoleFieldName + ); + + List findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameAndIdIn( + Long processId, RoleField roleField, String customRoleFieldName, List ids + ); + + @Query(""" + select ti + from ProcessTaskItem ti + where ti.process.id = :processId + and ti.deletedAt is null + and ti.roleField = :roleField + and ( + (:customName is null and ti.customRoleFieldName is null) + or (:customName is not null and ti.customRoleFieldName = :customName) + ) + order by + ti.sortOrder asc nulls last, + ti.id asc + """) + List findWeekMissionGroupItemsOrdered( + @Param("processId") Long processId, + @Param("roleField") RoleField roleField, + @Param("customName") String customName + ); } From 876da3b84e5f3bf41f4ae46b9616a4a29a48cba8 Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 01:04:13 +0900 Subject: [PATCH 04/66] =?UTF-8?q?[Refactor]=20=EB=A9=A4=EB=B2=84=ED=98=95?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8B=B4=EB=8B=B9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4,=20=EC=9C=A0=EC=A0=80=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/req/ProcessBasicUpdateReqDto.java | 3 + .../process/dto/req/ProcessCreateReqDto.java | 3 + .../dto/req/ProcessOrderUpdateReqDto.java | 3 + .../dto/res/ProcessBasicUpdateResDto.java | 3 + .../process/dto/res/ProcessCardResDto.java | 3 + .../process/dto/res/ProcessCreateResDto.java | 22 +- .../facade/ProcessAttachmentFacade.java | 62 ++-- .../service/ProcessAttachmentService.java | 51 ++-- .../service/ProcessFeedbackService.java | 16 +- .../team/process/service/ProcessService.java | 269 +++++++++++++++++- .../team/workspace/service/PostService.java | 2 +- .../team/process/ProcessRepository.java | 96 +++++++ 12 files changed, 443 insertions(+), 90 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessBasicUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessBasicUpdateReqDto.java index 98bce74b..3a5f3b8c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessBasicUpdateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessBasicUpdateReqDto.java @@ -29,6 +29,9 @@ public record ProcessBasicUpdateReqDto( @JsonProperty("custom_fields") List customFields, + @JsonProperty("mission_number") + Integer missionNumber, + @JsonProperty("assignee_ids") List assigneeIds, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessCreateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessCreateReqDto.java index 8edbf7b8..29c4f66c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessCreateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessCreateReqDto.java @@ -32,6 +32,9 @@ public record ProcessCreateReqDto( @JsonProperty("custom_field_name") String customFieldName, + @JsonProperty("mission_number") + Integer missionNumber, + @NotNull @JsonProperty("start_date") LocalDate startDate, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java index fd460f44..66b089c8 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java @@ -16,6 +16,9 @@ public record ProcessOrderUpdateReqDto( @JsonProperty("lane_key") String laneKey, + @JsonProperty("mission_number") + Integer missionNumber, + @JsonProperty("start_date") LocalDate startDate, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessBasicUpdateResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessBasicUpdateResDto.java index 66e486bf..7643fadc 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessBasicUpdateResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessBasicUpdateResDto.java @@ -36,6 +36,9 @@ public record ProcessBasicUpdateResDto( @JsonProperty("assignee_ids") List assigneeIds, + @JsonProperty("assignees") + List assignees, + @JsonProperty("mention_user_ids") List mentionUserIds, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java index 4d554a96..7b1ccdc1 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java @@ -37,6 +37,9 @@ public record ProcessCardResDto( @JsonProperty("custom_fields") List customFields, + @JsonProperty("mission_number") + Integer missionNumber, + @JsonProperty("assignee") List assignee ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCreateResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCreateResDto.java index e9fdf3c4..f09aa986 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCreateResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCreateResDto.java @@ -4,6 +4,7 @@ import com.nect.core.entity.user.enums.RoleField; import java.time.LocalDateTime; +import java.util.List; public record ProcessCreateResDto( @JsonProperty("process_id") @@ -13,8 +14,27 @@ public record ProcessCreateResDto( LocalDateTime createdAt, @JsonProperty("writer") - WriterDto writer + WriterDto writer, + + + @JsonProperty("assignees") + List assignees ) { + public record AssigneeDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("name") + String name, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} + + public record WriterDto( @JsonProperty("user_id") Long userId, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java index 81de063d..6daecc76 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java @@ -14,9 +14,14 @@ import com.nect.core.entity.notifications.enums.NotificationScope; import com.nect.core.entity.notifications.enums.NotificationType; import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.team.process.Process; +import com.nect.core.entity.team.process.enums.ProcessType; import com.nect.core.entity.user.User; import com.nect.core.repository.team.ProjectRepository; import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.team.process.ProcessRepository; import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -32,10 +37,8 @@ public class ProcessAttachmentFacade { private final FileService fileService; private final ProcessAttachmentService processAttachmentService; - private final NotificationFacade notificationFacade; - private final ProjectRepository projectRepository; private final ProjectUserRepository projectUserRepository; - private final UserRepository userRepository; + private final ProcessRepository processRepository; /** * 프로세스 모달에서 "파일 업로드" 시: @@ -44,6 +47,23 @@ public class ProcessAttachmentFacade { */ @Transactional public ProcessFileUploadAndAttachResDto uploadAndAttachFile(Long projectId, Long userId, Long processId, MultipartFile file) { + Process process = processRepository.findByIdAndProjectIdAndDeletedAtIsNull(processId, projectId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.PROCESS_NOT_FOUND, "processId=" + processId)); + + // 프로세스 타입이 위크미션이면 업로드 전에 리더 체크 + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + boolean isLeader = projectUserRepository.existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus( + projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE + ); + if (!isLeader) throw new ProcessException(ProcessErrorCode.FORBIDDEN, "WEEK_MISSION은 리더만 업로드/첨부 가능"); + } else { + // 일반 프로세스면 ACTIVE 멤버 체크 + if (!projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus(projectId, userId, ProjectMemberStatus.ACTIVE)) { + throw new ProcessException(ProcessErrorCode.FORBIDDEN, "not active member"); + } + } + + // 파일 업로드 -> 첨부 FileUploadResDto uploaded = fileService.upload(projectId, userId, file); ProcessFileAttachResDto attached = processAttachmentService.attachFile( @@ -53,8 +73,6 @@ public ProcessFileUploadAndAttachResDto uploadAndAttachFile(Long projectId, Long new ProcessFileAttachReqDto(uploaded.fileId()) ); - notifyWorkspaceFileUploaded(projectId, userId, uploaded.fileId(), uploaded.fileName()); - return new ProcessFileUploadAndAttachResDto( attached.fileId(), uploaded.fileName(), @@ -64,39 +82,5 @@ public ProcessFileUploadAndAttachResDto uploadAndAttachFile(Long projectId, Long ); } - private void notifyWorkspaceFileUploaded(Long projectId, Long actorId, Long fileId, String fileName) { - - Project project = projectRepository.findById(projectId) - .orElseThrow(() -> new ProcessException( - ProcessErrorCode.PROJECT_NOT_FOUND, - "projectId = " + projectId - )); - - User actor = userRepository.findById(actorId) - .orElseThrow(() -> new ProcessException( - ProcessErrorCode.USER_NOT_FOUND, - "actorId = " + actorId - )); - - // 프로젝트 멤버 전체 조회 - List receivers = projectUserRepository.findAllUsersByProjectId(projectId).stream() - .filter(u -> u != null && u.getUserId() != null) - .filter(u -> !Objects.equals(u.getUserId(), actorId)) - .toList(); - - if (receivers.isEmpty()) return; - - NotificationCommand command = new NotificationCommand( - NotificationType.WORKSPACE_FILE_UPLOADED, - NotificationClassification.FILE_UPlOAD, - NotificationScope.WORKSPACE_GLOBAL, - fileId, - new Object[]{ actor.getName() }, - new Object[]{ fileName }, - project - ); - - notificationFacade.notify(receivers, command); - } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java index 3582bb0f..fb0d2802 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java @@ -8,11 +8,14 @@ import com.nect.api.domain.team.process.enums.AttachmentErrorCode; import com.nect.api.domain.team.process.exception.AttachmentException; import com.nect.core.entity.team.SharedDocument; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.history.enums.HistoryAction; import com.nect.core.entity.team.history.enums.HistoryTargetType; import com.nect.core.entity.team.process.Link; import com.nect.core.entity.team.process.Process; import com.nect.core.entity.team.process.ProcessSharedDocument; +import com.nect.core.entity.team.process.enums.ProcessType; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.SharedDocumentRepository; import com.nect.core.repository.team.process.LinkRepository; @@ -37,20 +40,9 @@ public class ProcessAttachmentService { private final LinkRepository linkRepository; - // TODO(TEAM EVENT FACADE): Attachment 변경 시(Notification) ActivityFacade로 통합 예정 - private final ProjectHistoryPublisher historyPublisher; // 헬퍼 메서드 - private void assertActiveProjectMember(Long projectId, Long userId) { - if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { - throw new AttachmentException( - AttachmentErrorCode.FORBIDDEN, - "not an active project member. projectId=" + projectId + ", userId=" + userId - ); - } - } - private Process getActiveProcess(Long projectId, Long processId) { return processRepository.findByIdAndProjectIdAndDeletedAtIsNull(processId, projectId) .orElseThrow(() -> new AttachmentException( @@ -83,13 +75,35 @@ private void validateLinkCreateReq(ProcessLinkCreateReqDto req) { } } + private void assertWeekMissionLeader(Long projectId, Long userId) { + boolean ok = projectUserRepository.existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus( + projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE + ); + if (!ok) { + throw new AttachmentException(AttachmentErrorCode.FORBIDDEN, + "WEEK_MISSION은 프로젝트 리더만 수정할 수 있습니다. projectId=" + projectId + ", userId=" + userId); + } + } + + private void assertAttachmentPermission(Long projectId, Long userId, Process process) { + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + assertWeekMissionLeader(projectId, userId); + return; + } + + if (!projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus(projectId, userId, ProjectMemberStatus.ACTIVE)) { + throw new AttachmentException(AttachmentErrorCode.FORBIDDEN, + "not an active project member. projectId=" + projectId + ", userId=" + userId); + } + } + // 프로세스 파일 첨부 서비스 @Transactional public ProcessFileAttachResDto attachFile(Long projectId, Long userId, Long processId, ProcessFileAttachReqDto req) { - assertActiveProjectMember(projectId, userId); validateFileAttachReq(req); Process process = getActiveProcess(projectId, processId); + assertAttachmentPermission(projectId, userId, process); SharedDocument doc = getActiveDocument(projectId, req.fileId()); @@ -108,8 +122,6 @@ public ProcessFileAttachResDto attachFile(Long projectId, Long userId, Long proc processSharedDocumentRepository.save(psd); - // TODO(Notification): 파일 첨부 알림 트리거(수신자=프로젝트 멤버/프로세스 관련자, AFTER_COMMIT 전환 권장) - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); @@ -130,9 +142,8 @@ public ProcessFileAttachResDto attachFile(Long projectId, Long userId, Long proc // 프로세스 파일 첨부해제 서비스 @Transactional public void detachFile(Long projectId, Long userId, Long processId, Long fileId) { - assertActiveProjectMember(projectId, userId); - Process process = getActiveProcess(projectId, processId); + assertAttachmentPermission(projectId, userId, process); ProcessSharedDocument psd = processSharedDocumentRepository .findByProcessIdAndDocumentIdAndDeletedAtIsNull(process.getId(), fileId) @@ -143,7 +154,6 @@ public void detachFile(Long projectId, Long userId, Long processId, Long fileId) psd.softDelete(); - // TODO(Notification): 파일 첨부해제 알림 트리거(AFER_COMMIT 권장) Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("fileId", fileId); @@ -162,10 +172,10 @@ public void detachFile(Long projectId, Long userId, Long processId, Long fileId) // 프로세스 링크 추가 서비스 @Transactional public ProcessLinkCreateResDto createLink(Long projectId, Long userId, Long processId, ProcessLinkCreateReqDto req) { - assertActiveProjectMember(projectId, userId); validateLinkCreateReq(req); Process process = getActiveProcess(projectId, processId); + assertAttachmentPermission(projectId, userId, process); Link link = Link.builder() .process(process) @@ -197,9 +207,8 @@ public ProcessLinkCreateResDto createLink(Long projectId, Long userId, Long proc // 프로세스 링크 삭제 서비스 @Transactional public void deleteLink(Long projectId, Long userId, Long processId, Long linkId) { - assertActiveProjectMember(projectId, userId); - Process process = getActiveProcess(projectId, processId); + assertAttachmentPermission(projectId, userId, process); Link link = linkRepository.findByIdAndProcessIdAndDeletedAtIsNull(linkId, process.getId()) .orElseThrow(() -> new AttachmentException( @@ -210,8 +219,6 @@ public void deleteLink(Long projectId, Long userId, Long processId, Long linkId) String beforeUrl = link.getUrl(); link.softDelete(); - // TODO(Notification): 링크 삭제 알림 트리거(AFER_COMMIT 권장) - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("linkId", linkId); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java index 71c6240b..1afd0b6c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java @@ -191,7 +191,7 @@ public ProcessFeedbackCreateResDto createFeedback(Long projectId, Long userId, L NotificationCommand command = new NotificationCommand( NotificationType.WORKSPACE_TASK_FEEDBACK, NotificationClassification.WORK_STATUS, - NotificationScope.WORKSPACE_GLOBAL, + NotificationScope.WORKSPACE_ONLY, processId, new Object[]{actor.getName()}, new Object[]{preview(saved.getContent(), 60)}, @@ -278,21 +278,9 @@ public ProcessFeedbackDeleteResDto deleteFeedback(Long projectId, Long userId, L ProcessFeedback feedback = getFeedback(processId, feedbackId); - // TODO(HISTORY/NOTI): 삭제 전 스냅샷 확보 권장 - // - beforeContent = feedback.getContent() - // - beforeCreatedBy = feedback.getCreatedByUserId() (필드 확정 후) - // - beforeCreatedAt = feedback.getCreatedAt() - String beforeContent = feedback.getContent(); feedback.softDelete(); - // TODO(Notification): - // - "피드백 삭제" 알림 트리거 지점 - // - 수신자: 프로젝트 멤버 전체 OR 해당 프로세스 관련자 - // - NotificationType 예: PROCESS_FEEDBACK_DELETED - // - meta: 삭제된 피드백의 content 요약/작성자 등(스냅샷 기반) - // - 권장: AFTER_COMMIT 이후 알림 전송 - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); @@ -308,8 +296,6 @@ public ProcessFeedbackDeleteResDto deleteFeedback(Long projectId, Long userId, L meta ); - // TODO(TEAM EVENT FACADE): 추후 ActivityFacade로 통합 - return new ProcessFeedbackDeleteResDto(feedbackId); } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index e900947a..45152297 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -144,6 +144,153 @@ private void ensureLaneOrderRowsExist(Long projectId, ProcessStatus status, Stri } } + // lane 내 기간 겹침 검증 + private void validateNoOverlapInLane(Long projectId, ProcessCreateReqDto req, LocalDate start, LocalDate end) { + List roleFields = Optional.ofNullable(req.roleFields()).orElse(List.of()) + .stream().filter(Objects::nonNull).distinct().toList(); + + // ROLE (CUSTOM 제외) + for (RoleField rf : roleFields) { + if (rf == RoleField.CUSTOM) continue; + + boolean overlap = processRepository.existsOverlappingInRoleLane(projectId, rf, start, end); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "roleField=" + rf + ", start=" + start + ", end=" + end + ); + } + } + + // CUSTOM lane + if (roleFields.contains(RoleField.CUSTOM)) { + String custom = (req.customFieldName() == null) ? "" : req.customFieldName().trim(); + if (custom.isBlank()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "custom_field_name is required when role_fields contains CUSTOM"); + } + + boolean overlap = processRepository.existsOverlappingInCustomLane(projectId, custom, start, end); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "customName=" + custom + ", start=" + start + ", end=" + end + ); + } + } + } + + private void validateNoOverlapForUpdateBasic( + Long projectId, + Long processId, + List roleFields, + List customFields, + LocalDate start, + LocalDate end + ) { + // ROLE (CUSTOM 제외) + for (RoleField rf : Optional.ofNullable(roleFields).orElse(List.of())) { + if (rf == null || rf == RoleField.CUSTOM) continue; + + boolean overlap = processRepository.existsOverlappingInRoleLaneExcludingProcess( + projectId, rf, start, end, processId + ); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "roleField=" + rf + ", start=" + start + ", end=" + end + ); + } + } + + // CUSTOM lanes (이름 기반) + for (String name : Optional.ofNullable(customFields).orElse(List.of())) { + if (name == null) continue; + String trimmed = name.trim(); + if (trimmed.isBlank()) continue; + + boolean overlap = processRepository.existsOverlappingInCustomLaneExcludingProcess( + projectId, trimmed, start, end, processId + ); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "customName=" + trimmed + ", start=" + start + ", end=" + end + ); + } + } + } + + private void validateNoOverlapForUpdateOrderLane( + Long projectId, + Long processId, + String dbLaneKey, + LocalDate start, + LocalDate end + ) { + if (TEAM_LANE_KEY.equals(dbLaneKey)) return; + + if (dbLaneKey.startsWith("ROLE:")) { + RoleField rf = parseRoleField(dbLaneKey); + if (rf == RoleField.CUSTOM) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ROLE lane cannot be CUSTOM. laneKey=" + dbLaneKey); + } + + boolean overlap = processRepository.existsOverlappingInRoleLaneExcludingProcess( + projectId, rf, start, end, processId + ); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "roleField=" + rf + ", start=" + start + ", end=" + end + ); + } + return; + } + + if (dbLaneKey.startsWith("CUSTOM:")) { + String customName = parseCustomName(dbLaneKey); + + boolean overlap = processRepository.existsOverlappingInCustomLaneExcludingProcess( + projectId, customName, start, end, processId + ); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "customName=" + customName + ", start=" + start + ", end=" + end + ); + } + return; + } + + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "invalid lane_key prefix. laneKey=" + dbLaneKey); + } + + + // 선택 미션 N과 startDate 포함 검증 + private void validateStartDateInSelectedMission(Long projectId, Integer missionNumber, LocalDate startDate) { + if (missionNumber == null) return; + + var mp = processRepository.findWeekMissionPeriodByMissionNumber(projectId, missionNumber) + .orElseThrow(() -> new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "week mission not found. missionNumber=" + missionNumber + )); + + LocalDate mStart = mp.getStartAt(); + LocalDate mEnd = mp.getEndAt(); + if (mStart == null || mEnd == null) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "missionNumber=" + missionNumber); + } + + boolean ok = !startDate.isBefore(mStart) && !startDate.isAfter(mEnd); + if (!ok) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "startDate=" + startDate + ", mission=" + missionNumber + ); + } + } + // 알림 관련 헬퍼 메서드 private List validateAndLoadMentionReceivers(Long projectId, Long actorId, List mentionIds) { @@ -174,7 +321,7 @@ private void notifyWorkspaceMention(Project project, User actor, Long targetProc NotificationCommand command = new NotificationCommand( NotificationType.WORKSPACE_MENTIONED, NotificationClassification.WORK_STATUS, - NotificationScope.WORKSPACE_GLOBAL, + NotificationScope.WORKSPACE_ONLY, targetProcessId, new Object[]{ actor.getName() }, new Object[]{ content }, @@ -240,6 +387,10 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre process.updatePeriod(start, end); + validateStartDateInSelectedMission(projectId, req.missionNumber(), start); + + validateNoOverlapInLane(projectId, req, start, end); + int i = 0; // 업무 리스트 저장 for (var t : taskItems) { @@ -405,16 +556,32 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre "writer must be active project member. projectId=" + projectId + ", userId=" + userId )); + List assigneeDtos = + saved.getProcessUsers().stream() + .filter(pu -> pu.getDeletedAt() == null) + .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) + .map(pu -> { + User u = pu.getUser(); + return new ProcessCreateResDto.AssigneeDto( + u.getUserId(), + u.getName(), + u.getNickname(), + u.getProfileImageUrl() + ); + }) + .toList(); + return new ProcessCreateResDto( saved.getId(), saved.getCreatedAt(), - new ProcessCreateResDto.WriterDto( + new ProcessCreateResDto.WriterDto( // 작성자 writer.getUserId(), writer.getName(), writer.getNickname(), writerMember.getRoleField(), writerMember.getCustomRoleFieldName() - ) + ), + assigneeDtos // 담당자 ); } @@ -529,8 +696,7 @@ public ProcessDetailResDto getProcessDetail(Long projectId, Long userId, Long pr .map(pu -> { User u = pu.getUser(); - // TODO : 유저 프로필 넣기 - String userImage = null; + String userImage = u.getProfileImageUrl(); return new AssigneeResDto( u.getUserId(), @@ -763,6 +929,40 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, ); } + if (req.missionNumber() != null && mergedStart != null && mergedEnd != null) { + validateStartDateInSelectedMission(projectId, req.missionNumber(), mergedStart); + } + + if (mergedStart != null && mergedEnd != null && (newStart != null || newEnd != null)) { + + // 검증할 lane 후보 결정 + List laneRoleFields = + (req.roleFields() != null) + ? req.roleFields() + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .map(ProcessField::getRoleField) + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .distinct() + .toList(); + + List laneCustomFields = + (req.customFields() != null) + ? req.customFields() + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) + .map(ProcessField::getCustomFieldName) + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .toList(); + + validateNoOverlapForUpdateBasic(projectId, processId, laneRoleFields, laneCustomFields, mergedStart, mergedEnd); + } + if (newStart != null || newEnd != null) { process.updatePeriod(mergedStart, mergedEnd); } @@ -904,6 +1104,20 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, final LocalDate afterStart = process.getStartAt(); final LocalDate afterEnd = process.getEndAt(); + List assigneeDtos = process.getProcessUsers().stream() + .filter(pu -> pu.getDeletedAt() == null) + .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) + .map(pu -> { + User u = pu.getUser(); + return new AssigneeResDto( + u.getUserId(), + u.getName(), + u.getNickname(), + u.getProfileImageUrl() + ); + }) + .toList(); + final List afterMentionIds = (mentionIdsForRes == null) ? null // 요청이 null이면 멘션 변경 안함 : mentionIdsForRes.stream().filter(Objects::nonNull).distinct().sorted().toList(); @@ -1028,6 +1242,7 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, afterRoleFields, afterCustomFields, afterAssigneeIds, + assigneeDtos, (afterMentionIds == null) ? beforeMentionIds : afterMentionIds, process.getUpdatedAt(), new ProcessBasicUpdateResDto.WriterDto( @@ -1082,11 +1297,11 @@ public void deleteProcess(Long projectId, Long userId, Long processId) { ); } - - - private LocalDate normalizeWeekStart(LocalDate startDate) { - LocalDate base = (startDate == null) ? LocalDate.now() : startDate; - return base.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + private LocalDate normalizeWeekStart(LocalDate date) { + if (date == null) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "startDate must not be null"); + } + return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); } private ProcessCardResDto toProcessCardResDTO(Process p) { @@ -1120,12 +1335,13 @@ private ProcessCardResDto toProcessCardResDTO(Process p) { .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) .map(pu -> { User u = pu.getUser(); - String userImage = null; // TODO: 프로필 컬럼/연동되면 세팅 + String userImage = u.getProfileImageUrl(); String nickname = u.getNickname(); return new AssigneeResDto(u.getUserId(), u.getName(), nickname, userImage); }) .toList(); + Integer missionNumber = resolveMissionNumberByStartDate(p.getProject().getId(), p.getStartAt()); return new ProcessCardResDto( p.getId(), @@ -1138,6 +1354,7 @@ private ProcessCardResDto toProcessCardResDTO(Process p) { leftDay, roleFields, customFields, + missionNumber, assignees ); } @@ -1226,6 +1443,24 @@ private ProcessWeekResDto buildWeekDto(LocalDate weekStart, List 12) weeks = 12; + LocalDate fallback = processRepository.findMinProcessStartAt(projectId); + if (fallback == null) fallback = LocalDate.now(); - LocalDate rangeStart = normalizeWeekStart(startDate); + LocalDate rangeStart = resolveWeekStart(startDate, fallback); LocalDate rangeEnd = rangeStart.plusDays((long) weeks * 7 - 1); List processes = processRepository.findAllInRangeOrdered(projectId, rangeStart, rangeEnd); @@ -1557,6 +1794,14 @@ public ProcessOrderUpdateResDto updateProcessOrder(Long projectId, Long userId, ); } + if (req.missionNumber() != null && mergedStart != null) { + validateStartDateInSelectedMission(projectId, req.missionNumber(), mergedStart); + } + + if (mergedStart != null && mergedEnd != null) { + validateNoOverlapForUpdateOrderLane(projectId, processId, dbLaneKey, mergedStart, mergedEnd); + } + process.updatePeriod(mergedStart, mergedEnd); } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java index 44ede7f6..5cbcdbef 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java @@ -83,7 +83,7 @@ private void notifyBoardMention(Project project, User actor, Long targetBoardId, NotificationCommand command = new NotificationCommand( NotificationType.WORKSPACE_MENTIONED, NotificationClassification.BOARD, - NotificationScope.WORKSPACE_GLOBAL, + NotificationScope.WORKSPACE_ONLY, targetBoardId, new Object[]{ actor.getName() }, new Object[]{ content }, diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index 2d0e63ad..0419805e 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -485,5 +485,101 @@ Optional findWeekMissionContainingDate( @Param("date") LocalDate date ); + @Query(""" + select (count(p) > 0) + from Process p + join p.processFields pf + where p.project.id = :projectId + and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) + and pf.deletedAt is null + and pf.roleField = :roleField + and p.startAt <= :end + and p.endAt >= :start + """) + boolean existsOverlappingInRoleLane( + @Param("projectId") Long projectId, + @Param("roleField") RoleField roleField, + @Param("start") LocalDate start, + @Param("end") LocalDate end + ); + + @Query(""" + select (count(p) > 0) + from Process p + join p.processFields pf + where p.project.id = :projectId + and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) + and pf.deletedAt is null + and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM + and trim(pf.customFieldName) = :customName + and p.startAt <= :end + and p.endAt >= :start + """) + boolean existsOverlappingInCustomLane( + @Param("projectId") Long projectId, + @Param("customName") String customName, + @Param("start") LocalDate start, + @Param("end") LocalDate end + ); + + interface MissionPeriodRow { + LocalDate getStartAt(); + LocalDate getEndAt(); + } + + @Query(""" + select p.startAt as startAt, p.endAt as endAt + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + and p.missionNumber = :missionNumber + """) + Optional findWeekMissionPeriodByMissionNumber( + @Param("projectId") Long projectId, + @Param("missionNumber") Integer missionNumber + ); + + @Query(""" + select case when count(p) > 0 then true else false end + from Process p + join p.processFields pf + where p.project.id = :projectId + and p.deletedAt is null + and p.id <> :excludeProcessId + and pf.deletedAt is null + and pf.roleField = :roleField + and not (p.endAt < :start or p.startAt > :end) + """) + boolean existsOverlappingInRoleLaneExcludingProcess( + @Param("projectId") Long projectId, + @Param("roleField") RoleField roleField, + @Param("start") LocalDate start, + @Param("end") LocalDate end, + @Param("excludeProcessId") Long excludeProcessId + ); + + @Query(""" + select case when count(p) > 0 then true else false end + from Process p + join p.processFields pf + where p.project.id = :projectId + and p.deletedAt is null + and p.id <> :excludeProcessId + and pf.deletedAt is null + and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM + and pf.customFieldName = :customName + and not (p.endAt < :start or p.startAt > :end) + """) + boolean existsOverlappingInCustomLaneExcludingProcess( + @Param("projectId") Long projectId, + @Param("customName") String customName, + @Param("start") LocalDate start, + @Param("end") LocalDate end, + @Param("excludeProcessId") Long excludeProcessId + ); + } From 76bfa71b35ce471c870903752da805914602bce9 Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 01:08:34 +0900 Subject: [PATCH 05/66] =?UTF-8?q?[Test]=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=B0=8F=20=EC=9C=84?= =?UTF-8?q?=ED=81=AC=EB=AF=B8=EC=85=98=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ProcessControllerTest.java | 69 +++- .../ProcessTaskItemControllerTest.java | 9 +- .../controller/WeekMissionControllerTest.java | 379 ++++++++++++++++++ 3 files changed, 436 insertions(+), 21 deletions(-) create mode 100644 nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java index 49d0c576..a890e087 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java @@ -23,7 +23,6 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.FieldDescriptor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; @@ -128,6 +127,9 @@ void createProcess() throws Exception { "작성자닉네임", RoleField.BACKEND, null + ), + List.of( + new ProcessCreateResDto.AssigneeDto(2L, "담당자이름", "담당자닉", "https://img.com/2.png") ) ); @@ -136,22 +138,18 @@ void createProcess() throws Exception { "로그인/회원가입 API 초안 + 문서화", ProcessStatus.IN_PROGRESS, - // assignee_ids List.of(2L), - List.of(RoleField.BACKEND, RoleField.FRONTEND), null, + 1, LocalDate.of(2026, 1, 19), LocalDate.of(2026, 1, 25), List.of(), - - // file_ids List.of(), - // links (변경됨) List.of( new ProcessCreateReqDto.ProcessLinkItemReqDto("백엔드 Repo", "https://github.com/nect/nect-backend"), new ProcessCreateReqDto.ProcessLinkItemReqDto("피그마", "https://figma.com/file/xxxxx") @@ -195,16 +193,15 @@ void createProcess() throws Exception { fieldWithPath("assignee_ids").type(ARRAY).description("담당자 ID 목록"), fieldWithPath("role_fields").type(ARRAY).description("분야 목록 (예: BACKEND, FRONTEND 등)"), fieldWithPath("custom_field_name").optional().type(STRING).description("커스텀 분야명(null 가능)"), + fieldWithPath("mission_number").optional().type(NUMBER).description("미션 번호(위크미션이면 1..n, 기본형이면 null 가능)"), - // start/deadline은 네 DTO에서 @NotNull이라 optional 빼는게 맞음 fieldWithPath("start_date").type(STRING).description("시작일(yyyy-MM-dd)"), fieldWithPath("dead_line").type(STRING).description("마감일(yyyy-MM-dd)"), fieldWithPath("mention_user_ids").type(ARRAY).description("멘션된 유저 ID 목록"), fieldWithPath("file_ids").type(ARRAY).description("첨부 파일 ID 목록"), - // links 변경 - fieldWithPath("links").type(ARRAY).description("첨부 링크 목록").optional(), + fieldWithPath("links").optional().type(ARRAY).description("첨부 링크 목록"), fieldWithPath("links[].title").type(STRING).description("링크 제목"), fieldWithPath("links[].url").type(STRING).description("링크 URL"), @@ -221,7 +218,6 @@ void createProcess() throws Exception { fieldWithPath("body").description("응답 바디"), fieldWithPath("body.process_id").type(NUMBER).description("생성된 프로세스 ID"), - fieldWithPath("body.created_at").type(STRING).description("생성일시(ISO-8601)"), fieldWithPath("body.writer").type(OBJECT).description("작성자 정보"), @@ -229,9 +225,14 @@ void createProcess() throws Exception { fieldWithPath("body.writer.name").type(STRING).description("작성자 이름"), fieldWithPath("body.writer.nickname").type(STRING).description("작성자 닉네임"), fieldWithPath("body.writer.role_field").type(STRING).description("작성자 역할 분야(RoleField)"), - fieldWithPath("body.writer.custom_field_name").optional().type(STRING).description("작성자 커스텀 분야명(null 가능)") - ) + fieldWithPath("body.writer.custom_field_name").optional().type(STRING).description("작성자 커스텀 분야명(null 가능)"), + fieldWithPath("body.assignees").type(ARRAY).description("담당자 정보 목록"), + fieldWithPath("body.assignees[].user_id").type(NUMBER).description("담당자 유저 ID"), + fieldWithPath("body.assignees[].name").type(STRING).description("담당자 이름"), + fieldWithPath("body.assignees[].nickname").type(STRING).description("담당자 닉네임"), + fieldWithPath("body.assignees[].profile_image_url").type(STRING).description("담당자 프로필 이미지 URL") + ) .build() ) )); @@ -428,10 +429,17 @@ void updateProcessBasic() throws Exception { List.of(RoleField.FRONTEND, RoleField.BACKEND, RoleField.CUSTOM), List.of("AI"), + 1, + List.of(1L, 2L), List.of(3L, 4L) ); + List assignees = List.of( + new AssigneeResDto(1L, "유저1", "유저1닉", "https://img.com/1.png"), + new AssigneeResDto(2L, "유저2", "유저2닉", "https://img.com/2.png") + ); + ProcessBasicUpdateResDto response = new ProcessBasicUpdateResDto( processId, "수정된 제목", @@ -444,6 +452,8 @@ void updateProcessBasic() throws Exception { List.of("AI"), List.of(1L, 2L), + assignees, + List.of(3L, 4L), LocalDateTime.of(2026, 1, 24, 0, 0, 0), @@ -495,7 +505,8 @@ void updateProcessBasic() throws Exception { fieldWithPath("mention_user_ids").optional().type(ARRAY).description("멘션 유저 ID 목록 (미포함 시 변경 없음, []면 비우기)"), fieldWithPath("role_fields").optional().type(ARRAY).description("역할 분야 목록(RoleField)"), - fieldWithPath("custom_fields").optional().type(ARRAY).description("커스텀 분야명 목록(CUSTOM 선택 시)") + fieldWithPath("custom_fields").optional().type(ARRAY).description("커스텀 분야명 목록(CUSTOM 선택 시)"), + fieldWithPath("mission_number").optional().type(NUMBER).description("미션 번호(미포함 시 변경 없음, null 가능)") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), @@ -515,6 +526,12 @@ void updateProcessBasic() throws Exception { fieldWithPath("body.custom_fields").type(ARRAY).description("커스텀 분야명 목록(CUSTOM 선택 시)"), fieldWithPath("body.assignee_ids").type(ARRAY).description("담당자 ID 목록"), + fieldWithPath("body.assignees").type(ARRAY).description("담당자 정보 목록"), + fieldWithPath("body.assignees[].user_id").type(NUMBER).description("담당자 유저 ID"), + fieldWithPath("body.assignees[].user_name").type(STRING).description("담당자 이름"), + fieldWithPath("body.assignees[].nickname").type(STRING).description("담당자 닉네임"), + fieldWithPath("body.assignees[].user_image").type(STRING).description("담당자 이미지 URL"), + fieldWithPath("body.mention_user_ids").type(ARRAY).description("멘션 유저 ID 목록"), fieldWithPath("body.updated_at").type(STRING).description("수정일시(ISO-8601)"), @@ -657,14 +674,15 @@ void getPartProcesses() throws Exception { 10L, ProcessStatus.IN_PROGRESS, "백엔드 API 초안 작성", - 1, // complete_check_list - 3, // whole_check_list + 1, + 3, LocalDate.of(2026, 2, 1), LocalDate.of(2026, 2, 10), - 5, // left_day + 5, List.of(RoleField.BACKEND), - List.of("AI"), // custom_fields - List.of(a1, a2) + List.of("AI"), + 1, + List.of(a1, a2) // assignee ); ProcessCardResDto p12 = new ProcessCardResDto( @@ -678,9 +696,12 @@ void getPartProcesses() throws Exception { 3, List.of(RoleField.BACKEND, RoleField.FRONTEND), List.of("DevOps"), + null, List.of(a2) ); + + ProcessStatusGroupResDto inProgressGroup = new ProcessStatusGroupResDto( ProcessStatus.IN_PROGRESS, 2, @@ -699,6 +720,7 @@ void getPartProcesses() throws Exception { null, List.of(RoleField.BACKEND), List.of(), + null, List.of(a1) ); @@ -720,6 +742,7 @@ void getPartProcesses() throws Exception { 0, List.of(RoleField.BACKEND), List.of("Auth"), + 1, List.of(a1, a2) ); @@ -741,6 +764,7 @@ void getPartProcesses() throws Exception { null, List.of(RoleField.BACKEND), List.of("TechDebt"), + 1, List.of(a2) ); @@ -808,6 +832,10 @@ void getPartProcesses() throws Exception { fieldWithPath("body.groups[].processes[].role_fields").type(ARRAY).description("RoleField 목록"), fieldWithPath("body.groups[].processes[].custom_fields").type(ARRAY).description("커스텀 필드명 목록"), + fieldWithPath("body.groups[].processes[].mission_number").optional().type(VARIES).description("위크미션 번호(미션 프로세스면 1..n, 일반 프로세스면 null)"), + + fieldWithPath("body.groups[].processes[].assignee").type(ARRAY).description("담당자 목록"), + fieldWithPath("body.groups[].processes[].assignee").type(ARRAY).description("담당자 목록"), fieldWithPath("body.groups[].processes[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), fieldWithPath("body.groups[].processes[].assignee[].user_name").type(STRING).description("담당자 이름"), @@ -833,6 +861,7 @@ void updateProcessOrder() throws Exception { ProcessStatus.IN_PROGRESS, List.of(10L, 2L, 12L), "ROLE:BACKEND", + 1, LocalDate.of(2026, 2, 1), LocalDate.of(2026, 2, 10) ); @@ -876,7 +905,8 @@ void updateProcessOrder() throws Exception { fieldWithPath("ordered_process_ids").optional().type(ARRAY).description("정렬 순서대로 나열한 프로세스 ID 목록"), fieldWithPath("lane_key").type(STRING).description("레인 키(TEAM, ROLE:XXX, CUSTOM:이름)"), fieldWithPath("start_date").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), - fieldWithPath("dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)") + fieldWithPath("dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)"), + fieldWithPath("mission_number").optional().type(NUMBER).description("미션 번호(위크미션이면 사용, 기본형이면 null 가능)") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), @@ -888,6 +918,7 @@ void updateProcessOrder() throws Exception { fieldWithPath("body.process_id").type(NUMBER).description("프로세스 ID"), fieldWithPath("body.status").type(STRING).description("프로세스 상태"), fieldWithPath("body.status_order").type(NUMBER).description("해당 status 내 정렬 순서"), + fieldWithPath("body.groups[].processes[].mission_number").optional().type(NUMBER).description("미션 번호(null 가능)"), fieldWithPath("body.start_at").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), fieldWithPath("body.dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)") ) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java index cdd9407f..690084b7 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java @@ -12,6 +12,7 @@ import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.user.enums.RoleField; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -301,7 +302,9 @@ void reorderTaskItems() throws Exception { long userId = 1L; ProcessTaskItemReorderReqDto request = new ProcessTaskItemReorderReqDto( - List.of(100L, 101L, 102L) + List.of(100L, 101L, 102L), + RoleField.BACKEND, + null ); ProcessTaskItemResDto i0 = new ProcessTaskItemResDto(100L, "A", false, 0, null); @@ -338,7 +341,9 @@ void reorderTaskItems() throws Exception { headerWithName(AUTH_HEADER).description("Bearer Access Token") ) .requestFields( - fieldWithPath("ordered_task_item_ids").type(ARRAY).description("정렬된 업무 항목 ID 목록(전체 포함)") + fieldWithPath("ordered_task_item_ids").type(ARRAY).description("정렬된 업무 항목 ID 목록(전체 포함)"), + fieldWithPath("role_field").optional().type(STRING).description("레인 역할(RoleField). ROLE 레인일 때 사용"), + fieldWithPath("custom_role_field_name").optional().type(STRING).description("커스텀 레인 이름. CUSTOM 레인일 때 사용") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java new file mode 100644 index 00000000..465c3133 --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java @@ -0,0 +1,379 @@ +package com.nect.api.domain.team.process.controller; + +import com.epages.restdocs.apispec.ResourceDocumentation; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; +import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; +import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; +import com.nect.api.domain.team.process.service.WeekMissionService; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.team.process.enums.ProcessStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Constructor; +import java.lang.reflect.RecordComponent; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class WeekMissionControllerTest { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private WeekMissionService weekMissionService; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_MEMBER")) + .build() + ); + } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_MEMBER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return SecurityMockMvcRequestPostProcessors.authentication(auth); + } + + private T newRecord(Class recordType) { + try { + if (!recordType.isRecord()) return null; + + RecordComponent[] components = recordType.getRecordComponents(); + Class[] paramTypes = new Class[components.length]; + Object[] args = new Object[components.length]; + + for (int i = 0; i < components.length; i++) { + Class t = components[i].getType(); + paramTypes[i] = t; + args[i] = defaultValue(t); + } + + Constructor ctor = recordType.getDeclaredConstructor(paramTypes); + ctor.setAccessible(true); + return ctor.newInstance(args); + } catch (Exception e) { + throw new IllegalStateException("Failed to instantiate record: " + recordType.getName(), e); + } + } + + private Object defaultValue(Class t) { + if (t == String.class) return "sample"; + if (t == Long.class || t == long.class) return 1L; + if (t == Integer.class || t == int.class) return 1; + if (t == Boolean.class || t == boolean.class) return false; + if (t == LocalDate.class) return LocalDate.of(2026, 1, 19); + if (t == LocalDateTime.class) return LocalDateTime.of(2026, 1, 19, 0, 0, 0); + + if (List.class.isAssignableFrom(t)) return List.of(); + + if (t.isEnum()) { + Object[] constants = t.getEnumConstants(); + return (constants != null && constants.length > 0) ? constants[0] : null; + } + + if (t.isRecord()) { + @SuppressWarnings("unchecked") + Class rt = (Class) t; + return newRecord(rt); + } + + return null; + } + + @Test + @DisplayName("주차별 위크미션 조회") + void getWeekMissions() throws Exception { + long projectId = 1L; + long userId = 1L; + + LocalDate startDate = LocalDate.of(2026, 1, 19); + int weeks = 2; + + WeekMissionWeekResDto response = newRecord(WeekMissionWeekResDto.class); + + given(weekMissionService.getWeekMissions(eq(projectId), eq(userId), eq(startDate), eq(weeks))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/week-missions/week", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .param("start_date", startDate.toString()) + .param("weeks", String.valueOf(weeks)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("week-mission-week", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("주차별 위크미션 조회") + .description("start_date 기준으로 weeks 만큼 위크미션 주차 목록을 조회합니다. start_date 미입력 시 서버 정책에 따른 기본 시작일로 동작합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID") + ) + .queryParameters( + parameterWithName("start_date").optional().description("시작일(yyyy-MM-dd)"), + parameterWithName("weeks").optional().description("조회할 주차 수(기본 1)") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("주차별 위크미션 조회 결과") + ) + .build() + ) + )); + + verify(weekMissionService).getWeekMissions(eq(projectId), eq(userId), eq(startDate), eq(weeks)); + } + + @Test + @DisplayName("위크미션 상세 조회(체크리스트 포함)") + void getWeekMissionDetail() throws Exception { + long projectId = 1L; + long processId = 10L; + long userId = 1L; + + WeekMissionDetailResDto response = newRecord(WeekMissionDetailResDto.class); + + given(weekMissionService.getDetail(eq(projectId), eq(userId), eq(processId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/week-missions/{processId}", projectId, processId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("week-mission-detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("위크미션 상세 조회") + .description("위크미션(프로세스) 상세를 조회합니다. (체크리스트 포함)") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("processId").description("위크미션 프로세스 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("위크미션 상세 결과") + ) + .build() + ) + )); + + verify(weekMissionService).getDetail(eq(projectId), eq(userId), eq(processId)); + } + + @Test + @DisplayName("위크미션 상태 변경") + void updateWeekMissionStatus() throws Exception { + long projectId = 1L; + long processId = 10L; + long userId = 1L; + + WeekMissionStatusUpdateReqDto request = + new WeekMissionStatusUpdateReqDto(ProcessStatus.PLANNING); + + willDoNothing().given(weekMissionService) + .updateWeekMissionStatus(eq(projectId), eq(userId), eq(processId), any(WeekMissionStatusUpdateReqDto.class)); + + mockMvc.perform(patch("/api/v1/projects/{projectId}/week-missions/{processId}/status", projectId, processId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("week-mission-status-update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("위크미션 상태 변경") + .description("위크미션 프로세스의 상태를 변경합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("processId").description("위크미션 프로세스 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("status").type(STRING) + .description("변경할 상태(PLANNING/IN_PROGRESS/DONE/BACKLOG)") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명") + ) + .build() + ) + )); + + verify(weekMissionService).updateWeekMissionStatus(eq(projectId), eq(userId), eq(processId), any(WeekMissionStatusUpdateReqDto.class)); + } + + @Test + @DisplayName("위크미션 TASK 내 항목 내용 수정") + void updateWeekMissionTaskItem() throws Exception { + long projectId = 1L; + long processId = 10L; + long taskItemId = 100L; + long userId = 1L; + + WeekMissionTaskItemUpdateReqDto request = newRecord(WeekMissionTaskItemUpdateReqDto.class); + + ProcessTaskItemResDto response = new ProcessTaskItemResDto( + taskItemId, + "수정된 세부 작업", + true, + 1, + LocalDate.of(2026, 1, 25) + ); + + given(weekMissionService.updateWeekMissionTaskItem(eq(projectId), eq(userId), eq(processId), eq(taskItemId), any(WeekMissionTaskItemUpdateReqDto.class))) + .willReturn(response); + + mockMvc.perform(patch("/api/v1/projects/{projectId}/week-missions/{processId}/task-items/{taskItemId}", projectId, processId, taskItemId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("week-mission-taskitem-update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("위크미션 TASK 항목 수정") + .description("위크미션 프로세스 내 TaskItem의 내용을 수정합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("processId").description("위크미션 프로세스 ID"), + ResourceDocumentation.parameterWithName("taskItemId").description("업무 항목(TaskItem) ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("content").type(STRING).description("업무 항목 내용"), + fieldWithPath("is_done").type(BOOLEAN).description("완료 여부"), + fieldWithPath("role_field").optional().type(STRING).description("역할 분야(RoleField)"), + fieldWithPath("custom_role_field_name").optional().type(STRING).description("커스텀 역할 분야명(CUSTOM일 때)") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.task_item_id").type(NUMBER).description("업무 항목 ID"), + fieldWithPath("body.content").type(STRING).description("업무 항목 내용"), + fieldWithPath("body.is_done").type(BOOLEAN).description("완료 여부"), + fieldWithPath("body.sort_order").type(NUMBER).description("정렬 순서"), + fieldWithPath("body.done_at").optional().type(STRING).description("완료일(yyyy-MM-dd, null 가능)") + ) + .build() + ) + )); + + verify(weekMissionService).updateWeekMissionTaskItem(eq(projectId), eq(userId), eq(processId), eq(taskItemId), any(WeekMissionTaskItemUpdateReqDto.class)); + } +} From 52e2ced7f6e5fe11dfffc1924802a9c3a31341d0 Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 03:02:40 +0900 Subject: [PATCH 06/66] =?UTF-8?q?[Refactor]=20=EC=97=AD=ED=95=A0=20?= =?UTF-8?q?=EC=98=81=EC=96=B4=20label=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/entity/user/enums/RoleField.java | 87 ++++++++++--------- .../ProjectTeamRoleRepository.java | 0 2 files changed, 47 insertions(+), 40 deletions(-) rename nect-core/src/main/java/com/nect/core/repository/{analysis => team}/ProjectTeamRoleRepository.java (100%) diff --git a/nect-core/src/main/java/com/nect/core/entity/user/enums/RoleField.java b/nect-core/src/main/java/com/nect/core/entity/user/enums/RoleField.java index 9bad7361..65c9deb0 100644 --- a/nect-core/src/main/java/com/nect/core/entity/user/enums/RoleField.java +++ b/nect-core/src/main/java/com/nect/core/entity/user/enums/RoleField.java @@ -2,59 +2,62 @@ public enum RoleField { // 디자이너 - UI_UX("UI/UX", Role.DESIGNER), - ILLUSTRATION_GRAPHIC("일러스트/그래픽", Role.DESIGNER), - WEBTOON_EMOTICON("웹툰/이모티콘", Role.DESIGNER), - PHOTO_VIDEO("사진/영상", Role.DESIGNER), - SOUND("사운드", Role.DESIGNER), - THREE_D_MOTION("3D/모션", Role.DESIGNER), - PRODUCT("제품", Role.DESIGNER), - SPACE("공간", Role.DESIGNER), - PUBLISHING("출판", Role.DESIGNER), + UI_UX("UI/UX", "UI/UX", Role.DESIGNER), + ILLUSTRATION_GRAPHIC("일러스트/그래픽", "Illustration/Graphic", Role.DESIGNER), + WEBTOON_EMOTICON("웹툰/이모티콘", "Webtoon/Emoticon", Role.DESIGNER), + PHOTO_VIDEO("사진/영상", "Photo/Video", Role.DESIGNER), + SOUND("사운드", "Sound", Role.DESIGNER), + THREE_D_MOTION("3D/모션", "3D/Motion", Role.DESIGNER), + PRODUCT("제품", "Product", Role.DESIGNER), + SPACE("공간", "Space", Role.DESIGNER), + PUBLISHING("출판", "Publishing", Role.DESIGNER), // 개발자 - FRONTEND("프론트엔드", Role.DEVELOPER), - BACKEND("백엔드", Role.DEVELOPER), - IOS_ANDROID("IOS/안드로이드", Role.DEVELOPER), - DATA_ENGINEER("데이터 엔지니어", Role.DEVELOPER), - AI_MACHINE_LEARNING("AI/머신러닝", Role.DEVELOPER), - FULLSTACK("풀스택", Role.DEVELOPER), - GAME("게임", Role.DEVELOPER), - HARDWARE("하드웨어", Role.DEVELOPER), - SECURITY_NETWORK("보안/네트워크", Role.DEVELOPER), + FRONTEND("프론트엔드", "Frontend", Role.DEVELOPER), + BACKEND("백엔드", "Backend", Role.DEVELOPER), + IOS_ANDROID("IOS/안드로이드", "iOS/Android", Role.DEVELOPER), + DATA_ENGINEER("데이터 엔지니어", "Data Engineer", Role.DEVELOPER), + AI_MACHINE_LEARNING("AI/머신러닝", "AI/Machine Learning", Role.DEVELOPER), + FULLSTACK("풀스택", "Full-stack", Role.DEVELOPER), + GAME("게임", "Game", Role.DEVELOPER), + HARDWARE("하드웨어", "Hardware", Role.DEVELOPER), + SECURITY_NETWORK("보안/네트워크", "Security/Network", Role.DEVELOPER), // 기획자 - SERVICE("서비스", Role.PLANNER), - UX("UX", Role.PLANNER), - APP_WEB("앱/웹", Role.PLANNER), - BUSINESS("비즈니스", Role.PLANNER), - PERFORMANCE_EVENT("공연/행사", Role.PLANNER), + SERVICE("서비스", "Service", Role.PLANNER), + UX("UX", "UX", Role.PLANNER), + APP_WEB("앱/웹", "App/Web", Role.PLANNER), + BUSINESS("비즈니스", "Business", Role.PLANNER), + PERFORMANCE_EVENT("공연/행사", "Performance/Event", Role.PLANNER), + // 마케터 - CONTENT_CREATION("콘텐츠 제작", Role.MARKETER), - PERFORMANCE("퍼포먼스", Role.MARKETER), - CRM("CRM", Role.MARKETER), - BRAND_MARKETING("브랜드 마케팅", Role.MARKETER), - AD_VIRAL("광고/바이럴", Role.MARKETER), - LIVE_COMMERCE("라이브커머스", Role.MARKETER), - DATA_ANALYSIS("데이터 분석", Role.MARKETER), - MARKETING_OTHER("기타", Role.MARKETER), - OPERATIONS_CS("운영/CS", Role.MARKETER), - SALES_PARTNERSHIP("영업/제휴", Role.MARKETER), - VIDEO_MUSIC_DIRECTING("영상/음악 감독", Role.MARKETER), - TRANSLATION_INTERPRETATION("번역/통역", Role.MARKETER), - MANUSCRIPT_CONSULTING("원고 컨설턴트", Role.MARKETER), - ACCOUNTING_LAW_HR("세무/법무/노무", Role.MARKETER), - STARTUP_CONSULTING("창업 컨설팅", Role.MARKETER), + CONTENT_CREATION("콘텐츠 제작", "Content Creation", Role.MARKETER), + PERFORMANCE("퍼포먼스", "Performance", Role.MARKETER), + CRM("CRM", "CRM", Role.MARKETER), + BRAND_MARKETING("브랜드 마케팅", "Brand Marketing", Role.MARKETER), + AD_VIRAL("광고/바이럴", "Ads/Viral", Role.MARKETER), + LIVE_COMMERCE("라이브커머스", "Live Commerce", Role.MARKETER), + DATA_ANALYSIS("데이터 분석", "Data Analysis", Role.MARKETER), + MARKETING_OTHER("기타", "Other", Role.MARKETER), + OPERATIONS_CS("운영/CS", "Operations/CS", Role.MARKETER), + SALES_PARTNERSHIP("영업/제휴", "Sales/Partnership", Role.MARKETER), + VIDEO_MUSIC_DIRECTING("영상/음악 감독", "Video/Music Directing", Role.MARKETER), + TRANSLATION_INTERPRETATION("번역/통역", "Translation/Interpretation", Role.MARKETER), + MANUSCRIPT_CONSULTING("원고 컨설턴트", "Manuscript Consulting", Role.MARKETER), + ACCOUNTING_LAW_HR("세무/법무/노무", "Accounting/Law/HR", Role.MARKETER), + STARTUP_CONSULTING("창업 컨설팅", "Startup Consulting", Role.MARKETER), // 직접입력 (모든 Role에서 가능) - CUSTOM("직접입력", null); + CUSTOM("직접입력", "Custom",null); private final String description; + private final String labelEn; private final Role role; - RoleField(String description, Role role) { + RoleField(String description, String labelEn, Role role) { this.description = description; + this.labelEn = labelEn; this.role = role; } @@ -62,6 +65,10 @@ public String getDescription() { return description; } + public String getLabelEn() { + return labelEn; + } + public Role getRole() { return role; } diff --git a/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java similarity index 100% rename from nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java rename to nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java From 8fde356240ced496d6e99b641e92cfa86e775b11 Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 03:04:28 +0900 Subject: [PATCH 07/66] =?UTF-8?q?[Feat]=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=ED=8A=B8=20=EC=A1=B0=ED=9A=8C,=20?= =?UTF-8?q?=ED=8C=80=EC=9B=90=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C,?= =?UTF-8?q?=20=EB=AF=B8=EC=85=98=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WeekMissionController.java | 12 + .../dto/res/WeekMissionDropdownResDto.java | 29 ++ .../team/process/service/ProcessService.java | 263 ++++++++++++------ .../process/service/WeekMissionService.java | 33 +++ .../controller/ProjectPartsController.java | 42 +++ .../team/project/dto/ProjectPartsResDto.java | 32 +++ .../team/project/dto/ProjectUsersResDto.java | 42 +++ .../project/enums/code/ProjectErrorCode.java | 7 + .../project/exception/ProjectException.java | 4 + .../team/project/service/ProjectService.java | 1 + .../service/ProjectTeamQueryService.java | 116 ++++++++ .../core/entity/team/ProjectTeamRole.java | 29 +- .../team/ProjectTeamRoleRepository.java | 36 ++- .../team/process/ProcessRepository.java | 22 ++ 14 files changed, 581 insertions(+), 87 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDropdownResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartsResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectUsersResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java index 825b4ac2..a78088ee 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java @@ -4,6 +4,7 @@ import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDropdownResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; import com.nect.api.domain.team.process.service.WeekMissionService; import com.nect.api.global.response.ApiResponse; @@ -73,4 +74,15 @@ public ApiResponse updateWeekMissionTaskItem( ); } + // 멤버형 모달 미션 주차 선택 드롭다운 조회 + @GetMapping("/missions") + public ApiResponse readMissionDropdown( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok( + weekMissionService.getMissionDropdown(projectId, userDetails.getUserId()) + ); + } + } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDropdownResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDropdownResDto.java new file mode 100644 index 00000000..abc51548 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDropdownResDto.java @@ -0,0 +1,29 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.LocalDate; +import java.util.List; + +public record WeekMissionDropdownResDto( + @JsonProperty("missions") + List missions +) { + public WeekMissionDropdownResDto { + missions = (missions == null) ? List.of() : missions; + } + + public record MissionDto( + @JsonProperty("mission_number") + Integer missionNumber, + + @JsonProperty("start_date") + LocalDate startDate, + + @JsonProperty("end_date") + LocalDate endDate, + + @JsonProperty("is_current") + Boolean isCurrent + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index 45152297..b1be82b9 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -24,6 +24,7 @@ import com.nect.core.entity.user.User; import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.SharedDocumentRepository; import com.nect.core.repository.team.process.ProcessLaneOrderRepository; @@ -53,6 +54,7 @@ public class ProcessService { private final ProcessMentionRepository processMentionRepository; private final UserRepository userRepository; private final ProcessLaneOrderRepository processLaneOrderRepository; + private final ProjectTeamRoleRepository projectTeamRoleRepository; private final ProcessLaneOrderService processLaneOrderService; @@ -291,6 +293,41 @@ private void validateStartDateInSelectedMission(Long projectId, Integer missionN } } + private void validateProjectTeamRolesOrThrow(Long projectId, List roleFields, String customFieldName) { + + // roleFields(일반 역할) 검증 + for (RoleField rf : Optional.ofNullable(roleFields).orElse(List.of())) { + if (rf == null) continue; + + if (rf == RoleField.CUSTOM) continue; // CUSTOM은 customFieldName으로 검증 + + boolean exists = projectTeamRoleRepository.existsByProject_IdAndRoleField(projectId, rf); + if (!exists) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "role_field not registered in project. roleField=" + rf + ); + } + } + + // CUSTOM 검증 (ProcessCreateReqDto는 customFieldName 단일) + if (roleFields != null && roleFields.contains(RoleField.CUSTOM)) { + String name = (customFieldName == null) ? "" : customFieldName.trim(); + if (name.isBlank()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "custom_field_name is required"); + } + + boolean exists = projectTeamRoleRepository + .existsByProject_IdAndRoleFieldAndCustomRoleFieldName(projectId, RoleField.CUSTOM, name); + + if (!exists) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "custom role not registered in project. customRoleFieldName=" + name + ); + } + } + } // 알림 관련 헬퍼 메서드 private List validateAndLoadMentionReceivers(Long projectId, Long actorId, List mentionIds) { @@ -338,7 +375,7 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre assertActiveProjectMember(projectId, userId); validateProcessTitle(req.processTitle()); - List taskItems = req.taskItems(); + List taskItems = Optional.ofNullable(req.taskItems()).orElse(List.of()); validateTaskItems(taskItems); // 프로젝트 확인 @@ -387,8 +424,20 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre process.updatePeriod(start, end); + // 필드(역할) CUSTOM쪽 검증 + List roleFields = Optional.ofNullable(req.roleFields()).orElse(List.of()) + .stream().filter(Objects::nonNull).distinct().toList(); + + // 정규화 이후 검증/저장/히스토리 모두 이 값 사용 + String customName = (req.customFieldName() == null) ? null : req.customFieldName().trim(); + + // 프로젝트에 등록된 파트인지 검증 (CUSTOM 포함) + validateProjectTeamRolesOrThrow(projectId, roleFields, req.customFieldName()); + + // 미션 N 검증: "시작일만" 미션 기간에 포함되면 요구사항 일치 validateStartDateInSelectedMission(projectId, req.missionNumber(), start); + // lane 기간 겹침 검증 validateNoOverlapInLane(projectId, req, start, end); int i = 0; @@ -444,26 +493,9 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre } - // 필드(역할) CUSTOM쪽 검증 - List roleFields = Optional.ofNullable(req.roleFields()).orElse(List.of()) - .stream().filter(Objects::nonNull).distinct().toList(); - - - if (roleFields.contains(RoleField.CUSTOM)) { - if (req.customFieldName() == null || req.customFieldName().isBlank()) { - throw new ProcessException( - ProcessErrorCode.INVALID_REQUEST, - "custom_field_name is required when role_fields contains CUSTOM" - ); - } - } - for (RoleField rf : roleFields) { - if (rf == RoleField.CUSTOM) { - process.addField(RoleField.CUSTOM, req.customFieldName()); - } else { - process.addField(rf, null); - } + if (rf == RoleField.CUSTOM) process.addField(RoleField.CUSTOM, customName); + else process.addField(rf, null); } @@ -527,7 +559,7 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre meta.put("startAt", saved.getStartAt()); meta.put("endAt", saved.getEndAt()); meta.put("roleFields", roleFields); - meta.put("customFieldName", roleFields.contains(RoleField.CUSTOM) ? req.customFieldName() : null); + meta.put("customFieldName", roleFields.contains(RoleField.CUSTOM) ? customName : null); meta.put("assigneeIds", assigneeIds); meta.put("mentionUserIds", mentionIds); meta.put("fileIds", fileIds); @@ -875,6 +907,7 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, .filter(pf -> pf.getDeletedAt() == null) .map(ProcessField::getRoleField) .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) .distinct() .sorted(Comparator.comparing(Enum::name)) .toList(); @@ -900,6 +933,7 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, .toList(); if(req.processTitle() != null && !req.processTitle().isBlank()) { + validateProcessTitle(req.processTitle()); process.updateTitle(req.processTitle()); } @@ -929,41 +963,66 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, ); } + // 시작일만 미션 범위에 포함되면 통과 if (req.missionNumber() != null && mergedStart != null && mergedEnd != null) { validateStartDateInSelectedMission(projectId, req.missionNumber(), mergedStart); } - if (mergedStart != null && mergedEnd != null && (newStart != null || newEnd != null)) { + // fields PATCH 준비(정규화 + 프로젝트 등록 파트 검증) + boolean fieldsPatchRequested = (req.roleFields() != null || req.customFields() != null); - // 검증할 lane 후보 결정 - List laneRoleFields = - (req.roleFields() != null) - ? req.roleFields() - : process.getProcessFields().stream() - .filter(pf -> pf.getDeletedAt() == null) - .map(ProcessField::getRoleField) - .filter(Objects::nonNull) - .filter(rf -> rf != RoleField.CUSTOM) - .distinct() - .toList(); + List requestedRoleFields = (req.roleFields() == null) + ? null + : req.roleFields().stream() + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .distinct() + .toList(); - List laneCustomFields = - (req.customFields() != null) - ? req.customFields() - : process.getProcessFields().stream() - .filter(pf -> pf.getDeletedAt() == null) - .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) - .map(ProcessField::getCustomFieldName) - .filter(Objects::nonNull) - .map(String::trim) - .filter(s -> !s.isBlank()) - .distinct() - .toList(); + List requestedCustomFields = (req.customFields() == null) + ? null + : normalizeCustomFields(req.customFields()); + + if (fieldsPatchRequested) { + validateProjectTeamRolesForUpdateOrThrow( + projectId, + (requestedRoleFields == null ? List.of() : requestedRoleFields), + (requestedCustomFields == null ? List.of() : requestedCustomFields) + ); + } + + // 기간 변경이 없더라도 "파트/커스텀 변경"만으로도 lane이 바뀌면 overlap 가능 + boolean periodPatchRequested = (newStart != null || newEnd != null); + + if (mergedStart != null && mergedEnd != null && (periodPatchRequested || fieldsPatchRequested)) { + + List laneRoleFields = (requestedRoleFields != null) + ? requestedRoleFields + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .map(ProcessField::getRoleField) + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .distinct() + .toList(); + + List laneCustomFields = (requestedCustomFields != null) + ? requestedCustomFields + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) + .map(ProcessField::getCustomFieldName) + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .toList(); validateNoOverlapForUpdateBasic(projectId, processId, laneRoleFields, laneCustomFields, mergedStart, mergedEnd); } - if (newStart != null || newEnd != null) { + + if (periodPatchRequested) { process.updatePeriod(mergedStart, mergedEnd); } @@ -973,8 +1032,8 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, // 멘션 알림: 요청이 들어온 경우에만, 그리고 '새로 추가된 멘션'에게만 전송 if (req.mentionUserIds() != null) { - List afterIds = (mentionIdsForRes == null) ? List.of() : mentionIdsForRes.stream() - .filter(Objects::nonNull).distinct().toList(); + List afterIds = (mentionIdsForRes == null) ? List.of() + : mentionIdsForRes.stream().filter(Objects::nonNull).distinct().toList(); Set beforeSet = new HashSet<>(beforeMentionIds); List addedMentionIds = afterIds.stream() @@ -997,62 +1056,45 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, } } - if (req.roleFields() != null || req.customFields() != null) { - // 기존 전부 soft delete + if (fieldsPatchRequested) { + // 기존 전부 soft delete process.getProcessFields().forEach(pf -> { if (pf.getDeletedAt() == null) pf.softDelete(); }); - // roleFields 반영 (CUSTOM 제외) - List requestedRoleFields = (req.roleFields() == null) ? List.of() : req.roleFields(); - for (RoleField rf : requestedRoleFields) { - if (rf == null) continue; - if (rf == RoleField.CUSTOM) continue; - - // 기존에 같은 roleField가 삭제된 상태로 있으면 restore, 없으면 생성 + List finalRoleFields = (requestedRoleFields == null) ? List.of() : requestedRoleFields; + for (RoleField rf : finalRoleFields) { ProcessField found = process.getProcessFields().stream() .filter(pf -> pf.getRoleField() == rf) .findFirst() .orElse(null); - if (found != null) { - found.restore(); - } else { - ProcessField pf = ProcessField.builder() - .process(process) - .roleField(rf) - .customFieldName(null) - .build(); - process.getProcessFields().add(pf); - } + if (found != null) found.restore(); + else process.getProcessFields().add(ProcessField.builder() + .process(process) + .roleField(rf) + .customFieldName(null) + .build()); } - // customFields 반영 (CUSTOM은 이름 기반) - List requestedCustomFields = (req.customFields() == null) ? List.of() : req.customFields(); - for (String name : requestedCustomFields) { - if (name == null) continue; - String trimmed = name.trim(); - if (trimmed.isBlank()) continue; - + List finalCustomFields = (requestedCustomFields == null) ? List.of() : requestedCustomFields; + for (String name : finalCustomFields) { ProcessField found = process.getProcessFields().stream() .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) - .filter(pf -> trimmed.equals(pf.getCustomFieldName())) + .filter(pf -> pf.getCustomFieldName() != null && pf.getCustomFieldName().trim().equals(name)) .findFirst() .orElse(null); - if (found != null) { - found.restore(); - } else { - ProcessField pf = ProcessField.builder() - .process(process) - .roleField(RoleField.CUSTOM) - .customFieldName(trimmed) - .build(); - process.getProcessFields().add(pf); - } + if (found != null) found.restore(); + else process.getProcessFields().add(ProcessField.builder() + .process(process) + .roleField(RoleField.CUSTOM) + .customFieldName(name) + .build()); } } + // 요청이 null이면 변경 안 함 if (req.assigneeIds() != null) { List requestedAssigneeIds = req.assigneeIds().stream() @@ -1256,6 +1298,57 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, } + private List normalizeCustomFields(List raw) { + return raw.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .toList(); + } + + /** + * 프로젝트에 등록된 파트(ProjectTeamRole)인지 검증 + * - roleFields: CUSTOM 제외 리스트 + * - customFields: CUSTOM 이름 리스트 + */ + private void validateProjectTeamRolesForUpdateOrThrow(Long projectId, List roleFields, List customFields) { + List rows = + projectTeamRoleRepository.findActiveTeamRoleRowsByProjectId(projectId); + + Set registeredRoleFields = rows.stream() + .map(ProjectTeamRoleRepository.TeamRoleRow::getRoleField) + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .collect(Collectors.toSet()); + + Set registeredCustomNames = rows.stream() + .filter(r -> r.getRoleField() == RoleField.CUSTOM) + .map(ProjectTeamRoleRepository.TeamRoleRow::getCustomRoleFieldName) + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .collect(Collectors.toSet()); + + for (RoleField rf : roleFields) { + if (!registeredRoleFields.contains(rf)) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "role_field not registered in project. projectId=" + projectId + ", roleField=" + rf + ); + } + } + + for (String name : customFields) { + if (!registeredCustomNames.contains(name)) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "custom_field not registered in project. projectId=" + projectId + ", customField=" + name + ); + } + } + } + diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java index 5ef99da4..937fa046 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -7,6 +7,7 @@ import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDropdownResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; import com.nect.api.domain.team.process.enums.ProcessErrorCode; import com.nect.api.domain.team.process.exception.ProcessException; @@ -502,6 +503,38 @@ public ProcessTaskItemResDto updateWeekMissionTaskItem( ); } + // 위크미션 드롭 다운용 조화 + @Transactional(readOnly = true) + public WeekMissionDropdownResDto getMissionDropdown(Long projectId, Long userId) { + assertActiveProjectMember(projectId, userId); + assertProjectExists(projectId); + + var rows = processRepository.findWeekMissionRanges(projectId); + + LocalDate today = LocalDate.now(); + + List missions = rows.stream() + .map(r -> { + LocalDate start = r.getStartDate(); + LocalDate end = r.getEndDate(); + + boolean isCurrent = false; + if (start != null && end != null) { + isCurrent = (!today.isBefore(start) && !today.isAfter(end)); + } + + return new WeekMissionDropdownResDto.MissionDto( + r.getMissionNumber(), + start, + end, + isCurrent + ); + }) + .toList(); + + return new WeekMissionDropdownResDto(missions); + } + private LocalDate toMonday(LocalDate date) { return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java new file mode 100644 index 00000000..a5ed5826 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java @@ -0,0 +1,42 @@ +package com.nect.api.domain.team.project.controller; + +import com.nect.api.domain.team.project.dto.ProjectPartsResDto; +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.service.ProjectTeamQueryService; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}") +public class ProjectPartsController { + + private final ProjectTeamQueryService projectTeamQueryService; + + // 팀 파트 조회 (드롭다운) + @GetMapping("/parts") + public ApiResponse readProjectParts( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok( + projectTeamQueryService.readProjectParts(projectId, userDetails.getUserId()) + ); + } + + // 프로젝트 전체 인원 조회 (담당자 드롭다운) + @GetMapping("/users") + public ApiResponse readProjectUsers( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok( + projectTeamQueryService.readProjectUsers(projectId, userDetails.getUserId()) + ); + } + + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartsResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartsResDto.java new file mode 100644 index 00000000..c4151ff6 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartsResDto.java @@ -0,0 +1,32 @@ +package com.nect.api.domain.team.project.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +import java.util.List; + +public record ProjectPartsResDto( + @JsonProperty("parts") + List parts +) { + public ProjectPartsResDto { + parts = (parts == null) ? List.of() : parts; + } + + public record PartDto( + @JsonProperty("part_id") + Long partId, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("part_label") + String partLabel, + + @JsonProperty("required_count") + Integer requiredCount + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectUsersResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectUsersResDto.java new file mode 100644 index 00000000..9ac3e96f --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectUsersResDto.java @@ -0,0 +1,42 @@ +package com.nect.api.domain.team.project.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.user.enums.RoleField; + +import java.util.List; + +public record ProjectUsersResDto( + @JsonProperty("users") + List users +) { + public ProjectUsersResDto { + users = (users == null) ? List.of() : users; + } + + public record UserDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("name") + String name, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("part_label") + String partLabel, + + @JsonProperty("member_type") + ProjectMemberType memberType + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java index ee77c563..466d221d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java @@ -8,6 +8,8 @@ @AllArgsConstructor public enum ProjectErrorCode implements ResponseCode { + INVALID_REQUEST("P400_0", "요청 값이 올바르지 않습니다."), + PROJECT_NOT_FOUND("P400_1", "해당 프로젝트가 존재하지 않습니다."), PROJECT_USER_NOT_FOUND("P400_2", "해당 프로젝트 유저가 존재하지 않습니다."), ANALYSIS_NOT_FOUND("P400_3", "해당 분석서가 존재하지 않습니다."), @@ -17,7 +19,12 @@ public enum ProjectErrorCode implements ResponseCode { WEEK_MISSION_ALREADY_INITIALIZED("P400_7", "위크미션이 이미 생성되어 있습니다."), INVALID_WEEK_MISSION_UPDATE("P400_8", "수정할 수 없는 항목이 포함되어 있습니다."), + PROJECT_PART_NOT_FOUND("P400_9", "해당 프로젝트 파트(팀 역할)를 찾을 수 없습니다."), + DUPLICATE_PART("P400_10", "이미 존재하는 파트입니다."), + INVALID_CUSTOM_PART_NAME("P400_11", "CUSTOM 파트 이름이 올바르지 않습니다."), + + PROJECT_MEMBER_FORBIDDEN("P403_0", "프로젝트 멤버만 접근할 수 있습니다."), LEADER_ONLY_ACTION("P403_1", "리더만 할 수 있는 요청입니다."), ; diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/exception/ProjectException.java b/nect-api/src/main/java/com/nect/api/domain/team/project/exception/ProjectException.java index 10577982..0a2a5cf0 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/exception/ProjectException.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/exception/ProjectException.java @@ -8,4 +8,8 @@ public class ProjectException extends CustomException { public ProjectException(ResponseCode code) { super(code); } + + public ProjectException(ResponseCode code, String message) { + super(code, message); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java index d461bf8c..f252242a 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java @@ -19,6 +19,7 @@ import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.analysis.*; import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.process.ProcessRepository; import com.nect.core.repository.user.UserRepository; diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java new file mode 100644 index 00000000..9bf7ff88 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java @@ -0,0 +1,116 @@ +package com.nect.api.domain.team.project.service; + +import com.nect.api.domain.team.project.dto.ProjectPartsResDto; +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.enums.code.ProjectErrorCode; +import com.nect.api.domain.team.project.exception.ProjectException; +import com.nect.core.entity.team.ProjectTeamRole; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.user.User; +import com.nect.core.entity.user.enums.RoleField; +import com.nect.core.repository.team.ProjectTeamRoleRepository; +import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ProjectTeamQueryService { + private final ProjectTeamRoleRepository projectTeamRoleRepository; + private final ProjectUserRepository projectUserRepository; + private final UserRepository userRepository; + + // 프로젝트 파트 목록 조회 서비스 + @Transactional(readOnly = true) + public ProjectPartsResDto readProjectParts(Long projectId, Long userId) { + assertActiveProjectMember(projectId, userId); + + List roles = projectTeamRoleRepository.findAllActiveByProjectId(projectId); + + List parts = roles.stream() + .map(ptr -> { + RoleField rf = ptr.getRoleField(); + String customName = ptr.getCustomRoleFieldName(); + + String label = (rf == RoleField.CUSTOM) + ? customName + : rf.getLabelEn(); + + return new ProjectPartsResDto.PartDto( + ptr.getId(), + rf, + customName, + label, + ptr.getRequiredCount() + ); + }) + .toList(); + + return new ProjectPartsResDto(parts); + } + + // 프로젝트 멤버 전체 조회 서비스 + @Transactional(readOnly = true) + public ProjectUsersResDto readProjectUsers(Long projectId, Long userId) { + assertActiveProjectMember(projectId, userId); + + List rows = + projectUserRepository.findActiveMemberBoardRows(projectId); + + List ids = rows.stream() + .map(ProjectUserRepository.MemberBoardRow::getUserId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + Map userMap = userRepository.findAllById(ids).stream() + .collect(Collectors.toMap(User::getUserId, Function.identity())); + + List users = rows.stream() + .map(r -> { + User u = userMap.get(r.getUserId()); + String profileUrl = (u == null) ? null : u.getProfileImageUrl(); + + RoleField rf = r.getRoleField(); + String customName = r.getCustomRoleFieldName(); + + String label = (rf == RoleField.CUSTOM) + ? customName + : rf.getDescription(); + + return new ProjectUsersResDto.UserDto( + r.getUserId(), + r.getName(), + r.getNickname(), + profileUrl, + rf, + customName, + label, + r.getMemberType() + ); + }) + .toList(); + + return new ProjectUsersResDto(users); + } + + + private void assertActiveProjectMember(Long projectId, Long userId) { + boolean ok = projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus( + projectId, userId, ProjectMemberStatus.ACTIVE + ); + if (!ok) { + throw new ProjectException(ProjectErrorCode.PROJECT_MEMBER_FORBIDDEN, + "projectId=" + projectId + ", userId=" + userId); + } + + } +} diff --git a/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java b/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java index e48fbd3a..a3ec704a 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java @@ -6,6 +6,8 @@ import jakarta.persistence.*; import lombok.*; +import java.time.LocalDateTime; + @Entity @Getter @Setter @@ -25,15 +27,40 @@ public class ProjectTeamRole extends BaseEntity { @Column(name = "role_field", nullable = false) private RoleField roleField; + @Column(name = "custom_role_field_name", length = 50) + private String customRoleFieldName; + @Column(name = "required_count", nullable = false) private Integer requiredCount; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Builder - private ProjectTeamRole(Project project, RoleField roleField, Integer requiredCount) { + private ProjectTeamRole(Project project, RoleField roleField, String customRoleFieldName, Integer requiredCount) { + if (roleField == null) { + throw new IllegalArgumentException("roleField는 null일 수 없습니다."); + } + if (roleField == RoleField.CUSTOM && (customRoleFieldName == null || customRoleFieldName.isBlank())) { + throw new IllegalArgumentException("CUSTOM이면 customRoleFieldName(직접입력)이 필수입니다."); + } + this.project = project; this.roleField = roleField; + this.customRoleFieldName = (roleField == RoleField.CUSTOM) ? customRoleFieldName : null; this.requiredCount = requiredCount; } + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } + + public void restore() { + this.deletedAt = null; + } + + public boolean isDeleted() { + return this.deletedAt != null; + } } \ No newline at end of file diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java index f00182b5..87e0bdd5 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java @@ -1,9 +1,43 @@ -package com.nect.core.repository.analysis; +package com.nect.core.repository.team; import com.nect.core.entity.team.ProjectTeamRole; +import com.nect.core.entity.user.enums.RoleField; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface ProjectTeamRoleRepository extends JpaRepository { List findByProjectId(Long projectId); + + @Query(""" + select ptr + from ProjectTeamRole ptr + where ptr.project.id = :projectId + and ptr.deletedAt is null + order by ptr.id asc + """) + List findAllActiveByProjectId(@Param("projectId") Long projectId); + + @Query(""" + select ptr.roleField as roleField, + ptr.customRoleFieldName as customRoleFieldName + from ProjectTeamRole ptr + where ptr.project.id = :projectId + and ptr.deletedAt is null + """) + List findActiveTeamRoleRowsByProjectId(@Param("projectId") Long projectId); + + interface TeamRoleRow { + RoleField getRoleField(); + String getCustomRoleFieldName(); + } + + boolean existsByProject_IdAndRoleField(Long projectId, RoleField roleField); + + boolean existsByProject_IdAndRoleFieldAndCustomRoleFieldName( + Long projectId, + RoleField roleField, + String customRoleFieldName + ); } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index 0419805e..cece6921 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -581,5 +581,27 @@ boolean existsOverlappingInCustomLaneExcludingProcess( @Param("excludeProcessId") Long excludeProcessId ); + @Query(""" + select + p.missionNumber as missionNumber, + min(p.startAt) as startDate, + max(p.endAt) as endDate + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.missionNumber is not null + and p.startAt is not null + and p.endAt is not null + group by p.missionNumber + order by p.missionNumber asc + """) + List findWeekMissionRanges(@Param("projectId") Long projectId); + + interface WeekMissionRangeRow { + Integer getMissionNumber(); + LocalDate getStartDate(); + LocalDate getEndDate(); + } + } From daf574e2ef96416451c179b8bcb8e01ed89a4284 Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 03:05:20 +0900 Subject: [PATCH 08/66] =?UTF-8?q?[Test]=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=9C=A0=EC=A0=80=20=EC=A1=B0=ED=9A=8C,=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=ED=8C=8C=ED=8A=B8,=20?= =?UTF-8?q?=EB=AF=B8=EC=85=98=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WeekMissionControllerTest.java | 46 ++++ .../ProjectPartsControllerTest.java | 241 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java index 465c3133..d47eb84b 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java @@ -7,6 +7,7 @@ import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDropdownResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; import com.nect.api.domain.team.process.service.WeekMissionService; import com.nect.api.global.jwt.JwtUtil; @@ -376,4 +377,49 @@ void updateWeekMissionTaskItem() throws Exception { verify(weekMissionService).updateWeekMissionTaskItem(eq(projectId), eq(userId), eq(processId), eq(taskItemId), any(WeekMissionTaskItemUpdateReqDto.class)); } + + @Test + @DisplayName("멤버형 모달 미션 주차 선택 드롭다운 조회") + void readMissionDropdown() throws Exception { + long projectId = 1L; + long userId = 1L; + + WeekMissionDropdownResDto response = newRecord(WeekMissionDropdownResDto.class); + + given(weekMissionService.getMissionDropdown(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/week-missions/missions", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("week-mission-missions-dropdown", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("미션 주차 드롭다운 조회") + .description("멤버형 모달에서 미션(주차) 선택을 위한 드롭다운 목록을 조회합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("미션 드롭다운 조회 결과") + ) + .build() + ) + )); + + verify(weekMissionService).getMissionDropdown(eq(projectId), eq(userId)); + } } diff --git a/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java new file mode 100644 index 00000000..351432de --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java @@ -0,0 +1,241 @@ +package com.nect.api.domain.team.project.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.project.dto.ProjectPartsResDto; +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.service.ProjectTeamQueryService; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Constructor; +import java.lang.reflect.RecordComponent; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class ProjectPartsControllerTest { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ProjectTeamQueryService projectTeamQueryService; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_MEMBER")) + .build() + ); + } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_MEMBER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return SecurityMockMvcRequestPostProcessors.authentication(auth); + } + + private T newRecord(Class recordType) { + try { + if (!recordType.isRecord()) return null; + + RecordComponent[] components = recordType.getRecordComponents(); + Class[] paramTypes = new Class[components.length]; + Object[] args = new Object[components.length]; + + for (int i = 0; i < components.length; i++) { + Class t = components[i].getType(); + paramTypes[i] = t; + args[i] = defaultValue(t); + } + + Constructor ctor = recordType.getDeclaredConstructor(paramTypes); + ctor.setAccessible(true); + return ctor.newInstance(args); + } catch (Exception e) { + throw new IllegalStateException("Failed to instantiate record: " + recordType.getName(), e); + } + } + + private Object defaultValue(Class t) { + if (t == String.class) return "sample"; + if (t == Long.class || t == long.class) return 1L; + if (t == Integer.class || t == int.class) return 1; + if (t == Boolean.class || t == boolean.class) return false; + if (t == LocalDate.class) return LocalDate.of(2026, 1, 19); + if (t == LocalDateTime.class) return LocalDateTime.of(2026, 1, 19, 0, 0, 0); + + if (List.class.isAssignableFrom(t)) return List.of(); + + if (t.isEnum()) { + Object[] constants = t.getEnumConstants(); + return (constants != null && constants.length > 0) ? constants[0] : null; + } + + if (t.isRecord()) { + @SuppressWarnings("unchecked") + Class rt = (Class) t; + return newRecord(rt); + } + + return null; + } + + @Test + @DisplayName("팀 파트 조회 (드롭다운)") + void readProjectParts() throws Exception { + long projectId = 1L; + long userId = 1L; + + ProjectPartsResDto response = newRecord(ProjectPartsResDto.class); + + given(projectTeamQueryService.readProjectParts(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/parts", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("project-parts-read", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Project") + .summary("팀 파트 조회") + .description("현재 프로젝트에 설정된 파트 목록을 조회합니다. (드롭다운)") + .pathParameters( + com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName("projectId") + .description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("팀 파트 조회 결과") + ) + .build() + ) + )); + + verify(projectTeamQueryService).readProjectParts(eq(projectId), eq(userId)); + } + + @Test + @DisplayName("프로젝트 전체 인원 조회 (담당자 드롭다운)") + void readProjectUsers() throws Exception { + long projectId = 1L; + long userId = 1L; + + ProjectUsersResDto response = newRecord(ProjectUsersResDto.class); + + given(projectTeamQueryService.readProjectUsers(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/users", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("project-users-read", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Project") + .summary("프로젝트 전체 인원 조회") + .description("프로젝트에 속한 전체 인원 목록을 조회합니다. (담당자 드롭다운)") + .pathParameters( + com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName("projectId") + .description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("프로젝트 전체 인원 조회 결과") + ) + .build() + ) + )); + + verify(projectTeamQueryService).readProjectUsers(eq(projectId), eq(userId)); + } +} From 794e1d93c5436779c434c434d92e6696dbe14d8d Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 11:38:51 +0900 Subject: [PATCH 09/66] =?UTF-8?q?[Refactor]=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A7=A4=EA=B0=9C=EB=B3=80=EC=88=98=20=EA=B0=92=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nect/api/domain/team/process/service/ProcessService.java | 2 +- .../api/domain/team/process/service/WeekMissionService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index b1be82b9..af0d92df 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -432,7 +432,7 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre String customName = (req.customFieldName() == null) ? null : req.customFieldName().trim(); // 프로젝트에 등록된 파트인지 검증 (CUSTOM 포함) - validateProjectTeamRolesOrThrow(projectId, roleFields, req.customFieldName()); + validateProjectTeamRolesOrThrow(projectId, roleFields, customName); // 미션 N 검증: "시작일만" 미션 기간에 포함되면 요구사항 일치 validateStartDateInSelectedMission(projectId, req.missionNumber(), start); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java index 937fa046..8b9ea852 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -503,7 +503,7 @@ public ProcessTaskItemResDto updateWeekMissionTaskItem( ); } - // 위크미션 드롭 다운용 조화 + // 위크미션 드롭 다운용 조회 @Transactional(readOnly = true) public WeekMissionDropdownResDto getMissionDropdown(Long projectId, Long userId) { assertActiveProjectMember(projectId, userId); From 3f6adbaadeccbc3095960a7f89553416f798934d Mon Sep 17 00:00:00 2001 From: infiniment Date: Mon, 2 Feb 2026 00:05:09 +0900 Subject: [PATCH 10/66] =?UTF-8?q?[Feat]=20=ED=8C=8C=ED=8A=B8=EB=B3=84=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=ED=98=84=ED=99=A9=20=ED=8C=8C=ED=8A=B8?= =?UTF-8?q?=EB=B3=84=20=EC=9E=91=EC=97=85=20=EC=A7=84=ED=96=89=EB=A5=A0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java --- .../team/process/service/ProcessService.java | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index b2c8144b..221fd3eb 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -1517,6 +1517,107 @@ private int rate(long part, long total) { return (int) Math.round(part * 100.0 / total); } + // 파트별 작업 진행률 조회 서비스 + @Transactional(readOnly = true) + public ProcessProgressSummaryResDto getPartProgressSummary(Long projectId, Long userId) { + assertActiveProjectMember(projectId, userId); + + if (!projectRepository.existsById(projectId)) { + throw new ProcessException(ProcessErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId); + } + + List statuses = List.of( + ProcessStatus.PLANNING, + ProcessStatus.IN_PROGRESS, + ProcessStatus.DONE + ); + + RoleField custom = RoleField.CUSTOM; + + List roleRows = + processRepository.countRoleLaneStatusForProgressSummary(projectId, custom, statuses); + + List customRows = + processRepository.countCustomLaneStatusForProgressSummary(projectId, custom, statuses); + + // laneKey -> status -> count + Map> laneCounts = new LinkedHashMap<>(); + + // ROLE lanes + for (var r : roleRows) { + RoleField rf = r.getRoleField(); + if (rf == null) continue; + + String laneKey = "ROLE:" + rf.name(); + laneCounts.computeIfAbsent(laneKey, k -> new EnumMap<>(ProcessStatus.class)) + .put(r.getStatus(), r.getCnt()); + } + + // CUSTOM lanes + for (var r : customRows) { + String name = r.getCustomName(); + if (name == null) continue; + + String trimmed = name.trim(); + if (trimmed.isBlank()) continue; + + String laneKey = "CUSTOM:" + trimmed; + laneCounts.computeIfAbsent(laneKey, k -> new EnumMap<>(ProcessStatus.class)) + .put(r.getStatus(), r.getCnt()); + } + + + // 정렬 : ROLE 먼저, CUSTOM 다음, 이름 오름차순으로 + List sortedKeys = laneCounts.keySet().stream() + .sorted((a, b) -> { + int ta = a.startsWith("ROLE:") ? 0 : 1; + int tb = b.startsWith("CUSTOM:") ? 0 : 1; + if(ta != tb) return Integer.compare(ta, tb); + return a.compareTo(b); + }) + .toList(); + + List lanes = sortedKeys.stream() + .map(laneKey -> { + EnumMap m = laneCounts.get(laneKey); + + long planning = m.getOrDefault(ProcessStatus.PLANNING, 0L); + long inProgress = m.getOrDefault(ProcessStatus.IN_PROGRESS, 0L); + long done = m.getOrDefault(ProcessStatus.DONE, 0L); + long total = planning + inProgress + done; + + int planningRate = rate(planning, total); + int inProgressRate = rate(inProgress, total); + int doneRate = (total == 0) ? 0 : Math.max(0, 100 - planningRate - inProgressRate); + + LaneType laneType = laneKey.startsWith("ROLE:") ? LaneType.ROLE : LaneType.CUSTOM; + String laneName = laneType == LaneType.ROLE + ? laneKey.substring("ROLE:".length()) + : laneKey.substring("CUSTOM:".length()); + + return new LaneProgressResDto( + laneKey, + laneType, + laneName, + planning, + inProgress, + done, + total, + planningRate, + inProgressRate, + doneRate + ); + }) + .toList(); + + return new ProcessProgressSummaryResDto(lanes); + } + + private int rate(long part, long total) { + if (total == 0) return 0; + return (int) Math.round(part * 100.0 / total); + } + // 프로세스 위치 상태 정렬 변경 서비스 @Transactional From 4daf22b35993b9535b82e5154fefc84ad9a809f0 Mon Sep 17 00:00:00 2001 From: infiniment Date: Thu, 5 Feb 2026 19:40:08 +0900 Subject: [PATCH 11/66] =?UTF-8?q?[Fix]=20=EC=B6=A9=EB=8F=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/process/service/ProcessService.java | 102 ------------------ 1 file changed, 102 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index 221fd3eb..e900947a 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -1517,108 +1517,6 @@ private int rate(long part, long total) { return (int) Math.round(part * 100.0 / total); } - // 파트별 작업 진행률 조회 서비스 - @Transactional(readOnly = true) - public ProcessProgressSummaryResDto getPartProgressSummary(Long projectId, Long userId) { - assertActiveProjectMember(projectId, userId); - - if (!projectRepository.existsById(projectId)) { - throw new ProcessException(ProcessErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId); - } - - List statuses = List.of( - ProcessStatus.PLANNING, - ProcessStatus.IN_PROGRESS, - ProcessStatus.DONE - ); - - RoleField custom = RoleField.CUSTOM; - - List roleRows = - processRepository.countRoleLaneStatusForProgressSummary(projectId, custom, statuses); - - List customRows = - processRepository.countCustomLaneStatusForProgressSummary(projectId, custom, statuses); - - // laneKey -> status -> count - Map> laneCounts = new LinkedHashMap<>(); - - // ROLE lanes - for (var r : roleRows) { - RoleField rf = r.getRoleField(); - if (rf == null) continue; - - String laneKey = "ROLE:" + rf.name(); - laneCounts.computeIfAbsent(laneKey, k -> new EnumMap<>(ProcessStatus.class)) - .put(r.getStatus(), r.getCnt()); - } - - // CUSTOM lanes - for (var r : customRows) { - String name = r.getCustomName(); - if (name == null) continue; - - String trimmed = name.trim(); - if (trimmed.isBlank()) continue; - - String laneKey = "CUSTOM:" + trimmed; - laneCounts.computeIfAbsent(laneKey, k -> new EnumMap<>(ProcessStatus.class)) - .put(r.getStatus(), r.getCnt()); - } - - - // 정렬 : ROLE 먼저, CUSTOM 다음, 이름 오름차순으로 - List sortedKeys = laneCounts.keySet().stream() - .sorted((a, b) -> { - int ta = a.startsWith("ROLE:") ? 0 : 1; - int tb = b.startsWith("CUSTOM:") ? 0 : 1; - if(ta != tb) return Integer.compare(ta, tb); - return a.compareTo(b); - }) - .toList(); - - List lanes = sortedKeys.stream() - .map(laneKey -> { - EnumMap m = laneCounts.get(laneKey); - - long planning = m.getOrDefault(ProcessStatus.PLANNING, 0L); - long inProgress = m.getOrDefault(ProcessStatus.IN_PROGRESS, 0L); - long done = m.getOrDefault(ProcessStatus.DONE, 0L); - long total = planning + inProgress + done; - - int planningRate = rate(planning, total); - int inProgressRate = rate(inProgress, total); - int doneRate = (total == 0) ? 0 : Math.max(0, 100 - planningRate - inProgressRate); - - LaneType laneType = laneKey.startsWith("ROLE:") ? LaneType.ROLE : LaneType.CUSTOM; - String laneName = laneType == LaneType.ROLE - ? laneKey.substring("ROLE:".length()) - : laneKey.substring("CUSTOM:".length()); - - return new LaneProgressResDto( - laneKey, - laneType, - laneName, - planning, - inProgress, - done, - total, - planningRate, - inProgressRate, - doneRate - ); - }) - .toList(); - - return new ProcessProgressSummaryResDto(lanes); - } - - private int rate(long part, long total) { - if (total == 0) return 0; - return (int) Math.round(part * 100.0 / total); - } - - // 프로세스 위치 상태 정렬 변경 서비스 @Transactional public ProcessOrderUpdateResDto updateProcessOrder(Long projectId, Long userId, Long processId, ProcessOrderUpdateReqDto req) { From 4e62b813e30227cd8406236fdf4872a8323aa755 Mon Sep 17 00:00:00 2001 From: infiniment Date: Thu, 5 Feb 2026 23:37:18 +0900 Subject: [PATCH 12/66] =?UTF-8?q?[Feat]=20=EC=9C=84=ED=81=AC=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD/=ED=95=A0?= =?UTF-8?q?=EC=9D=BC=20=ED=95=AD=EB=AA=A9=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WeekMissionController.java | 76 +++ .../dto/req/ProcessTaskItemReorderReqDto.java | 10 +- .../req/WeekMissionStatusUpdateReqDto.java | 9 + .../req/WeekMissionTaskItemReorderReqDto.java | 17 + .../req/WeekMissionTaskItemUpdateReqDto.java | 18 + .../dto/res/WeekMissionDetailResDto.java | 74 +++ .../dto/res/WeekMissionWeekResDto.java | 61 +++ .../service/ProcessTaskItemService.java | 231 +++++++- .../process/service/WeekMissionService.java | 513 ++++++++++++++++++ .../team/project/service/ProjectService.java | 2 +- .../team/{process => }/ProjectTeamRole.java | 3 +- .../analysis/ProjectTeamRoleRepository.java | 2 +- .../team/ProjectUserRepository.java | 25 + .../team/process/ProcessRepository.java | 157 +++++- .../process/ProcessTaskItemRepository.java | 31 ++ 15 files changed, 1192 insertions(+), 37 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionStatusUpdateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemReorderReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemUpdateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionWeekResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java rename nect-core/src/main/java/com/nect/core/entity/team/{process => }/ProjectTeamRole.java (91%) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java new file mode 100644 index 00000000..825b4ac2 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java @@ -0,0 +1,76 @@ +package com.nect.api.domain.team.process.controller; + +import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; +import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; +import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; +import com.nect.api.domain.team.process.service.WeekMissionService; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}/week-missions") +public class WeekMissionController { + + private final WeekMissionService weekMissionService; + + // 주차별 위크미션 조회 + @GetMapping("/week") + public ApiResponse getWeekMissions( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(value = "start_date", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(value = "weeks", required = false, defaultValue = "1") Integer weeks + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(weekMissionService.getWeekMissions(projectId, userId, startDate, weeks)); + } + + // 위크미션 상세 조회(체크리스트 포함) + @GetMapping("/{processId}") + public ApiResponse getWeekMissionDetail( + @PathVariable Long projectId, + @PathVariable Long processId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok( + weekMissionService.getDetail(projectId, userDetails.getUserId(), processId) + ); + } + + // 위크미션 상태 변경 + @PatchMapping("/{processId}/status") + public ApiResponse updateWeekMissionStatus( + @PathVariable Long projectId, + @PathVariable Long processId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody WeekMissionStatusUpdateReqDto req + ) { + weekMissionService.updateWeekMissionStatus(projectId, userDetails.getUserId(), processId, req); + return ApiResponse.ok(null); + } + + // 위크미션 TASK 내 항목 내용 수정 + @PatchMapping("/{processId}/task-items/{taskItemId}") + public ApiResponse updateWeekMissionTaskItem( + @PathVariable Long projectId, + @PathVariable Long processId, + @PathVariable Long taskItemId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody WeekMissionTaskItemUpdateReqDto req + ) { + return ApiResponse.ok( + weekMissionService.updateWeekMissionTaskItem(projectId, userDetails.getUserId(), processId, taskItemId, req) + ); + } + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java index ace1b35e..aec05c72 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java @@ -1,10 +1,18 @@ package com.nect.api.domain.team.process.dto.req; import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; import java.util.List; public record ProcessTaskItemReorderReqDto( @JsonProperty("ordered_task_item_ids") - List orderedTaskItemIds + List orderedTaskItemIds, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName + ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionStatusUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionStatusUpdateReqDto.java new file mode 100644 index 00000000..97531f2d --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionStatusUpdateReqDto.java @@ -0,0 +1,9 @@ +package com.nect.api.domain.team.process.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.process.enums.ProcessStatus; + +public record WeekMissionStatusUpdateReqDto( + @JsonProperty("status") + ProcessStatus status +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemReorderReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemReorderReqDto.java new file mode 100644 index 00000000..9e689c22 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemReorderReqDto.java @@ -0,0 +1,17 @@ +package com.nect.api.domain.team.process.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +import java.util.List; + +public record WeekMissionTaskItemReorderReqDto( + @JsonProperty("ordered_task_item_ids") + List orderedTaskItemIds, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemUpdateReqDto.java new file mode 100644 index 00000000..c00df089 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemUpdateReqDto.java @@ -0,0 +1,18 @@ +package com.nect.api.domain.team.process.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +public record WeekMissionTaskItemUpdateReqDto( + @JsonProperty("content") + String content, + + @JsonProperty("is_done") + Boolean isDone, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java new file mode 100644 index 00000000..d758526f --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java @@ -0,0 +1,74 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.nect.core.entity.team.process.enums.ProcessStatus; +import com.nect.core.entity.user.enums.RoleField; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public record WeekMissionDetailResDto( + @JsonProperty("process_id") + Long processId, + + @JsonProperty("mission_number") + Integer missionNumber, + + @JsonProperty("title") + String title, + + @JsonProperty("content") + String content, + + @JsonProperty("status") + ProcessStatus status, + + @JsonProperty("start_date") + LocalDate startDate, + + @JsonProperty("dead_line") + LocalDate deadLine, + + @JsonProperty("assignee") + AssigneeDto assignee, + + @JsonProperty("task_groups") + List taskGroups, + + @JsonProperty("task_items") + List taskItems, + + @JsonProperty("created_at") + LocalDateTime createdAt, + + @JsonProperty("updated_at") + LocalDateTime updatedAt +) { + public record AssigneeDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("name") + String name, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} + + public record TaskGroupResDto( + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_field_name") + String customFieldName, + + @JsonProperty("items") + List items + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionWeekResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionWeekResDto.java new file mode 100644 index 00000000..47b6efbc --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionWeekResDto.java @@ -0,0 +1,61 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.process.enums.ProcessStatus; + +import java.time.LocalDate; +import java.util.List; + +public record WeekMissionWeekResDto( + @JsonProperty("week_start") + LocalDate weekStart, + + @JsonProperty("week_end") + LocalDate weekEnd, + + @JsonProperty("missions") + List missions +) { + public record WeekMissionCardResDto( + @JsonProperty("process_id") + Long processId, + + @JsonProperty("mission_number") + Integer missionNumber, + + @JsonProperty("status") + ProcessStatus status, + + @JsonProperty("title") + String title, + + @JsonProperty("start_date") + LocalDate startDate, + + @JsonProperty("dead_line") + LocalDate deadLine, + + @JsonProperty("left_day") + Integer leftDay, + + @JsonProperty("done_count") + int doneCount, + + @JsonProperty("total_count") + int totalCount, + + @JsonProperty("assignee") + AssigneeProfileDto assignee + ) {} + + public record AssigneeProfileDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java index 6bb71493..01c928d6 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java @@ -1,5 +1,7 @@ package com.nect.api.domain.team.process.service; +import com.nect.api.domain.notifications.command.NotificationCommand; +import com.nect.api.domain.notifications.facade.NotificationFacade; import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; import com.nect.api.domain.team.process.dto.req.ProcessTaskItemReorderReqDto; import com.nect.api.domain.team.process.dto.req.ProcessTaskItemUpsertReqDto; @@ -7,13 +9,23 @@ import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; import com.nect.api.domain.team.process.enums.ProcessErrorCode; import com.nect.api.domain.team.process.exception.ProcessException; +import com.nect.core.entity.notifications.enums.NotificationClassification; +import com.nect.core.entity.notifications.enums.NotificationScope; +import com.nect.core.entity.notifications.enums.NotificationType; +import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.history.enums.HistoryAction; import com.nect.core.entity.team.history.enums.HistoryTargetType; import com.nect.core.entity.team.process.Process; import com.nect.core.entity.team.process.ProcessTaskItem; +import com.nect.core.entity.team.process.enums.ProcessType; +import com.nect.core.entity.user.User; +import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.process.ProcessRepository; import com.nect.core.repository.team.process.ProcessTaskItemRepository; +import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +41,9 @@ public class ProcessTaskItemService { private final ProjectUserRepository projectUserRepository; private final ProjectHistoryPublisher historyPublisher; + private final UserRepository userRepository; + private final NotificationFacade notificationFacade; + // 헬퍼 메서드 private void assertWritableMember(Long projectId, Long userId) { if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { @@ -55,6 +70,7 @@ private ProcessTaskItem getTaskItem(Long processId, Long taskItemId) { )); } + // 전체 정규화 private void normalizeSortOrder(Long processId) { List items = taskItemRepository.findAllByProcessIdAndDeletedAtIsNullOrderBySortOrderAsc(processId); @@ -69,6 +85,44 @@ private void normalizeSortOrder(Long processId) { } } + // 파트별 정규화 + private void normalizeSortOrderByGroup(Long processId, RoleField roleField, String customName) { + List items = taskItemRepository + .findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameOrderBySortOrderAsc( + processId, roleField, customName + ); + + items = items.stream() + .sorted(Comparator.comparing(t -> t.getSortOrder() == null ? Integer.MAX_VALUE : t.getSortOrder())) + .toList(); + + int i = 0; + for (ProcessTaskItem it : items) { + it.updateSortOrder(i++); + } + } + + private void assertWeekMissionLeader(Long projectId, Long userId) { + boolean isLeader = projectUserRepository + .existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus(projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE); + + if (!isLeader) { + throw new ProcessException( + ProcessErrorCode.FORBIDDEN, + "WEEK_MISSION은 프로젝트 리더만 수정할 수 있습니다. projectId=" + projectId + ", userId=" + userId + ); + } + } + + private void assertReorderPermission(Long projectId, Long userId, Process process) { + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + assertWeekMissionLeader(projectId, userId); + return; + } + assertWritableMember(projectId, userId); + } + + // 항목 생성 서비스 @Transactional public ProcessTaskItemResDto create(Long projectId, Long userId, Long processId, ProcessTaskItemUpsertReqDto req) { @@ -76,6 +130,9 @@ public ProcessTaskItemResDto create(Long projectId, Long userId, Long processId, Process process = getActiveProcess(projectId, processId); + // 위크미션 TASK 수정 권한(리더만 가능) + assertReorderPermission(projectId, userId, process); + if (req.content() == null || req.content().isBlank()) { throw new ProcessException(ProcessErrorCode.INVALID_TASK_ITEM_CONTENT); } @@ -106,13 +163,6 @@ public ProcessTaskItemResDto create(Long projectId, Long userId, Long processId, // 최종 정규화 normalizeSortOrder(processId); - // TODO(Notification): - // - 프로젝트 멤버 전체 또는 해당 프로세스 관련자(assignee/mention)에게 "업무 항목 추가" 알림 전송 - // - 유저/멤버십 붙으면 NotificationFacade 주입 후 notify 호출 - // - 권장: AFTER_COMMIT 이벤트로 보내기 - // notifyProjectMembersTodo(projectId, actorUserId, processId, "TASK_ITEM_CREATED"); - // notifyMentionsTodo(projectId, actorUserId, processId, /* process mention ids */); - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("taskItemId", saved.getId()); @@ -221,13 +271,6 @@ public ProcessTaskItemResDto update(Long projectId, Long userId, Long processId, "sortOrder", item.getSortOrder() )); - // TODO(Notification): - // - 유저/멤버십 연동 후 수신자 결정(프로젝트 멤버 / 해당 프로세스 assignee / mention 등) - // - "업무 항목 수정" 알림 전송 - // - meta에 변경 요약 포함 권장(예: done 토글, 내용 변경, 순서 변경) - // - 권장: AFTER_COMMIT 이벤트 리스너로 전송 - // notifyTaskItemUpdatedTodo(projectId, actorUserId, processId, item.getId(), ...); - historyPublisher.publish( projectId, userId, @@ -267,8 +310,6 @@ public void delete(Long projectId, Long userId, Long processId, Long taskItemId) normalizeSortOrder(processId); - // TODO(Notification) - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("taskItemId", taskItemId); @@ -285,15 +326,44 @@ public void delete(Long projectId, Long userId, Long processId, Long taskItemId) meta ); - // TODO(TEAM EVENT FACADE): 추후 ActivityFacade로 통합 } - // 업무 위치 변경 서비스 + private List loadWorkspaceReceivers(Long projectId, Long actorId) { + return projectUserRepository.findAllUsersByProjectId(projectId).stream() + .filter(u -> !u.getUserId().equals(actorId)) + .toList(); + } + + private void notifyWorkspaceWeekMissionUpdated(Process process, Long actorId) { + // WEEK_MISSION만 알림 + if (process.getProcessType() != com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) return; + + Project project = process.getProject(); + User actor = userRepository.findById(actorId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.USER_NOT_FOUND, "userId=" + actorId)); + + List receivers = loadWorkspaceReceivers(project.getId(), actor.getUserId()); + if (receivers.isEmpty()) return; + + NotificationCommand command = new NotificationCommand( + NotificationType.WORKSPACE_MISSION_UPDATED, + NotificationClassification.WORK_STATUS, + NotificationScope.WORKSPACE_ONLY, + process.getId(), + new Object[]{ process.getMissionNumber() }, + new Object[]{ process.getTitle() }, + project + ); + + notificationFacade.notify(receivers, command); + } + + // 업무 위치 변경 서비스 (멤버형, 리더형을 하나로 관리) @Transactional public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long processId, ProcessTaskItemReorderReqDto req) { - assertWritableMember(projectId, userId); + Process process = getActiveProcess(projectId, processId); - getActiveProcess(projectId, processId); + assertReorderPermission(projectId, userId, process); if (req == null || req.orderedTaskItemIds() == null || req.orderedTaskItemIds().isEmpty()) { throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ordered_task_item_ids is empty"); @@ -311,11 +381,118 @@ public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long pr throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ordered_task_item_ids contains duplicates"); } + // 위크미션 TASK내에 필드별 항목 리스트 / 전체(멤버형) + RoleField roleField = req.roleField(); + String customName = req.customRoleFieldName(); + + boolean groupMode = (roleField != null); + + if (groupMode) { + // 분야별 모드 유효성 검사 + if (roleField == RoleField.CUSTOM) { + if (customName == null || customName.isBlank()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "CUSTOM이면 custom_role_field_name 필수"); + } + customName = customName.trim(); + } else { + // CUSTOM이 아니면 null로 고정 + customName = null; + } + + // 분야별 정규화(꼬임 방지) + normalizeSortOrderByGroup(processId, roleField, customName); + + // beforeIds (분야별) + List groupAll = taskItemRepository + .findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameOrderBySortOrderAsc( + processId, roleField, customName + ); + + List beforeIds = groupAll.stream().map(ProcessTaskItem::getId).toList(); + + // 변경 없으면 그대로 반환 + if (beforeIds.equals(orderedIds)) { + List resItems = groupAll.stream() + .map(t -> new ProcessTaskItemResDto(t.getId(), t.getContent(), t.isDone(), t.getSortOrder(), t.getDoneAt())) + .toList(); + return new ProcessTaskItemReorderResDto(processId, resItems); + } + + // 전체 포함 정책(그룹 단위) + if (groupAll.size() != orderedIds.size()) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "ordered_task_item_ids must include all task items of the group" + ); + } + + // 요청 ids가 모두 해당 그룹의 항목인지 검증 + List targets = + taskItemRepository.findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameAndIdIn( + processId, roleField, customName, orderedIds + ); + + if (targets.size() != orderedIds.size()) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "ordered_task_item_ids contains invalid taskItemId(s) for the group" + ); + } + + Map map = targets.stream() + .collect(Collectors.toMap(ProcessTaskItem::getId, t -> t)); + + // 재정렬 반영(분야별 그룹 내부 0..n-1) + int i = 0; + for (Long id : orderedIds) { + ProcessTaskItem item = map.get(id); + item.updateSortOrder(i++); + } + + Map meta = new LinkedHashMap<>(); + meta.put("processId", processId); + meta.put("processType", process.getProcessType() == null ? null : process.getProcessType().name()); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + + meta.put("groupMode", true); + meta.put("roleField", roleField.name()); + meta.put("customRoleFieldName", customName); + + meta.put("beforeOrderedTaskItemIds", beforeIds); + meta.put("afterOrderedTaskItemIds", orderedIds); + + historyPublisher.publish( + projectId, + userId, + HistoryAction.TASK_ITEM_REORDERED, + HistoryTargetType.PROCESS, + processId, + meta + ); + + notifyWorkspaceWeekMissionUpdated(process, userId); + + // 응답(요청 순서대로) + List resItems = orderedIds.stream() + .map(id -> { + ProcessTaskItem t = map.get(id); + return new ProcessTaskItemResDto( + t.getId(), t.getContent(), t.isDone(), t.getSortOrder(), t.getDoneAt() + ); + }) + .toList(); + + return new ProcessTaskItemReorderResDto(processId, resItems); + } + + /* + * 멤버형 프로세스 모달 전용 + * */ + // 꼬임 방지용 정규화 normalizeSortOrder(processId); - - // TODO(HISTORY/NOTI): before orderedIds 스냅샷이 필요하면 여기서 조회 List beforeIds = taskItemRepository .findAllByProcessIdAndDeletedAtIsNullOrderBySortOrderAsc(processId) .stream() @@ -367,9 +544,13 @@ public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long pr item.updateSortOrder(i++); } - // TODO(Notification): Map meta = new LinkedHashMap<>(); meta.put("processId", processId); + meta.put("processType", process.getProcessType() == null ? null : process.getProcessType().name()); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + + meta.put("groupMode", false); meta.put("beforeOrderedTaskItemIds", beforeIds); meta.put("afterOrderedTaskItemIds", orderedIds); @@ -381,7 +562,9 @@ public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long pr processId, meta ); - // TODO(TEAM EVENT FACADE): 추후 ActivityFacade로 통합 + + notifyWorkspaceWeekMissionUpdated(process, userId); + List resItems = orderedIds.stream() .map(id -> { diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java new file mode 100644 index 00000000..5ef99da4 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -0,0 +1,513 @@ +package com.nect.api.domain.team.process.service; + +import com.nect.api.domain.notifications.command.NotificationCommand; +import com.nect.api.domain.notifications.facade.NotificationFacade; +import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; +import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; +import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; +import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; +import com.nect.api.domain.team.process.enums.ProcessErrorCode; +import com.nect.api.domain.team.process.exception.ProcessException; +import com.nect.core.entity.notifications.enums.NotificationClassification; +import com.nect.core.entity.notifications.enums.NotificationScope; +import com.nect.core.entity.notifications.enums.NotificationType; +import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.team.history.enums.HistoryAction; +import com.nect.core.entity.team.history.enums.HistoryTargetType; +import com.nect.core.entity.team.process.Process; +import com.nect.core.entity.team.process.ProcessTaskItem; +import com.nect.core.entity.team.process.enums.ProcessStatus; +import com.nect.core.entity.user.User; +import com.nect.core.entity.user.enums.RoleField; +import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.team.process.ProcessRepository; +import com.nect.core.repository.team.process.ProcessTaskItemRepository; +import com.nect.core.repository.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class WeekMissionService { + + private final ProjectRepository projectRepository; + private final ProjectUserRepository projectUserRepository; + private final ProcessRepository processRepository; + private final ProcessTaskItemRepository processTaskItemRepository; + + private final UserRepository userRepository; + private final NotificationFacade notificationFacade; + private final ProjectHistoryPublisher historyPublisher; + + private void assertActiveProjectMember(Long projectId, Long userId) { + if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { + throw new ProcessException( + ProcessErrorCode.FORBIDDEN, + "프로젝트 멤버가 아닙니다. projectId=" + projectId + ", userId=" + userId + ); + } + } + + private void assertActiveLeader(Long projectId, Long userId) { + boolean ok = projectUserRepository.existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus( + projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE + ); + if (!ok) { + throw new ProcessException(ProcessErrorCode.FORBIDDEN, "WEEK_MISSION 수정은 프로젝트 리더만 가능합니다."); + } + } + + private String normalizeCustom(RoleField roleField, String customName) { + if (roleField == RoleField.CUSTOM) { + if (customName == null || customName.isBlank()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "CUSTOM이면 custom_role_field_name 필수"); + } + return customName.trim(); + } + return null; + } + + private void normalizeGroupOrders(Long processId, RoleField roleField, String customName) { + List items = processTaskItemRepository + .findWeekMissionGroupItemsOrdered(processId, roleField, customName); + + int i = 0; + for (ProcessTaskItem it : items) { + it.updateSortOrder(i++); + } + } + + private void assertProjectExists(Long projectId) { + if (!projectRepository.existsById(projectId)) { + throw new ProcessException(ProcessErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId); + } + } + + private Integer calcLeftDay(LocalDate deadLine) { + if (deadLine == null) return null; + long diff = ChronoUnit.DAYS.between(LocalDate.now(), deadLine); + return (int) Math.max(diff, 0); + } + + + private WeekMissionWeekResDto toWeekRes( + LocalDate start, + LocalDate end, + List rows, + WeekMissionWeekResDto.AssigneeProfileDto leaderFallback + ){ + List cards = rows.stream() + .map(r -> { + long done = (r.getDoneCount() == null) ? 0L : r.getDoneCount(); + long total = (r.getTotalCount() == null) ? 0L : r.getTotalCount(); + + WeekMissionWeekResDto.AssigneeProfileDto assignee = + (r.getLeaderUserId() != null) + ? new WeekMissionWeekResDto.AssigneeProfileDto( + r.getLeaderUserId(), + r.getLeaderNickname(), + r.getLeaderProfileImageUrl() + ) + : leaderFallback; + + return new WeekMissionWeekResDto.WeekMissionCardResDto( + r.getProcessId(), + r.getMissionNumber(), + r.getStatus(), + r.getTitle(), + r.getStartDate(), + r.getDeadLine(), + calcLeftDay(r.getDeadLine()), + (int) done, + (int) total, + assignee + ); + }) + .toList(); + + return new WeekMissionWeekResDto(start, end, cards); + } + + private List loadWorkspaceReceivers(Long projectId, Long actorId) { + return projectUserRepository.findAllUsersByProjectId(projectId).stream() + .filter(u -> !u.getUserId().equals(actorId)) + .toList(); + } + + private void notifyWorkspaceWeekMissionUpdated(Project project, User actor, Process process) { + List receivers = loadWorkspaceReceivers(project.getId(), actor.getUserId()); + if (receivers == null || receivers.isEmpty()) return; + + NotificationCommand command = new NotificationCommand( + NotificationType.WORKSPACE_MISSION_UPDATED, + NotificationClassification.WORK_STATUS, + NotificationScope.WORKSPACE_ONLY, + process.getId(), + new Object[]{ process.getMissionNumber() }, + new Object[]{ process.getTitle() }, + project + ); + + notificationFacade.notify(receivers, command); + } + + private void publishWeekMissionHistory( + Long projectId, Long userId, Long processId, + HistoryAction action, + Map meta + ) { + historyPublisher.publish( + projectId, + userId, + action, + HistoryTargetType.PROCESS, + processId, + meta + ); + } + + /** + * 주차(월~일) 기준 WEEK_MISSION 목록 (정규화 O) + * GET /week-missions/week?start_date=YYYY-MM-DD&weeks=4 + */ + @Transactional(readOnly = true) + public WeekMissionWeekResDto getWeekMissions(Long projectId, Long userId, LocalDate startDate, Integer weeks) { + assertActiveProjectMember(projectId, userId); + assertProjectExists(projectId); + + int w = (weeks == null) ? 1 : weeks; + + // 방어 (원하는 상한 정하면 됨) + if (w <= 0) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "weeks must be >= 1"); + } + if (w > 12) { // 예: 과도 조회 방지 + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "weeks is too large"); + } + + LocalDate fallback = processRepository.findMinWeekMissionStartAt(projectId); + if (fallback == null) { + // 프로젝트에 아직 위크미션이 없다면(혹은 startAt이 전부 null) + fallback = LocalDate.now(); + } + + LocalDate weekStart = resolveWeekStart(startDate, fallback); + LocalDate end = weekStart.plusDays(w * 7L - 1); + + var rows = processRepository.findWeekMissionCardsInRange(projectId, weekStart, end); + + WeekMissionWeekResDto.AssigneeProfileDto leaderFallback = projectUserRepository + .findActiveLeaderProfile(projectId) + .map(r -> new WeekMissionWeekResDto.AssigneeProfileDto( + r.getUserId(), + r.getNickname(), + r.getProfileImageUrl() + )) + .orElse(null); + + return toWeekRes(weekStart, end, rows, leaderFallback); + } + + /** + * WEEK_MISSION 상세 (체크리스트 포함) + * GET /week-missions/{processId} + */ + @Transactional(readOnly = true) + public WeekMissionDetailResDto getDetail(Long projectId, Long userId, Long processId) { + assertActiveProjectMember(projectId, userId); + + Process process = processRepository.findWeekMissionDetail(projectId, processId) + .orElseThrow(() -> new ProcessException( + ProcessErrorCode.PROCESS_NOT_FOUND, + "projectId=" + projectId + ", processId=" + processId + )); + + // 삭제 제외 + 정렬(공통) + List aliveItems = process.getTaskItems().stream() + .filter(t -> t.getDeletedAt() == null) + .sorted(Comparator.comparing(t -> t.getSortOrder() == null ? Integer.MAX_VALUE : t.getSortOrder())) + .toList(); + + // task_items + List taskItems = aliveItems.stream() + .map(t -> new ProcessTaskItemResDto( + t.getId(), + t.getContent(), + t.isDone(), + t.getSortOrder(), + t.getDoneAt() + )) + .toList(); + + // task_groups (리더형: roleField + customRoleFieldName 기준) + record GroupKey(RoleField roleField, String customName) {} + + Map> grouped = aliveItems.stream() + .collect(Collectors.groupingBy( + t -> new GroupKey(t.getRoleField(), t.getCustomRoleFieldName()), + LinkedHashMap::new, + Collectors.toList() + )); + + List taskGroups = grouped.entrySet().stream() + .map(e -> { + GroupKey key = e.getKey(); + + List items = e.getValue().stream() + .sorted(Comparator.comparing(t -> t.getSortOrder() == null ? Integer.MAX_VALUE : t.getSortOrder())) + .map(t -> new ProcessTaskItemResDto( + t.getId(), + t.getContent(), + t.isDone(), + t.getSortOrder(), + t.getDoneAt() + )) + .toList(); + + return new WeekMissionDetailResDto.TaskGroupResDto( + key.roleField(), + key.customName(), + items + ); + }) + // 그룹 순서 정렬 + .sorted((a, b) -> { + // null은 맨 뒤 + int ra = (a.roleField() == null) ? Integer.MAX_VALUE : a.roleField().ordinal(); + int rb = (b.roleField() == null) ? Integer.MAX_VALUE : b.roleField().ordinal(); + + // CUSTOM은 일반 RoleField 뒤로 보내고 싶으면 가중치 + if (a.roleField() == RoleField.CUSTOM) ra += 1000; + if (b.roleField() == RoleField.CUSTOM) rb += 1000; + + int cmp = Integer.compare(ra, rb); + if (cmp != 0) return cmp; + + // 같은 roleField면 customFieldName 알파벳/가나다 순 + String ca = (a.customFieldName() == null) ? "" : a.customFieldName(); + String cb = (b.customFieldName() == null) ? "" : b.customFieldName(); + return ca.compareTo(cb); + }) + .toList(); + + User leader = process.getCreatedBy(); + + WeekMissionDetailResDto.AssigneeDto assignee = new WeekMissionDetailResDto.AssigneeDto( + leader.getUserId(), + leader.getName(), + leader.getNickname(), + leader.getProfileImageUrl() + ); + + // DTO 생성자 인자 순서 주의: (taskGroups, taskItems) 둘 다 넣기 + return new WeekMissionDetailResDto( + process.getId(), + process.getMissionNumber(), + process.getTitle(), + process.getContent(), + process.getStatus(), + process.getStartAt(), + process.getEndAt(), + assignee, + taskGroups, + taskItems, + process.getCreatedAt(), + process.getUpdatedAt() + ); + } + + // 위크미션 TASK 프로세스 상태 변경 서비스 + @Transactional + public void updateWeekMissionStatus(Long projectId, Long userId, Long processId, WeekMissionStatusUpdateReqDto req) { + assertActiveLeader(projectId, userId); + + if (req == null || req.status() == null) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "status is required"); + } + + Process process = processRepository.findWeekMissionDetail(projectId, processId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.PROCESS_NOT_FOUND)); + + + ProcessStatus before = process.getStatus(); + ProcessStatus after = req.status(); + if(before == after) return; + + process.updateStatus(after); + + User actor = userRepository.findById(userId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.USER_NOT_FOUND, "userId=" + userId)); + + Project project = process.getProject(); + + notifyWorkspaceWeekMissionUpdated(project, actor, process); + + Map meta = new LinkedHashMap<>(); + meta.put("processType", "WEEK_MISSION"); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + meta.put("beforeStatus", before.name()); + meta.put("afterStatus", after.name()); + + publishWeekMissionHistory( + projectId, userId, processId, + HistoryAction.PROCESS_STATUS_CHANGED, + meta + ); + } + + // 위크미션 TASK 항목 수정 + @Transactional + public ProcessTaskItemResDto updateWeekMissionTaskItem( + Long projectId, Long userId, Long processId, Long taskItemId, WeekMissionTaskItemUpdateReqDto req + ) { + assertActiveLeader(projectId, userId); + + // 위크미션 존재 검증 + Process process = processRepository.findWeekMissionDetail(projectId, processId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.PROCESS_NOT_FOUND)); + + ProcessTaskItem item = processTaskItemRepository.findByIdAndProcessIdAndDeletedAtIsNull(taskItemId, processId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.TASK_ITEM_NOT_FOUND)); + + if (req == null) throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "request is null"); + + boolean changed = false; + + // Before 스냅샷 + String beforeContent = item.getContent(); + Boolean beforeDone = item.isDone(); + Integer beforeSortOrder = item.getSortOrder(); + RoleField beforeRole = item.getRoleField(); + String beforeCustom = item.getCustomRoleFieldName(); + + + // content + if (req.content() != null) { + if (req.content().isBlank()) throw new ProcessException(ProcessErrorCode.INVALID_TASK_ITEM_CONTENT); + String newContent = req.content().trim(); + if (!newContent.equals(beforeContent)) { + item.updateContent(newContent); + changed = true; + } + } + + // done + if (req.isDone() != null) { + boolean newDone = req.isDone(); + if (newDone != beforeDone) { + item.updateDone(newDone); + changed = true; + } + } + + + // role 변경(원하면 허용 / 싫으면 이 블록 삭제) + if (req.roleField() != null) { + RoleField newRole = req.roleField(); + String newCustom = normalizeCustom(newRole, req.customRoleFieldName()); + + boolean roleChanged = + newRole != beforeRole || + (newRole == RoleField.CUSTOM && !java.util.Objects.equals(newCustom, beforeCustom)); + + if (roleChanged) { + // 이동 전 그룹 정보 저장 + RoleField oldRole = beforeRole; + String oldCustom = beforeCustom; + + try { + item.updateRoleField(newRole, newCustom); + } catch (IllegalArgumentException e) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, e.getMessage()); + } + + // 이전 그룹 정규화 + normalizeGroupOrders(processId, oldRole, oldCustom); + + // 새 그룹 끝으로 보내고 정규화 + List newGroup = processTaskItemRepository + .findWeekMissionGroupItemsOrdered(processId, newRole, newCustom); + + int nextOrder = newGroup.size() - 1; + item.updateSortOrder(Math.max(nextOrder, 0)); + normalizeGroupOrders(processId, newRole, newCustom); + + changed = true; + } + } + + if (!changed) { + return new ProcessTaskItemResDto( + item.getId(), item.getContent(), item.isDone(), item.getSortOrder(), item.getDoneAt() + ); + } + + User actor = userRepository.findById(userId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.USER_NOT_FOUND, "userId=" + userId)); + Project project = process.getProject(); + + notifyWorkspaceWeekMissionUpdated(project, actor, process); + + Map meta = new LinkedHashMap<>(); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + meta.put("taskItemId", item.getId()); + + meta.put("before", Map.of( + "content", beforeContent, + "isDone", beforeDone, + "sortOrder", beforeSortOrder, + "roleField", beforeRole == null ? null : beforeRole.name(), + "customRoleFieldName", beforeCustom + )); + meta.put("after", Map.of( + "content", item.getContent(), + "isDone", item.isDone(), + "sortOrder", item.getSortOrder(), + "roleField", item.getRoleField() == null ? null : item.getRoleField().name(), + "customRoleFieldName", item.getCustomRoleFieldName() + )); + + publishWeekMissionHistory( + projectId, userId, processId, + HistoryAction.TASK_ITEM_UPDATED, + meta + ); + + + + return new ProcessTaskItemResDto( + item.getId(), + item.getContent(), + item.isDone(), + item.getSortOrder(), + item.getDoneAt() + ); + } + + private LocalDate toMonday(LocalDate date) { + return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + } + + private LocalDate resolveWeekStart(LocalDate requested, LocalDate fallbackBaseDate) { + LocalDate base = (requested != null) ? requested : fallbackBaseDate; + return toMonday(base); + } +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java index a2846af9..d461bf8c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java @@ -14,7 +14,7 @@ import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.enums.RecruitmentStatus; import com.nect.core.entity.team.process.ProcessTaskItem; -import com.nect.core.entity.team.process.ProjectTeamRole; +import com.nect.core.entity.team.ProjectTeamRole; import com.nect.core.entity.user.User; import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.analysis.*; diff --git a/nect-core/src/main/java/com/nect/core/entity/team/process/ProjectTeamRole.java b/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java similarity index 91% rename from nect-core/src/main/java/com/nect/core/entity/team/process/ProjectTeamRole.java rename to nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java index 9aa027e1..e48fbd3a 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/process/ProjectTeamRole.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java @@ -1,8 +1,7 @@ -package com.nect.core.entity.team.process; +package com.nect.core.entity.team; import com.nect.core.entity.BaseEntity; -import com.nect.core.entity.team.Project; import com.nect.core.entity.user.enums.RoleField; import jakarta.persistence.*; import lombok.*; diff --git a/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java index 1e34d2c4..f00182b5 100644 --- a/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java @@ -1,5 +1,5 @@ package com.nect.core.repository.analysis; -import com.nect.core.entity.team.process.ProjectTeamRole; +import com.nect.core.entity.team.ProjectTeamRole; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java index 20aa1d8d..62c09546 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java @@ -228,4 +228,29 @@ interface MemberBoardRow { } Optional findByProjectIdAndMemberType(Long projectId, ProjectMemberType memberType); + + boolean existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus(Long projectId, Long userId, ProjectMemberType projectMemberType, ProjectMemberStatus projectMemberStatus); + + interface ProjectLeaderProfileRow { + Long getUserId(); + String getNickname(); + String getProfileImageUrl(); + } + + @Query(""" + select + u.userId as userId, + u.nickname as nickname, + u.profileImageUrl as profileImageUrl + from ProjectUser pu + join User u + on u.userId = pu.userId + where pu.project.id = :projectId + and pu.memberStatus = com.nect.core.entity.team.enums.ProjectMemberStatus.ACTIVE + and pu.memberType = com.nect.core.entity.team.enums.ProjectMemberType.LEADER + """) + Optional findActiveLeaderProfile(@Param("projectId") Long projectId); + + + } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index 2ea0a4ba..2d0e63ad 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -17,15 +17,13 @@ public interface ProcessRepository extends JpaRepository { // 소속 검증 + 소프트 delete 제외 Optional findByIdAndProjectIdAndDeletedAtIsNull(Long id, Long projectId); - @EntityGraph(attributePaths = { - "processUsers", - "processUsers.user" - }) + @EntityGraph(attributePaths = { "processUsers", "processUsers.user" }) @Query(""" select p from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and ( (p.startAt is null and p.endAt is null) or (p.startAt is null and p.endAt >= :start) @@ -51,6 +49,7 @@ List findAllInRangeOrdered( from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.id in :ids """) List findAllByIdsInProject( @@ -59,6 +58,7 @@ List findAllByIdsInProject( ); + /** * Team 보드(공통) 조회 * - "모든 팀의 작업들을 전부 확인" => 필드/파트 관계없이 전체 프로세스 조회 @@ -75,6 +75,7 @@ List findAllByIdsInProject( and o.deletedAt is null where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) order by p.status asc, coalesce(o.sortOrder, 999999) asc, @@ -82,13 +83,14 @@ List findAllByIdsInProject( """) List findAllForTeamBoard(@Param("projectId") Long projectId); - // ROLE 레인: 조건에 맞는 Process ID만 (정렬은 굳이 안 해도 됨) + // ROLE 레인: 조건에 맞는 Process ID만 @Query(""" select distinct p.id from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField = :roleField """) @@ -104,6 +106,7 @@ List findRoleLaneIds( join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM and trim(pf.customFieldName) = :customName @@ -124,6 +127,7 @@ List findCustomLaneIds( from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.id in :ids """) List findAllByIdsInProjectWithUsers( @@ -137,6 +141,7 @@ List findAllByIdsInProjectWithUsers( from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.id in :ids """) List findAllByIdsInProjectWithFields( @@ -161,6 +166,7 @@ interface MissionProgressRow { JOIN p.processFields pf WHERE p.project.id = :projectId AND p.deletedAt IS NULL + AND (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) AND pf.deletedAt IS NULL GROUP BY pf.roleField, pf.customFieldName """) @@ -182,6 +188,7 @@ interface MemberProcessCountRow { JOIN p.processUsers pu WHERE p.project.id = :projectId AND p.deletedAt IS NULL + AND (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) AND pu.deletedAt IS NULL GROUP BY pu.user.userId, p.status """) @@ -206,6 +213,7 @@ interface LaneStatusCountRow { join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField is not null and pf.roleField <> :custom @@ -229,6 +237,7 @@ List countRoleLaneStatusForProgressSummary( join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField = :custom and pf.customFieldName is not null @@ -242,12 +251,13 @@ List countCustomLaneStatusForProgressSummary( @Param("statuses") List statuses ); - // status 내 TEAM 전체 + // status 내 TEAM 전체 (GENERAL만) @Query(""" select p from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status """) List findAllByStatusInProject( @@ -255,13 +265,14 @@ List findAllByStatusInProject( @Param("status") ProcessStatus status ); - // status 내 ROLE lane 전체 + // status 내 ROLE lane 전체 (GENERAL만) @Query(""" select distinct p from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status and pf.deletedAt is null and pf.roleField = :roleField @@ -272,13 +283,15 @@ List findAllInRoleLaneByStatus( @Param("roleField") RoleField roleField ); - // status 내 CUSTOM lane 전체 + + // status 내 CUSTOM lane 전체 (GENERAL만) @Query(""" select distinct p from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status and pf.deletedAt is null and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM @@ -304,6 +317,7 @@ interface LaneKeyRow { join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField is not null """) @@ -312,12 +326,30 @@ interface LaneKeyRow { int countByProjectIdAndDeletedAtIsNullAndStatus(Long projectId, ProcessStatus status); + /** + * TEAM lane total (GENERAL만) + */ + @Query(""" + select count(p) + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.status = :status + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) + """) + int countTeamLaneTotalExcludingWeekMission( + @Param("projectId") Long projectId, + @Param("status") ProcessStatus status + ); + + // ROLE lane total @Query(""" select count(distinct p) from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status and pf.deletedAt is null and pf.roleField = :roleField @@ -328,12 +360,14 @@ int countRoleLaneTotal( @Param("roleField") RoleField roleField ); + // CUSTOM lane total @Query(""" select count(distinct p) from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status and pf.deletedAt is null and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM @@ -344,5 +378,112 @@ int countCustomLaneTotal( @Param("status") ProcessStatus status, @Param("customName") String customName ); + + // WEEK_MISSION 상세(체크리스트 포함) + @EntityGraph(attributePaths = { "taskItems", "createdBy" }) + @Query(""" + select p + from Process p + where p.project.id = :projectId + and p.id = :processId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + """) + Optional findWeekMissionDetail( + @Param("projectId") Long projectId, + @Param("processId") Long processId + ); + + + // WEEK_MISSION 주차별 조회 + interface WeekMissionCardRow { + Long getProcessId(); + Integer getMissionNumber(); + ProcessStatus getStatus(); + String getTitle(); + LocalDate getStartDate(); + LocalDate getDeadLine(); + Long getDoneCount(); + Long getTotalCount(); + Long getLeaderUserId(); + String getLeaderNickname(); + String getLeaderProfileImageUrl(); + } + + @Query(""" + select + p.id as processId, + p.missionNumber as missionNumber, + p.status as status, + p.title as title, + p.startAt as startDate, + p.endAt as deadLine, + sum(case when ti.isDone = true then 1 else 0 end) as doneCount, + count(ti.id) as totalCount, + u.userId as leaderUserId, + u.nickname as leaderNickname, + u.profileImageUrl as leaderProfileImageUrl + from Process p + left join p.taskItems ti on ti.deletedAt is null + left join p.processUsers pu + on pu.deletedAt is null + and pu.assignmentRole = com.nect.core.entity.team.process.enums.AssignmentRole.ASSIGNEE + left join pu.user u + where p.project.id = :projectId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + and ( + (p.startAt is null and p.endAt is null) + or (p.startAt is null and p.endAt >= :start) + or (p.endAt is null and p.startAt <= :end) + or (p.startAt <= :end and p.endAt >= :start) + ) + group by p.id, p.missionNumber, p.status, p.title, p.startAt, p.endAt, + u.userId, u.nickname, u.profileImageUrl + order by p.missionNumber asc nulls last, p.startAt asc nulls last, p.id asc + """) + List findWeekMissionCardsInRange( + @Param("projectId") Long projectId, + @Param("start") LocalDate start, + @Param("end") LocalDate end + ); + + + + // WEEK_MISSION 중 가장 이른 startAt + @Query(""" + select min(p.startAt) + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + and p.startAt is not null + """) + LocalDate findMinWeekMissionStartAt(@Param("projectId") Long projectId); + + // 전체 프로세스 중 가장 이른 startAt (GENERAL + WEEK_MISSION 포함) + @Query(""" + select min(p.startAt) + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.startAt is not null + """) + LocalDate findMinProcessStartAt(@Param("projectId") Long projectId); + + @Query(""" + select p + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + and p.startAt <= :date + and p.endAt >= :date + """) + Optional findWeekMissionContainingDate( + @Param("projectId") Long projectId, + @Param("date") LocalDate date + ); + } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java index a7a4bb66..18854e9b 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java @@ -1,7 +1,10 @@ package com.nect.core.repository.team.process; import com.nect.core.entity.team.process.ProcessTaskItem; +import com.nect.core.entity.user.enums.RoleField; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -13,4 +16,32 @@ public interface ProcessTaskItemRepository extends JpaRepository findAllByProcessIdAndDeletedAtIsNullOrderBySortOrderAsc(Long processId); List findAllByProcessIdAndDeletedAtIsNullAndIdIn(Long processId, List ids); + + List findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameOrderBySortOrderAsc( + Long processId, RoleField roleField, String customRoleFieldName + ); + + List findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameAndIdIn( + Long processId, RoleField roleField, String customRoleFieldName, List ids + ); + + @Query(""" + select ti + from ProcessTaskItem ti + where ti.process.id = :processId + and ti.deletedAt is null + and ti.roleField = :roleField + and ( + (:customName is null and ti.customRoleFieldName is null) + or (:customName is not null and ti.customRoleFieldName = :customName) + ) + order by + ti.sortOrder asc nulls last, + ti.id asc + """) + List findWeekMissionGroupItemsOrdered( + @Param("processId") Long processId, + @Param("roleField") RoleField roleField, + @Param("customName") String customName + ); } From f926c99a5f3d66d4b8c2a4b387992c77d90cddda Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 01:04:13 +0900 Subject: [PATCH 13/66] =?UTF-8?q?[Refactor]=20=EB=A9=A4=EB=B2=84=ED=98=95?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8B=B4=EB=8B=B9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4,=20=EC=9C=A0=EC=A0=80=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/req/ProcessBasicUpdateReqDto.java | 3 + .../process/dto/req/ProcessCreateReqDto.java | 3 + .../dto/req/ProcessOrderUpdateReqDto.java | 3 + .../dto/res/ProcessBasicUpdateResDto.java | 3 + .../process/dto/res/ProcessCardResDto.java | 3 + .../process/dto/res/ProcessCreateResDto.java | 22 +- .../facade/ProcessAttachmentFacade.java | 62 ++-- .../service/ProcessAttachmentService.java | 51 ++-- .../service/ProcessFeedbackService.java | 16 +- .../team/process/service/ProcessService.java | 269 +++++++++++++++++- .../team/workspace/service/PostService.java | 2 +- .../team/process/ProcessRepository.java | 96 +++++++ 12 files changed, 443 insertions(+), 90 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessBasicUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessBasicUpdateReqDto.java index 98bce74b..3a5f3b8c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessBasicUpdateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessBasicUpdateReqDto.java @@ -29,6 +29,9 @@ public record ProcessBasicUpdateReqDto( @JsonProperty("custom_fields") List customFields, + @JsonProperty("mission_number") + Integer missionNumber, + @JsonProperty("assignee_ids") List assigneeIds, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessCreateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessCreateReqDto.java index 8edbf7b8..29c4f66c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessCreateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessCreateReqDto.java @@ -32,6 +32,9 @@ public record ProcessCreateReqDto( @JsonProperty("custom_field_name") String customFieldName, + @JsonProperty("mission_number") + Integer missionNumber, + @NotNull @JsonProperty("start_date") LocalDate startDate, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java index fd460f44..66b089c8 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java @@ -16,6 +16,9 @@ public record ProcessOrderUpdateReqDto( @JsonProperty("lane_key") String laneKey, + @JsonProperty("mission_number") + Integer missionNumber, + @JsonProperty("start_date") LocalDate startDate, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessBasicUpdateResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessBasicUpdateResDto.java index 66e486bf..7643fadc 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessBasicUpdateResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessBasicUpdateResDto.java @@ -36,6 +36,9 @@ public record ProcessBasicUpdateResDto( @JsonProperty("assignee_ids") List assigneeIds, + @JsonProperty("assignees") + List assignees, + @JsonProperty("mention_user_ids") List mentionUserIds, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java index 4d554a96..7b1ccdc1 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java @@ -37,6 +37,9 @@ public record ProcessCardResDto( @JsonProperty("custom_fields") List customFields, + @JsonProperty("mission_number") + Integer missionNumber, + @JsonProperty("assignee") List assignee ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCreateResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCreateResDto.java index e9fdf3c4..f09aa986 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCreateResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCreateResDto.java @@ -4,6 +4,7 @@ import com.nect.core.entity.user.enums.RoleField; import java.time.LocalDateTime; +import java.util.List; public record ProcessCreateResDto( @JsonProperty("process_id") @@ -13,8 +14,27 @@ public record ProcessCreateResDto( LocalDateTime createdAt, @JsonProperty("writer") - WriterDto writer + WriterDto writer, + + + @JsonProperty("assignees") + List assignees ) { + public record AssigneeDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("name") + String name, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} + + public record WriterDto( @JsonProperty("user_id") Long userId, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java index 81de063d..6daecc76 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java @@ -14,9 +14,14 @@ import com.nect.core.entity.notifications.enums.NotificationScope; import com.nect.core.entity.notifications.enums.NotificationType; import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.team.process.Process; +import com.nect.core.entity.team.process.enums.ProcessType; import com.nect.core.entity.user.User; import com.nect.core.repository.team.ProjectRepository; import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.team.process.ProcessRepository; import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -32,10 +37,8 @@ public class ProcessAttachmentFacade { private final FileService fileService; private final ProcessAttachmentService processAttachmentService; - private final NotificationFacade notificationFacade; - private final ProjectRepository projectRepository; private final ProjectUserRepository projectUserRepository; - private final UserRepository userRepository; + private final ProcessRepository processRepository; /** * 프로세스 모달에서 "파일 업로드" 시: @@ -44,6 +47,23 @@ public class ProcessAttachmentFacade { */ @Transactional public ProcessFileUploadAndAttachResDto uploadAndAttachFile(Long projectId, Long userId, Long processId, MultipartFile file) { + Process process = processRepository.findByIdAndProjectIdAndDeletedAtIsNull(processId, projectId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.PROCESS_NOT_FOUND, "processId=" + processId)); + + // 프로세스 타입이 위크미션이면 업로드 전에 리더 체크 + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + boolean isLeader = projectUserRepository.existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus( + projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE + ); + if (!isLeader) throw new ProcessException(ProcessErrorCode.FORBIDDEN, "WEEK_MISSION은 리더만 업로드/첨부 가능"); + } else { + // 일반 프로세스면 ACTIVE 멤버 체크 + if (!projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus(projectId, userId, ProjectMemberStatus.ACTIVE)) { + throw new ProcessException(ProcessErrorCode.FORBIDDEN, "not active member"); + } + } + + // 파일 업로드 -> 첨부 FileUploadResDto uploaded = fileService.upload(projectId, userId, file); ProcessFileAttachResDto attached = processAttachmentService.attachFile( @@ -53,8 +73,6 @@ public ProcessFileUploadAndAttachResDto uploadAndAttachFile(Long projectId, Long new ProcessFileAttachReqDto(uploaded.fileId()) ); - notifyWorkspaceFileUploaded(projectId, userId, uploaded.fileId(), uploaded.fileName()); - return new ProcessFileUploadAndAttachResDto( attached.fileId(), uploaded.fileName(), @@ -64,39 +82,5 @@ public ProcessFileUploadAndAttachResDto uploadAndAttachFile(Long projectId, Long ); } - private void notifyWorkspaceFileUploaded(Long projectId, Long actorId, Long fileId, String fileName) { - - Project project = projectRepository.findById(projectId) - .orElseThrow(() -> new ProcessException( - ProcessErrorCode.PROJECT_NOT_FOUND, - "projectId = " + projectId - )); - - User actor = userRepository.findById(actorId) - .orElseThrow(() -> new ProcessException( - ProcessErrorCode.USER_NOT_FOUND, - "actorId = " + actorId - )); - - // 프로젝트 멤버 전체 조회 - List receivers = projectUserRepository.findAllUsersByProjectId(projectId).stream() - .filter(u -> u != null && u.getUserId() != null) - .filter(u -> !Objects.equals(u.getUserId(), actorId)) - .toList(); - - if (receivers.isEmpty()) return; - - NotificationCommand command = new NotificationCommand( - NotificationType.WORKSPACE_FILE_UPLOADED, - NotificationClassification.FILE_UPlOAD, - NotificationScope.WORKSPACE_GLOBAL, - fileId, - new Object[]{ actor.getName() }, - new Object[]{ fileName }, - project - ); - - notificationFacade.notify(receivers, command); - } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java index 3582bb0f..fb0d2802 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java @@ -8,11 +8,14 @@ import com.nect.api.domain.team.process.enums.AttachmentErrorCode; import com.nect.api.domain.team.process.exception.AttachmentException; import com.nect.core.entity.team.SharedDocument; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.history.enums.HistoryAction; import com.nect.core.entity.team.history.enums.HistoryTargetType; import com.nect.core.entity.team.process.Link; import com.nect.core.entity.team.process.Process; import com.nect.core.entity.team.process.ProcessSharedDocument; +import com.nect.core.entity.team.process.enums.ProcessType; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.SharedDocumentRepository; import com.nect.core.repository.team.process.LinkRepository; @@ -37,20 +40,9 @@ public class ProcessAttachmentService { private final LinkRepository linkRepository; - // TODO(TEAM EVENT FACADE): Attachment 변경 시(Notification) ActivityFacade로 통합 예정 - private final ProjectHistoryPublisher historyPublisher; // 헬퍼 메서드 - private void assertActiveProjectMember(Long projectId, Long userId) { - if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { - throw new AttachmentException( - AttachmentErrorCode.FORBIDDEN, - "not an active project member. projectId=" + projectId + ", userId=" + userId - ); - } - } - private Process getActiveProcess(Long projectId, Long processId) { return processRepository.findByIdAndProjectIdAndDeletedAtIsNull(processId, projectId) .orElseThrow(() -> new AttachmentException( @@ -83,13 +75,35 @@ private void validateLinkCreateReq(ProcessLinkCreateReqDto req) { } } + private void assertWeekMissionLeader(Long projectId, Long userId) { + boolean ok = projectUserRepository.existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus( + projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE + ); + if (!ok) { + throw new AttachmentException(AttachmentErrorCode.FORBIDDEN, + "WEEK_MISSION은 프로젝트 리더만 수정할 수 있습니다. projectId=" + projectId + ", userId=" + userId); + } + } + + private void assertAttachmentPermission(Long projectId, Long userId, Process process) { + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + assertWeekMissionLeader(projectId, userId); + return; + } + + if (!projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus(projectId, userId, ProjectMemberStatus.ACTIVE)) { + throw new AttachmentException(AttachmentErrorCode.FORBIDDEN, + "not an active project member. projectId=" + projectId + ", userId=" + userId); + } + } + // 프로세스 파일 첨부 서비스 @Transactional public ProcessFileAttachResDto attachFile(Long projectId, Long userId, Long processId, ProcessFileAttachReqDto req) { - assertActiveProjectMember(projectId, userId); validateFileAttachReq(req); Process process = getActiveProcess(projectId, processId); + assertAttachmentPermission(projectId, userId, process); SharedDocument doc = getActiveDocument(projectId, req.fileId()); @@ -108,8 +122,6 @@ public ProcessFileAttachResDto attachFile(Long projectId, Long userId, Long proc processSharedDocumentRepository.save(psd); - // TODO(Notification): 파일 첨부 알림 트리거(수신자=프로젝트 멤버/프로세스 관련자, AFTER_COMMIT 전환 권장) - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); @@ -130,9 +142,8 @@ public ProcessFileAttachResDto attachFile(Long projectId, Long userId, Long proc // 프로세스 파일 첨부해제 서비스 @Transactional public void detachFile(Long projectId, Long userId, Long processId, Long fileId) { - assertActiveProjectMember(projectId, userId); - Process process = getActiveProcess(projectId, processId); + assertAttachmentPermission(projectId, userId, process); ProcessSharedDocument psd = processSharedDocumentRepository .findByProcessIdAndDocumentIdAndDeletedAtIsNull(process.getId(), fileId) @@ -143,7 +154,6 @@ public void detachFile(Long projectId, Long userId, Long processId, Long fileId) psd.softDelete(); - // TODO(Notification): 파일 첨부해제 알림 트리거(AFER_COMMIT 권장) Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("fileId", fileId); @@ -162,10 +172,10 @@ public void detachFile(Long projectId, Long userId, Long processId, Long fileId) // 프로세스 링크 추가 서비스 @Transactional public ProcessLinkCreateResDto createLink(Long projectId, Long userId, Long processId, ProcessLinkCreateReqDto req) { - assertActiveProjectMember(projectId, userId); validateLinkCreateReq(req); Process process = getActiveProcess(projectId, processId); + assertAttachmentPermission(projectId, userId, process); Link link = Link.builder() .process(process) @@ -197,9 +207,8 @@ public ProcessLinkCreateResDto createLink(Long projectId, Long userId, Long proc // 프로세스 링크 삭제 서비스 @Transactional public void deleteLink(Long projectId, Long userId, Long processId, Long linkId) { - assertActiveProjectMember(projectId, userId); - Process process = getActiveProcess(projectId, processId); + assertAttachmentPermission(projectId, userId, process); Link link = linkRepository.findByIdAndProcessIdAndDeletedAtIsNull(linkId, process.getId()) .orElseThrow(() -> new AttachmentException( @@ -210,8 +219,6 @@ public void deleteLink(Long projectId, Long userId, Long processId, Long linkId) String beforeUrl = link.getUrl(); link.softDelete(); - // TODO(Notification): 링크 삭제 알림 트리거(AFER_COMMIT 권장) - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("linkId", linkId); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java index 71c6240b..1afd0b6c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java @@ -191,7 +191,7 @@ public ProcessFeedbackCreateResDto createFeedback(Long projectId, Long userId, L NotificationCommand command = new NotificationCommand( NotificationType.WORKSPACE_TASK_FEEDBACK, NotificationClassification.WORK_STATUS, - NotificationScope.WORKSPACE_GLOBAL, + NotificationScope.WORKSPACE_ONLY, processId, new Object[]{actor.getName()}, new Object[]{preview(saved.getContent(), 60)}, @@ -278,21 +278,9 @@ public ProcessFeedbackDeleteResDto deleteFeedback(Long projectId, Long userId, L ProcessFeedback feedback = getFeedback(processId, feedbackId); - // TODO(HISTORY/NOTI): 삭제 전 스냅샷 확보 권장 - // - beforeContent = feedback.getContent() - // - beforeCreatedBy = feedback.getCreatedByUserId() (필드 확정 후) - // - beforeCreatedAt = feedback.getCreatedAt() - String beforeContent = feedback.getContent(); feedback.softDelete(); - // TODO(Notification): - // - "피드백 삭제" 알림 트리거 지점 - // - 수신자: 프로젝트 멤버 전체 OR 해당 프로세스 관련자 - // - NotificationType 예: PROCESS_FEEDBACK_DELETED - // - meta: 삭제된 피드백의 content 요약/작성자 등(스냅샷 기반) - // - 권장: AFTER_COMMIT 이후 알림 전송 - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); @@ -308,8 +296,6 @@ public ProcessFeedbackDeleteResDto deleteFeedback(Long projectId, Long userId, L meta ); - // TODO(TEAM EVENT FACADE): 추후 ActivityFacade로 통합 - return new ProcessFeedbackDeleteResDto(feedbackId); } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index e900947a..45152297 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -144,6 +144,153 @@ private void ensureLaneOrderRowsExist(Long projectId, ProcessStatus status, Stri } } + // lane 내 기간 겹침 검증 + private void validateNoOverlapInLane(Long projectId, ProcessCreateReqDto req, LocalDate start, LocalDate end) { + List roleFields = Optional.ofNullable(req.roleFields()).orElse(List.of()) + .stream().filter(Objects::nonNull).distinct().toList(); + + // ROLE (CUSTOM 제외) + for (RoleField rf : roleFields) { + if (rf == RoleField.CUSTOM) continue; + + boolean overlap = processRepository.existsOverlappingInRoleLane(projectId, rf, start, end); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "roleField=" + rf + ", start=" + start + ", end=" + end + ); + } + } + + // CUSTOM lane + if (roleFields.contains(RoleField.CUSTOM)) { + String custom = (req.customFieldName() == null) ? "" : req.customFieldName().trim(); + if (custom.isBlank()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "custom_field_name is required when role_fields contains CUSTOM"); + } + + boolean overlap = processRepository.existsOverlappingInCustomLane(projectId, custom, start, end); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "customName=" + custom + ", start=" + start + ", end=" + end + ); + } + } + } + + private void validateNoOverlapForUpdateBasic( + Long projectId, + Long processId, + List roleFields, + List customFields, + LocalDate start, + LocalDate end + ) { + // ROLE (CUSTOM 제외) + for (RoleField rf : Optional.ofNullable(roleFields).orElse(List.of())) { + if (rf == null || rf == RoleField.CUSTOM) continue; + + boolean overlap = processRepository.existsOverlappingInRoleLaneExcludingProcess( + projectId, rf, start, end, processId + ); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "roleField=" + rf + ", start=" + start + ", end=" + end + ); + } + } + + // CUSTOM lanes (이름 기반) + for (String name : Optional.ofNullable(customFields).orElse(List.of())) { + if (name == null) continue; + String trimmed = name.trim(); + if (trimmed.isBlank()) continue; + + boolean overlap = processRepository.existsOverlappingInCustomLaneExcludingProcess( + projectId, trimmed, start, end, processId + ); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "customName=" + trimmed + ", start=" + start + ", end=" + end + ); + } + } + } + + private void validateNoOverlapForUpdateOrderLane( + Long projectId, + Long processId, + String dbLaneKey, + LocalDate start, + LocalDate end + ) { + if (TEAM_LANE_KEY.equals(dbLaneKey)) return; + + if (dbLaneKey.startsWith("ROLE:")) { + RoleField rf = parseRoleField(dbLaneKey); + if (rf == RoleField.CUSTOM) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ROLE lane cannot be CUSTOM. laneKey=" + dbLaneKey); + } + + boolean overlap = processRepository.existsOverlappingInRoleLaneExcludingProcess( + projectId, rf, start, end, processId + ); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "roleField=" + rf + ", start=" + start + ", end=" + end + ); + } + return; + } + + if (dbLaneKey.startsWith("CUSTOM:")) { + String customName = parseCustomName(dbLaneKey); + + boolean overlap = processRepository.existsOverlappingInCustomLaneExcludingProcess( + projectId, customName, start, end, processId + ); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "customName=" + customName + ", start=" + start + ", end=" + end + ); + } + return; + } + + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "invalid lane_key prefix. laneKey=" + dbLaneKey); + } + + + // 선택 미션 N과 startDate 포함 검증 + private void validateStartDateInSelectedMission(Long projectId, Integer missionNumber, LocalDate startDate) { + if (missionNumber == null) return; + + var mp = processRepository.findWeekMissionPeriodByMissionNumber(projectId, missionNumber) + .orElseThrow(() -> new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "week mission not found. missionNumber=" + missionNumber + )); + + LocalDate mStart = mp.getStartAt(); + LocalDate mEnd = mp.getEndAt(); + if (mStart == null || mEnd == null) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "missionNumber=" + missionNumber); + } + + boolean ok = !startDate.isBefore(mStart) && !startDate.isAfter(mEnd); + if (!ok) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "startDate=" + startDate + ", mission=" + missionNumber + ); + } + } + // 알림 관련 헬퍼 메서드 private List validateAndLoadMentionReceivers(Long projectId, Long actorId, List mentionIds) { @@ -174,7 +321,7 @@ private void notifyWorkspaceMention(Project project, User actor, Long targetProc NotificationCommand command = new NotificationCommand( NotificationType.WORKSPACE_MENTIONED, NotificationClassification.WORK_STATUS, - NotificationScope.WORKSPACE_GLOBAL, + NotificationScope.WORKSPACE_ONLY, targetProcessId, new Object[]{ actor.getName() }, new Object[]{ content }, @@ -240,6 +387,10 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre process.updatePeriod(start, end); + validateStartDateInSelectedMission(projectId, req.missionNumber(), start); + + validateNoOverlapInLane(projectId, req, start, end); + int i = 0; // 업무 리스트 저장 for (var t : taskItems) { @@ -405,16 +556,32 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre "writer must be active project member. projectId=" + projectId + ", userId=" + userId )); + List assigneeDtos = + saved.getProcessUsers().stream() + .filter(pu -> pu.getDeletedAt() == null) + .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) + .map(pu -> { + User u = pu.getUser(); + return new ProcessCreateResDto.AssigneeDto( + u.getUserId(), + u.getName(), + u.getNickname(), + u.getProfileImageUrl() + ); + }) + .toList(); + return new ProcessCreateResDto( saved.getId(), saved.getCreatedAt(), - new ProcessCreateResDto.WriterDto( + new ProcessCreateResDto.WriterDto( // 작성자 writer.getUserId(), writer.getName(), writer.getNickname(), writerMember.getRoleField(), writerMember.getCustomRoleFieldName() - ) + ), + assigneeDtos // 담당자 ); } @@ -529,8 +696,7 @@ public ProcessDetailResDto getProcessDetail(Long projectId, Long userId, Long pr .map(pu -> { User u = pu.getUser(); - // TODO : 유저 프로필 넣기 - String userImage = null; + String userImage = u.getProfileImageUrl(); return new AssigneeResDto( u.getUserId(), @@ -763,6 +929,40 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, ); } + if (req.missionNumber() != null && mergedStart != null && mergedEnd != null) { + validateStartDateInSelectedMission(projectId, req.missionNumber(), mergedStart); + } + + if (mergedStart != null && mergedEnd != null && (newStart != null || newEnd != null)) { + + // 검증할 lane 후보 결정 + List laneRoleFields = + (req.roleFields() != null) + ? req.roleFields() + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .map(ProcessField::getRoleField) + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .distinct() + .toList(); + + List laneCustomFields = + (req.customFields() != null) + ? req.customFields() + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) + .map(ProcessField::getCustomFieldName) + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .toList(); + + validateNoOverlapForUpdateBasic(projectId, processId, laneRoleFields, laneCustomFields, mergedStart, mergedEnd); + } + if (newStart != null || newEnd != null) { process.updatePeriod(mergedStart, mergedEnd); } @@ -904,6 +1104,20 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, final LocalDate afterStart = process.getStartAt(); final LocalDate afterEnd = process.getEndAt(); + List assigneeDtos = process.getProcessUsers().stream() + .filter(pu -> pu.getDeletedAt() == null) + .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) + .map(pu -> { + User u = pu.getUser(); + return new AssigneeResDto( + u.getUserId(), + u.getName(), + u.getNickname(), + u.getProfileImageUrl() + ); + }) + .toList(); + final List afterMentionIds = (mentionIdsForRes == null) ? null // 요청이 null이면 멘션 변경 안함 : mentionIdsForRes.stream().filter(Objects::nonNull).distinct().sorted().toList(); @@ -1028,6 +1242,7 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, afterRoleFields, afterCustomFields, afterAssigneeIds, + assigneeDtos, (afterMentionIds == null) ? beforeMentionIds : afterMentionIds, process.getUpdatedAt(), new ProcessBasicUpdateResDto.WriterDto( @@ -1082,11 +1297,11 @@ public void deleteProcess(Long projectId, Long userId, Long processId) { ); } - - - private LocalDate normalizeWeekStart(LocalDate startDate) { - LocalDate base = (startDate == null) ? LocalDate.now() : startDate; - return base.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + private LocalDate normalizeWeekStart(LocalDate date) { + if (date == null) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "startDate must not be null"); + } + return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); } private ProcessCardResDto toProcessCardResDTO(Process p) { @@ -1120,12 +1335,13 @@ private ProcessCardResDto toProcessCardResDTO(Process p) { .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) .map(pu -> { User u = pu.getUser(); - String userImage = null; // TODO: 프로필 컬럼/연동되면 세팅 + String userImage = u.getProfileImageUrl(); String nickname = u.getNickname(); return new AssigneeResDto(u.getUserId(), u.getName(), nickname, userImage); }) .toList(); + Integer missionNumber = resolveMissionNumberByStartDate(p.getProject().getId(), p.getStartAt()); return new ProcessCardResDto( p.getId(), @@ -1138,6 +1354,7 @@ private ProcessCardResDto toProcessCardResDTO(Process p) { leftDay, roleFields, customFields, + missionNumber, assignees ); } @@ -1226,6 +1443,24 @@ private ProcessWeekResDto buildWeekDto(LocalDate weekStart, List 12) weeks = 12; + LocalDate fallback = processRepository.findMinProcessStartAt(projectId); + if (fallback == null) fallback = LocalDate.now(); - LocalDate rangeStart = normalizeWeekStart(startDate); + LocalDate rangeStart = resolveWeekStart(startDate, fallback); LocalDate rangeEnd = rangeStart.plusDays((long) weeks * 7 - 1); List processes = processRepository.findAllInRangeOrdered(projectId, rangeStart, rangeEnd); @@ -1557,6 +1794,14 @@ public ProcessOrderUpdateResDto updateProcessOrder(Long projectId, Long userId, ); } + if (req.missionNumber() != null && mergedStart != null) { + validateStartDateInSelectedMission(projectId, req.missionNumber(), mergedStart); + } + + if (mergedStart != null && mergedEnd != null) { + validateNoOverlapForUpdateOrderLane(projectId, processId, dbLaneKey, mergedStart, mergedEnd); + } + process.updatePeriod(mergedStart, mergedEnd); } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java index 44ede7f6..5cbcdbef 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java @@ -83,7 +83,7 @@ private void notifyBoardMention(Project project, User actor, Long targetBoardId, NotificationCommand command = new NotificationCommand( NotificationType.WORKSPACE_MENTIONED, NotificationClassification.BOARD, - NotificationScope.WORKSPACE_GLOBAL, + NotificationScope.WORKSPACE_ONLY, targetBoardId, new Object[]{ actor.getName() }, new Object[]{ content }, diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index 2d0e63ad..0419805e 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -485,5 +485,101 @@ Optional findWeekMissionContainingDate( @Param("date") LocalDate date ); + @Query(""" + select (count(p) > 0) + from Process p + join p.processFields pf + where p.project.id = :projectId + and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) + and pf.deletedAt is null + and pf.roleField = :roleField + and p.startAt <= :end + and p.endAt >= :start + """) + boolean existsOverlappingInRoleLane( + @Param("projectId") Long projectId, + @Param("roleField") RoleField roleField, + @Param("start") LocalDate start, + @Param("end") LocalDate end + ); + + @Query(""" + select (count(p) > 0) + from Process p + join p.processFields pf + where p.project.id = :projectId + and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) + and pf.deletedAt is null + and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM + and trim(pf.customFieldName) = :customName + and p.startAt <= :end + and p.endAt >= :start + """) + boolean existsOverlappingInCustomLane( + @Param("projectId") Long projectId, + @Param("customName") String customName, + @Param("start") LocalDate start, + @Param("end") LocalDate end + ); + + interface MissionPeriodRow { + LocalDate getStartAt(); + LocalDate getEndAt(); + } + + @Query(""" + select p.startAt as startAt, p.endAt as endAt + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + and p.missionNumber = :missionNumber + """) + Optional findWeekMissionPeriodByMissionNumber( + @Param("projectId") Long projectId, + @Param("missionNumber") Integer missionNumber + ); + + @Query(""" + select case when count(p) > 0 then true else false end + from Process p + join p.processFields pf + where p.project.id = :projectId + and p.deletedAt is null + and p.id <> :excludeProcessId + and pf.deletedAt is null + and pf.roleField = :roleField + and not (p.endAt < :start or p.startAt > :end) + """) + boolean existsOverlappingInRoleLaneExcludingProcess( + @Param("projectId") Long projectId, + @Param("roleField") RoleField roleField, + @Param("start") LocalDate start, + @Param("end") LocalDate end, + @Param("excludeProcessId") Long excludeProcessId + ); + + @Query(""" + select case when count(p) > 0 then true else false end + from Process p + join p.processFields pf + where p.project.id = :projectId + and p.deletedAt is null + and p.id <> :excludeProcessId + and pf.deletedAt is null + and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM + and pf.customFieldName = :customName + and not (p.endAt < :start or p.startAt > :end) + """) + boolean existsOverlappingInCustomLaneExcludingProcess( + @Param("projectId") Long projectId, + @Param("customName") String customName, + @Param("start") LocalDate start, + @Param("end") LocalDate end, + @Param("excludeProcessId") Long excludeProcessId + ); + } From 3d9c77acab3457437e9f01ba33e3b7c3d949ab6c Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 01:08:34 +0900 Subject: [PATCH 14/66] =?UTF-8?q?[Test]=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=B0=8F=20=EC=9C=84?= =?UTF-8?q?=ED=81=AC=EB=AF=B8=EC=85=98=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ProcessControllerTest.java | 69 +++- .../ProcessTaskItemControllerTest.java | 9 +- .../controller/WeekMissionControllerTest.java | 379 ++++++++++++++++++ 3 files changed, 436 insertions(+), 21 deletions(-) create mode 100644 nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java index 49d0c576..a890e087 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java @@ -23,7 +23,6 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.FieldDescriptor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; @@ -128,6 +127,9 @@ void createProcess() throws Exception { "작성자닉네임", RoleField.BACKEND, null + ), + List.of( + new ProcessCreateResDto.AssigneeDto(2L, "담당자이름", "담당자닉", "https://img.com/2.png") ) ); @@ -136,22 +138,18 @@ void createProcess() throws Exception { "로그인/회원가입 API 초안 + 문서화", ProcessStatus.IN_PROGRESS, - // assignee_ids List.of(2L), - List.of(RoleField.BACKEND, RoleField.FRONTEND), null, + 1, LocalDate.of(2026, 1, 19), LocalDate.of(2026, 1, 25), List.of(), - - // file_ids List.of(), - // links (변경됨) List.of( new ProcessCreateReqDto.ProcessLinkItemReqDto("백엔드 Repo", "https://github.com/nect/nect-backend"), new ProcessCreateReqDto.ProcessLinkItemReqDto("피그마", "https://figma.com/file/xxxxx") @@ -195,16 +193,15 @@ void createProcess() throws Exception { fieldWithPath("assignee_ids").type(ARRAY).description("담당자 ID 목록"), fieldWithPath("role_fields").type(ARRAY).description("분야 목록 (예: BACKEND, FRONTEND 등)"), fieldWithPath("custom_field_name").optional().type(STRING).description("커스텀 분야명(null 가능)"), + fieldWithPath("mission_number").optional().type(NUMBER).description("미션 번호(위크미션이면 1..n, 기본형이면 null 가능)"), - // start/deadline은 네 DTO에서 @NotNull이라 optional 빼는게 맞음 fieldWithPath("start_date").type(STRING).description("시작일(yyyy-MM-dd)"), fieldWithPath("dead_line").type(STRING).description("마감일(yyyy-MM-dd)"), fieldWithPath("mention_user_ids").type(ARRAY).description("멘션된 유저 ID 목록"), fieldWithPath("file_ids").type(ARRAY).description("첨부 파일 ID 목록"), - // links 변경 - fieldWithPath("links").type(ARRAY).description("첨부 링크 목록").optional(), + fieldWithPath("links").optional().type(ARRAY).description("첨부 링크 목록"), fieldWithPath("links[].title").type(STRING).description("링크 제목"), fieldWithPath("links[].url").type(STRING).description("링크 URL"), @@ -221,7 +218,6 @@ void createProcess() throws Exception { fieldWithPath("body").description("응답 바디"), fieldWithPath("body.process_id").type(NUMBER).description("생성된 프로세스 ID"), - fieldWithPath("body.created_at").type(STRING).description("생성일시(ISO-8601)"), fieldWithPath("body.writer").type(OBJECT).description("작성자 정보"), @@ -229,9 +225,14 @@ void createProcess() throws Exception { fieldWithPath("body.writer.name").type(STRING).description("작성자 이름"), fieldWithPath("body.writer.nickname").type(STRING).description("작성자 닉네임"), fieldWithPath("body.writer.role_field").type(STRING).description("작성자 역할 분야(RoleField)"), - fieldWithPath("body.writer.custom_field_name").optional().type(STRING).description("작성자 커스텀 분야명(null 가능)") - ) + fieldWithPath("body.writer.custom_field_name").optional().type(STRING).description("작성자 커스텀 분야명(null 가능)"), + fieldWithPath("body.assignees").type(ARRAY).description("담당자 정보 목록"), + fieldWithPath("body.assignees[].user_id").type(NUMBER).description("담당자 유저 ID"), + fieldWithPath("body.assignees[].name").type(STRING).description("담당자 이름"), + fieldWithPath("body.assignees[].nickname").type(STRING).description("담당자 닉네임"), + fieldWithPath("body.assignees[].profile_image_url").type(STRING).description("담당자 프로필 이미지 URL") + ) .build() ) )); @@ -428,10 +429,17 @@ void updateProcessBasic() throws Exception { List.of(RoleField.FRONTEND, RoleField.BACKEND, RoleField.CUSTOM), List.of("AI"), + 1, + List.of(1L, 2L), List.of(3L, 4L) ); + List assignees = List.of( + new AssigneeResDto(1L, "유저1", "유저1닉", "https://img.com/1.png"), + new AssigneeResDto(2L, "유저2", "유저2닉", "https://img.com/2.png") + ); + ProcessBasicUpdateResDto response = new ProcessBasicUpdateResDto( processId, "수정된 제목", @@ -444,6 +452,8 @@ void updateProcessBasic() throws Exception { List.of("AI"), List.of(1L, 2L), + assignees, + List.of(3L, 4L), LocalDateTime.of(2026, 1, 24, 0, 0, 0), @@ -495,7 +505,8 @@ void updateProcessBasic() throws Exception { fieldWithPath("mention_user_ids").optional().type(ARRAY).description("멘션 유저 ID 목록 (미포함 시 변경 없음, []면 비우기)"), fieldWithPath("role_fields").optional().type(ARRAY).description("역할 분야 목록(RoleField)"), - fieldWithPath("custom_fields").optional().type(ARRAY).description("커스텀 분야명 목록(CUSTOM 선택 시)") + fieldWithPath("custom_fields").optional().type(ARRAY).description("커스텀 분야명 목록(CUSTOM 선택 시)"), + fieldWithPath("mission_number").optional().type(NUMBER).description("미션 번호(미포함 시 변경 없음, null 가능)") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), @@ -515,6 +526,12 @@ void updateProcessBasic() throws Exception { fieldWithPath("body.custom_fields").type(ARRAY).description("커스텀 분야명 목록(CUSTOM 선택 시)"), fieldWithPath("body.assignee_ids").type(ARRAY).description("담당자 ID 목록"), + fieldWithPath("body.assignees").type(ARRAY).description("담당자 정보 목록"), + fieldWithPath("body.assignees[].user_id").type(NUMBER).description("담당자 유저 ID"), + fieldWithPath("body.assignees[].user_name").type(STRING).description("담당자 이름"), + fieldWithPath("body.assignees[].nickname").type(STRING).description("담당자 닉네임"), + fieldWithPath("body.assignees[].user_image").type(STRING).description("담당자 이미지 URL"), + fieldWithPath("body.mention_user_ids").type(ARRAY).description("멘션 유저 ID 목록"), fieldWithPath("body.updated_at").type(STRING).description("수정일시(ISO-8601)"), @@ -657,14 +674,15 @@ void getPartProcesses() throws Exception { 10L, ProcessStatus.IN_PROGRESS, "백엔드 API 초안 작성", - 1, // complete_check_list - 3, // whole_check_list + 1, + 3, LocalDate.of(2026, 2, 1), LocalDate.of(2026, 2, 10), - 5, // left_day + 5, List.of(RoleField.BACKEND), - List.of("AI"), // custom_fields - List.of(a1, a2) + List.of("AI"), + 1, + List.of(a1, a2) // assignee ); ProcessCardResDto p12 = new ProcessCardResDto( @@ -678,9 +696,12 @@ void getPartProcesses() throws Exception { 3, List.of(RoleField.BACKEND, RoleField.FRONTEND), List.of("DevOps"), + null, List.of(a2) ); + + ProcessStatusGroupResDto inProgressGroup = new ProcessStatusGroupResDto( ProcessStatus.IN_PROGRESS, 2, @@ -699,6 +720,7 @@ void getPartProcesses() throws Exception { null, List.of(RoleField.BACKEND), List.of(), + null, List.of(a1) ); @@ -720,6 +742,7 @@ void getPartProcesses() throws Exception { 0, List.of(RoleField.BACKEND), List.of("Auth"), + 1, List.of(a1, a2) ); @@ -741,6 +764,7 @@ void getPartProcesses() throws Exception { null, List.of(RoleField.BACKEND), List.of("TechDebt"), + 1, List.of(a2) ); @@ -808,6 +832,10 @@ void getPartProcesses() throws Exception { fieldWithPath("body.groups[].processes[].role_fields").type(ARRAY).description("RoleField 목록"), fieldWithPath("body.groups[].processes[].custom_fields").type(ARRAY).description("커스텀 필드명 목록"), + fieldWithPath("body.groups[].processes[].mission_number").optional().type(VARIES).description("위크미션 번호(미션 프로세스면 1..n, 일반 프로세스면 null)"), + + fieldWithPath("body.groups[].processes[].assignee").type(ARRAY).description("담당자 목록"), + fieldWithPath("body.groups[].processes[].assignee").type(ARRAY).description("담당자 목록"), fieldWithPath("body.groups[].processes[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), fieldWithPath("body.groups[].processes[].assignee[].user_name").type(STRING).description("담당자 이름"), @@ -833,6 +861,7 @@ void updateProcessOrder() throws Exception { ProcessStatus.IN_PROGRESS, List.of(10L, 2L, 12L), "ROLE:BACKEND", + 1, LocalDate.of(2026, 2, 1), LocalDate.of(2026, 2, 10) ); @@ -876,7 +905,8 @@ void updateProcessOrder() throws Exception { fieldWithPath("ordered_process_ids").optional().type(ARRAY).description("정렬 순서대로 나열한 프로세스 ID 목록"), fieldWithPath("lane_key").type(STRING).description("레인 키(TEAM, ROLE:XXX, CUSTOM:이름)"), fieldWithPath("start_date").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), - fieldWithPath("dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)") + fieldWithPath("dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)"), + fieldWithPath("mission_number").optional().type(NUMBER).description("미션 번호(위크미션이면 사용, 기본형이면 null 가능)") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), @@ -888,6 +918,7 @@ void updateProcessOrder() throws Exception { fieldWithPath("body.process_id").type(NUMBER).description("프로세스 ID"), fieldWithPath("body.status").type(STRING).description("프로세스 상태"), fieldWithPath("body.status_order").type(NUMBER).description("해당 status 내 정렬 순서"), + fieldWithPath("body.groups[].processes[].mission_number").optional().type(NUMBER).description("미션 번호(null 가능)"), fieldWithPath("body.start_at").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), fieldWithPath("body.dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)") ) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java index cdd9407f..690084b7 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java @@ -12,6 +12,7 @@ import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.user.enums.RoleField; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -301,7 +302,9 @@ void reorderTaskItems() throws Exception { long userId = 1L; ProcessTaskItemReorderReqDto request = new ProcessTaskItemReorderReqDto( - List.of(100L, 101L, 102L) + List.of(100L, 101L, 102L), + RoleField.BACKEND, + null ); ProcessTaskItemResDto i0 = new ProcessTaskItemResDto(100L, "A", false, 0, null); @@ -338,7 +341,9 @@ void reorderTaskItems() throws Exception { headerWithName(AUTH_HEADER).description("Bearer Access Token") ) .requestFields( - fieldWithPath("ordered_task_item_ids").type(ARRAY).description("정렬된 업무 항목 ID 목록(전체 포함)") + fieldWithPath("ordered_task_item_ids").type(ARRAY).description("정렬된 업무 항목 ID 목록(전체 포함)"), + fieldWithPath("role_field").optional().type(STRING).description("레인 역할(RoleField). ROLE 레인일 때 사용"), + fieldWithPath("custom_role_field_name").optional().type(STRING).description("커스텀 레인 이름. CUSTOM 레인일 때 사용") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java new file mode 100644 index 00000000..465c3133 --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java @@ -0,0 +1,379 @@ +package com.nect.api.domain.team.process.controller; + +import com.epages.restdocs.apispec.ResourceDocumentation; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; +import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; +import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; +import com.nect.api.domain.team.process.service.WeekMissionService; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.team.process.enums.ProcessStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Constructor; +import java.lang.reflect.RecordComponent; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class WeekMissionControllerTest { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private WeekMissionService weekMissionService; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_MEMBER")) + .build() + ); + } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_MEMBER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return SecurityMockMvcRequestPostProcessors.authentication(auth); + } + + private T newRecord(Class recordType) { + try { + if (!recordType.isRecord()) return null; + + RecordComponent[] components = recordType.getRecordComponents(); + Class[] paramTypes = new Class[components.length]; + Object[] args = new Object[components.length]; + + for (int i = 0; i < components.length; i++) { + Class t = components[i].getType(); + paramTypes[i] = t; + args[i] = defaultValue(t); + } + + Constructor ctor = recordType.getDeclaredConstructor(paramTypes); + ctor.setAccessible(true); + return ctor.newInstance(args); + } catch (Exception e) { + throw new IllegalStateException("Failed to instantiate record: " + recordType.getName(), e); + } + } + + private Object defaultValue(Class t) { + if (t == String.class) return "sample"; + if (t == Long.class || t == long.class) return 1L; + if (t == Integer.class || t == int.class) return 1; + if (t == Boolean.class || t == boolean.class) return false; + if (t == LocalDate.class) return LocalDate.of(2026, 1, 19); + if (t == LocalDateTime.class) return LocalDateTime.of(2026, 1, 19, 0, 0, 0); + + if (List.class.isAssignableFrom(t)) return List.of(); + + if (t.isEnum()) { + Object[] constants = t.getEnumConstants(); + return (constants != null && constants.length > 0) ? constants[0] : null; + } + + if (t.isRecord()) { + @SuppressWarnings("unchecked") + Class rt = (Class) t; + return newRecord(rt); + } + + return null; + } + + @Test + @DisplayName("주차별 위크미션 조회") + void getWeekMissions() throws Exception { + long projectId = 1L; + long userId = 1L; + + LocalDate startDate = LocalDate.of(2026, 1, 19); + int weeks = 2; + + WeekMissionWeekResDto response = newRecord(WeekMissionWeekResDto.class); + + given(weekMissionService.getWeekMissions(eq(projectId), eq(userId), eq(startDate), eq(weeks))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/week-missions/week", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .param("start_date", startDate.toString()) + .param("weeks", String.valueOf(weeks)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("week-mission-week", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("주차별 위크미션 조회") + .description("start_date 기준으로 weeks 만큼 위크미션 주차 목록을 조회합니다. start_date 미입력 시 서버 정책에 따른 기본 시작일로 동작합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID") + ) + .queryParameters( + parameterWithName("start_date").optional().description("시작일(yyyy-MM-dd)"), + parameterWithName("weeks").optional().description("조회할 주차 수(기본 1)") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("주차별 위크미션 조회 결과") + ) + .build() + ) + )); + + verify(weekMissionService).getWeekMissions(eq(projectId), eq(userId), eq(startDate), eq(weeks)); + } + + @Test + @DisplayName("위크미션 상세 조회(체크리스트 포함)") + void getWeekMissionDetail() throws Exception { + long projectId = 1L; + long processId = 10L; + long userId = 1L; + + WeekMissionDetailResDto response = newRecord(WeekMissionDetailResDto.class); + + given(weekMissionService.getDetail(eq(projectId), eq(userId), eq(processId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/week-missions/{processId}", projectId, processId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("week-mission-detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("위크미션 상세 조회") + .description("위크미션(프로세스) 상세를 조회합니다. (체크리스트 포함)") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("processId").description("위크미션 프로세스 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("위크미션 상세 결과") + ) + .build() + ) + )); + + verify(weekMissionService).getDetail(eq(projectId), eq(userId), eq(processId)); + } + + @Test + @DisplayName("위크미션 상태 변경") + void updateWeekMissionStatus() throws Exception { + long projectId = 1L; + long processId = 10L; + long userId = 1L; + + WeekMissionStatusUpdateReqDto request = + new WeekMissionStatusUpdateReqDto(ProcessStatus.PLANNING); + + willDoNothing().given(weekMissionService) + .updateWeekMissionStatus(eq(projectId), eq(userId), eq(processId), any(WeekMissionStatusUpdateReqDto.class)); + + mockMvc.perform(patch("/api/v1/projects/{projectId}/week-missions/{processId}/status", projectId, processId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("week-mission-status-update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("위크미션 상태 변경") + .description("위크미션 프로세스의 상태를 변경합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("processId").description("위크미션 프로세스 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("status").type(STRING) + .description("변경할 상태(PLANNING/IN_PROGRESS/DONE/BACKLOG)") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명") + ) + .build() + ) + )); + + verify(weekMissionService).updateWeekMissionStatus(eq(projectId), eq(userId), eq(processId), any(WeekMissionStatusUpdateReqDto.class)); + } + + @Test + @DisplayName("위크미션 TASK 내 항목 내용 수정") + void updateWeekMissionTaskItem() throws Exception { + long projectId = 1L; + long processId = 10L; + long taskItemId = 100L; + long userId = 1L; + + WeekMissionTaskItemUpdateReqDto request = newRecord(WeekMissionTaskItemUpdateReqDto.class); + + ProcessTaskItemResDto response = new ProcessTaskItemResDto( + taskItemId, + "수정된 세부 작업", + true, + 1, + LocalDate.of(2026, 1, 25) + ); + + given(weekMissionService.updateWeekMissionTaskItem(eq(projectId), eq(userId), eq(processId), eq(taskItemId), any(WeekMissionTaskItemUpdateReqDto.class))) + .willReturn(response); + + mockMvc.perform(patch("/api/v1/projects/{projectId}/week-missions/{processId}/task-items/{taskItemId}", projectId, processId, taskItemId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("week-mission-taskitem-update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("위크미션 TASK 항목 수정") + .description("위크미션 프로세스 내 TaskItem의 내용을 수정합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("processId").description("위크미션 프로세스 ID"), + ResourceDocumentation.parameterWithName("taskItemId").description("업무 항목(TaskItem) ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("content").type(STRING).description("업무 항목 내용"), + fieldWithPath("is_done").type(BOOLEAN).description("완료 여부"), + fieldWithPath("role_field").optional().type(STRING).description("역할 분야(RoleField)"), + fieldWithPath("custom_role_field_name").optional().type(STRING).description("커스텀 역할 분야명(CUSTOM일 때)") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.task_item_id").type(NUMBER).description("업무 항목 ID"), + fieldWithPath("body.content").type(STRING).description("업무 항목 내용"), + fieldWithPath("body.is_done").type(BOOLEAN).description("완료 여부"), + fieldWithPath("body.sort_order").type(NUMBER).description("정렬 순서"), + fieldWithPath("body.done_at").optional().type(STRING).description("완료일(yyyy-MM-dd, null 가능)") + ) + .build() + ) + )); + + verify(weekMissionService).updateWeekMissionTaskItem(eq(projectId), eq(userId), eq(processId), eq(taskItemId), any(WeekMissionTaskItemUpdateReqDto.class)); + } +} From 4c869f239788391fea305257a6ee0a1fc0d4084f Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 03:02:40 +0900 Subject: [PATCH 15/66] =?UTF-8?q?[Refactor]=20=EC=97=AD=ED=95=A0=20?= =?UTF-8?q?=EC=98=81=EC=96=B4=20label=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/entity/user/enums/RoleField.java | 87 ++++++++++--------- .../ProjectTeamRoleRepository.java | 0 2 files changed, 47 insertions(+), 40 deletions(-) rename nect-core/src/main/java/com/nect/core/repository/{analysis => team}/ProjectTeamRoleRepository.java (100%) diff --git a/nect-core/src/main/java/com/nect/core/entity/user/enums/RoleField.java b/nect-core/src/main/java/com/nect/core/entity/user/enums/RoleField.java index 9bad7361..65c9deb0 100644 --- a/nect-core/src/main/java/com/nect/core/entity/user/enums/RoleField.java +++ b/nect-core/src/main/java/com/nect/core/entity/user/enums/RoleField.java @@ -2,59 +2,62 @@ public enum RoleField { // 디자이너 - UI_UX("UI/UX", Role.DESIGNER), - ILLUSTRATION_GRAPHIC("일러스트/그래픽", Role.DESIGNER), - WEBTOON_EMOTICON("웹툰/이모티콘", Role.DESIGNER), - PHOTO_VIDEO("사진/영상", Role.DESIGNER), - SOUND("사운드", Role.DESIGNER), - THREE_D_MOTION("3D/모션", Role.DESIGNER), - PRODUCT("제품", Role.DESIGNER), - SPACE("공간", Role.DESIGNER), - PUBLISHING("출판", Role.DESIGNER), + UI_UX("UI/UX", "UI/UX", Role.DESIGNER), + ILLUSTRATION_GRAPHIC("일러스트/그래픽", "Illustration/Graphic", Role.DESIGNER), + WEBTOON_EMOTICON("웹툰/이모티콘", "Webtoon/Emoticon", Role.DESIGNER), + PHOTO_VIDEO("사진/영상", "Photo/Video", Role.DESIGNER), + SOUND("사운드", "Sound", Role.DESIGNER), + THREE_D_MOTION("3D/모션", "3D/Motion", Role.DESIGNER), + PRODUCT("제품", "Product", Role.DESIGNER), + SPACE("공간", "Space", Role.DESIGNER), + PUBLISHING("출판", "Publishing", Role.DESIGNER), // 개발자 - FRONTEND("프론트엔드", Role.DEVELOPER), - BACKEND("백엔드", Role.DEVELOPER), - IOS_ANDROID("IOS/안드로이드", Role.DEVELOPER), - DATA_ENGINEER("데이터 엔지니어", Role.DEVELOPER), - AI_MACHINE_LEARNING("AI/머신러닝", Role.DEVELOPER), - FULLSTACK("풀스택", Role.DEVELOPER), - GAME("게임", Role.DEVELOPER), - HARDWARE("하드웨어", Role.DEVELOPER), - SECURITY_NETWORK("보안/네트워크", Role.DEVELOPER), + FRONTEND("프론트엔드", "Frontend", Role.DEVELOPER), + BACKEND("백엔드", "Backend", Role.DEVELOPER), + IOS_ANDROID("IOS/안드로이드", "iOS/Android", Role.DEVELOPER), + DATA_ENGINEER("데이터 엔지니어", "Data Engineer", Role.DEVELOPER), + AI_MACHINE_LEARNING("AI/머신러닝", "AI/Machine Learning", Role.DEVELOPER), + FULLSTACK("풀스택", "Full-stack", Role.DEVELOPER), + GAME("게임", "Game", Role.DEVELOPER), + HARDWARE("하드웨어", "Hardware", Role.DEVELOPER), + SECURITY_NETWORK("보안/네트워크", "Security/Network", Role.DEVELOPER), // 기획자 - SERVICE("서비스", Role.PLANNER), - UX("UX", Role.PLANNER), - APP_WEB("앱/웹", Role.PLANNER), - BUSINESS("비즈니스", Role.PLANNER), - PERFORMANCE_EVENT("공연/행사", Role.PLANNER), + SERVICE("서비스", "Service", Role.PLANNER), + UX("UX", "UX", Role.PLANNER), + APP_WEB("앱/웹", "App/Web", Role.PLANNER), + BUSINESS("비즈니스", "Business", Role.PLANNER), + PERFORMANCE_EVENT("공연/행사", "Performance/Event", Role.PLANNER), + // 마케터 - CONTENT_CREATION("콘텐츠 제작", Role.MARKETER), - PERFORMANCE("퍼포먼스", Role.MARKETER), - CRM("CRM", Role.MARKETER), - BRAND_MARKETING("브랜드 마케팅", Role.MARKETER), - AD_VIRAL("광고/바이럴", Role.MARKETER), - LIVE_COMMERCE("라이브커머스", Role.MARKETER), - DATA_ANALYSIS("데이터 분석", Role.MARKETER), - MARKETING_OTHER("기타", Role.MARKETER), - OPERATIONS_CS("운영/CS", Role.MARKETER), - SALES_PARTNERSHIP("영업/제휴", Role.MARKETER), - VIDEO_MUSIC_DIRECTING("영상/음악 감독", Role.MARKETER), - TRANSLATION_INTERPRETATION("번역/통역", Role.MARKETER), - MANUSCRIPT_CONSULTING("원고 컨설턴트", Role.MARKETER), - ACCOUNTING_LAW_HR("세무/법무/노무", Role.MARKETER), - STARTUP_CONSULTING("창업 컨설팅", Role.MARKETER), + CONTENT_CREATION("콘텐츠 제작", "Content Creation", Role.MARKETER), + PERFORMANCE("퍼포먼스", "Performance", Role.MARKETER), + CRM("CRM", "CRM", Role.MARKETER), + BRAND_MARKETING("브랜드 마케팅", "Brand Marketing", Role.MARKETER), + AD_VIRAL("광고/바이럴", "Ads/Viral", Role.MARKETER), + LIVE_COMMERCE("라이브커머스", "Live Commerce", Role.MARKETER), + DATA_ANALYSIS("데이터 분석", "Data Analysis", Role.MARKETER), + MARKETING_OTHER("기타", "Other", Role.MARKETER), + OPERATIONS_CS("운영/CS", "Operations/CS", Role.MARKETER), + SALES_PARTNERSHIP("영업/제휴", "Sales/Partnership", Role.MARKETER), + VIDEO_MUSIC_DIRECTING("영상/음악 감독", "Video/Music Directing", Role.MARKETER), + TRANSLATION_INTERPRETATION("번역/통역", "Translation/Interpretation", Role.MARKETER), + MANUSCRIPT_CONSULTING("원고 컨설턴트", "Manuscript Consulting", Role.MARKETER), + ACCOUNTING_LAW_HR("세무/법무/노무", "Accounting/Law/HR", Role.MARKETER), + STARTUP_CONSULTING("창업 컨설팅", "Startup Consulting", Role.MARKETER), // 직접입력 (모든 Role에서 가능) - CUSTOM("직접입력", null); + CUSTOM("직접입력", "Custom",null); private final String description; + private final String labelEn; private final Role role; - RoleField(String description, Role role) { + RoleField(String description, String labelEn, Role role) { this.description = description; + this.labelEn = labelEn; this.role = role; } @@ -62,6 +65,10 @@ public String getDescription() { return description; } + public String getLabelEn() { + return labelEn; + } + public Role getRole() { return role; } diff --git a/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java similarity index 100% rename from nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java rename to nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java From 3d91cf2a322e272d8ba6760f7f4bf77411c3a606 Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 03:04:28 +0900 Subject: [PATCH 16/66] =?UTF-8?q?[Feat]=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=ED=8A=B8=20=EC=A1=B0=ED=9A=8C,=20?= =?UTF-8?q?=ED=8C=80=EC=9B=90=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C,?= =?UTF-8?q?=20=EB=AF=B8=EC=85=98=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WeekMissionController.java | 12 + .../dto/res/WeekMissionDropdownResDto.java | 29 ++ .../team/process/service/ProcessService.java | 263 ++++++++++++------ .../process/service/WeekMissionService.java | 33 +++ .../controller/ProjectPartsController.java | 42 +++ .../team/project/dto/ProjectPartsResDto.java | 32 +++ .../team/project/dto/ProjectUsersResDto.java | 42 +++ .../project/enums/code/ProjectErrorCode.java | 7 + .../project/exception/ProjectException.java | 4 + .../team/project/service/ProjectService.java | 1 + .../service/ProjectTeamQueryService.java | 116 ++++++++ .../core/entity/team/ProjectTeamRole.java | 29 +- .../team/ProjectTeamRoleRepository.java | 36 ++- .../team/process/ProcessRepository.java | 22 ++ 14 files changed, 581 insertions(+), 87 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDropdownResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartsResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectUsersResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java index 825b4ac2..a78088ee 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java @@ -4,6 +4,7 @@ import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDropdownResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; import com.nect.api.domain.team.process.service.WeekMissionService; import com.nect.api.global.response.ApiResponse; @@ -73,4 +74,15 @@ public ApiResponse updateWeekMissionTaskItem( ); } + // 멤버형 모달 미션 주차 선택 드롭다운 조회 + @GetMapping("/missions") + public ApiResponse readMissionDropdown( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok( + weekMissionService.getMissionDropdown(projectId, userDetails.getUserId()) + ); + } + } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDropdownResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDropdownResDto.java new file mode 100644 index 00000000..abc51548 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDropdownResDto.java @@ -0,0 +1,29 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.LocalDate; +import java.util.List; + +public record WeekMissionDropdownResDto( + @JsonProperty("missions") + List missions +) { + public WeekMissionDropdownResDto { + missions = (missions == null) ? List.of() : missions; + } + + public record MissionDto( + @JsonProperty("mission_number") + Integer missionNumber, + + @JsonProperty("start_date") + LocalDate startDate, + + @JsonProperty("end_date") + LocalDate endDate, + + @JsonProperty("is_current") + Boolean isCurrent + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index 45152297..b1be82b9 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -24,6 +24,7 @@ import com.nect.core.entity.user.User; import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.SharedDocumentRepository; import com.nect.core.repository.team.process.ProcessLaneOrderRepository; @@ -53,6 +54,7 @@ public class ProcessService { private final ProcessMentionRepository processMentionRepository; private final UserRepository userRepository; private final ProcessLaneOrderRepository processLaneOrderRepository; + private final ProjectTeamRoleRepository projectTeamRoleRepository; private final ProcessLaneOrderService processLaneOrderService; @@ -291,6 +293,41 @@ private void validateStartDateInSelectedMission(Long projectId, Integer missionN } } + private void validateProjectTeamRolesOrThrow(Long projectId, List roleFields, String customFieldName) { + + // roleFields(일반 역할) 검증 + for (RoleField rf : Optional.ofNullable(roleFields).orElse(List.of())) { + if (rf == null) continue; + + if (rf == RoleField.CUSTOM) continue; // CUSTOM은 customFieldName으로 검증 + + boolean exists = projectTeamRoleRepository.existsByProject_IdAndRoleField(projectId, rf); + if (!exists) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "role_field not registered in project. roleField=" + rf + ); + } + } + + // CUSTOM 검증 (ProcessCreateReqDto는 customFieldName 단일) + if (roleFields != null && roleFields.contains(RoleField.CUSTOM)) { + String name = (customFieldName == null) ? "" : customFieldName.trim(); + if (name.isBlank()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "custom_field_name is required"); + } + + boolean exists = projectTeamRoleRepository + .existsByProject_IdAndRoleFieldAndCustomRoleFieldName(projectId, RoleField.CUSTOM, name); + + if (!exists) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "custom role not registered in project. customRoleFieldName=" + name + ); + } + } + } // 알림 관련 헬퍼 메서드 private List validateAndLoadMentionReceivers(Long projectId, Long actorId, List mentionIds) { @@ -338,7 +375,7 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre assertActiveProjectMember(projectId, userId); validateProcessTitle(req.processTitle()); - List taskItems = req.taskItems(); + List taskItems = Optional.ofNullable(req.taskItems()).orElse(List.of()); validateTaskItems(taskItems); // 프로젝트 확인 @@ -387,8 +424,20 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre process.updatePeriod(start, end); + // 필드(역할) CUSTOM쪽 검증 + List roleFields = Optional.ofNullable(req.roleFields()).orElse(List.of()) + .stream().filter(Objects::nonNull).distinct().toList(); + + // 정규화 이후 검증/저장/히스토리 모두 이 값 사용 + String customName = (req.customFieldName() == null) ? null : req.customFieldName().trim(); + + // 프로젝트에 등록된 파트인지 검증 (CUSTOM 포함) + validateProjectTeamRolesOrThrow(projectId, roleFields, req.customFieldName()); + + // 미션 N 검증: "시작일만" 미션 기간에 포함되면 요구사항 일치 validateStartDateInSelectedMission(projectId, req.missionNumber(), start); + // lane 기간 겹침 검증 validateNoOverlapInLane(projectId, req, start, end); int i = 0; @@ -444,26 +493,9 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre } - // 필드(역할) CUSTOM쪽 검증 - List roleFields = Optional.ofNullable(req.roleFields()).orElse(List.of()) - .stream().filter(Objects::nonNull).distinct().toList(); - - - if (roleFields.contains(RoleField.CUSTOM)) { - if (req.customFieldName() == null || req.customFieldName().isBlank()) { - throw new ProcessException( - ProcessErrorCode.INVALID_REQUEST, - "custom_field_name is required when role_fields contains CUSTOM" - ); - } - } - for (RoleField rf : roleFields) { - if (rf == RoleField.CUSTOM) { - process.addField(RoleField.CUSTOM, req.customFieldName()); - } else { - process.addField(rf, null); - } + if (rf == RoleField.CUSTOM) process.addField(RoleField.CUSTOM, customName); + else process.addField(rf, null); } @@ -527,7 +559,7 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre meta.put("startAt", saved.getStartAt()); meta.put("endAt", saved.getEndAt()); meta.put("roleFields", roleFields); - meta.put("customFieldName", roleFields.contains(RoleField.CUSTOM) ? req.customFieldName() : null); + meta.put("customFieldName", roleFields.contains(RoleField.CUSTOM) ? customName : null); meta.put("assigneeIds", assigneeIds); meta.put("mentionUserIds", mentionIds); meta.put("fileIds", fileIds); @@ -875,6 +907,7 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, .filter(pf -> pf.getDeletedAt() == null) .map(ProcessField::getRoleField) .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) .distinct() .sorted(Comparator.comparing(Enum::name)) .toList(); @@ -900,6 +933,7 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, .toList(); if(req.processTitle() != null && !req.processTitle().isBlank()) { + validateProcessTitle(req.processTitle()); process.updateTitle(req.processTitle()); } @@ -929,41 +963,66 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, ); } + // 시작일만 미션 범위에 포함되면 통과 if (req.missionNumber() != null && mergedStart != null && mergedEnd != null) { validateStartDateInSelectedMission(projectId, req.missionNumber(), mergedStart); } - if (mergedStart != null && mergedEnd != null && (newStart != null || newEnd != null)) { + // fields PATCH 준비(정규화 + 프로젝트 등록 파트 검증) + boolean fieldsPatchRequested = (req.roleFields() != null || req.customFields() != null); - // 검증할 lane 후보 결정 - List laneRoleFields = - (req.roleFields() != null) - ? req.roleFields() - : process.getProcessFields().stream() - .filter(pf -> pf.getDeletedAt() == null) - .map(ProcessField::getRoleField) - .filter(Objects::nonNull) - .filter(rf -> rf != RoleField.CUSTOM) - .distinct() - .toList(); + List requestedRoleFields = (req.roleFields() == null) + ? null + : req.roleFields().stream() + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .distinct() + .toList(); - List laneCustomFields = - (req.customFields() != null) - ? req.customFields() - : process.getProcessFields().stream() - .filter(pf -> pf.getDeletedAt() == null) - .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) - .map(ProcessField::getCustomFieldName) - .filter(Objects::nonNull) - .map(String::trim) - .filter(s -> !s.isBlank()) - .distinct() - .toList(); + List requestedCustomFields = (req.customFields() == null) + ? null + : normalizeCustomFields(req.customFields()); + + if (fieldsPatchRequested) { + validateProjectTeamRolesForUpdateOrThrow( + projectId, + (requestedRoleFields == null ? List.of() : requestedRoleFields), + (requestedCustomFields == null ? List.of() : requestedCustomFields) + ); + } + + // 기간 변경이 없더라도 "파트/커스텀 변경"만으로도 lane이 바뀌면 overlap 가능 + boolean periodPatchRequested = (newStart != null || newEnd != null); + + if (mergedStart != null && mergedEnd != null && (periodPatchRequested || fieldsPatchRequested)) { + + List laneRoleFields = (requestedRoleFields != null) + ? requestedRoleFields + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .map(ProcessField::getRoleField) + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .distinct() + .toList(); + + List laneCustomFields = (requestedCustomFields != null) + ? requestedCustomFields + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) + .map(ProcessField::getCustomFieldName) + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .toList(); validateNoOverlapForUpdateBasic(projectId, processId, laneRoleFields, laneCustomFields, mergedStart, mergedEnd); } - if (newStart != null || newEnd != null) { + + if (periodPatchRequested) { process.updatePeriod(mergedStart, mergedEnd); } @@ -973,8 +1032,8 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, // 멘션 알림: 요청이 들어온 경우에만, 그리고 '새로 추가된 멘션'에게만 전송 if (req.mentionUserIds() != null) { - List afterIds = (mentionIdsForRes == null) ? List.of() : mentionIdsForRes.stream() - .filter(Objects::nonNull).distinct().toList(); + List afterIds = (mentionIdsForRes == null) ? List.of() + : mentionIdsForRes.stream().filter(Objects::nonNull).distinct().toList(); Set beforeSet = new HashSet<>(beforeMentionIds); List addedMentionIds = afterIds.stream() @@ -997,62 +1056,45 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, } } - if (req.roleFields() != null || req.customFields() != null) { - // 기존 전부 soft delete + if (fieldsPatchRequested) { + // 기존 전부 soft delete process.getProcessFields().forEach(pf -> { if (pf.getDeletedAt() == null) pf.softDelete(); }); - // roleFields 반영 (CUSTOM 제외) - List requestedRoleFields = (req.roleFields() == null) ? List.of() : req.roleFields(); - for (RoleField rf : requestedRoleFields) { - if (rf == null) continue; - if (rf == RoleField.CUSTOM) continue; - - // 기존에 같은 roleField가 삭제된 상태로 있으면 restore, 없으면 생성 + List finalRoleFields = (requestedRoleFields == null) ? List.of() : requestedRoleFields; + for (RoleField rf : finalRoleFields) { ProcessField found = process.getProcessFields().stream() .filter(pf -> pf.getRoleField() == rf) .findFirst() .orElse(null); - if (found != null) { - found.restore(); - } else { - ProcessField pf = ProcessField.builder() - .process(process) - .roleField(rf) - .customFieldName(null) - .build(); - process.getProcessFields().add(pf); - } + if (found != null) found.restore(); + else process.getProcessFields().add(ProcessField.builder() + .process(process) + .roleField(rf) + .customFieldName(null) + .build()); } - // customFields 반영 (CUSTOM은 이름 기반) - List requestedCustomFields = (req.customFields() == null) ? List.of() : req.customFields(); - for (String name : requestedCustomFields) { - if (name == null) continue; - String trimmed = name.trim(); - if (trimmed.isBlank()) continue; - + List finalCustomFields = (requestedCustomFields == null) ? List.of() : requestedCustomFields; + for (String name : finalCustomFields) { ProcessField found = process.getProcessFields().stream() .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) - .filter(pf -> trimmed.equals(pf.getCustomFieldName())) + .filter(pf -> pf.getCustomFieldName() != null && pf.getCustomFieldName().trim().equals(name)) .findFirst() .orElse(null); - if (found != null) { - found.restore(); - } else { - ProcessField pf = ProcessField.builder() - .process(process) - .roleField(RoleField.CUSTOM) - .customFieldName(trimmed) - .build(); - process.getProcessFields().add(pf); - } + if (found != null) found.restore(); + else process.getProcessFields().add(ProcessField.builder() + .process(process) + .roleField(RoleField.CUSTOM) + .customFieldName(name) + .build()); } } + // 요청이 null이면 변경 안 함 if (req.assigneeIds() != null) { List requestedAssigneeIds = req.assigneeIds().stream() @@ -1256,6 +1298,57 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, } + private List normalizeCustomFields(List raw) { + return raw.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .toList(); + } + + /** + * 프로젝트에 등록된 파트(ProjectTeamRole)인지 검증 + * - roleFields: CUSTOM 제외 리스트 + * - customFields: CUSTOM 이름 리스트 + */ + private void validateProjectTeamRolesForUpdateOrThrow(Long projectId, List roleFields, List customFields) { + List rows = + projectTeamRoleRepository.findActiveTeamRoleRowsByProjectId(projectId); + + Set registeredRoleFields = rows.stream() + .map(ProjectTeamRoleRepository.TeamRoleRow::getRoleField) + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .collect(Collectors.toSet()); + + Set registeredCustomNames = rows.stream() + .filter(r -> r.getRoleField() == RoleField.CUSTOM) + .map(ProjectTeamRoleRepository.TeamRoleRow::getCustomRoleFieldName) + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .collect(Collectors.toSet()); + + for (RoleField rf : roleFields) { + if (!registeredRoleFields.contains(rf)) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "role_field not registered in project. projectId=" + projectId + ", roleField=" + rf + ); + } + } + + for (String name : customFields) { + if (!registeredCustomNames.contains(name)) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "custom_field not registered in project. projectId=" + projectId + ", customField=" + name + ); + } + } + } + diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java index 5ef99da4..937fa046 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -7,6 +7,7 @@ import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDropdownResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; import com.nect.api.domain.team.process.enums.ProcessErrorCode; import com.nect.api.domain.team.process.exception.ProcessException; @@ -502,6 +503,38 @@ public ProcessTaskItemResDto updateWeekMissionTaskItem( ); } + // 위크미션 드롭 다운용 조화 + @Transactional(readOnly = true) + public WeekMissionDropdownResDto getMissionDropdown(Long projectId, Long userId) { + assertActiveProjectMember(projectId, userId); + assertProjectExists(projectId); + + var rows = processRepository.findWeekMissionRanges(projectId); + + LocalDate today = LocalDate.now(); + + List missions = rows.stream() + .map(r -> { + LocalDate start = r.getStartDate(); + LocalDate end = r.getEndDate(); + + boolean isCurrent = false; + if (start != null && end != null) { + isCurrent = (!today.isBefore(start) && !today.isAfter(end)); + } + + return new WeekMissionDropdownResDto.MissionDto( + r.getMissionNumber(), + start, + end, + isCurrent + ); + }) + .toList(); + + return new WeekMissionDropdownResDto(missions); + } + private LocalDate toMonday(LocalDate date) { return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java new file mode 100644 index 00000000..a5ed5826 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java @@ -0,0 +1,42 @@ +package com.nect.api.domain.team.project.controller; + +import com.nect.api.domain.team.project.dto.ProjectPartsResDto; +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.service.ProjectTeamQueryService; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}") +public class ProjectPartsController { + + private final ProjectTeamQueryService projectTeamQueryService; + + // 팀 파트 조회 (드롭다운) + @GetMapping("/parts") + public ApiResponse readProjectParts( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok( + projectTeamQueryService.readProjectParts(projectId, userDetails.getUserId()) + ); + } + + // 프로젝트 전체 인원 조회 (담당자 드롭다운) + @GetMapping("/users") + public ApiResponse readProjectUsers( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok( + projectTeamQueryService.readProjectUsers(projectId, userDetails.getUserId()) + ); + } + + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartsResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartsResDto.java new file mode 100644 index 00000000..c4151ff6 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartsResDto.java @@ -0,0 +1,32 @@ +package com.nect.api.domain.team.project.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +import java.util.List; + +public record ProjectPartsResDto( + @JsonProperty("parts") + List parts +) { + public ProjectPartsResDto { + parts = (parts == null) ? List.of() : parts; + } + + public record PartDto( + @JsonProperty("part_id") + Long partId, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("part_label") + String partLabel, + + @JsonProperty("required_count") + Integer requiredCount + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectUsersResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectUsersResDto.java new file mode 100644 index 00000000..9ac3e96f --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectUsersResDto.java @@ -0,0 +1,42 @@ +package com.nect.api.domain.team.project.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.user.enums.RoleField; + +import java.util.List; + +public record ProjectUsersResDto( + @JsonProperty("users") + List users +) { + public ProjectUsersResDto { + users = (users == null) ? List.of() : users; + } + + public record UserDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("name") + String name, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("part_label") + String partLabel, + + @JsonProperty("member_type") + ProjectMemberType memberType + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java index ee77c563..466d221d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java @@ -8,6 +8,8 @@ @AllArgsConstructor public enum ProjectErrorCode implements ResponseCode { + INVALID_REQUEST("P400_0", "요청 값이 올바르지 않습니다."), + PROJECT_NOT_FOUND("P400_1", "해당 프로젝트가 존재하지 않습니다."), PROJECT_USER_NOT_FOUND("P400_2", "해당 프로젝트 유저가 존재하지 않습니다."), ANALYSIS_NOT_FOUND("P400_3", "해당 분석서가 존재하지 않습니다."), @@ -17,7 +19,12 @@ public enum ProjectErrorCode implements ResponseCode { WEEK_MISSION_ALREADY_INITIALIZED("P400_7", "위크미션이 이미 생성되어 있습니다."), INVALID_WEEK_MISSION_UPDATE("P400_8", "수정할 수 없는 항목이 포함되어 있습니다."), + PROJECT_PART_NOT_FOUND("P400_9", "해당 프로젝트 파트(팀 역할)를 찾을 수 없습니다."), + DUPLICATE_PART("P400_10", "이미 존재하는 파트입니다."), + INVALID_CUSTOM_PART_NAME("P400_11", "CUSTOM 파트 이름이 올바르지 않습니다."), + + PROJECT_MEMBER_FORBIDDEN("P403_0", "프로젝트 멤버만 접근할 수 있습니다."), LEADER_ONLY_ACTION("P403_1", "리더만 할 수 있는 요청입니다."), ; diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/exception/ProjectException.java b/nect-api/src/main/java/com/nect/api/domain/team/project/exception/ProjectException.java index 10577982..0a2a5cf0 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/exception/ProjectException.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/exception/ProjectException.java @@ -8,4 +8,8 @@ public class ProjectException extends CustomException { public ProjectException(ResponseCode code) { super(code); } + + public ProjectException(ResponseCode code, String message) { + super(code, message); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java index d461bf8c..f252242a 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java @@ -19,6 +19,7 @@ import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.analysis.*; import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.process.ProcessRepository; import com.nect.core.repository.user.UserRepository; diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java new file mode 100644 index 00000000..9bf7ff88 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java @@ -0,0 +1,116 @@ +package com.nect.api.domain.team.project.service; + +import com.nect.api.domain.team.project.dto.ProjectPartsResDto; +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.enums.code.ProjectErrorCode; +import com.nect.api.domain.team.project.exception.ProjectException; +import com.nect.core.entity.team.ProjectTeamRole; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.user.User; +import com.nect.core.entity.user.enums.RoleField; +import com.nect.core.repository.team.ProjectTeamRoleRepository; +import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ProjectTeamQueryService { + private final ProjectTeamRoleRepository projectTeamRoleRepository; + private final ProjectUserRepository projectUserRepository; + private final UserRepository userRepository; + + // 프로젝트 파트 목록 조회 서비스 + @Transactional(readOnly = true) + public ProjectPartsResDto readProjectParts(Long projectId, Long userId) { + assertActiveProjectMember(projectId, userId); + + List roles = projectTeamRoleRepository.findAllActiveByProjectId(projectId); + + List parts = roles.stream() + .map(ptr -> { + RoleField rf = ptr.getRoleField(); + String customName = ptr.getCustomRoleFieldName(); + + String label = (rf == RoleField.CUSTOM) + ? customName + : rf.getLabelEn(); + + return new ProjectPartsResDto.PartDto( + ptr.getId(), + rf, + customName, + label, + ptr.getRequiredCount() + ); + }) + .toList(); + + return new ProjectPartsResDto(parts); + } + + // 프로젝트 멤버 전체 조회 서비스 + @Transactional(readOnly = true) + public ProjectUsersResDto readProjectUsers(Long projectId, Long userId) { + assertActiveProjectMember(projectId, userId); + + List rows = + projectUserRepository.findActiveMemberBoardRows(projectId); + + List ids = rows.stream() + .map(ProjectUserRepository.MemberBoardRow::getUserId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + Map userMap = userRepository.findAllById(ids).stream() + .collect(Collectors.toMap(User::getUserId, Function.identity())); + + List users = rows.stream() + .map(r -> { + User u = userMap.get(r.getUserId()); + String profileUrl = (u == null) ? null : u.getProfileImageUrl(); + + RoleField rf = r.getRoleField(); + String customName = r.getCustomRoleFieldName(); + + String label = (rf == RoleField.CUSTOM) + ? customName + : rf.getDescription(); + + return new ProjectUsersResDto.UserDto( + r.getUserId(), + r.getName(), + r.getNickname(), + profileUrl, + rf, + customName, + label, + r.getMemberType() + ); + }) + .toList(); + + return new ProjectUsersResDto(users); + } + + + private void assertActiveProjectMember(Long projectId, Long userId) { + boolean ok = projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus( + projectId, userId, ProjectMemberStatus.ACTIVE + ); + if (!ok) { + throw new ProjectException(ProjectErrorCode.PROJECT_MEMBER_FORBIDDEN, + "projectId=" + projectId + ", userId=" + userId); + } + + } +} diff --git a/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java b/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java index e48fbd3a..a3ec704a 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java @@ -6,6 +6,8 @@ import jakarta.persistence.*; import lombok.*; +import java.time.LocalDateTime; + @Entity @Getter @Setter @@ -25,15 +27,40 @@ public class ProjectTeamRole extends BaseEntity { @Column(name = "role_field", nullable = false) private RoleField roleField; + @Column(name = "custom_role_field_name", length = 50) + private String customRoleFieldName; + @Column(name = "required_count", nullable = false) private Integer requiredCount; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Builder - private ProjectTeamRole(Project project, RoleField roleField, Integer requiredCount) { + private ProjectTeamRole(Project project, RoleField roleField, String customRoleFieldName, Integer requiredCount) { + if (roleField == null) { + throw new IllegalArgumentException("roleField는 null일 수 없습니다."); + } + if (roleField == RoleField.CUSTOM && (customRoleFieldName == null || customRoleFieldName.isBlank())) { + throw new IllegalArgumentException("CUSTOM이면 customRoleFieldName(직접입력)이 필수입니다."); + } + this.project = project; this.roleField = roleField; + this.customRoleFieldName = (roleField == RoleField.CUSTOM) ? customRoleFieldName : null; this.requiredCount = requiredCount; } + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } + + public void restore() { + this.deletedAt = null; + } + + public boolean isDeleted() { + return this.deletedAt != null; + } } \ No newline at end of file diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java index f00182b5..87e0bdd5 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java @@ -1,9 +1,43 @@ -package com.nect.core.repository.analysis; +package com.nect.core.repository.team; import com.nect.core.entity.team.ProjectTeamRole; +import com.nect.core.entity.user.enums.RoleField; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface ProjectTeamRoleRepository extends JpaRepository { List findByProjectId(Long projectId); + + @Query(""" + select ptr + from ProjectTeamRole ptr + where ptr.project.id = :projectId + and ptr.deletedAt is null + order by ptr.id asc + """) + List findAllActiveByProjectId(@Param("projectId") Long projectId); + + @Query(""" + select ptr.roleField as roleField, + ptr.customRoleFieldName as customRoleFieldName + from ProjectTeamRole ptr + where ptr.project.id = :projectId + and ptr.deletedAt is null + """) + List findActiveTeamRoleRowsByProjectId(@Param("projectId") Long projectId); + + interface TeamRoleRow { + RoleField getRoleField(); + String getCustomRoleFieldName(); + } + + boolean existsByProject_IdAndRoleField(Long projectId, RoleField roleField); + + boolean existsByProject_IdAndRoleFieldAndCustomRoleFieldName( + Long projectId, + RoleField roleField, + String customRoleFieldName + ); } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index 0419805e..cece6921 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -581,5 +581,27 @@ boolean existsOverlappingInCustomLaneExcludingProcess( @Param("excludeProcessId") Long excludeProcessId ); + @Query(""" + select + p.missionNumber as missionNumber, + min(p.startAt) as startDate, + max(p.endAt) as endDate + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.missionNumber is not null + and p.startAt is not null + and p.endAt is not null + group by p.missionNumber + order by p.missionNumber asc + """) + List findWeekMissionRanges(@Param("projectId") Long projectId); + + interface WeekMissionRangeRow { + Integer getMissionNumber(); + LocalDate getStartDate(); + LocalDate getEndDate(); + } + } From 0088263573e939516e650df9e2f1cbec4d460592 Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 03:05:20 +0900 Subject: [PATCH 17/66] =?UTF-8?q?[Test]=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=9C=A0=EC=A0=80=20=EC=A1=B0=ED=9A=8C,=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=ED=8C=8C=ED=8A=B8,=20?= =?UTF-8?q?=EB=AF=B8=EC=85=98=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WeekMissionControllerTest.java | 46 ++++ .../ProjectPartsControllerTest.java | 241 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java index 465c3133..d47eb84b 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java @@ -7,6 +7,7 @@ import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDropdownResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; import com.nect.api.domain.team.process.service.WeekMissionService; import com.nect.api.global.jwt.JwtUtil; @@ -376,4 +377,49 @@ void updateWeekMissionTaskItem() throws Exception { verify(weekMissionService).updateWeekMissionTaskItem(eq(projectId), eq(userId), eq(processId), eq(taskItemId), any(WeekMissionTaskItemUpdateReqDto.class)); } + + @Test + @DisplayName("멤버형 모달 미션 주차 선택 드롭다운 조회") + void readMissionDropdown() throws Exception { + long projectId = 1L; + long userId = 1L; + + WeekMissionDropdownResDto response = newRecord(WeekMissionDropdownResDto.class); + + given(weekMissionService.getMissionDropdown(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/week-missions/missions", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("week-mission-missions-dropdown", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("미션 주차 드롭다운 조회") + .description("멤버형 모달에서 미션(주차) 선택을 위한 드롭다운 목록을 조회합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("미션 드롭다운 조회 결과") + ) + .build() + ) + )); + + verify(weekMissionService).getMissionDropdown(eq(projectId), eq(userId)); + } } diff --git a/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java new file mode 100644 index 00000000..351432de --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java @@ -0,0 +1,241 @@ +package com.nect.api.domain.team.project.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.project.dto.ProjectPartsResDto; +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.service.ProjectTeamQueryService; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Constructor; +import java.lang.reflect.RecordComponent; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class ProjectPartsControllerTest { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ProjectTeamQueryService projectTeamQueryService; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_MEMBER")) + .build() + ); + } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_MEMBER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return SecurityMockMvcRequestPostProcessors.authentication(auth); + } + + private T newRecord(Class recordType) { + try { + if (!recordType.isRecord()) return null; + + RecordComponent[] components = recordType.getRecordComponents(); + Class[] paramTypes = new Class[components.length]; + Object[] args = new Object[components.length]; + + for (int i = 0; i < components.length; i++) { + Class t = components[i].getType(); + paramTypes[i] = t; + args[i] = defaultValue(t); + } + + Constructor ctor = recordType.getDeclaredConstructor(paramTypes); + ctor.setAccessible(true); + return ctor.newInstance(args); + } catch (Exception e) { + throw new IllegalStateException("Failed to instantiate record: " + recordType.getName(), e); + } + } + + private Object defaultValue(Class t) { + if (t == String.class) return "sample"; + if (t == Long.class || t == long.class) return 1L; + if (t == Integer.class || t == int.class) return 1; + if (t == Boolean.class || t == boolean.class) return false; + if (t == LocalDate.class) return LocalDate.of(2026, 1, 19); + if (t == LocalDateTime.class) return LocalDateTime.of(2026, 1, 19, 0, 0, 0); + + if (List.class.isAssignableFrom(t)) return List.of(); + + if (t.isEnum()) { + Object[] constants = t.getEnumConstants(); + return (constants != null && constants.length > 0) ? constants[0] : null; + } + + if (t.isRecord()) { + @SuppressWarnings("unchecked") + Class rt = (Class) t; + return newRecord(rt); + } + + return null; + } + + @Test + @DisplayName("팀 파트 조회 (드롭다운)") + void readProjectParts() throws Exception { + long projectId = 1L; + long userId = 1L; + + ProjectPartsResDto response = newRecord(ProjectPartsResDto.class); + + given(projectTeamQueryService.readProjectParts(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/parts", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("project-parts-read", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Project") + .summary("팀 파트 조회") + .description("현재 프로젝트에 설정된 파트 목록을 조회합니다. (드롭다운)") + .pathParameters( + com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName("projectId") + .description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("팀 파트 조회 결과") + ) + .build() + ) + )); + + verify(projectTeamQueryService).readProjectParts(eq(projectId), eq(userId)); + } + + @Test + @DisplayName("프로젝트 전체 인원 조회 (담당자 드롭다운)") + void readProjectUsers() throws Exception { + long projectId = 1L; + long userId = 1L; + + ProjectUsersResDto response = newRecord(ProjectUsersResDto.class); + + given(projectTeamQueryService.readProjectUsers(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/users", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("project-users-read", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Project") + .summary("프로젝트 전체 인원 조회") + .description("프로젝트에 속한 전체 인원 목록을 조회합니다. (담당자 드롭다운)") + .pathParameters( + com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName("projectId") + .description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("프로젝트 전체 인원 조회 결과") + ) + .build() + ) + )); + + verify(projectTeamQueryService).readProjectUsers(eq(projectId), eq(userId)); + } +} From 7223355fa79533f339b4f00bf7f169834eb10b2e Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 11:38:51 +0900 Subject: [PATCH 18/66] =?UTF-8?q?[Refactor]=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A7=A4=EA=B0=9C=EB=B3=80=EC=88=98=20=EA=B0=92=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nect/api/domain/team/process/service/ProcessService.java | 2 +- .../api/domain/team/process/service/WeekMissionService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index b1be82b9..af0d92df 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -432,7 +432,7 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre String customName = (req.customFieldName() == null) ? null : req.customFieldName().trim(); // 프로젝트에 등록된 파트인지 검증 (CUSTOM 포함) - validateProjectTeamRolesOrThrow(projectId, roleFields, req.customFieldName()); + validateProjectTeamRolesOrThrow(projectId, roleFields, customName); // 미션 N 검증: "시작일만" 미션 기간에 포함되면 요구사항 일치 validateStartDateInSelectedMission(projectId, req.missionNumber(), start); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java index 937fa046..8b9ea852 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -503,7 +503,7 @@ public ProcessTaskItemResDto updateWeekMissionTaskItem( ); } - // 위크미션 드롭 다운용 조화 + // 위크미션 드롭 다운용 조회 @Transactional(readOnly = true) public WeekMissionDropdownResDto getMissionDropdown(Long projectId, Long userId) { assertActiveProjectMember(projectId, userId); From c6a28f0451acee298eef229f2520122d09a44471 Mon Sep 17 00:00:00 2001 From: infiniment Date: Sat, 7 Feb 2026 00:17:17 +0900 Subject: [PATCH 19/66] =?UTF-8?q?[Feat]=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=A1=B0=ED=9A=8C=20API=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=8C=80=20=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nect-api/build.gradle | 12 +++ .../controller/WorkTimerController.java | 17 +++- .../workspace/dto/res/MemberBoardResDto.java | 1 - .../workspace/dto/res/WorkTimerSnapshot.java | 20 ++++ .../team/workspace/enums/BoardsErrorCode.java | 4 +- .../workspace/facade/WorkTimerFacade.java | 5 + .../service/BoardsBasicInfoService.java | 49 +++++++++- .../service/BoardsMemberBoardService.java | 2 +- .../service/BoardsScheduleService.java | 4 - .../service/BoardsSharedDocumentService.java | 2 +- .../workspace/service/WorkTimerService.java | 98 ++++++++++++++++--- .../enums/NotificationClassification.java | 1 + .../notifications/enums/NotificationType.java | 10 ++ .../team/ProjectUserRepository.java | 2 + .../ProjectUserWorkDailyRepository.java | 8 +- 15 files changed, 203 insertions(+), 32 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/WorkTimerSnapshot.java diff --git a/nect-api/build.gradle b/nect-api/build.gradle index bfdbb962..1e572d4c 100644 --- a/nect-api/build.gradle +++ b/nect-api/build.gradle @@ -54,6 +54,18 @@ test { outputs.dir snippetsDir } +tasks.withType(Test).configureEach { + maxHeapSize = "2g" + jvmArgs( + "-XX:+HeapDumpOnOutOfMemoryError", + "-XX:HeapDumpPath=${rootProject.projectDir}/build/heapdumps" + ) + + doFirst { + file("${rootProject.projectDir}/build/heapdumps").mkdirs() + } +} + asciidoctor { inputs.dir snippetsDir dependsOn test diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/WorkTimerController.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/WorkTimerController.java index 16037695..841975ba 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/WorkTimerController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/WorkTimerController.java @@ -1,15 +1,12 @@ package com.nect.api.domain.team.workspace.controller; +import com.nect.api.domain.team.workspace.dto.res.WorkTimerSnapshot; import com.nect.api.domain.team.workspace.facade.WorkTimerFacade; -import com.nect.api.domain.team.workspace.service.WorkTimerService; import com.nect.api.global.response.ApiResponse; import com.nect.api.global.security.UserDetailsImpl; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @@ -39,4 +36,14 @@ public ApiResponse stop( workTimerFacade.stop(projectId, userId); return ApiResponse.ok(null); } + + // 스냅샷 + @GetMapping("/snapshot") + public ApiResponse snapshot( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + WorkTimerSnapshot data = workTimerFacade.snapshot(projectId, userDetails.getUserId()); + return ApiResponse.ok(data); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/MemberBoardResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/MemberBoardResDto.java index d2dfa4d0..4b57fc66 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/MemberBoardResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/MemberBoardResDto.java @@ -20,7 +20,6 @@ public record MemberDto( @JsonProperty("nickname") String nickname, - // TODO: UserProfile 엔티티 생기면 연결해서 내려주기 @JsonProperty("profile_image_url") String profileImageUrl, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/WorkTimerSnapshot.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/WorkTimerSnapshot.java new file mode 100644 index 00000000..e108bdcd --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/WorkTimerSnapshot.java @@ -0,0 +1,20 @@ +package com.nect.api.domain.team.workspace.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.LocalDateTime; + +public record WorkTimerSnapshot( + @JsonProperty("is_working") + boolean isWorking, + + @JsonProperty("today_work_seconds") + long todayWorkSeconds, + + @JsonProperty("working_started_at") + LocalDateTime workingStartedAt +) { + public static WorkTimerSnapshot empty() { + return new WorkTimerSnapshot(false, 0L, null); + } +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/enums/BoardsErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/enums/BoardsErrorCode.java index a373bd91..f61ef6c8 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/enums/BoardsErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/enums/BoardsErrorCode.java @@ -12,7 +12,9 @@ public enum BoardsErrorCode implements ResponseCode { PROJECT_MEMBER_FORBIDDEN("B4031", "프로젝트 멤버만 접근할 수 있습니다."), PROJECT_LEADER_FORBIDDEN("B4032", "프로젝트 리더만 수정할 수 있습니다."), - PROJECT_NOT_FOUND("B4041", "프로젝트를 찾을 수 없습니다."); + PROJECT_NOT_FOUND("B4041", "프로젝트를 찾을 수 없습니다."), + PROJECT_MEMBER_NOT_FOUND("B4042", "프로젝트 멤버를 찾을 수 없습니다."), + USER_NOT_FOUND("B4043", "사용자를 찾을 수 없습니다."); private final String statusCode; private final String message; diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/WorkTimerFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/WorkTimerFacade.java index 98aa43b9..1a80151a 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/WorkTimerFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/WorkTimerFacade.java @@ -1,5 +1,6 @@ package com.nect.api.domain.team.workspace.facade; +import com.nect.api.domain.team.workspace.dto.res.WorkTimerSnapshot; import com.nect.api.domain.team.workspace.service.WorkTimerService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,4 +18,8 @@ public void start(Long projectId, Long userId) { public void stop(Long projectId, Long userId) { workTimerService.stop(projectId, userId); } + + public WorkTimerSnapshot snapshot(Long projectId, Long userId) { + return workTimerService.getTodaySnapshot(projectId, userId); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsBasicInfoService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsBasicInfoService.java index 9dbc0c23..2757db43 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsBasicInfoService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsBasicInfoService.java @@ -1,14 +1,20 @@ package com.nect.api.domain.team.workspace.service; +import com.nect.api.domain.notifications.command.NotificationCommand; +import com.nect.api.domain.notifications.facade.NotificationFacade; import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; import com.nect.api.domain.team.workspace.dto.req.BoardsBasicInfoUpdateReqDto; import com.nect.api.domain.team.workspace.dto.res.BoardsBasicInfoGetResDto; import com.nect.api.domain.team.workspace.enums.BoardsErrorCode; import com.nect.api.domain.team.workspace.exception.BoardsException; import com.nect.api.global.code.DateConstants; +import com.nect.core.entity.notifications.enums.NotificationClassification; +import com.nect.core.entity.notifications.enums.NotificationScope; +import com.nect.core.entity.notifications.enums.NotificationType; import com.nect.core.entity.team.Project; import com.nect.core.entity.team.history.enums.HistoryAction; import com.nect.core.entity.team.history.enums.HistoryTargetType; +import com.nect.core.entity.user.User; import com.nect.core.repository.team.ProjectRepository; import com.nect.core.repository.team.ProjectUserRepository; import lombok.RequiredArgsConstructor; @@ -19,6 +25,7 @@ import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -32,6 +39,7 @@ public class BoardsBasicInfoService { private final ProjectUserRepository projectUserRepository; private final ProjectHistoryPublisher historyPublisher; + private final NotificationFacade notificationFacade; @Transactional(readOnly = true) public BoardsBasicInfoGetResDto getBasicInfo(Long projectId, Long userId) { @@ -151,6 +159,8 @@ public void updateBasicInfo(Long projectId, Long userId, BoardsBasicInfoUpdateRe throw new BoardsException(BoardsErrorCode.INVALID_REQUEST, "no actual changes"); } + notifyTeamBoardUpdate(project, userId, changedMeta); + historyPublisher.publish( projectId, userId, @@ -160,6 +170,43 @@ public void updateBasicInfo(Long projectId, Long userId, BoardsBasicInfoUpdateRe Map.of("changed", changedMeta) ); - // TODO: NotificationFacade를 통해 "프로젝트 공지/정기회의 수정" 알림 생성/발송 + } + + private void notifyTeamBoardUpdate(Project project, Long actorUserId, Map changedMeta) { + // 수신자 = 프로젝트 ACTIVE 유저 - 본인 + List receivers = projectUserRepository.findAllUsersByProjectId(project.getId()) + .stream() + .filter(u -> !u.getUserId().equals(actorUserId)) + .toList(); + + if (receivers.isEmpty()) return; + + // 공지 변경 알림 + if (changedMeta.containsKey("notice_text")) { + NotificationCommand command = new NotificationCommand( + NotificationType.WORKSPACE_BOARD_NOTICE_UPDATED, + NotificationClassification.TEAM_BOARD, + NotificationScope.WORKSPACE_ONLY, + project.getId(), + new Object[]{}, + null, + project + ); + notificationFacade.notify(receivers, command); + } + + // 정기회의 변경 알림 + if (changedMeta.containsKey("regular_meeting_text")) { + NotificationCommand command = new NotificationCommand( + NotificationType.WORKSPACE_BOARD_REGULAR_MEETING_UPDATED, + NotificationClassification.TEAM_BOARD, + NotificationScope.WORKSPACE_ONLY, + project.getId(), + new Object[]{}, + null, + project + ); + notificationFacade.notify(receivers, command); + } } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsMemberBoardService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsMemberBoardService.java index 19a32fcf..3ce5a5b7 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsMemberBoardService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsMemberBoardService.java @@ -104,7 +104,7 @@ public MemberBoardResDto getMemberBoard(Long projectId, Long userId) { m.getUserId(), m.getName(), m.getNickname(), - null, // profile_image_url (TODO) + m.getProfileImageUrl(), fieldDto, m.getMemberType(), new MemberBoardResDto.CountsDto(arr[0], arr[1], arr[2]), diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsScheduleService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsScheduleService.java index af3fa678..ec77a206 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsScheduleService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsScheduleService.java @@ -179,8 +179,6 @@ public ScheduleCreateResDto create(Long projectId, Long userId, ScheduleCreateRe ) ); - // TODO: NotificationFacade 통해 "일정 생성" 알림 - return new ScheduleCreateResDto(saved.getId()); } @@ -252,7 +250,6 @@ public void update(Long projectId, Long userId, Long scheduleId, ScheduleUpdateR Map.of("changed", changed) ); - // TODO: NotificationFacade 통해 "일정 수정" 알림 } // 일정 삭제 @@ -287,7 +284,6 @@ public void delete(Long projectId, Long userId, Long scheduleId) { ) ); - // TODO: NotificationFacade 통해 "일정 삭제" 알림 } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java index c9c1bd5e..09fca6c1 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java @@ -66,7 +66,7 @@ public SharedDocumentsPreviewResDto getPreview(Long projectId, Long userId, int u.getUserId(), u.getName(), u.getNickname(), - null // TODO: profile_image_url + u.getProfileImageUrl() ) ); }).toList(); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/WorkTimerService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/WorkTimerService.java index 52ee35db..cb79cf8e 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/WorkTimerService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/WorkTimerService.java @@ -1,15 +1,21 @@ package com.nect.api.domain.team.workspace.service; +import com.nect.api.domain.team.workspace.dto.res.WorkTimerSnapshot; +import com.nect.api.domain.team.workspace.enums.BoardsErrorCode; +import com.nect.api.domain.team.workspace.exception.BoardsException; import com.nect.core.entity.team.Project; import com.nect.core.entity.team.workspace.ProjectUserWorkDaily; import com.nect.core.entity.user.User; import com.nect.core.repository.team.ProjectRepository; -import com.nect.core.repository.user.UserRepository; +import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.workspace.ProjectUserWorkDailyRepository; +import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; @@ -19,33 +25,26 @@ public class WorkTimerService { private final ProjectRepository projectRepository; private final UserRepository userRepository; + private final ProjectUserRepository projectUserRepository; private final ProjectUserWorkDailyRepository workDailyRepository; - // 작업 시작 + // 시작 서비스 로직 @Transactional public void start(Long projectId, Long userId) { + validateProjectMember(projectId, userId); + LocalDate today = LocalDate.now(); LocalDateTime now = LocalDateTime.now(); - ProjectUserWorkDaily work = workDailyRepository.findTodayForUpdate(projectId, userId, today) - .orElseGet(() -> { - Project project = projectRepository.getReferenceById(projectId); - User user = userRepository.getReferenceById(userId); - return workDailyRepository.save( - ProjectUserWorkDaily.builder() - .project(project) - .user(user) - .workDate(today) - .build() - ); - }); - + ProjectUserWorkDaily work = getOrCreateTodayRowForUpdate(projectId, userId, today); work.start(now); } - // 작업 중지 + // 정지 서비스 로직 @Transactional public void stop(Long projectId, Long userId) { + validateProjectMember(projectId, userId); + LocalDate today = LocalDate.now(); LocalDateTime now = LocalDateTime.now(); @@ -55,4 +54,71 @@ public void stop(Long projectId, Long userId) { if (work == null) return; work.stop(now); } + + + // 스냅샷 서비스 로직 + @Transactional(readOnly = true) + public WorkTimerSnapshot getTodaySnapshot(Long projectId, Long userId) { + LocalDate today = LocalDate.now(); + LocalDateTime now = LocalDateTime.now(); + + ProjectUserWorkDaily work = workDailyRepository.findToday(projectId, userId, today) + .orElse(null); + + return (work == null) ? WorkTimerSnapshot.empty() : toSnapshot(work, now); + } + + + + private void validateProjectMember(Long projectId, Long userId) { + if (!projectRepository.existsById(projectId)) { + throw new BoardsException(BoardsErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId); + } + + if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { + throw new BoardsException(BoardsErrorCode.PROJECT_MEMBER_FORBIDDEN, + "projectId=" + projectId + ", userId=" + userId); + } + + if (!userRepository.existsById(userId)) { + throw new BoardsException(BoardsErrorCode.USER_NOT_FOUND, "userId=" + userId); + } + } + + + private ProjectUserWorkDaily getOrCreateTodayRowForUpdate(Long projectId, Long userId, LocalDate today) { + return workDailyRepository.findTodayForUpdate(projectId, userId, today) + .orElseGet(() -> { + try { + Project projectRef = projectRepository.getReferenceById(projectId); + User userRef = userRepository.getReferenceById(userId); + + ProjectUserWorkDaily created = ProjectUserWorkDaily.builder() + .project(projectRef) + .user(userRef) + .workDate(today) + .build(); + + return workDailyRepository.save(created); + } catch (DataIntegrityViolationException e) { + return workDailyRepository.findTodayForUpdate(projectId, userId, today) + .orElseThrow(() -> e); + } + }); + } + + private WorkTimerSnapshot toSnapshot(ProjectUserWorkDaily work, LocalDateTime now) { + boolean isWorking = work.isWorking(); + LocalDateTime startedAt = isWorking ? work.getStartedAt() : null; + + long accumulated = (work.getAccumulatedSeconds() != null) ? work.getAccumulatedSeconds() : 0L; + + long running = 0L; + if (isWorking && startedAt != null) { + long delta = Duration.between(startedAt, now).getSeconds(); + if (delta > 0) running = delta; + } + + return new WorkTimerSnapshot(isWorking, accumulated + running, startedAt); + } } diff --git a/nect-core/src/main/java/com/nect/core/entity/notifications/enums/NotificationClassification.java b/nect-core/src/main/java/com/nect/core/entity/notifications/enums/NotificationClassification.java index 22173152..a451b504 100644 --- a/nect-core/src/main/java/com/nect/core/entity/notifications/enums/NotificationClassification.java +++ b/nect-core/src/main/java/com/nect/core/entity/notifications/enums/NotificationClassification.java @@ -24,6 +24,7 @@ public enum NotificationClassification { // 작업실 전용 FILE_UPlOAD("파일 업로드"), BOARD("게시판"), + TEAM_BOARD("팀보드"), WORK_STATUS("작업현황"), WEEK_MISSION("위크미션") diff --git a/nect-core/src/main/java/com/nect/core/entity/notifications/enums/NotificationType.java b/nect-core/src/main/java/com/nect/core/entity/notifications/enums/NotificationType.java index a22edf97..d3064368 100644 --- a/nect-core/src/main/java/com/nect/core/entity/notifications/enums/NotificationType.java +++ b/nect-core/src/main/java/com/nect/core/entity/notifications/enums/NotificationType.java @@ -80,6 +80,16 @@ public enum NotificationType { WORKSPACE_TASK_FEEDBACK( "%s님이 나의 작업에 피드백을 남겼습니다.", "“%s”" + ), + + WORKSPACE_BOARD_NOTICE_UPDATED( + "새로운 공지사항이 등록되었습니다.", + null + ), + + WORKSPACE_BOARD_REGULAR_MEETING_UPDATED( + "새로운 정기회의가 등록되었습니다.", + null ); private final String mainMessageFormat; diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java index 62c09546..be60ed63 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java @@ -148,6 +148,7 @@ SELECT COUNT(pu) > 0 pu.userId as userId, u.name as name, u.nickname as nickname, + u.profileImageUrl as profileImageUrl, pu.roleField as roleField, pu.customRoleFieldName as customRoleFieldName, pu.memberType as memberType @@ -222,6 +223,7 @@ interface MemberBoardRow { Long getUserId(); String getName(); String getNickname(); + String getProfileImageUrl(); RoleField getRoleField(); String getCustomRoleFieldName(); ProjectMemberType getMemberType(); diff --git a/nect-core/src/main/java/com/nect/core/repository/team/workspace/ProjectUserWorkDailyRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/workspace/ProjectUserWorkDailyRepository.java index 8810fbfb..e004cae0 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/workspace/ProjectUserWorkDailyRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/workspace/ProjectUserWorkDailyRepository.java @@ -10,7 +10,6 @@ import java.util.Optional; public interface ProjectUserWorkDailyRepository extends JpaRepository { - @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(""" select w @@ -29,10 +28,15 @@ Optional findTodayForUpdate( select w from ProjectUserWorkDaily w where w.project.id = :projectId + and w.user.userId = :userId and w.workDate = :workDate """) - List findAllByProjectIdAndWorkDate( + Optional findToday( @Param("projectId") Long projectId, + @Param("userId") Long userId, @Param("workDate") LocalDate workDate ); + + + List findAllByProjectIdAndWorkDate(Long projectId, LocalDate workDate); } From 7d0a3122a9e7876bba951ee077a107b35e7e4cde Mon Sep 17 00:00:00 2001 From: infiniment Date: Sat, 7 Feb 2026 10:43:36 +0900 Subject: [PATCH 20/66] =?UTF-8?q?[Chore]=20gradle=20OOM=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EC=9E=84=EC=8B=9C=20=EC=84=A4=EC=A0=95=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nect-api/build.gradle | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/nect-api/build.gradle b/nect-api/build.gradle index 1e572d4c..bfdbb962 100644 --- a/nect-api/build.gradle +++ b/nect-api/build.gradle @@ -54,18 +54,6 @@ test { outputs.dir snippetsDir } -tasks.withType(Test).configureEach { - maxHeapSize = "2g" - jvmArgs( - "-XX:+HeapDumpOnOutOfMemoryError", - "-XX:HeapDumpPath=${rootProject.projectDir}/build/heapdumps" - ) - - doFirst { - file("${rootProject.projectDir}/build/heapdumps").mkdirs() - } -} - asciidoctor { inputs.dir snippetsDir dependsOn test From 36dca778f4afca784a8d7cf471ef71ed4072b68a Mon Sep 17 00:00:00 2001 From: KimMinKyu Date: Fri, 6 Feb 2026 12:51:02 +0900 Subject: [PATCH 21/66] =?UTF-8?q?[Fix]=20=20=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AC=B8=EC=84=9C=ED=99=94=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20&=20=EC=9E=91=EC=97=85=EC=8B=A4=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=20API=20=EC=9C=A0=EC=A0=80=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :fix : 아이디어 분석 생성 API 위크미션 타입 추가 개선 & 작업실 채팅& 파일업로드 개선 * :fix : 아이디어 분석 생성 API 위크미션 타입 추가 개선 & 작업실 채팅& 파일업로드 개선 * :fix : 아이디어 분석 생성 API 위크미션 타입 추가 개선 & 작업실 채팅& 파일업로드 개선 * fix ëëAI 아이디어 분석 프롬프트 개선 * fix : 작업실 ì±파일 업로드 개선 & 작업시실 채팅 API 리팩토링 * fix : IdeaAnalysisException cause ê°추가 * fix : ChatMessageDto 빌더 패턴으로 변경 * fix : 채팅 테스트 문서화 개선 & 작업실 채팅 API 유저 프로필 이미지 리팩토링 --- .../chat/controller/ChatFileController.java | 3 - .../controller/ChatMessageController.java | 2 +- .../chat/controller/TeamChatController.java | 12 ---- .../team/chat/converter/ChatConverter.java | 16 +---- .../dto/res/ChatRoomInviteResponseDto.java | 3 +- .../chat/dto/res/ChatRoomResponseDto.java | 24 +++---- .../team/chat/service/ChatRoomService.java | 29 ++++---- .../domain/team/chat/service/ChatService.java | 1 + .../team/chat/service/TeamChatService.java | 66 ++++--------------- .../chat/controller/ChatControllerTest.java | 16 ++++- .../team/chat/ChatRoomUserRepository.java | 4 ++ 11 files changed, 58 insertions(+), 118 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatFileController.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatFileController.java index 5eb783fb..8e46d7dc 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatFileController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatFileController.java @@ -30,8 +30,6 @@ public ApiResponse uploadFile( ChatMessageDto response = chatFileService.uploadAndSendFile( roomId, file, userDetails.getUserId()); - - return ApiResponse.ok(response); } @@ -59,7 +57,6 @@ public ApiResponse> getProjectAlbum( return ApiResponse.ok(response); } - //TODO : WF 페이징처리가 없지만 채팅방별 클라우드 이미지 파일 조회 시 필요예상 @GetMapping("/rooms/{roomId}/album") public ApiResponse getChatRoomAlbumDetail( @PathVariable Long roomId, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatMessageController.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatMessageController.java index 488549e3..dfda9344 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatMessageController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatMessageController.java @@ -26,7 +26,7 @@ public class ChatMessageController { private final ChatService chatService; private final TeamChatService teamChatService; - // 방 메시지 조회 + // 채팅방 내부 조회 @GetMapping("/rooms/{room_id}/messages") public ApiResponse getChatMessages( @PathVariable Long room_id, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/TeamChatController.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/TeamChatController.java index 85d2a7f4..a3b23f55 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/TeamChatController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/TeamChatController.java @@ -39,18 +39,6 @@ public ApiResponse> getProjectMembers( return ApiResponse.ok(response); } - @PostMapping("/personal") - public ApiResponse createPersonalChatRoom( - @RequestBody ChatRoomCreateRequestDto request, - @AuthenticationPrincipal UserDetailsImpl userDetails - ) { - - Long currentUserId = (userDetails != null) ? userDetails.getUserId() : 1L; - - ChatRoomResponseDto response = teamChatService.createOneOnOneChatRoom(currentUserId, request); - return ApiResponse.ok(response); - } - @PostMapping("/group") public ApiResponse createGroupChatRoom( diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/converter/ChatConverter.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/converter/ChatConverter.java index 942ae799..e4efe271 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/converter/ChatConverter.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/converter/ChatConverter.java @@ -103,23 +103,13 @@ public static ChatRoomUser toChatRoomMemberEntity(ChatRoom chatRoom, User user, return member; } - public static ChatRoomResponseDto toResponseDTO(ChatRoom chatRoom, User targetUser) { - - String roomName = chatRoom.getName(); - String profileImage = null; - - - if (chatRoom.getType() == ChatRoomType.DIRECT && targetUser != null) { - roomName = targetUser.getNickname(); - // profileImage = targetUser.getProfileImage(); // TODO: 나중에 프로필 이미지 생기면 추가 - } - + public static ChatRoomResponseDto toResponseDTO(ChatRoom chatRoom, List profileImages) { return ChatRoomResponseDto.builder() .roomId(chatRoom.getId()) .projectId(chatRoom.getProject() != null ? chatRoom.getProject().getId() : null) - .roomName(roomName) + .roomName(chatRoom.getName()) .roomType(chatRoom.getType()) - .profileImage(profileImage) + .profileImages(profileImages) .createdAt(chatRoom.getCreatedAt() != null ? chatRoom.getCreatedAt() : LocalDateTime.now()) .build(); } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/res/ChatRoomInviteResponseDto.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/res/ChatRoomInviteResponseDto.java index b32bf4ea..c25199c7 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/res/ChatRoomInviteResponseDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/res/ChatRoomInviteResponseDto.java @@ -10,5 +10,6 @@ public record ChatRoomInviteResponseDto( Long roomId, Integer invitedCount, - List invitedUserNames + List invitedUserNames, + List profileImages ) {} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/res/ChatRoomResponseDto.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/res/ChatRoomResponseDto.java index 4d388dea..bc44366d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/res/ChatRoomResponseDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/res/ChatRoomResponseDto.java @@ -3,23 +3,17 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.nect.core.entity.team.chat.enums.ChatRoomType; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.List; -@Data @Builder -@NoArgsConstructor -@AllArgsConstructor @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) -public class ChatRoomResponseDto { - private Long roomId; - private Long projectId; //TODO 프로젝트 관련 엔티티에 따라 수정 - private String roomName; - private ChatRoomType roomType; - private String profileImage; //TODO : 프로필 엔티티 생기면 수정 필요 - private LocalDateTime createdAt; - -} +public record ChatRoomResponseDto( + Long roomId, + Long projectId, + String roomName, + ChatRoomType roomType, + List profileImages, + LocalDateTime createdAt +) {} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatRoomService.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatRoomService.java index 07abef79..a5e5f4d2 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatRoomService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatRoomService.java @@ -19,6 +19,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.Collections; @@ -126,7 +127,6 @@ private ChatRoomListDto buildChatRoomListDto(ChatRoom chatRoom, Long userId) { int memberCount = chatRoomUserRepository.countByChatRoomId(chatRoom.getId()); - // TODO: 프로필 이미지 (User 엔티티에 profileImage 필드 추가 후 활성화) List profileImages = getProfileImages(chatRoom.getId()); @@ -147,24 +147,17 @@ private ChatRoomListDto buildChatRoomListDto(ChatRoom chatRoom, Long userId) { .hasNewMessage(hasNewMessage) .build(); } - /** - * TODO: 프로필 이미지 목록 조회 - */ + private List getProfileImages(Long chatRoomId) { - // TODO: User 엔티티에 profileImage 필드 추가 후 주석 해제 - return Collections.emptyList(); - - /* - List roomUsers = chatRoomUserRepository - .findAllByChatRoomId(chatRoomId); - - return roomUsers.stream() - .map(ChatRoomUser::getUser) - .map(User::getProfileImage) - .filter(StringUtils::hasText) // null, 빈 문자열 필터링 - .limit(4) - .collect(Collectors.toList()); - */ + List roomUsers = chatRoomUserRepository + .findAllByChatRoomId(chatRoomId); + + return roomUsers.stream() + .map(ChatRoomUser::getUser) + .map(User::getProfileImageUrl) + .filter(StringUtils::hasText) + .limit(4) + .collect(Collectors.toList()); } private boolean checkHasNewMessage(Long chatRoomId, Long userId, ChatMessage lastMessage) { diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatService.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatService.java index b622502f..0abbf0fd 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatService.java @@ -180,6 +180,7 @@ public ChatNoticeResponseDto createNotice(Long messageId, Boolean isPinned,Long return ChatConverter.toNoticeResponseDTO(message); } + @Transactional(readOnly = true) public ChatMessageSearchResponseDto searchMessages( Long roomId, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/TeamChatService.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/TeamChatService.java index bf4f05c0..6f5fba48 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/TeamChatService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/TeamChatService.java @@ -49,57 +49,7 @@ public List getProjectMembers(Long projectId) { } - // 1:1 채팅방 생성 - @Transactional - public ChatRoomResponseDto createOneOnOneChatRoom(Long currentUserId, ChatRoomCreateRequestDto request) { - - - boolean isMeInProject = projectUserRepository.existsByProjectIdAndUserId(request.getProject_id(), currentUserId); - boolean isTargetInProject = projectUserRepository.existsByProjectIdAndUserId(request.getProject_id(), request.getTarget_user_id()); - - if (!isMeInProject || !isTargetInProject) { - throw new ChatException(ChatErrorCode.CHAT_MEMBER_NOT_FOUND, "서로 같은 팀원이 아닙니다."); - } - - Project project = projectRepository.findById(request.getProject_id()) - .orElseThrow(() -> new RuntimeException("프로젝트를 찾을 수 없습니다.")); - - User me = userRepository.findById(currentUserId) - .orElseThrow(() -> new ChatException(ChatErrorCode.USER_NOT_FOUND, "현재 사용자를 찾을 수 없습니다.")); - - User targetUser = userRepository.findById(request.getTarget_user_id()) - .orElseThrow(() -> new ChatException(ChatErrorCode.USER_NOT_FOUND, "상대방 사용자를 찾을 수 없습니다.")); - - - Optional existingRoomId = chatRoomRepository.findExistingOneOnOneRoomId( - request.getProject_id(), - currentUserId, - request.getTarget_user_id() - ); - - if (existingRoomId.isPresent()) { - ChatRoom existingRoom = chatRoomRepository.findById(existingRoomId.get()) - .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND, "존재하는 채팅방 ID를 찾을 수 없습니다.")); - - return ChatConverter.toResponseDTO(existingRoom, targetUser); - } - - - ChatRoom chatRoom = ChatConverter.toChatRoomEntity( - project, - null, - ChatRoomType.DIRECT - ); - chatRoomRepository.save(chatRoom); - - ChatRoomUser myMember = ChatConverter.toChatRoomMemberEntity(chatRoom, me, LocalDateTime.now()); - ChatRoomUser targetMember = ChatConverter.toChatRoomMemberEntity(chatRoom, targetUser, null); - - chatRoomUserRepository.saveAll(List.of(myMember, targetMember)); - - return ChatConverter.toResponseDTO(chatRoom, targetUser); - } // 팀 채팅방 생성 @Transactional @@ -163,7 +113,14 @@ public ChatRoomResponseDto createGroupChatRoom(Long currentUserId, GroupChatRoom chatRoomUserRepository.saveAll(members); - return ChatConverter.toResponseDTO(chatRoom, null); + List profileImages = members.stream() + .map(member -> member.getUser().getProfileImageUrl()) + .filter(StringUtils::hasText) + .limit(4) + .collect(Collectors.toList()); + + return ChatConverter.toResponseDTO(chatRoom, profileImages); + } @Transactional @@ -240,10 +197,15 @@ public ChatRoomInviteResponseDto inviteMembers( .map(User::getNickname) .collect(Collectors.toList()); + List profileImages = newMembers.stream() + .map(User::getProfileImageUrl) + .collect(Collectors.toList()); + return ChatRoomInviteResponseDto.builder() .roomId(roomId) .invitedCount(newMembers.size()) .invitedUserNames(invitedUserNames) + .profileImages(profileImages) .build(); } @@ -289,7 +251,7 @@ public List getProjectMembers(Long projectId, Long currentUser .userId(user.getUserId()) .nickname(user.getNickname()) .name(user.getName()) - .profileImage("/images/default-profile.png") + .profileImage(user.getProfileImageUrl()) .build()) .collect(Collectors.toList()); } diff --git a/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatControllerTest.java b/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatControllerTest.java index a44a1e64..201f7320 100644 --- a/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatControllerTest.java @@ -202,8 +202,13 @@ void createGroupChatRoom() throws Exception { ChatRoomResponseDto response = ChatRoomResponseDto.builder() .roomId(10L) + .projectId(1L) .roomName("개발팀 채팅방") .roomType(ChatRoomType.GROUP) + .profileImages(Arrays.asList( + "https://example.com/profile1.jpg", + "https://example.com/profile2.jpg" + )) .createdAt(LocalDateTime.now()) .build(); @@ -243,7 +248,7 @@ void createGroupChatRoom() throws Exception { fieldWithPath("body.room_name").description("채팅방 이름"), fieldWithPath("body.room_type").description("채팅방 타입 (GROUP)"), fieldWithPath("body.project_id").description("프로젝트 ID").optional(), - fieldWithPath("body.profile_image").description("프로필 이미지").optional(), + fieldWithPath("body.profile_images").description("참여자 프로필 이미지 목록 (최대 4개)").optional(), fieldWithPath("body.created_at").description("생성 시간") ) .build() @@ -603,7 +608,11 @@ void inviteMembers() throws Exception { ChatRoomInviteResponseDto response = new ChatRoomInviteResponseDto( roomId, 2, - Arrays.asList("새멤버1", "새멤버2") + Arrays.asList("새멤버1", "새멤버2"), + Arrays.asList( + "https://example.com/profile5.jpg", + "https://example.com/profile6.jpg" + ) ); given(teamChatService.inviteMembers(eq(roomId), eq(1L), any(ChatRoomInviteRequestDto.class))) @@ -640,7 +649,8 @@ void inviteMembers() throws Exception { fieldWithPath("status.description").description("상세 설명").optional(), fieldWithPath("body.room_id").description("채팅방 ID"), fieldWithPath("body.invited_count").description("실제 초대된 인원 수"), - fieldWithPath("body.invited_user_names").description("초대된 사용자 이름 목록") + fieldWithPath("body.invited_user_names").description("초대된 사용자 이름 목록"), + fieldWithPath("body.profile_images").description("초대된 사용자 프로필 이미지 목록") ) .build() ) diff --git a/nect-core/src/main/java/com/nect/core/repository/team/chat/ChatRoomUserRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/chat/ChatRoomUserRepository.java index 403945cc..feb82689 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/chat/ChatRoomUserRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/chat/ChatRoomUserRepository.java @@ -24,6 +24,10 @@ public interface ChatRoomUserRepository extends JpaRepository List findAllByUserUserId(Long userId); + @Query("SELECT cru FROM ChatRoomUser cru " + + "JOIN FETCH cru.user " + + "WHERE cru.chatRoom.id = :chatRoomId") + List findAllByChatRoomId(@Param("chatRoomId") Long chatRoomId); @Query("select cru from ChatRoomUser cru " + "join fetch cru.chatRoom " + From 2b3392e3f8a64e53de3e45648d975e5a86863ea5 Mon Sep 17 00:00:00 2001 From: Juunbro Date: Fri, 6 Feb 2026 14:51:09 +0900 Subject: [PATCH 22/66] =?UTF-8?q?[Identity]=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EB=B6=84=EC=84=9D=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: 프로필 분석 * Fix: 회원가입 바디에 토큰 추가 * 마이페이지에 프로필 분석 추가 및 불러오기 api 수정 * Feat: 프로필 분석 삭제 기획 변경에 따른 수정 --- .../mypage/controller/MypageController.java | 11 + .../domain/mypage/dto/ProfileSettingsDto.java | 9 +- .../domain/mypage/service/MypageService.java | 51 +++- .../user/controller/UserController.java | 50 +++- .../domain/user/dto/ProfileAnalysisDto.java | 68 +++++ .../api/domain/user/enums/UserErrorCode.java | 5 +- .../UserProfileAnalysisNotFound.java | 14 + .../UserProfileAnalysisParsingFailed.java | 14 + .../UserProfileAnalysisSaveFailed.java | 14 + .../ProfileAnalysisMatchingService.java | 198 +++++++++++++ .../api/domain/user/service/UserService.java | 166 ++++++++++- .../ai/dto/OnboardingAnalysisScheme.java | 86 ++++++ .../OnboardingAnalysisServiceImpl.java | 11 +- .../resources/prompts/onboarding-analysis.txt | 143 ++++++++- .../controller/MypageControllerTest.java | 40 ++- .../user/controller/UserControllerTest.java | 278 +++++++++++++++++- .../core/entity/user/UserProfileAnalysis.java | 49 +++ .../user/UserProfileAnalysisRepository.java | 11 + 18 files changed, 1186 insertions(+), 32 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/user/dto/ProfileAnalysisDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/user/exception/UserProfileAnalysisNotFound.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/user/exception/UserProfileAnalysisParsingFailed.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/user/exception/UserProfileAnalysisSaveFailed.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/user/service/ProfileAnalysisMatchingService.java create mode 100644 nect-core/src/main/java/com/nect/core/entity/user/UserProfileAnalysis.java create mode 100644 nect-core/src/main/java/com/nect/core/repository/user/UserProfileAnalysisRepository.java diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java index 438277d4..190c2f02 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java @@ -1,5 +1,6 @@ package com.nect.api.domain.mypage.controller; +import com.nect.api.domain.mypage.dto.ProfileSettingsDto; import com.nect.api.domain.mypage.dto.ProfileSettingsDto.*; import com.nect.api.domain.mypage.service.MypageService; import com.nect.api.global.response.ApiResponse; @@ -36,4 +37,14 @@ public ApiResponse updateProfile( mypageService.updateProfile(userDetails.getUserId(), request); return ApiResponse.ok(); } + + /** + * 프로필 분석 불러오기 + */ + @GetMapping("/profile-analysis") + public ApiResponse getProfileAnalysis( + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok(mypageService.getProfileAnalysis(userDetails.getUserId())); + } } \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/dto/ProfileSettingsDto.java b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/ProfileSettingsDto.java index 1b832415..0db21cf7 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/dto/ProfileSettingsDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/ProfileSettingsDto.java @@ -22,7 +22,9 @@ public record ProfileSettingsResponseDto( List careers, List portfolios, List projectHistories, - List skills + List skills, + String profileType, + List tags ) {} public record ProfileSettingsRequestDto( @@ -83,4 +85,9 @@ public record SkillItemDto( String skillLabel, Boolean isSelected ) {} + + public record ProfileAnalysisResponseDto( + String profileType, + List tags + ) {} } \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/service/MypageService.java b/nect-api/src/main/java/com/nect/api/domain/mypage/service/MypageService.java index b57e8d3c..ec1ba983 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/service/MypageService.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/service/MypageService.java @@ -1,6 +1,7 @@ package com.nect.api.domain.mypage.service; +import com.nect.api.domain.mypage.dto.ProfileSettingsDto; import com.nect.api.domain.mypage.dto.ProfileSettingsDto.*; import com.nect.api.domain.mypage.exception.InvalidUserStatusException; import com.nect.api.domain.mypage.exception.UserNotFoundException; @@ -24,6 +25,8 @@ public class MypageService { private final UserPortfolioRepository userPortfolioRepository; private final UserProjectHistoryRepository userProjectHistoryRepository; private final UserSkillRepository userSkillRepository; + private final UserProfileAnalysisRepository userProfileAnalysisRepository; + private final com.fasterxml.jackson.databind.ObjectMapper objectMapper; @Transactional(readOnly = true) public ProfileSettingsResponseDto getProfile(Long userId) { @@ -99,6 +102,24 @@ public ProfileSettingsResponseDto getProfile(Long userId) { }) .collect(Collectors.toList()); + // 프로필 분석 정보 조회 + String profileType = null; + List tags = null; + + var profileAnalysis = userProfileAnalysisRepository.findByUser(user); + if (profileAnalysis.isPresent()) { + profileType = profileAnalysis.get().getProfileType(); + if (profileAnalysis.get().getTags() != null) { + try { + tags = objectMapper.readValue(profileAnalysis.get().getTags(), + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)); + } catch (Exception e) { + // JSON 파싱 실패 시 null + tags = null; + } + } + } + return new ProfileSettingsResponseDto( user.getUserId(), user.getName(), @@ -116,7 +137,9 @@ public ProfileSettingsResponseDto getProfile(Long userId) { careerDto, portfolioDto, projectHistoryDto, - skillDto + skillDto, + profileType, + tags ); } @@ -217,6 +240,32 @@ public void updateProfile(Long userId, ProfileSettingsRequestDto request) { } } + @Transactional(readOnly = true) + public ProfileSettingsDto.ProfileAnalysisResponseDto getProfileAnalysis(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다.")); + + var profileAnalysis = userProfileAnalysisRepository.findByUser(user); + + if (profileAnalysis.isEmpty()) { + return new ProfileSettingsDto.ProfileAnalysisResponseDto(null, null); + } + + String profileType = profileAnalysis.get().getProfileType(); + List tags = null; + + if (profileAnalysis.get().getTags() != null) { + try { + tags = objectMapper.readValue(profileAnalysis.get().getTags(), + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)); + } catch (Exception e) { + tags = null; + } + } + + return new ProfileSettingsDto.ProfileAnalysisResponseDto(profileType, tags); + } + private UserStatus parseUserStatus(String userStatusStr) { try { return UserStatus.valueOf(userStatusStr.toUpperCase()); diff --git a/nect-api/src/main/java/com/nect/api/domain/user/controller/UserController.java b/nect-api/src/main/java/com/nect/api/domain/user/controller/UserController.java index e6230f14..b96817cc 100644 --- a/nect-api/src/main/java/com/nect/api/domain/user/controller/UserController.java +++ b/nect-api/src/main/java/com/nect/api/domain/user/controller/UserController.java @@ -1,15 +1,13 @@ package com.nect.api.domain.user.controller; -import com.nect.api.domain.user.dto.AgreeDto; -import com.nect.api.domain.user.dto.DuplicateCheckDto; -import com.nect.api.domain.user.dto.LoginDto; -import com.nect.api.domain.user.dto.ProfileDto; -import com.nect.api.domain.user.dto.SignUpDto; +import com.nect.api.domain.user.dto.*; import com.nect.api.domain.user.service.UserService; import com.nect.api.global.response.ApiResponse; import com.nect.api.global.security.UserDetailsImpl; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -53,11 +51,11 @@ public ApiResponse checkDuplicate( } @PostMapping("/signup") - public ApiResponse signUp( + public ApiResponse signUp( @RequestBody SignUpDto.SignUpRequestDto request ) { - userService.signUp(request); - return ApiResponse.ok(); + LoginDto.TokenResponseDto response = userService.signUp(request); + return ApiResponse.ok(response); } @PostMapping("/login") @@ -101,4 +99,40 @@ public ApiResponse getUserInfo( ProfileDto.UserInfoResponseDto response = userService.getUserInfo(userDetails.getUserId()); return ApiResponse.ok(response); } + + @GetMapping("/profile/analysis") + public ApiResponse analyzeProfile( + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + ProfileAnalysisDto response = userService.analyzeProfile(userDetails.getUserId()); + return ApiResponse.ok(response); + } + + @GetMapping("/profile/analysis/projects") + public ApiResponse> getRecommendedProjects( + @AuthenticationPrincipal UserDetailsImpl userDetails, + Pageable pageable + ) { + ProfileAnalysisDto.PaginatedResponse projects = + userService.getRecommendedProjects(userDetails.getUserId(), pageable); + return ApiResponse.ok(projects); + } + + @GetMapping("/profile/analysis/team-members") + public ApiResponse> getRecommendedTeamMembers( + @AuthenticationPrincipal UserDetailsImpl userDetails, + Pageable pageable + ) { + ProfileAnalysisDto.PaginatedResponse teamMembers = + userService.getRecommendedTeamMembers(userDetails.getUserId(), pageable); + return ApiResponse.ok(teamMembers); + } + + @DeleteMapping("/profile/analysis") + public ApiResponse deleteProfileAnalysis( + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + userService.deleteProfileAnalysis(userDetails.getUserId()); + return ApiResponse.ok(); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/user/dto/ProfileAnalysisDto.java b/nect-api/src/main/java/com/nect/api/domain/user/dto/ProfileAnalysisDto.java new file mode 100644 index 00000000..a3de31f2 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/user/dto/ProfileAnalysisDto.java @@ -0,0 +1,68 @@ +package com.nect.api.domain.user.dto; + +import com.nect.api.global.ai.dto.OnboardingAnalysisScheme; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +public class ProfileAnalysisDto { + + // AI 분석 결과 + private String profileType; + private List tags; + private OnboardingAnalysisScheme.CollaborationStyle collaborationStyle; + private List skills; + private OnboardingAnalysisScheme.RoleRecommendation roleRecommendation; + private List growthGuide; + + // 추천 정보 + @Getter + @Builder + @AllArgsConstructor + public static class RecommendedProjectInfo { + private Long projectId; + private String projectTitle; + private String recruitmentPeriod; + private String recruitmentStatus; + private String description; + private List participantRoles; + } + + @Getter + @Builder + @AllArgsConstructor + public static class RecommendedTeamMemberInfo { + private Long userId; + private String nickname; + private String role; + private String bio; + private boolean matched; + } + + @Getter + @Builder + @AllArgsConstructor + public static class PaginatedResponse { + private List content; + private int pageNumber; + private int pageSize; + private long totalElements; + private int totalPages; + + public static PaginatedResponse from(Page page) { + return PaginatedResponse.builder() + .content(page.getContent()) + .pageNumber(page.getNumber()) + .pageSize(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .build(); + } + } +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/user/enums/UserErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/user/enums/UserErrorCode.java index 1dbbb751..c727a861 100644 --- a/nect-api/src/main/java/com/nect/api/domain/user/enums/UserErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/user/enums/UserErrorCode.java @@ -25,7 +25,10 @@ public enum UserErrorCode implements ResponseCode { INVALID_INTEREST_FIELD("U016", "관심분야 타입이 올바르지 않습니다"), INVALID_SKILL_CATEGORY("U017", "스킬 카테고리가 올바르지 않습니다"), INVALID_COLLABORATION_SCORE("U018", "협업 스타일 점수가 올바르지 않습니다"), - INVALID_NICKNAME_FORMAT("U019", "닉네임은 2글자 이상이어야 합니다"); + INVALID_NICKNAME_FORMAT("U019", "닉네임은 2글자 이상이어야 합니다"), + PROFILE_ANALYSIS_NOT_FOUND("U020", "프로필 분석 결과를 찾을 수 없습니다"), + PROFILE_ANALYSIS_PARSING_FAILED("U021", "프로필 분석 결과 파싱에 실패했습니다"), + PROFILE_ANALYSIS_SAVE_FAILED("U022", "프로필 분석 결과 저장에 실패했습니다"); private final String statusCode; private final String message; diff --git a/nect-api/src/main/java/com/nect/api/domain/user/exception/UserProfileAnalysisNotFound.java b/nect-api/src/main/java/com/nect/api/domain/user/exception/UserProfileAnalysisNotFound.java new file mode 100644 index 00000000..719e253d --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/user/exception/UserProfileAnalysisNotFound.java @@ -0,0 +1,14 @@ +package com.nect.api.domain.user.exception; + +import com.nect.api.domain.user.enums.UserErrorCode; +import com.nect.api.global.exception.CustomException; + +public class UserProfileAnalysisNotFound extends CustomException { + public UserProfileAnalysisNotFound() { + super(UserErrorCode.PROFILE_ANALYSIS_NOT_FOUND); + } + + public UserProfileAnalysisNotFound(String message) { + super(UserErrorCode.PROFILE_ANALYSIS_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/user/exception/UserProfileAnalysisParsingFailed.java b/nect-api/src/main/java/com/nect/api/domain/user/exception/UserProfileAnalysisParsingFailed.java new file mode 100644 index 00000000..21133809 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/user/exception/UserProfileAnalysisParsingFailed.java @@ -0,0 +1,14 @@ +package com.nect.api.domain.user.exception; + +import com.nect.api.domain.user.enums.UserErrorCode; +import com.nect.api.global.exception.CustomException; + +public class UserProfileAnalysisParsingFailed extends CustomException { + public UserProfileAnalysisParsingFailed() { + super(UserErrorCode.PROFILE_ANALYSIS_PARSING_FAILED); + } + + public UserProfileAnalysisParsingFailed(String message) { + super(UserErrorCode.PROFILE_ANALYSIS_PARSING_FAILED, message); + } +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/user/exception/UserProfileAnalysisSaveFailed.java b/nect-api/src/main/java/com/nect/api/domain/user/exception/UserProfileAnalysisSaveFailed.java new file mode 100644 index 00000000..68156a60 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/user/exception/UserProfileAnalysisSaveFailed.java @@ -0,0 +1,14 @@ +package com.nect.api.domain.user.exception; + +import com.nect.api.domain.user.enums.UserErrorCode; +import com.nect.api.global.exception.CustomException; + +public class UserProfileAnalysisSaveFailed extends CustomException { + public UserProfileAnalysisSaveFailed() { + super(UserErrorCode.PROFILE_ANALYSIS_SAVE_FAILED); + } + + public UserProfileAnalysisSaveFailed(String message) { + super(UserErrorCode.PROFILE_ANALYSIS_SAVE_FAILED, message); + } +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/user/service/ProfileAnalysisMatchingService.java b/nect-api/src/main/java/com/nect/api/domain/user/service/ProfileAnalysisMatchingService.java new file mode 100644 index 00000000..0f218a17 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/user/service/ProfileAnalysisMatchingService.java @@ -0,0 +1,198 @@ +package com.nect.api.domain.user.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.user.dto.ProfileAnalysisDto; +import com.nect.api.global.ai.dto.OnboardingAnalysisScheme; +import com.nect.core.entity.matching.Recruitment; +import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.ProjectUser; +import com.nect.core.entity.team.enums.RecruitmentStatus; +import com.nect.core.entity.user.User; +import com.nect.core.entity.user.UserProfileAnalysis; +import com.nect.core.repository.matching.RecruitmentRepository; +import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.user.UserProfileAnalysisRepository; +import com.nect.core.repository.user.UserRepository; +import com.nect.core.repository.user.UserSkillRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProfileAnalysisMatchingService { + + private final ProjectRepository projectRepository; + private final ProjectUserRepository projectUserRepository; + private final RecruitmentRepository recruitmentRepository; + private final UserRepository userRepository; + private final UserProfileAnalysisRepository userProfileAnalysisRepository; + private final UserSkillRepository userSkillRepository; + private final ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public Page matchProjects( + User user, + OnboardingAnalysisScheme analysisResult, + Pageable pageable) { + + try { + List availableProjects = projectRepository.findHomeProjects( + user.getUserId(), + RecruitmentStatus.OPEN + ); + + String userRole = user.getRole() != null ? user.getRole().toString() : null; + + List projects = availableProjects.stream() + .map(project -> { + List recruitmentRoles = extractProjectRecruitmentRoles(project); + String recruitmentPeriod = formatRecruitmentPeriod(project); + return ProfileAnalysisDto.RecommendedProjectInfo.builder() + .projectId(project.getId()) + .projectTitle(project.getTitle()) + .recruitmentPeriod(recruitmentPeriod) + .recruitmentStatus(project.getRecruitmentStatus().getStatus()) + .description(project.getDescription()) + .participantRoles(recruitmentRoles) + .build(); + }) + .sorted((p1, p2) -> { + // 사용자 역할과 모집 역할이 일치하는 프로젝트를 우선순위로 + boolean p1Match = userRole != null && p1.getParticipantRoles().contains(userRole); + boolean p2Match = userRole != null && p2.getParticipantRoles().contains(userRole); + + if (p1Match != p2Match) { + return Boolean.compare(p2Match, p1Match); // 일치하는 것을 먼저 + } + return 0; + }) + .collect(Collectors.toList()); + + int start = (int) pageable.getOffset(); + int end = Math.min((start + pageable.getPageSize()), projects.size()); + + List pageContent = + projects.subList(start, end); + + return new PageImpl<>(pageContent, pageable, projects.size()); + + } catch (Exception e) { + log.error("프로젝트 매칭 중 오류 발생: {}", e.getMessage()); + return new PageImpl<>(new ArrayList<>(), pageable, 0); + } + } + + private String formatRecruitmentPeriod(Project project) { + if (project.getPlannedEndedOn() == null) { + return "미정"; + } + + LocalDate today = LocalDate.now(); + LocalDate endDate = project.getPlannedEndedOn(); + + if (today.isAfter(endDate)) { + return "모집 완료"; + } + + long remainingDays = ChronoUnit.DAYS.between(today, endDate); + + if (remainingDays == 0) { + return "D-0"; + } + + return "D-" + remainingDays; + } + + private List extractProjectRecruitmentRoles(Project project) { + List recruitments = recruitmentRepository.findOpenFieldsByProject(project); + return recruitments.stream() + .map(recruitment -> { + if (recruitment.getField().toString().equals("CUSTOM")) { + return recruitment.getCustomField(); + } + return recruitment.getField().toString(); + }) + .distinct() + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public Page matchTeamMembers( + User user, + OnboardingAnalysisScheme analysisResult, + Pageable pageable) { + + try { + List teamMembers = userRepository.findAll().stream() + .filter(u -> !u.getUserId().equals(user.getUserId())) + .sorted((u1, u2) -> { + if (u1.getIsOnboardingCompleted() != u2.getIsOnboardingCompleted()) { + return Boolean.compare(u2.getIsOnboardingCompleted(), u1.getIsOnboardingCompleted()); + } + return u1.getNickname().compareTo(u2.getNickname()); + }) + .map(otherUser -> ProfileAnalysisDto.RecommendedTeamMemberInfo.builder() + .userId(otherUser.getUserId()) + .nickname(otherUser.getNickname()) + .role(otherUser.getRole() != null ? otherUser.getRole().toString() : "MEMBER") + .bio(otherUser.getBio()) + .matched(false) + .build() + ) + .collect(Collectors.toList()); + + int start = (int) pageable.getOffset(); + int end = Math.min((start + pageable.getPageSize()), teamMembers.size()); + + List pageContent = + teamMembers.subList(start, end); + + return new PageImpl<>(pageContent, pageable, teamMembers.size()); + + } catch (Exception e) { + log.error("팀원 매칭 중 오류 발생: {}", e.getMessage()); + return new PageImpl<>(new ArrayList<>(), pageable, 0); + } + } + + + public OnboardingAnalysisScheme parseProfileAnalysis(UserProfileAnalysis analysis) { + try { + OnboardingAnalysisScheme scheme = new OnboardingAnalysisScheme(); + scheme.profile_type = analysis.getProfileType(); + + if (analysis.getTags() != null) { + scheme.tags = objectMapper.readValue(analysis.getTags(), + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)); + } + + if (analysis.getCollaborationStyle() != null) { + scheme.collaboration_style = objectMapper.readValue(analysis.getCollaborationStyle(), + OnboardingAnalysisScheme.CollaborationStyle.class); + } + + if (analysis.getSkills() != null) { + scheme.skills = objectMapper.readValue(analysis.getSkills(), + objectMapper.getTypeFactory().constructCollectionType(List.class, OnboardingAnalysisScheme.SkillCategory.class)); + } + + return scheme; + } catch (Exception e) { + log.warn("프로필 분석 결과 파싱 실패: {}", e.getMessage()); + return null; + } + } +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/user/service/UserService.java b/nect-api/src/main/java/com/nect/api/domain/user/service/UserService.java index 3bfff649..8af8e5cc 100644 --- a/nect-api/src/main/java/com/nect/api/domain/user/service/UserService.java +++ b/nect-api/src/main/java/com/nect/api/domain/user/service/UserService.java @@ -1,14 +1,22 @@ package com.nect.api.domain.user.service; +import com.fasterxml.jackson.databind.ObjectMapper; import com.nect.api.domain.user.dto.*; import com.nect.api.domain.user.exception.*; +import com.nect.api.global.ai.dto.OnboardingAnalysisScheme; +import com.nect.api.global.ai.service.OnboardingAnalysisServiceImpl; import com.nect.api.global.jwt.JwtUtil; import com.nect.api.global.jwt.dto.TokenDataDto; import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; +import com.nect.client.openai.dto.OpenAiResponse; import com.nect.core.entity.user.*; import com.nect.core.entity.user.enums.*; import com.nect.core.repository.user.*; +import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.entity.user.UserProfileAnalysis; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -19,7 +27,9 @@ import org.springframework.web.context.request.ServletRequestAttributes; import java.time.LocalDate; +import java.util.List; import java.util.regex.Pattern; +import java.util.stream.Collectors; @Slf4j @Service @@ -31,9 +41,13 @@ public class UserService { private final UserRoleRepository userRoleRepository; private final UserSkillRepository userSkillRepository; private final UserInterestRepository userInterestRepository; + private final UserProfileAnalysisRepository userProfileAnalysisRepository; + private final ProjectRepository projectRepository; private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; private final TokenBlacklistService tokenBlacklistService; + private final OnboardingAnalysisServiceImpl onboardingAnalysisService; + private final ProfileAnalysisMatchingService profileAnalysisMatchingService; @Value("${app.auth.key}") private String authKey; @@ -94,7 +108,7 @@ public LoginDto.EmailResponseDto getEmailByUserId(Long userId) { } @Transactional - public void signUp(SignUpDto.SignUpRequestDto request) { + public LoginDto.TokenResponseDto signUp(SignUpDto.SignUpRequestDto request) { validateSignUpRequest(request); String encodedPassword = passwordEncoder.encode(request.password()); @@ -119,6 +133,15 @@ public void signUp(SignUpDto.SignUpRequestDto request) { .build(); userRepository.save(user); + + TokenDataDto tokenData = jwtUtil.createTokenData(user.getUserId()); + + return LoginDto.TokenResponseDto.of( + tokenData.getAccessToken(), + tokenData.getRefreshToken(), + tokenData.getAccessTokenExpiredAt(), + tokenData.getRefreshTokenExpiredAt() + ); } @Transactional @@ -502,9 +525,148 @@ public ProfileDto.UserInfoResponseDto getUserInfo(Long userId) { user.getEmail() ); } - + + @Transactional + public ProfileAnalysisDto analyzeProfile(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + ProfileDto.ProfileSetupRequestDto profileRequest = buildProfileRequestFromUser(user); + + OpenAiResponse openAiResponse = onboardingAnalysisService.analyzeProfile(profileRequest); + + String analysisJson = openAiResponse.getFirstOutputText(); + ObjectMapper objectMapper = new ObjectMapper(); + + OnboardingAnalysisScheme analysisResult; + try { + analysisResult = objectMapper.readValue(analysisJson, OnboardingAnalysisScheme.class); + } catch (Exception e) { + throw new RuntimeException("프로필 분석 결과 파싱 실패", e); + } + + saveProfileAnalysis(user, analysisResult, objectMapper); + + return ProfileAnalysisDto.builder() + .profileType(analysisResult.profile_type) + .tags(analysisResult.tags) + .collaborationStyle(analysisResult.collaboration_style) + .skills(analysisResult.skills) + .roleRecommendation(analysisResult.role_recommendation) + .growthGuide(analysisResult.growth_guide) + .build(); + } + + @Transactional(readOnly = true) + public ProfileAnalysisDto.PaginatedResponse getRecommendedProjects(Long userId, Pageable pageable) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + OnboardingAnalysisScheme analysisResult = getAnalysisResult(user); + Page page = profileAnalysisMatchingService.matchProjects(user, analysisResult, pageable); + return ProfileAnalysisDto.PaginatedResponse.from(page); + } + + @Transactional(readOnly = true) + public ProfileAnalysisDto.PaginatedResponse getRecommendedTeamMembers(Long userId, Pageable pageable) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + OnboardingAnalysisScheme analysisResult = getAnalysisResult(user); + Page page = profileAnalysisMatchingService.matchTeamMembers(user, analysisResult, pageable); + return ProfileAnalysisDto.PaginatedResponse.from(page); + } + + private OnboardingAnalysisScheme getAnalysisResult(User user) { + UserProfileAnalysis analysis = userProfileAnalysisRepository.findByUser(user) + .orElseThrow(UserProfileAnalysisNotFound::new); + + OnboardingAnalysisScheme analysisResult = profileAnalysisMatchingService.parseProfileAnalysis(analysis); + if (analysisResult == null) { + throw new UserProfileAnalysisParsingFailed(); + } + + return analysisResult; + } + + protected void saveProfileAnalysis(User user, OnboardingAnalysisScheme analysisResult, ObjectMapper objectMapper) { + try { + userProfileAnalysisRepository.findByUser(user) + .ifPresent(userProfileAnalysisRepository::delete); + + UserProfileAnalysis profileAnalysis = UserProfileAnalysis.builder() + .user(user) + .profileType(analysisResult.profile_type) + .tags(objectMapper.writeValueAsString(analysisResult.tags)) + .collaborationStyle(objectMapper.writeValueAsString(analysisResult.collaboration_style)) + .skills(objectMapper.writeValueAsString(analysisResult.skills)) + .roleRecommendation(objectMapper.writeValueAsString(analysisResult.role_recommendation)) + .growthGuide(objectMapper.writeValueAsString(analysisResult.growth_guide)) + .build(); + + userProfileAnalysisRepository.save(profileAnalysis); + } catch (Exception e) { + log.warn("프로필 분석 결과 저장 실패 (userId: {}): {}", user.getUserId(), e.getMessage()); + throw new UserProfileAnalysisSaveFailed(e.getMessage()); + } + } + + private ProfileDto.ProfileSetupRequestDto buildProfileRequestFromUser(User user) { + List fields = userRoleRepository.findByUser(user) + .stream() + .map(userRole -> new ProfileDto.FieldDto( + userRole.getRoleField().name(), + userRole.getCustomField() + )) + .collect(Collectors.toList()); + + List skills = userSkillRepository.findByUserUserId(user.getUserId()) + .stream() + .map(userSkill -> new ProfileDto.SkillDto( + userSkill.getSkillCategory(), + userSkill.getSkill().name(), + userSkill.getCustomSkillName() + )) + .collect(Collectors.toList()); + + List interests = userInterestRepository.findByUserUserId(user.getUserId()) + .stream() + .map(userInterest -> userInterest.getInterestField().name()) + .collect(Collectors.toList()); + + return new ProfileDto.ProfileSetupRequestDto( + user.getNickname(), + user.getBirthDate() != null ? user.getBirthDate().toString().replace("-", "") : null, + user.getJob() != null ? user.getJob().name() : null, + user.getRole() != null ? user.getRole().name() : null, + fields, + skills, + interests, + user.getFirstGoal() != null ? user.getFirstGoal().name() : null, + user.getCollaborationStylePlanning() != null ? + new ProfileDto.CollaborationStyleDto( + user.getCollaborationStylePlanning(), + user.getCollaborationStyleLogic(), + user.getCollaborationStyleLeadership() + ) : null + ); + } + public User getUser(Long userId){ return userRepository.findById(userId) .orElseThrow(UserNotFoundException::new); } + + @Transactional + public void deleteProfileAnalysis(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserProfileAnalysisNotFound("사용자를 찾을 수 없습니다.")); + + var profileAnalysis = userProfileAnalysisRepository.findByUser(user); + if (profileAnalysis.isPresent()) { + userProfileAnalysisRepository.delete(profileAnalysis.get()); + } else { + throw new UserProfileAnalysisNotFound("프로필 분석 결과를 찾을 수 없습니다."); + } + } } diff --git a/nect-api/src/main/java/com/nect/api/global/ai/dto/OnboardingAnalysisScheme.java b/nect-api/src/main/java/com/nect/api/global/ai/dto/OnboardingAnalysisScheme.java index a12055fd..62f4efaf 100644 --- a/nect-api/src/main/java/com/nect/api/global/ai/dto/OnboardingAnalysisScheme.java +++ b/nect-api/src/main/java/com/nect/api/global/ai/dto/OnboardingAnalysisScheme.java @@ -1,4 +1,90 @@ package com.nect.api.global.ai.dto; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor public class OnboardingAnalysisScheme { + @JsonProperty("profile_type") + public String profile_type; + public List tags; + @JsonProperty("collaboration_style") + public CollaborationStyle collaboration_style; + public List skills; + @JsonProperty("role_recommendation") + public RoleRecommendation role_recommendation; + @JsonProperty("growth_guide") + public List growth_guide; + @JsonProperty("recommended_projects") + public List recommended_projects; + @JsonProperty("recommended_team_members") + public List recommended_team_members; + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class CollaborationStyle { + public Integer planning; + public Integer logic; + public Integer leadership; + public Integer empathy; + public Integer execution; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class SkillCategory { + public String category; + @JsonProperty("skill_names") + public List skill_names; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class RoleRecommendation { + public String leader; + @JsonProperty("team_member") + public String team_member; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class GrowthGuide { + public Integer order; + public String tip; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class RecommendedProject { + @JsonProperty("project_name") + public String project_name; + public String description; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class RecommendedTeamMember { + public String role; + public String characteristics; + public String synergy; + } } diff --git a/nect-api/src/main/java/com/nect/api/global/ai/service/OnboardingAnalysisServiceImpl.java b/nect-api/src/main/java/com/nect/api/global/ai/service/OnboardingAnalysisServiceImpl.java index 25beef1f..eacdd24b 100644 --- a/nect-api/src/main/java/com/nect/api/global/ai/service/OnboardingAnalysisServiceImpl.java +++ b/nect-api/src/main/java/com/nect/api/global/ai/service/OnboardingAnalysisServiceImpl.java @@ -1,14 +1,11 @@ package com.nect.api.global.ai.service; +import com.nect.api.domain.user.dto.ProfileDto; import com.nect.api.global.ai.dto.OnboardingAnalysisScheme; import com.nect.client.openai.OpenAiClient; import com.nect.client.openai.dto.OpenAiResponse; import org.springframework.stereotype.Service; -/** - * 온보딩 분석 요청을 OpenAI로 전달하고 - * 결과를 반환하는 서비스입니다. - */ @Service public class OnboardingAnalysisServiceImpl extends AbstractOpenAiAnalysisService { @@ -19,12 +16,10 @@ public OnboardingAnalysisServiceImpl(OpenAiClient openAiClient) { super(openAiClient); } - // 온보딩 분석 호출 - public OpenAiResponse analyze(OnboardingAnalysisScheme onboardingInput) { - return analyzeInternal(onboardingInput); + public OpenAiResponse analyzeProfile(ProfileDto.ProfileSetupRequestDto profileRequest) { + return analyzeInternal(profileRequest); } - // 텍스트 결과만 반환 public String analyzeText(OnboardingAnalysisScheme onboardingInput) { return analyzeTextInternal(onboardingInput); } diff --git a/nect-api/src/main/resources/prompts/onboarding-analysis.txt b/nect-api/src/main/resources/prompts/onboarding-analysis.txt index 8d985dff..3a5b83f7 100644 --- a/nect-api/src/main/resources/prompts/onboarding-analysis.txt +++ b/nect-api/src/main/resources/prompts/onboarding-analysis.txt @@ -1,3 +1,140 @@ -You are a data extraction assistant. -From the given onboarding info, produce a JSON object that matches the provided schema. -Fill fields conservatively. If a field is unknown, use null. Do not invent facts. +당신은 개인의 역량과 성향을 분석하는 전문가입니다. 사용자의 프로필 정보를 바탕으로 NECT 커뮤니티에 최적화된 분석 결과를 제공해주세요. + +# 사용자 프로필 정보 +- 닉네임: {nickname} +- 직업: {job} +- 역할: {role} +- 직종: {fields} +- 보유 스킬: {skills} +- 관심사: {interests} +- 첫 번째 목표: {firstGoal} +- 협업 스타일 점수 (1~5): + * 계획형: {collaborationStylePlanning} + * 논리형: {collaborationStyleLogic} + * 리더형: {collaborationStyleLeadership} + +# 분석 요구사항 + +**중요: 반드시 사용자 정보를 기반으로 실제 내용을 생성해주세요. 예시 값을 그대로 사용하지 마세요.** + +1. **타입 분류**: + - 사용자의 직무, 협업 스타일, 관심사, 목표를 종합적으로 고려하여 한국인이 이해하기 쉬운 타입을 지정하세요. + - 형식: "[역할명형 보조설명]" (예: "대인형 서포터", "기술 주도형 개발자", "창의형 디자이너") + - 타입은 사용자의 핵심 특성을 가장 잘 설명할 수 있는 짧은 표현(15글자 이내) + +2. **태그 3~5개**: + - 사용자의 직무, 스킬, 관심사, 성향을 반영하는 구체적인 태그를 생성하세요. + - 예: "#포트폴리오집중 #신중한설계자 #비주얼전문가" + - 각 태그는 한두 단어로 구성, 띄어쓰기 없이 작성 + +3. **협업 스타일 분석 (5개 차원)**: + - 입력된 3개 점수(계획형, 논리형, 리더형)를 기반으로 5개 차원의 점수를 분석하여 생성하세요. + - 차원: + * planning (계획형): 체계적이고 계획적으로 접근하는 성향 + * logic (논리형): 논리적이고 분석적인 사고 방식 + * leadership (리더형): 리더십과 주도성 + * empathy (공감형): 타인 이해와 감정 공감 능력 + * execution (실행형): 행동력과 실행 능력 + - 각 점수는 1~5점 범위로, 입력된 점수들과의 논리적 관계를 유지하면서도 전체적으로 조화로운 프로필이 되도록 작성 + - 예: 계획형 3, 논리형 4, 리더형 2인 경우 → empathy와 execution은 이를 보완할 수 있는 수준으로 + +4. **보유 스킬**: + - 입력된 스킬들을 그대로 반영하되, 카테고리별로 정리하세요. + - 형식: 각 카테고리 하에 보유 스킬을 리스트로 표현 + +5. **역할별 맞춤 추천**: + - **리더로서의 추천**: 팀 리더 또는 프로젝트 리더 역할에서 활약할 때 강점과 역할을 구체적으로 설명 + * 현재 협업 스타일 프로필을 고려하여 어떤 팀과 함께하면 좋을지 제시 + * 최소 2-3문장 + - **팀원으로서의 추천**: 일반 팀원으로서의 역할에서 어떻게 기여할 수 있는지 제시 + * 현재 보유 스킬과 역할을 고려하여 프로젝트 선택 시 중점사항 제시 + * 최소 2-3문장 + +6. **성장 가이드 (Tip 2개)**: + - Tip 1: 사용자의 현재 역할과 협업 스타일을 고려한 단기 성장 방안 (2-3문장) + - Tip 2: 강점을 극대화하고 부족한 영역을 보완할 수 있는 조언 (2-3문장) + - 예: "포트폴리오로 제작한 것들을 팀 프로젝트에서 실현할 기회를 적극 활용하세요. 다양한 협업 경험을 통해..." + +7. **NECT 추천 프로젝트 3개**: + - 사용자의 역할, 스킬, 관심사와 매칭되는 프로젝트를 구성하세요. + - 각 프로젝트마다: + * 프로젝트명 (구체적이고 실제 프로젝트처럼) + * 한줄 설명 + - 추천 이유를 암시적으로 포함 (예: 사용자가 보유한 스킬을 활용할 수 있는 프로젝트) + +8. **NECT 추천 팀원 3~4명**: + - 사용자와 협력할 때 보완적인 역할과 협업 스타일을 가진 팀원 프로필을 제시 + - 각 팀원마다: + * 팀원의 역할 (SERVICE, UI_UX, FRONTEND, BACKEND, IOS_ANDROID, DATA_ENGINEER, AI_MACHINE_LEARNING) + * 팀원의 특징 (한두 문장) + * 함께할 때의 시너지 (한 문장) + +# 응답 형식 + +**아래는 JSON 구조만 보여주는 스키마입니다. 실제 값은 사용자 정보에 맞게 생성하세요.** + +{ + "profile_type": "<사용자의 타입 분류>", + "tags": [ + "#태그1", + "#태그2", + "#태그3" + ], + "collaboration_style": { + "planning": <1~5>, + "logic": <1~5>, + "leadership": <1~5>, + "empathy": <1~5>, + "execution": <1~5> + }, + "skills": [ + { + "category": "<카테고리>", + "skill_names": ["스킬1", "스킬2"] + } + ], + "role_recommendation": { + "leader": "<리더로서의 추천 내용>", + "team_member": "<팀원으로서의 추천 내용>" + }, + "growth_guide": [ + { + "order": 1, + "tip": "<성장 가이드 Tip 1>" + }, + { + "order": 2, + "tip": "<성장 가이드 Tip 2>" + } + ], + "recommended_projects": [ + { + "project_name": "<프로젝트명1>", + "description": "<한줄 설명>" + }, + { + "project_name": "<프로젝트명2>", + "description": "<한줄 설명>" + }, + { + "project_name": "<프로젝트명3>", + "description": "<한줄 설명>" + } + ], + "recommended_team_members": [ + { + "role": "<역할코드>", + "characteristics": "<팀원의 특징>", + "synergy": "<함께할 때의 시너지>" + } + ] +} + +**필수 규칙**: +- profile_type은 한국인이 이해하기 쉬운 표현으로, 15글자 이내 +- collaboration_style의 모든 점수는 1~5 범위 +- 모든 내용은 사용자 정보를 기반으로 구체적으로 작성 +- "추천1", "팀원1" 같은 플레이스홀더를 절대 사용하지 마세요 +- role_recommendation은 각각 최소 2-3문장 +- growth_guide의 각 tip은 최소 2-3문장 +- role 필드는 위의 7개 역할 코드만 사용 (SERVICE, UI_UX, FRONTEND, BACKEND, IOS_ANDROID, DATA_ENGINEER, AI_MACHINE_LEARNING) diff --git a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java index fb1d0e9e..76eb1f04 100644 --- a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java @@ -9,6 +9,7 @@ import org.springframework.restdocs.payload.JsonFieldType; import java.util.ArrayList; +import java.util.List; import static com.epages.restdocs.apispec.ResourceDocumentation.resource; import static org.mockito.ArgumentMatchers.any; @@ -44,7 +45,9 @@ void getProfile() throws Exception { new ArrayList<>(), // careers new ArrayList<>(), // portfolios new ArrayList<>(), // projectHistories - new ArrayList<>() // skills + new ArrayList<>(), // skills + "기술 주도형 개발자", // profileType + List.of("#프로그래밍전문가", "#금융애플리케이션", "#백엔드개발자", "#효율적협업", "#기술적창의성") // tags ); given(mypageService.getProfile(1L)).willReturn(mockResponse); @@ -77,7 +80,9 @@ void getProfile() throws Exception { fieldWithPath("body.careers").type(JsonFieldType.ARRAY).description("경력 목록"), fieldWithPath("body.portfolios").type(JsonFieldType.ARRAY).description("포트폴리오 목록"), fieldWithPath("body.projectHistories").type(JsonFieldType.ARRAY).description("프로젝트 히스토리 목록"), - fieldWithPath("body.skills").type(JsonFieldType.ARRAY).description("스킬 목록") + fieldWithPath("body.skills").type(JsonFieldType.ARRAY).description("스킬 목록"), + fieldWithPath("body.profileType").type(JsonFieldType.STRING).description("AI 프로필 분석 타입 (예: 기술 주도형 개발자)").optional(), + fieldWithPath("body.tags").type(JsonFieldType.ARRAY).description("프로필 분석 키워드 태그 (예: #프로그래밍전문가, #백엔드개발자)").optional() ) .build() ) @@ -232,4 +237,35 @@ void updateProfile() throws Exception { ) )); } + + @Test + void getProfileAnalysis() throws Exception { + // given + ProfileSettingsDto.ProfileAnalysisResponseDto mockResponse = new ProfileSettingsDto.ProfileAnalysisResponseDto( + "기술 주도형 개발자", + List.of("#프로그래밍전문가", "#금융애플리케이션", "#백엔드개발자", "#효율적협업", "#기술적창의성") + ); + given(mypageService.getProfileAnalysis(1L)).willReturn(mockResponse); + + // when & then + this.mockMvc.perform(get("/api/v1/mypage/profile-analysis") + .header("Authorization", "Bearer mock-token")) + .andExpect(status().isOk()) + .andDo(document("mypage-get-profile-analysis", + resource( + ResourceSnippetParameters.builder() + .tag("mypage") + .summary("마이페이지 프로필 분석 불러오기") + .description("데이터베이스에 저장된 AI 프로필 분석 결과를 조회합니다. 분석 결과가 없으면 profileType과 tags는 null입니다.") + .responseFields( + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), + fieldWithPath("status.description").type(JsonFieldType.STRING).description("상태 설명").optional(), + fieldWithPath("body.profileType").type(JsonFieldType.STRING).description("AI 프로필 분석 타입 (예: 기술 주도형 개발자)").optional(), + fieldWithPath("body.tags").type(JsonFieldType.ARRAY).description("프로필 분석 키워드 태그 (예: #프로그래밍전문가, #백엔드개발자)").optional() + ) + .build() + ) + )); + } } \ No newline at end of file diff --git a/nect-api/src/test/java/com/nect/api/domain/user/controller/UserControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/user/controller/UserControllerTest.java index bbae3bbb..cc582bc1 100644 --- a/nect-api/src/test/java/com/nect/api/domain/user/controller/UserControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/user/controller/UserControllerTest.java @@ -5,26 +5,31 @@ import com.nect.api.domain.user.dto.AgreeDto; import com.nect.api.domain.user.dto.DuplicateCheckDto; import com.nect.api.domain.user.dto.LoginDto; +import com.nect.api.domain.user.dto.ProfileAnalysisDto; import com.nect.api.domain.user.dto.ProfileDto; import com.nect.api.domain.user.dto.SignUpDto; import com.nect.api.domain.user.enums.CheckType; +import com.nect.api.global.ai.dto.OnboardingAnalysisScheme; import com.nect.core.entity.user.enums.SkillCategory; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import java.time.LocalDate; import java.util.List; import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; -import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; @@ -69,7 +74,13 @@ void checkDuplicate() throws Exception { @Test void signUp() throws Exception { // given - doNothing().when(userService).signUp(any(SignUpDto.SignUpRequestDto.class)); + LoginDto.TokenResponseDto responseDto = LoginDto.TokenResponseDto.of( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + System.currentTimeMillis() + 3600000, + System.currentTimeMillis() + 86400000 + ); + when(userService.signUp(any(SignUpDto.SignUpRequestDto.class))).thenReturn(responseDto); // when this.mockMvc.perform(post("/api/v1/users/signup") @@ -87,7 +98,7 @@ void signUp() throws Exception { ResourceSnippetParameters.builder() .tag("users") .summary("회원가입") - .description("새로운 계정을 생성합니다. 닉네임, 생년월일, 직업, 역할 등은 프로필 설정 API에서 입력합니다.") + .description("새로운 계정을 생성합니다. 성공 시 액세스 토큰과 리프레시 토큰을 발급합니다. 닉네임, 생년월일, 직업, 역할 등은 프로필 설정 API에서 입력합니다.") .requestFields( fieldWithPath("email").type(JsonFieldType.STRING).description("이메일 (고유값, 이메일 형식)"), fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호 (최소 8자)"), @@ -98,7 +109,12 @@ void signUp() throws Exception { .responseFields( fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), - fieldWithPath("status.description").type(JsonFieldType.STRING).description("상태 설명").optional() + fieldWithPath("status.description").type(JsonFieldType.STRING).description("상태 설명").optional(), + fieldWithPath("body.grantType").type(JsonFieldType.STRING).description("토큰 타입 (Bearer)"), + fieldWithPath("body.accessToken").type(JsonFieldType.STRING).description("액세스 토큰 (API 요청 시 Authorization 헤더에 사용)"), + fieldWithPath("body.refreshToken").type(JsonFieldType.STRING).description("리프레시 토큰 (액세스 토큰 만료 시 갱신에 사용)"), + fieldWithPath("body.accessTokenExpiredAt").type(JsonFieldType.NUMBER).description("액세스 토큰 만료 시간 (Unix timestamp)"), + fieldWithPath("body.refreshTokenExpiredAt").type(JsonFieldType.NUMBER).description("리프레시 토큰 만료 시간 (Unix timestamp)") ) .build() ) @@ -434,4 +450,254 @@ void getUserInfo() throws Exception { ) )); } + + @Test + void analyzeProfile() throws Exception { + // given + OnboardingAnalysisScheme.CollaborationStyle style = new OnboardingAnalysisScheme.CollaborationStyle(); + style.planning = 3; + style.logic = 4; + style.leadership = 2; + style.empathy = 3; + style.execution = 4; + + OnboardingAnalysisScheme.SkillCategory skillCat = new OnboardingAnalysisScheme.SkillCategory(); + skillCat.category = "Design"; + skillCat.skill_names = List.of("Figma", "Photoshop"); + + OnboardingAnalysisScheme.RoleRecommendation roleRec = new OnboardingAnalysisScheme.RoleRecommendation(); + roleRec.leader = "설계 역할에서 팀을 주도할 수 있습니다."; + roleRec.team_member = "디자인 팀원으로 세부사항에 집중하세요."; + + OnboardingAnalysisScheme.GrowthGuide guide = new OnboardingAnalysisScheme.GrowthGuide(); + guide.order = 1; + guide.tip = "포트폴리오를 꾸준히 업데이트하세요."; + + ProfileAnalysisDto responseDto = ProfileAnalysisDto.builder() + .profileType("창의형 디자이너") + .tags(List.of("#포트폴리오집중", "#신중한설계자")) + .collaborationStyle(style) + .skills(List.of(skillCat)) + .roleRecommendation(roleRec) + .growthGuide(List.of(guide)) + .build(); + + when(userService.analyzeProfile(anyLong())).thenReturn(responseDto); + + // when + this.mockMvc.perform(get("/api/v1/users/profile/analysis") + .contentType("application/json") + .header("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")) + .andExpect(status().isOk()) + .andDo(document("profile-analysis", + resource( + ResourceSnippetParameters.builder() + .tag("users") + .summary("프로필 AI 분석") + .description("사용자의 프로필 정보를 AI로 분석합니다. 타입, 태그, 협업스타일(5개 차원), 스킬, 역할별 추천, 성장가이드를 제공합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .responseFields( + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), + fieldWithPath("status.description").type(JsonFieldType.STRING).description("상태 설명").optional(), + fieldWithPath("body").type(JsonFieldType.OBJECT).description("응답 본문"), + fieldWithPath("body.profileType").type(JsonFieldType.STRING).description("사용자 타입"), + fieldWithPath("body.tags").type(JsonFieldType.ARRAY).description("사용자 특성 태그"), + fieldWithPath("body.collaborationStyle.planning").type(JsonFieldType.NUMBER).description("협업스타일 - 계획형 (1-5)"), + fieldWithPath("body.collaborationStyle.logic").type(JsonFieldType.NUMBER).description("협업스타일 - 논리형 (1-5)"), + fieldWithPath("body.collaborationStyle.leadership").type(JsonFieldType.NUMBER).description("협업스타일 - 리더형 (1-5)"), + fieldWithPath("body.collaborationStyle.empathy").type(JsonFieldType.NUMBER).description("협업스타일 - 공감형 (1-5)"), + fieldWithPath("body.collaborationStyle.execution").type(JsonFieldType.NUMBER).description("협업스타일 - 실행형 (1-5)"), + fieldWithPath("body.skills[].category").type(JsonFieldType.STRING).description("스킬 카테고리"), + fieldWithPath("body.skills[].skill_names").type(JsonFieldType.ARRAY).description("보유 스킬"), + fieldWithPath("body.roleRecommendation.leader").type(JsonFieldType.STRING).description("리더로서의 역할 추천"), + fieldWithPath("body.roleRecommendation.team_member").type(JsonFieldType.STRING).description("팀원으로서의 역할 추천"), + fieldWithPath("body.growthGuide[].order").type(JsonFieldType.NUMBER).description("성장가이드 순서"), + fieldWithPath("body.growthGuide[].tip").type(JsonFieldType.STRING).description("성장 팁") + ) + .build() + ) + )); + } + + @Test + void getRecommendedProjects() throws Exception { + // given + List projects = List.of( + ProfileAnalysisDto.RecommendedProjectInfo.builder() + .projectId(1L) + .projectTitle("Nect 사용자 매칭 시스템") + .recruitmentPeriod("D-30") + .recruitmentStatus("모집 중") + .description("AI 기반 사용자 매칭 플랫폼") + .participantRoles(List.of("FRONTEND", "BACKEND", "UI_UX")) + .build(), + ProfileAnalysisDto.RecommendedProjectInfo.builder() + .projectId(2L) + .projectTitle("팀 협업 관리 도구") + .recruitmentPeriod("D-55") + .recruitmentStatus("모집 중") + .description("실시간 팀 협업 플랫폼") + .participantRoles(List.of("FRONTEND", "BACKEND")) + .build(), + ProfileAnalysisDto.RecommendedProjectInfo.builder() + .projectId(3L) + .projectTitle("포트폴리오 플랫폼") + .recruitmentPeriod("모집 완료") + .recruitmentStatus("모집 종료") + .description("사용자 포트폴리오 관리 시스템") + .participantRoles(List.of("UI_UX")) + .build() + ); + + Pageable pageable = PageRequest.of(0, 3); + Page page = new PageImpl<>(projects, pageable, projects.size()); + ProfileAnalysisDto.PaginatedResponse response = ProfileAnalysisDto.PaginatedResponse.from(page); + + when(userService.getRecommendedProjects(anyLong(), any(Pageable.class))).thenReturn(response); + + // when + this.mockMvc.perform(get("/api/v1/users/profile/analysis/projects") + .param("page", "0") + .param("size", "3") + .contentType("application/json") + .header("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")) + .andExpect(status().isOk()) + .andDo(document("recommended-projects", + resource( + ResourceSnippetParameters.builder() + .tag("users") + .summary("추천 프로젝트 조회") + .description("사용자 프로필에 맞는 추천 프로젝트를 페이징으로 조회합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .queryParameters( + parameterWithName("page").description("페이지 번호 (0부터 시작)"), + parameterWithName("size").description("페이지 크기 (몇개씩 출력할껀지)") + ) + .responseFields( + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), + fieldWithPath("status.description").type(JsonFieldType.STRING).description("상태 설명").optional(), + fieldWithPath("body").type(JsonFieldType.OBJECT).description("응답 본문"), + fieldWithPath("body.content[].projectId").type(JsonFieldType.NUMBER).description("프로젝트 ID"), + fieldWithPath("body.content[].projectTitle").type(JsonFieldType.STRING).description("프로젝트명"), + fieldWithPath("body.content[].recruitmentPeriod").type(JsonFieldType.STRING).description("모집 남은 기간 (예: D-30, 모집 완료)"), + fieldWithPath("body.content[].recruitmentStatus").type(JsonFieldType.STRING).description("모집 상태 (모집 중, 모집 예정, 모집 종료)"), + fieldWithPath("body.content[].description").type(JsonFieldType.STRING).description("프로젝트 설명"), + fieldWithPath("body.content[].participantRoles").type(JsonFieldType.ARRAY).description("프로젝트가 모집 중인 역할 (FRONTEND, BACKEND, UI_UX 등 - capacity > 0인 역할만)"), + fieldWithPath("body.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("body.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("body.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("body.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수") + ) + .build() + ) + )); + } + + @Test + void getRecommendedTeamMembers() throws Exception { + // given + List teamMembers = List.of( + ProfileAnalysisDto.RecommendedTeamMemberInfo.builder() + .userId(2L) + .nickname("이방토니") + .role("DESIGNER") + .bio("UI/UX 디자인에 능한 디자이너입니다") + .matched(false) + .build(), + ProfileAnalysisDto.RecommendedTeamMemberInfo.builder() + .userId(3L) + .nickname("김웹개발") + .role("DEVELOPER") + .bio("React 기반의 프론트엔드 개발자입니다") + .matched(false) + .build(), + ProfileAnalysisDto.RecommendedTeamMemberInfo.builder() + .userId(4L) + .nickname("박서버") + .role("DEVELOPER") + .bio("Spring Boot 기반 백엔드 개발에 경험 많습니다") + .matched(false) + .build() + ); + + Pageable pageable = PageRequest.of(0, 3); + Page page = new PageImpl<>(teamMembers, pageable, 7); + ProfileAnalysisDto.PaginatedResponse response = ProfileAnalysisDto.PaginatedResponse.from(page); + + when(userService.getRecommendedTeamMembers(anyLong(), any(Pageable.class))).thenReturn(response); + + // when + this.mockMvc.perform(get("/api/v1/users/profile/analysis/team-members") + .param("page", "0") + .param("size", "3") + .contentType("application/json") + .header("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")) + .andExpect(status().isOk()) + .andDo(document("recommended-team-members", + resource( + ResourceSnippetParameters.builder() + .tag("users") + .summary("추천 팀원 조회") + .description("사용자와 시너지가 좋은 팀원을 페이징으로 조회합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .queryParameters( + parameterWithName("page").description("페이지 번호 (0부터 시작)"), + parameterWithName("size").description("페이지 크기 (몇개씩 출력할껀지)") + ) + .responseFields( + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), + fieldWithPath("status.description").type(JsonFieldType.STRING).description("상태 설명").optional(), + fieldWithPath("body").type(JsonFieldType.OBJECT).description("응답 본문"), + fieldWithPath("body.content[].userId").type(JsonFieldType.NUMBER).description("사용자 ID"), + fieldWithPath("body.content[].nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("body.content[].role").type(JsonFieldType.STRING).description("사용자 역할 (DESIGNER, DEVELOPER, PM 등)"), + fieldWithPath("body.content[].bio").type(JsonFieldType.STRING).description("사용자 자기소개").optional(), + fieldWithPath("body.content[].matched").type(JsonFieldType.BOOLEAN).description("이미 매칭 여부"), + fieldWithPath("body.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("body.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("body.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("body.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수") + ) + .build() + ) + )); + } + + @Test + void deleteProfileAnalysis() throws Exception { + // given + doNothing().when(userService).deleteProfileAnalysis(1L); + + // when & then + this.mockMvc.perform(delete("/api/v1/users/profile/analysis") + .header("Authorization", "Bearer mock-token")) + .andExpect(status().isOk()) + .andDo(document("delete-profile-analysis", + resource( + ResourceSnippetParameters.builder() + .tag("users") + .summary("프로필 분석 삭제") + .description("사용자의 프로필 분석 결과를 삭제합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .responseFields( + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), + fieldWithPath("status.description").type(JsonFieldType.STRING).description("상태 설명").optional(), + fieldWithPath("body").type(JsonFieldType.NULL).description("응답 본문 (null)").optional() + ) + .build() + ) + )); + } } diff --git a/nect-core/src/main/java/com/nect/core/entity/user/UserProfileAnalysis.java b/nect-core/src/main/java/com/nect/core/entity/user/UserProfileAnalysis.java new file mode 100644 index 00000000..50067586 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/entity/user/UserProfileAnalysis.java @@ -0,0 +1,49 @@ +package com.nect.core.entity.user; + +import com.nect.core.entity.BaseEntity; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.Type; + +@Entity +@Table(name = "user_profile_analysis", indexes = { + @Index(name = "idx_user_id", columnList = "user_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class UserProfileAnalysis extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 50) + private String profileType; + + @Type(JsonType.class) + @Column(columnDefinition = "JSON") + private String tags; + + @Type(JsonType.class) + @Column(columnDefinition = "JSON") + private String collaborationStyle; + + @Type(JsonType.class) + @Column(columnDefinition = "JSON") + private String skills; + + @Type(JsonType.class) + @Column(columnDefinition = "JSON", length = 2000) + private String roleRecommendation; + + @Type(JsonType.class) + @Column(columnDefinition = "JSON") + private String growthGuide; +} \ No newline at end of file diff --git a/nect-core/src/main/java/com/nect/core/repository/user/UserProfileAnalysisRepository.java b/nect-core/src/main/java/com/nect/core/repository/user/UserProfileAnalysisRepository.java new file mode 100644 index 00000000..d124fd88 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/repository/user/UserProfileAnalysisRepository.java @@ -0,0 +1,11 @@ +package com.nect.core.repository.user; + +import com.nect.core.entity.user.User; +import com.nect.core.entity.user.UserProfileAnalysis; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserProfileAnalysisRepository extends JpaRepository { + Optional findByUser(User user); +} \ No newline at end of file From c3abfd3a62c53ff3096d628705b6c7b18851243e Mon Sep 17 00:00:00 2001 From: kjunh972 Date: Fri, 6 Feb 2026 19:10:05 +0900 Subject: [PATCH 23/66] =?UTF-8?q?Feat:=20cors=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/nect/api/global/config/WebConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nect-api/src/main/java/com/nect/api/global/config/WebConfig.java b/nect-api/src/main/java/com/nect/api/global/config/WebConfig.java index dddfe153..7f10379c 100644 --- a/nect-api/src/main/java/com/nect/api/global/config/WebConfig.java +++ b/nect-api/src/main/java/com/nect/api/global/config/WebConfig.java @@ -16,7 +16,8 @@ public void addCorsMappings(CorsRegistry registry) { .allowedOrigins( "http://localhost:5173", "http://localhost:8080", - "https://getnect.tech" + "https://getnect.tech", + "https://umc-nect.netlify.app" ) .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") .allowedHeaders("*") From 4d7e2878e19c21f3147f8941d65bef2bbb0d0369 Mon Sep 17 00:00:00 2001 From: Junyong <93406666+ggamnunq@users.noreply.github.com> Date: Sat, 7 Feb 2026 02:45:14 +0900 Subject: [PATCH 24/66] =?UTF-8?q?[Feat]=20=ED=99=88=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80/=EA=B0=9C=EC=84=A0=20+?= =?UTF-8?q?=20=EC=9C=A0=EC=A0=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Feat] 홈화면 헤더 프로필 조회 API 구현 * [Feat] 홈화면 매칭 가능한 넥터 필터링 추가 * [Refactor] 알림 조회 API 요청 파라미터 변경 scope -> filter * [Feat] 개인 채팅 기능 추가 * [Feat] 홈 api 보류 필드 추가 * [Refactor] 채팅방 조회 로직 개선 * [Refactor] 홈 API 로직 개선 * [Test] 홈, DM 테스트 코드 작성 * [Test] Chat 테스트코드 수정 * [Refactor] 유저 이미지 반환 방식 변경 * [Fix] 모집중인 프로젝트 npe 제거 --- build.gradle | 1 + .../controller/DirectMessageController.java | 35 ++ .../dm/controller/DmQueryController.java | 43 ++ .../api/domain/dm/dto/DirectMessageDto.java | 48 ++ .../domain/dm/dto/DmMessageListResponse.java | 15 + .../dm/dto/DmMessageSendRequestDto.java | 15 + .../api/domain/dm/dto/DmRoomListResponse.java | 12 + .../api/domain/dm/dto/DmRoomSummaryDto.java | 56 ++ .../dm/infra/DmRedisMessageHandler.java | 30 + .../api/domain/dm/infra/DmRedisPublisher.java | 18 + .../domain/dm/service/DmPresenceRegistry.java | 38 ++ .../nect/api/domain/dm/service/DmService.java | 149 +++++ .../home/controller/HomeController.java | 47 +- .../domain/home/dto/HomeHeaderResponse.java | 37 ++ .../api/domain/home/dto/HomeMemberItem.java | 51 +- .../domain/home/dto/HomeMembersResponse.java | 21 +- .../api/domain/home/dto/HomeProjectItem.java | 67 +- .../domain/home/dto/HomeProjectResponse.java | 22 +- .../domain/home/enums/code/HomeErrorCode.java | 4 +- .../domain/home/exception/HomeException.java | 12 - .../HomeInvalidParametersException.java | 13 + .../domain/home/facade/MainHomeFacade.java | 128 +++- .../service/HomeMemberQueryService.java | 41 +- .../service/HomeProjectQueryService.java | 35 +- .../domain/mypage/service/MypageService.java | 9 +- .../controller/NotificationController.java | 5 +- .../NotificationEnumsController.java | 16 + .../dto/NotificationEnumResponse.java | 5 + .../dto/NotificationResponse.java | 6 +- .../enums/code/NotificationErrorCode.java | 1 + .../enums/code/NotificationSearchFilter.java | 25 + .../service/NotificationService.java | 47 +- .../team/chat/converter/ChatConverter.java | 2 +- .../domain/team/chat/enums/ChatErrorCode.java | 3 +- .../chat/infra/ChatRedisMessageHandler.java | 29 + .../team/chat/infra/ChatRedisPublisher.java | 18 + .../team/chat/service/ChatFileService.java | 9 +- .../team/chat/service/ChatRoomService.java | 11 +- .../domain/team/chat/service/ChatService.java | 8 +- .../team/chat/service/RedisPublisher.java | 36 -- .../team/chat/service/RedisSubscriber.java | 39 -- .../team/chat/service/TeamChatService.java | 12 +- .../domain/team/chat/util/FileValidator.java | 2 +- .../nect/api/global/code/RedisErrorCode.java | 15 + .../exception => code}/StorageErrorCode.java | 4 +- .../nect/api/global/config/RedisConfig.java | 15 +- .../com/nect/api/global/infra/S3Service.java | 28 +- .../infra/exception/RedisException.java | 19 + .../infra/redis/RedisMessageHandler.java | 9 + .../global/infra/redis/RedisPublisher.java | 26 + .../global/infra/redis/RedisSubscriber.java | 47 ++ .../api/global/infra/s3/ImageService.java | 7 + .../dm/controller/DmQueryControllerTest.java | 228 +++++++ .../home/controller/HomeControllerTest.java | 111 +++- .../NotificationEnumsControllerTest.java | 33 + .../NotificationControllerTest.java | 12 +- .../chat/controller/ChatControllerTest.java | 76 ++- .../controller/ChatFileControllerTest.java | 570 ++++++++++-------- .../nect/core/entity/dm/DirectMessage.java | 35 ++ .../java/com/nect/core/entity/user/User.java | 2 +- .../com/nect/core/entity/user/UserCareer.java | 1 + .../nect/core/repository/dm/DmRepository.java | 75 +++ .../matching/RecruitmentRepository.java | 14 - .../notifications/NotificationRepository.java | 31 + .../team/ProjectUserRepository.java | 8 + .../user/UserInterestRepository.java | 30 + .../repository/user/UserRoleRepository.java | 1 + 67 files changed, 2055 insertions(+), 563 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/dm/controller/DirectMessageController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/dm/controller/DmQueryController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/dm/dto/DirectMessageDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/dm/dto/DmMessageListResponse.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/dm/dto/DmMessageSendRequestDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/dm/dto/DmRoomListResponse.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/dm/dto/DmRoomSummaryDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/dm/infra/DmRedisMessageHandler.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/dm/infra/DmRedisPublisher.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/dm/service/DmPresenceRegistry.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/dm/service/DmService.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/home/dto/HomeHeaderResponse.java delete mode 100644 nect-api/src/main/java/com/nect/api/domain/home/exception/HomeException.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/home/exception/HomeInvalidParametersException.java rename nect-api/src/main/java/com/nect/api/domain/{user => home}/service/HomeMemberQueryService.java (53%) rename nect-api/src/main/java/com/nect/api/domain/{team/project => home}/service/HomeProjectQueryService.java (80%) create mode 100644 nect-api/src/main/java/com/nect/api/domain/notifications/enums/code/NotificationSearchFilter.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/chat/infra/ChatRedisMessageHandler.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/chat/infra/ChatRedisPublisher.java delete mode 100644 nect-api/src/main/java/com/nect/api/domain/team/chat/service/RedisPublisher.java delete mode 100644 nect-api/src/main/java/com/nect/api/domain/team/chat/service/RedisSubscriber.java create mode 100644 nect-api/src/main/java/com/nect/api/global/code/RedisErrorCode.java rename nect-api/src/main/java/com/nect/api/global/{infra/exception => code}/StorageErrorCode.java (90%) create mode 100644 nect-api/src/main/java/com/nect/api/global/infra/exception/RedisException.java create mode 100644 nect-api/src/main/java/com/nect/api/global/infra/redis/RedisMessageHandler.java create mode 100644 nect-api/src/main/java/com/nect/api/global/infra/redis/RedisPublisher.java create mode 100644 nect-api/src/main/java/com/nect/api/global/infra/redis/RedisSubscriber.java create mode 100644 nect-api/src/main/java/com/nect/api/global/infra/s3/ImageService.java create mode 100644 nect-api/src/test/java/com/nect/api/domain/dm/controller/DmQueryControllerTest.java create mode 100644 nect-core/src/main/java/com/nect/core/entity/dm/DirectMessage.java create mode 100644 nect-core/src/main/java/com/nect/core/repository/dm/DmRepository.java diff --git a/build.gradle b/build.gradle index 6c6527c3..820e96db 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,7 @@ subprojects { test { systemProperty 'test.mode', 'true' + maxHeapSize = "2g" useJUnitPlatform() } } diff --git a/nect-api/src/main/java/com/nect/api/domain/dm/controller/DirectMessageController.java b/nect-api/src/main/java/com/nect/api/domain/dm/controller/DirectMessageController.java new file mode 100644 index 00000000..944560fd --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/dm/controller/DirectMessageController.java @@ -0,0 +1,35 @@ +package com.nect.api.domain.dm.controller; + +import com.nect.api.domain.dm.dto.DmMessageSendRequestDto; +import com.nect.api.domain.dm.service.DmService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@Controller +@RequiredArgsConstructor +@Slf4j +public class DirectMessageController { + + private final DmService dmService; + + @MessageMapping("/chat-send/dms/{userId}") + public void sendDm(@DestinationVariable("userId") Long receiverId, DmMessageSendRequestDto request, Principal principal) { + Long senderId = Long.valueOf(principal.getName()); + dmService.sendMessage(senderId, receiverId, request.getContent()); + + log.info(" DM 메시지 수신 - senderId: {}, receiverId: {}", senderId, receiverId); + } + + @MessageMapping("/dm-leave/{userId}") + public void leaveDmRoom(@DestinationVariable("userId") Long otherUserId, Principal principal) { + Long userId = Long.valueOf(principal.getName()); + dmService.leaveRoom(userId, otherUserId); + log.info(" DM 방 나가기 - userId: {}, otherUserId: {}", userId, otherUserId); + } + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/dm/controller/DmQueryController.java b/nect-api/src/main/java/com/nect/api/domain/dm/controller/DmQueryController.java new file mode 100644 index 00000000..33c5dddf --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/dm/controller/DmQueryController.java @@ -0,0 +1,43 @@ +package com.nect.api.domain.dm.controller; + +import com.nect.api.domain.dm.dto.DmMessageListResponse; +import com.nect.api.domain.dm.dto.DmRoomListResponse; +import com.nect.api.domain.dm.service.DmService; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/dms") +public class DmQueryController { + + private final DmService dmService; + + @GetMapping("/messages") + public ApiResponse getMessages( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam("userId") Long otherUserId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") @Min(1) int size + ) { + DmMessageListResponse response = dmService.getMessages(userDetails.getUserId(), otherUserId, cursor, size); + return ApiResponse.ok(response); + } + + @GetMapping("/rooms") + public ApiResponse getRooms( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") @Min(1) int size + ) { + DmRoomListResponse response = dmService.getRooms(userDetails.getUserId(), cursor, size); + return ApiResponse.ok(response); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/dm/dto/DirectMessageDto.java b/nect-api/src/main/java/com/nect/api/domain/dm/dto/DirectMessageDto.java new file mode 100644 index 00000000..8eceddf0 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/dm/dto/DirectMessageDto.java @@ -0,0 +1,48 @@ +package com.nect.api.domain.dm.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.nect.core.entity.dm.DirectMessage; +import com.nect.core.entity.user.User; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class DirectMessageDto { + + private Long messageId; + + private Long senderId; + private String senderName; + private String senderProfileImage; + private String content; + private Boolean isPinned; + private LocalDateTime createdAt; + private Boolean isRead; + + // DM -> DTO + public static DirectMessageDto fromDm(DirectMessage dm) { + + User sender = dm.getSender(); + return DirectMessageDto.builder() + .messageId(dm.getId()) + .senderId(sender.getUserId()) + .senderName(sender.getName()) + .senderProfileImage(sender.getProfileImageName()) + .content(dm.getContent()) + .isPinned(false) + .createdAt(dm.getCreatedAt()) + .isRead(dm.getIsRead()) + .build(); + } + + public void setImageUrl(String imageUrl) { + this.senderProfileImage = imageUrl; + } + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/dm/dto/DmMessageListResponse.java b/nect-api/src/main/java/com/nect/api/domain/dm/dto/DmMessageListResponse.java new file mode 100644 index 00000000..5cf773a6 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/dm/dto/DmMessageListResponse.java @@ -0,0 +1,15 @@ +package com.nect.api.domain.dm.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; + +import java.util.List; + +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record DmMessageListResponse( + List messages, + Long nextCursor +) { +} diff --git a/nect-api/src/main/java/com/nect/api/domain/dm/dto/DmMessageSendRequestDto.java b/nect-api/src/main/java/com/nect/api/domain/dm/dto/DmMessageSendRequestDto.java new file mode 100644 index 00000000..05bacb87 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/dm/dto/DmMessageSendRequestDto.java @@ -0,0 +1,15 @@ +package com.nect.api.domain.dm.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class DmMessageSendRequestDto { + private String content; +} diff --git a/nect-api/src/main/java/com/nect/api/domain/dm/dto/DmRoomListResponse.java b/nect-api/src/main/java/com/nect/api/domain/dm/dto/DmRoomListResponse.java new file mode 100644 index 00000000..a97b31c8 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/dm/dto/DmRoomListResponse.java @@ -0,0 +1,12 @@ +package com.nect.api.domain.dm.dto; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record DmRoomListResponse( + List messages, + Long nextCursor +) { +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/dm/dto/DmRoomSummaryDto.java b/nect-api/src/main/java/com/nect/api/domain/dm/dto/DmRoomSummaryDto.java new file mode 100644 index 00000000..9a839cb2 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/dm/dto/DmRoomSummaryDto.java @@ -0,0 +1,56 @@ +package com.nect.api.domain.dm.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.nect.core.entity.dm.DirectMessage; +import com.nect.core.entity.user.User; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@AllArgsConstructor +@NoArgsConstructor +public class DmRoomSummaryDto{ + + private Long otherUserId; + private Long otherUserName; + private String otherUserImageUrl; + private String otherUserRoleField; + private Long lastMessageId; + private String lastMessage; + private LocalDate lastMessageAt; + private Boolean isRead; + + // 로그인한 유저의 userId와 dm을 파리미터로 넣음 + public static DmRoomSummaryDto fromOtherUser(Long userId, DirectMessage message) { + + // 마지막 메시지를 보낸 사람이 누군지 + Long senderId = message.getSender().getUserId(); + User sender = userId.equals(senderId) ? message.getReceiver() : message.getSender(); + + // 상대방이 보낸 메시지면 그 메시지를 읽었는지 + boolean isRead = userId.equals(senderId) || message.getIsRead(); + + return DmRoomSummaryDto.builder() + .otherUserId(sender.getUserId()) + .otherUserName(sender.getUserId()) + .otherUserRoleField(sender.getName()) + .lastMessageId(message.getId()) + .lastMessage(message.getContent()) + .lastMessageAt(message.getCreatedAt().toLocalDate()) + .isRead(isRead) + .build(); + } + + public void setImageUrl(String url) { + this.otherUserImageUrl = url; + } + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/dm/infra/DmRedisMessageHandler.java b/nect-api/src/main/java/com/nect/api/domain/dm/infra/DmRedisMessageHandler.java new file mode 100644 index 00000000..7ac267ab --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/dm/infra/DmRedisMessageHandler.java @@ -0,0 +1,30 @@ +package com.nect.api.domain.dm.infra; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.dm.dto.DirectMessageDto; +import com.nect.api.global.infra.redis.RedisMessageHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DmRedisMessageHandler implements RedisMessageHandler { + private static final String CHANNEL_PREFIX = "dm:"; + + @Override + public String channelPrefix() { + return CHANNEL_PREFIX; + } + + @Override + public void handle(String channel, String payload, ObjectMapper objectMapper, SimpMessageSendingOperations messagingTemplate) throws Exception { + DirectMessageDto dmMessage = objectMapper.readValue(payload, DirectMessageDto.class); + String channelId = channel.substring(CHANNEL_PREFIX.length()); + String destination = "/topic/dm/" + channelId; + messagingTemplate.convertAndSend(destination, dmMessage); + log.info(" WebSocket 전송 완료 - Destination: {}", destination); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/dm/infra/DmRedisPublisher.java b/nect-api/src/main/java/com/nect/api/domain/dm/infra/DmRedisPublisher.java new file mode 100644 index 00000000..dcb99ab7 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/dm/infra/DmRedisPublisher.java @@ -0,0 +1,18 @@ +package com.nect.api.domain.dm.infra; + +import com.nect.api.domain.dm.dto.DirectMessageDto; +import com.nect.api.global.infra.redis.RedisPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DmRedisPublisher { + private static final String CHANNEL_PREFIX = "dm:"; + + private final RedisPublisher redisPublisher; + + public void publish(String roomId, DirectMessageDto message) { + redisPublisher.publish(CHANNEL_PREFIX + roomId, message); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/dm/service/DmPresenceRegistry.java b/nect-api/src/main/java/com/nect/api/domain/dm/service/DmPresenceRegistry.java new file mode 100644 index 00000000..b55d9fad --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/dm/service/DmPresenceRegistry.java @@ -0,0 +1,38 @@ +package com.nect.api.domain.dm.service; + +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 개인 채팅방 접속 정보를 관리합니다. + */ +@Component +public class DmPresenceRegistry { + + private final ConcurrentHashMap> roomUsers = new ConcurrentHashMap<>(); + + // 채팅방 입장 + public void enter(String roomId, Long userId) { + roomUsers.computeIfAbsent(roomId, key -> ConcurrentHashMap.newKeySet()).add(userId); + } + + // 채팅방 나가기 + public void leave(String roomId, Long userId) { + Set users = roomUsers.get(roomId); + if (users == null) { + return; + } + users.remove(userId); + if (users.isEmpty()) { + roomUsers.remove(roomId); + } + } + + // 두 유저 모두 채팅방에 들어와있는지 + public boolean bothPresent(String roomId, Long userA, Long userB) { + Set users = roomUsers.get(roomId); + return users != null && users.contains(userA) && users.contains(userB); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/dm/service/DmService.java b/nect-api/src/main/java/com/nect/api/domain/dm/service/DmService.java new file mode 100644 index 00000000..5121de4e --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/dm/service/DmService.java @@ -0,0 +1,149 @@ +package com.nect.api.domain.dm.service; + +import com.nect.api.domain.dm.dto.DirectMessageDto; +import com.nect.api.domain.dm.dto.DmMessageListResponse; +import com.nect.api.domain.dm.dto.DmRoomListResponse; +import com.nect.api.domain.dm.dto.DmRoomSummaryDto; +import com.nect.api.domain.dm.infra.DmRedisPublisher; +import com.nect.api.domain.user.exception.UserNotFoundException; +import com.nect.api.global.infra.S3Service; +import com.nect.core.entity.dm.DirectMessage; +import com.nect.core.entity.user.User; +import com.nect.core.repository.dm.DmRepository; +import com.nect.core.repository.user.UserRepository; +import com.nect.core.repository.user.UserRoleRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class DmService { + + private final DmRepository dmRepository; + private final UserRepository userRepository; + private final UserRoleRepository userRoleRepository; + private final DmRedisPublisher dmRedisPublisher; + private final DmPresenceRegistry dmPresenceRegistry; + private final S3Service s3Service; + + @Transactional + public DirectMessageDto sendMessage(Long senderId, Long receiverId, String content) { + + // 검증 + User sender = userRepository.findById(senderId).orElseThrow(UserNotFoundException::new); + User receiver = userRepository.findById(receiverId).orElseThrow(UserNotFoundException::new); + + // 채팅방 정보 + String roomId = buildChannelId(senderId, receiverId); + boolean isRead = dmPresenceRegistry.bothPresent(roomId, senderId, receiverId); + + // DM 생성 + DirectMessage message = DirectMessage.builder() + .sender(sender) + .receiver(receiver) + .content(content) + .isRead(isRead) + .build(); + + // DM 저장 + DirectMessage saved = dmRepository.save(message); + + // DM -> DTO + DirectMessageDto dto = DirectMessageDto.fromDm(saved); + dto.setImageUrl(s3Service.getPresignedGetUrl(saved.getSender().getProfileImageName())); + + // 상대에게 전송 + dmRedisPublisher.publish(roomId, dto); + + return dto; + } + + @Transactional + public DmMessageListResponse getMessages(Long userId, Long otherUserId, Long cursor, int size) { + + // 검증 + userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + userRepository.findById(otherUserId).orElseThrow(UserNotFoundException::new); + + // 채팅방 정보 + String roomId = buildChannelId(userId, otherUserId); + dmPresenceRegistry.enter(roomId, userId); + + // 채팅방 채팅 읽음처리 + dmRepository.markAsRead(userId, otherUserId, null); + + int safeSize = Math.max(1, size); + + // 채팅 조회 + List messages = dmRepository.findConversation( + userId, + otherUserId, + cursor, + PageRequest.of(0, safeSize) + ); + + // 커서 정보 + Long nextCursor = (messages.size() == safeSize) + ? messages.getLast().getId() + : null; + + // 정렬 + Collections.reverse(messages); + + // DM -> Dto + List list = messages.stream() + .map(DirectMessageDto::fromDm) + .toList(); + + // 응답값 생성 후 반환 + return DmMessageListResponse.builder() + .messages(list) + .nextCursor(nextCursor) + .build(); + } + + @Transactional(readOnly = true) + public DmRoomListResponse getRooms(Long userId, Long cursor, int size) { + + // 검증 + userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + int safeSize = Math.max(1, size); + + // 각 개인 채팅에 대한 마지막 메시지 + List latest = dmRepository.findLatestMessagesByUser(userId, cursor, PageRequest.of(0, safeSize)); + + // 채팅룸 커서 정보 + Long nextCursor = (latest.size() == safeSize) ? latest.getLast().getId() : null; + + // List -> List + List messages = latest.stream() + .map(message -> { + DmRoomSummaryDto dto = DmRoomSummaryDto.fromOtherUser(userId, message); + dto.setImageUrl(s3Service.getPresignedGetUrl(message.getSender().getProfileImageName())); + return dto; + }) + .toList(); + + // 응답값 생성 후 반환 + return DmRoomListResponse.builder() + .messages(messages) + .nextCursor(nextCursor) + .build(); + } + + private String buildChannelId(Long senderId, Long receiverId) { + long a = Math.min(senderId, receiverId); + long b = Math.max(senderId, receiverId); + return a + "_" + b; + } + + public void leaveRoom(Long userId, Long otherUserId) { + String roomId = buildChannelId(userId, otherUserId); + dmPresenceRegistry.leave(roomId, userId); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/home/controller/HomeController.java b/nect-api/src/main/java/com/nect/api/domain/home/controller/HomeController.java index 7b6bb4d1..481cf8b3 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/controller/HomeController.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/controller/HomeController.java @@ -1,10 +1,13 @@ package com.nect.api.domain.home.controller; +import com.nect.api.domain.home.dto.HomeHeaderResponse; import com.nect.api.domain.home.dto.HomeMembersResponse; import com.nect.api.domain.home.dto.HomeProjectResponse; import com.nect.api.domain.home.facade.MainHomeFacade; import com.nect.api.global.response.ApiResponse; import com.nect.api.global.security.UserDetailsImpl; +import com.nect.core.entity.user.enums.InterestField; +import com.nect.core.entity.user.enums.Role; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -16,36 +19,58 @@ public class HomeController { private final MainHomeFacade mainHomeFacade; - // 모집 중인 프로젝트 조회 + // 모집 중인 프로젝트 조회, role, interest 필수 x @GetMapping("/projects") - public ApiResponse recruitingProjects(@AuthenticationPrincipal UserDetailsImpl userDetails, @RequestParam("count") int count){ - Long userId = (userDetails == null) ? null : userDetails.getUserId(); - HomeProjectResponse projects = mainHomeFacade.getRecruitingProjects(userId, count); + public ApiResponse recruitingProjects( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam("count") int count, + @RequestParam(value = "role", required = false) Role role, + @RequestParam(value = "interest", required = false) InterestField interest + ){ + Long userId = resolveUserId(userDetails); + HomeProjectResponse projects = mainHomeFacade.getRecruitingProjects(userId, count, role, interest); return ApiResponse.ok(projects); } // 홈화면 프로젝트 추천 @GetMapping("/recommendations/projects") public ApiResponse recommendedProjects(@AuthenticationPrincipal UserDetailsImpl userDetails, @RequestParam("count") int count){ - Long userId = (userDetails == null) ? null : userDetails.getUserId(); + Long userId = resolveUserId(userDetails); HomeProjectResponse projects = mainHomeFacade.getRecommendedProjects(userId, count); return ApiResponse.ok(projects); } - // 홈화면 매칭 가능한 넥터 + // 홈화면 매칭 가능한 넥터, , role, interest 필수x @GetMapping("/members") - public ApiResponse matchableMembers(@AuthenticationPrincipal UserDetailsImpl userDetails, @RequestParam("count") int count){ - Long userId = (userDetails == null) ? null : userDetails.getUserId(); - HomeMembersResponse members = mainHomeFacade.getMatchableMembers(userId, count); + public ApiResponse matchableMembers( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam("count") int count, + @RequestParam(value = "role", required = false) Role role, + @RequestParam(value = "interest", required = false) InterestField interest + ){ + Long userId = resolveUserId(userDetails); + HomeMembersResponse members = mainHomeFacade.getMatchableMembers(userId, count, role, interest); return ApiResponse.ok(members); } // 홈화면 팀원 추천 @GetMapping("/recommendations/members") public ApiResponse recommendedMembers(@AuthenticationPrincipal UserDetailsImpl userDetails, @RequestParam("count") int count){ - Long userId = (userDetails == null) ? null : userDetails.getUserId(); + Long userId = resolveUserId(userDetails); HomeMembersResponse members = mainHomeFacade.getRecommendedMembers(userId, count); return ApiResponse.ok(members); } -} \ No newline at end of file + // 홈화면 헤더 프로필 + @GetMapping("/profile") + public ApiResponse headerProfile(@AuthenticationPrincipal UserDetailsImpl userDetails) { + Long userId = resolveUserId(userDetails); + HomeHeaderResponse profileInfo = mainHomeFacade.getHeaderProfile(userId); + return ApiResponse.ok(profileInfo); + } + + private Long resolveUserId(UserDetailsImpl userDetails) { + return (userDetails == null) ? null : userDetails.getUserId(); + } + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeHeaderResponse.java b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeHeaderResponse.java new file mode 100644 index 00000000..181bcded --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeHeaderResponse.java @@ -0,0 +1,37 @@ +package com.nect.api.domain.home.dto; + +import com.nect.core.entity.user.enums.Role; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor +@AllArgsConstructor +public class HomeHeaderResponse { + + private Long userId; + private String imageUrl; + private String name; + private String email; + private Role role; + + public static HomeHeaderResponse of( + Long userId, + String imageUrl, + String name, + String email, + Role role + ) { + return HomeHeaderResponse.builder() + .userId(userId) + .imageUrl(imageUrl) + .name(name) + .email(email) + .role(role) + .build(); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeMemberItem.java b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeMemberItem.java index c381e2d0..e266685b 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeMemberItem.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeMemberItem.java @@ -1,15 +1,46 @@ package com.nect.api.domain.home.dto; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + import java.util.List; -public record HomeMemberItem( - Long userId, - String imageUrl, - String name, - String part, - String introduction, - String status, - Boolean isScrapped, - List roles -) { +@Getter +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor +@AllArgsConstructor +public class HomeMemberItem { + private Long userId; + private String imageUrl; + private String name; + private String part; + private String introduction; + private String status; + private Boolean isScrapped; + private List roles; + + public static HomeMemberItem of( + Long userId, + String imageUrl, + String name, + String part, + String introduction, + String status, + Boolean isScrapped, + List roles + ) { + return HomeMemberItem.builder() + .userId(userId) + .imageUrl(imageUrl) + .name(name) + .part(part) + .introduction(introduction) + .status(status) + .isScrapped(isScrapped) + .roles(roles) + .build(); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeMembersResponse.java b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeMembersResponse.java index 207050ad..112a77f9 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeMembersResponse.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeMembersResponse.java @@ -1,8 +1,23 @@ package com.nect.api.domain.home.dto; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + import java.util.List; -public record HomeMembersResponse( - List members -) { +@Getter +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor +@AllArgsConstructor +public class HomeMembersResponse { + private List members; + + public static HomeMembersResponse of(List members) { + return HomeMembersResponse.builder() + .members(members) + .build(); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeProjectItem.java b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeProjectItem.java index cfb2373a..7dd6348b 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeProjectItem.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeProjectItem.java @@ -1,19 +1,58 @@ package com.nect.api.domain.home.dto; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + import java.util.Map; -public record HomeProjectItem( - Long projectId, - Long imageUrl, - String projectName, - String authorName, - String authorPart, - String introduction, - Integer leftDays, - Integer maxMemberCount, - Integer curMemberCount, - Boolean isScrapped, - String status, - Map roles -) { +@Getter +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor +@AllArgsConstructor +public class HomeProjectItem { + private Long projectId; + private String imageUrl; + private String projectName; + private String authorName; + private String authorPart; + private String introduction; + private Integer leftDays; + private Integer maxMemberCount; + private Integer curMemberCount; + private Boolean isScrapped; + private String status; + private Map roles; + + public static HomeProjectItem of( + Long projectId, + String imageUrl, + String projectName, + String authorName, + String authorPart, + String introduction, + Integer leftDays, + Integer maxMemberCount, + Integer curMemberCount, + Boolean isScrapped, + String status, + Map roles + ) { + return HomeProjectItem.builder() + .projectId(projectId) + .imageUrl(imageUrl) + .projectName(projectName) + .authorName(authorName) + .authorPart(authorPart) + .introduction(introduction) + .leftDays(leftDays) + .maxMemberCount(maxMemberCount) + .curMemberCount(curMemberCount) + .isScrapped(isScrapped) + .status(status) + .roles(roles) + .build(); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeProjectResponse.java b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeProjectResponse.java index 40deee56..edd2d08d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeProjectResponse.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeProjectResponse.java @@ -1,9 +1,23 @@ package com.nect.api.domain.home.dto; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + import java.util.List; -public record HomeProjectResponse( - List projects -){ -} +@Getter +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor +@AllArgsConstructor +public class HomeProjectResponse { + private List projects; + public static HomeProjectResponse of(List projects) { + return HomeProjectResponse.builder() + .projects(projects) + .build(); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/home/enums/code/HomeErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/home/enums/code/HomeErrorCode.java index 170683f9..fcce5bdd 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/enums/code/HomeErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/enums/code/HomeErrorCode.java @@ -8,7 +8,9 @@ @AllArgsConstructor public enum HomeErrorCode implements ResponseCode { - INVALID_HOME_COUNT("H400_1", "count는 1 이상이어야 합니다."); + INVALID_HOME_COUNT("H400_1", "count는 1 이상이어야 합니다."), + INVALID_PARAMETERS("H400_2", "올바르지 않은 파라미터입니다.") + ; private final String statusCode; diff --git a/nect-api/src/main/java/com/nect/api/domain/home/exception/HomeException.java b/nect-api/src/main/java/com/nect/api/domain/home/exception/HomeException.java deleted file mode 100644 index 9e3973ed..00000000 --- a/nect-api/src/main/java/com/nect/api/domain/home/exception/HomeException.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.nect.api.domain.home.exception; - -import com.nect.api.global.code.ResponseCode; -import com.nect.api.global.exception.CustomException; - -public class HomeException extends CustomException { - - public HomeException(ResponseCode code) { - super(code); - } - -} diff --git a/nect-api/src/main/java/com/nect/api/domain/home/exception/HomeInvalidParametersException.java b/nect-api/src/main/java/com/nect/api/domain/home/exception/HomeInvalidParametersException.java new file mode 100644 index 00000000..3a5b5296 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/home/exception/HomeInvalidParametersException.java @@ -0,0 +1,13 @@ +package com.nect.api.domain.home.exception; + +import com.nect.api.domain.home.enums.code.HomeErrorCode; +import com.nect.api.global.code.ResponseCode; +import com.nect.api.global.exception.CustomException; + +public class HomeInvalidParametersException extends CustomException { + + public HomeInvalidParametersException(String message) { + super(HomeErrorCode.INVALID_PARAMETERS, message); + } + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java b/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java index 3c17c9e9..30765d8b 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java @@ -4,10 +4,15 @@ import com.nect.api.domain.home.dto.HomeMembersResponse; import com.nect.api.domain.home.dto.HomeProjectItem; import com.nect.api.domain.home.dto.HomeProjectResponse; -import com.nect.api.domain.user.service.HomeMemberQueryService; -import com.nect.api.domain.team.project.service.HomeProjectQueryService; +import com.nect.api.domain.home.dto.HomeHeaderResponse; +import com.nect.api.domain.home.exception.HomeInvalidParametersException; +import com.nect.api.domain.home.service.HomeMemberQueryService; +import com.nect.api.domain.home.service.HomeProjectQueryService; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.Project; import com.nect.core.entity.user.User; +import com.nect.core.entity.user.enums.InterestField; +import com.nect.core.entity.user.enums.Role; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -25,77 +30,117 @@ @RequiredArgsConstructor public class MainHomeFacade { - private final HomeProjectQueryService homeQueryService; + private final HomeProjectQueryService homeProjectQueryService; private final HomeMemberQueryService homeMemberQueryService; + private final S3Service s3Service; // 모집 중인 프로젝트 - public HomeProjectResponse getRecruitingProjects(Long userId, int count){ - PageRequest pageRequest = PageRequest.of(0, count); - List projects = homeQueryService.getProjects(userId, pageRequest); + public HomeProjectResponse getRecruitingProjects(Long userId, int count, Role role, InterestField interest){ - if (projects.isEmpty()) { - return new HomeProjectResponse(List.of()); - } + int safeCount = safeCount(count); + + // 페이징 정보 + PageRequest pageRequest = PageRequest.of(0, safeCount); + + // List 미리 생성 +// List projects = new ArrayList<>(); - return new HomeProjectResponse(responsesFromProjects(projects)); +// // 둘 중 하나가 null일 수는 없음 +// if ((role == null && interest != null) || (role != null && interest == null)) { +// throw new HomeInvalidParametersException("role과 interest 중 하나만 null일 수 없습니다."); +// } +// +// // role이 null일 때 +// if (role == null) { +// +// }else{ +// +// } + + List projects = homeProjectQueryService.getProjects(userId, pageRequest); + + return buildProjectResponse(projects); } // 홈화면 추천 프로젝트들 public HomeProjectResponse getRecommendedProjects(Long userId, int count) { - List projects = homeQueryService.getProjects(userId); + int safeCount = safeCount(count); + List projects = homeProjectQueryService.getProjects(userId, PageRequest.of(0, safeCount)); if (projects.isEmpty()) { - return new HomeProjectResponse(List.of()); + return HomeProjectResponse.of(List.of()); } Collections.shuffle(projects); - List randomProjects = projects.subList(0, Math.min(count, projects.size())); + List randomProjects = projects.subList(0, Math.min(safeCount, projects.size())); - return new HomeProjectResponse(responsesFromProjects(randomProjects)); + return buildProjectResponse(randomProjects); } // 홈화면 매칭 가능한 넥터 - public HomeMembersResponse getMatchableMembers(Long userId, int count) { - List users = homeMemberQueryService.getAllUsersWithoutUser(userId, count); - return new HomeMembersResponse(responsesFromMembers(users)); + public HomeMembersResponse getMatchableMembers(Long userId, int count, Role role, InterestField interest) { + + // 둘 중 하나가 null일 수는 없음 + if ((role == null && interest != null) || (role != null && interest == null)) { + throw new HomeInvalidParametersException("role과 interest 중 하나만 null일 수 없습니다."); + } + + // List 선언 + List users; + int safeCount = safeCount(count); + + if (role != null) { // 둘 다 null이 아니면 필터링해서 반환 + users = homeMemberQueryService.getFilteredMembers(userId, safeCount, role, interest); + } + else{ // 둘 모두 null이면 모두 조회하여 반환 + users = homeMemberQueryService.getAllUsersWithoutUser(userId, safeCount); + } + + return buildMemberResponse(users); } // 홈화면 추천 넥터 public HomeMembersResponse getRecommendedMembers(Long userId, int count) { - List users = homeMemberQueryService.getAllUsersWithoutUser(userId, count); + int safeCount = safeCount(count); + List users = homeMemberQueryService.getAllUsersWithoutUser(userId, safeCount); List items = new ArrayList<>(responsesFromMembers(users)); Collections.shuffle(items); - return new HomeMembersResponse(items); + return HomeMembersResponse.of(items); + } + + // 홈화면 헤더 프로필 + public HomeHeaderResponse getHeaderProfile(Long userId) { + return homeMemberQueryService.getHeaderProfile(userId); } // List -> List private List responsesFromProjects(List projects) { - HomeProjectQueryService.HomeProjectBatch batch = homeQueryService.loadHomeProjectBatch(projects); + HomeProjectQueryService.HomeProjectBatch batch = homeProjectQueryService.loadHomeProjectBatch(projects); return projects.stream() .map(p -> { Long projectId = p.getId(); User author = batch.authorByProjectId().get(projectId); - Integer dDay = homeQueryService.getDDay(p); + Integer dDay = homeProjectQueryService.getDDay(p); Integer maxMemberCount = batch.maxMemberCountByProjectId().getOrDefault(projectId, 0); Integer currentMemberCount = batch.activeCountByProjectId().getOrDefault(projectId, 0); Map partCounts = batch.partCountsByProjectId().getOrDefault(projectId, Map.of()); - return new HomeProjectItem( + return HomeProjectItem.of( projectId, - null, + resolveProjectImage(p), p.getTitle(), author == null ? null : author.getName(), - null, + author == null ? null : author.getRole().name(), p.getDescription(), dDay, maxMemberCount, currentMemberCount, false, - p.getRecruitmentStatus().getStatus(), + p.getRecruitmentStatus() != null ? p.getRecruitmentStatus().getStatus() : null, partCounts ); }) @@ -110,17 +155,40 @@ private List responsesFromMembers(List users) { .map(user -> { List parts = partsByUserId.getOrDefault(user.getUserId(), List.of()); - return new HomeMemberItem( + return HomeMemberItem.of( user.getUserId(), - null, + s3Service.getPresignedGetUrl(user.getProfileImageName()), user.getName(), - user.getRole().name(), - null, + user.getRole() != null ? user.getRole().name() : null, null, + user.getUserStatus() != null ? user.getUserStatus().name() : null, false, parts ); }) .toList(); } -} \ No newline at end of file + + private HomeProjectResponse buildProjectResponse(List projects) { + if (projects.isEmpty()) { + return HomeProjectResponse.of(List.of()); + } + + return HomeProjectResponse.of(responsesFromProjects(projects)); + } + + private HomeMembersResponse buildMemberResponse(List users) { + return HomeMembersResponse.of(responsesFromMembers(users)); + } + + private int safeCount(int count) { + return Math.max(1, count); + } + + private String resolveProjectImage(Project project) { + String imageName = project.getImageName(); + return imageName == null ? null : s3Service.getPresignedGetUrl(imageName); + } + + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/user/service/HomeMemberQueryService.java b/nect-api/src/main/java/com/nect/api/domain/home/service/HomeMemberQueryService.java similarity index 53% rename from nect-api/src/main/java/com/nect/api/domain/user/service/HomeMemberQueryService.java rename to nect-api/src/main/java/com/nect/api/domain/home/service/HomeMemberQueryService.java index f842b685..2fc6ba04 100644 --- a/nect-api/src/main/java/com/nect/api/domain/user/service/HomeMemberQueryService.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/service/HomeMemberQueryService.java @@ -1,8 +1,14 @@ -package com.nect.api.domain.user.service; +package com.nect.api.domain.home.service; +import com.nect.api.domain.home.dto.HomeHeaderResponse; +import com.nect.api.domain.user.exception.UserNotFoundException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.user.User; import com.nect.core.entity.user.UserRole; +import com.nect.core.entity.user.enums.InterestField; +import com.nect.core.entity.user.enums.Role; import com.nect.core.entity.user.enums.RoleField; +import com.nect.core.repository.user.UserInterestRepository; import com.nect.core.repository.user.UserRepository; import com.nect.core.repository.user.UserRoleRepository; import lombok.RequiredArgsConstructor; @@ -22,10 +28,19 @@ public class HomeMemberQueryService { private final UserRepository userRepository; private final UserRoleRepository userRoleRepository; + private final UserInterestRepository userInterestRepository; + private final S3Service s3Service; + + public List getFilteredMembers(Long userId, int count, Role role, InterestField interest) { + PageRequest pageRequest = PageRequest.of(0, count); + return userInterestRepository.findUsersByInterestAndRoleExcludingUser(interest, role, userId, pageRequest); + } public List getAllUsersWithoutUser(Long userId, int count) { PageRequest pageRequest = PageRequest.of(0, count); - return userRepository.findByUserIdNot(userId, pageRequest); + return (userId == null) + ? userRepository.findAll(pageRequest).getContent() + : userRepository.findByUserIdNot(userId, pageRequest); } public Map> partsByUsers(List users) { @@ -53,5 +68,25 @@ public List parts(User user) { .distinct() .toList(); } -} + public HomeHeaderResponse getHeaderProfile(Long userId) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException("유저를 찾을 수 없습니다.")); + + // 역할들 + List userRoles = userRoleRepository.findByUser(user); + + // 역할 ( 개발자, 디자이너, 기획자 등 ) + Role role = userRoles.getFirst().getRoleField().getRole(); + + return HomeHeaderResponse.of( + user.getUserId(), + s3Service.getPresignedGetUrl(user.getProfileImageName()), + user.getName(), + user.getEmail(), + role + ); + } + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/HomeProjectQueryService.java b/nect-api/src/main/java/com/nect/api/domain/home/service/HomeProjectQueryService.java similarity index 80% rename from nect-api/src/main/java/com/nect/api/domain/team/project/service/HomeProjectQueryService.java rename to nect-api/src/main/java/com/nect/api/domain/home/service/HomeProjectQueryService.java index d36d3e30..1f9d54fd 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/HomeProjectQueryService.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/service/HomeProjectQueryService.java @@ -1,6 +1,8 @@ -package com.nect.api.domain.team.project.service; +package com.nect.api.domain.home.service; +import com.nect.core.entity.matching.Recruitment; import com.nect.core.entity.team.Project; +import com.nect.core.entity.user.enums.RoleField; import com.nect.core.entity.team.enums.RecruitmentStatus; import com.nect.core.entity.user.User; import com.nect.core.repository.matching.RecruitmentRepository; @@ -74,18 +76,34 @@ public HomeProjectBatch loadHomeProjectBatch(List projects) { )); Map> partCountsByProjectId = new HashMap<>(); -// for (RecruitmentRepository.ProjectRoleCapacityRow row : recruitmentRepository.sumRoleCapacityByProjectIds(projectIds)) { // TODO: Recruitmnet Field 바뀌면 적용 -// partCountsByProjectId -// .computeIfAbsent(row.getProjectId(), k -> new HashMap<>()) -// .put(row.getRoleName(), row.getCapacitySum() == null ? 0 : row.getCapacitySum()); -// } + for (Recruitment recruitment : recruitmentRepository.findAllByProject_IdIn(projectIds)) { + Integer capacity = recruitment.getCapacity(); + + if (capacity == null || capacity <= 0) { + continue; + } + + RoleField field = recruitment.getField(); + String roleKey; + if (field == RoleField.CUSTOM) { + String customField = recruitment.getCustomField(); + roleKey = (customField == null || customField.isBlank()) + ? RoleField.CUSTOM.name() + : customField; + } else { + roleKey = field.name(); + } + + partCountsByProjectId + .computeIfAbsent(recruitment.getProject().getId(), k -> new HashMap<>()) + .merge(roleKey, capacity, Integer::sum); + } return new HomeProjectBatch( authorByProjectId, activeCountByProjectId, maxMemberCountByProjectId, - null // TODO: Recruitmnet Field 바뀌면 적용 -// partCountsByProjectId + partCountsByProjectId ); } @@ -111,4 +129,3 @@ public Integer getDDay(Project project) { - diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/service/MypageService.java b/nect-api/src/main/java/com/nect/api/domain/mypage/service/MypageService.java index ec1ba983..70855232 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/service/MypageService.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/service/MypageService.java @@ -1,10 +1,12 @@ package com.nect.api.domain.mypage.service; +import com.fasterxml.jackson.databind.ObjectMapper; import com.nect.api.domain.mypage.dto.ProfileSettingsDto; import com.nect.api.domain.mypage.dto.ProfileSettingsDto.*; import com.nect.api.domain.mypage.exception.InvalidUserStatusException; import com.nect.api.domain.mypage.exception.UserNotFoundException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.user.*; import com.nect.core.entity.user.enums.*; import com.nect.core.repository.user.*; @@ -26,7 +28,8 @@ public class MypageService { private final UserProjectHistoryRepository userProjectHistoryRepository; private final UserSkillRepository userSkillRepository; private final UserProfileAnalysisRepository userProfileAnalysisRepository; - private final com.fasterxml.jackson.databind.ObjectMapper objectMapper; + private final ObjectMapper objectMapper; + private final S3Service s3Service; @Transactional(readOnly = true) public ProfileSettingsResponseDto getProfile(Long userId) { @@ -126,7 +129,7 @@ public ProfileSettingsResponseDto getProfile(Long userId) { user.getNickname(), user.getEmail(), user.getRole() != null ? user.getRole().name() : null, - user.getProfileImageUrl(), + s3Service.getPresignedGetUrl(user.getProfileImageName()), user.getBio(), user.getCoreCompetencies(), user.getUserStatus() != null ? user.getUserStatus().getDescription() : null, @@ -149,7 +152,7 @@ public void updateProfile(Long userId, ProfileSettingsRequestDto request) { .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다.")); if (request.profileImageUrl() != null) { - user.setProfileImageUrl(request.profileImageUrl()); + user.setProfileImageName(request.profileImageUrl()); } if (request.bio() != null) { user.setBio(request.bio()); diff --git a/nect-api/src/main/java/com/nect/api/domain/notifications/controller/NotificationController.java b/nect-api/src/main/java/com/nect/api/domain/notifications/controller/NotificationController.java index bb291f27..6fe883d4 100644 --- a/nect-api/src/main/java/com/nect/api/domain/notifications/controller/NotificationController.java +++ b/nect-api/src/main/java/com/nect/api/domain/notifications/controller/NotificationController.java @@ -1,5 +1,6 @@ package com.nect.api.domain.notifications.controller; +import com.nect.api.domain.notifications.enums.code.NotificationSearchFilter; import com.nect.api.global.response.ApiResponse; import com.nect.api.domain.notifications.dto.NotificationListResponse; import com.nect.api.domain.notifications.service.NotificationDispatchService; @@ -52,11 +53,11 @@ public SseEmitter subscribe(@AuthenticationPrincipal UserDetailsImpl userDetails @GetMapping public ApiResponse notifications( @AuthenticationPrincipal UserDetailsImpl userDetails, - @RequestParam("scope") NotificationScope scope, + @RequestParam("filter") NotificationSearchFilter filter, @RequestParam(required = false) Long cursor, @RequestParam(defaultValue = "20") int size ) { - NotificationListResponse response = notificationService.getNotifications(userDetails.getUserId(), scope, cursor, size); + NotificationListResponse response = notificationService.getNotifications(userDetails.getUserId(), filter, cursor, size); return ApiResponse.ok(response); } diff --git a/nect-api/src/main/java/com/nect/api/domain/notifications/controller/NotificationEnumsController.java b/nect-api/src/main/java/com/nect/api/domain/notifications/controller/NotificationEnumsController.java index 12d48d01..f7e5fc6a 100644 --- a/nect-api/src/main/java/com/nect/api/domain/notifications/controller/NotificationEnumsController.java +++ b/nect-api/src/main/java/com/nect/api/domain/notifications/controller/NotificationEnumsController.java @@ -1,7 +1,9 @@ package com.nect.api.domain.notifications.controller; import com.nect.api.domain.notifications.dto.NotificationEnumResponse.EnumValueDto; +import com.nect.api.domain.notifications.dto.NotificationEnumResponse.NotificationSearchFilterDto; import com.nect.api.domain.notifications.dto.NotificationEnumResponse.NotificationTypeDto; +import com.nect.api.domain.notifications.enums.code.NotificationSearchFilter; import com.nect.api.global.response.ApiResponse; import com.nect.core.entity.notifications.enums.NotificationClassification; import com.nect.core.entity.notifications.enums.NotificationScope; @@ -33,6 +35,17 @@ public ApiResponse> getScopes() { return ApiResponse.ok(response); } + @GetMapping("/filters") + public ApiResponse> getFilters() { + List response = Arrays.stream(NotificationSearchFilter.values()) + .map(value -> new NotificationSearchFilterDto( + value.name(), + value.getScopes().stream().map(Enum::name).toList() + )) + .toList(); + return ApiResponse.ok(response); + } + @GetMapping("/types/matching-rejected") public ApiResponse getMatchingRejectedType() { NotificationType type = NotificationType.MATCHING_REJECTED; @@ -44,4 +57,7 @@ public ApiResponse getMatchingRejectedType() { ); return ApiResponse.ok(response); } + + + } diff --git a/nect-api/src/main/java/com/nect/api/domain/notifications/dto/NotificationEnumResponse.java b/nect-api/src/main/java/com/nect/api/domain/notifications/dto/NotificationEnumResponse.java index 83611851..c5ba837d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/notifications/dto/NotificationEnumResponse.java +++ b/nect-api/src/main/java/com/nect/api/domain/notifications/dto/NotificationEnumResponse.java @@ -7,6 +7,11 @@ public record EnumValueDto( String label ) {} + public record NotificationSearchFilterDto( + String value, + java.util.List scopes + ) {} + public record NotificationTypeDto( String value, String mainMessageFormat, diff --git a/nect-api/src/main/java/com/nect/api/domain/notifications/dto/NotificationResponse.java b/nect-api/src/main/java/com/nect/api/domain/notifications/dto/NotificationResponse.java index f29c7882..0582c3c7 100644 --- a/nect-api/src/main/java/com/nect/api/domain/notifications/dto/NotificationResponse.java +++ b/nect-api/src/main/java/com/nect/api/domain/notifications/dto/NotificationResponse.java @@ -34,12 +34,16 @@ public record NotificationResponse( ) { public static NotificationResponse from(Notification notification) { + Long projectId = (notification.getProject() != null) + ? notification.getProject().getId() + : null; + return new NotificationResponse( notification.getMainMessage(), notification.getContentMessage(), notification.getId(), notification.getTargetId(), - notification.getProject().getId(), + projectId, notification.getCreatedAt().format(FORMATTER), notification.getClassification().getClassifyKr(), notification.getType().name(), diff --git a/nect-api/src/main/java/com/nect/api/domain/notifications/enums/code/NotificationErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/notifications/enums/code/NotificationErrorCode.java index 4f0128ec..7c6ff6f1 100644 --- a/nect-api/src/main/java/com/nect/api/domain/notifications/enums/code/NotificationErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/notifications/enums/code/NotificationErrorCode.java @@ -15,6 +15,7 @@ public enum NotificationErrorCode implements ResponseCode { NOTIFICATION_NOT_FOUND("N003", "알림을 찾을 수 없습니다"), INVALID_NOTIFICATION_SCOPE("N004", "유효하지 않은 알림 범위입니다"), EMITTER_NOT_FOUND("N005", "알림 연결 정보가 존재하지 않습니다"), + INVALID_NOTIFICATION_FILTER("N006", "유효하지 않은 알림 필터입니다"), // 비즈니스 차원 NOTIFICATION_LENGTH_EXCEED("N006", "알림 내용 길이가 초과했습니다. 관리자에게 문의해주세요"), diff --git a/nect-api/src/main/java/com/nect/api/domain/notifications/enums/code/NotificationSearchFilter.java b/nect-api/src/main/java/com/nect/api/domain/notifications/enums/code/NotificationSearchFilter.java new file mode 100644 index 00000000..2cc76cc2 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/notifications/enums/code/NotificationSearchFilter.java @@ -0,0 +1,25 @@ +package com.nect.api.domain.notifications.enums.code; + +import com.nect.core.entity.notifications.enums.NotificationScope; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +/** + * 검색 조회 필터에 사용됨 + */ +@Getter +@AllArgsConstructor +public enum NotificationSearchFilter { + + EXPLORATION(List.of(NotificationScope.MAIN_HOME)), + WORKSPACE_ONLY(List.of(NotificationScope.WORKSPACE_ONLY)), + WORKSPACE_GLOBAL(List.of(NotificationScope.WORKSPACE_GLOBAL)), + WORKSPACES(List.of(NotificationScope.WORKSPACE_GLOBAL, NotificationScope.WORKSPACE_ONLY)) + + ; + + private final List scopes; + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/notifications/service/NotificationService.java b/nect-api/src/main/java/com/nect/api/domain/notifications/service/NotificationService.java index 48c41b66..cabbed06 100644 --- a/nect-api/src/main/java/com/nect/api/domain/notifications/service/NotificationService.java +++ b/nect-api/src/main/java/com/nect/api/domain/notifications/service/NotificationService.java @@ -1,5 +1,6 @@ package com.nect.api.domain.notifications.service; +import com.nect.api.domain.notifications.enums.code.NotificationSearchFilter; import com.nect.api.domain.user.exception.UserNotFoundException; import com.nect.api.global.code.CommonResponseCode; import com.nect.api.domain.notifications.command.NotificationCommand; @@ -8,8 +9,10 @@ import com.nect.api.domain.notifications.exception.NotificationException; import com.nect.core.entity.notifications.Notification; import com.nect.core.entity.notifications.enums.NotificationScope; +import com.nect.core.entity.team.Project; import com.nect.core.entity.user.User; import com.nect.core.repository.notifications.NotificationRepository; +import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; @@ -38,6 +41,7 @@ public class NotificationService { private final NotificationRepository notificationRepository; private final UserRepository userRepository; + private final ProjectUserRepository projectUserRepository; // 여러 유저에 대해 생성 @Transactional(readOnly = false) @@ -74,7 +78,7 @@ public List createForUsers(List receivers, NotificationComma @Transactional(readOnly = true) public NotificationListResponse getNotifications( Long userId, - NotificationScope scope, + NotificationSearchFilter filter, Long cursor, int size ) { @@ -83,18 +87,43 @@ public NotificationListResponse getNotifications( User user = userRepository.findById(userId) .orElseThrow(UserNotFoundException::new); - if (scope == null) { // scope 없으면 안됨. + if (filter == null) { // filter 없으면 안됨. throw new NotificationException(NotificationErrorCode.INVALID_NOTIFICATION_SCOPE); } - // 페이징 정보 - PageRequest pageRequest = PageRequest.of(0, size); - - // 알림 목록 조회 -> List 반환 - List notifications = notificationRepository.findByScopeWithCursor(user, scope, cursor, pageRequest); + int safeSize = Math.max(1, size); // size가 1보다 작으면 1로 설정 + + // FILTER에 담긴 SCOPE 가져오기 + List scopes = filter.getScopes(); + List notifications; + + // filter가 EXPLORATION이면 나에 대한 알림을 조회 + if (filter == NotificationSearchFilter.EXPLORATION) { + notifications = notificationRepository.findByScopesWithCursor( + user, + scopes, + cursor, + PageRequest.of(0, safeSize) + ); + } else { + // filter가 WORKSPACE 종류일 때는 내가 속한 프로젝트들을 가져와서 그거로 조회 + List myProjects = projectUserRepository.findActiveProjectsByUserId(userId); + if (myProjects.isEmpty()) { + return NotificationListResponse.from(List.of(), null); + } + + notifications = notificationRepository.findByScopesAndProjectsWithCursor( + user, + scopes, + myProjects, + cursor, + PageRequest.of(0, safeSize) + ); + } - // cursor 정보 생성 - Long nextCursor = notifications.isEmpty() ? null : notifications.getLast().getId(); + Long nextCursor = (notifications.size() == safeSize) + ? notifications.getLast().getId() + : null; // API 응답 객체 생성 후 반환 return NotificationListResponse.from(notifications, nextCursor); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/converter/ChatConverter.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/converter/ChatConverter.java index e4efe271..4945af08 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/converter/ChatConverter.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/converter/ChatConverter.java @@ -30,7 +30,7 @@ public static ChatMessageDto toMessageDto(ChatMessage message) { .roomId(message.getChatRoom().getId()) .userId(message.getUser().getUserId()) .userName(message.getUser().getName()) - .profileImage(message.getUser().getProfileImageUrl()) + .profileImage(message.getUser().getProfileImageName()) .content(message.getContent()) .messageType(message.getMessageType()) .isPinned(message.getIsPinned()) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/enums/ChatErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/enums/ChatErrorCode.java index a2d801e1..0a6773a2 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/enums/ChatErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/enums/ChatErrorCode.java @@ -25,6 +25,7 @@ public enum ChatErrorCode implements ResponseCode { // 메시지 관련 CHAT_MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "CHAT_201", "메시지를 찾을 수 없습니다"), CHAT_MESSAGE_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CHAT_202", "메시지 전송에 실패했습니다"), + INVALID_MESSAGE_FOR_ROOM(HttpStatus.INTERNAL_SERVER_ERROR, "CHAT_203", "해당 방의 메시지가 아닙니다."), // Redis 관련 REDIS_PUBLISH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CHAT_301", "Redis 메시지 발행에 실패했습니다"), @@ -54,4 +55,4 @@ public String getMessage() { return this.message; } -} \ No newline at end of file +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/infra/ChatRedisMessageHandler.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/infra/ChatRedisMessageHandler.java new file mode 100644 index 00000000..711b9856 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/infra/ChatRedisMessageHandler.java @@ -0,0 +1,29 @@ +package com.nect.api.domain.team.chat.infra; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.chat.dto.req.ChatMessageDto; +import com.nect.api.global.infra.redis.RedisMessageHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ChatRedisMessageHandler implements RedisMessageHandler { + private static final String CHANNEL_PREFIX = "chatroom:"; + + @Override + public String channelPrefix() { + return CHANNEL_PREFIX; + } + + @Override + public void handle(String channel, String payload, ObjectMapper objectMapper, SimpMessageSendingOperations messagingTemplate) throws Exception { + ChatMessageDto chatMessage = objectMapper.readValue(payload, ChatMessageDto.class); + String destination = "/topic/chatroom/" + chatMessage.getRoomId(); + messagingTemplate.convertAndSend(destination, chatMessage); + log.info(" WebSocket 전송 완료 - Destination: {}", destination); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/infra/ChatRedisPublisher.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/infra/ChatRedisPublisher.java new file mode 100644 index 00000000..8f072f5c --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/infra/ChatRedisPublisher.java @@ -0,0 +1,18 @@ +package com.nect.api.domain.team.chat.infra; + +import com.nect.api.domain.team.chat.dto.req.ChatMessageDto; +import com.nect.api.global.infra.redis.RedisPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ChatRedisPublisher { + private static final String CHANNEL_PREFIX = "chatroom:"; + + private final RedisPublisher redisPublisher; + + public void publish(Long roomId, ChatMessageDto message) { + redisPublisher.publish(CHANNEL_PREFIX + roomId, message); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatFileService.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatFileService.java index f666a420..f5fd2c5e 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatFileService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatFileService.java @@ -2,10 +2,11 @@ import com.nect.api.domain.team.chat.converter.FileConverter; import com.nect.api.domain.team.chat.dto.req.ChatMessageDto; import com.nect.api.domain.team.chat.dto.res.*; +import com.nect.api.domain.team.chat.infra.ChatRedisPublisher; import com.nect.api.domain.team.chat.util.FileValidator; import com.nect.api.domain.user.enums.UserErrorCode; +import com.nect.api.global.code.StorageErrorCode; import com.nect.api.global.infra.S3Service; -import com.nect.api.global.infra.exception.StorageErrorCode; import com.nect.api.global.infra.exception.StorageException; import com.nect.core.entity.team.chat.ChatFile; import com.nect.core.entity.team.chat.ChatMessage; @@ -42,7 +43,7 @@ public class ChatFileService { private final UserRepository userRepository; private final ChatMessageRepository chatMessageRepository; private final ChatRoomUserRepository chatRoomUserRepository; - private final RedisPublisher redisPublisher; + private final ChatRedisPublisher redisPublisher; private final S3Service s3Service; private final ProjectUserRepository projectUserRepository; @@ -89,7 +90,7 @@ public ChatMessageDto uploadAndSendFile(Long roomId, MultipartFile file, Long us messageDto.setReadCount(totalMembers - 1); String channel = "chatroom:" + roomId; - redisPublisher.publish(channel, messageDto); + redisPublisher.publish(roomId, messageDto); return messageDto; @@ -146,7 +147,7 @@ public List getChatAlbum(Long projectId, int limitPerR @Transactional(readOnly = true) public ChatRoomAlbumDetailDto getChatRoomAlbumDetail(Long roomId, int page, int size, Long userId) { - validateRoomMember(roomId, userId); +validateRoomMember(roomId, userId); LocalDateTime fifteenDaysAgo = LocalDateTime.now().minusDays(15); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatRoomService.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatRoomService.java index a5e5f4d2..e1ae33fc 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatRoomService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatRoomService.java @@ -1,20 +1,18 @@ package com.nect.api.domain.team.chat.service; -import com.nect.api.domain.team.chat.converter.ChatConverter; import com.nect.api.domain.team.chat.dto.res.ChatRoomLeaveResponseDto; import com.nect.api.domain.team.chat.dto.res.ChatRoomListDto; import com.nect.api.domain.team.chat.enums.ChatErrorCode; import com.nect.api.domain.team.chat.exeption.ChatException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.chat.ChatMessage; import com.nect.core.entity.team.chat.ChatRoom; import com.nect.core.entity.team.chat.ChatRoomUser; import com.nect.core.entity.team.chat.enums.MessageType; import com.nect.core.entity.user.User; -import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.chat.ChatMessageRepository; import com.nect.core.repository.team.chat.ChatRoomUserRepository; import com.nect.core.repository.team.chat.ChatRoomRepository; -import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -22,7 +20,6 @@ import org.springframework.util.StringUtils; import java.time.LocalDateTime; -import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -35,10 +32,8 @@ public class ChatRoomService { private final ChatRoomRepository chatRoomRepository; private final ChatRoomUserRepository chatRoomUserRepository; private final ChatMessageRepository chatMessageRepository; - private final ProjectUserRepository projectUserRepository; - private final UserRepository userRepository; private final ChatService chatService; - private final ChatConverter chatConverter; + private final S3Service s3Service; public List getMyChatRooms(Long user_id) { @@ -154,7 +149,7 @@ private List getProfileImages(Long chatRoomId) { return roomUsers.stream() .map(ChatRoomUser::getUser) - .map(User::getProfileImageUrl) + .map(u -> s3Service.getPresignedGetUrl(u.getProfileImageName())) .filter(StringUtils::hasText) .limit(4) .collect(Collectors.toList()); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatService.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatService.java index 0abbf0fd..7d1c5b19 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatService.java @@ -8,6 +8,7 @@ import com.nect.api.domain.team.chat.dto.res.ChatRoomMessagesResponseDto; import com.nect.api.domain.team.chat.enums.ChatErrorCode; import com.nect.api.domain.team.chat.exeption.ChatException; +import com.nect.api.domain.team.chat.infra.ChatRedisPublisher; import com.nect.core.entity.team.chat.ChatFile; import com.nect.core.entity.team.chat.ChatMessage; import com.nect.core.entity.team.chat.ChatRoom; @@ -42,7 +43,7 @@ public class ChatService { private final ChatRoomRepository chatRoomRepository; private final ChatRoomUserRepository chatRoomUserRepository; private final ChatMessageRepository chatMessageRepository; - private final RedisPublisher redisPublisher; + private final ChatRedisPublisher redisPublisher; private final UserRepository userRepository; private final ChatFileRepository chatFileRepository; @@ -70,13 +71,14 @@ public ChatMessageDto sendMessage(Long roomId, Long userId, String content) { // DTO 변환 ChatMessageDto messageDto = ChatConverter.toMessageDto(message); + //Redis 발행 + redisPublisher.publish(roomId, messageDto); // readCount = 안 읽은 사람 수 (전체 인원 - 1(본인)) int totalMembers = chatRoomUserRepository.countByChatRoomId(roomId); messageDto.setReadCount(totalMembers - 1); // 메시지 발행 - String channel = "chatroom:" + roomId; - redisPublisher.publish(channel, messageDto); + redisPublisher.publish(roomId, messageDto); return messageDto; } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/RedisPublisher.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/RedisPublisher.java deleted file mode 100644 index 2036d1ce..00000000 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/RedisPublisher.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.nect.api.domain.team.chat.service; - -import com.nect.api.domain.team.chat.dto.req.ChatMessageDto; -import com.nect.api.domain.team.chat.enums.ChatErrorCode; -import com.nect.api.domain.team.chat.exeption.ChatException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class RedisPublisher { - - - private final RedisTemplate objectRedisTemplate; - - // 메시지 발행 - public void publish(String channel, ChatMessageDto message) { - try { - - objectRedisTemplate.convertAndSend(channel, message); - log.info("Redis 메시지 발행 성공 - Channel: {}, Sender: {}", - channel, message.getUserName()); - } catch (Exception e) { - throw new ChatException( - ChatErrorCode.REDIS_PUBLISH_FAILED, - "Channel: " + channel, - e - ); - } - } - - -} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/RedisSubscriber.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/RedisSubscriber.java deleted file mode 100644 index e53a3844..00000000 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/RedisSubscriber.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.nect.api.domain.team.chat.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.nect.api.domain.team.chat.dto.req.ChatMessageDto; -import com.nect.api.domain.team.chat.enums.ChatErrorCode; -import com.nect.api.domain.team.chat.exeption.ChatException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.connection.Message; // import 주의 -import org.springframework.data.redis.connection.MessageListener; // import 주의 -import org.springframework.messaging.simp.SimpMessageSendingOperations; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor - -public class RedisSubscriber implements MessageListener { // MessageListener 인터페이스 구현 - - private final ObjectMapper objectMapper; - private final SimpMessageSendingOperations messagingTemplate; - - @Override - public void onMessage(Message message, byte[] pattern) { - try { - String publishMessage = new String(message.getBody()); - - - ChatMessageDto chatMessage = objectMapper.readValue(publishMessage, ChatMessageDto.class); - - String destination = "/topic/chatroom/" + chatMessage.getRoomId(); - - messagingTemplate.convertAndSend(destination, chatMessage); - - - } catch (Exception e) { - throw new ChatException(ChatErrorCode.REDIS_SUBSCRIBE_FAILED, "Message handling failed", e); - } - } -} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/TeamChatService.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/TeamChatService.java index 6f5fba48..9645e675 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/TeamChatService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/TeamChatService.java @@ -1,7 +1,6 @@ package com.nect.api.domain.team.chat.service; import com.nect.api.domain.team.chat.converter.ChatConverter; -import com.nect.api.domain.team.chat.dto.req.ChatRoomCreateRequestDto; import com.nect.api.domain.team.chat.dto.req.ChatRoomInviteRequestDto; import com.nect.api.domain.team.chat.dto.req.GroupChatRoomCreateRequestDto; import com.nect.api.domain.team.chat.dto.res.ChatRoomInviteResponseDto; @@ -10,6 +9,7 @@ import com.nect.api.domain.team.chat.dto.res.ProjectMemberResponseDto; import com.nect.api.domain.team.chat.enums.ChatErrorCode; import com.nect.api.domain.team.chat.exeption.ChatException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.Project; import com.nect.core.entity.team.ProjectUser; import com.nect.core.entity.team.chat.ChatRoom; @@ -42,15 +42,13 @@ public class TeamChatService { private final ProjectUserRepository projectUserRepository; private final ProjectRepository projectRepository; private final ChatService chatService; + private final S3Service s3Service; public List getProjectMembers(Long projectId) { List members = projectUserRepository.findAllUsersByProjectId(projectId); return ChatConverter.toProjectMemberResponseDTOList(members); } - - - // 팀 채팅방 생성 @Transactional public ChatRoomResponseDto createGroupChatRoom(Long currentUserId, GroupChatRoomCreateRequestDto request) { @@ -114,7 +112,7 @@ public ChatRoomResponseDto createGroupChatRoom(Long currentUserId, GroupChatRoom chatRoomUserRepository.saveAll(members); List profileImages = members.stream() - .map(member -> member.getUser().getProfileImageUrl()) + .map(member -> s3Service.getPresignedGetUrl(member.getUser().getProfileImageName())) .filter(StringUtils::hasText) .limit(4) .collect(Collectors.toList()); @@ -198,7 +196,7 @@ public ChatRoomInviteResponseDto inviteMembers( .collect(Collectors.toList()); List profileImages = newMembers.stream() - .map(User::getProfileImageUrl) + .map( u -> s3Service.getPresignedGetUrl(u.getProfileImageName())) .collect(Collectors.toList()); return ChatRoomInviteResponseDto.builder() @@ -251,7 +249,7 @@ public List getProjectMembers(Long projectId, Long currentUser .userId(user.getUserId()) .nickname(user.getNickname()) .name(user.getName()) - .profileImage(user.getProfileImageUrl()) + .profileImage(s3Service.getPresignedGetUrl(user.getProfileImageName())) .build()) .collect(Collectors.toList()); } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/util/FileValidator.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/util/FileValidator.java index f7b15672..fc4d3408 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/util/FileValidator.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/util/FileValidator.java @@ -1,6 +1,6 @@ package com.nect.api.domain.team.chat.util; -import com.nect.api.global.infra.exception.StorageErrorCode; +import com.nect.api.global.code.StorageErrorCode; import com.nect.api.global.infra.exception.StorageException; import org.springframework.web.multipart.MultipartFile; diff --git a/nect-api/src/main/java/com/nect/api/global/code/RedisErrorCode.java b/nect-api/src/main/java/com/nect/api/global/code/RedisErrorCode.java new file mode 100644 index 00000000..15faee42 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/global/code/RedisErrorCode.java @@ -0,0 +1,15 @@ +package com.nect.api.global.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum RedisErrorCode implements ResponseCode { + + REDIS_PUBLISH_FAILED("RDS_301", "Redis 메시지 발행에 실패했습니다"), + REDIS_SUBSCRIBE_FAILED("RDS_302", "Redis 메시지 수신에 실패했습니다"); + + private final String statusCode; + private final String message; +} diff --git a/nect-api/src/main/java/com/nect/api/global/infra/exception/StorageErrorCode.java b/nect-api/src/main/java/com/nect/api/global/code/StorageErrorCode.java similarity index 90% rename from nect-api/src/main/java/com/nect/api/global/infra/exception/StorageErrorCode.java rename to nect-api/src/main/java/com/nect/api/global/code/StorageErrorCode.java index db5e6389..5f8b5ff7 100644 --- a/nect-api/src/main/java/com/nect/api/global/infra/exception/StorageErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/global/code/StorageErrorCode.java @@ -1,6 +1,5 @@ -package com.nect.api.global.infra.exception; +package com.nect.api.global.code; -import com.nect.api.global.code.ResponseCode; import lombok.AllArgsConstructor; import lombok.Getter; @@ -8,6 +7,7 @@ @AllArgsConstructor public enum StorageErrorCode implements ResponseCode { + S3_EXCEPTION("400_0", "S3 호출 중 오류 발생"), EMPTY_FILE("400_1", "파일이 비어있습니다."), EMPTY_FILE_NAME("400_2", "파일 이름이 비어있습니다."), INVALID_FILE_TYPE("400_3", "이미지 파일(jpg, jpeg, png)만 업로드 가능합니다."), diff --git a/nect-api/src/main/java/com/nect/api/global/config/RedisConfig.java b/nect-api/src/main/java/com/nect/api/global/config/RedisConfig.java index a446008e..5ec49756 100644 --- a/nect-api/src/main/java/com/nect/api/global/config/RedisConfig.java +++ b/nect-api/src/main/java/com/nect/api/global/config/RedisConfig.java @@ -3,7 +3,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.nect.api.domain.team.chat.service.RedisSubscriber; +import com.nect.api.global.infra.redis.RedisMessageHandler; +import com.nect.api.global.infra.redis.RedisSubscriber; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -16,6 +17,8 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; +import java.util.List; + @Configuration public class RedisConfig { @@ -68,11 +71,13 @@ public RedisTemplate objectRedisTemplate() { @Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory, - MessageListenerAdapter listenerAdapter) { + MessageListenerAdapter listenerAdapter, + List handlers) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); - // 모든 채팅방 구독 - container.addMessageListener(listenerAdapter, new PatternTopic("chatroom:*")); + for (RedisMessageHandler handler : handlers) { + container.addMessageListener(listenerAdapter, new PatternTopic(handler.channelPrefix() + "*")); + } return container; } @@ -85,4 +90,4 @@ public MessageListenerAdapter listenerAdapter(RedisSubscriber subscriber) { return new MessageListenerAdapter(subscriber, "onMessage"); } -} \ No newline at end of file +} diff --git a/nect-api/src/main/java/com/nect/api/global/infra/S3Service.java b/nect-api/src/main/java/com/nect/api/global/infra/S3Service.java index 48bdad65..adcb44bb 100644 --- a/nect-api/src/main/java/com/nect/api/global/infra/S3Service.java +++ b/nect-api/src/main/java/com/nect/api/global/infra/S3Service.java @@ -1,11 +1,13 @@ package com.nect.api.global.infra; import com.amazonaws.HttpMethod; +import com.amazonaws.SdkClientException; import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.AmazonS3Exception; import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; -import com.nect.api.global.infra.exception.StorageErrorCode; +import com.nect.api.global.code.StorageErrorCode; import com.nect.api.global.infra.exception.StorageException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -53,12 +55,26 @@ public String getPresignedGetUrl(String fileName) { Date expiration = new Date(System.currentTimeMillis() + PRESIGNED_EXPIRE_MILLIS); - GeneratePresignedUrlRequest request = - new GeneratePresignedUrlRequest(bucket, fileName) - .withMethod(HttpMethod.GET) - .withExpiration(expiration); + URL url; + try { + GeneratePresignedUrlRequest request = + new GeneratePresignedUrlRequest(bucket, fileName) + .withMethod(HttpMethod.GET) + .withExpiration(expiration); + + url = amazonS3.generatePresignedUrl(request); + } catch (AmazonS3Exception e) { + if (e.getStatusCode() == 404) { + throw new StorageException(StorageErrorCode.FILE_NOT_FOUND); + } + throw new StorageException(StorageErrorCode.FILE_DOWNLOAD_FAILED); + + } catch (SdkClientException | IllegalArgumentException e) { + throw new StorageException(StorageErrorCode.FILE_DOWNLOAD_FAILED); + } catch (Exception e) { + throw new StorageException(StorageErrorCode.S3_EXCEPTION); + } - URL url = amazonS3.generatePresignedUrl(request); return url.toString(); } diff --git a/nect-api/src/main/java/com/nect/api/global/infra/exception/RedisException.java b/nect-api/src/main/java/com/nect/api/global/infra/exception/RedisException.java new file mode 100644 index 00000000..1ad5bc65 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/global/infra/exception/RedisException.java @@ -0,0 +1,19 @@ +package com.nect.api.global.infra.exception; + +import com.nect.api.global.code.ResponseCode; +import com.nect.api.global.exception.CustomException; + +public class RedisException extends CustomException { + + public RedisException(ResponseCode code) { + super(code); + } + + public RedisException(ResponseCode code, String message) { + super(code, message); + } + + public RedisException(ResponseCode code, String message, Throwable cause) { + super(code, message, cause); + } +} diff --git a/nect-api/src/main/java/com/nect/api/global/infra/redis/RedisMessageHandler.java b/nect-api/src/main/java/com/nect/api/global/infra/redis/RedisMessageHandler.java new file mode 100644 index 00000000..7fba07c2 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/global/infra/redis/RedisMessageHandler.java @@ -0,0 +1,9 @@ +package com.nect.api.global.infra.redis; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.messaging.simp.SimpMessageSendingOperations; + +public interface RedisMessageHandler { + String channelPrefix(); + void handle(String channel, String payload, ObjectMapper objectMapper, SimpMessageSendingOperations messagingTemplate) throws Exception; +} diff --git a/nect-api/src/main/java/com/nect/api/global/infra/redis/RedisPublisher.java b/nect-api/src/main/java/com/nect/api/global/infra/redis/RedisPublisher.java new file mode 100644 index 00000000..c1008305 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/global/infra/redis/RedisPublisher.java @@ -0,0 +1,26 @@ +package com.nect.api.global.infra.redis; + +import com.nect.api.global.code.RedisErrorCode; +import com.nect.api.global.infra.exception.RedisException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RedisPublisher { + + + private final RedisTemplate objectRedisTemplate; + + public void publish(String channel, Object message) { + try { + objectRedisTemplate.convertAndSend(channel, message); + log.info("Redis 메시지 발행 성공 - Channel: {}", channel); + } catch (Exception e) { + throw new RedisException(RedisErrorCode.REDIS_PUBLISH_FAILED, "Channel: " + channel, e); + } + } +} diff --git a/nect-api/src/main/java/com/nect/api/global/infra/redis/RedisSubscriber.java b/nect-api/src/main/java/com/nect/api/global/infra/redis/RedisSubscriber.java new file mode 100644 index 00000000..3d69f439 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/global/infra/redis/RedisSubscriber.java @@ -0,0 +1,47 @@ +package com.nect.api.global.infra.redis; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.global.code.RedisErrorCode; +import com.nect.api.global.infra.exception.RedisException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.Message; // import 주의 +import org.springframework.data.redis.connection.MessageListener; // import 주의 +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RedisSubscriber implements MessageListener { // MessageListener 인터페이스 구현 + + private final ObjectMapper objectMapper; + private final SimpMessageSendingOperations messagingTemplate; + private final List handlers; + + @Override + public void onMessage(Message message, byte[] pattern) { + String channel = new String(message.getChannel()); + try { + // Redis에서 온 메시지 String으로 변환 + String publishMessage = new String(message.getBody()); + + log.info(" Redis Subscriber 수신 - Channel: {}, Message: {}", channel, publishMessage); + + for (RedisMessageHandler handler : handlers) { + if (channel.startsWith(handler.channelPrefix())) { + handler.handle(channel, publishMessage, objectMapper, messagingTemplate); + return; + } + } + + log.warn(" Redis Subscriber 미지원 채널 - Channel: {}", channel); + + } catch (Exception e) { + log.error(" Redis 메시지 처리 실패", e); + throw new RedisException(RedisErrorCode.REDIS_SUBSCRIBE_FAILED, "Channel: " + channel, e); + } + } +} diff --git a/nect-api/src/main/java/com/nect/api/global/infra/s3/ImageService.java b/nect-api/src/main/java/com/nect/api/global/infra/s3/ImageService.java new file mode 100644 index 00000000..fc5a6e98 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/global/infra/s3/ImageService.java @@ -0,0 +1,7 @@ +package com.nect.api.global.infra.s3; + +interface ImageService { + + + +} diff --git a/nect-api/src/test/java/com/nect/api/domain/dm/controller/DmQueryControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/dm/controller/DmQueryControllerTest.java new file mode 100644 index 00000000..4faa0a8d --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/dm/controller/DmQueryControllerTest.java @@ -0,0 +1,228 @@ +package com.nect.api.domain.dm.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.nect.api.domain.dm.dto.DirectMessageDto; +import com.nect.api.domain.dm.dto.DmMessageListResponse; +import com.nect.api.domain.dm.dto.DmRoomListResponse; +import com.nect.api.domain.dm.dto.DmRoomSummaryDto; +import com.nect.api.domain.dm.service.DmService; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class DmQueryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private DmService dmService; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + private static final String AUTH_HEADER = "Authorization"; + private static final String TEST_ACCESS_TOKEN = "Bearer AccessToken"; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_USER")) + .build() + ); + } + + @Test + @DisplayName("DM 메시지 조회 API") + void getMessages() throws Exception { + given(dmService.getMessages(eq(1L), eq(2L), eq(100L), eq(20))) + .willReturn(mockMessageListResponse()); + + mockMvc.perform(get("/api/v1/dms/messages") + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .param("userId", "2") + .param("cursor", "100") + .param("size", "20") + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(document("dm-messages", + resource(ResourceSnippetParameters.builder() + .tag("개인 메시지") + .summary("DM 메시지 조회") + .description("상대 유저와의 DM 메시지 목록을 조회합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .queryParameters( + parameterWithName("userId").description("상대 유저 ID"), + parameterWithName("cursor").optional().description("커서 (마지막 메시지 ID)"), + parameterWithName("size").optional().description("조회 개수 (기본 20)") + ) + .responseFields(messageResponseFields()) + .build() + ) + )); + } + + @Test + @DisplayName("DM 채팅방 목록 조회 API") + void getRooms() throws Exception { + given(dmService.getRooms(eq(1L), eq(200L), eq(20))) + .willReturn(mockRoomListResponse()); + + mockMvc.perform(get("/api/v1/dms/rooms") + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .param("cursor", "200") + .param("size", "20") + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(document("dm-rooms", + resource(ResourceSnippetParameters.builder() + .tag("개인 메시지") + .summary("DM 채팅방 목록 조회") + .description("로그인 유저의 DM 채팅방 목록을 조회합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .queryParameters( + parameterWithName("cursor").optional().description("커서 (마지막 메시지 ID)"), + parameterWithName("size").optional().description("조회 개수 (기본 20)") + ) + .responseFields(roomResponseFields()) + .build() + ) + )); + } + + private DmMessageListResponse mockMessageListResponse() { + List messages = List.of( + new DirectMessageDto( + 10L, + 1L, + "홍길동", + "https://example.com/profile/1.png", + "안녕하세요!", + false, + LocalDateTime.of(2024, 1, 1, 12, 0, 0), + true + ), + new DirectMessageDto( + 11L, + 2L, + "김철수", + "https://example.com/profile/2.png", + "반가워요!", + false, + LocalDateTime.of(2024, 1, 1, 12, 1, 0), + false + ) + ); + + return DmMessageListResponse.builder() + .messages(messages) + .nextCursor(11L) + .build(); + } + + private DmRoomListResponse mockRoomListResponse() { + List rooms = List.of( + new DmRoomSummaryDto( + 2L, + 2L, + "https://example.com/profile/2.png", + "Backend", + 20L, + "최근 메시지", + LocalDate.of(2024, 1, 2), + false + ) + ); + + return DmRoomListResponse.builder() + .messages(rooms) + .nextCursor(20L) + .build(); + } + + private static List messageResponseFields() { + return List.of( + fieldWithPath("status.statusCode").description("응답 상태 코드"), + fieldWithPath("status.message").description("응답 메시지"), + fieldWithPath("status.description").optional().description("응답 상세 설명"), + fieldWithPath("body.messages").description("DM 메시지 목록"), + fieldWithPath("body.messages[].message_id").description("메시지 ID"), + fieldWithPath("body.messages[].sender_id").description("보낸 유저 ID"), + fieldWithPath("body.messages[].sender_name").description("보낸 유저 이름"), + fieldWithPath("body.messages[].sender_profile_image").description("보낸 유저 프로필 이미지 URL"), + fieldWithPath("body.messages[].content").description("메시지 내용"), + fieldWithPath("body.messages[].is_pinned").description("고정 여부"), + fieldWithPath("body.messages[].created_at").description("메시지 생성 시간"), + fieldWithPath("body.messages[].is_read").description("읽음 여부"), + fieldWithPath("body.next_cursor").description("다음 커서 (마지막 메시지 ID)").optional() + ); + } + + private static List roomResponseFields() { + return List.of( + fieldWithPath("status.statusCode").description("응답 상태 코드"), + fieldWithPath("status.message").description("응답 메시지"), + fieldWithPath("status.description").optional().description("응답 상세 설명"), + fieldWithPath("body.messages").description("DM 채팅방 목록"), + fieldWithPath("body.messages[].other_user_id").description("상대 유저 ID"), + fieldWithPath("body.messages[].other_user_name").description("상대 유저 이름"), + fieldWithPath("body.messages[].other_user_image_url").description("상대 유저 프로필 이미지 URL"), + fieldWithPath("body.messages[].other_user_role_field").description("상대 유저 역할"), + fieldWithPath("body.messages[].last_message_id").description("마지막 메시지 ID"), + fieldWithPath("body.messages[].last_message").description("마지막 메시지 내용"), + fieldWithPath("body.messages[].last_message_at").description("마지막 메시지 일자"), + fieldWithPath("body.messages[].is_read").description("읽음 여부"), + fieldWithPath("body.nextCursor").description("다음 커서 (마지막 메시지 ID)").optional() + ); + } +} diff --git a/nect-api/src/test/java/com/nect/api/domain/home/controller/HomeControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/home/controller/HomeControllerTest.java index afa7ef77..3daa5f54 100644 --- a/nect-api/src/test/java/com/nect/api/domain/home/controller/HomeControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/home/controller/HomeControllerTest.java @@ -1,6 +1,7 @@ package com.nect.api.domain.home.controller; import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.nect.api.domain.home.dto.HomeHeaderResponse; import com.nect.api.domain.home.dto.HomeMemberItem; import com.nect.api.domain.home.dto.HomeMembersResponse; import com.nect.api.domain.home.dto.HomeProjectItem; @@ -10,7 +11,8 @@ import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; import com.nect.api.global.security.UserDetailsServiceImpl; -import com.nect.core.repository.matching.RecruitmentRepository; +import com.nect.core.entity.user.enums.InterestField; +import com.nect.core.entity.user.enums.Role; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -52,9 +54,6 @@ class HomeControllerTest { @MockitoBean private MainHomeFacade mainHomeFacade; - @MockitoBean - private RecruitmentRepository recruitmentRepository; - @MockitoBean private JwtUtil jwtUtil; @@ -83,12 +82,14 @@ void setUpAuth() { @Test @DisplayName("모집 중인 프로젝트 조회 API") void 모집_중인_프로젝트_조회_API() throws Exception { - given(mainHomeFacade.getRecruitingProjects(eq(1L), eq(3))) + given(mainHomeFacade.getRecruitingProjects(eq(1L), eq(3), eq(Role.DEVELOPER), eq(InterestField.IT_WEB_MOBILE))) .willReturn(mockProjectResponse()); mockMvc.perform(get("/api/v1/home/projects") .header(AUTH_HEADER, TEST_ACCESS_TOKEN) .param("count", "3") + .param("role", "DEVELOPER") + .param("interest", "IT_WEB_MOBILE") .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) @@ -101,7 +102,13 @@ void setUpAuth() { headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") ) .queryParameters( - parameterWithName("count").description("조회할 프로젝트 개수") + parameterWithName("count").description("조회할 프로젝트 개수"), + parameterWithName("role") + .optional() + .description("필터 역할 (role과 interest는 모두 null이거나 모두 null이 아니어야 함; enum 조회는 /api/v1/enums/roles)"), + parameterWithName("interest") + .optional() + .description("필터 관심 분야 (role과 interest는 모두 null이거나 모두 null이 아니어야 함; enum 조회는 /api/v1/enums/interest-fields)") ) .responseFields(projectResponseFields()) .build() @@ -141,12 +148,14 @@ void setUpAuth() { @Test @DisplayName("홈화면 매칭 가능한 넥터 API") void 홈화면_매칭_가능한_넥터_API() throws Exception { - given(mainHomeFacade.getMatchableMembers(eq(1L), eq(3))) + given(mainHomeFacade.getMatchableMembers(eq(1L), eq(3), eq(Role.DEVELOPER), eq(InterestField.IT_WEB_MOBILE))) .willReturn(mockMembersResponse()); mockMvc.perform(get("/api/v1/home/members") .header(AUTH_HEADER, TEST_ACCESS_TOKEN) .param("count", "3") + .param("role", "DEVELOPER") + .param("interest", "IT_WEB_MOBILE") .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) @@ -159,7 +168,13 @@ void setUpAuth() { headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") ) .queryParameters( - parameterWithName("count").description("조회할 넥터 개수") + parameterWithName("count").description("조회할 넥터 개수"), + parameterWithName("role") + .optional() + .description("필터 역할 (role과 interest는 모두 null이거나 모두 null이 아니어야 함; enum 조회는 /api/v1/enums/roles)"), + parameterWithName("interest") + .optional() + .description("필터 관심 분야 (role과 interest는 모두 null이거나 모두 null이 아니어야 함; enum 조회는 /api/v1/enums/interest-fields)") ) .responseFields(memberResponseFields()) .build() @@ -196,14 +211,39 @@ void setUpAuth() { )); } + @Test + @DisplayName("홈화면 헤더 프로필 API") + void 홈화면_헤더_프로필_API() throws Exception { + given(mainHomeFacade.getHeaderProfile(eq(1L))) + .willReturn(mockHeaderProfileResponse()); + + mockMvc.perform(get("/api/v1/home/profile") + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(document("home-header-profile", + resource(ResourceSnippetParameters.builder() + .tag("홈") + .summary("홈화면 헤더 프로필") + .description("홈 화면 헤더에 표시할 프로필 정보를 조회합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .responseFields(headerProfileResponseFields()) + .build() + ) + )); + } + private HomeProjectResponse mockProjectResponse() { - return new HomeProjectResponse(List.of( - new HomeProjectItem( + return HomeProjectResponse.of(List.of( + HomeProjectItem.of( 10L, - 1001L, + "https://imageUrl", "AI 협업툴 개발", "홍길동", - "Backend", + "DEVELOPER", "팀 협업 효율을 높이는 AI 기반 협업툴 프로젝트입니다.", 12, 6, @@ -212,12 +252,12 @@ private HomeProjectResponse mockProjectResponse() { "모집 중", Map.of("Backend", 2, "Design", 1) ), - new HomeProjectItem( + HomeProjectItem.of( 11L, - 1002L, + "https://imageUrl", "모바일 일정 관리", "김철수", - "PM", + "PLANNER", "개인 맞춤 일정 관리 앱을 개발합니다.", 7, 5, @@ -229,25 +269,35 @@ private HomeProjectResponse mockProjectResponse() { )); } + private HomeHeaderResponse mockHeaderProfileResponse() { + return HomeHeaderResponse.of( + 1L, + "https://example.com/profile/1.png", + "홍길동", + "honggildong@example.com", + Role.DEVELOPER + ); + } + private HomeMembersResponse mockMembersResponse() { - return new HomeMembersResponse(List.of( - new HomeMemberItem( + return HomeMembersResponse.of(List.of( + HomeMemberItem.of( 21L, "https://example.com/profile/21.png", "이영희", - "Design", + "DESIGNER", "사용자 경험 중심의 디자인을 지향합니다.", - "매칭 가능", + "JOB_SEEKING", true, List.of("PM", "Design") ), - new HomeMemberItem( + HomeMemberItem.of( 22L, "https://example.com/profile/22.png", "박민수", - "Backend", + "DEVELOPER", "대규모 트래픽 처리를 경험했습니다.", - "매칭 가능", + "EMPLOYED", false, List.of("Server", "Frontend") ) @@ -261,7 +311,7 @@ private static List projectResponseFields() { fieldWithPath("status.description").optional().description("응답 상세 설명"), fieldWithPath("body.projects").description("프로젝트 목록"), fieldWithPath("body.projects[].projectId").description("프로젝트 ID"), - fieldWithPath("body.projects[].imageUrl").description("프로젝트 이미지 ID"), + fieldWithPath("body.projects[].imageUrl").description("프로젝트 이미지 URL"), fieldWithPath("body.projects[].projectName").description("프로젝트 이름"), fieldWithPath("body.projects[].authorName").description("작성자 이름"), fieldWithPath("body.projects[].authorPart").description("작성자 파트"), @@ -289,7 +339,7 @@ private static List memberResponseFields() { fieldWithPath("body.members[].userId").description("유저 ID"), fieldWithPath("body.members[].imageUrl").description("프로필 이미지 URL"), fieldWithPath("body.members[].name").description("이름"), - fieldWithPath("body.members[].part").description("파트"), + fieldWithPath("body.members[].part").description("파트(역할)"), fieldWithPath("body.members[].introduction").description("소개"), fieldWithPath("body.members[].status").description("상태"), fieldWithPath("body.members[].isScrapped").description("스크랩 여부"), @@ -297,4 +347,17 @@ private static List memberResponseFields() { ); } + private static List headerProfileResponseFields() { + return List.of( + fieldWithPath("status.statusCode").description("응답 상태 코드"), + fieldWithPath("status.message").description("응답 메시지"), + fieldWithPath("status.description").optional().description("응답 상세 설명"), + fieldWithPath("body.userId").description("유저 ID"), + fieldWithPath("body.imageUrl").description("프로필 이미지 URL"), + fieldWithPath("body.name").description("이름"), + fieldWithPath("body.email").description("이메일"), + fieldWithPath("body.role").description("역할") + ); + } + } diff --git a/nect-api/src/test/java/com/nect/api/domain/notifications/controller/NotificationEnumsControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/notifications/controller/NotificationEnumsControllerTest.java index 105a897d..2e656300 100644 --- a/nect-api/src/test/java/com/nect/api/domain/notifications/controller/NotificationEnumsControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/notifications/controller/NotificationEnumsControllerTest.java @@ -134,6 +134,28 @@ void setUpAuth() { )); } + @Test + @DisplayName("알림 검색 필터 enum 조회 API") + void 알림_검색_필터_enum_조회_API() throws Exception { + mockMvc.perform(get("/api/v1/enums/notifications/filters") + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(document("notifications-enums-filters", + resource(ResourceSnippetParameters.builder() + .tag("알림") + .summary("알림 검색 필터 enum 조회") + .description("알림 검색 필터(NotificationSearchFilter) enum 목록을 조회합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .responseFields(filterListResponseFields()) + .build() + ) + )); + } + private static List enumListResponseFields() { return List.of( fieldWithPath("status.statusCode").description("응답 상태 코드"), @@ -156,4 +178,15 @@ private static List matchingRejectedResponseFields() { fieldWithPath("body.hasContent").description("서브 메시지 제공 여부") ); } + + private static List filterListResponseFields() { + return List.of( + fieldWithPath("status.statusCode").description("응답 상태 코드"), + fieldWithPath("status.message").description("응답 메시지"), + fieldWithPath("status.description").optional().description("응답 상세 설명"), + fieldWithPath("body").description("검색 필터 목록"), + fieldWithPath("body[].value").description("enum 값"), + fieldWithPath("body[].scopes").description("해당 필터에 포함되는 scope 목록") + ); + } } diff --git a/nect-api/src/test/java/com/nect/api/notifications/controller/NotificationControllerTest.java b/nect-api/src/test/java/com/nect/api/notifications/controller/NotificationControllerTest.java index 891cdd9b..eb074f5a 100644 --- a/nect-api/src/test/java/com/nect/api/notifications/controller/NotificationControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/notifications/controller/NotificationControllerTest.java @@ -9,6 +9,7 @@ import com.nect.api.global.security.UserDetailsImpl; import com.nect.api.global.security.UserDetailsServiceImpl; import com.nect.core.entity.notifications.enums.NotificationClassification; +import com.nect.api.domain.notifications.enums.code.NotificationSearchFilter; import com.nect.core.entity.notifications.enums.NotificationScope; import com.nect.core.entity.notifications.enums.NotificationType; import org.junit.jupiter.api.BeforeEach; @@ -81,14 +82,14 @@ void setUpAuth() { given(notificationService.getNotifications( any(), - eq(NotificationScope.MAIN_HOME), + eq(NotificationSearchFilter.EXPLORATION), eq(null), eq(20) )).willReturn(mockResponse()); mockMvc.perform(get("/api/v1/notifications") .header(AUTH_HEADER, TEST_ACCESS_TOKEN) - .param("scope", "MAIN_HOME") + .param("filter", "EXPLORATION") .param("size", "20") .accept(MediaType.APPLICATION_JSON) ) @@ -108,8 +109,8 @@ void setUpAuth() { headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") ) .queryParameters( - parameterWithName("scope") - .description("알림 범위 (MAIN_HOME, WORKSPACE_ONLY, WORKSPACE_GLOBAL)"), + parameterWithName("filter") + .description("알림 필터 (EXPLORATION, WORKSPACE_ONLY, WORKSPACE_GLOBAL, WORKSPACES)"), parameterWithName("cursor") .optional() .description("커서 기반 페이징용 알림 ID"), @@ -138,6 +139,7 @@ void setUpAuth() { fieldWithPath("body.notifications[].targetId") .description("알림 대상 ID"), fieldWithPath("body.notifications[].projectId") + .optional() .description("프로젝트 ID"), fieldWithPath("body.notifications[].createdDate") .description("알림 생성일 (yy.MM.dd)"), @@ -145,7 +147,7 @@ void setUpAuth() { .optional() .description("알림 분류 (한글)"), fieldWithPath("body.notifications[].isRead") - .description("다음 페이지 조회용 커서"), + .description("읽음 여부"), fieldWithPath("body.notifications[].type") .description("알림 타입"), fieldWithPath("body.notifications[].scope") diff --git a/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatControllerTest.java b/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatControllerTest.java index 201f7320..80648d5b 100644 --- a/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatControllerTest.java @@ -2,29 +2,34 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.chat.controller.ChatMessageController; +import com.nect.api.domain.team.chat.controller.TeamChatController; import com.nect.api.domain.team.chat.dto.req.*; import com.nect.api.domain.team.chat.dto.res.*; +import com.nect.api.domain.team.chat.facade.ChatFacade; import com.nect.api.domain.team.chat.service.ChatRoomService; import com.nect.api.domain.team.chat.service.ChatService; import com.nect.api.domain.team.chat.service.TeamChatService; -import com.nect.api.global.jwt.JwtUtil; -import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; -import com.nect.api.global.security.UserDetailsServiceImpl; import com.nect.core.entity.team.chat.enums.ChatRoomType; import com.nect.core.entity.team.chat.enums.MessageType; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import java.time.LocalDateTime; import java.util.Arrays; @@ -35,7 +40,6 @@ import static com.epages.restdocs.apispec.ResourceDocumentation.resource; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.doNothing; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; @@ -43,10 +47,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@SpringBootTest -@AutoConfigureMockMvc +@WebMvcTest(controllers = {ChatMessageController.class, TeamChatController.class}) +@ContextConfiguration(classes = {ChatMessageController.class, TeamChatController.class}) +@AutoConfigureMockMvc(addFilters = false) @AutoConfigureRestDocs -@Transactional class ChatControllerTest { @Autowired @@ -62,31 +66,36 @@ class ChatControllerTest { private ChatService chatService; @MockitoBean - private TeamChatService teamChatService; - - @MockitoBean - private JwtUtil jwtUtil; + private ChatFacade chatFacade; @MockitoBean - private UserDetailsServiceImpl userDetailsService; - - @MockitoBean - private TokenBlacklistService tokenBlacklistService; + private TeamChatService teamChatService; private static final String AUTH_HEADER = "Authorization"; private static final String TEST_ACCESS_TOKEN = "Bearer AccessToken"; - @BeforeEach - void setUpAuth() { - doNothing().when(jwtUtil).validateToken(anyString()); - given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); - given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); - given(userDetailsService.loadUserByUsername(anyString())).willReturn( - UserDetailsImpl.builder() - .userId(1L) - .roles(List.of("ROLE_USER")) - .build() - ); + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_USER")) + .build(); + + return request -> { + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + null, + principal.getAuthorities() + ); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + return request; + }; + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); } // ========== 1. 프로젝트 멤버 조회 ========== @@ -107,6 +116,7 @@ void getProjectMembers() throws Exception { mockMvc.perform( get("/api/v1/chats/projects/{projectId}/members", projectId) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .with(mockUser(1L)) .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) @@ -155,6 +165,7 @@ void getProjectMembersWithKeyword() throws Exception { mockMvc.perform( get("/api/v1/chats/projects/{projectId}/members", projectId) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .with(mockUser(1L)) .param("keyword", keyword) .accept(MediaType.APPLICATION_JSON) ) @@ -218,6 +229,7 @@ void createGroupChatRoom() throws Exception { mockMvc.perform( post("/api/v1/chats/rooms/group") .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .with(mockUser(1L)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) .accept(MediaType.APPLICATION_JSON) @@ -274,6 +286,7 @@ void getProjectChatRooms() throws Exception { mockMvc.perform( get("/api/v1/chats/projects/{projectId}/rooms", projectId) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .with(mockUser(1L)) .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) @@ -342,6 +355,7 @@ void getChatMessages() throws Exception { mockMvc.perform( get("/api/v1/chats/rooms/{room_id}/messages", roomId) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .with(mockUser(1L)) .param("size", "20") .accept(MediaType.APPLICATION_JSON) ) @@ -423,6 +437,7 @@ void searchMessages() throws Exception { mockMvc.perform( get("/api/v1/chats/rooms/{room_id}/messages/search", roomId) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .with(mockUser(1L)) .param("keyword", keyword) .param("page", "0") .param("size", "20") @@ -500,6 +515,7 @@ void updateNotice() throws Exception { mockMvc.perform( patch("/api/v1/chats/message/{message_id}/notice", messageId) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .with(mockUser(1L)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) .accept(MediaType.APPLICATION_JSON) @@ -561,6 +577,7 @@ void leaveChatRoom() throws Exception { mockMvc.perform( RestDocumentationRequestBuilders.delete("/api/v1/chats/{room_id}/leave", roomId) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .with(mockUser(1L)) .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) @@ -621,6 +638,7 @@ void inviteMembers() throws Exception { mockMvc.perform( post("/api/v1/chats/rooms/{roomId}/invite", roomId) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .with(mockUser(1L)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) .accept(MediaType.APPLICATION_JSON) diff --git a/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatFileControllerTest.java b/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatFileControllerTest.java index 504a2036..be479ef8 100644 --- a/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatFileControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatFileControllerTest.java @@ -1,9 +1,13 @@ package com.nect.api.team.chat.controller; +import com.epages.restdocs.apispec.ResourceDocumentation; import com.epages.restdocs.apispec.ResourceSnippetParameters; -import com.fasterxml.jackson.databind.ObjectMapper; import com.nect.api.domain.team.chat.dto.req.ChatMessageDto; -import com.nect.api.domain.team.chat.dto.res.*; +import com.nect.api.domain.team.chat.dto.res.ChatFileDetailDto; +import com.nect.api.domain.team.chat.dto.res.ChatFileResponseDto; +import com.nect.api.domain.team.chat.dto.res.ChatFileUploadResponseDto; +import com.nect.api.domain.team.chat.dto.res.ChatRoomAlbumDetailDto; +import com.nect.api.domain.team.chat.dto.res.ChatRoomAlbumResponseDto; import com.nect.api.domain.team.chat.service.ChatFileService; import com.nect.api.global.jwt.JwtUtil; import com.nect.api.global.jwt.service.TokenBlacklistService; @@ -19,9 +23,11 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -30,14 +36,28 @@ import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; import static com.epages.restdocs.apispec.ResourceDocumentation.resource; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doNothing; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.OBJECT; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.request.RequestDocumentation.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -47,8 +67,8 @@ @Transactional class ChatFileControllerTest { - @Autowired - private ObjectMapper objectMapper; + private static final String AUTH_HEADER = "Authorization"; + private static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; @Autowired private MockMvc mockMvc; @@ -65,400 +85,420 @@ class ChatFileControllerTest { @MockitoBean private TokenBlacklistService tokenBlacklistService; - private static final String AUTH_HEADER = "Authorization"; - private static final String TEST_ACCESS_TOKEN = "Bearer AccessToken"; - @BeforeEach void setUpAuth() { - doNothing().when(jwtUtil).validateToken(anyString()); - given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); - given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); - given(userDetailsService.loadUserByUsername(anyString())).willReturn( + doNothing().when(jwtUtil).validateToken(any()); + given(tokenBlacklistService.isBlacklisted(any())).willReturn(false); + given(jwtUtil.getUserIdFromToken(any())).willReturn(1L); + given(userDetailsService.loadUserByUsername(any())).willReturn( UserDetailsImpl.builder() .userId(1L) .roles(List.of("ROLE_USER")) .build() ); } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_USER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return authentication(auth); + } + @Test - @DisplayName("작업실 채팅방 파일 업로드 API") + @DisplayName("채팅 파일 업로드") void uploadFile() throws Exception { - // Given + long roomId = 10L; + long userId = 1L; + MockMultipartFile file = new MockMultipartFile( "file", - "test.png", - "image/png", - "test file content".getBytes() + "design.png", + MediaType.IMAGE_PNG_VALUE, + "dummy image bytes".getBytes() + ); + + ChatFileUploadResponseDto fileInfo = new ChatFileUploadResponseDto( + 200L, + "design.png", + "https://cdn.example.com/chat/files/200_design.png", + 2048L, + "PNG" ); - // ChatMessageDto 구조로 변경 ChatMessageDto response = ChatMessageDto.builder() - .messageId(100L) - .userId(1L) - .roomId(1L) - .userName("테스트유저") - .profileImage("https://example.com/profile.jpg") - .content("파일 전송") + .messageId(1000L) + .userId(userId) + .roomId(roomId) + .userName("민우") + .profileImage("/images/default-profile.png") + .content("파일 업로드") .messageType(MessageType.FILE) .isPinned(false) - .createdAt(LocalDateTime.now()) + .createdAt(LocalDateTime.of(2025, 1, 10, 12, 0)) .readCount(0) + .fileInfo(fileInfo) .build(); - // fileInfo 추가 - ChatFileUploadResponseDto fileInfo = new ChatFileUploadResponseDto( - 1L, - "test.png", - "https://r2.example.com/uuid_test.png", - 1024L, - "image/png" - ); - response.setFileInfo(fileInfo); - - given(chatFileService.uploadAndSendFile(anyLong(), any(), anyLong())) + given(chatFileService.uploadAndSendFile(eq(roomId), any(), eq(userId))) .willReturn(response); - // When & Then - mockMvc.perform(multipart("/api/v1/chats/{roomId}/files", 1L) - .file(file) - .header(AUTH_HEADER, TEST_ACCESS_TOKEN) - .contentType(MediaType.MULTIPART_FORM_DATA)) + mockMvc.perform( + multipart("/api/v1/chats/{roomId}/files", roomId) + .file(file) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.APPLICATION_JSON) + ) .andExpect(status().isOk()) - .andExpect(jsonPath("$.body.message_id").value(100L)) - .andExpect(jsonPath("$.body.message_type").value("FILE")) - .andExpect(jsonPath("$.body.file_info.file_id").value(1L)) - .andExpect(jsonPath("$.body.file_info.file_name").value("test.png")) + .andExpect(jsonPath("$.body.message_id").value(1000)) .andDo(document("chat-file-upload", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), + requestParts( + partWithName("file").description("업로드할 파일(MultipartFile)") + ), resource(ResourceSnippetParameters.builder() - .tag("채팅 파일") - .summary("파일 업로드 및 전송") - .description("채팅 메시지에 첨부할 이미지 파일을 Cloudflare R2에 업로드하고 " + - "실시간으로 채팅방에 전송합니다. " + - "jpg, jpeg, png 형식만 지원하며, 업로드된 파일은 DB에 메타데이터가 저장되고 " + - "5분 유효한 Presigned URL이 반환됩니다. " + - "Redis를 통해 채팅방의 모든 참여자에게 실시간으로 전달됩니다.") - .requestHeaders( - headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") - ) + .tag("채팅") + .summary("채팅 파일 업로드") + .description("채팅방에 파일을 업로드하고 파일 메시지를 전송합니다.") .pathParameters( - parameterWithName("roomId").description("파일을 업로드할 채팅방 ID") + ResourceDocumentation.parameterWithName("roomId").description("채팅방 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") ) .responseFields( - fieldWithPath("status.statusCode").description("상태 코드"), - fieldWithPath("status.message").description("상태 메시지"), - fieldWithPath("status.description").description("상세 설명").optional(), - fieldWithPath("body.message_id").description("생성된 메시지 ID"), - fieldWithPath("body.user_id").description("발신자 ID"), - fieldWithPath("body.room_id").description("채팅방 ID"), - fieldWithPath("body.user_name").description("발신자 이름"), - fieldWithPath("body.profile_image").description("발신자 프로필 이미지").optional(), - fieldWithPath("body.content").description("메시지 내용"), - fieldWithPath("body.message_type").description("메시지 타입 (FILE)"), - fieldWithPath("body.is_pinned").description("고정 여부"), - fieldWithPath("body.created_at").description("메시지 생성 시간"), - fieldWithPath("body.read_count").description("읽지 않은 사용자 수"), - fieldWithPath("body.file_info").description("파일 정보"), - fieldWithPath("body.file_info.file_id").description("생성된 파일 고유 ID"), - fieldWithPath("body.file_info.file_name").description("원본 파일 이름"), - fieldWithPath("body.file_info.file_url").description("Presigned URL (5분 유효)"), - fieldWithPath("body.file_info.file_size").description("파일 크기 (bytes)"), - fieldWithPath("body.file_info.file_type").description("파일 MIME 타입") + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.message_id").type(NUMBER).description("메시지 ID"), + fieldWithPath("body.user_id").type(NUMBER).description("보낸 사람 유저 ID"), + fieldWithPath("body.room_id").type(NUMBER).description("채팅방 ID"), + fieldWithPath("body.user_name").type(STRING).description("보낸 사람 이름"), + fieldWithPath("body.profile_image").type(STRING).description("프로필 이미지 URL"), + fieldWithPath("body.content").type(STRING).description("메시지 내용"), + fieldWithPath("body.message_type").type(STRING).description("메시지 타입"), + fieldWithPath("body.is_pinned").type(BOOLEAN).description("고정 여부"), + fieldWithPath("body.created_at").type(STRING).description("생성일시(ISO-8601)"), + fieldWithPath("body.read_count").type(NUMBER).description("읽음 수"), + fieldWithPath("body.file_info").type(OBJECT).description("파일 정보"), + fieldWithPath("body.file_info.file_id").type(NUMBER).description("파일 ID"), + fieldWithPath("body.file_info.file_name").type(STRING).description("파일명"), + fieldWithPath("body.file_info.file_url").type(STRING).description("파일 URL"), + fieldWithPath("body.file_info.file_size").type(NUMBER).description("파일 크기(bytes)"), + fieldWithPath("body.file_info.file_type").type(STRING).description("파일 확장자") ) .build() ) )); + + verify(chatFileService).uploadAndSendFile(eq(roomId), any(), eq(userId)); } @Test - @DisplayName("작업실 채팅방 파일 업로드 API 클라우드 조회 API") - void getProjectAlbum() throws Exception { - // Given - ChatFileResponseDto file1 = new ChatFileResponseDto( - "photo1.png", - "https://r2.example.com/uuid_photo1.png", - LocalDateTime.now() - ); - - ChatFileResponseDto file2 = new ChatFileResponseDto( - "photo2.jpg", - "https://r2.example.com/uuid_photo2.jpg", - LocalDateTime.now() - ); - - ChatRoomAlbumResponseDto album1 = new ChatRoomAlbumResponseDto( - 1L, - "넥트 전체방", - "GROUP", - 24, - List.of(file1, file2) - ); - - ChatRoomAlbumResponseDto album2 = new ChatRoomAlbumResponseDto( - 2L, - "디자인팀", - "GROUP", - 15, - List.of(file1) - ); + @DisplayName("채팅 파일 삭제") + void deleteFile() throws Exception { + long fileId = 55L; + long userId = 1L; - given(chatFileService.getChatAlbum(anyLong(), anyInt(), anyLong())) - .willReturn(List.of(album1, album2)); + doNothing().when(chatFileService).deleteFile(eq(fileId), eq(userId)); - // When & Then mockMvc.perform( - get("/api/v1/chats/projects/{projectId}/albums", 1L) + delete("/api/v1/chats/files/{fileId}", fileId) + .with(mockUser(userId)) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) - .param("limit", "9") .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) - .andExpect(jsonPath("$.body[0].room_id").value(1L)) - .andExpect(jsonPath("$.body[0].room_name").value("넥트 전체방")) - .andExpect(jsonPath("$.body[0].file_count").value(24)) - .andDo(document("chat-project-albums", + .andDo(document("chat-file-delete", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), resource(ResourceSnippetParameters.builder() - .tag("채팅 파일") - .summary("프로젝트 앨범 요약 조회") - .description("프로젝트 내 모든 채팅방의 파일을 채팅방별로 그룹핑하여 조회합니다. " + - "각 채팅방당 최근 N개(기본 9개)만 반환하며, " + - "더보기 버튼 표시 여부 판단을 위해 전체 파일 개수도 제공합니다. " + - "최근 15일 이내 업로드된 파일만 포함됩니다.") - .requestHeaders( - headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") - ) + .tag("채팅") + .summary("채팅 파일 삭제") + .description("채팅 파일을 삭제합니다.") .pathParameters( - parameterWithName("projectId").description("프로젝트 ID") + ResourceDocumentation.parameterWithName("fileId").description("파일 ID") ) - .queryParameters( - parameterWithName("limit").description("각 채팅방당 최대 파일 개수 (기본: 9)").optional() + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") ) .responseFields( - fieldWithPath("status.statusCode").description("상태 코드"), - fieldWithPath("status.message").description("상태 메시지"), - fieldWithPath("status.description").description("상세 설명").optional(), - fieldWithPath("body[].room_id").description("채팅방 ID"), - fieldWithPath("body[].room_name").description("채팅방 이름"), - fieldWithPath("body[].room_type").description("채팅방 타입 (GROUP, DIRECT)"), - fieldWithPath("body[].file_count").description("해당 채팅방의 전체 파일 개수 (최근 15일)"), - fieldWithPath("body[].files[].file_name").description("원본 파일명"), - fieldWithPath("body[].files[].file_url").description("Presigned URL (5분 유효)"), - fieldWithPath("body[].files[].created_at").description("파일 업로드 시간") + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명") ) .build() ) )); + + verify(chatFileService).deleteFile(eq(fileId), eq(userId)); } @Test - @DisplayName("작업실 특정 채팅방 상세 앨범 조회 API") - void getChatRoomAlbumDetail() throws Exception { - // Given - ChatFileResponseDto file1 = new ChatFileResponseDto( - "detail1.jpg", - "https://r2.example.com/uuid_detail1.jpg", - LocalDateTime.now() - ); - - ChatFileResponseDto file2 = new ChatFileResponseDto( - "detail2.png", - "https://r2.example.com/uuid_detail2.png", - LocalDateTime.now() - ); - - ChatRoomAlbumDetailDto response = new ChatRoomAlbumDetailDto( - 1L, - "넥트 전체방", - List.of(file1, file2), - 50, - 0, - 3, - true + @DisplayName("프로젝트 채팅 앨범 조회") + void getProjectAlbum() throws Exception { + long projectId = 3L; + long userId = 1L; + + List response = List.of( + new ChatRoomAlbumResponseDto( + 10L, + "개발팀", + "GROUP", + 2, + List.of( + new ChatFileResponseDto( + "design.png", + "https://cdn.example.com/chat/files/design.png", + LocalDateTime.of(2025, 1, 10, 11, 0) + ), + new ChatFileResponseDto( + "spec.pdf", + "https://cdn.example.com/chat/files/spec.pdf", + LocalDateTime.of(2025, 1, 9, 18, 30) + ) + ) + ) ); - given(chatFileService.getChatRoomAlbumDetail(anyLong(), anyInt(), anyInt(), anyLong())) + given(chatFileService.getChatAlbum(eq(projectId), eq(9), eq(userId))) .willReturn(response); - // When & Then mockMvc.perform( - get("/api/v1/chats/rooms/{roomId}/album", 1L) + get("/api/v1/chats/projects/{projectId}/albums", projectId) + .with(mockUser(userId)) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) - .param("page", "0") - .param("size", "20") + .param("limit", "9") .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) - .andExpect(jsonPath("$.body.room_id").value(1L)) - .andExpect(jsonPath("$.body.room_name").value("넥트 전체방")) - .andExpect(jsonPath("$.body.total_count").value(50)) - .andExpect(jsonPath("$.body.has_next").value(true)) - .andDo(document("chat-room-detail-album", + .andExpect(jsonPath("$.body[0].room_id").value(10)) + .andDo(document("chat-project-album", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), resource(ResourceSnippetParameters.builder() - .tag("채팅 파일") - .summary("특정 채팅방 상세 앨범 조회") - .description("특정 채팅방의 파일을 페이징하여 조회합니다. " + - "더보기 버튼 클릭 시 page 파라미터를 증가시켜 반복 호출하며, " + - "has_next가 false가 될 때까지 호출 가능합니다. " + - "최근 15일 이내 업로드된 파일만 조회됩니다.") - .requestHeaders( - headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") - ) + .tag("채팅") + .summary("프로젝트 채팅 앨범 조회") + .description("프로젝트 내 채팅방의 최근 파일 목록을 조회합니다.") .pathParameters( - parameterWithName("roomId").description("채팅방 ID") + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID") ) .queryParameters( - parameterWithName("page").description("페이지 번호 (0부터 시작, 기본: 0)").optional(), - parameterWithName("size").description("페이지당 파일 개수 (기본: 20)").optional() + parameterWithName("limit").description("채팅방별 파일 조회 개수") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") ) .responseFields( - fieldWithPath("status.statusCode").description("상태 코드"), - fieldWithPath("status.message").description("상태 메시지"), - fieldWithPath("status.description").description("상세 설명").optional(), - fieldWithPath("body.room_id").description("채팅방 ID"), - fieldWithPath("body.room_name").description("채팅방 이름"), - fieldWithPath("body.files[].file_name").description("원본 파일명"), - fieldWithPath("body.files[].file_url").description("Presigned URL (5분 유효)"), - fieldWithPath("body.files[].created_at").description("파일 업로드 시간"), - fieldWithPath("body.total_count").description("전체 파일 개수 (최근 15일)"), - fieldWithPath("body.current_page").description("현재 페이지 번호"), - fieldWithPath("body.total_pages").description("전체 페이지 수"), - fieldWithPath("body.has_next").description("다음 페이지 존재 여부") + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(ARRAY).description("응답 바디"), + fieldWithPath("body[].room_id").type(NUMBER).description("채팅방 ID"), + fieldWithPath("body[].room_name").type(STRING).description("채팅방 이름"), + fieldWithPath("body[].room_type").type(STRING).description("채팅방 타입"), + fieldWithPath("body[].file_count").type(NUMBER).description("파일 개수"), + fieldWithPath("body[].files").type(ARRAY).description("파일 목록"), + fieldWithPath("body[].files[].file_name").type(STRING).description("파일명"), + fieldWithPath("body[].files[].file_url").type(STRING).description("파일 URL"), + fieldWithPath("body[].files[].created_at").type(STRING).description("생성일시(ISO-8601)") ) .build() ) )); + + verify(chatFileService).getChatAlbum(eq(projectId), eq(9), eq(userId)); } @Test - @DisplayName("작업실 채팅방 파일 삭제 API ") - void deleteFile() throws Exception { - // Given - Long fileId = 1L; + @DisplayName("채팅방 앨범 상세 조회") + void getChatRoomAlbumDetail() throws Exception { + long roomId = 10L; + long userId = 1L; - doNothing().when(chatFileService).deleteFile(anyLong(), anyLong()); + ChatRoomAlbumDetailDto response = new ChatRoomAlbumDetailDto( + roomId, + "개발팀", + List.of( + new ChatFileResponseDto( + "design.png", + "https://cdn.example.com/chat/files/design.png", + LocalDateTime.of(2025, 1, 10, 11, 0) + ) + ), + 1, + 0, + 1, + false + ); + + given(chatFileService.getChatRoomAlbumDetail(eq(roomId), eq(0), eq(20), eq(userId))) + .willReturn(response); - // When & Then mockMvc.perform( - RestDocumentationRequestBuilders.delete("/api/v1/chats/files/{fileId}", fileId) + get("/api/v1/chats/rooms/{roomId}/album", roomId) + .with(mockUser(userId)) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) - .contentType(MediaType.APPLICATION_JSON) + .param("page", "0") + .param("size", "20") + .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) - .andDo(document("chat-file-delete", + .andDo(document("chat-room-album-detail", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), resource(ResourceSnippetParameters.builder() - .tag("채팅 파일") - .summary("파일 삭제") - .description("업로드된 파일을 Cloudflare R2 스토리지와 DB에서 완전히 삭제합니다. " + - "삭제 후 해당 파일의 Presigned URL은 즉시 접근 불가능해집니다.") - .requestHeaders( - headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") - ) + .tag("채팅") + .summary("채팅방 앨범 상세 조회") + .description("채팅방의 파일 앨범 상세 목록을 페이징 조회합니다.") .pathParameters( - parameterWithName("fileId").description("삭제할 파일 ID") + ResourceDocumentation.parameterWithName("roomId").description("채팅방 ID") + ) + .queryParameters( + parameterWithName("page").description("페이지 번호(0부터 시작)"), + parameterWithName("size").description("페이지당 항목 수") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") ) .responseFields( - fieldWithPath("status.statusCode").description("상태 코드"), - fieldWithPath("status.message").description("상태 메시지"), - fieldWithPath("status.description").description("상세 설명").optional() + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.room_id").type(NUMBER).description("채팅방 ID"), + fieldWithPath("body.room_name").type(STRING).description("채팅방 이름"), + fieldWithPath("body.files").type(ARRAY).description("파일 목록"), + fieldWithPath("body.files[].file_name").type(STRING).description("파일명"), + fieldWithPath("body.files[].file_url").type(STRING).description("파일 URL"), + fieldWithPath("body.files[].created_at").type(STRING).description("생성일시(ISO-8601)"), + fieldWithPath("body.total_count").type(NUMBER).description("총 파일 수"), + fieldWithPath("body.current_page").type(NUMBER).description("현재 페이지"), + fieldWithPath("body.total_pages").type(NUMBER).description("총 페이지 수"), + fieldWithPath("body.has_next").type(BOOLEAN).description("다음 페이지 존재 여부") ) .build() ) )); + + verify(chatFileService).getChatRoomAlbumDetail(eq(roomId), eq(0), eq(20), eq(userId)); } @Test - @DisplayName("작업실 채팅방 파일 상세 조회 API (이미지 뷰어용)") + @DisplayName("채팅 파일 상세 조회") void getFileDetail() throws Exception { - // Given + long fileId = 55L; + long userId = 1L; + ChatFileDetailDto response = ChatFileDetailDto.builder() - .fileId(1L) - .fileName("photo.png") - .fileUrl("https://r2.example.com/uuid_photo.png") - .fileSize(1024L) - .fileType("image/png") - .createdAt(LocalDateTime.now()) + .fileId(fileId) + .fileName("design.png") + .fileUrl("https://cdn.example.com/chat/files/design.png") + .fileSize(2048L) + .fileType("PNG") + .createdAt(LocalDateTime.of(2025, 1, 10, 12, 10)) .build(); - given(chatFileService.getFileDetail(anyLong(), anyLong())) + given(chatFileService.getFileDetail(eq(fileId), eq(userId))) .willReturn(response); - // When & Then mockMvc.perform( - get("/api/v1/chats/files/{fileId}", 1L) + get("/api/v1/chats/files/{fileId}", fileId) + .with(mockUser(userId)) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) - .andExpect(jsonPath("$.body.file_id").value(1L)) - .andExpect(jsonPath("$.body.file_name").value("photo.png")) .andDo(document("chat-file-detail", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), resource(ResourceSnippetParameters.builder() - .tag("채팅 파일") - .summary("파일 상세 조회 (이미지 뷰어용)") - .description("이미지 클릭 시 모달 뷰어에 표시할 파일 정보를 조회합니다. " + - "파일 메타데이터와 함께 5분 유효한 Presigned URL이 반환됩니다.") - .requestHeaders( - headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") - ) + .tag("채팅") + .summary("채팅 파일 상세 조회") + .description("채팅 파일의 상세 정보를 조회합니다.") .pathParameters( - parameterWithName("fileId").description("조회할 파일 ID") + ResourceDocumentation.parameterWithName("fileId").description("파일 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") ) .responseFields( - fieldWithPath("status.statusCode").description("상태 코드"), - fieldWithPath("status.message").description("상태 메시지"), - fieldWithPath("status.description").description("상세 설명").optional(), - fieldWithPath("body.file_id").description("파일 ID"), - fieldWithPath("body.file_name").description("원본 파일명"), - fieldWithPath("body.file_url").description("이미지 표시용 Presigned URL (5분 유효)"), - fieldWithPath("body.file_size").description("파일 크기 (bytes)"), - fieldWithPath("body.file_type").description("파일 MIME 타입"), - fieldWithPath("body.created_at").description("파일 업로드 시간") + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.file_id").type(NUMBER).description("파일 ID"), + fieldWithPath("body.file_name").type(STRING).description("파일명"), + fieldWithPath("body.file_url").type(STRING).description("파일 URL"), + fieldWithPath("body.file_size").type(NUMBER).description("파일 크기(bytes)"), + fieldWithPath("body.file_type").type(STRING).description("파일 확장자"), + fieldWithPath("body.created_at").type(STRING).description("생성일시(ISO-8601)") ) .build() ) )); + + verify(chatFileService).getFileDetail(eq(fileId), eq(userId)); } @Test - @DisplayName("작업실 채팅방 파일 다운로드 API") + @DisplayName("채팅 파일 다운로드(리다이렉트)") void downloadFile() throws Exception { - // Given - String downloadUrl = "https://r2.example.com/uuid_photo.png?expires=300"; + long fileId = 55L; + long userId = 1L; + String redirectUrl = "https://cdn.example.com/chat/files/55/download?token=abc"; - given(chatFileService.getDownloadUrl(anyLong(), anyLong())) - .willReturn(downloadUrl); + given(chatFileService.getDownloadUrl(eq(fileId), eq(userId))) + .willReturn(redirectUrl); - // When & Then mockMvc.perform( - get("/api/v1/chats/files/{fileId}/download", 1L) + get("/api/v1/chats/files/{fileId}/download", fileId) + .with(mockUser(userId)) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) ) - .andExpect(status().is3xxRedirection()) - .andDo(document("chat-file-download", + .andExpect(status().isFound()) + .andExpect(header().string("Location", redirectUrl)) + .andDo(document("chat-file-download-redirect", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), resource(ResourceSnippetParameters.builder() - .tag("채팅 파일") - .summary("파일 다운로드") - .description("파일을 사용자 컴퓨터에 다운로드합니다. " + - "Presigned URL로 리다이렉트되며, 브라우저가 파일 다운로드를 처리합니다.") + .tag("채팅") + .summary("채팅 파일 다운로드(리다이렉트)") + .description("파일 다운로드 URL을 조회한 뒤 302(FOUND)로 Location 헤더에 담아 리다이렉트합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("fileId").description("파일 ID") + ) .requestHeaders( - headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + headerWithName(AUTH_HEADER).description("Bearer Access Token") ) - .pathParameters( - parameterWithName("fileId").description("다운로드할 파일 ID") + .responseHeaders( + headerWithName("Location").description("다운로드 리다이렉트 URL") ) .build() ) )); + + verify(chatFileService).getDownloadUrl(eq(fileId), eq(userId)); } -} \ No newline at end of file +} diff --git a/nect-core/src/main/java/com/nect/core/entity/dm/DirectMessage.java b/nect-core/src/main/java/com/nect/core/entity/dm/DirectMessage.java new file mode 100644 index 00000000..3d7b8387 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/entity/dm/DirectMessage.java @@ -0,0 +1,35 @@ +package com.nect.core.entity.dm; + +import com.nect.core.entity.BaseEntity; +import com.nect.core.entity.user.User; +import jakarta.persistence.*; +import lombok.*; + +// 개인 채팅 엔티티 +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class DirectMessage extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Builder.Default + @Column(name = "is_read") + private Boolean isRead = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id") + private User receiver; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id") + private User sender; + +} diff --git a/nect-core/src/main/java/com/nect/core/entity/user/User.java b/nect-core/src/main/java/com/nect/core/entity/user/User.java index 232c2a38..6605b273 100644 --- a/nect-core/src/main/java/com/nect/core/entity/user/User.java +++ b/nect-core/src/main/java/com/nect/core/entity/user/User.java @@ -76,7 +76,7 @@ public class User extends BaseEntity { private Boolean isAutoLoginEnabled = false; @Column(name = "profile_image_url") - private String profileImageUrl; + private String profileImageName; @Column(name = "bio", columnDefinition = "TEXT") private String bio; diff --git a/nect-core/src/main/java/com/nect/core/entity/user/UserCareer.java b/nect-core/src/main/java/com/nect/core/entity/user/UserCareer.java index d15f41ce..f6d49899 100644 --- a/nect-core/src/main/java/com/nect/core/entity/user/UserCareer.java +++ b/nect-core/src/main/java/com/nect/core/entity/user/UserCareer.java @@ -34,6 +34,7 @@ public class UserCareer extends BaseEntity { private String endDate; @Column(name = "is_ongoing", nullable = false) + @Builder.Default private Boolean isOngoing = false; @Column(name = "role") diff --git a/nect-core/src/main/java/com/nect/core/repository/dm/DmRepository.java b/nect-core/src/main/java/com/nect/core/repository/dm/DmRepository.java new file mode 100644 index 00000000..c31606f8 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/repository/dm/DmRepository.java @@ -0,0 +1,75 @@ +package com.nect.core.repository.dm; + +import com.nect.core.entity.dm.DirectMessage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface DmRepository extends JpaRepository { + + interface UnreadCountRow { + Long getSenderId(); + Long getUnreadCount(); + } + + @Query(""" + SELECT dm FROM DirectMessage dm + WHERE ((dm.sender.userId = :userA AND dm.receiver.userId = :userB) + OR (dm.sender.userId = :userB AND dm.receiver.userId = :userA)) + AND (:cursor IS NULL OR dm.id < :cursor) + ORDER BY dm.id DESC + """) + List findConversation( + @Param("userA") Long userA, + @Param("userB") Long userB, + @Param("cursor") Long cursor, + Pageable pageable + ); + + @Query(""" + SELECT dm FROM DirectMessage dm + WHERE dm.id IN ( + SELECT MAX(dm2.id) FROM DirectMessage dm2 + WHERE (dm2.sender.userId = :userId OR dm2.receiver.userId = :userId) + GROUP BY CASE + WHEN dm2.sender.userId = :userId THEN dm2.receiver.userId + ELSE dm2.sender.userId + END + ) + AND (:cursor IS NULL OR dm.id < :cursor) + ORDER BY dm.id DESC + """) + List findLatestMessagesByUser( + @Param("userId") Long userId, + @Param("cursor") Long cursor, + Pageable pageable + ); + + @Query(""" + SELECT dm.sender.userId as senderId, COUNT(dm) as unreadCount + FROM DirectMessage dm + WHERE dm.receiver.userId = :receiverId + AND dm.isRead = false + GROUP BY dm.sender.userId + """) + List countUnreadBySender(@Param("receiverId") Long receiverId); + + @Modifying + @Query(""" + UPDATE DirectMessage dm + SET dm.isRead = true + WHERE dm.receiver.userId = :receiverId + AND dm.sender.userId = :senderId + AND dm.isRead = false + AND (:lastReadId IS NULL OR dm.id <= :lastReadId) + """) + int markAsRead( + @Param("receiverId") Long receiverId, + @Param("senderId") Long senderId, + @Param("lastReadId") Long lastReadId + ); +} diff --git a/nect-core/src/main/java/com/nect/core/repository/matching/RecruitmentRepository.java b/nect-core/src/main/java/com/nect/core/repository/matching/RecruitmentRepository.java index 4af4dc40..40d143c4 100644 --- a/nect-core/src/main/java/com/nect/core/repository/matching/RecruitmentRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/matching/RecruitmentRepository.java @@ -31,18 +31,6 @@ select r.project.id as projectId, sum(r.capacity) as capacitySum List findAllByProject_IdIn(@Param("projectIds") List projectIds); - // TODO: Recruitmnet Field 바뀌면 적용 -// @Query(""" -// select r.project.id as projectId, -// r.field.role.description as roleName, -// sum(r.capacity) as capacitySum -// from Recruitment r -// where r.project.id in :projectIds -// and r.field.role is not null -// group by r.project.id, r.field.role.description -// """) -// List sumRoleCapacityByProjectIds(@Param("projectIds") List projectIds); - @Query(""" select r from Recruitment r @@ -53,8 +41,6 @@ List findOpenFieldsByProject( @Param("project") Project project ); - - interface ProjectCapacityRow { Long getProjectId(); Integer getCapacitySum(); diff --git a/nect-core/src/main/java/com/nect/core/repository/notifications/NotificationRepository.java b/nect-core/src/main/java/com/nect/core/repository/notifications/NotificationRepository.java index 5789645c..b917abfa 100644 --- a/nect-core/src/main/java/com/nect/core/repository/notifications/NotificationRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/notifications/NotificationRepository.java @@ -2,6 +2,7 @@ import com.nect.core.entity.notifications.Notification; import com.nect.core.entity.notifications.enums.NotificationScope; +import com.nect.core.entity.team.Project; import com.nect.core.entity.user.User; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -26,4 +27,34 @@ List findByScopeWithCursor( Pageable pageable ); + @Query(""" + SELECT n FROM Notification n + WHERE n.scope IN :scopes + AND n.receiver = :user + AND (:cursor IS NULL OR n.id < :cursor) + ORDER BY n.id DESC + """) + List findByScopesWithCursor( + @Param("user") User user, + @Param("scopes") List scopes, + @Param("cursor") Long cursor, + Pageable pageable + ); + + @Query(""" + SELECT n FROM Notification n + WHERE n.scope IN :scopes + AND n.receiver = :user + AND n.project IN :projects + AND (:cursor IS NULL OR n.id < :cursor) + ORDER BY n.id DESC + """) + List findByScopesAndProjectsWithCursor( + @Param("user") User user, + @Param("scopes") List scopes, + @Param("projects") List projects, + @Param("cursor") Long cursor, + Pageable pageable + ); + } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java index 043a1029..62c09546 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java @@ -53,6 +53,14 @@ List findByUserIdAndProjectMemberStatus( List findAllUsersByProjectId(@Param("projectId") Long projectId); + @Query(""" + SELECT pu.project + FROM ProjectUser pu + WHERE pu.userId = :userId + AND pu.memberStatus = 'ACTIVE' + """) + List findActiveProjectsByUserId(@Param("userId") Long userId); + @Query(""" SELECT u FROM User u JOIN ProjectUser pu ON u.userId = pu.userId diff --git a/nect-core/src/main/java/com/nect/core/repository/user/UserInterestRepository.java b/nect-core/src/main/java/com/nect/core/repository/user/UserInterestRepository.java index aa1c3442..31f2ae2d 100644 --- a/nect-core/src/main/java/com/nect/core/repository/user/UserInterestRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/user/UserInterestRepository.java @@ -1,11 +1,41 @@ package com.nect.core.repository.user; +import com.nect.core.entity.user.User; import com.nect.core.entity.user.UserInterest; +import com.nect.core.entity.user.enums.InterestField; +import com.nect.core.entity.user.enums.Role; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface UserInterestRepository extends JpaRepository { List findByUserUserId(Long userId); void deleteByUserUserId(Long userId); + + @Query(""" + SELECT ui.user + FROM UserInterest ui + JOIN ui.user u + WHERE ui.interestField = :interest + """) + List findUsersByInterest(@Param("interest") InterestField interest, Pageable pageable); + + @Query(""" + SELECT ui.user + FROM UserInterest ui + JOIN ui.user u + WHERE ui.interestField = :interest + AND u.role = :role + AND (:userId IS NULL OR u.userId <> :userId) + """) + List findUsersByInterestAndRoleExcludingUser( + @Param("interest") InterestField interest, + @Param("role") Role role, + @Param("userId") Long userId, + Pageable pageable + ); + } diff --git a/nect-core/src/main/java/com/nect/core/repository/user/UserRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/user/UserRoleRepository.java index b4c9cdc0..8ac7839a 100644 --- a/nect-core/src/main/java/com/nect/core/repository/user/UserRoleRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/user/UserRoleRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface UserRoleRepository extends JpaRepository { From b2ddf3df0d889003fab2f8bbfe60ae3938747dc5 Mon Sep 17 00:00:00 2001 From: infiniment Date: Sat, 7 Feb 2026 11:37:33 +0900 Subject: [PATCH 25/66] =?UTF-8?q?[Refactor]=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=AA=85=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20?= =?UTF-8?q?presigned=20URL=20=EC=9D=91=EB=8B=B5=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/process/service/ProcessService.java | 24 ++++++++++++++----- .../process/service/WeekMissionService.java | 12 +++++++++- .../service/ProjectTeamQueryService.java | 9 ++++++- .../team/workspace/dto/res/PostGetResDto.java | 5 +++- .../dto/res/SharedDocumentsPreviewResDto.java | 1 - .../service/BoardsMemberBoardService.java | 12 +++++++++- .../service/BoardsSharedDocumentService.java | 11 ++++++++- .../team/workspace/service/PostService.java | 3 ++- .../controller/PostControllerTest.java | 5 ++-- .../team/ProjectUserRepository.java | 4 +++- .../team/process/ProcessRepository.java | 4 ++-- 11 files changed, 72 insertions(+), 18 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index af0d92df..63c753df 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -9,6 +9,7 @@ import com.nect.api.domain.team.process.enums.ProcessErrorCode; import com.nect.api.domain.team.process.exception.ProcessException; import com.nect.api.domain.notifications.facade.NotificationFacade; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.notifications.enums.NotificationClassification; import com.nect.core.entity.notifications.enums.NotificationScope; import com.nect.core.entity.notifications.enums.NotificationType; @@ -56,6 +57,7 @@ public class ProcessService { private final ProcessLaneOrderRepository processLaneOrderRepository; private final ProjectTeamRoleRepository projectTeamRoleRepository; + private final S3Service s3Service; private final ProcessLaneOrderService processLaneOrderService; private static final String TEAM_LANE_KEY = "TEAM"; @@ -329,6 +331,11 @@ private void validateProjectTeamRolesOrThrow(Long projectId, List rol } } + private String toPresignedUserImage(String fileKey) { + if (fileKey == null || fileKey.isBlank()) return null; + return s3Service.getPresignedGetUrl(fileKey); + } + // 알림 관련 헬퍼 메서드 private List validateAndLoadMentionReceivers(Long projectId, Long actorId, List mentionIds) { if (mentionIds == null) return List.of(); @@ -588,17 +595,21 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre "writer must be active project member. projectId=" + projectId + ", userId=" + userId )); + + List assigneeDtos = saved.getProcessUsers().stream() .filter(pu -> pu.getDeletedAt() == null) .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) .map(pu -> { User u = pu.getUser(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); + return new ProcessCreateResDto.AssigneeDto( u.getUserId(), u.getName(), u.getNickname(), - u.getProfileImageUrl() + profileUrl ); }) .toList(); @@ -728,13 +739,13 @@ public ProcessDetailResDto getProcessDetail(Long projectId, Long userId, Long pr .map(pu -> { User u = pu.getUser(); - String userImage = u.getProfileImageUrl(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); return new AssigneeResDto( u.getUserId(), u.getName(), u.getNickname(), - userImage + profileUrl ); }) .toList(); @@ -1151,11 +1162,12 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) .map(pu -> { User u = pu.getUser(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); return new AssigneeResDto( u.getUserId(), u.getName(), u.getNickname(), - u.getProfileImageUrl() + profileUrl ); }) .toList(); @@ -1428,9 +1440,9 @@ private ProcessCardResDto toProcessCardResDTO(Process p) { .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) .map(pu -> { User u = pu.getUser(); - String userImage = u.getProfileImageUrl(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); String nickname = u.getNickname(); - return new AssigneeResDto(u.getUserId(), u.getName(), nickname, userImage); + return new AssigneeResDto(u.getUserId(), u.getName(), nickname, profileUrl); }) .toList(); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java index 8b9ea852..30470bf9 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -11,6 +11,7 @@ import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; import com.nect.api.domain.team.process.enums.ProcessErrorCode; import com.nect.api.domain.team.process.exception.ProcessException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.notifications.enums.NotificationClassification; import com.nect.core.entity.notifications.enums.NotificationScope; import com.nect.core.entity.notifications.enums.NotificationType; @@ -55,6 +56,13 @@ public class WeekMissionService { private final UserRepository userRepository; private final NotificationFacade notificationFacade; private final ProjectHistoryPublisher historyPublisher; + private final S3Service s3Service; + + private String toPresignedUserImage(String fileKey) { + if (fileKey == null || fileKey.isBlank()) return null; + return s3Service.getPresignedGetUrl(fileKey); + } + private void assertActiveProjectMember(Long projectId, Long userId) { if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { @@ -309,11 +317,13 @@ record GroupKey(RoleField roleField, String customName) {} User leader = process.getCreatedBy(); + String profileUrl = (leader == null) ? null : toPresignedUserImage(leader.getProfileImageName()); + WeekMissionDetailResDto.AssigneeDto assignee = new WeekMissionDetailResDto.AssigneeDto( leader.getUserId(), leader.getName(), leader.getNickname(), - leader.getProfileImageUrl() + profileUrl ); // DTO 생성자 인자 순서 주의: (taskGroups, taskItems) 둘 다 넣기 diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java index 9bf7ff88..3e7d5a9d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java @@ -4,6 +4,7 @@ import com.nect.api.domain.team.project.dto.ProjectUsersResDto; import com.nect.api.domain.team.project.enums.code.ProjectErrorCode; import com.nect.api.domain.team.project.exception.ProjectException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.ProjectTeamRole; import com.nect.core.entity.team.enums.ProjectMemberStatus; import com.nect.core.entity.user.User; @@ -27,6 +28,12 @@ public class ProjectTeamQueryService { private final ProjectTeamRoleRepository projectTeamRoleRepository; private final ProjectUserRepository projectUserRepository; private final UserRepository userRepository; + private final S3Service s3Service; + + private String toPresignedUserImage(String fileKey) { + if (fileKey == null || fileKey.isBlank()) return null; + return s3Service.getPresignedGetUrl(fileKey); + } // 프로젝트 파트 목록 조회 서비스 @Transactional(readOnly = true) @@ -77,7 +84,7 @@ public ProjectUsersResDto readProjectUsers(Long projectId, Long userId) { List users = rows.stream() .map(r -> { User u = userMap.get(r.getUserId()); - String profileUrl = (u == null) ? null : u.getProfileImageUrl(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); RoleField rf = r.getRoleField(); String customName = r.getCustomRoleFieldName(); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostGetResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostGetResDto.java index 948d8117..315c2e04 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostGetResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostGetResDto.java @@ -35,6 +35,9 @@ public record AuthorDto( Long userId, @JsonProperty("name") - String name + String name, + + @JsonProperty("nickname") + String nickname ) {} } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentsPreviewResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentsPreviewResDto.java index 77831ce5..8bc3fe75 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentsPreviewResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentsPreviewResDto.java @@ -49,7 +49,6 @@ public record UploaderDto( @JsonProperty("nickname") String nickname, - // TODO: UserProfile 엔티티 생기면 연결 @JsonProperty("profile_image_url") String profileImageUrl ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsMemberBoardService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsMemberBoardService.java index 19a32fcf..272084de 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsMemberBoardService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsMemberBoardService.java @@ -4,6 +4,7 @@ import com.nect.api.domain.team.workspace.dto.res.RoleFieldDto; import com.nect.api.domain.team.workspace.enums.BoardsErrorCode; import com.nect.api.domain.team.workspace.exception.BoardsException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.Project; import com.nect.core.entity.team.process.enums.ProcessStatus; import com.nect.core.entity.team.workspace.ProjectUserWorkDaily; @@ -30,6 +31,13 @@ public class BoardsMemberBoardService { private final ProjectUserRepository projectUserRepository; private final ProcessRepository processRepository; private final ProjectUserWorkDailyRepository projectUserWorkDailyRepository; + private final S3Service s3Service; + + private String toPresignedUserImage(String fileKey) { + if (fileKey == null || fileKey.isBlank()) return null; + return s3Service.getPresignedGetUrl(fileKey); + } + // 멤버 보드 조회 서비스 @Transactional(readOnly = true) @@ -100,11 +108,13 @@ public MemberBoardResDto getMemberBoard(Long projectId, Long userId) { ? RoleFieldDto.of(m.getRoleField(), m.getCustomRoleFieldName()) : RoleFieldDto.of(m.getRoleField()); + String userImageUrl = toPresignedUserImage(m.getProfileImageName()); + return new MemberBoardResDto.MemberDto( m.getUserId(), m.getName(), m.getNickname(), - null, // profile_image_url (TODO) + userImageUrl, fieldDto, m.getMemberType(), new MemberBoardResDto.CountsDto(arr[0], arr[1], arr[2]), diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java index c9c1bd5e..be2e2b3e 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java @@ -3,6 +3,7 @@ import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsPreviewResDto; import com.nect.api.domain.team.workspace.enums.BoardsErrorCode; import com.nect.api.domain.team.workspace.exception.BoardsException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.Project; import com.nect.core.entity.team.SharedDocument; import com.nect.core.entity.user.User; @@ -23,6 +24,12 @@ public class BoardsSharedDocumentService { private final ProjectRepository projectRepository; private final ProjectUserRepository projectUserRepository; private final SharedDocumentRepository sharedDocumentRepository; + private final S3Service s3Service; + + private String toPresignedUserImage(String fileKey) { + if (fileKey == null || fileKey.isBlank()) return null; + return s3Service.getPresignedGetUrl(fileKey); + } /** * 공유 문서함 프리뷰 조회 @@ -53,6 +60,8 @@ public SharedDocumentsPreviewResDto getPreview(Long projectId, Long userId, int List result = docs.stream().map(d -> { User u = d.getCreatedBy(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); + return new SharedDocumentsPreviewResDto.DocumentDto( d.getId(), d.isPinned(), @@ -66,7 +75,7 @@ public SharedDocumentsPreviewResDto getPreview(Long projectId, Long userId, int u.getUserId(), u.getName(), u.getNickname(), - null // TODO: profile_image_url + profileUrl ) ); }).toList(); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java index 5cbcdbef..504865cc 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java @@ -192,7 +192,8 @@ public PostGetResDto getPost(Long projectId, Long userId, Long postId) { post.getCreatedAt(), new PostGetResDto.AuthorDto( post.getAuthor().getUserId(), - post.getAuthor().getName() + post.getAuthor().getName(), + post.getAuthor().getNickname() ) ); } diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java index 22968e5e..027bba8b 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java @@ -181,7 +181,7 @@ void getPost() throws Exception { true, 7L, LocalDateTime.of(2026, 1, 31, 10, 0), - new PostGetResDto.AuthorDto(1L, "노수민") + new PostGetResDto.AuthorDto(1L, "노수민", "패트") ); given(postFacade.getPost(eq(projectId), eq(userId), eq(postId))) @@ -224,7 +224,8 @@ void getPost() throws Exception { fieldWithPath("body.author").type(OBJECT).description("작성자 정보"), fieldWithPath("body.author.user_id").type(NUMBER).description("작성자 유저 ID"), - fieldWithPath("body.author.name").type(STRING).description("작성자 이름") + fieldWithPath("body.author.name").type(STRING).description("작성자 이름"), + fieldWithPath("body.author.nickname").type(STRING).description("작성자 별명") ) .build() ) diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java index 62c09546..421955a7 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java @@ -148,6 +148,7 @@ SELECT COUNT(pu) > 0 pu.userId as userId, u.name as name, u.nickname as nickname, + u.profileImageName as profileImageName, pu.roleField as roleField, pu.customRoleFieldName as customRoleFieldName, pu.memberType as memberType @@ -222,6 +223,7 @@ interface MemberBoardRow { Long getUserId(); String getName(); String getNickname(); + String getProfileImageName(); RoleField getRoleField(); String getCustomRoleFieldName(); ProjectMemberType getMemberType(); @@ -241,7 +243,7 @@ interface ProjectLeaderProfileRow { select u.userId as userId, u.nickname as nickname, - u.profileImageUrl as profileImageUrl + u.profileImageName as profileImageName from ProjectUser pu join User u on u.userId = pu.userId diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index cece6921..ff824587 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -422,7 +422,7 @@ interface WeekMissionCardRow { count(ti.id) as totalCount, u.userId as leaderUserId, u.nickname as leaderNickname, - u.profileImageUrl as leaderProfileImageUrl + u.profileImageName as leaderProfileImageUrl from Process p left join p.taskItems ti on ti.deletedAt is null left join p.processUsers pu @@ -439,7 +439,7 @@ interface WeekMissionCardRow { or (p.startAt <= :end and p.endAt >= :start) ) group by p.id, p.missionNumber, p.status, p.title, p.startAt, p.endAt, - u.userId, u.nickname, u.profileImageUrl + u.userId, u.nickname, u.profileImageName order by p.missionNumber asc nulls last, p.startAt asc nulls last, p.id asc """) List findWeekMissionCardsInRange( From 9438b7bb2392b0db7364f55f27d07c472a543479 Mon Sep 17 00:00:00 2001 From: infiniment Date: Mon, 2 Feb 2026 00:05:09 +0900 Subject: [PATCH 26/66] =?UTF-8?q?[Feat]=20=ED=8C=8C=ED=8A=B8=EB=B3=84=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=ED=98=84=ED=99=A9=20=ED=8C=8C=ED=8A=B8?= =?UTF-8?q?=EB=B3=84=20=EC=9E=91=EC=97=85=20=EC=A7=84=ED=96=89=EB=A5=A0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java --- .../team/process/service/ProcessService.java | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index b2c8144b..221fd3eb 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -1517,6 +1517,107 @@ private int rate(long part, long total) { return (int) Math.round(part * 100.0 / total); } + // 파트별 작업 진행률 조회 서비스 + @Transactional(readOnly = true) + public ProcessProgressSummaryResDto getPartProgressSummary(Long projectId, Long userId) { + assertActiveProjectMember(projectId, userId); + + if (!projectRepository.existsById(projectId)) { + throw new ProcessException(ProcessErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId); + } + + List statuses = List.of( + ProcessStatus.PLANNING, + ProcessStatus.IN_PROGRESS, + ProcessStatus.DONE + ); + + RoleField custom = RoleField.CUSTOM; + + List roleRows = + processRepository.countRoleLaneStatusForProgressSummary(projectId, custom, statuses); + + List customRows = + processRepository.countCustomLaneStatusForProgressSummary(projectId, custom, statuses); + + // laneKey -> status -> count + Map> laneCounts = new LinkedHashMap<>(); + + // ROLE lanes + for (var r : roleRows) { + RoleField rf = r.getRoleField(); + if (rf == null) continue; + + String laneKey = "ROLE:" + rf.name(); + laneCounts.computeIfAbsent(laneKey, k -> new EnumMap<>(ProcessStatus.class)) + .put(r.getStatus(), r.getCnt()); + } + + // CUSTOM lanes + for (var r : customRows) { + String name = r.getCustomName(); + if (name == null) continue; + + String trimmed = name.trim(); + if (trimmed.isBlank()) continue; + + String laneKey = "CUSTOM:" + trimmed; + laneCounts.computeIfAbsent(laneKey, k -> new EnumMap<>(ProcessStatus.class)) + .put(r.getStatus(), r.getCnt()); + } + + + // 정렬 : ROLE 먼저, CUSTOM 다음, 이름 오름차순으로 + List sortedKeys = laneCounts.keySet().stream() + .sorted((a, b) -> { + int ta = a.startsWith("ROLE:") ? 0 : 1; + int tb = b.startsWith("CUSTOM:") ? 0 : 1; + if(ta != tb) return Integer.compare(ta, tb); + return a.compareTo(b); + }) + .toList(); + + List lanes = sortedKeys.stream() + .map(laneKey -> { + EnumMap m = laneCounts.get(laneKey); + + long planning = m.getOrDefault(ProcessStatus.PLANNING, 0L); + long inProgress = m.getOrDefault(ProcessStatus.IN_PROGRESS, 0L); + long done = m.getOrDefault(ProcessStatus.DONE, 0L); + long total = planning + inProgress + done; + + int planningRate = rate(planning, total); + int inProgressRate = rate(inProgress, total); + int doneRate = (total == 0) ? 0 : Math.max(0, 100 - planningRate - inProgressRate); + + LaneType laneType = laneKey.startsWith("ROLE:") ? LaneType.ROLE : LaneType.CUSTOM; + String laneName = laneType == LaneType.ROLE + ? laneKey.substring("ROLE:".length()) + : laneKey.substring("CUSTOM:".length()); + + return new LaneProgressResDto( + laneKey, + laneType, + laneName, + planning, + inProgress, + done, + total, + planningRate, + inProgressRate, + doneRate + ); + }) + .toList(); + + return new ProcessProgressSummaryResDto(lanes); + } + + private int rate(long part, long total) { + if (total == 0) return 0; + return (int) Math.round(part * 100.0 / total); + } + // 프로세스 위치 상태 정렬 변경 서비스 @Transactional From 0cf40a2404932ea0b4f2eae887639800632bdf5f Mon Sep 17 00:00:00 2001 From: infiniment Date: Thu, 5 Feb 2026 19:40:08 +0900 Subject: [PATCH 27/66] =?UTF-8?q?[Fix]=20=EC=B6=A9=EB=8F=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/process/service/ProcessService.java | 102 ------------------ 1 file changed, 102 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index 221fd3eb..e900947a 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -1517,108 +1517,6 @@ private int rate(long part, long total) { return (int) Math.round(part * 100.0 / total); } - // 파트별 작업 진행률 조회 서비스 - @Transactional(readOnly = true) - public ProcessProgressSummaryResDto getPartProgressSummary(Long projectId, Long userId) { - assertActiveProjectMember(projectId, userId); - - if (!projectRepository.existsById(projectId)) { - throw new ProcessException(ProcessErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId); - } - - List statuses = List.of( - ProcessStatus.PLANNING, - ProcessStatus.IN_PROGRESS, - ProcessStatus.DONE - ); - - RoleField custom = RoleField.CUSTOM; - - List roleRows = - processRepository.countRoleLaneStatusForProgressSummary(projectId, custom, statuses); - - List customRows = - processRepository.countCustomLaneStatusForProgressSummary(projectId, custom, statuses); - - // laneKey -> status -> count - Map> laneCounts = new LinkedHashMap<>(); - - // ROLE lanes - for (var r : roleRows) { - RoleField rf = r.getRoleField(); - if (rf == null) continue; - - String laneKey = "ROLE:" + rf.name(); - laneCounts.computeIfAbsent(laneKey, k -> new EnumMap<>(ProcessStatus.class)) - .put(r.getStatus(), r.getCnt()); - } - - // CUSTOM lanes - for (var r : customRows) { - String name = r.getCustomName(); - if (name == null) continue; - - String trimmed = name.trim(); - if (trimmed.isBlank()) continue; - - String laneKey = "CUSTOM:" + trimmed; - laneCounts.computeIfAbsent(laneKey, k -> new EnumMap<>(ProcessStatus.class)) - .put(r.getStatus(), r.getCnt()); - } - - - // 정렬 : ROLE 먼저, CUSTOM 다음, 이름 오름차순으로 - List sortedKeys = laneCounts.keySet().stream() - .sorted((a, b) -> { - int ta = a.startsWith("ROLE:") ? 0 : 1; - int tb = b.startsWith("CUSTOM:") ? 0 : 1; - if(ta != tb) return Integer.compare(ta, tb); - return a.compareTo(b); - }) - .toList(); - - List lanes = sortedKeys.stream() - .map(laneKey -> { - EnumMap m = laneCounts.get(laneKey); - - long planning = m.getOrDefault(ProcessStatus.PLANNING, 0L); - long inProgress = m.getOrDefault(ProcessStatus.IN_PROGRESS, 0L); - long done = m.getOrDefault(ProcessStatus.DONE, 0L); - long total = planning + inProgress + done; - - int planningRate = rate(planning, total); - int inProgressRate = rate(inProgress, total); - int doneRate = (total == 0) ? 0 : Math.max(0, 100 - planningRate - inProgressRate); - - LaneType laneType = laneKey.startsWith("ROLE:") ? LaneType.ROLE : LaneType.CUSTOM; - String laneName = laneType == LaneType.ROLE - ? laneKey.substring("ROLE:".length()) - : laneKey.substring("CUSTOM:".length()); - - return new LaneProgressResDto( - laneKey, - laneType, - laneName, - planning, - inProgress, - done, - total, - planningRate, - inProgressRate, - doneRate - ); - }) - .toList(); - - return new ProcessProgressSummaryResDto(lanes); - } - - private int rate(long part, long total) { - if (total == 0) return 0; - return (int) Math.round(part * 100.0 / total); - } - - // 프로세스 위치 상태 정렬 변경 서비스 @Transactional public ProcessOrderUpdateResDto updateProcessOrder(Long projectId, Long userId, Long processId, ProcessOrderUpdateReqDto req) { From 909902ed06dc9c7e6f87d28e4af8e6c934fef3ad Mon Sep 17 00:00:00 2001 From: infiniment Date: Thu, 5 Feb 2026 23:37:18 +0900 Subject: [PATCH 28/66] =?UTF-8?q?[Feat]=20=EC=9C=84=ED=81=AC=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD/=ED=95=A0?= =?UTF-8?q?=EC=9D=BC=20=ED=95=AD=EB=AA=A9=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WeekMissionController.java | 76 +++ .../dto/req/ProcessTaskItemReorderReqDto.java | 10 +- .../req/WeekMissionStatusUpdateReqDto.java | 9 + .../req/WeekMissionTaskItemReorderReqDto.java | 17 + .../req/WeekMissionTaskItemUpdateReqDto.java | 18 + .../dto/res/WeekMissionDetailResDto.java | 74 +++ .../dto/res/WeekMissionWeekResDto.java | 61 +++ .../service/ProcessTaskItemService.java | 231 +++++++- .../process/service/WeekMissionService.java | 513 ++++++++++++++++++ .../team/project/service/ProjectService.java | 2 +- .../team/{process => }/ProjectTeamRole.java | 3 +- .../analysis/ProjectTeamRoleRepository.java | 2 +- .../team/ProjectUserRepository.java | 25 + .../team/process/ProcessRepository.java | 157 +++++- .../process/ProcessTaskItemRepository.java | 31 ++ 15 files changed, 1192 insertions(+), 37 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionStatusUpdateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemReorderReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemUpdateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionWeekResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java rename nect-core/src/main/java/com/nect/core/entity/team/{process => }/ProjectTeamRole.java (91%) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java new file mode 100644 index 00000000..825b4ac2 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java @@ -0,0 +1,76 @@ +package com.nect.api.domain.team.process.controller; + +import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; +import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; +import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; +import com.nect.api.domain.team.process.service.WeekMissionService; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}/week-missions") +public class WeekMissionController { + + private final WeekMissionService weekMissionService; + + // 주차별 위크미션 조회 + @GetMapping("/week") + public ApiResponse getWeekMissions( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(value = "start_date", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(value = "weeks", required = false, defaultValue = "1") Integer weeks + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(weekMissionService.getWeekMissions(projectId, userId, startDate, weeks)); + } + + // 위크미션 상세 조회(체크리스트 포함) + @GetMapping("/{processId}") + public ApiResponse getWeekMissionDetail( + @PathVariable Long projectId, + @PathVariable Long processId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok( + weekMissionService.getDetail(projectId, userDetails.getUserId(), processId) + ); + } + + // 위크미션 상태 변경 + @PatchMapping("/{processId}/status") + public ApiResponse updateWeekMissionStatus( + @PathVariable Long projectId, + @PathVariable Long processId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody WeekMissionStatusUpdateReqDto req + ) { + weekMissionService.updateWeekMissionStatus(projectId, userDetails.getUserId(), processId, req); + return ApiResponse.ok(null); + } + + // 위크미션 TASK 내 항목 내용 수정 + @PatchMapping("/{processId}/task-items/{taskItemId}") + public ApiResponse updateWeekMissionTaskItem( + @PathVariable Long projectId, + @PathVariable Long processId, + @PathVariable Long taskItemId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody WeekMissionTaskItemUpdateReqDto req + ) { + return ApiResponse.ok( + weekMissionService.updateWeekMissionTaskItem(projectId, userDetails.getUserId(), processId, taskItemId, req) + ); + } + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java index ace1b35e..aec05c72 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java @@ -1,10 +1,18 @@ package com.nect.api.domain.team.process.dto.req; import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; import java.util.List; public record ProcessTaskItemReorderReqDto( @JsonProperty("ordered_task_item_ids") - List orderedTaskItemIds + List orderedTaskItemIds, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName + ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionStatusUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionStatusUpdateReqDto.java new file mode 100644 index 00000000..97531f2d --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionStatusUpdateReqDto.java @@ -0,0 +1,9 @@ +package com.nect.api.domain.team.process.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.process.enums.ProcessStatus; + +public record WeekMissionStatusUpdateReqDto( + @JsonProperty("status") + ProcessStatus status +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemReorderReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemReorderReqDto.java new file mode 100644 index 00000000..9e689c22 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemReorderReqDto.java @@ -0,0 +1,17 @@ +package com.nect.api.domain.team.process.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +import java.util.List; + +public record WeekMissionTaskItemReorderReqDto( + @JsonProperty("ordered_task_item_ids") + List orderedTaskItemIds, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemUpdateReqDto.java new file mode 100644 index 00000000..c00df089 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemUpdateReqDto.java @@ -0,0 +1,18 @@ +package com.nect.api.domain.team.process.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +public record WeekMissionTaskItemUpdateReqDto( + @JsonProperty("content") + String content, + + @JsonProperty("is_done") + Boolean isDone, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java new file mode 100644 index 00000000..d758526f --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java @@ -0,0 +1,74 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.nect.core.entity.team.process.enums.ProcessStatus; +import com.nect.core.entity.user.enums.RoleField; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public record WeekMissionDetailResDto( + @JsonProperty("process_id") + Long processId, + + @JsonProperty("mission_number") + Integer missionNumber, + + @JsonProperty("title") + String title, + + @JsonProperty("content") + String content, + + @JsonProperty("status") + ProcessStatus status, + + @JsonProperty("start_date") + LocalDate startDate, + + @JsonProperty("dead_line") + LocalDate deadLine, + + @JsonProperty("assignee") + AssigneeDto assignee, + + @JsonProperty("task_groups") + List taskGroups, + + @JsonProperty("task_items") + List taskItems, + + @JsonProperty("created_at") + LocalDateTime createdAt, + + @JsonProperty("updated_at") + LocalDateTime updatedAt +) { + public record AssigneeDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("name") + String name, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} + + public record TaskGroupResDto( + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_field_name") + String customFieldName, + + @JsonProperty("items") + List items + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionWeekResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionWeekResDto.java new file mode 100644 index 00000000..47b6efbc --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionWeekResDto.java @@ -0,0 +1,61 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.process.enums.ProcessStatus; + +import java.time.LocalDate; +import java.util.List; + +public record WeekMissionWeekResDto( + @JsonProperty("week_start") + LocalDate weekStart, + + @JsonProperty("week_end") + LocalDate weekEnd, + + @JsonProperty("missions") + List missions +) { + public record WeekMissionCardResDto( + @JsonProperty("process_id") + Long processId, + + @JsonProperty("mission_number") + Integer missionNumber, + + @JsonProperty("status") + ProcessStatus status, + + @JsonProperty("title") + String title, + + @JsonProperty("start_date") + LocalDate startDate, + + @JsonProperty("dead_line") + LocalDate deadLine, + + @JsonProperty("left_day") + Integer leftDay, + + @JsonProperty("done_count") + int doneCount, + + @JsonProperty("total_count") + int totalCount, + + @JsonProperty("assignee") + AssigneeProfileDto assignee + ) {} + + public record AssigneeProfileDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java index 6bb71493..01c928d6 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java @@ -1,5 +1,7 @@ package com.nect.api.domain.team.process.service; +import com.nect.api.domain.notifications.command.NotificationCommand; +import com.nect.api.domain.notifications.facade.NotificationFacade; import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; import com.nect.api.domain.team.process.dto.req.ProcessTaskItemReorderReqDto; import com.nect.api.domain.team.process.dto.req.ProcessTaskItemUpsertReqDto; @@ -7,13 +9,23 @@ import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; import com.nect.api.domain.team.process.enums.ProcessErrorCode; import com.nect.api.domain.team.process.exception.ProcessException; +import com.nect.core.entity.notifications.enums.NotificationClassification; +import com.nect.core.entity.notifications.enums.NotificationScope; +import com.nect.core.entity.notifications.enums.NotificationType; +import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.history.enums.HistoryAction; import com.nect.core.entity.team.history.enums.HistoryTargetType; import com.nect.core.entity.team.process.Process; import com.nect.core.entity.team.process.ProcessTaskItem; +import com.nect.core.entity.team.process.enums.ProcessType; +import com.nect.core.entity.user.User; +import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.process.ProcessRepository; import com.nect.core.repository.team.process.ProcessTaskItemRepository; +import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +41,9 @@ public class ProcessTaskItemService { private final ProjectUserRepository projectUserRepository; private final ProjectHistoryPublisher historyPublisher; + private final UserRepository userRepository; + private final NotificationFacade notificationFacade; + // 헬퍼 메서드 private void assertWritableMember(Long projectId, Long userId) { if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { @@ -55,6 +70,7 @@ private ProcessTaskItem getTaskItem(Long processId, Long taskItemId) { )); } + // 전체 정규화 private void normalizeSortOrder(Long processId) { List items = taskItemRepository.findAllByProcessIdAndDeletedAtIsNullOrderBySortOrderAsc(processId); @@ -69,6 +85,44 @@ private void normalizeSortOrder(Long processId) { } } + // 파트별 정규화 + private void normalizeSortOrderByGroup(Long processId, RoleField roleField, String customName) { + List items = taskItemRepository + .findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameOrderBySortOrderAsc( + processId, roleField, customName + ); + + items = items.stream() + .sorted(Comparator.comparing(t -> t.getSortOrder() == null ? Integer.MAX_VALUE : t.getSortOrder())) + .toList(); + + int i = 0; + for (ProcessTaskItem it : items) { + it.updateSortOrder(i++); + } + } + + private void assertWeekMissionLeader(Long projectId, Long userId) { + boolean isLeader = projectUserRepository + .existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus(projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE); + + if (!isLeader) { + throw new ProcessException( + ProcessErrorCode.FORBIDDEN, + "WEEK_MISSION은 프로젝트 리더만 수정할 수 있습니다. projectId=" + projectId + ", userId=" + userId + ); + } + } + + private void assertReorderPermission(Long projectId, Long userId, Process process) { + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + assertWeekMissionLeader(projectId, userId); + return; + } + assertWritableMember(projectId, userId); + } + + // 항목 생성 서비스 @Transactional public ProcessTaskItemResDto create(Long projectId, Long userId, Long processId, ProcessTaskItemUpsertReqDto req) { @@ -76,6 +130,9 @@ public ProcessTaskItemResDto create(Long projectId, Long userId, Long processId, Process process = getActiveProcess(projectId, processId); + // 위크미션 TASK 수정 권한(리더만 가능) + assertReorderPermission(projectId, userId, process); + if (req.content() == null || req.content().isBlank()) { throw new ProcessException(ProcessErrorCode.INVALID_TASK_ITEM_CONTENT); } @@ -106,13 +163,6 @@ public ProcessTaskItemResDto create(Long projectId, Long userId, Long processId, // 최종 정규화 normalizeSortOrder(processId); - // TODO(Notification): - // - 프로젝트 멤버 전체 또는 해당 프로세스 관련자(assignee/mention)에게 "업무 항목 추가" 알림 전송 - // - 유저/멤버십 붙으면 NotificationFacade 주입 후 notify 호출 - // - 권장: AFTER_COMMIT 이벤트로 보내기 - // notifyProjectMembersTodo(projectId, actorUserId, processId, "TASK_ITEM_CREATED"); - // notifyMentionsTodo(projectId, actorUserId, processId, /* process mention ids */); - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("taskItemId", saved.getId()); @@ -221,13 +271,6 @@ public ProcessTaskItemResDto update(Long projectId, Long userId, Long processId, "sortOrder", item.getSortOrder() )); - // TODO(Notification): - // - 유저/멤버십 연동 후 수신자 결정(프로젝트 멤버 / 해당 프로세스 assignee / mention 등) - // - "업무 항목 수정" 알림 전송 - // - meta에 변경 요약 포함 권장(예: done 토글, 내용 변경, 순서 변경) - // - 권장: AFTER_COMMIT 이벤트 리스너로 전송 - // notifyTaskItemUpdatedTodo(projectId, actorUserId, processId, item.getId(), ...); - historyPublisher.publish( projectId, userId, @@ -267,8 +310,6 @@ public void delete(Long projectId, Long userId, Long processId, Long taskItemId) normalizeSortOrder(processId); - // TODO(Notification) - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("taskItemId", taskItemId); @@ -285,15 +326,44 @@ public void delete(Long projectId, Long userId, Long processId, Long taskItemId) meta ); - // TODO(TEAM EVENT FACADE): 추후 ActivityFacade로 통합 } - // 업무 위치 변경 서비스 + private List loadWorkspaceReceivers(Long projectId, Long actorId) { + return projectUserRepository.findAllUsersByProjectId(projectId).stream() + .filter(u -> !u.getUserId().equals(actorId)) + .toList(); + } + + private void notifyWorkspaceWeekMissionUpdated(Process process, Long actorId) { + // WEEK_MISSION만 알림 + if (process.getProcessType() != com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) return; + + Project project = process.getProject(); + User actor = userRepository.findById(actorId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.USER_NOT_FOUND, "userId=" + actorId)); + + List receivers = loadWorkspaceReceivers(project.getId(), actor.getUserId()); + if (receivers.isEmpty()) return; + + NotificationCommand command = new NotificationCommand( + NotificationType.WORKSPACE_MISSION_UPDATED, + NotificationClassification.WORK_STATUS, + NotificationScope.WORKSPACE_ONLY, + process.getId(), + new Object[]{ process.getMissionNumber() }, + new Object[]{ process.getTitle() }, + project + ); + + notificationFacade.notify(receivers, command); + } + + // 업무 위치 변경 서비스 (멤버형, 리더형을 하나로 관리) @Transactional public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long processId, ProcessTaskItemReorderReqDto req) { - assertWritableMember(projectId, userId); + Process process = getActiveProcess(projectId, processId); - getActiveProcess(projectId, processId); + assertReorderPermission(projectId, userId, process); if (req == null || req.orderedTaskItemIds() == null || req.orderedTaskItemIds().isEmpty()) { throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ordered_task_item_ids is empty"); @@ -311,11 +381,118 @@ public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long pr throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ordered_task_item_ids contains duplicates"); } + // 위크미션 TASK내에 필드별 항목 리스트 / 전체(멤버형) + RoleField roleField = req.roleField(); + String customName = req.customRoleFieldName(); + + boolean groupMode = (roleField != null); + + if (groupMode) { + // 분야별 모드 유효성 검사 + if (roleField == RoleField.CUSTOM) { + if (customName == null || customName.isBlank()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "CUSTOM이면 custom_role_field_name 필수"); + } + customName = customName.trim(); + } else { + // CUSTOM이 아니면 null로 고정 + customName = null; + } + + // 분야별 정규화(꼬임 방지) + normalizeSortOrderByGroup(processId, roleField, customName); + + // beforeIds (분야별) + List groupAll = taskItemRepository + .findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameOrderBySortOrderAsc( + processId, roleField, customName + ); + + List beforeIds = groupAll.stream().map(ProcessTaskItem::getId).toList(); + + // 변경 없으면 그대로 반환 + if (beforeIds.equals(orderedIds)) { + List resItems = groupAll.stream() + .map(t -> new ProcessTaskItemResDto(t.getId(), t.getContent(), t.isDone(), t.getSortOrder(), t.getDoneAt())) + .toList(); + return new ProcessTaskItemReorderResDto(processId, resItems); + } + + // 전체 포함 정책(그룹 단위) + if (groupAll.size() != orderedIds.size()) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "ordered_task_item_ids must include all task items of the group" + ); + } + + // 요청 ids가 모두 해당 그룹의 항목인지 검증 + List targets = + taskItemRepository.findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameAndIdIn( + processId, roleField, customName, orderedIds + ); + + if (targets.size() != orderedIds.size()) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "ordered_task_item_ids contains invalid taskItemId(s) for the group" + ); + } + + Map map = targets.stream() + .collect(Collectors.toMap(ProcessTaskItem::getId, t -> t)); + + // 재정렬 반영(분야별 그룹 내부 0..n-1) + int i = 0; + for (Long id : orderedIds) { + ProcessTaskItem item = map.get(id); + item.updateSortOrder(i++); + } + + Map meta = new LinkedHashMap<>(); + meta.put("processId", processId); + meta.put("processType", process.getProcessType() == null ? null : process.getProcessType().name()); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + + meta.put("groupMode", true); + meta.put("roleField", roleField.name()); + meta.put("customRoleFieldName", customName); + + meta.put("beforeOrderedTaskItemIds", beforeIds); + meta.put("afterOrderedTaskItemIds", orderedIds); + + historyPublisher.publish( + projectId, + userId, + HistoryAction.TASK_ITEM_REORDERED, + HistoryTargetType.PROCESS, + processId, + meta + ); + + notifyWorkspaceWeekMissionUpdated(process, userId); + + // 응답(요청 순서대로) + List resItems = orderedIds.stream() + .map(id -> { + ProcessTaskItem t = map.get(id); + return new ProcessTaskItemResDto( + t.getId(), t.getContent(), t.isDone(), t.getSortOrder(), t.getDoneAt() + ); + }) + .toList(); + + return new ProcessTaskItemReorderResDto(processId, resItems); + } + + /* + * 멤버형 프로세스 모달 전용 + * */ + // 꼬임 방지용 정규화 normalizeSortOrder(processId); - - // TODO(HISTORY/NOTI): before orderedIds 스냅샷이 필요하면 여기서 조회 List beforeIds = taskItemRepository .findAllByProcessIdAndDeletedAtIsNullOrderBySortOrderAsc(processId) .stream() @@ -367,9 +544,13 @@ public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long pr item.updateSortOrder(i++); } - // TODO(Notification): Map meta = new LinkedHashMap<>(); meta.put("processId", processId); + meta.put("processType", process.getProcessType() == null ? null : process.getProcessType().name()); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + + meta.put("groupMode", false); meta.put("beforeOrderedTaskItemIds", beforeIds); meta.put("afterOrderedTaskItemIds", orderedIds); @@ -381,7 +562,9 @@ public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long pr processId, meta ); - // TODO(TEAM EVENT FACADE): 추후 ActivityFacade로 통합 + + notifyWorkspaceWeekMissionUpdated(process, userId); + List resItems = orderedIds.stream() .map(id -> { diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java new file mode 100644 index 00000000..5ef99da4 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -0,0 +1,513 @@ +package com.nect.api.domain.team.process.service; + +import com.nect.api.domain.notifications.command.NotificationCommand; +import com.nect.api.domain.notifications.facade.NotificationFacade; +import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; +import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; +import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; +import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; +import com.nect.api.domain.team.process.enums.ProcessErrorCode; +import com.nect.api.domain.team.process.exception.ProcessException; +import com.nect.core.entity.notifications.enums.NotificationClassification; +import com.nect.core.entity.notifications.enums.NotificationScope; +import com.nect.core.entity.notifications.enums.NotificationType; +import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.team.history.enums.HistoryAction; +import com.nect.core.entity.team.history.enums.HistoryTargetType; +import com.nect.core.entity.team.process.Process; +import com.nect.core.entity.team.process.ProcessTaskItem; +import com.nect.core.entity.team.process.enums.ProcessStatus; +import com.nect.core.entity.user.User; +import com.nect.core.entity.user.enums.RoleField; +import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.team.process.ProcessRepository; +import com.nect.core.repository.team.process.ProcessTaskItemRepository; +import com.nect.core.repository.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class WeekMissionService { + + private final ProjectRepository projectRepository; + private final ProjectUserRepository projectUserRepository; + private final ProcessRepository processRepository; + private final ProcessTaskItemRepository processTaskItemRepository; + + private final UserRepository userRepository; + private final NotificationFacade notificationFacade; + private final ProjectHistoryPublisher historyPublisher; + + private void assertActiveProjectMember(Long projectId, Long userId) { + if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { + throw new ProcessException( + ProcessErrorCode.FORBIDDEN, + "프로젝트 멤버가 아닙니다. projectId=" + projectId + ", userId=" + userId + ); + } + } + + private void assertActiveLeader(Long projectId, Long userId) { + boolean ok = projectUserRepository.existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus( + projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE + ); + if (!ok) { + throw new ProcessException(ProcessErrorCode.FORBIDDEN, "WEEK_MISSION 수정은 프로젝트 리더만 가능합니다."); + } + } + + private String normalizeCustom(RoleField roleField, String customName) { + if (roleField == RoleField.CUSTOM) { + if (customName == null || customName.isBlank()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "CUSTOM이면 custom_role_field_name 필수"); + } + return customName.trim(); + } + return null; + } + + private void normalizeGroupOrders(Long processId, RoleField roleField, String customName) { + List items = processTaskItemRepository + .findWeekMissionGroupItemsOrdered(processId, roleField, customName); + + int i = 0; + for (ProcessTaskItem it : items) { + it.updateSortOrder(i++); + } + } + + private void assertProjectExists(Long projectId) { + if (!projectRepository.existsById(projectId)) { + throw new ProcessException(ProcessErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId); + } + } + + private Integer calcLeftDay(LocalDate deadLine) { + if (deadLine == null) return null; + long diff = ChronoUnit.DAYS.between(LocalDate.now(), deadLine); + return (int) Math.max(diff, 0); + } + + + private WeekMissionWeekResDto toWeekRes( + LocalDate start, + LocalDate end, + List rows, + WeekMissionWeekResDto.AssigneeProfileDto leaderFallback + ){ + List cards = rows.stream() + .map(r -> { + long done = (r.getDoneCount() == null) ? 0L : r.getDoneCount(); + long total = (r.getTotalCount() == null) ? 0L : r.getTotalCount(); + + WeekMissionWeekResDto.AssigneeProfileDto assignee = + (r.getLeaderUserId() != null) + ? new WeekMissionWeekResDto.AssigneeProfileDto( + r.getLeaderUserId(), + r.getLeaderNickname(), + r.getLeaderProfileImageUrl() + ) + : leaderFallback; + + return new WeekMissionWeekResDto.WeekMissionCardResDto( + r.getProcessId(), + r.getMissionNumber(), + r.getStatus(), + r.getTitle(), + r.getStartDate(), + r.getDeadLine(), + calcLeftDay(r.getDeadLine()), + (int) done, + (int) total, + assignee + ); + }) + .toList(); + + return new WeekMissionWeekResDto(start, end, cards); + } + + private List loadWorkspaceReceivers(Long projectId, Long actorId) { + return projectUserRepository.findAllUsersByProjectId(projectId).stream() + .filter(u -> !u.getUserId().equals(actorId)) + .toList(); + } + + private void notifyWorkspaceWeekMissionUpdated(Project project, User actor, Process process) { + List receivers = loadWorkspaceReceivers(project.getId(), actor.getUserId()); + if (receivers == null || receivers.isEmpty()) return; + + NotificationCommand command = new NotificationCommand( + NotificationType.WORKSPACE_MISSION_UPDATED, + NotificationClassification.WORK_STATUS, + NotificationScope.WORKSPACE_ONLY, + process.getId(), + new Object[]{ process.getMissionNumber() }, + new Object[]{ process.getTitle() }, + project + ); + + notificationFacade.notify(receivers, command); + } + + private void publishWeekMissionHistory( + Long projectId, Long userId, Long processId, + HistoryAction action, + Map meta + ) { + historyPublisher.publish( + projectId, + userId, + action, + HistoryTargetType.PROCESS, + processId, + meta + ); + } + + /** + * 주차(월~일) 기준 WEEK_MISSION 목록 (정규화 O) + * GET /week-missions/week?start_date=YYYY-MM-DD&weeks=4 + */ + @Transactional(readOnly = true) + public WeekMissionWeekResDto getWeekMissions(Long projectId, Long userId, LocalDate startDate, Integer weeks) { + assertActiveProjectMember(projectId, userId); + assertProjectExists(projectId); + + int w = (weeks == null) ? 1 : weeks; + + // 방어 (원하는 상한 정하면 됨) + if (w <= 0) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "weeks must be >= 1"); + } + if (w > 12) { // 예: 과도 조회 방지 + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "weeks is too large"); + } + + LocalDate fallback = processRepository.findMinWeekMissionStartAt(projectId); + if (fallback == null) { + // 프로젝트에 아직 위크미션이 없다면(혹은 startAt이 전부 null) + fallback = LocalDate.now(); + } + + LocalDate weekStart = resolveWeekStart(startDate, fallback); + LocalDate end = weekStart.plusDays(w * 7L - 1); + + var rows = processRepository.findWeekMissionCardsInRange(projectId, weekStart, end); + + WeekMissionWeekResDto.AssigneeProfileDto leaderFallback = projectUserRepository + .findActiveLeaderProfile(projectId) + .map(r -> new WeekMissionWeekResDto.AssigneeProfileDto( + r.getUserId(), + r.getNickname(), + r.getProfileImageUrl() + )) + .orElse(null); + + return toWeekRes(weekStart, end, rows, leaderFallback); + } + + /** + * WEEK_MISSION 상세 (체크리스트 포함) + * GET /week-missions/{processId} + */ + @Transactional(readOnly = true) + public WeekMissionDetailResDto getDetail(Long projectId, Long userId, Long processId) { + assertActiveProjectMember(projectId, userId); + + Process process = processRepository.findWeekMissionDetail(projectId, processId) + .orElseThrow(() -> new ProcessException( + ProcessErrorCode.PROCESS_NOT_FOUND, + "projectId=" + projectId + ", processId=" + processId + )); + + // 삭제 제외 + 정렬(공통) + List aliveItems = process.getTaskItems().stream() + .filter(t -> t.getDeletedAt() == null) + .sorted(Comparator.comparing(t -> t.getSortOrder() == null ? Integer.MAX_VALUE : t.getSortOrder())) + .toList(); + + // task_items + List taskItems = aliveItems.stream() + .map(t -> new ProcessTaskItemResDto( + t.getId(), + t.getContent(), + t.isDone(), + t.getSortOrder(), + t.getDoneAt() + )) + .toList(); + + // task_groups (리더형: roleField + customRoleFieldName 기준) + record GroupKey(RoleField roleField, String customName) {} + + Map> grouped = aliveItems.stream() + .collect(Collectors.groupingBy( + t -> new GroupKey(t.getRoleField(), t.getCustomRoleFieldName()), + LinkedHashMap::new, + Collectors.toList() + )); + + List taskGroups = grouped.entrySet().stream() + .map(e -> { + GroupKey key = e.getKey(); + + List items = e.getValue().stream() + .sorted(Comparator.comparing(t -> t.getSortOrder() == null ? Integer.MAX_VALUE : t.getSortOrder())) + .map(t -> new ProcessTaskItemResDto( + t.getId(), + t.getContent(), + t.isDone(), + t.getSortOrder(), + t.getDoneAt() + )) + .toList(); + + return new WeekMissionDetailResDto.TaskGroupResDto( + key.roleField(), + key.customName(), + items + ); + }) + // 그룹 순서 정렬 + .sorted((a, b) -> { + // null은 맨 뒤 + int ra = (a.roleField() == null) ? Integer.MAX_VALUE : a.roleField().ordinal(); + int rb = (b.roleField() == null) ? Integer.MAX_VALUE : b.roleField().ordinal(); + + // CUSTOM은 일반 RoleField 뒤로 보내고 싶으면 가중치 + if (a.roleField() == RoleField.CUSTOM) ra += 1000; + if (b.roleField() == RoleField.CUSTOM) rb += 1000; + + int cmp = Integer.compare(ra, rb); + if (cmp != 0) return cmp; + + // 같은 roleField면 customFieldName 알파벳/가나다 순 + String ca = (a.customFieldName() == null) ? "" : a.customFieldName(); + String cb = (b.customFieldName() == null) ? "" : b.customFieldName(); + return ca.compareTo(cb); + }) + .toList(); + + User leader = process.getCreatedBy(); + + WeekMissionDetailResDto.AssigneeDto assignee = new WeekMissionDetailResDto.AssigneeDto( + leader.getUserId(), + leader.getName(), + leader.getNickname(), + leader.getProfileImageUrl() + ); + + // DTO 생성자 인자 순서 주의: (taskGroups, taskItems) 둘 다 넣기 + return new WeekMissionDetailResDto( + process.getId(), + process.getMissionNumber(), + process.getTitle(), + process.getContent(), + process.getStatus(), + process.getStartAt(), + process.getEndAt(), + assignee, + taskGroups, + taskItems, + process.getCreatedAt(), + process.getUpdatedAt() + ); + } + + // 위크미션 TASK 프로세스 상태 변경 서비스 + @Transactional + public void updateWeekMissionStatus(Long projectId, Long userId, Long processId, WeekMissionStatusUpdateReqDto req) { + assertActiveLeader(projectId, userId); + + if (req == null || req.status() == null) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "status is required"); + } + + Process process = processRepository.findWeekMissionDetail(projectId, processId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.PROCESS_NOT_FOUND)); + + + ProcessStatus before = process.getStatus(); + ProcessStatus after = req.status(); + if(before == after) return; + + process.updateStatus(after); + + User actor = userRepository.findById(userId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.USER_NOT_FOUND, "userId=" + userId)); + + Project project = process.getProject(); + + notifyWorkspaceWeekMissionUpdated(project, actor, process); + + Map meta = new LinkedHashMap<>(); + meta.put("processType", "WEEK_MISSION"); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + meta.put("beforeStatus", before.name()); + meta.put("afterStatus", after.name()); + + publishWeekMissionHistory( + projectId, userId, processId, + HistoryAction.PROCESS_STATUS_CHANGED, + meta + ); + } + + // 위크미션 TASK 항목 수정 + @Transactional + public ProcessTaskItemResDto updateWeekMissionTaskItem( + Long projectId, Long userId, Long processId, Long taskItemId, WeekMissionTaskItemUpdateReqDto req + ) { + assertActiveLeader(projectId, userId); + + // 위크미션 존재 검증 + Process process = processRepository.findWeekMissionDetail(projectId, processId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.PROCESS_NOT_FOUND)); + + ProcessTaskItem item = processTaskItemRepository.findByIdAndProcessIdAndDeletedAtIsNull(taskItemId, processId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.TASK_ITEM_NOT_FOUND)); + + if (req == null) throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "request is null"); + + boolean changed = false; + + // Before 스냅샷 + String beforeContent = item.getContent(); + Boolean beforeDone = item.isDone(); + Integer beforeSortOrder = item.getSortOrder(); + RoleField beforeRole = item.getRoleField(); + String beforeCustom = item.getCustomRoleFieldName(); + + + // content + if (req.content() != null) { + if (req.content().isBlank()) throw new ProcessException(ProcessErrorCode.INVALID_TASK_ITEM_CONTENT); + String newContent = req.content().trim(); + if (!newContent.equals(beforeContent)) { + item.updateContent(newContent); + changed = true; + } + } + + // done + if (req.isDone() != null) { + boolean newDone = req.isDone(); + if (newDone != beforeDone) { + item.updateDone(newDone); + changed = true; + } + } + + + // role 변경(원하면 허용 / 싫으면 이 블록 삭제) + if (req.roleField() != null) { + RoleField newRole = req.roleField(); + String newCustom = normalizeCustom(newRole, req.customRoleFieldName()); + + boolean roleChanged = + newRole != beforeRole || + (newRole == RoleField.CUSTOM && !java.util.Objects.equals(newCustom, beforeCustom)); + + if (roleChanged) { + // 이동 전 그룹 정보 저장 + RoleField oldRole = beforeRole; + String oldCustom = beforeCustom; + + try { + item.updateRoleField(newRole, newCustom); + } catch (IllegalArgumentException e) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, e.getMessage()); + } + + // 이전 그룹 정규화 + normalizeGroupOrders(processId, oldRole, oldCustom); + + // 새 그룹 끝으로 보내고 정규화 + List newGroup = processTaskItemRepository + .findWeekMissionGroupItemsOrdered(processId, newRole, newCustom); + + int nextOrder = newGroup.size() - 1; + item.updateSortOrder(Math.max(nextOrder, 0)); + normalizeGroupOrders(processId, newRole, newCustom); + + changed = true; + } + } + + if (!changed) { + return new ProcessTaskItemResDto( + item.getId(), item.getContent(), item.isDone(), item.getSortOrder(), item.getDoneAt() + ); + } + + User actor = userRepository.findById(userId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.USER_NOT_FOUND, "userId=" + userId)); + Project project = process.getProject(); + + notifyWorkspaceWeekMissionUpdated(project, actor, process); + + Map meta = new LinkedHashMap<>(); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + meta.put("taskItemId", item.getId()); + + meta.put("before", Map.of( + "content", beforeContent, + "isDone", beforeDone, + "sortOrder", beforeSortOrder, + "roleField", beforeRole == null ? null : beforeRole.name(), + "customRoleFieldName", beforeCustom + )); + meta.put("after", Map.of( + "content", item.getContent(), + "isDone", item.isDone(), + "sortOrder", item.getSortOrder(), + "roleField", item.getRoleField() == null ? null : item.getRoleField().name(), + "customRoleFieldName", item.getCustomRoleFieldName() + )); + + publishWeekMissionHistory( + projectId, userId, processId, + HistoryAction.TASK_ITEM_UPDATED, + meta + ); + + + + return new ProcessTaskItemResDto( + item.getId(), + item.getContent(), + item.isDone(), + item.getSortOrder(), + item.getDoneAt() + ); + } + + private LocalDate toMonday(LocalDate date) { + return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + } + + private LocalDate resolveWeekStart(LocalDate requested, LocalDate fallbackBaseDate) { + LocalDate base = (requested != null) ? requested : fallbackBaseDate; + return toMonday(base); + } +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java index a2846af9..d461bf8c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java @@ -14,7 +14,7 @@ import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.enums.RecruitmentStatus; import com.nect.core.entity.team.process.ProcessTaskItem; -import com.nect.core.entity.team.process.ProjectTeamRole; +import com.nect.core.entity.team.ProjectTeamRole; import com.nect.core.entity.user.User; import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.analysis.*; diff --git a/nect-core/src/main/java/com/nect/core/entity/team/process/ProjectTeamRole.java b/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java similarity index 91% rename from nect-core/src/main/java/com/nect/core/entity/team/process/ProjectTeamRole.java rename to nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java index 9aa027e1..e48fbd3a 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/process/ProjectTeamRole.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java @@ -1,8 +1,7 @@ -package com.nect.core.entity.team.process; +package com.nect.core.entity.team; import com.nect.core.entity.BaseEntity; -import com.nect.core.entity.team.Project; import com.nect.core.entity.user.enums.RoleField; import jakarta.persistence.*; import lombok.*; diff --git a/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java index 07d1badc..ece578bf 100644 --- a/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java @@ -1,5 +1,5 @@ package com.nect.core.repository.analysis; -import com.nect.core.entity.team.process.ProjectTeamRole; +import com.nect.core.entity.team.ProjectTeamRole; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java index 20aa1d8d..62c09546 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java @@ -228,4 +228,29 @@ interface MemberBoardRow { } Optional findByProjectIdAndMemberType(Long projectId, ProjectMemberType memberType); + + boolean existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus(Long projectId, Long userId, ProjectMemberType projectMemberType, ProjectMemberStatus projectMemberStatus); + + interface ProjectLeaderProfileRow { + Long getUserId(); + String getNickname(); + String getProfileImageUrl(); + } + + @Query(""" + select + u.userId as userId, + u.nickname as nickname, + u.profileImageUrl as profileImageUrl + from ProjectUser pu + join User u + on u.userId = pu.userId + where pu.project.id = :projectId + and pu.memberStatus = com.nect.core.entity.team.enums.ProjectMemberStatus.ACTIVE + and pu.memberType = com.nect.core.entity.team.enums.ProjectMemberType.LEADER + """) + Optional findActiveLeaderProfile(@Param("projectId") Long projectId); + + + } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index 2ea0a4ba..2d0e63ad 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -17,15 +17,13 @@ public interface ProcessRepository extends JpaRepository { // 소속 검증 + 소프트 delete 제외 Optional findByIdAndProjectIdAndDeletedAtIsNull(Long id, Long projectId); - @EntityGraph(attributePaths = { - "processUsers", - "processUsers.user" - }) + @EntityGraph(attributePaths = { "processUsers", "processUsers.user" }) @Query(""" select p from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and ( (p.startAt is null and p.endAt is null) or (p.startAt is null and p.endAt >= :start) @@ -51,6 +49,7 @@ List findAllInRangeOrdered( from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.id in :ids """) List findAllByIdsInProject( @@ -59,6 +58,7 @@ List findAllByIdsInProject( ); + /** * Team 보드(공통) 조회 * - "모든 팀의 작업들을 전부 확인" => 필드/파트 관계없이 전체 프로세스 조회 @@ -75,6 +75,7 @@ List findAllByIdsInProject( and o.deletedAt is null where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) order by p.status asc, coalesce(o.sortOrder, 999999) asc, @@ -82,13 +83,14 @@ List findAllByIdsInProject( """) List findAllForTeamBoard(@Param("projectId") Long projectId); - // ROLE 레인: 조건에 맞는 Process ID만 (정렬은 굳이 안 해도 됨) + // ROLE 레인: 조건에 맞는 Process ID만 @Query(""" select distinct p.id from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField = :roleField """) @@ -104,6 +106,7 @@ List findRoleLaneIds( join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM and trim(pf.customFieldName) = :customName @@ -124,6 +127,7 @@ List findCustomLaneIds( from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.id in :ids """) List findAllByIdsInProjectWithUsers( @@ -137,6 +141,7 @@ List findAllByIdsInProjectWithUsers( from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.id in :ids """) List findAllByIdsInProjectWithFields( @@ -161,6 +166,7 @@ interface MissionProgressRow { JOIN p.processFields pf WHERE p.project.id = :projectId AND p.deletedAt IS NULL + AND (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) AND pf.deletedAt IS NULL GROUP BY pf.roleField, pf.customFieldName """) @@ -182,6 +188,7 @@ interface MemberProcessCountRow { JOIN p.processUsers pu WHERE p.project.id = :projectId AND p.deletedAt IS NULL + AND (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) AND pu.deletedAt IS NULL GROUP BY pu.user.userId, p.status """) @@ -206,6 +213,7 @@ interface LaneStatusCountRow { join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField is not null and pf.roleField <> :custom @@ -229,6 +237,7 @@ List countRoleLaneStatusForProgressSummary( join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField = :custom and pf.customFieldName is not null @@ -242,12 +251,13 @@ List countCustomLaneStatusForProgressSummary( @Param("statuses") List statuses ); - // status 내 TEAM 전체 + // status 내 TEAM 전체 (GENERAL만) @Query(""" select p from Process p where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status """) List findAllByStatusInProject( @@ -255,13 +265,14 @@ List findAllByStatusInProject( @Param("status") ProcessStatus status ); - // status 내 ROLE lane 전체 + // status 내 ROLE lane 전체 (GENERAL만) @Query(""" select distinct p from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status and pf.deletedAt is null and pf.roleField = :roleField @@ -272,13 +283,15 @@ List findAllInRoleLaneByStatus( @Param("roleField") RoleField roleField ); - // status 내 CUSTOM lane 전체 + + // status 내 CUSTOM lane 전체 (GENERAL만) @Query(""" select distinct p from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status and pf.deletedAt is null and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM @@ -304,6 +317,7 @@ interface LaneKeyRow { join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and pf.deletedAt is null and pf.roleField is not null """) @@ -312,12 +326,30 @@ interface LaneKeyRow { int countByProjectIdAndDeletedAtIsNullAndStatus(Long projectId, ProcessStatus status); + /** + * TEAM lane total (GENERAL만) + */ + @Query(""" + select count(p) + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.status = :status + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) + """) + int countTeamLaneTotalExcludingWeekMission( + @Param("projectId") Long projectId, + @Param("status") ProcessStatus status + ); + + // ROLE lane total @Query(""" select count(distinct p) from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status and pf.deletedAt is null and pf.roleField = :roleField @@ -328,12 +360,14 @@ int countRoleLaneTotal( @Param("roleField") RoleField roleField ); + // CUSTOM lane total @Query(""" select count(distinct p) from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.status = :status and pf.deletedAt is null and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM @@ -344,5 +378,112 @@ int countCustomLaneTotal( @Param("status") ProcessStatus status, @Param("customName") String customName ); + + // WEEK_MISSION 상세(체크리스트 포함) + @EntityGraph(attributePaths = { "taskItems", "createdBy" }) + @Query(""" + select p + from Process p + where p.project.id = :projectId + and p.id = :processId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + """) + Optional findWeekMissionDetail( + @Param("projectId") Long projectId, + @Param("processId") Long processId + ); + + + // WEEK_MISSION 주차별 조회 + interface WeekMissionCardRow { + Long getProcessId(); + Integer getMissionNumber(); + ProcessStatus getStatus(); + String getTitle(); + LocalDate getStartDate(); + LocalDate getDeadLine(); + Long getDoneCount(); + Long getTotalCount(); + Long getLeaderUserId(); + String getLeaderNickname(); + String getLeaderProfileImageUrl(); + } + + @Query(""" + select + p.id as processId, + p.missionNumber as missionNumber, + p.status as status, + p.title as title, + p.startAt as startDate, + p.endAt as deadLine, + sum(case when ti.isDone = true then 1 else 0 end) as doneCount, + count(ti.id) as totalCount, + u.userId as leaderUserId, + u.nickname as leaderNickname, + u.profileImageUrl as leaderProfileImageUrl + from Process p + left join p.taskItems ti on ti.deletedAt is null + left join p.processUsers pu + on pu.deletedAt is null + and pu.assignmentRole = com.nect.core.entity.team.process.enums.AssignmentRole.ASSIGNEE + left join pu.user u + where p.project.id = :projectId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + and ( + (p.startAt is null and p.endAt is null) + or (p.startAt is null and p.endAt >= :start) + or (p.endAt is null and p.startAt <= :end) + or (p.startAt <= :end and p.endAt >= :start) + ) + group by p.id, p.missionNumber, p.status, p.title, p.startAt, p.endAt, + u.userId, u.nickname, u.profileImageUrl + order by p.missionNumber asc nulls last, p.startAt asc nulls last, p.id asc + """) + List findWeekMissionCardsInRange( + @Param("projectId") Long projectId, + @Param("start") LocalDate start, + @Param("end") LocalDate end + ); + + + + // WEEK_MISSION 중 가장 이른 startAt + @Query(""" + select min(p.startAt) + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + and p.startAt is not null + """) + LocalDate findMinWeekMissionStartAt(@Param("projectId") Long projectId); + + // 전체 프로세스 중 가장 이른 startAt (GENERAL + WEEK_MISSION 포함) + @Query(""" + select min(p.startAt) + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.startAt is not null + """) + LocalDate findMinProcessStartAt(@Param("projectId") Long projectId); + + @Query(""" + select p + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + and p.startAt <= :date + and p.endAt >= :date + """) + Optional findWeekMissionContainingDate( + @Param("projectId") Long projectId, + @Param("date") LocalDate date + ); + } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java index a7a4bb66..18854e9b 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java @@ -1,7 +1,10 @@ package com.nect.core.repository.team.process; import com.nect.core.entity.team.process.ProcessTaskItem; +import com.nect.core.entity.user.enums.RoleField; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -13,4 +16,32 @@ public interface ProcessTaskItemRepository extends JpaRepository findAllByProcessIdAndDeletedAtIsNullOrderBySortOrderAsc(Long processId); List findAllByProcessIdAndDeletedAtIsNullAndIdIn(Long processId, List ids); + + List findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameOrderBySortOrderAsc( + Long processId, RoleField roleField, String customRoleFieldName + ); + + List findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameAndIdIn( + Long processId, RoleField roleField, String customRoleFieldName, List ids + ); + + @Query(""" + select ti + from ProcessTaskItem ti + where ti.process.id = :processId + and ti.deletedAt is null + and ti.roleField = :roleField + and ( + (:customName is null and ti.customRoleFieldName is null) + or (:customName is not null and ti.customRoleFieldName = :customName) + ) + order by + ti.sortOrder asc nulls last, + ti.id asc + """) + List findWeekMissionGroupItemsOrdered( + @Param("processId") Long processId, + @Param("roleField") RoleField roleField, + @Param("customName") String customName + ); } From 179879e80a6c99deaf8cf718cf1a9448eb92bc70 Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 01:04:13 +0900 Subject: [PATCH 29/66] =?UTF-8?q?[Refactor]=20=EB=A9=A4=EB=B2=84=ED=98=95?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8B=B4=EB=8B=B9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4,=20=EC=9C=A0=EC=A0=80=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/req/ProcessBasicUpdateReqDto.java | 3 + .../process/dto/req/ProcessCreateReqDto.java | 3 + .../dto/req/ProcessOrderUpdateReqDto.java | 3 + .../dto/res/ProcessBasicUpdateResDto.java | 3 + .../process/dto/res/ProcessCardResDto.java | 3 + .../process/dto/res/ProcessCreateResDto.java | 22 +- .../facade/ProcessAttachmentFacade.java | 62 ++-- .../service/ProcessAttachmentService.java | 51 ++-- .../service/ProcessFeedbackService.java | 16 +- .../team/process/service/ProcessService.java | 269 +++++++++++++++++- .../team/workspace/service/PostService.java | 2 +- .../team/process/ProcessRepository.java | 96 +++++++ 12 files changed, 443 insertions(+), 90 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessBasicUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessBasicUpdateReqDto.java index 98bce74b..3a5f3b8c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessBasicUpdateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessBasicUpdateReqDto.java @@ -29,6 +29,9 @@ public record ProcessBasicUpdateReqDto( @JsonProperty("custom_fields") List customFields, + @JsonProperty("mission_number") + Integer missionNumber, + @JsonProperty("assignee_ids") List assigneeIds, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessCreateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessCreateReqDto.java index 8edbf7b8..29c4f66c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessCreateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessCreateReqDto.java @@ -32,6 +32,9 @@ public record ProcessCreateReqDto( @JsonProperty("custom_field_name") String customFieldName, + @JsonProperty("mission_number") + Integer missionNumber, + @NotNull @JsonProperty("start_date") LocalDate startDate, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java index fd460f44..66b089c8 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java @@ -16,6 +16,9 @@ public record ProcessOrderUpdateReqDto( @JsonProperty("lane_key") String laneKey, + @JsonProperty("mission_number") + Integer missionNumber, + @JsonProperty("start_date") LocalDate startDate, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessBasicUpdateResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessBasicUpdateResDto.java index 66e486bf..7643fadc 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessBasicUpdateResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessBasicUpdateResDto.java @@ -36,6 +36,9 @@ public record ProcessBasicUpdateResDto( @JsonProperty("assignee_ids") List assigneeIds, + @JsonProperty("assignees") + List assignees, + @JsonProperty("mention_user_ids") List mentionUserIds, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java index 4d554a96..7b1ccdc1 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java @@ -37,6 +37,9 @@ public record ProcessCardResDto( @JsonProperty("custom_fields") List customFields, + @JsonProperty("mission_number") + Integer missionNumber, + @JsonProperty("assignee") List assignee ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCreateResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCreateResDto.java index e9fdf3c4..f09aa986 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCreateResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCreateResDto.java @@ -4,6 +4,7 @@ import com.nect.core.entity.user.enums.RoleField; import java.time.LocalDateTime; +import java.util.List; public record ProcessCreateResDto( @JsonProperty("process_id") @@ -13,8 +14,27 @@ public record ProcessCreateResDto( LocalDateTime createdAt, @JsonProperty("writer") - WriterDto writer + WriterDto writer, + + + @JsonProperty("assignees") + List assignees ) { + public record AssigneeDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("name") + String name, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} + + public record WriterDto( @JsonProperty("user_id") Long userId, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java index 81de063d..6daecc76 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java @@ -14,9 +14,14 @@ import com.nect.core.entity.notifications.enums.NotificationScope; import com.nect.core.entity.notifications.enums.NotificationType; import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.team.process.Process; +import com.nect.core.entity.team.process.enums.ProcessType; import com.nect.core.entity.user.User; import com.nect.core.repository.team.ProjectRepository; import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.team.process.ProcessRepository; import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -32,10 +37,8 @@ public class ProcessAttachmentFacade { private final FileService fileService; private final ProcessAttachmentService processAttachmentService; - private final NotificationFacade notificationFacade; - private final ProjectRepository projectRepository; private final ProjectUserRepository projectUserRepository; - private final UserRepository userRepository; + private final ProcessRepository processRepository; /** * 프로세스 모달에서 "파일 업로드" 시: @@ -44,6 +47,23 @@ public class ProcessAttachmentFacade { */ @Transactional public ProcessFileUploadAndAttachResDto uploadAndAttachFile(Long projectId, Long userId, Long processId, MultipartFile file) { + Process process = processRepository.findByIdAndProjectIdAndDeletedAtIsNull(processId, projectId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.PROCESS_NOT_FOUND, "processId=" + processId)); + + // 프로세스 타입이 위크미션이면 업로드 전에 리더 체크 + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + boolean isLeader = projectUserRepository.existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus( + projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE + ); + if (!isLeader) throw new ProcessException(ProcessErrorCode.FORBIDDEN, "WEEK_MISSION은 리더만 업로드/첨부 가능"); + } else { + // 일반 프로세스면 ACTIVE 멤버 체크 + if (!projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus(projectId, userId, ProjectMemberStatus.ACTIVE)) { + throw new ProcessException(ProcessErrorCode.FORBIDDEN, "not active member"); + } + } + + // 파일 업로드 -> 첨부 FileUploadResDto uploaded = fileService.upload(projectId, userId, file); ProcessFileAttachResDto attached = processAttachmentService.attachFile( @@ -53,8 +73,6 @@ public ProcessFileUploadAndAttachResDto uploadAndAttachFile(Long projectId, Long new ProcessFileAttachReqDto(uploaded.fileId()) ); - notifyWorkspaceFileUploaded(projectId, userId, uploaded.fileId(), uploaded.fileName()); - return new ProcessFileUploadAndAttachResDto( attached.fileId(), uploaded.fileName(), @@ -64,39 +82,5 @@ public ProcessFileUploadAndAttachResDto uploadAndAttachFile(Long projectId, Long ); } - private void notifyWorkspaceFileUploaded(Long projectId, Long actorId, Long fileId, String fileName) { - - Project project = projectRepository.findById(projectId) - .orElseThrow(() -> new ProcessException( - ProcessErrorCode.PROJECT_NOT_FOUND, - "projectId = " + projectId - )); - - User actor = userRepository.findById(actorId) - .orElseThrow(() -> new ProcessException( - ProcessErrorCode.USER_NOT_FOUND, - "actorId = " + actorId - )); - - // 프로젝트 멤버 전체 조회 - List receivers = projectUserRepository.findAllUsersByProjectId(projectId).stream() - .filter(u -> u != null && u.getUserId() != null) - .filter(u -> !Objects.equals(u.getUserId(), actorId)) - .toList(); - - if (receivers.isEmpty()) return; - - NotificationCommand command = new NotificationCommand( - NotificationType.WORKSPACE_FILE_UPLOADED, - NotificationClassification.FILE_UPlOAD, - NotificationScope.WORKSPACE_GLOBAL, - fileId, - new Object[]{ actor.getName() }, - new Object[]{ fileName }, - project - ); - - notificationFacade.notify(receivers, command); - } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java index 3582bb0f..fb0d2802 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java @@ -8,11 +8,14 @@ import com.nect.api.domain.team.process.enums.AttachmentErrorCode; import com.nect.api.domain.team.process.exception.AttachmentException; import com.nect.core.entity.team.SharedDocument; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.history.enums.HistoryAction; import com.nect.core.entity.team.history.enums.HistoryTargetType; import com.nect.core.entity.team.process.Link; import com.nect.core.entity.team.process.Process; import com.nect.core.entity.team.process.ProcessSharedDocument; +import com.nect.core.entity.team.process.enums.ProcessType; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.SharedDocumentRepository; import com.nect.core.repository.team.process.LinkRepository; @@ -37,20 +40,9 @@ public class ProcessAttachmentService { private final LinkRepository linkRepository; - // TODO(TEAM EVENT FACADE): Attachment 변경 시(Notification) ActivityFacade로 통합 예정 - private final ProjectHistoryPublisher historyPublisher; // 헬퍼 메서드 - private void assertActiveProjectMember(Long projectId, Long userId) { - if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { - throw new AttachmentException( - AttachmentErrorCode.FORBIDDEN, - "not an active project member. projectId=" + projectId + ", userId=" + userId - ); - } - } - private Process getActiveProcess(Long projectId, Long processId) { return processRepository.findByIdAndProjectIdAndDeletedAtIsNull(processId, projectId) .orElseThrow(() -> new AttachmentException( @@ -83,13 +75,35 @@ private void validateLinkCreateReq(ProcessLinkCreateReqDto req) { } } + private void assertWeekMissionLeader(Long projectId, Long userId) { + boolean ok = projectUserRepository.existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus( + projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE + ); + if (!ok) { + throw new AttachmentException(AttachmentErrorCode.FORBIDDEN, + "WEEK_MISSION은 프로젝트 리더만 수정할 수 있습니다. projectId=" + projectId + ", userId=" + userId); + } + } + + private void assertAttachmentPermission(Long projectId, Long userId, Process process) { + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + assertWeekMissionLeader(projectId, userId); + return; + } + + if (!projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus(projectId, userId, ProjectMemberStatus.ACTIVE)) { + throw new AttachmentException(AttachmentErrorCode.FORBIDDEN, + "not an active project member. projectId=" + projectId + ", userId=" + userId); + } + } + // 프로세스 파일 첨부 서비스 @Transactional public ProcessFileAttachResDto attachFile(Long projectId, Long userId, Long processId, ProcessFileAttachReqDto req) { - assertActiveProjectMember(projectId, userId); validateFileAttachReq(req); Process process = getActiveProcess(projectId, processId); + assertAttachmentPermission(projectId, userId, process); SharedDocument doc = getActiveDocument(projectId, req.fileId()); @@ -108,8 +122,6 @@ public ProcessFileAttachResDto attachFile(Long projectId, Long userId, Long proc processSharedDocumentRepository.save(psd); - // TODO(Notification): 파일 첨부 알림 트리거(수신자=프로젝트 멤버/프로세스 관련자, AFTER_COMMIT 전환 권장) - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); @@ -130,9 +142,8 @@ public ProcessFileAttachResDto attachFile(Long projectId, Long userId, Long proc // 프로세스 파일 첨부해제 서비스 @Transactional public void detachFile(Long projectId, Long userId, Long processId, Long fileId) { - assertActiveProjectMember(projectId, userId); - Process process = getActiveProcess(projectId, processId); + assertAttachmentPermission(projectId, userId, process); ProcessSharedDocument psd = processSharedDocumentRepository .findByProcessIdAndDocumentIdAndDeletedAtIsNull(process.getId(), fileId) @@ -143,7 +154,6 @@ public void detachFile(Long projectId, Long userId, Long processId, Long fileId) psd.softDelete(); - // TODO(Notification): 파일 첨부해제 알림 트리거(AFER_COMMIT 권장) Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("fileId", fileId); @@ -162,10 +172,10 @@ public void detachFile(Long projectId, Long userId, Long processId, Long fileId) // 프로세스 링크 추가 서비스 @Transactional public ProcessLinkCreateResDto createLink(Long projectId, Long userId, Long processId, ProcessLinkCreateReqDto req) { - assertActiveProjectMember(projectId, userId); validateLinkCreateReq(req); Process process = getActiveProcess(projectId, processId); + assertAttachmentPermission(projectId, userId, process); Link link = Link.builder() .process(process) @@ -197,9 +207,8 @@ public ProcessLinkCreateResDto createLink(Long projectId, Long userId, Long proc // 프로세스 링크 삭제 서비스 @Transactional public void deleteLink(Long projectId, Long userId, Long processId, Long linkId) { - assertActiveProjectMember(projectId, userId); - Process process = getActiveProcess(projectId, processId); + assertAttachmentPermission(projectId, userId, process); Link link = linkRepository.findByIdAndProcessIdAndDeletedAtIsNull(linkId, process.getId()) .orElseThrow(() -> new AttachmentException( @@ -210,8 +219,6 @@ public void deleteLink(Long projectId, Long userId, Long processId, Long linkId) String beforeUrl = link.getUrl(); link.softDelete(); - // TODO(Notification): 링크 삭제 알림 트리거(AFER_COMMIT 권장) - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("linkId", linkId); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java index 71c6240b..1afd0b6c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java @@ -191,7 +191,7 @@ public ProcessFeedbackCreateResDto createFeedback(Long projectId, Long userId, L NotificationCommand command = new NotificationCommand( NotificationType.WORKSPACE_TASK_FEEDBACK, NotificationClassification.WORK_STATUS, - NotificationScope.WORKSPACE_GLOBAL, + NotificationScope.WORKSPACE_ONLY, processId, new Object[]{actor.getName()}, new Object[]{preview(saved.getContent(), 60)}, @@ -278,21 +278,9 @@ public ProcessFeedbackDeleteResDto deleteFeedback(Long projectId, Long userId, L ProcessFeedback feedback = getFeedback(processId, feedbackId); - // TODO(HISTORY/NOTI): 삭제 전 스냅샷 확보 권장 - // - beforeContent = feedback.getContent() - // - beforeCreatedBy = feedback.getCreatedByUserId() (필드 확정 후) - // - beforeCreatedAt = feedback.getCreatedAt() - String beforeContent = feedback.getContent(); feedback.softDelete(); - // TODO(Notification): - // - "피드백 삭제" 알림 트리거 지점 - // - 수신자: 프로젝트 멤버 전체 OR 해당 프로세스 관련자 - // - NotificationType 예: PROCESS_FEEDBACK_DELETED - // - meta: 삭제된 피드백의 content 요약/작성자 등(스냅샷 기반) - // - 권장: AFTER_COMMIT 이후 알림 전송 - Map meta = new LinkedHashMap<>(); meta.put("processId", processId); @@ -308,8 +296,6 @@ public ProcessFeedbackDeleteResDto deleteFeedback(Long projectId, Long userId, L meta ); - // TODO(TEAM EVENT FACADE): 추후 ActivityFacade로 통합 - return new ProcessFeedbackDeleteResDto(feedbackId); } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index e900947a..45152297 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -144,6 +144,153 @@ private void ensureLaneOrderRowsExist(Long projectId, ProcessStatus status, Stri } } + // lane 내 기간 겹침 검증 + private void validateNoOverlapInLane(Long projectId, ProcessCreateReqDto req, LocalDate start, LocalDate end) { + List roleFields = Optional.ofNullable(req.roleFields()).orElse(List.of()) + .stream().filter(Objects::nonNull).distinct().toList(); + + // ROLE (CUSTOM 제외) + for (RoleField rf : roleFields) { + if (rf == RoleField.CUSTOM) continue; + + boolean overlap = processRepository.existsOverlappingInRoleLane(projectId, rf, start, end); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "roleField=" + rf + ", start=" + start + ", end=" + end + ); + } + } + + // CUSTOM lane + if (roleFields.contains(RoleField.CUSTOM)) { + String custom = (req.customFieldName() == null) ? "" : req.customFieldName().trim(); + if (custom.isBlank()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "custom_field_name is required when role_fields contains CUSTOM"); + } + + boolean overlap = processRepository.existsOverlappingInCustomLane(projectId, custom, start, end); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "customName=" + custom + ", start=" + start + ", end=" + end + ); + } + } + } + + private void validateNoOverlapForUpdateBasic( + Long projectId, + Long processId, + List roleFields, + List customFields, + LocalDate start, + LocalDate end + ) { + // ROLE (CUSTOM 제외) + for (RoleField rf : Optional.ofNullable(roleFields).orElse(List.of())) { + if (rf == null || rf == RoleField.CUSTOM) continue; + + boolean overlap = processRepository.existsOverlappingInRoleLaneExcludingProcess( + projectId, rf, start, end, processId + ); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "roleField=" + rf + ", start=" + start + ", end=" + end + ); + } + } + + // CUSTOM lanes (이름 기반) + for (String name : Optional.ofNullable(customFields).orElse(List.of())) { + if (name == null) continue; + String trimmed = name.trim(); + if (trimmed.isBlank()) continue; + + boolean overlap = processRepository.existsOverlappingInCustomLaneExcludingProcess( + projectId, trimmed, start, end, processId + ); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "customName=" + trimmed + ", start=" + start + ", end=" + end + ); + } + } + } + + private void validateNoOverlapForUpdateOrderLane( + Long projectId, + Long processId, + String dbLaneKey, + LocalDate start, + LocalDate end + ) { + if (TEAM_LANE_KEY.equals(dbLaneKey)) return; + + if (dbLaneKey.startsWith("ROLE:")) { + RoleField rf = parseRoleField(dbLaneKey); + if (rf == RoleField.CUSTOM) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ROLE lane cannot be CUSTOM. laneKey=" + dbLaneKey); + } + + boolean overlap = processRepository.existsOverlappingInRoleLaneExcludingProcess( + projectId, rf, start, end, processId + ); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "roleField=" + rf + ", start=" + start + ", end=" + end + ); + } + return; + } + + if (dbLaneKey.startsWith("CUSTOM:")) { + String customName = parseCustomName(dbLaneKey); + + boolean overlap = processRepository.existsOverlappingInCustomLaneExcludingProcess( + projectId, customName, start, end, processId + ); + if (overlap) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "customName=" + customName + ", start=" + start + ", end=" + end + ); + } + return; + } + + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "invalid lane_key prefix. laneKey=" + dbLaneKey); + } + + + // 선택 미션 N과 startDate 포함 검증 + private void validateStartDateInSelectedMission(Long projectId, Integer missionNumber, LocalDate startDate) { + if (missionNumber == null) return; + + var mp = processRepository.findWeekMissionPeriodByMissionNumber(projectId, missionNumber) + .orElseThrow(() -> new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "week mission not found. missionNumber=" + missionNumber + )); + + LocalDate mStart = mp.getStartAt(); + LocalDate mEnd = mp.getEndAt(); + if (mStart == null || mEnd == null) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "missionNumber=" + missionNumber); + } + + boolean ok = !startDate.isBefore(mStart) && !startDate.isAfter(mEnd); + if (!ok) { + throw new ProcessException( + ProcessErrorCode.INVALID_PROCESS_PERIOD, + "startDate=" + startDate + ", mission=" + missionNumber + ); + } + } + // 알림 관련 헬퍼 메서드 private List validateAndLoadMentionReceivers(Long projectId, Long actorId, List mentionIds) { @@ -174,7 +321,7 @@ private void notifyWorkspaceMention(Project project, User actor, Long targetProc NotificationCommand command = new NotificationCommand( NotificationType.WORKSPACE_MENTIONED, NotificationClassification.WORK_STATUS, - NotificationScope.WORKSPACE_GLOBAL, + NotificationScope.WORKSPACE_ONLY, targetProcessId, new Object[]{ actor.getName() }, new Object[]{ content }, @@ -240,6 +387,10 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre process.updatePeriod(start, end); + validateStartDateInSelectedMission(projectId, req.missionNumber(), start); + + validateNoOverlapInLane(projectId, req, start, end); + int i = 0; // 업무 리스트 저장 for (var t : taskItems) { @@ -405,16 +556,32 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre "writer must be active project member. projectId=" + projectId + ", userId=" + userId )); + List assigneeDtos = + saved.getProcessUsers().stream() + .filter(pu -> pu.getDeletedAt() == null) + .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) + .map(pu -> { + User u = pu.getUser(); + return new ProcessCreateResDto.AssigneeDto( + u.getUserId(), + u.getName(), + u.getNickname(), + u.getProfileImageUrl() + ); + }) + .toList(); + return new ProcessCreateResDto( saved.getId(), saved.getCreatedAt(), - new ProcessCreateResDto.WriterDto( + new ProcessCreateResDto.WriterDto( // 작성자 writer.getUserId(), writer.getName(), writer.getNickname(), writerMember.getRoleField(), writerMember.getCustomRoleFieldName() - ) + ), + assigneeDtos // 담당자 ); } @@ -529,8 +696,7 @@ public ProcessDetailResDto getProcessDetail(Long projectId, Long userId, Long pr .map(pu -> { User u = pu.getUser(); - // TODO : 유저 프로필 넣기 - String userImage = null; + String userImage = u.getProfileImageUrl(); return new AssigneeResDto( u.getUserId(), @@ -763,6 +929,40 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, ); } + if (req.missionNumber() != null && mergedStart != null && mergedEnd != null) { + validateStartDateInSelectedMission(projectId, req.missionNumber(), mergedStart); + } + + if (mergedStart != null && mergedEnd != null && (newStart != null || newEnd != null)) { + + // 검증할 lane 후보 결정 + List laneRoleFields = + (req.roleFields() != null) + ? req.roleFields() + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .map(ProcessField::getRoleField) + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .distinct() + .toList(); + + List laneCustomFields = + (req.customFields() != null) + ? req.customFields() + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) + .map(ProcessField::getCustomFieldName) + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .toList(); + + validateNoOverlapForUpdateBasic(projectId, processId, laneRoleFields, laneCustomFields, mergedStart, mergedEnd); + } + if (newStart != null || newEnd != null) { process.updatePeriod(mergedStart, mergedEnd); } @@ -904,6 +1104,20 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, final LocalDate afterStart = process.getStartAt(); final LocalDate afterEnd = process.getEndAt(); + List assigneeDtos = process.getProcessUsers().stream() + .filter(pu -> pu.getDeletedAt() == null) + .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) + .map(pu -> { + User u = pu.getUser(); + return new AssigneeResDto( + u.getUserId(), + u.getName(), + u.getNickname(), + u.getProfileImageUrl() + ); + }) + .toList(); + final List afterMentionIds = (mentionIdsForRes == null) ? null // 요청이 null이면 멘션 변경 안함 : mentionIdsForRes.stream().filter(Objects::nonNull).distinct().sorted().toList(); @@ -1028,6 +1242,7 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, afterRoleFields, afterCustomFields, afterAssigneeIds, + assigneeDtos, (afterMentionIds == null) ? beforeMentionIds : afterMentionIds, process.getUpdatedAt(), new ProcessBasicUpdateResDto.WriterDto( @@ -1082,11 +1297,11 @@ public void deleteProcess(Long projectId, Long userId, Long processId) { ); } - - - private LocalDate normalizeWeekStart(LocalDate startDate) { - LocalDate base = (startDate == null) ? LocalDate.now() : startDate; - return base.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + private LocalDate normalizeWeekStart(LocalDate date) { + if (date == null) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "startDate must not be null"); + } + return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); } private ProcessCardResDto toProcessCardResDTO(Process p) { @@ -1120,12 +1335,13 @@ private ProcessCardResDto toProcessCardResDTO(Process p) { .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) .map(pu -> { User u = pu.getUser(); - String userImage = null; // TODO: 프로필 컬럼/연동되면 세팅 + String userImage = u.getProfileImageUrl(); String nickname = u.getNickname(); return new AssigneeResDto(u.getUserId(), u.getName(), nickname, userImage); }) .toList(); + Integer missionNumber = resolveMissionNumberByStartDate(p.getProject().getId(), p.getStartAt()); return new ProcessCardResDto( p.getId(), @@ -1138,6 +1354,7 @@ private ProcessCardResDto toProcessCardResDTO(Process p) { leftDay, roleFields, customFields, + missionNumber, assignees ); } @@ -1226,6 +1443,24 @@ private ProcessWeekResDto buildWeekDto(LocalDate weekStart, List 12) weeks = 12; + LocalDate fallback = processRepository.findMinProcessStartAt(projectId); + if (fallback == null) fallback = LocalDate.now(); - LocalDate rangeStart = normalizeWeekStart(startDate); + LocalDate rangeStart = resolveWeekStart(startDate, fallback); LocalDate rangeEnd = rangeStart.plusDays((long) weeks * 7 - 1); List processes = processRepository.findAllInRangeOrdered(projectId, rangeStart, rangeEnd); @@ -1557,6 +1794,14 @@ public ProcessOrderUpdateResDto updateProcessOrder(Long projectId, Long userId, ); } + if (req.missionNumber() != null && mergedStart != null) { + validateStartDateInSelectedMission(projectId, req.missionNumber(), mergedStart); + } + + if (mergedStart != null && mergedEnd != null) { + validateNoOverlapForUpdateOrderLane(projectId, processId, dbLaneKey, mergedStart, mergedEnd); + } + process.updatePeriod(mergedStart, mergedEnd); } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java index 44ede7f6..5cbcdbef 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java @@ -83,7 +83,7 @@ private void notifyBoardMention(Project project, User actor, Long targetBoardId, NotificationCommand command = new NotificationCommand( NotificationType.WORKSPACE_MENTIONED, NotificationClassification.BOARD, - NotificationScope.WORKSPACE_GLOBAL, + NotificationScope.WORKSPACE_ONLY, targetBoardId, new Object[]{ actor.getName() }, new Object[]{ content }, diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index 2d0e63ad..0419805e 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -485,5 +485,101 @@ Optional findWeekMissionContainingDate( @Param("date") LocalDate date ); + @Query(""" + select (count(p) > 0) + from Process p + join p.processFields pf + where p.project.id = :projectId + and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) + and pf.deletedAt is null + and pf.roleField = :roleField + and p.startAt <= :end + and p.endAt >= :start + """) + boolean existsOverlappingInRoleLane( + @Param("projectId") Long projectId, + @Param("roleField") RoleField roleField, + @Param("start") LocalDate start, + @Param("end") LocalDate end + ); + + @Query(""" + select (count(p) > 0) + from Process p + join p.processFields pf + where p.project.id = :projectId + and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) + and pf.deletedAt is null + and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM + and trim(pf.customFieldName) = :customName + and p.startAt <= :end + and p.endAt >= :start + """) + boolean existsOverlappingInCustomLane( + @Param("projectId") Long projectId, + @Param("customName") String customName, + @Param("start") LocalDate start, + @Param("end") LocalDate end + ); + + interface MissionPeriodRow { + LocalDate getStartAt(); + LocalDate getEndAt(); + } + + @Query(""" + select p.startAt as startAt, p.endAt as endAt + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.processType = com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION + and p.missionNumber = :missionNumber + """) + Optional findWeekMissionPeriodByMissionNumber( + @Param("projectId") Long projectId, + @Param("missionNumber") Integer missionNumber + ); + + @Query(""" + select case when count(p) > 0 then true else false end + from Process p + join p.processFields pf + where p.project.id = :projectId + and p.deletedAt is null + and p.id <> :excludeProcessId + and pf.deletedAt is null + and pf.roleField = :roleField + and not (p.endAt < :start or p.startAt > :end) + """) + boolean existsOverlappingInRoleLaneExcludingProcess( + @Param("projectId") Long projectId, + @Param("roleField") RoleField roleField, + @Param("start") LocalDate start, + @Param("end") LocalDate end, + @Param("excludeProcessId") Long excludeProcessId + ); + + @Query(""" + select case when count(p) > 0 then true else false end + from Process p + join p.processFields pf + where p.project.id = :projectId + and p.deletedAt is null + and p.id <> :excludeProcessId + and pf.deletedAt is null + and pf.roleField = com.nect.core.entity.user.enums.RoleField.CUSTOM + and pf.customFieldName = :customName + and not (p.endAt < :start or p.startAt > :end) + """) + boolean existsOverlappingInCustomLaneExcludingProcess( + @Param("projectId") Long projectId, + @Param("customName") String customName, + @Param("start") LocalDate start, + @Param("end") LocalDate end, + @Param("excludeProcessId") Long excludeProcessId + ); + } From a3b2d0a2afc9b78066051fb451b26923cb908f27 Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 01:08:34 +0900 Subject: [PATCH 30/66] =?UTF-8?q?[Test]=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=B0=8F=20=EC=9C=84?= =?UTF-8?q?=ED=81=AC=EB=AF=B8=EC=85=98=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ProcessControllerTest.java | 69 +++- .../ProcessTaskItemControllerTest.java | 9 +- .../controller/WeekMissionControllerTest.java | 379 ++++++++++++++++++ 3 files changed, 436 insertions(+), 21 deletions(-) create mode 100644 nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java index 49d0c576..a890e087 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java @@ -23,7 +23,6 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.FieldDescriptor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; @@ -128,6 +127,9 @@ void createProcess() throws Exception { "작성자닉네임", RoleField.BACKEND, null + ), + List.of( + new ProcessCreateResDto.AssigneeDto(2L, "담당자이름", "담당자닉", "https://img.com/2.png") ) ); @@ -136,22 +138,18 @@ void createProcess() throws Exception { "로그인/회원가입 API 초안 + 문서화", ProcessStatus.IN_PROGRESS, - // assignee_ids List.of(2L), - List.of(RoleField.BACKEND, RoleField.FRONTEND), null, + 1, LocalDate.of(2026, 1, 19), LocalDate.of(2026, 1, 25), List.of(), - - // file_ids List.of(), - // links (변경됨) List.of( new ProcessCreateReqDto.ProcessLinkItemReqDto("백엔드 Repo", "https://github.com/nect/nect-backend"), new ProcessCreateReqDto.ProcessLinkItemReqDto("피그마", "https://figma.com/file/xxxxx") @@ -195,16 +193,15 @@ void createProcess() throws Exception { fieldWithPath("assignee_ids").type(ARRAY).description("담당자 ID 목록"), fieldWithPath("role_fields").type(ARRAY).description("분야 목록 (예: BACKEND, FRONTEND 등)"), fieldWithPath("custom_field_name").optional().type(STRING).description("커스텀 분야명(null 가능)"), + fieldWithPath("mission_number").optional().type(NUMBER).description("미션 번호(위크미션이면 1..n, 기본형이면 null 가능)"), - // start/deadline은 네 DTO에서 @NotNull이라 optional 빼는게 맞음 fieldWithPath("start_date").type(STRING).description("시작일(yyyy-MM-dd)"), fieldWithPath("dead_line").type(STRING).description("마감일(yyyy-MM-dd)"), fieldWithPath("mention_user_ids").type(ARRAY).description("멘션된 유저 ID 목록"), fieldWithPath("file_ids").type(ARRAY).description("첨부 파일 ID 목록"), - // links 변경 - fieldWithPath("links").type(ARRAY).description("첨부 링크 목록").optional(), + fieldWithPath("links").optional().type(ARRAY).description("첨부 링크 목록"), fieldWithPath("links[].title").type(STRING).description("링크 제목"), fieldWithPath("links[].url").type(STRING).description("링크 URL"), @@ -221,7 +218,6 @@ void createProcess() throws Exception { fieldWithPath("body").description("응답 바디"), fieldWithPath("body.process_id").type(NUMBER).description("생성된 프로세스 ID"), - fieldWithPath("body.created_at").type(STRING).description("생성일시(ISO-8601)"), fieldWithPath("body.writer").type(OBJECT).description("작성자 정보"), @@ -229,9 +225,14 @@ void createProcess() throws Exception { fieldWithPath("body.writer.name").type(STRING).description("작성자 이름"), fieldWithPath("body.writer.nickname").type(STRING).description("작성자 닉네임"), fieldWithPath("body.writer.role_field").type(STRING).description("작성자 역할 분야(RoleField)"), - fieldWithPath("body.writer.custom_field_name").optional().type(STRING).description("작성자 커스텀 분야명(null 가능)") - ) + fieldWithPath("body.writer.custom_field_name").optional().type(STRING).description("작성자 커스텀 분야명(null 가능)"), + fieldWithPath("body.assignees").type(ARRAY).description("담당자 정보 목록"), + fieldWithPath("body.assignees[].user_id").type(NUMBER).description("담당자 유저 ID"), + fieldWithPath("body.assignees[].name").type(STRING).description("담당자 이름"), + fieldWithPath("body.assignees[].nickname").type(STRING).description("담당자 닉네임"), + fieldWithPath("body.assignees[].profile_image_url").type(STRING).description("담당자 프로필 이미지 URL") + ) .build() ) )); @@ -428,10 +429,17 @@ void updateProcessBasic() throws Exception { List.of(RoleField.FRONTEND, RoleField.BACKEND, RoleField.CUSTOM), List.of("AI"), + 1, + List.of(1L, 2L), List.of(3L, 4L) ); + List assignees = List.of( + new AssigneeResDto(1L, "유저1", "유저1닉", "https://img.com/1.png"), + new AssigneeResDto(2L, "유저2", "유저2닉", "https://img.com/2.png") + ); + ProcessBasicUpdateResDto response = new ProcessBasicUpdateResDto( processId, "수정된 제목", @@ -444,6 +452,8 @@ void updateProcessBasic() throws Exception { List.of("AI"), List.of(1L, 2L), + assignees, + List.of(3L, 4L), LocalDateTime.of(2026, 1, 24, 0, 0, 0), @@ -495,7 +505,8 @@ void updateProcessBasic() throws Exception { fieldWithPath("mention_user_ids").optional().type(ARRAY).description("멘션 유저 ID 목록 (미포함 시 변경 없음, []면 비우기)"), fieldWithPath("role_fields").optional().type(ARRAY).description("역할 분야 목록(RoleField)"), - fieldWithPath("custom_fields").optional().type(ARRAY).description("커스텀 분야명 목록(CUSTOM 선택 시)") + fieldWithPath("custom_fields").optional().type(ARRAY).description("커스텀 분야명 목록(CUSTOM 선택 시)"), + fieldWithPath("mission_number").optional().type(NUMBER).description("미션 번호(미포함 시 변경 없음, null 가능)") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), @@ -515,6 +526,12 @@ void updateProcessBasic() throws Exception { fieldWithPath("body.custom_fields").type(ARRAY).description("커스텀 분야명 목록(CUSTOM 선택 시)"), fieldWithPath("body.assignee_ids").type(ARRAY).description("담당자 ID 목록"), + fieldWithPath("body.assignees").type(ARRAY).description("담당자 정보 목록"), + fieldWithPath("body.assignees[].user_id").type(NUMBER).description("담당자 유저 ID"), + fieldWithPath("body.assignees[].user_name").type(STRING).description("담당자 이름"), + fieldWithPath("body.assignees[].nickname").type(STRING).description("담당자 닉네임"), + fieldWithPath("body.assignees[].user_image").type(STRING).description("담당자 이미지 URL"), + fieldWithPath("body.mention_user_ids").type(ARRAY).description("멘션 유저 ID 목록"), fieldWithPath("body.updated_at").type(STRING).description("수정일시(ISO-8601)"), @@ -657,14 +674,15 @@ void getPartProcesses() throws Exception { 10L, ProcessStatus.IN_PROGRESS, "백엔드 API 초안 작성", - 1, // complete_check_list - 3, // whole_check_list + 1, + 3, LocalDate.of(2026, 2, 1), LocalDate.of(2026, 2, 10), - 5, // left_day + 5, List.of(RoleField.BACKEND), - List.of("AI"), // custom_fields - List.of(a1, a2) + List.of("AI"), + 1, + List.of(a1, a2) // assignee ); ProcessCardResDto p12 = new ProcessCardResDto( @@ -678,9 +696,12 @@ void getPartProcesses() throws Exception { 3, List.of(RoleField.BACKEND, RoleField.FRONTEND), List.of("DevOps"), + null, List.of(a2) ); + + ProcessStatusGroupResDto inProgressGroup = new ProcessStatusGroupResDto( ProcessStatus.IN_PROGRESS, 2, @@ -699,6 +720,7 @@ void getPartProcesses() throws Exception { null, List.of(RoleField.BACKEND), List.of(), + null, List.of(a1) ); @@ -720,6 +742,7 @@ void getPartProcesses() throws Exception { 0, List.of(RoleField.BACKEND), List.of("Auth"), + 1, List.of(a1, a2) ); @@ -741,6 +764,7 @@ void getPartProcesses() throws Exception { null, List.of(RoleField.BACKEND), List.of("TechDebt"), + 1, List.of(a2) ); @@ -808,6 +832,10 @@ void getPartProcesses() throws Exception { fieldWithPath("body.groups[].processes[].role_fields").type(ARRAY).description("RoleField 목록"), fieldWithPath("body.groups[].processes[].custom_fields").type(ARRAY).description("커스텀 필드명 목록"), + fieldWithPath("body.groups[].processes[].mission_number").optional().type(VARIES).description("위크미션 번호(미션 프로세스면 1..n, 일반 프로세스면 null)"), + + fieldWithPath("body.groups[].processes[].assignee").type(ARRAY).description("담당자 목록"), + fieldWithPath("body.groups[].processes[].assignee").type(ARRAY).description("담당자 목록"), fieldWithPath("body.groups[].processes[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), fieldWithPath("body.groups[].processes[].assignee[].user_name").type(STRING).description("담당자 이름"), @@ -833,6 +861,7 @@ void updateProcessOrder() throws Exception { ProcessStatus.IN_PROGRESS, List.of(10L, 2L, 12L), "ROLE:BACKEND", + 1, LocalDate.of(2026, 2, 1), LocalDate.of(2026, 2, 10) ); @@ -876,7 +905,8 @@ void updateProcessOrder() throws Exception { fieldWithPath("ordered_process_ids").optional().type(ARRAY).description("정렬 순서대로 나열한 프로세스 ID 목록"), fieldWithPath("lane_key").type(STRING).description("레인 키(TEAM, ROLE:XXX, CUSTOM:이름)"), fieldWithPath("start_date").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), - fieldWithPath("dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)") + fieldWithPath("dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)"), + fieldWithPath("mission_number").optional().type(NUMBER).description("미션 번호(위크미션이면 사용, 기본형이면 null 가능)") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), @@ -888,6 +918,7 @@ void updateProcessOrder() throws Exception { fieldWithPath("body.process_id").type(NUMBER).description("프로세스 ID"), fieldWithPath("body.status").type(STRING).description("프로세스 상태"), fieldWithPath("body.status_order").type(NUMBER).description("해당 status 내 정렬 순서"), + fieldWithPath("body.groups[].processes[].mission_number").optional().type(NUMBER).description("미션 번호(null 가능)"), fieldWithPath("body.start_at").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), fieldWithPath("body.dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)") ) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java index cdd9407f..690084b7 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java @@ -12,6 +12,7 @@ import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.user.enums.RoleField; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -301,7 +302,9 @@ void reorderTaskItems() throws Exception { long userId = 1L; ProcessTaskItemReorderReqDto request = new ProcessTaskItemReorderReqDto( - List.of(100L, 101L, 102L) + List.of(100L, 101L, 102L), + RoleField.BACKEND, + null ); ProcessTaskItemResDto i0 = new ProcessTaskItemResDto(100L, "A", false, 0, null); @@ -338,7 +341,9 @@ void reorderTaskItems() throws Exception { headerWithName(AUTH_HEADER).description("Bearer Access Token") ) .requestFields( - fieldWithPath("ordered_task_item_ids").type(ARRAY).description("정렬된 업무 항목 ID 목록(전체 포함)") + fieldWithPath("ordered_task_item_ids").type(ARRAY).description("정렬된 업무 항목 ID 목록(전체 포함)"), + fieldWithPath("role_field").optional().type(STRING).description("레인 역할(RoleField). ROLE 레인일 때 사용"), + fieldWithPath("custom_role_field_name").optional().type(STRING).description("커스텀 레인 이름. CUSTOM 레인일 때 사용") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java new file mode 100644 index 00000000..465c3133 --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java @@ -0,0 +1,379 @@ +package com.nect.api.domain.team.process.controller; + +import com.epages.restdocs.apispec.ResourceDocumentation; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; +import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; +import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; +import com.nect.api.domain.team.process.service.WeekMissionService; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.team.process.enums.ProcessStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Constructor; +import java.lang.reflect.RecordComponent; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class WeekMissionControllerTest { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private WeekMissionService weekMissionService; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_MEMBER")) + .build() + ); + } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_MEMBER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return SecurityMockMvcRequestPostProcessors.authentication(auth); + } + + private T newRecord(Class recordType) { + try { + if (!recordType.isRecord()) return null; + + RecordComponent[] components = recordType.getRecordComponents(); + Class[] paramTypes = new Class[components.length]; + Object[] args = new Object[components.length]; + + for (int i = 0; i < components.length; i++) { + Class t = components[i].getType(); + paramTypes[i] = t; + args[i] = defaultValue(t); + } + + Constructor ctor = recordType.getDeclaredConstructor(paramTypes); + ctor.setAccessible(true); + return ctor.newInstance(args); + } catch (Exception e) { + throw new IllegalStateException("Failed to instantiate record: " + recordType.getName(), e); + } + } + + private Object defaultValue(Class t) { + if (t == String.class) return "sample"; + if (t == Long.class || t == long.class) return 1L; + if (t == Integer.class || t == int.class) return 1; + if (t == Boolean.class || t == boolean.class) return false; + if (t == LocalDate.class) return LocalDate.of(2026, 1, 19); + if (t == LocalDateTime.class) return LocalDateTime.of(2026, 1, 19, 0, 0, 0); + + if (List.class.isAssignableFrom(t)) return List.of(); + + if (t.isEnum()) { + Object[] constants = t.getEnumConstants(); + return (constants != null && constants.length > 0) ? constants[0] : null; + } + + if (t.isRecord()) { + @SuppressWarnings("unchecked") + Class rt = (Class) t; + return newRecord(rt); + } + + return null; + } + + @Test + @DisplayName("주차별 위크미션 조회") + void getWeekMissions() throws Exception { + long projectId = 1L; + long userId = 1L; + + LocalDate startDate = LocalDate.of(2026, 1, 19); + int weeks = 2; + + WeekMissionWeekResDto response = newRecord(WeekMissionWeekResDto.class); + + given(weekMissionService.getWeekMissions(eq(projectId), eq(userId), eq(startDate), eq(weeks))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/week-missions/week", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .param("start_date", startDate.toString()) + .param("weeks", String.valueOf(weeks)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("week-mission-week", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("주차별 위크미션 조회") + .description("start_date 기준으로 weeks 만큼 위크미션 주차 목록을 조회합니다. start_date 미입력 시 서버 정책에 따른 기본 시작일로 동작합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID") + ) + .queryParameters( + parameterWithName("start_date").optional().description("시작일(yyyy-MM-dd)"), + parameterWithName("weeks").optional().description("조회할 주차 수(기본 1)") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("주차별 위크미션 조회 결과") + ) + .build() + ) + )); + + verify(weekMissionService).getWeekMissions(eq(projectId), eq(userId), eq(startDate), eq(weeks)); + } + + @Test + @DisplayName("위크미션 상세 조회(체크리스트 포함)") + void getWeekMissionDetail() throws Exception { + long projectId = 1L; + long processId = 10L; + long userId = 1L; + + WeekMissionDetailResDto response = newRecord(WeekMissionDetailResDto.class); + + given(weekMissionService.getDetail(eq(projectId), eq(userId), eq(processId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/week-missions/{processId}", projectId, processId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("week-mission-detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("위크미션 상세 조회") + .description("위크미션(프로세스) 상세를 조회합니다. (체크리스트 포함)") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("processId").description("위크미션 프로세스 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("위크미션 상세 결과") + ) + .build() + ) + )); + + verify(weekMissionService).getDetail(eq(projectId), eq(userId), eq(processId)); + } + + @Test + @DisplayName("위크미션 상태 변경") + void updateWeekMissionStatus() throws Exception { + long projectId = 1L; + long processId = 10L; + long userId = 1L; + + WeekMissionStatusUpdateReqDto request = + new WeekMissionStatusUpdateReqDto(ProcessStatus.PLANNING); + + willDoNothing().given(weekMissionService) + .updateWeekMissionStatus(eq(projectId), eq(userId), eq(processId), any(WeekMissionStatusUpdateReqDto.class)); + + mockMvc.perform(patch("/api/v1/projects/{projectId}/week-missions/{processId}/status", projectId, processId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("week-mission-status-update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("위크미션 상태 변경") + .description("위크미션 프로세스의 상태를 변경합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("processId").description("위크미션 프로세스 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("status").type(STRING) + .description("변경할 상태(PLANNING/IN_PROGRESS/DONE/BACKLOG)") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명") + ) + .build() + ) + )); + + verify(weekMissionService).updateWeekMissionStatus(eq(projectId), eq(userId), eq(processId), any(WeekMissionStatusUpdateReqDto.class)); + } + + @Test + @DisplayName("위크미션 TASK 내 항목 내용 수정") + void updateWeekMissionTaskItem() throws Exception { + long projectId = 1L; + long processId = 10L; + long taskItemId = 100L; + long userId = 1L; + + WeekMissionTaskItemUpdateReqDto request = newRecord(WeekMissionTaskItemUpdateReqDto.class); + + ProcessTaskItemResDto response = new ProcessTaskItemResDto( + taskItemId, + "수정된 세부 작업", + true, + 1, + LocalDate.of(2026, 1, 25) + ); + + given(weekMissionService.updateWeekMissionTaskItem(eq(projectId), eq(userId), eq(processId), eq(taskItemId), any(WeekMissionTaskItemUpdateReqDto.class))) + .willReturn(response); + + mockMvc.perform(patch("/api/v1/projects/{projectId}/week-missions/{processId}/task-items/{taskItemId}", projectId, processId, taskItemId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("week-mission-taskitem-update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("위크미션 TASK 항목 수정") + .description("위크미션 프로세스 내 TaskItem의 내용을 수정합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("processId").description("위크미션 프로세스 ID"), + ResourceDocumentation.parameterWithName("taskItemId").description("업무 항목(TaskItem) ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("content").type(STRING).description("업무 항목 내용"), + fieldWithPath("is_done").type(BOOLEAN).description("완료 여부"), + fieldWithPath("role_field").optional().type(STRING).description("역할 분야(RoleField)"), + fieldWithPath("custom_role_field_name").optional().type(STRING).description("커스텀 역할 분야명(CUSTOM일 때)") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.task_item_id").type(NUMBER).description("업무 항목 ID"), + fieldWithPath("body.content").type(STRING).description("업무 항목 내용"), + fieldWithPath("body.is_done").type(BOOLEAN).description("완료 여부"), + fieldWithPath("body.sort_order").type(NUMBER).description("정렬 순서"), + fieldWithPath("body.done_at").optional().type(STRING).description("완료일(yyyy-MM-dd, null 가능)") + ) + .build() + ) + )); + + verify(weekMissionService).updateWeekMissionTaskItem(eq(projectId), eq(userId), eq(processId), eq(taskItemId), any(WeekMissionTaskItemUpdateReqDto.class)); + } +} From 2efad4b24e59c790b94aa6e82b310ed9a454951f Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 03:02:40 +0900 Subject: [PATCH 31/66] =?UTF-8?q?[Refactor]=20=EC=97=AD=ED=95=A0=20?= =?UTF-8?q?=EC=98=81=EC=96=B4=20label=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/entity/user/enums/RoleField.java | 87 ++++++++++--------- .../ProjectTeamRoleRepository.java | 0 2 files changed, 47 insertions(+), 40 deletions(-) rename nect-core/src/main/java/com/nect/core/repository/{analysis => team}/ProjectTeamRoleRepository.java (100%) diff --git a/nect-core/src/main/java/com/nect/core/entity/user/enums/RoleField.java b/nect-core/src/main/java/com/nect/core/entity/user/enums/RoleField.java index 9bad7361..65c9deb0 100644 --- a/nect-core/src/main/java/com/nect/core/entity/user/enums/RoleField.java +++ b/nect-core/src/main/java/com/nect/core/entity/user/enums/RoleField.java @@ -2,59 +2,62 @@ public enum RoleField { // 디자이너 - UI_UX("UI/UX", Role.DESIGNER), - ILLUSTRATION_GRAPHIC("일러스트/그래픽", Role.DESIGNER), - WEBTOON_EMOTICON("웹툰/이모티콘", Role.DESIGNER), - PHOTO_VIDEO("사진/영상", Role.DESIGNER), - SOUND("사운드", Role.DESIGNER), - THREE_D_MOTION("3D/모션", Role.DESIGNER), - PRODUCT("제품", Role.DESIGNER), - SPACE("공간", Role.DESIGNER), - PUBLISHING("출판", Role.DESIGNER), + UI_UX("UI/UX", "UI/UX", Role.DESIGNER), + ILLUSTRATION_GRAPHIC("일러스트/그래픽", "Illustration/Graphic", Role.DESIGNER), + WEBTOON_EMOTICON("웹툰/이모티콘", "Webtoon/Emoticon", Role.DESIGNER), + PHOTO_VIDEO("사진/영상", "Photo/Video", Role.DESIGNER), + SOUND("사운드", "Sound", Role.DESIGNER), + THREE_D_MOTION("3D/모션", "3D/Motion", Role.DESIGNER), + PRODUCT("제품", "Product", Role.DESIGNER), + SPACE("공간", "Space", Role.DESIGNER), + PUBLISHING("출판", "Publishing", Role.DESIGNER), // 개발자 - FRONTEND("프론트엔드", Role.DEVELOPER), - BACKEND("백엔드", Role.DEVELOPER), - IOS_ANDROID("IOS/안드로이드", Role.DEVELOPER), - DATA_ENGINEER("데이터 엔지니어", Role.DEVELOPER), - AI_MACHINE_LEARNING("AI/머신러닝", Role.DEVELOPER), - FULLSTACK("풀스택", Role.DEVELOPER), - GAME("게임", Role.DEVELOPER), - HARDWARE("하드웨어", Role.DEVELOPER), - SECURITY_NETWORK("보안/네트워크", Role.DEVELOPER), + FRONTEND("프론트엔드", "Frontend", Role.DEVELOPER), + BACKEND("백엔드", "Backend", Role.DEVELOPER), + IOS_ANDROID("IOS/안드로이드", "iOS/Android", Role.DEVELOPER), + DATA_ENGINEER("데이터 엔지니어", "Data Engineer", Role.DEVELOPER), + AI_MACHINE_LEARNING("AI/머신러닝", "AI/Machine Learning", Role.DEVELOPER), + FULLSTACK("풀스택", "Full-stack", Role.DEVELOPER), + GAME("게임", "Game", Role.DEVELOPER), + HARDWARE("하드웨어", "Hardware", Role.DEVELOPER), + SECURITY_NETWORK("보안/네트워크", "Security/Network", Role.DEVELOPER), // 기획자 - SERVICE("서비스", Role.PLANNER), - UX("UX", Role.PLANNER), - APP_WEB("앱/웹", Role.PLANNER), - BUSINESS("비즈니스", Role.PLANNER), - PERFORMANCE_EVENT("공연/행사", Role.PLANNER), + SERVICE("서비스", "Service", Role.PLANNER), + UX("UX", "UX", Role.PLANNER), + APP_WEB("앱/웹", "App/Web", Role.PLANNER), + BUSINESS("비즈니스", "Business", Role.PLANNER), + PERFORMANCE_EVENT("공연/행사", "Performance/Event", Role.PLANNER), + // 마케터 - CONTENT_CREATION("콘텐츠 제작", Role.MARKETER), - PERFORMANCE("퍼포먼스", Role.MARKETER), - CRM("CRM", Role.MARKETER), - BRAND_MARKETING("브랜드 마케팅", Role.MARKETER), - AD_VIRAL("광고/바이럴", Role.MARKETER), - LIVE_COMMERCE("라이브커머스", Role.MARKETER), - DATA_ANALYSIS("데이터 분석", Role.MARKETER), - MARKETING_OTHER("기타", Role.MARKETER), - OPERATIONS_CS("운영/CS", Role.MARKETER), - SALES_PARTNERSHIP("영업/제휴", Role.MARKETER), - VIDEO_MUSIC_DIRECTING("영상/음악 감독", Role.MARKETER), - TRANSLATION_INTERPRETATION("번역/통역", Role.MARKETER), - MANUSCRIPT_CONSULTING("원고 컨설턴트", Role.MARKETER), - ACCOUNTING_LAW_HR("세무/법무/노무", Role.MARKETER), - STARTUP_CONSULTING("창업 컨설팅", Role.MARKETER), + CONTENT_CREATION("콘텐츠 제작", "Content Creation", Role.MARKETER), + PERFORMANCE("퍼포먼스", "Performance", Role.MARKETER), + CRM("CRM", "CRM", Role.MARKETER), + BRAND_MARKETING("브랜드 마케팅", "Brand Marketing", Role.MARKETER), + AD_VIRAL("광고/바이럴", "Ads/Viral", Role.MARKETER), + LIVE_COMMERCE("라이브커머스", "Live Commerce", Role.MARKETER), + DATA_ANALYSIS("데이터 분석", "Data Analysis", Role.MARKETER), + MARKETING_OTHER("기타", "Other", Role.MARKETER), + OPERATIONS_CS("운영/CS", "Operations/CS", Role.MARKETER), + SALES_PARTNERSHIP("영업/제휴", "Sales/Partnership", Role.MARKETER), + VIDEO_MUSIC_DIRECTING("영상/음악 감독", "Video/Music Directing", Role.MARKETER), + TRANSLATION_INTERPRETATION("번역/통역", "Translation/Interpretation", Role.MARKETER), + MANUSCRIPT_CONSULTING("원고 컨설턴트", "Manuscript Consulting", Role.MARKETER), + ACCOUNTING_LAW_HR("세무/법무/노무", "Accounting/Law/HR", Role.MARKETER), + STARTUP_CONSULTING("창업 컨설팅", "Startup Consulting", Role.MARKETER), // 직접입력 (모든 Role에서 가능) - CUSTOM("직접입력", null); + CUSTOM("직접입력", "Custom",null); private final String description; + private final String labelEn; private final Role role; - RoleField(String description, Role role) { + RoleField(String description, String labelEn, Role role) { this.description = description; + this.labelEn = labelEn; this.role = role; } @@ -62,6 +65,10 @@ public String getDescription() { return description; } + public String getLabelEn() { + return labelEn; + } + public Role getRole() { return role; } diff --git a/nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java similarity index 100% rename from nect-core/src/main/java/com/nect/core/repository/analysis/ProjectTeamRoleRepository.java rename to nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java From 87e8f597ca97087d94a000aae20bbffce4a085f0 Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 03:04:28 +0900 Subject: [PATCH 32/66] =?UTF-8?q?[Feat]=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=ED=8A=B8=20=EC=A1=B0=ED=9A=8C,=20?= =?UTF-8?q?=ED=8C=80=EC=9B=90=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C,?= =?UTF-8?q?=20=EB=AF=B8=EC=85=98=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java --- .../controller/WeekMissionController.java | 12 + .../dto/res/WeekMissionDropdownResDto.java | 29 ++ .../team/process/service/ProcessService.java | 263 ++++++++++++------ .../process/service/WeekMissionService.java | 33 +++ .../controller/ProjectPartsController.java | 42 +++ .../team/project/dto/ProjectPartsResDto.java | 32 +++ .../team/project/dto/ProjectUsersResDto.java | 42 +++ .../project/enums/code/ProjectErrorCode.java | 7 + .../project/exception/ProjectException.java | 4 + .../team/project/service/ProjectService.java | 1 + .../service/ProjectTeamQueryService.java | 116 ++++++++ .../core/entity/team/ProjectTeamRole.java | 29 +- .../team/ProjectTeamRoleRepository.java | 34 +++ .../team/process/ProcessRepository.java | 22 ++ 14 files changed, 580 insertions(+), 86 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDropdownResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartsResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectUsersResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java index 825b4ac2..a78088ee 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java @@ -4,6 +4,7 @@ import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDropdownResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; import com.nect.api.domain.team.process.service.WeekMissionService; import com.nect.api.global.response.ApiResponse; @@ -73,4 +74,15 @@ public ApiResponse updateWeekMissionTaskItem( ); } + // 멤버형 모달 미션 주차 선택 드롭다운 조회 + @GetMapping("/missions") + public ApiResponse readMissionDropdown( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok( + weekMissionService.getMissionDropdown(projectId, userDetails.getUserId()) + ); + } + } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDropdownResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDropdownResDto.java new file mode 100644 index 00000000..abc51548 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDropdownResDto.java @@ -0,0 +1,29 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.LocalDate; +import java.util.List; + +public record WeekMissionDropdownResDto( + @JsonProperty("missions") + List missions +) { + public WeekMissionDropdownResDto { + missions = (missions == null) ? List.of() : missions; + } + + public record MissionDto( + @JsonProperty("mission_number") + Integer missionNumber, + + @JsonProperty("start_date") + LocalDate startDate, + + @JsonProperty("end_date") + LocalDate endDate, + + @JsonProperty("is_current") + Boolean isCurrent + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index 45152297..b1be82b9 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -24,6 +24,7 @@ import com.nect.core.entity.user.User; import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.SharedDocumentRepository; import com.nect.core.repository.team.process.ProcessLaneOrderRepository; @@ -53,6 +54,7 @@ public class ProcessService { private final ProcessMentionRepository processMentionRepository; private final UserRepository userRepository; private final ProcessLaneOrderRepository processLaneOrderRepository; + private final ProjectTeamRoleRepository projectTeamRoleRepository; private final ProcessLaneOrderService processLaneOrderService; @@ -291,6 +293,41 @@ private void validateStartDateInSelectedMission(Long projectId, Integer missionN } } + private void validateProjectTeamRolesOrThrow(Long projectId, List roleFields, String customFieldName) { + + // roleFields(일반 역할) 검증 + for (RoleField rf : Optional.ofNullable(roleFields).orElse(List.of())) { + if (rf == null) continue; + + if (rf == RoleField.CUSTOM) continue; // CUSTOM은 customFieldName으로 검증 + + boolean exists = projectTeamRoleRepository.existsByProject_IdAndRoleField(projectId, rf); + if (!exists) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "role_field not registered in project. roleField=" + rf + ); + } + } + + // CUSTOM 검증 (ProcessCreateReqDto는 customFieldName 단일) + if (roleFields != null && roleFields.contains(RoleField.CUSTOM)) { + String name = (customFieldName == null) ? "" : customFieldName.trim(); + if (name.isBlank()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "custom_field_name is required"); + } + + boolean exists = projectTeamRoleRepository + .existsByProject_IdAndRoleFieldAndCustomRoleFieldName(projectId, RoleField.CUSTOM, name); + + if (!exists) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "custom role not registered in project. customRoleFieldName=" + name + ); + } + } + } // 알림 관련 헬퍼 메서드 private List validateAndLoadMentionReceivers(Long projectId, Long actorId, List mentionIds) { @@ -338,7 +375,7 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre assertActiveProjectMember(projectId, userId); validateProcessTitle(req.processTitle()); - List taskItems = req.taskItems(); + List taskItems = Optional.ofNullable(req.taskItems()).orElse(List.of()); validateTaskItems(taskItems); // 프로젝트 확인 @@ -387,8 +424,20 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre process.updatePeriod(start, end); + // 필드(역할) CUSTOM쪽 검증 + List roleFields = Optional.ofNullable(req.roleFields()).orElse(List.of()) + .stream().filter(Objects::nonNull).distinct().toList(); + + // 정규화 이후 검증/저장/히스토리 모두 이 값 사용 + String customName = (req.customFieldName() == null) ? null : req.customFieldName().trim(); + + // 프로젝트에 등록된 파트인지 검증 (CUSTOM 포함) + validateProjectTeamRolesOrThrow(projectId, roleFields, req.customFieldName()); + + // 미션 N 검증: "시작일만" 미션 기간에 포함되면 요구사항 일치 validateStartDateInSelectedMission(projectId, req.missionNumber(), start); + // lane 기간 겹침 검증 validateNoOverlapInLane(projectId, req, start, end); int i = 0; @@ -444,26 +493,9 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre } - // 필드(역할) CUSTOM쪽 검증 - List roleFields = Optional.ofNullable(req.roleFields()).orElse(List.of()) - .stream().filter(Objects::nonNull).distinct().toList(); - - - if (roleFields.contains(RoleField.CUSTOM)) { - if (req.customFieldName() == null || req.customFieldName().isBlank()) { - throw new ProcessException( - ProcessErrorCode.INVALID_REQUEST, - "custom_field_name is required when role_fields contains CUSTOM" - ); - } - } - for (RoleField rf : roleFields) { - if (rf == RoleField.CUSTOM) { - process.addField(RoleField.CUSTOM, req.customFieldName()); - } else { - process.addField(rf, null); - } + if (rf == RoleField.CUSTOM) process.addField(RoleField.CUSTOM, customName); + else process.addField(rf, null); } @@ -527,7 +559,7 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre meta.put("startAt", saved.getStartAt()); meta.put("endAt", saved.getEndAt()); meta.put("roleFields", roleFields); - meta.put("customFieldName", roleFields.contains(RoleField.CUSTOM) ? req.customFieldName() : null); + meta.put("customFieldName", roleFields.contains(RoleField.CUSTOM) ? customName : null); meta.put("assigneeIds", assigneeIds); meta.put("mentionUserIds", mentionIds); meta.put("fileIds", fileIds); @@ -875,6 +907,7 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, .filter(pf -> pf.getDeletedAt() == null) .map(ProcessField::getRoleField) .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) .distinct() .sorted(Comparator.comparing(Enum::name)) .toList(); @@ -900,6 +933,7 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, .toList(); if(req.processTitle() != null && !req.processTitle().isBlank()) { + validateProcessTitle(req.processTitle()); process.updateTitle(req.processTitle()); } @@ -929,41 +963,66 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, ); } + // 시작일만 미션 범위에 포함되면 통과 if (req.missionNumber() != null && mergedStart != null && mergedEnd != null) { validateStartDateInSelectedMission(projectId, req.missionNumber(), mergedStart); } - if (mergedStart != null && mergedEnd != null && (newStart != null || newEnd != null)) { + // fields PATCH 준비(정규화 + 프로젝트 등록 파트 검증) + boolean fieldsPatchRequested = (req.roleFields() != null || req.customFields() != null); - // 검증할 lane 후보 결정 - List laneRoleFields = - (req.roleFields() != null) - ? req.roleFields() - : process.getProcessFields().stream() - .filter(pf -> pf.getDeletedAt() == null) - .map(ProcessField::getRoleField) - .filter(Objects::nonNull) - .filter(rf -> rf != RoleField.CUSTOM) - .distinct() - .toList(); + List requestedRoleFields = (req.roleFields() == null) + ? null + : req.roleFields().stream() + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .distinct() + .toList(); - List laneCustomFields = - (req.customFields() != null) - ? req.customFields() - : process.getProcessFields().stream() - .filter(pf -> pf.getDeletedAt() == null) - .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) - .map(ProcessField::getCustomFieldName) - .filter(Objects::nonNull) - .map(String::trim) - .filter(s -> !s.isBlank()) - .distinct() - .toList(); + List requestedCustomFields = (req.customFields() == null) + ? null + : normalizeCustomFields(req.customFields()); + + if (fieldsPatchRequested) { + validateProjectTeamRolesForUpdateOrThrow( + projectId, + (requestedRoleFields == null ? List.of() : requestedRoleFields), + (requestedCustomFields == null ? List.of() : requestedCustomFields) + ); + } + + // 기간 변경이 없더라도 "파트/커스텀 변경"만으로도 lane이 바뀌면 overlap 가능 + boolean periodPatchRequested = (newStart != null || newEnd != null); + + if (mergedStart != null && mergedEnd != null && (periodPatchRequested || fieldsPatchRequested)) { + + List laneRoleFields = (requestedRoleFields != null) + ? requestedRoleFields + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .map(ProcessField::getRoleField) + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .distinct() + .toList(); + + List laneCustomFields = (requestedCustomFields != null) + ? requestedCustomFields + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) + .map(ProcessField::getCustomFieldName) + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .toList(); validateNoOverlapForUpdateBasic(projectId, processId, laneRoleFields, laneCustomFields, mergedStart, mergedEnd); } - if (newStart != null || newEnd != null) { + + if (periodPatchRequested) { process.updatePeriod(mergedStart, mergedEnd); } @@ -973,8 +1032,8 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, // 멘션 알림: 요청이 들어온 경우에만, 그리고 '새로 추가된 멘션'에게만 전송 if (req.mentionUserIds() != null) { - List afterIds = (mentionIdsForRes == null) ? List.of() : mentionIdsForRes.stream() - .filter(Objects::nonNull).distinct().toList(); + List afterIds = (mentionIdsForRes == null) ? List.of() + : mentionIdsForRes.stream().filter(Objects::nonNull).distinct().toList(); Set beforeSet = new HashSet<>(beforeMentionIds); List addedMentionIds = afterIds.stream() @@ -997,62 +1056,45 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, } } - if (req.roleFields() != null || req.customFields() != null) { - // 기존 전부 soft delete + if (fieldsPatchRequested) { + // 기존 전부 soft delete process.getProcessFields().forEach(pf -> { if (pf.getDeletedAt() == null) pf.softDelete(); }); - // roleFields 반영 (CUSTOM 제외) - List requestedRoleFields = (req.roleFields() == null) ? List.of() : req.roleFields(); - for (RoleField rf : requestedRoleFields) { - if (rf == null) continue; - if (rf == RoleField.CUSTOM) continue; - - // 기존에 같은 roleField가 삭제된 상태로 있으면 restore, 없으면 생성 + List finalRoleFields = (requestedRoleFields == null) ? List.of() : requestedRoleFields; + for (RoleField rf : finalRoleFields) { ProcessField found = process.getProcessFields().stream() .filter(pf -> pf.getRoleField() == rf) .findFirst() .orElse(null); - if (found != null) { - found.restore(); - } else { - ProcessField pf = ProcessField.builder() - .process(process) - .roleField(rf) - .customFieldName(null) - .build(); - process.getProcessFields().add(pf); - } + if (found != null) found.restore(); + else process.getProcessFields().add(ProcessField.builder() + .process(process) + .roleField(rf) + .customFieldName(null) + .build()); } - // customFields 반영 (CUSTOM은 이름 기반) - List requestedCustomFields = (req.customFields() == null) ? List.of() : req.customFields(); - for (String name : requestedCustomFields) { - if (name == null) continue; - String trimmed = name.trim(); - if (trimmed.isBlank()) continue; - + List finalCustomFields = (requestedCustomFields == null) ? List.of() : requestedCustomFields; + for (String name : finalCustomFields) { ProcessField found = process.getProcessFields().stream() .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) - .filter(pf -> trimmed.equals(pf.getCustomFieldName())) + .filter(pf -> pf.getCustomFieldName() != null && pf.getCustomFieldName().trim().equals(name)) .findFirst() .orElse(null); - if (found != null) { - found.restore(); - } else { - ProcessField pf = ProcessField.builder() - .process(process) - .roleField(RoleField.CUSTOM) - .customFieldName(trimmed) - .build(); - process.getProcessFields().add(pf); - } + if (found != null) found.restore(); + else process.getProcessFields().add(ProcessField.builder() + .process(process) + .roleField(RoleField.CUSTOM) + .customFieldName(name) + .build()); } } + // 요청이 null이면 변경 안 함 if (req.assigneeIds() != null) { List requestedAssigneeIds = req.assigneeIds().stream() @@ -1256,6 +1298,57 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, } + private List normalizeCustomFields(List raw) { + return raw.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .toList(); + } + + /** + * 프로젝트에 등록된 파트(ProjectTeamRole)인지 검증 + * - roleFields: CUSTOM 제외 리스트 + * - customFields: CUSTOM 이름 리스트 + */ + private void validateProjectTeamRolesForUpdateOrThrow(Long projectId, List roleFields, List customFields) { + List rows = + projectTeamRoleRepository.findActiveTeamRoleRowsByProjectId(projectId); + + Set registeredRoleFields = rows.stream() + .map(ProjectTeamRoleRepository.TeamRoleRow::getRoleField) + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .collect(Collectors.toSet()); + + Set registeredCustomNames = rows.stream() + .filter(r -> r.getRoleField() == RoleField.CUSTOM) + .map(ProjectTeamRoleRepository.TeamRoleRow::getCustomRoleFieldName) + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .collect(Collectors.toSet()); + + for (RoleField rf : roleFields) { + if (!registeredRoleFields.contains(rf)) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "role_field not registered in project. projectId=" + projectId + ", roleField=" + rf + ); + } + } + + for (String name : customFields) { + if (!registeredCustomNames.contains(name)) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "custom_field not registered in project. projectId=" + projectId + ", customField=" + name + ); + } + } + } + diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java index 5ef99da4..937fa046 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -7,6 +7,7 @@ import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDropdownResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; import com.nect.api.domain.team.process.enums.ProcessErrorCode; import com.nect.api.domain.team.process.exception.ProcessException; @@ -502,6 +503,38 @@ public ProcessTaskItemResDto updateWeekMissionTaskItem( ); } + // 위크미션 드롭 다운용 조화 + @Transactional(readOnly = true) + public WeekMissionDropdownResDto getMissionDropdown(Long projectId, Long userId) { + assertActiveProjectMember(projectId, userId); + assertProjectExists(projectId); + + var rows = processRepository.findWeekMissionRanges(projectId); + + LocalDate today = LocalDate.now(); + + List missions = rows.stream() + .map(r -> { + LocalDate start = r.getStartDate(); + LocalDate end = r.getEndDate(); + + boolean isCurrent = false; + if (start != null && end != null) { + isCurrent = (!today.isBefore(start) && !today.isAfter(end)); + } + + return new WeekMissionDropdownResDto.MissionDto( + r.getMissionNumber(), + start, + end, + isCurrent + ); + }) + .toList(); + + return new WeekMissionDropdownResDto(missions); + } + private LocalDate toMonday(LocalDate date) { return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java new file mode 100644 index 00000000..a5ed5826 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java @@ -0,0 +1,42 @@ +package com.nect.api.domain.team.project.controller; + +import com.nect.api.domain.team.project.dto.ProjectPartsResDto; +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.service.ProjectTeamQueryService; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}") +public class ProjectPartsController { + + private final ProjectTeamQueryService projectTeamQueryService; + + // 팀 파트 조회 (드롭다운) + @GetMapping("/parts") + public ApiResponse readProjectParts( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok( + projectTeamQueryService.readProjectParts(projectId, userDetails.getUserId()) + ); + } + + // 프로젝트 전체 인원 조회 (담당자 드롭다운) + @GetMapping("/users") + public ApiResponse readProjectUsers( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.ok( + projectTeamQueryService.readProjectUsers(projectId, userDetails.getUserId()) + ); + } + + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartsResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartsResDto.java new file mode 100644 index 00000000..c4151ff6 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartsResDto.java @@ -0,0 +1,32 @@ +package com.nect.api.domain.team.project.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +import java.util.List; + +public record ProjectPartsResDto( + @JsonProperty("parts") + List parts +) { + public ProjectPartsResDto { + parts = (parts == null) ? List.of() : parts; + } + + public record PartDto( + @JsonProperty("part_id") + Long partId, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("part_label") + String partLabel, + + @JsonProperty("required_count") + Integer requiredCount + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectUsersResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectUsersResDto.java new file mode 100644 index 00000000..9ac3e96f --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectUsersResDto.java @@ -0,0 +1,42 @@ +package com.nect.api.domain.team.project.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.user.enums.RoleField; + +import java.util.List; + +public record ProjectUsersResDto( + @JsonProperty("users") + List users +) { + public ProjectUsersResDto { + users = (users == null) ? List.of() : users; + } + + public record UserDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("name") + String name, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("part_label") + String partLabel, + + @JsonProperty("member_type") + ProjectMemberType memberType + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java index ee77c563..466d221d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java @@ -8,6 +8,8 @@ @AllArgsConstructor public enum ProjectErrorCode implements ResponseCode { + INVALID_REQUEST("P400_0", "요청 값이 올바르지 않습니다."), + PROJECT_NOT_FOUND("P400_1", "해당 프로젝트가 존재하지 않습니다."), PROJECT_USER_NOT_FOUND("P400_2", "해당 프로젝트 유저가 존재하지 않습니다."), ANALYSIS_NOT_FOUND("P400_3", "해당 분석서가 존재하지 않습니다."), @@ -17,7 +19,12 @@ public enum ProjectErrorCode implements ResponseCode { WEEK_MISSION_ALREADY_INITIALIZED("P400_7", "위크미션이 이미 생성되어 있습니다."), INVALID_WEEK_MISSION_UPDATE("P400_8", "수정할 수 없는 항목이 포함되어 있습니다."), + PROJECT_PART_NOT_FOUND("P400_9", "해당 프로젝트 파트(팀 역할)를 찾을 수 없습니다."), + DUPLICATE_PART("P400_10", "이미 존재하는 파트입니다."), + INVALID_CUSTOM_PART_NAME("P400_11", "CUSTOM 파트 이름이 올바르지 않습니다."), + + PROJECT_MEMBER_FORBIDDEN("P403_0", "프로젝트 멤버만 접근할 수 있습니다."), LEADER_ONLY_ACTION("P403_1", "리더만 할 수 있는 요청입니다."), ; diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/exception/ProjectException.java b/nect-api/src/main/java/com/nect/api/domain/team/project/exception/ProjectException.java index 10577982..0a2a5cf0 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/exception/ProjectException.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/exception/ProjectException.java @@ -8,4 +8,8 @@ public class ProjectException extends CustomException { public ProjectException(ResponseCode code) { super(code); } + + public ProjectException(ResponseCode code, String message) { + super(code, message); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java index d461bf8c..f252242a 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java @@ -19,6 +19,7 @@ import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.analysis.*; import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.process.ProcessRepository; import com.nect.core.repository.user.UserRepository; diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java new file mode 100644 index 00000000..9bf7ff88 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java @@ -0,0 +1,116 @@ +package com.nect.api.domain.team.project.service; + +import com.nect.api.domain.team.project.dto.ProjectPartsResDto; +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.enums.code.ProjectErrorCode; +import com.nect.api.domain.team.project.exception.ProjectException; +import com.nect.core.entity.team.ProjectTeamRole; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.user.User; +import com.nect.core.entity.user.enums.RoleField; +import com.nect.core.repository.team.ProjectTeamRoleRepository; +import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ProjectTeamQueryService { + private final ProjectTeamRoleRepository projectTeamRoleRepository; + private final ProjectUserRepository projectUserRepository; + private final UserRepository userRepository; + + // 프로젝트 파트 목록 조회 서비스 + @Transactional(readOnly = true) + public ProjectPartsResDto readProjectParts(Long projectId, Long userId) { + assertActiveProjectMember(projectId, userId); + + List roles = projectTeamRoleRepository.findAllActiveByProjectId(projectId); + + List parts = roles.stream() + .map(ptr -> { + RoleField rf = ptr.getRoleField(); + String customName = ptr.getCustomRoleFieldName(); + + String label = (rf == RoleField.CUSTOM) + ? customName + : rf.getLabelEn(); + + return new ProjectPartsResDto.PartDto( + ptr.getId(), + rf, + customName, + label, + ptr.getRequiredCount() + ); + }) + .toList(); + + return new ProjectPartsResDto(parts); + } + + // 프로젝트 멤버 전체 조회 서비스 + @Transactional(readOnly = true) + public ProjectUsersResDto readProjectUsers(Long projectId, Long userId) { + assertActiveProjectMember(projectId, userId); + + List rows = + projectUserRepository.findActiveMemberBoardRows(projectId); + + List ids = rows.stream() + .map(ProjectUserRepository.MemberBoardRow::getUserId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + Map userMap = userRepository.findAllById(ids).stream() + .collect(Collectors.toMap(User::getUserId, Function.identity())); + + List users = rows.stream() + .map(r -> { + User u = userMap.get(r.getUserId()); + String profileUrl = (u == null) ? null : u.getProfileImageUrl(); + + RoleField rf = r.getRoleField(); + String customName = r.getCustomRoleFieldName(); + + String label = (rf == RoleField.CUSTOM) + ? customName + : rf.getDescription(); + + return new ProjectUsersResDto.UserDto( + r.getUserId(), + r.getName(), + r.getNickname(), + profileUrl, + rf, + customName, + label, + r.getMemberType() + ); + }) + .toList(); + + return new ProjectUsersResDto(users); + } + + + private void assertActiveProjectMember(Long projectId, Long userId) { + boolean ok = projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus( + projectId, userId, ProjectMemberStatus.ACTIVE + ); + if (!ok) { + throw new ProjectException(ProjectErrorCode.PROJECT_MEMBER_FORBIDDEN, + "projectId=" + projectId + ", userId=" + userId); + } + + } +} diff --git a/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java b/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java index e48fbd3a..a3ec704a 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/ProjectTeamRole.java @@ -6,6 +6,8 @@ import jakarta.persistence.*; import lombok.*; +import java.time.LocalDateTime; + @Entity @Getter @Setter @@ -25,15 +27,40 @@ public class ProjectTeamRole extends BaseEntity { @Column(name = "role_field", nullable = false) private RoleField roleField; + @Column(name = "custom_role_field_name", length = 50) + private String customRoleFieldName; + @Column(name = "required_count", nullable = false) private Integer requiredCount; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Builder - private ProjectTeamRole(Project project, RoleField roleField, Integer requiredCount) { + private ProjectTeamRole(Project project, RoleField roleField, String customRoleFieldName, Integer requiredCount) { + if (roleField == null) { + throw new IllegalArgumentException("roleField는 null일 수 없습니다."); + } + if (roleField == RoleField.CUSTOM && (customRoleFieldName == null || customRoleFieldName.isBlank())) { + throw new IllegalArgumentException("CUSTOM이면 customRoleFieldName(직접입력)이 필수입니다."); + } + this.project = project; this.roleField = roleField; + this.customRoleFieldName = (roleField == RoleField.CUSTOM) ? customRoleFieldName : null; this.requiredCount = requiredCount; } + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } + + public void restore() { + this.deletedAt = null; + } + + public boolean isDeleted() { + return this.deletedAt != null; + } } \ No newline at end of file diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java index ece578bf..1711b0de 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java @@ -1,6 +1,9 @@ package com.nect.core.repository.analysis; import com.nect.core.entity.team.ProjectTeamRole; +import com.nect.core.entity.user.enums.RoleField; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; @@ -9,4 +12,35 @@ public interface ProjectTeamRoleRepository extends JpaRepository findByProjectIdIn(List projectIds); + + @Query(""" + select ptr + from ProjectTeamRole ptr + where ptr.project.id = :projectId + and ptr.deletedAt is null + order by ptr.id asc + """) + List findAllActiveByProjectId(@Param("projectId") Long projectId); + + @Query(""" + select ptr.roleField as roleField, + ptr.customRoleFieldName as customRoleFieldName + from ProjectTeamRole ptr + where ptr.project.id = :projectId + and ptr.deletedAt is null + """) + List findActiveTeamRoleRowsByProjectId(@Param("projectId") Long projectId); + + interface TeamRoleRow { + RoleField getRoleField(); + String getCustomRoleFieldName(); + } + + boolean existsByProject_IdAndRoleField(Long projectId, RoleField roleField); + + boolean existsByProject_IdAndRoleFieldAndCustomRoleFieldName( + Long projectId, + RoleField roleField, + String customRoleFieldName + ); } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index 0419805e..cece6921 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -581,5 +581,27 @@ boolean existsOverlappingInCustomLaneExcludingProcess( @Param("excludeProcessId") Long excludeProcessId ); + @Query(""" + select + p.missionNumber as missionNumber, + min(p.startAt) as startDate, + max(p.endAt) as endDate + from Process p + where p.project.id = :projectId + and p.deletedAt is null + and p.missionNumber is not null + and p.startAt is not null + and p.endAt is not null + group by p.missionNumber + order by p.missionNumber asc + """) + List findWeekMissionRanges(@Param("projectId") Long projectId); + + interface WeekMissionRangeRow { + Integer getMissionNumber(); + LocalDate getStartDate(); + LocalDate getEndDate(); + } + } From dbd34cedb24da5efab21a662c6064b5208b91c3e Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 03:05:20 +0900 Subject: [PATCH 33/66] =?UTF-8?q?[Test]=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=9C=A0=EC=A0=80=20=EC=A1=B0=ED=9A=8C,=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=ED=8C=8C=ED=8A=B8,=20?= =?UTF-8?q?=EB=AF=B8=EC=85=98=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WeekMissionControllerTest.java | 46 ++++ .../ProjectPartsControllerTest.java | 241 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java index 465c3133..d47eb84b 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java @@ -7,6 +7,7 @@ import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; +import com.nect.api.domain.team.process.dto.res.WeekMissionDropdownResDto; import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; import com.nect.api.domain.team.process.service.WeekMissionService; import com.nect.api.global.jwt.JwtUtil; @@ -376,4 +377,49 @@ void updateWeekMissionTaskItem() throws Exception { verify(weekMissionService).updateWeekMissionTaskItem(eq(projectId), eq(userId), eq(processId), eq(taskItemId), any(WeekMissionTaskItemUpdateReqDto.class)); } + + @Test + @DisplayName("멤버형 모달 미션 주차 선택 드롭다운 조회") + void readMissionDropdown() throws Exception { + long projectId = 1L; + long userId = 1L; + + WeekMissionDropdownResDto response = newRecord(WeekMissionDropdownResDto.class); + + given(weekMissionService.getMissionDropdown(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/week-missions/missions", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("week-mission-missions-dropdown", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Week-Mission") + .summary("미션 주차 드롭다운 조회") + .description("멤버형 모달에서 미션(주차) 선택을 위한 드롭다운 목록을 조회합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("미션 드롭다운 조회 결과") + ) + .build() + ) + )); + + verify(weekMissionService).getMissionDropdown(eq(projectId), eq(userId)); + } } diff --git a/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java new file mode 100644 index 00000000..351432de --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java @@ -0,0 +1,241 @@ +package com.nect.api.domain.team.project.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.project.dto.ProjectPartsResDto; +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.service.ProjectTeamQueryService; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Constructor; +import java.lang.reflect.RecordComponent; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class ProjectPartsControllerTest { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ProjectTeamQueryService projectTeamQueryService; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_MEMBER")) + .build() + ); + } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_MEMBER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return SecurityMockMvcRequestPostProcessors.authentication(auth); + } + + private T newRecord(Class recordType) { + try { + if (!recordType.isRecord()) return null; + + RecordComponent[] components = recordType.getRecordComponents(); + Class[] paramTypes = new Class[components.length]; + Object[] args = new Object[components.length]; + + for (int i = 0; i < components.length; i++) { + Class t = components[i].getType(); + paramTypes[i] = t; + args[i] = defaultValue(t); + } + + Constructor ctor = recordType.getDeclaredConstructor(paramTypes); + ctor.setAccessible(true); + return ctor.newInstance(args); + } catch (Exception e) { + throw new IllegalStateException("Failed to instantiate record: " + recordType.getName(), e); + } + } + + private Object defaultValue(Class t) { + if (t == String.class) return "sample"; + if (t == Long.class || t == long.class) return 1L; + if (t == Integer.class || t == int.class) return 1; + if (t == Boolean.class || t == boolean.class) return false; + if (t == LocalDate.class) return LocalDate.of(2026, 1, 19); + if (t == LocalDateTime.class) return LocalDateTime.of(2026, 1, 19, 0, 0, 0); + + if (List.class.isAssignableFrom(t)) return List.of(); + + if (t.isEnum()) { + Object[] constants = t.getEnumConstants(); + return (constants != null && constants.length > 0) ? constants[0] : null; + } + + if (t.isRecord()) { + @SuppressWarnings("unchecked") + Class rt = (Class) t; + return newRecord(rt); + } + + return null; + } + + @Test + @DisplayName("팀 파트 조회 (드롭다운)") + void readProjectParts() throws Exception { + long projectId = 1L; + long userId = 1L; + + ProjectPartsResDto response = newRecord(ProjectPartsResDto.class); + + given(projectTeamQueryService.readProjectParts(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/parts", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("project-parts-read", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Project") + .summary("팀 파트 조회") + .description("현재 프로젝트에 설정된 파트 목록을 조회합니다. (드롭다운)") + .pathParameters( + com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName("projectId") + .description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("팀 파트 조회 결과") + ) + .build() + ) + )); + + verify(projectTeamQueryService).readProjectParts(eq(projectId), eq(userId)); + } + + @Test + @DisplayName("프로젝트 전체 인원 조회 (담당자 드롭다운)") + void readProjectUsers() throws Exception { + long projectId = 1L; + long userId = 1L; + + ProjectUsersResDto response = newRecord(ProjectUsersResDto.class); + + given(projectTeamQueryService.readProjectUsers(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/users", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("project-users-read", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Project") + .summary("프로젝트 전체 인원 조회") + .description("프로젝트에 속한 전체 인원 목록을 조회합니다. (담당자 드롭다운)") + .pathParameters( + com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName("projectId") + .description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + subsectionWithPath("body").type(OBJECT).description("프로젝트 전체 인원 조회 결과") + ) + .build() + ) + )); + + verify(projectTeamQueryService).readProjectUsers(eq(projectId), eq(userId)); + } +} From e1f84d68d0119e3393374c271d4e292097b16b6b Mon Sep 17 00:00:00 2001 From: infiniment Date: Fri, 6 Feb 2026 11:38:51 +0900 Subject: [PATCH 34/66] =?UTF-8?q?[Refactor]=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A7=A4=EA=B0=9C=EB=B3=80=EC=88=98=20=EA=B0=92=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nect/api/domain/team/process/service/ProcessService.java | 2 +- .../api/domain/team/process/service/WeekMissionService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index b1be82b9..af0d92df 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -432,7 +432,7 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre String customName = (req.customFieldName() == null) ? null : req.customFieldName().trim(); // 프로젝트에 등록된 파트인지 검증 (CUSTOM 포함) - validateProjectTeamRolesOrThrow(projectId, roleFields, req.customFieldName()); + validateProjectTeamRolesOrThrow(projectId, roleFields, customName); // 미션 N 검증: "시작일만" 미션 기간에 포함되면 요구사항 일치 validateStartDateInSelectedMission(projectId, req.missionNumber(), start); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java index 937fa046..8b9ea852 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -503,7 +503,7 @@ public ProcessTaskItemResDto updateWeekMissionTaskItem( ); } - // 위크미션 드롭 다운용 조화 + // 위크미션 드롭 다운용 조회 @Transactional(readOnly = true) public WeekMissionDropdownResDto getMissionDropdown(Long projectId, Long userId) { assertActiveProjectMember(projectId, userId); From e7cbddf5fa455e75c6a58fd303ecff853fbef4fa Mon Sep 17 00:00:00 2001 From: infiniment Date: Sat, 7 Feb 2026 11:37:33 +0900 Subject: [PATCH 35/66] =?UTF-8?q?[Refactor]=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=AA=85=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20?= =?UTF-8?q?presigned=20URL=20=EC=9D=91=EB=8B=B5=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/process/service/ProcessService.java | 24 ++++++++++++++----- .../process/service/WeekMissionService.java | 12 +++++++++- .../service/ProjectTeamQueryService.java | 9 ++++++- .../team/workspace/dto/res/PostGetResDto.java | 5 +++- .../dto/res/SharedDocumentsPreviewResDto.java | 1 - .../service/BoardsMemberBoardService.java | 12 +++++++++- .../service/BoardsSharedDocumentService.java | 11 ++++++++- .../team/workspace/service/PostService.java | 3 ++- .../controller/PostControllerTest.java | 5 ++-- .../team/ProjectUserRepository.java | 4 +++- .../team/process/ProcessRepository.java | 4 ++-- 11 files changed, 72 insertions(+), 18 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index af0d92df..63c753df 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -9,6 +9,7 @@ import com.nect.api.domain.team.process.enums.ProcessErrorCode; import com.nect.api.domain.team.process.exception.ProcessException; import com.nect.api.domain.notifications.facade.NotificationFacade; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.notifications.enums.NotificationClassification; import com.nect.core.entity.notifications.enums.NotificationScope; import com.nect.core.entity.notifications.enums.NotificationType; @@ -56,6 +57,7 @@ public class ProcessService { private final ProcessLaneOrderRepository processLaneOrderRepository; private final ProjectTeamRoleRepository projectTeamRoleRepository; + private final S3Service s3Service; private final ProcessLaneOrderService processLaneOrderService; private static final String TEAM_LANE_KEY = "TEAM"; @@ -329,6 +331,11 @@ private void validateProjectTeamRolesOrThrow(Long projectId, List rol } } + private String toPresignedUserImage(String fileKey) { + if (fileKey == null || fileKey.isBlank()) return null; + return s3Service.getPresignedGetUrl(fileKey); + } + // 알림 관련 헬퍼 메서드 private List validateAndLoadMentionReceivers(Long projectId, Long actorId, List mentionIds) { if (mentionIds == null) return List.of(); @@ -588,17 +595,21 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre "writer must be active project member. projectId=" + projectId + ", userId=" + userId )); + + List assigneeDtos = saved.getProcessUsers().stream() .filter(pu -> pu.getDeletedAt() == null) .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) .map(pu -> { User u = pu.getUser(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); + return new ProcessCreateResDto.AssigneeDto( u.getUserId(), u.getName(), u.getNickname(), - u.getProfileImageUrl() + profileUrl ); }) .toList(); @@ -728,13 +739,13 @@ public ProcessDetailResDto getProcessDetail(Long projectId, Long userId, Long pr .map(pu -> { User u = pu.getUser(); - String userImage = u.getProfileImageUrl(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); return new AssigneeResDto( u.getUserId(), u.getName(), u.getNickname(), - userImage + profileUrl ); }) .toList(); @@ -1151,11 +1162,12 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) .map(pu -> { User u = pu.getUser(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); return new AssigneeResDto( u.getUserId(), u.getName(), u.getNickname(), - u.getProfileImageUrl() + profileUrl ); }) .toList(); @@ -1428,9 +1440,9 @@ private ProcessCardResDto toProcessCardResDTO(Process p) { .filter(pu -> pu.getAssignmentRole() == AssignmentRole.ASSIGNEE) .map(pu -> { User u = pu.getUser(); - String userImage = u.getProfileImageUrl(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); String nickname = u.getNickname(); - return new AssigneeResDto(u.getUserId(), u.getName(), nickname, userImage); + return new AssigneeResDto(u.getUserId(), u.getName(), nickname, profileUrl); }) .toList(); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java index 8b9ea852..30470bf9 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -11,6 +11,7 @@ import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; import com.nect.api.domain.team.process.enums.ProcessErrorCode; import com.nect.api.domain.team.process.exception.ProcessException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.notifications.enums.NotificationClassification; import com.nect.core.entity.notifications.enums.NotificationScope; import com.nect.core.entity.notifications.enums.NotificationType; @@ -55,6 +56,13 @@ public class WeekMissionService { private final UserRepository userRepository; private final NotificationFacade notificationFacade; private final ProjectHistoryPublisher historyPublisher; + private final S3Service s3Service; + + private String toPresignedUserImage(String fileKey) { + if (fileKey == null || fileKey.isBlank()) return null; + return s3Service.getPresignedGetUrl(fileKey); + } + private void assertActiveProjectMember(Long projectId, Long userId) { if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { @@ -309,11 +317,13 @@ record GroupKey(RoleField roleField, String customName) {} User leader = process.getCreatedBy(); + String profileUrl = (leader == null) ? null : toPresignedUserImage(leader.getProfileImageName()); + WeekMissionDetailResDto.AssigneeDto assignee = new WeekMissionDetailResDto.AssigneeDto( leader.getUserId(), leader.getName(), leader.getNickname(), - leader.getProfileImageUrl() + profileUrl ); // DTO 생성자 인자 순서 주의: (taskGroups, taskItems) 둘 다 넣기 diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java index 9bf7ff88..3e7d5a9d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java @@ -4,6 +4,7 @@ import com.nect.api.domain.team.project.dto.ProjectUsersResDto; import com.nect.api.domain.team.project.enums.code.ProjectErrorCode; import com.nect.api.domain.team.project.exception.ProjectException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.ProjectTeamRole; import com.nect.core.entity.team.enums.ProjectMemberStatus; import com.nect.core.entity.user.User; @@ -27,6 +28,12 @@ public class ProjectTeamQueryService { private final ProjectTeamRoleRepository projectTeamRoleRepository; private final ProjectUserRepository projectUserRepository; private final UserRepository userRepository; + private final S3Service s3Service; + + private String toPresignedUserImage(String fileKey) { + if (fileKey == null || fileKey.isBlank()) return null; + return s3Service.getPresignedGetUrl(fileKey); + } // 프로젝트 파트 목록 조회 서비스 @Transactional(readOnly = true) @@ -77,7 +84,7 @@ public ProjectUsersResDto readProjectUsers(Long projectId, Long userId) { List users = rows.stream() .map(r -> { User u = userMap.get(r.getUserId()); - String profileUrl = (u == null) ? null : u.getProfileImageUrl(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); RoleField rf = r.getRoleField(); String customName = r.getCustomRoleFieldName(); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostGetResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostGetResDto.java index 948d8117..315c2e04 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostGetResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostGetResDto.java @@ -35,6 +35,9 @@ public record AuthorDto( Long userId, @JsonProperty("name") - String name + String name, + + @JsonProperty("nickname") + String nickname ) {} } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentsPreviewResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentsPreviewResDto.java index 77831ce5..8bc3fe75 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentsPreviewResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentsPreviewResDto.java @@ -49,7 +49,6 @@ public record UploaderDto( @JsonProperty("nickname") String nickname, - // TODO: UserProfile 엔티티 생기면 연결 @JsonProperty("profile_image_url") String profileImageUrl ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsMemberBoardService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsMemberBoardService.java index 19a32fcf..272084de 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsMemberBoardService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsMemberBoardService.java @@ -4,6 +4,7 @@ import com.nect.api.domain.team.workspace.dto.res.RoleFieldDto; import com.nect.api.domain.team.workspace.enums.BoardsErrorCode; import com.nect.api.domain.team.workspace.exception.BoardsException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.Project; import com.nect.core.entity.team.process.enums.ProcessStatus; import com.nect.core.entity.team.workspace.ProjectUserWorkDaily; @@ -30,6 +31,13 @@ public class BoardsMemberBoardService { private final ProjectUserRepository projectUserRepository; private final ProcessRepository processRepository; private final ProjectUserWorkDailyRepository projectUserWorkDailyRepository; + private final S3Service s3Service; + + private String toPresignedUserImage(String fileKey) { + if (fileKey == null || fileKey.isBlank()) return null; + return s3Service.getPresignedGetUrl(fileKey); + } + // 멤버 보드 조회 서비스 @Transactional(readOnly = true) @@ -100,11 +108,13 @@ public MemberBoardResDto getMemberBoard(Long projectId, Long userId) { ? RoleFieldDto.of(m.getRoleField(), m.getCustomRoleFieldName()) : RoleFieldDto.of(m.getRoleField()); + String userImageUrl = toPresignedUserImage(m.getProfileImageName()); + return new MemberBoardResDto.MemberDto( m.getUserId(), m.getName(), m.getNickname(), - null, // profile_image_url (TODO) + userImageUrl, fieldDto, m.getMemberType(), new MemberBoardResDto.CountsDto(arr[0], arr[1], arr[2]), diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java index c9c1bd5e..be2e2b3e 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java @@ -3,6 +3,7 @@ import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsPreviewResDto; import com.nect.api.domain.team.workspace.enums.BoardsErrorCode; import com.nect.api.domain.team.workspace.exception.BoardsException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.Project; import com.nect.core.entity.team.SharedDocument; import com.nect.core.entity.user.User; @@ -23,6 +24,12 @@ public class BoardsSharedDocumentService { private final ProjectRepository projectRepository; private final ProjectUserRepository projectUserRepository; private final SharedDocumentRepository sharedDocumentRepository; + private final S3Service s3Service; + + private String toPresignedUserImage(String fileKey) { + if (fileKey == null || fileKey.isBlank()) return null; + return s3Service.getPresignedGetUrl(fileKey); + } /** * 공유 문서함 프리뷰 조회 @@ -53,6 +60,8 @@ public SharedDocumentsPreviewResDto getPreview(Long projectId, Long userId, int List result = docs.stream().map(d -> { User u = d.getCreatedBy(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); + return new SharedDocumentsPreviewResDto.DocumentDto( d.getId(), d.isPinned(), @@ -66,7 +75,7 @@ public SharedDocumentsPreviewResDto getPreview(Long projectId, Long userId, int u.getUserId(), u.getName(), u.getNickname(), - null // TODO: profile_image_url + profileUrl ) ); }).toList(); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java index 5cbcdbef..504865cc 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java @@ -192,7 +192,8 @@ public PostGetResDto getPost(Long projectId, Long userId, Long postId) { post.getCreatedAt(), new PostGetResDto.AuthorDto( post.getAuthor().getUserId(), - post.getAuthor().getName() + post.getAuthor().getName(), + post.getAuthor().getNickname() ) ); } diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java index 22968e5e..027bba8b 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java @@ -181,7 +181,7 @@ void getPost() throws Exception { true, 7L, LocalDateTime.of(2026, 1, 31, 10, 0), - new PostGetResDto.AuthorDto(1L, "노수민") + new PostGetResDto.AuthorDto(1L, "노수민", "패트") ); given(postFacade.getPost(eq(projectId), eq(userId), eq(postId))) @@ -224,7 +224,8 @@ void getPost() throws Exception { fieldWithPath("body.author").type(OBJECT).description("작성자 정보"), fieldWithPath("body.author.user_id").type(NUMBER).description("작성자 유저 ID"), - fieldWithPath("body.author.name").type(STRING).description("작성자 이름") + fieldWithPath("body.author.name").type(STRING).description("작성자 이름"), + fieldWithPath("body.author.nickname").type(STRING).description("작성자 별명") ) .build() ) diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java index 62c09546..421955a7 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java @@ -148,6 +148,7 @@ SELECT COUNT(pu) > 0 pu.userId as userId, u.name as name, u.nickname as nickname, + u.profileImageName as profileImageName, pu.roleField as roleField, pu.customRoleFieldName as customRoleFieldName, pu.memberType as memberType @@ -222,6 +223,7 @@ interface MemberBoardRow { Long getUserId(); String getName(); String getNickname(); + String getProfileImageName(); RoleField getRoleField(); String getCustomRoleFieldName(); ProjectMemberType getMemberType(); @@ -241,7 +243,7 @@ interface ProjectLeaderProfileRow { select u.userId as userId, u.nickname as nickname, - u.profileImageUrl as profileImageUrl + u.profileImageName as profileImageName from ProjectUser pu join User u on u.userId = pu.userId diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index cece6921..ff824587 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -422,7 +422,7 @@ interface WeekMissionCardRow { count(ti.id) as totalCount, u.userId as leaderUserId, u.nickname as leaderNickname, - u.profileImageUrl as leaderProfileImageUrl + u.profileImageName as leaderProfileImageUrl from Process p left join p.taskItems ti on ti.deletedAt is null left join p.processUsers pu @@ -439,7 +439,7 @@ interface WeekMissionCardRow { or (p.startAt <= :end and p.endAt >= :start) ) group by p.id, p.missionNumber, p.status, p.title, p.startAt, p.endAt, - u.userId, u.nickname, u.profileImageUrl + u.userId, u.nickname, u.profileImageName order by p.missionNumber asc nulls last, p.startAt asc nulls last, p.id asc """) List findWeekMissionCardsInRange( From 378c82be17fc3c9e4e6adc6fb21962e585d68cb0 Mon Sep 17 00:00:00 2001 From: infiniment Date: Sat, 7 Feb 2026 14:43:20 +0900 Subject: [PATCH 36/66] =?UTF-8?q?[Fix]=20=EC=B6=A9=EB=8F=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nect/core/repository/team/ProjectTeamRoleRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java index 1711b0de..df756510 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java @@ -1,4 +1,4 @@ -package com.nect.core.repository.analysis; +package com.nect.core.repository.team; import com.nect.core.entity.team.ProjectTeamRole; import com.nect.core.entity.user.enums.RoleField; import org.springframework.data.jpa.repository.JpaRepository; From ea40a3e0b60e5d4efc8fa4aaed36f50232b9dbad Mon Sep 17 00:00:00 2001 From: infiniment Date: Sat, 7 Feb 2026 14:52:07 +0900 Subject: [PATCH 37/66] =?UTF-8?q?[Fix]=20=EC=B6=A9=EB=8F=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/domain/mypage/service/MyPageProjectQueryService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/service/MyPageProjectQueryService.java b/nect-api/src/main/java/com/nect/api/domain/mypage/service/MyPageProjectQueryService.java index 8c15d320..9e3ef526 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/service/MyPageProjectQueryService.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/service/MyPageProjectQueryService.java @@ -2,13 +2,13 @@ import com.nect.api.domain.mypage.dto.MyProjectsResponseDto; import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.ProjectTeamRole; import com.nect.core.entity.team.ProjectUser; import com.nect.core.entity.team.enums.ProjectMemberStatus; import com.nect.core.entity.team.enums.ProjectMemberType; -import com.nect.core.entity.team.process.ProjectTeamRole; import com.nect.core.entity.user.User; -import com.nect.core.repository.analysis.ProjectTeamRoleRepository; +import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.user.ProjectUserRepositoryComplete; import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; From a7c88330801a384975afa84b09b18f3deb9be79b Mon Sep 17 00:00:00 2001 From: infiniment Date: Sat, 7 Feb 2026 18:46:13 +0900 Subject: [PATCH 38/66] =?UTF-8?q?[Feat]=20=EA=B3=B5=EC=9C=A0=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=95=A8=20=EC=A1=B0=ED=9A=8C/=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20SharedDocument=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/team/file/service/FileService.java | 21 +-- .../controller/ProcessLinkController.java | 7 +- .../dto/req/ProcessLinkCreateReqDto.java | 4 +- .../team/process/dto/res/AttachmentDto.java | 39 ++++ .../process/dto/res/ProcessDetailResDto.java | 32 ---- .../res/ProcessLinkCreateAndAttachResDto.java | 14 ++ .../dto/res/WeekMissionDetailResDto.java | 3 + .../process/enums/AttachmentErrorCode.java | 4 +- .../team/process/enums/ProcessErrorCode.java | 1 + .../facade/ProcessAttachmentFacade.java | 38 ++-- .../service/ProcessAttachmentService.java | 85 ++++++--- .../team/process/service/ProcessService.java | 63 ++++--- .../process/service/WeekMissionService.java | 44 ++++- .../BoardsSharedDocumentController.java | 45 +++++ .../req/SharedDocumentNameUpdateReqDto.java | 16 ++ .../res/SharedDocumentNameUpdateResDto.java | 11 ++ .../dto/res/SharedDocumentsGetResDto.java | 77 ++++++++ .../team/workspace/enums/BoardsErrorCode.java | 5 +- .../workspace/enums/SharedDocumentsSort.java | 8 + .../facade/BoardsSharedDocumentFacade.java | 22 +++ .../service/BoardsSharedDocumentService.java | 176 ++++++++++++++++++ .../nect/core/entity/team/SharedDocument.java | 63 ++++++- .../core/entity/team/enums/DocumentType.java | 5 + .../team/history/enums/HistoryAction.java | 3 + .../nect/core/entity/team/process/Link.java | 54 ------ .../core/entity/team/process/Process.java | 18 +- .../team/ProjectUserRepository.java | 13 ++ .../team/SharedDocumentRepository.java | 19 ++ .../team/process/LinkRepository.java | 10 - .../team/process/ProcessRepository.java | 13 ++ .../ProcessSharedDocumentRepository.java | 24 +++ 31 files changed, 729 insertions(+), 208 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessLinkCreateAndAttachResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/SharedDocumentNameUpdateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentNameUpdateResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentsGetResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/workspace/enums/SharedDocumentsSort.java create mode 100644 nect-core/src/main/java/com/nect/core/entity/team/enums/DocumentType.java delete mode 100644 nect-core/src/main/java/com/nect/core/entity/team/process/Link.java delete mode 100644 nect-core/src/main/java/com/nect/core/repository/team/process/LinkRepository.java diff --git a/nect-api/src/main/java/com/nect/api/domain/team/file/service/FileService.java b/nect-api/src/main/java/com/nect/api/domain/team/file/service/FileService.java index bc38632c..e7d00508 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/file/service/FileService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/file/service/FileService.java @@ -110,20 +110,19 @@ public FileUploadResDto upload(Long projectId, Long userId, MultipartFile file) throw new FileException(FileErrorCode.FILE_UPLOAD_FAILED, "R2 upload failed", e); } - SharedDocument doc = SharedDocument.builder() - .createdBy(user) - .project(project) - .isPinned(false) - .title(originalName) - .description(null) - .fileName(originalName) - .fileExt(ext) - .fileUrl(fileKey) - .fileSize(fileSize) - .build(); + SharedDocument doc = SharedDocument.ofFile( + user, + project, + originalName, + originalName, + ext, + fileKey, + fileSize + ); SharedDocument saved = sharedDocumentRepository.save(doc); + String downloadUrl = s3Service.getPresignedGetUrl(saved.getFileUrl()); return new FileUploadResDto( diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/ProcessLinkController.java b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/ProcessLinkController.java index 18ced3a1..7c7ade25 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/ProcessLinkController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/ProcessLinkController.java @@ -1,7 +1,9 @@ package com.nect.api.domain.team.process.controller; import com.nect.api.domain.team.process.dto.req.ProcessLinkCreateReqDto; +import com.nect.api.domain.team.process.dto.res.ProcessLinkCreateAndAttachResDto; import com.nect.api.domain.team.process.dto.res.ProcessLinkCreateResDto; +import com.nect.api.domain.team.process.facade.ProcessAttachmentFacade; import com.nect.api.domain.team.process.service.ProcessAttachmentService; import com.nect.api.global.response.ApiResponse; import com.nect.api.global.security.UserDetailsImpl; @@ -15,16 +17,17 @@ public class ProcessLinkController { private final ProcessAttachmentService processAttachmentService; + private final ProcessAttachmentFacade processAttachmentFacade; @PostMapping - public ApiResponse create( + public ApiResponse create( @PathVariable Long projectId, @PathVariable Long processId, @AuthenticationPrincipal UserDetailsImpl userDetails, @RequestBody ProcessLinkCreateReqDto req ) { Long userId = userDetails.getUserId(); - return ApiResponse.ok(processAttachmentService.createLink(projectId, userId, processId, req)); + return ApiResponse.ok(processAttachmentFacade.createAndAttachLink(projectId, userId, processId, req)); } @DeleteMapping("/{linkId}") diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessLinkCreateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessLinkCreateReqDto.java index adcbb80f..dcd237b3 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessLinkCreateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessLinkCreateReqDto.java @@ -6,6 +6,6 @@ public record ProcessLinkCreateReqDto( @JsonProperty("title") String title, - @JsonProperty("url") - String url + @JsonProperty("link_url") + String linkUrl ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentDto.java new file mode 100644 index 00000000..50f80dbe --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentDto.java @@ -0,0 +1,39 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.api.domain.team.process.enums.AttachmentType; +import com.nect.core.entity.team.enums.FileExt; + +import java.time.LocalDateTime; + +public record AttachmentDto( + @JsonProperty("type") + AttachmentType type, + + // 공통 식별자(파일이면 file_id, 링크면 link_id) + @JsonProperty("id") + Long id, + + @JsonProperty("created_at") + LocalDateTime createdAt, + + // LINK 전용 + @JsonProperty("title") + String title, + + @JsonProperty("url") + String url, + + // FILE 전용 + @JsonProperty("file_name") + String fileName, + + @JsonProperty("file_url") + String fileUrl, + + @JsonProperty("file_type") + FileExt fileType, + + @JsonProperty("file_size") + Long fileSize +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessDetailResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessDetailResDto.java index deb6300b..99467970 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessDetailResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessDetailResDto.java @@ -71,36 +71,4 @@ public record ProcessDetailResDto( feedbacks = (feedbacks == null) ? List.of() : feedbacks; attachments = (attachments == null) ? List.of() : attachments; } - - public record AttachmentDto( - @JsonProperty("type") - AttachmentType type, - - // 공통 식별자(파일이면 file_id, 링크면 link_id) - @JsonProperty("id") - Long id, - - @JsonProperty("created_at") - LocalDateTime createdAt, - - // LINK 전용 - @JsonProperty("title") - String title, - - @JsonProperty("url") - String url, - - // FILE 전용 - @JsonProperty("file_name") - String fileName, - - @JsonProperty("file_url") - String fileUrl, - - @JsonProperty("file_type") - FileExt fileType, - - @JsonProperty("file_size") - Long fileSize - ) {} } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessLinkCreateAndAttachResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessLinkCreateAndAttachResDto.java new file mode 100644 index 00000000..b2c34c2a --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessLinkCreateAndAttachResDto.java @@ -0,0 +1,14 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record ProcessLinkCreateAndAttachResDto( + @JsonProperty("document_id") + Long documentId, + + @JsonProperty("title") + String title, + + @JsonProperty("url") + String url +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java index d758526f..5d0608e6 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/WeekMissionDetailResDto.java @@ -35,6 +35,9 @@ public record WeekMissionDetailResDto( @JsonProperty("assignee") AssigneeDto assignee, + @JsonProperty("attachments") + List attachments, + @JsonProperty("task_groups") List taskGroups, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/enums/AttachmentErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/team/process/enums/AttachmentErrorCode.java index bda8a556..9ab5ce9a 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/enums/AttachmentErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/enums/AttachmentErrorCode.java @@ -17,7 +17,9 @@ public enum AttachmentErrorCode implements ResponseCode { LINK_NOT_FOUND("A4044", "링크를 찾을 수 없습니다."), FILE_ALREADY_ATTACHED("A4091", "이미 첨부된 파일입니다."), - FILE_NOT_ATTACHED("A4045", "첨부되지 않은 파일입니다."); + LINK_ALREADY_ATTACHED("A4092", "이미 첨부된 링크입니다."), + FILE_NOT_ATTACHED("A4045", "첨부되지 않은 파일입니다."), + LINK_NOT_ATTACHED("A4046", "첨부되지 않은 링크입니다."); private final String statusCode; private final String message; diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/enums/ProcessErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/team/process/enums/ProcessErrorCode.java index 4b17f78e..33d8cf7d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/enums/ProcessErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/enums/ProcessErrorCode.java @@ -17,6 +17,7 @@ public enum ProcessErrorCode implements ResponseCode { FORBIDDEN("P4030", "해당 프로젝트에 대한 권한이 없습니다."), PROCESS_NOT_IN_PROJECT("P4031", "해당 프로젝트에 속한 프로세스가 아닙니다."), + WEEK_MISSION_FORBIDDEN("P4032", "위크 미션은 프로세스 상세 조회에서 조회할 수 없습니다."), PROJECT_NOT_FOUND("P4041", "프로젝트를 찾을 수 없습니다."), PROCESS_NOT_FOUND("P4042", "프로세스를 찾을 수 없습니다."), diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java index 6daecc76..909c3165 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/facade/ProcessAttachmentFacade.java @@ -1,35 +1,26 @@ package com.nect.api.domain.team.process.facade; -import com.nect.api.domain.notifications.command.NotificationCommand; -import com.nect.api.domain.notifications.facade.NotificationFacade; import com.nect.api.domain.team.file.dto.res.FileUploadResDto; import com.nect.api.domain.team.file.service.FileService; import com.nect.api.domain.team.process.dto.req.ProcessFileAttachReqDto; +import com.nect.api.domain.team.process.dto.req.ProcessLinkCreateReqDto; import com.nect.api.domain.team.process.dto.res.ProcessFileAttachResDto; import com.nect.api.domain.team.process.dto.res.ProcessFileUploadAndAttachResDto; +import com.nect.api.domain.team.process.dto.res.ProcessLinkCreateAndAttachResDto; import com.nect.api.domain.team.process.enums.ProcessErrorCode; import com.nect.api.domain.team.process.exception.ProcessException; import com.nect.api.domain.team.process.service.ProcessAttachmentService; -import com.nect.core.entity.notifications.enums.NotificationClassification; -import com.nect.core.entity.notifications.enums.NotificationScope; -import com.nect.core.entity.notifications.enums.NotificationType; -import com.nect.core.entity.team.Project; import com.nect.core.entity.team.enums.ProjectMemberStatus; import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.process.Process; import com.nect.core.entity.team.process.enums.ProcessType; -import com.nect.core.entity.user.User; -import com.nect.core.repository.team.ProjectRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.process.ProcessRepository; -import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.util.List; -import java.util.Objects; @Service @RequiredArgsConstructor @@ -82,5 +73,30 @@ public ProcessFileUploadAndAttachResDto uploadAndAttachFile(Long projectId, Long ); } + // 링크 첨부 + 공유 문서 저장 서비스 + @Transactional + public ProcessLinkCreateAndAttachResDto createAndAttachLink(Long projectId, Long userId, Long processId, ProcessLinkCreateReqDto req) { + Process process = processRepository.findByIdAndProjectIdAndDeletedAtIsNull(processId, projectId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.PROCESS_NOT_FOUND, "processId=" + processId)); + + // 권한 체크 (uploadAndAttachFile과 동일) + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + boolean isLeader = projectUserRepository.existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus( + projectId, userId, ProjectMemberType.LEADER, ProjectMemberStatus.ACTIVE + ); + if (!isLeader) throw new ProcessException(ProcessErrorCode.FORBIDDEN, "WEEK_MISSION은 리더만 링크 추가 가능"); + } else { + if (!projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus(projectId, userId, ProjectMemberStatus.ACTIVE)) { + throw new ProcessException(ProcessErrorCode.FORBIDDEN, "not active member"); + } + } + + // 서비스에서 SharedDocument(LINK) 생성 + attach 수행 + ProcessFileAttachResDto attached = processAttachmentService.createAndAttachLink(projectId, userId, processId, req); + + // 응답은 UI 필요에 따라 title/url 포함해서 내려도 됨 + return new ProcessLinkCreateAndAttachResDto(attached.fileId(), req.title().trim(), req.linkUrl().trim()); + } + } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java index fb0d2802..cad7ab02 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessAttachmentService.java @@ -1,24 +1,25 @@ package com.nect.api.domain.team.process.service; +import com.nect.api.domain.team.file.enums.FileErrorCode; +import com.nect.api.domain.team.file.exception.FileException; import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; import com.nect.api.domain.team.process.dto.req.ProcessFileAttachReqDto; import com.nect.api.domain.team.process.dto.req.ProcessLinkCreateReqDto; import com.nect.api.domain.team.process.dto.res.ProcessFileAttachResDto; -import com.nect.api.domain.team.process.dto.res.ProcessLinkCreateResDto; import com.nect.api.domain.team.process.enums.AttachmentErrorCode; import com.nect.api.domain.team.process.exception.AttachmentException; import com.nect.core.entity.team.SharedDocument; +import com.nect.core.entity.team.enums.DocumentType; import com.nect.core.entity.team.enums.ProjectMemberStatus; import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.history.enums.HistoryAction; import com.nect.core.entity.team.history.enums.HistoryTargetType; -import com.nect.core.entity.team.process.Link; import com.nect.core.entity.team.process.Process; import com.nect.core.entity.team.process.ProcessSharedDocument; import com.nect.core.entity.team.process.enums.ProcessType; +import com.nect.core.entity.user.User; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.SharedDocumentRepository; -import com.nect.core.repository.team.process.LinkRepository; import com.nect.core.repository.team.process.ProcessRepository; import com.nect.core.repository.team.process.ProcessSharedDocumentRepository; import lombok.RequiredArgsConstructor; @@ -37,7 +38,6 @@ public class ProcessAttachmentService { private final ProcessRepository processRepository; private final SharedDocumentRepository sharedDocumentRepository; private final ProcessSharedDocumentRepository processSharedDocumentRepository; - private final LinkRepository linkRepository; private final ProjectHistoryPublisher historyPublisher; @@ -66,7 +66,7 @@ private void validateFileAttachReq(ProcessFileAttachReqDto req) { } private void validateLinkCreateReq(ProcessLinkCreateReqDto req) { - if (req == null || req.url() == null || req.url().isBlank()) { + if (req == null || req.linkUrl() == null || req.linkUrl().isBlank()) { throw new AttachmentException(AttachmentErrorCode.INVALID_REQUEST, "url is required"); } @@ -107,6 +107,13 @@ public ProcessFileAttachResDto attachFile(Long projectId, Long userId, Long proc SharedDocument doc = getActiveDocument(projectId, req.fileId()); + if (doc.getDocumentType() != DocumentType.FILE) { + throw new AttachmentException( + AttachmentErrorCode.INVALID_REQUEST, + "file attach API only allows FILE documents. documentId=" + doc.getId() + ); + } + if (processSharedDocumentRepository.existsByProcessIdAndDocumentIdAndDeletedAtIsNull(process.getId(), doc.getId())) { throw new AttachmentException( AttachmentErrorCode.FILE_ALREADY_ATTACHED, @@ -171,67 +178,85 @@ public void detachFile(Long projectId, Long userId, Long processId, Long fileId) // 프로세스 링크 추가 서비스 @Transactional - public ProcessLinkCreateResDto createLink(Long projectId, Long userId, Long processId, ProcessLinkCreateReqDto req) { - validateLinkCreateReq(req); + public ProcessFileAttachResDto createLink(Long projectId, Long userId, Long processId, ProcessLinkCreateReqDto req) { + return createAndAttachLink(projectId, userId, processId, req); + } + // 프로세스 링크 삭제 서비스 + @Transactional + public void deleteLink(Long projectId, Long userId, Long processId, Long linkId) { Process process = getActiveProcess(projectId, processId); assertAttachmentPermission(projectId, userId, process); - Link link = Link.builder() - .process(process) - .title(req.title().trim()) - .url(req.url().trim()) - .build(); + ProcessSharedDocument psd = processSharedDocumentRepository + .findByProcessIdAndDocumentIdAndDeletedAtIsNull(process.getId(), linkId) + .orElseThrow(() -> new AttachmentException( + AttachmentErrorCode.LINK_NOT_ATTACHED, + "processId=" + processId + ", documentId=" + linkId + )); - Link saved = linkRepository.save(link); + psd.softDelete(); Map meta = new LinkedHashMap<>(); meta.put("processId", processId); - meta.put("linkId", saved.getId()); - meta.put("url", saved.getUrl()); - meta.put("title", saved.getTitle()); + meta.put("documentId", linkId); + meta.put("type", "LINK"); historyPublisher.publish( projectId, userId, - HistoryAction.LINK_ATTACHED, + HistoryAction.DOCUMENT_DETACHED, HistoryTargetType.PROCESS, processId, meta ); - return new ProcessLinkCreateResDto(saved.getId(), saved.getTitle(), saved.getUrl(), saved.getCreatedAt()); } - // 프로세스 링크 삭제 서비스 + // 프로세스 링크 추가 @Transactional - public void deleteLink(Long projectId, Long userId, Long processId, Long linkId) { + public ProcessFileAttachResDto createAndAttachLink(Long projectId, Long userId, Long processId, ProcessLinkCreateReqDto req) { + validateLinkCreateReq(req); + Process process = getActiveProcess(projectId, processId); assertAttachmentPermission(projectId, userId, process); - Link link = linkRepository.findByIdAndProcessIdAndDeletedAtIsNull(linkId, process.getId()) - .orElseThrow(() -> new AttachmentException( - AttachmentErrorCode.LINK_NOT_FOUND, - "linkId=" + linkId + ", processId=" + processId - )); - String beforeUrl = link.getUrl(); - link.softDelete(); + User user = projectUserRepository.findActiveUserByProjectIdAndUserId(projectId, userId) + .orElseThrow(() -> new FileException(FileErrorCode.FORBIDDEN, "not active member")); + + SharedDocument doc = SharedDocument.ofLink(user, process.getProject(), req.title().trim(), req.linkUrl().trim()); + SharedDocument saved = sharedDocumentRepository.save(doc); + + if (processSharedDocumentRepository.existsByProcessIdAndDocumentIdAndDeletedAtIsNull(process.getId(), saved.getId())) { + throw new AttachmentException(AttachmentErrorCode.FILE_ALREADY_ATTACHED, "processId=" + processId + ", documentId=" + saved.getId()); + } + + ProcessSharedDocument psd = ProcessSharedDocument.builder() + .process(process) + .document(saved) + .attachedAt(LocalDateTime.now()) + .build(); + + processSharedDocumentRepository.save(psd); Map meta = new LinkedHashMap<>(); meta.put("processId", processId); - meta.put("linkId", linkId); - meta.put("url", beforeUrl); + meta.put("documentId", saved.getId()); + meta.put("type", "LINK"); + meta.put("url", saved.getLinkUrl()); + meta.put("title", saved.getTitle()); historyPublisher.publish( projectId, userId, - HistoryAction.LINK_DETACHED, + HistoryAction.DOCUMENT_ATTACHED, HistoryTargetType.PROCESS, processId, meta ); + return new ProcessFileAttachResDto(saved.getId()); } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index 63c753df..54b1a269 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -14,6 +14,7 @@ import com.nect.core.entity.notifications.enums.NotificationScope; import com.nect.core.entity.notifications.enums.NotificationType; import com.nect.core.entity.team.ProjectUser; +import com.nect.core.entity.team.enums.DocumentType; import com.nect.core.entity.team.history.enums.HistoryAction; import com.nect.core.entity.team.history.enums.HistoryTargetType; import com.nect.core.entity.team.Project; @@ -22,6 +23,7 @@ import com.nect.core.entity.team.process.Process; import com.nect.core.entity.team.process.enums.AssignmentRole; import com.nect.core.entity.team.process.enums.ProcessStatus; +import com.nect.core.entity.team.process.enums.ProcessType; import com.nect.core.entity.user.User; import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.team.ProjectRepository; @@ -478,6 +480,8 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre List links = Optional.ofNullable(req.links()).orElse(List.of()); + List linkDocumentIds = new ArrayList<>(); + for (var l : links) { if (l == null) continue; @@ -491,12 +495,14 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre ); } - Link link = Link.builder() - .title(title) - .url(url) - .build(); + // LINK 문서 생성(공유문서함에 쌓임) + SharedDocument linkDoc = SharedDocument.ofLink(writer, project, title, url); + SharedDocument savedLinkDoc = sharedDocumentRepository.save(linkDoc); + + // 프로세스에 첨부(ProcessSharedDocument 생성) + process.attachDocument(savedLinkDoc); - process.addLink(link); + linkDocumentIds.add(savedLinkDoc.getId()); } @@ -640,6 +646,13 @@ public ProcessDetailResDto getProcessDetail(Long projectId, Long userId, Long pr "projectId=" + projectId + ", processId=" + processId )); + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + throw new ProcessException( + ProcessErrorCode.WEEK_MISSION_FORBIDDEN, + "projectId=" + projectId + ", processId=" + processId + ); + } + String dbLaneKey = toDbLaneKey(laneKey); // lane 기준 정렬값(status_order) 조회 @@ -665,18 +678,30 @@ public ProcessDetailResDto getProcessDetail(Long projectId, Long userId, Long pr )) .toList(); - List fileAttachments = + List attachments = process.getSharedDocuments().stream() .filter(psd -> psd.getDeletedAt() == null) .map(psd -> { SharedDocument doc = psd.getDocument(); - if (doc == null) return null; + if (doc == null || doc.getDeletedAt() != null) return null; - // 정렬 기준 시간: attachedAt 우선, 없으면 psd createdAt fallback LocalDateTime at = psd.getAttachedAt() != null ? psd.getAttachedAt() : psd.getCreatedAt(); - return new ProcessDetailResDto.AttachmentDto( + // documentType 기준으로 FILE/LINK 분기 + if (doc.getDocumentType() == DocumentType.LINK) { + return new AttachmentDto( + AttachmentType.LINK, + doc.getId(), + at, + doc.getTitle(), // 링크 표시명 + doc.getLinkUrl(), // 링크 URL + null, null, null, null + ); + } + + // FILE + return new AttachmentDto( AttachmentType.FILE, doc.getId(), at, @@ -688,27 +713,11 @@ public ProcessDetailResDto getProcessDetail(Long projectId, Long userId, Long pr ); }) .filter(Objects::nonNull) - .toList(); - - List linkAttachments = - process.getLinks().stream() - .filter(l -> l.getDeletedAt() == null) - .map(l -> new ProcessDetailResDto.AttachmentDto( - AttachmentType.LINK, - l.getId(), - l.getCreatedAt(), - l.getTitle(), - l.getUrl(), - null, null, null, null - )) - .toList(); - - List attachments = - Stream.concat(fileAttachments.stream(), linkAttachments.stream()) .filter(a -> a.createdAt() != null) - .sorted(Comparator.comparing(ProcessDetailResDto.AttachmentDto::createdAt).reversed()) + .sorted(Comparator.comparing(AttachmentDto::createdAt).reversed()) .toList(); + List mentionUserIds = process.getMentions().stream() .map(ProcessMention::getMentionedUserId) .filter(Objects::nonNull) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java index 30470bf9..ab87182e 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -5,10 +5,8 @@ import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; -import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; -import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; -import com.nect.api.domain.team.process.dto.res.WeekMissionDropdownResDto; -import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; +import com.nect.api.domain.team.process.dto.res.*; +import com.nect.api.domain.team.process.enums.AttachmentType; import com.nect.api.domain.team.process.enums.ProcessErrorCode; import com.nect.api.domain.team.process.exception.ProcessException; import com.nect.api.global.infra.S3Service; @@ -16,6 +14,8 @@ import com.nect.core.entity.notifications.enums.NotificationScope; import com.nect.core.entity.notifications.enums.NotificationType; import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.SharedDocument; +import com.nect.core.entity.team.enums.DocumentType; import com.nect.core.entity.team.enums.ProjectMemberStatus; import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.history.enums.HistoryAction; @@ -28,6 +28,7 @@ import com.nect.core.repository.team.ProjectRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.process.ProcessRepository; +import com.nect.core.repository.team.process.ProcessSharedDocumentRepository; import com.nect.core.repository.team.process.ProcessTaskItemRepository; import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; @@ -36,12 +37,10 @@ import java.time.DayOfWeek; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalAdjusters; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.stream.Collectors; @Service @@ -52,7 +51,7 @@ public class WeekMissionService { private final ProjectUserRepository projectUserRepository; private final ProcessRepository processRepository; private final ProcessTaskItemRepository processTaskItemRepository; - + private final ProcessSharedDocumentRepository processSharedDocumentRepository; private final UserRepository userRepository; private final NotificationFacade notificationFacade; private final ProjectHistoryPublisher historyPublisher; @@ -326,6 +325,8 @@ record GroupKey(RoleField roleField, String customName) {} profileUrl ); + List attachments = buildAttachments(processId); + // DTO 생성자 인자 순서 주의: (taskGroups, taskItems) 둘 다 넣기 return new WeekMissionDetailResDto( process.getId(), @@ -336,6 +337,7 @@ record GroupKey(RoleField roleField, String customName) {} process.getStartAt(), process.getEndAt(), assignee, + attachments, taskGroups, taskItems, process.getCreatedAt(), @@ -343,6 +345,30 @@ record GroupKey(RoleField roleField, String customName) {} ); } + private List buildAttachments(Long processId) { + return processSharedDocumentRepository.findAliveAttachmentsWithDoc(processId).stream() + .map(psd -> { + SharedDocument doc = psd.getDocument(); + LocalDateTime at = (psd.getAttachedAt() != null) ? psd.getAttachedAt() : psd.getCreatedAt(); + + if (doc.getDocumentType() == DocumentType.LINK) { + return new AttachmentDto( + AttachmentType.LINK, doc.getId(), at, + doc.getTitle(), doc.getLinkUrl(), + null, null, null, null + ); + } + return new AttachmentDto( + AttachmentType.FILE, doc.getId(), at, + null, null, + doc.getFileName(), doc.getFileUrl(), doc.getFileExt(), doc.getFileSize() + ); + }) + .sorted(Comparator.comparing(AttachmentDto::createdAt).reversed()) + .toList(); + } + + // 위크미션 TASK 프로세스 상태 변경 서비스 @Transactional public void updateWeekMissionStatus(Long projectId, Long userId, Long processId, WeekMissionStatusUpdateReqDto req) { diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java index c4e5fdbc..afbca4c1 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java @@ -1,9 +1,14 @@ package com.nect.api.domain.team.workspace.controller; +import com.nect.api.domain.team.workspace.dto.req.SharedDocumentNameUpdateReqDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentNameUpdateResDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsGetResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsPreviewResDto; +import com.nect.api.domain.team.workspace.enums.SharedDocumentsSort; import com.nect.api.domain.team.workspace.facade.BoardsSharedDocumentFacade; import com.nect.api.global.response.ApiResponse; import com.nect.api.global.security.UserDetailsImpl; +import com.nect.core.entity.team.enums.DocumentType; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -28,4 +33,44 @@ public ApiResponse getSharedDocumentsPreview( Long userId = userDetails.getUserId(); return ApiResponse.ok(facade.getPreview(projectId, userId, limit)); } + + + // 공유 문서함 조회 + @GetMapping("/shared-documents") + public ApiResponse getSharedDocuments( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) DocumentType type, + @RequestParam(defaultValue = "RECENT") SharedDocumentsSort sort + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(facade.getDocuments(projectId, userId, page, size, type, sort)); + } + + + // 문서 이름 수정 + @PatchMapping("/shared-documents/{documentId}/name") + public ApiResponse rename( + @PathVariable Long projectId, + @PathVariable Long documentId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody SharedDocumentNameUpdateReqDto req + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(facade.rename(projectId, userId, documentId, req)); + } + + // 문서 삭제 + @DeleteMapping("/shared-documents/{documentId}") + public ApiResponse deleteSharedDocument( + @PathVariable Long projectId, + @PathVariable Long documentId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + Long userId = userDetails.getUserId(); + facade.delete(projectId, userId, documentId); + return ApiResponse.ok(null); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/SharedDocumentNameUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/SharedDocumentNameUpdateReqDto.java new file mode 100644 index 00000000..74afcc08 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/SharedDocumentNameUpdateReqDto.java @@ -0,0 +1,16 @@ +package com.nect.api.domain.team.workspace.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record SharedDocumentNameUpdateReqDto( + @JsonProperty("title") + String title, + + @JsonProperty("name") + String name +) { + public String resolvedTitle() { + String v = (title != null) ? title : name; + return (v == null) ? null : v.trim(); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentNameUpdateResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentNameUpdateResDto.java new file mode 100644 index 00000000..44fde995 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentNameUpdateResDto.java @@ -0,0 +1,11 @@ +package com.nect.api.domain.team.workspace.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record SharedDocumentNameUpdateResDto( + @JsonProperty("document_id") + Long documentId, + + @JsonProperty("title") + String title +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentsGetResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentsGetResDto.java new file mode 100644 index 00000000..0c60b87b --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentsGetResDto.java @@ -0,0 +1,77 @@ +package com.nect.api.domain.team.workspace.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.enums.FileExt; + +import java.time.LocalDateTime; +import java.util.List; + +public record SharedDocumentsGetResDto( + @JsonProperty("page") + int page, + + @JsonProperty("size") + int size, + + @JsonProperty("total_elements") + long totalElements, + + @JsonProperty("total_pages") + int totalPages, + + @JsonProperty("documents") + List documents +) { + public record DocumentDto( + @JsonProperty("document_id") + Long documentId, + + @JsonProperty("is_pinned") + boolean isPinned, + + @JsonProperty("document_type") + DocumentType documentType, + + @JsonProperty("title") + String title, + + + @JsonProperty("file_name") + String fileName, + + @JsonProperty("file_ext") + FileExt fileExt, + + // FILE: fileKey, LINK: null + @JsonProperty("file_url") + String fileUrl, + + // LINK: url, FILE: null + @JsonProperty("link_url") + String linkUrl, + + @JsonProperty("file_size") + Long fileSize, + + @JsonProperty("created_at") + LocalDateTime createdAt, + + @JsonProperty("uploader") + UploaderDto uploader + ) {} + + public record UploaderDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("name") + String name, + + @JsonProperty("nickname") + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/enums/BoardsErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/enums/BoardsErrorCode.java index f61ef6c8..3e6350fb 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/enums/BoardsErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/enums/BoardsErrorCode.java @@ -14,8 +14,9 @@ public enum BoardsErrorCode implements ResponseCode { PROJECT_NOT_FOUND("B4041", "프로젝트를 찾을 수 없습니다."), PROJECT_MEMBER_NOT_FOUND("B4042", "프로젝트 멤버를 찾을 수 없습니다."), - USER_NOT_FOUND("B4043", "사용자를 찾을 수 없습니다."); + USER_NOT_FOUND("B4043", "사용자를 찾을 수 없습니다."), + DOCUMENT_NOT_FOUND("B4044", "문서를 찾을 수 없습니다."); private final String statusCode; private final String message; -} \ No newline at end of file +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/enums/SharedDocumentsSort.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/enums/SharedDocumentsSort.java new file mode 100644 index 00000000..3266fc24 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/enums/SharedDocumentsSort.java @@ -0,0 +1,8 @@ +package com.nect.api.domain.team.workspace.enums; + +public enum SharedDocumentsSort { + RECENT, // 최신순 + OLDEST, // 오래된순 + NAME, // 이름순 + FORMAT // 파일 형식순 +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsSharedDocumentFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsSharedDocumentFacade.java index 87d96193..5ea6fb73 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsSharedDocumentFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsSharedDocumentFacade.java @@ -1,7 +1,12 @@ package com.nect.api.domain.team.workspace.facade; +import com.nect.api.domain.team.workspace.dto.req.SharedDocumentNameUpdateReqDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentNameUpdateResDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsGetResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsPreviewResDto; +import com.nect.api.domain.team.workspace.enums.SharedDocumentsSort; import com.nect.api.domain.team.workspace.service.BoardsSharedDocumentService; +import com.nect.core.entity.team.enums.DocumentType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -14,4 +19,21 @@ public class BoardsSharedDocumentFacade { public SharedDocumentsPreviewResDto getPreview(Long projectId, Long userId, int limit) { return service.getPreview(projectId, userId, limit); } + + public SharedDocumentsGetResDto getDocuments( + Long projectId, Long userId, int page, int size, DocumentType type, SharedDocumentsSort sort + ) { + return service.getDocuments(projectId, userId, page, size, type, sort); + } + + public SharedDocumentNameUpdateResDto rename( + Long projectId, Long userId, Long documentId, SharedDocumentNameUpdateReqDto req + ) { + return service.rename(projectId, userId, documentId, req); + } + + public void delete(Long projectId, Long userId, Long documentId) { + service.delete(projectId, userId, documentId); + } + } \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java index be2e2b3e..a58812d5 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java @@ -1,21 +1,35 @@ package com.nect.api.domain.team.workspace.service; +import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; +import com.nect.api.domain.team.workspace.dto.req.SharedDocumentNameUpdateReqDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentNameUpdateResDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsGetResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsPreviewResDto; import com.nect.api.domain.team.workspace.enums.BoardsErrorCode; +import com.nect.api.domain.team.workspace.enums.SharedDocumentsSort; import com.nect.api.domain.team.workspace.exception.BoardsException; import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.Project; import com.nect.core.entity.team.SharedDocument; +import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.history.enums.HistoryAction; +import com.nect.core.entity.team.history.enums.HistoryTargetType; import com.nect.core.entity.user.User; import com.nect.core.repository.team.ProjectRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.SharedDocumentRepository; +import com.nect.core.repository.team.process.ProcessSharedDocumentRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -24,7 +38,9 @@ public class BoardsSharedDocumentService { private final ProjectRepository projectRepository; private final ProjectUserRepository projectUserRepository; private final SharedDocumentRepository sharedDocumentRepository; + private final ProcessSharedDocumentRepository processSharedDocumentRepository; private final S3Service s3Service; + private final ProjectHistoryPublisher historyPublisher; private String toPresignedUserImage(String fileKey) { if (fileKey == null || fileKey.isBlank()) return null; @@ -82,4 +98,164 @@ public SharedDocumentsPreviewResDto getPreview(Long projectId, Long userId, int return new SharedDocumentsPreviewResDto(result); } + + // 공유 문서함 조회 + @Transactional(readOnly = true) + public SharedDocumentsGetResDto getDocuments(Long projectId, Long userId, + int page, int size, + DocumentType type, + SharedDocumentsSort sort) { + + projectRepository.findById(projectId) + .orElseThrow(() -> new BoardsException(BoardsErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId)); + + if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { + throw new BoardsException(BoardsErrorCode.PROJECT_MEMBER_FORBIDDEN, + "projectId=" + projectId + ", userId=" + userId); + } + + int p = Math.max(0, page); + int s = Math.max(1, Math.min(size, 50)); + + Sort sortSpec = buildSort(sort); + PageRequest pr = PageRequest.of(p, s, sortSpec); + + Page docsPage = (type == null) + ? sharedDocumentRepository.findAllActiveByProjectId(projectId, pr) + : sharedDocumentRepository.findAllActiveByProjectIdAndType(projectId, type, pr); + + var docs = docsPage.getContent().stream().map(d -> { + User u = d.getCreatedBy(); + String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); + return new SharedDocumentsGetResDto.DocumentDto( + d.getId(), + d.isPinned(), + d.getDocumentType(), + d.getTitle(), + d.getFileName(), + d.getFileExt(), + d.getFileUrl(), + d.getLinkUrl(), + d.getFileSize(), + d.getCreatedAt(), + new SharedDocumentsGetResDto.UploaderDto( + u.getUserId(), + u.getName(), + u.getNickname(), + profileUrl + ) + ); + }).toList(); + + return new SharedDocumentsGetResDto( + docsPage.getNumber(), + docsPage.getSize(), + docsPage.getTotalElements(), + docsPage.getTotalPages(), + docs + ); + } + + private Sort buildSort(SharedDocumentsSort sort) { + Sort pinnedFirst = Sort.by("isPinned").descending(); + + return switch (sort == null ? SharedDocumentsSort.RECENT : sort) { + case OLDEST -> pinnedFirst + .and(Sort.by("createdAt").ascending()) + .and(Sort.by("id").ascending()); + + case NAME -> pinnedFirst + .and(Sort.by("title").ascending()) + .and(Sort.by("id").descending()); + + case FORMAT -> pinnedFirst + .and(Sort.by("documentType").ascending()) // FILE/LINK 묶음 정렬 안정화 + .and(Sort.by("fileExt").ascending()) + .and(Sort.by("title").ascending()) + .and(Sort.by("id").descending()); + + default -> pinnedFirst + .and(Sort.by("createdAt").descending()) + .and(Sort.by("id").descending()); + }; + } + + // 이름 변경 서비스 + @Transactional + public SharedDocumentNameUpdateResDto rename(Long projectId, Long userId, Long documentId, SharedDocumentNameUpdateReqDto req) { + + String after = (req == null) ? null : req.resolvedTitle(); + if (after == null || after.isBlank()) { + throw new BoardsException(BoardsErrorCode.INVALID_REQUEST, "title is required"); + } + + if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { + throw new BoardsException(BoardsErrorCode.PROJECT_MEMBER_FORBIDDEN, + "projectId=" + projectId + ", userId=" + userId); + } + + SharedDocument doc = sharedDocumentRepository.findByIdAndProjectIdAndDeletedAtIsNull(documentId, projectId) + .orElseThrow(() -> new BoardsException(BoardsErrorCode.DOCUMENT_NOT_FOUND, "documentId=" + documentId)); + + String before = doc.getTitle(); + + doc.updateTitle(after); + + Map meta = new LinkedHashMap<>(); + meta.put("documentId", doc.getId()); + meta.put("beforeTitle", before); + meta.put("afterTitle", after); + meta.put("documentType", doc.getDocumentType().name()); + if (doc.getDocumentType() == DocumentType.LINK) meta.put("url", doc.getLinkUrl()); + if (doc.getDocumentType() == DocumentType.FILE) meta.put("fileExt", doc.getFileExt()); + + historyPublisher.publish( + projectId, + userId, + HistoryAction.DOCUMENT_RENAMED, + HistoryTargetType.DOCUMENT, + doc.getId(), + meta + ); + + return new SharedDocumentNameUpdateResDto(doc.getId(), doc.getTitle()); + } + + // 문서 삭제 서비스 + @Transactional + public void delete(Long projectId, Long userId, Long documentId) { + + if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { + throw new BoardsException(BoardsErrorCode.PROJECT_MEMBER_FORBIDDEN, + "projectId=" + projectId + ", userId=" + userId); + } + + SharedDocument doc = sharedDocumentRepository.findByIdAndProjectIdAndDeletedAtIsNull(documentId, projectId) + .orElseThrow(() -> new BoardsException(BoardsErrorCode.DOCUMENT_NOT_FOUND, "documentId=" + documentId)); + + // meta 만들기 (삭제 전에 정보 확보) + Map meta = new LinkedHashMap<>(); + meta.put("documentId", doc.getId()); + meta.put("title", doc.getTitle()); + meta.put("documentType", doc.getDocumentType().name()); + if (doc.getDocumentType() == DocumentType.LINK) meta.put("url", doc.getLinkUrl()); + if (doc.getDocumentType() == DocumentType.FILE) meta.put("fileExt", doc.getFileExt()); + meta.put("deletedAt", LocalDateTime.now().toString()); + + // 문서 삭제 + doc.softDelete(); + + // 문서가 삭제되면 프로세스 첨부 목록에도 안 보이게 + int detachedCount = processSharedDocumentRepository.softDeleteAllAttachments(projectId, documentId); + meta.put("detachedFromProcesses", detachedCount); + + historyPublisher.publish( + projectId, + userId, + HistoryAction.DOCUMENT_DELETED, + HistoryTargetType.DOCUMENT, + doc.getId(), + meta + ); + } } \ No newline at end of file diff --git a/nect-core/src/main/java/com/nect/core/entity/team/SharedDocument.java b/nect-core/src/main/java/com/nect/core/entity/team/SharedDocument.java index 8e20de16..d28d9b74 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/SharedDocument.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/SharedDocument.java @@ -1,6 +1,7 @@ package com.nect.core.entity.team; import com.nect.core.entity.BaseEntity; +import com.nect.core.entity.team.enums.DocumentType; import com.nect.core.entity.team.enums.FileExt; import com.nect.core.entity.user.User; import jakarta.persistence.*; @@ -28,6 +29,13 @@ public class SharedDocument extends BaseEntity { @JoinColumn(name = "project_id", nullable = false) private Project project; + @Enumerated(EnumType.STRING) + @Column(name = "document_type", nullable = false) + private DocumentType documentType; + + @Column(name = "link_url") + private String linkUrl; + @Column(name = "is_pinned", nullable = false) private boolean isPinned; @@ -41,14 +49,14 @@ public class SharedDocument extends BaseEntity { private String fileName; @Enumerated(EnumType.STRING) - @Column(name = "file_ext", length = 10, nullable = false) + @Column(name = "file_ext", length = 10) private FileExt fileExt; - @Column(name = "file_url", nullable = false, columnDefinition = "TEXT") + @Column(name = "file_url", columnDefinition = "TEXT") private String fileUrl; - @Column(name = "file_size", nullable = false) - private Long fileSize; + @Column(name = "file_size") + private Long fileSize = 0L; @Column(name = "deleted_at") private LocalDateTime deletedAt; @@ -57,15 +65,20 @@ public class SharedDocument extends BaseEntity { private SharedDocument( User createdBy, Project project, + DocumentType documentType, + String linkUrl, boolean isPinned, String title, String description, String fileName, FileExt fileExt, String fileUrl, - Long fileSize) { + Long fileSize + ) { this.createdBy = createdBy; this.project = project; + this.documentType = documentType; + this.linkUrl = linkUrl; this.isPinned = isPinned; this.title = title; this.description = description; @@ -83,4 +96,44 @@ public void unpin() { this.isPinned = false; } + public static SharedDocument ofFile(User user, Project project, String title, String fileName, FileExt ext, String fileKey, Long size) { + return SharedDocument.builder() + .createdBy(user) + .project(project) + .documentType(DocumentType.FILE) + .isPinned(false) + .title(title) + .fileName(fileName) + .fileExt(ext) + .fileUrl(fileKey) + .fileSize(size) + .linkUrl(null) + .description(null) + .build(); + } + + public static SharedDocument ofLink(User user, Project project, String title, String url) { + return SharedDocument.builder() + .createdBy(user) + .project(project) + .documentType(DocumentType.LINK) + .isPinned(false) + .title(title) + .fileName(null) + .linkUrl(url) + .fileExt(null) + .fileUrl(null) + .fileSize(0L) + .description(null) + .build(); + } + + public void updateTitle(String title) { + this.title = title; + } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } + } diff --git a/nect-core/src/main/java/com/nect/core/entity/team/enums/DocumentType.java b/nect-core/src/main/java/com/nect/core/entity/team/enums/DocumentType.java new file mode 100644 index 00000000..3d07b166 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/entity/team/enums/DocumentType.java @@ -0,0 +1,5 @@ +package com.nect.core.entity.team.enums; + +public enum DocumentType { + FILE, LINK +} diff --git a/nect-core/src/main/java/com/nect/core/entity/team/history/enums/HistoryAction.java b/nect-core/src/main/java/com/nect/core/entity/team/history/enums/HistoryAction.java index be2ab850..1d27fddb 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/history/enums/HistoryAction.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/history/enums/HistoryAction.java @@ -24,6 +24,9 @@ public enum HistoryAction { LINK_ATTACHED, LINK_DETACHED, + DOCUMENT_RENAMED, + DOCUMENT_DELETED, + POST_CREATED, POST_UPDATED, POST_DELETED, diff --git a/nect-core/src/main/java/com/nect/core/entity/team/process/Link.java b/nect-core/src/main/java/com/nect/core/entity/team/process/Link.java deleted file mode 100644 index 157e8ab7..00000000 --- a/nect-core/src/main/java/com/nect/core/entity/team/process/Link.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.nect.core.entity.team.process; - -import com.nect.core.entity.BaseEntity; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "link") -public class Link extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name="process_id", nullable=false) - private Process process; - - @Column(name="title", length=50, nullable=false) - private String title; - - @Column(name = "url", nullable = false, columnDefinition = "TEXT") - private String url; - - @Column(name = "deleted_at") - private LocalDateTime deletedAt; - - @Builder - public Link(Process process, String title, String url) { - this.process = process; - this.title = title; - this.url = url; - } - - void setProcess(Process process) { - this.process = process; - } - - - public void softDelete() { - if (this.deletedAt != null) return; - this.deletedAt = LocalDateTime.now(); - } - - public boolean isDeleted() { - return this.deletedAt != null; - } -} diff --git a/nect-core/src/main/java/com/nect/core/entity/team/process/Process.java b/nect-core/src/main/java/com/nect/core/entity/team/process/Process.java index 2cf91a7c..c2ef2888 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/process/Process.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/process/Process.java @@ -72,11 +72,6 @@ public class Process extends BaseEntity { @BatchSize(size = 100) private final List taskItems = new ArrayList<>(); - @OneToMany(mappedBy = "process", cascade = CascadeType.ALL) - @SQLRestriction("deleted_at is null") - @BatchSize(size = 100) - private final List links = new ArrayList<>(); - @OneToMany(mappedBy = "process", cascade = CascadeType.ALL) @SQLRestriction("deleted_at is null") @BatchSize(size = 100) @@ -121,16 +116,20 @@ public void attachDocument(SharedDocument doc) { if (doc == null || doc.getId() == null) { throw new IllegalArgumentException("문서 객체 또는 문서 ID는 null일 수 없습니다."); } + if (doc.getDeletedAt() != null) { + throw new IllegalArgumentException("삭제된 문서는 첨부할 수 없습니다. documentId=" + doc.getId()); + } boolean exists = sharedDocuments.stream() .anyMatch(psd -> - psd.getDocument() != null + psd.getDeletedAt() == null + && psd.getDocument() != null && psd.getDocument().getId() != null && psd.getDocument().getId().equals(doc.getId()) ); if (exists) { - throw new IllegalStateException("해당 문서는 이미 첨부되었습니다. documentId = " + doc.getId()); + throw new IllegalStateException("해당 문서는 이미 첨부되었습니다. documentId=" + doc.getId()); } ProcessSharedDocument psd = ProcessSharedDocument.builder() @@ -147,10 +146,6 @@ public void addTaskItem(ProcessTaskItem item) { this.taskItems.add(item); } - public void addLink(Link link) { - link.setProcess(this); - this.links.add(link); - } public void addProcessUser(ProcessUser pu) { pu.setProcess(this); @@ -227,7 +222,6 @@ public void softDeleteCascade() { this.taskItems.forEach(ProcessTaskItem::softDelete); this.feedbacks.forEach(ProcessFeedback::softDelete); - this.links.forEach(Link::softDelete); this.sharedDocuments.forEach(ProcessSharedDocument::softDelete); this.processFields.forEach(ProcessField::softDelete); this.processUsers.forEach(ProcessUser::delete); diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java index 421955a7..9d10a765 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java @@ -253,6 +253,19 @@ interface ProjectLeaderProfileRow { """) Optional findActiveLeaderProfile(@Param("projectId") Long projectId); + @Query(""" + SELECT u + FROM User u + JOIN ProjectUser pu ON u.userId = pu.userId + WHERE pu.project.id = :projectId + AND pu.userId = :userId + AND pu.memberStatus = com.nect.core.entity.team.enums.ProjectMemberStatus.ACTIVE + """) + Optional findActiveUserByProjectIdAndUserId( + @Param("projectId") Long projectId, + @Param("userId") Long userId + ); + } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/SharedDocumentRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/SharedDocumentRepository.java index 71b8095f..06badaf7 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/SharedDocumentRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/SharedDocumentRepository.java @@ -1,6 +1,8 @@ package com.nect.core.repository.team; import com.nect.core.entity.team.SharedDocument; +import com.nect.core.entity.team.enums.DocumentType; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -22,4 +24,21 @@ public interface SharedDocumentRepository extends JpaRepository findPreviewByProjectId(Long projectId, Pageable pageable); + + @Query(""" + SELECT d + FROM SharedDocument d + WHERE d.project.id = :projectId + AND d.deletedAt IS NULL + """) + Page findAllActiveByProjectId(Long projectId, Pageable pageable); + + @Query(""" + SELECT d + FROM SharedDocument d + WHERE d.project.id = :projectId + AND d.deletedAt IS NULL + AND d.documentType = :type + """) + Page findAllActiveByProjectIdAndType(Long projectId, DocumentType type, Pageable pageable); } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/LinkRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/LinkRepository.java deleted file mode 100644 index 9192b1ab..00000000 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/LinkRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.nect.core.repository.team.process; - -import com.nect.core.entity.team.process.Link; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface LinkRepository extends JpaRepository { - Optional findByIdAndProcessIdAndDeletedAtIsNull(Long id, Long processId); -} \ No newline at end of file diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index ff824587..4f443e23 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -603,5 +603,18 @@ interface WeekMissionRangeRow { LocalDate getEndDate(); } + @Query(""" + select p + from Process p + where p.id = :processId + and p.project.id = :projectId + and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) + """) + Optional findGeneralDetail( + @Param("projectId") Long projectId, + @Param("processId") Long processId + ); + } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java index 9e3dbf30..0af622df 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java @@ -2,10 +2,34 @@ import com.nect.core.entity.team.process.ProcessSharedDocument; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface ProcessSharedDocumentRepository extends JpaRepository { Optional findByProcessIdAndDocumentIdAndDeletedAtIsNull(Long processId, Long documentId); boolean existsByProcessIdAndDocumentIdAndDeletedAtIsNull(Long processId, Long documentId); + + @Modifying + @Query(""" + update ProcessSharedDocument psd + set psd.deletedAt = CURRENT_TIMESTAMP + where psd.process.project.id = :projectId + and psd.document.id = :documentId + and psd.deletedAt is null + """) + int softDeleteAllAttachments(@Param("projectId") Long projectId, @Param("documentId") Long documentId); + + @Query(""" + select psd + from ProcessSharedDocument psd + join fetch psd.document d + where psd.process.id = :processId + and psd.deletedAt is null + and d.deletedAt is null + """) + List findAliveAttachmentsWithDoc(@Param("processId") Long processId); } From 9a388f491b101cce9ab390cec93d86b8587109dd Mon Sep 17 00:00:00 2001 From: infiniment Date: Sat, 7 Feb 2026 18:46:54 +0900 Subject: [PATCH 39/66] =?UTF-8?q?[Test]=20=EC=B2=A8=EB=B6=80=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ProcessControllerTest.java | 4 +- .../controller/ProcessLinkControllerTest.java | 22 ++- .../BoardsSharedDocumentControllerTest.java | 173 ++++++++++++++++++ 3 files changed, 188 insertions(+), 11 deletions(-) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java index a890e087..d6d77f3c 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java @@ -292,7 +292,7 @@ void getProcessDetail() throws Exception { // attachments (FILE + LINK 통합) List.of( - new ProcessDetailResDto.AttachmentDto( + new AttachmentDto( com.nect.api.domain.team.process.enums.AttachmentType.FILE, 1001L, LocalDateTime.of(2026, 1, 23, 12, 0), @@ -302,7 +302,7 @@ void getProcessDetail() throws Exception { FileExt.PDF, 1024L ), - new ProcessDetailResDto.AttachmentDto( + new AttachmentDto( com.nect.api.domain.team.process.enums.AttachmentType.LINK, 2001L, LocalDateTime.of(2026, 1, 22, 9, 0), diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessLinkControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessLinkControllerTest.java index 06dc2de0..cb0f12a8 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessLinkControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessLinkControllerTest.java @@ -4,7 +4,9 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.fasterxml.jackson.databind.ObjectMapper; import com.nect.api.domain.team.process.dto.req.ProcessLinkCreateReqDto; +import com.nect.api.domain.team.process.dto.res.ProcessLinkCreateAndAttachResDto; import com.nect.api.domain.team.process.dto.res.ProcessLinkCreateResDto; +import com.nect.api.domain.team.process.facade.ProcessAttachmentFacade; import com.nect.api.domain.team.process.service.ProcessAttachmentService; import com.nect.api.global.jwt.JwtUtil; import com.nect.api.global.jwt.service.TokenBlacklistService; @@ -17,6 +19,7 @@ import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.FieldDescriptor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -65,6 +68,9 @@ class ProcessLinkControllerTest { @MockitoBean private ProcessAttachmentService processAttachmentService; + @MockitoBean + private ProcessAttachmentFacade processAttachmentFacade; + @MockitoBean private JwtUtil jwtUtil; @@ -114,14 +120,13 @@ void createLink() throws Exception { "https://example.com" ); - ProcessLinkCreateResDto response = new ProcessLinkCreateResDto( + ProcessLinkCreateAndAttachResDto response = new ProcessLinkCreateAndAttachResDto( 100L, "예시 링크", - "https://example.com", - LocalDateTime.of(2026, 1, 25, 10, 0, 0) + "https://example.com" ); - given(processAttachmentService.createLink(eq(projectId), eq(userId), eq(processId), any(ProcessLinkCreateReqDto.class))) + given(processAttachmentFacade.createAndAttachLink(eq(projectId), eq(userId), eq(processId), any(ProcessLinkCreateReqDto.class))) .willReturn(response); mockMvc.perform(post("/api/v1/projects/{projectId}/processes/{processId}/links", projectId, processId) @@ -137,7 +142,7 @@ void createLink() throws Exception { ResourceSnippetParameters.builder() .tag("Process-Attachment") .summary("링크 추가") - .description("프로세스(카드)에 링크를 추가합니다.") + .description("프로세스(카드)에 링크를 추가하며, 공유 문서함에 LINK 문서로 저장됩니다.") .pathParameters( ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), ResourceDocumentation.parameterWithName("processId").description("프로세스 ID") @@ -156,16 +161,15 @@ void createLink() throws Exception { fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), fieldWithPath("body").type(OBJECT).description("응답 바디"), - fieldWithPath("body.link_id").type(NUMBER).description("링크 ID"), + fieldWithPath("body.document_id").type(NUMBER).description("생성된 공유문서(document) ID"), fieldWithPath("body.title").type(STRING).description("링크 제목"), - fieldWithPath("body.url").type(STRING).description("링크 URL"), - fieldWithPath("body.created_at").type(STRING).description("생성일시(ISO-8601)") + fieldWithPath("body.url").type(STRING).description("링크 URL") ) .build() ) )); - verify(processAttachmentService).createLink(eq(projectId), eq(userId), eq(processId), any(ProcessLinkCreateReqDto.class)); + verify(processAttachmentFacade).createAndAttachLink(eq(projectId), eq(userId), eq(processId), any(ProcessLinkCreateReqDto.class)); } diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java index 03c090f5..1c4d7762 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java @@ -3,12 +3,17 @@ import com.epages.restdocs.apispec.ResourceDocumentation; import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.workspace.dto.req.SharedDocumentNameUpdateReqDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentNameUpdateResDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsGetResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsPreviewResDto; +import com.nect.api.domain.team.workspace.enums.SharedDocumentsSort; import com.nect.api.domain.team.workspace.facade.BoardsSharedDocumentFacade; import com.nect.api.global.jwt.JwtUtil; import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.team.enums.DocumentType; import com.nect.core.entity.team.enums.FileExt; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -37,6 +42,8 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; @@ -197,4 +204,170 @@ void getSharedDocumentsPreview() throws Exception { verify(facade).getPreview(eq(projectId), eq(userId), eq(limit)); } + + @Test + @DisplayName("공유 문서함 조회") + void getSharedDocuments() throws Exception { + long projectId = 1L; + long userId = 1L; + + int page = 0; + int size = 20; + DocumentType type = DocumentType.FILE; + SharedDocumentsSort sort = SharedDocumentsSort.RECENT; + + // SharedDocumentsGetResDto 구조를 여기서 정확히 모르므로 mock으로 대체 (body는 {} 로 직렬화) + SharedDocumentsGetResDto response = org.mockito.Mockito.mock(SharedDocumentsGetResDto.class); + + given(facade.getDocuments(eq(projectId), eq(userId), eq(page), eq(size), eq(type), eq(sort))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/boards/shared-documents", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size)) + .param("type", type.name()) + .param("sort", sort.name()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("boards-shared-documents-get", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Boards") + .summary("공유 문서함 조회") + .description("공유 문서함 목록을 조회합니다. (페이징/타입/정렬 지원)") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .queryParameters( + parameterWithName("page").optional().description("페이지 번호(기본 0)"), + parameterWithName("size").optional().description("페이지 크기(기본 20)"), + parameterWithName("type").optional().description("문서 타입(FILE/LINK)"), + parameterWithName("sort").optional().description("정렬 기준(기본 RECENT)") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.page").type(NUMBER).description("현재 페이지"), + fieldWithPath("body.size").type(NUMBER).description("페이지 크기"), + fieldWithPath("body.total_elements").type(NUMBER).description("전체 요소 수"), + fieldWithPath("body.total_pages").type(NUMBER).description("전체 페이지 수"), + fieldWithPath("body.documents").type(ARRAY).description("문서 목록") + ) + .build() + ) + )); + + verify(facade).getDocuments(eq(projectId), eq(userId), eq(page), eq(size), eq(type), eq(sort)); + } + + @Test + @DisplayName("문서 이름(표시명) 수정") + void renameSharedDocument() throws Exception { + long projectId = 1L; + long userId = 1L; + long documentId = 100L; + + // 요청: title 기준(하위호환이 필요하면 name도 같이 넣어도 됨) + SharedDocumentNameUpdateReqDto req = new SharedDocumentNameUpdateReqDto("새 문서 제목", null); + + SharedDocumentNameUpdateResDto res = new SharedDocumentNameUpdateResDto(documentId, "새 문서 제목"); + + given(facade.rename(eq(projectId), eq(userId), eq(documentId), any(SharedDocumentNameUpdateReqDto.class))) + .willReturn(res); + + mockMvc.perform(patch("/api/v1/projects/{projectId}/boards/shared-documents/{documentId}/name", projectId, documentId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("boards-shared-documents-rename", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Boards") + .summary("문서 표시명(title) 변경") + .description("공유 문서의 표시명(title)을 변경합니다. (하위호환: name도 지원 가능)") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID"), + parameterWithName("documentId").description("문서 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("title").optional().type(STRING).description("변경할 표시명(title)"), + fieldWithPath("name").optional().type(STRING).description("하위호환용 필드(name) - title이 없을 때 사용") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.document_id").type(NUMBER).description("문서 ID"), + fieldWithPath("body.title").type(STRING).description("변경된 표시명(title)") + ) + .build() + ) + )); + + verify(facade).rename(eq(projectId), eq(userId), eq(documentId), any(SharedDocumentNameUpdateReqDto.class)); + } + + @Test + @DisplayName("문서 삭제") + void deleteSharedDocument() throws Exception { + long projectId = 1L; + long userId = 1L; + long documentId = 200L; + + doNothing().when(facade).delete(eq(projectId), eq(userId), eq(documentId)); + + mockMvc.perform(delete("/api/v1/projects/{projectId}/boards/shared-documents/{documentId}", projectId, documentId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("boards-shared-documents-delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Boards") + .summary("문서 삭제") + .description("공유 문서를 삭제(소프트 삭제)합니다.") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID"), + parameterWithName("documentId").description("문서 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명") + ) + .build() + ) + )); + + verify(facade).delete(eq(projectId), eq(userId), eq(documentId)); + } } From 584cd1d0e4b022474518a36db0a1a8fd7c625b77 Mon Sep 17 00:00:00 2001 From: infiniment Date: Mon, 2 Feb 2026 00:05:09 +0900 Subject: [PATCH 40/66] =?UTF-8?q?[Feat]=20=ED=8C=8C=ED=8A=B8=EB=B3=84=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=ED=98=84=ED=99=A9=20=ED=8C=8C=ED=8A=B8?= =?UTF-8?q?=EB=B3=84=20=EC=9E=91=EC=97=85=20=EC=A7=84=ED=96=89=EB=A5=A0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java # Conflicts: # nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java --- .../nect/api/domain/team/process/service/ProcessService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index 63c753df..056dc13c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -365,7 +365,7 @@ private void notifyWorkspaceMention(Project project, User actor, Long targetProc NotificationCommand command = new NotificationCommand( NotificationType.WORKSPACE_MENTIONED, NotificationClassification.WORK_STATUS, - NotificationScope.WORKSPACE_ONLY, + NotificationScope.WORKSPACE_GLOBAL, targetProcessId, new Object[]{ actor.getName() }, new Object[]{ content }, @@ -566,7 +566,7 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre meta.put("startAt", saved.getStartAt()); meta.put("endAt", saved.getEndAt()); meta.put("roleFields", roleFields); - meta.put("customFieldName", roleFields.contains(RoleField.CUSTOM) ? customName : null); + meta.put("customFieldName", roleFields.contains(RoleField.CUSTOM) ? req.customFieldName() : null); meta.put("assigneeIds", assigneeIds); meta.put("mentionUserIds", mentionIds); meta.put("fileIds", fileIds); @@ -1859,6 +1859,7 @@ private int rate(long part, long total) { return (int) Math.round(part * 100.0 / total); } + // 프로세스 위치 상태 정렬 변경 서비스 @Transactional public ProcessOrderUpdateResDto updateProcessOrder(Long projectId, Long userId, Long processId, ProcessOrderUpdateReqDto req) { From 84fb46f649cd0bd2bcf7635710d5b780173d86f4 Mon Sep 17 00:00:00 2001 From: infiniment Date: Sat, 7 Feb 2026 19:22:47 +0900 Subject: [PATCH 41/66] =?UTF-8?q?[Test]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/process/controller/ProcessLinkControllerTest.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessLinkControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessLinkControllerTest.java index cb0f12a8..cf57011b 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessLinkControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessLinkControllerTest.java @@ -5,7 +5,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.nect.api.domain.team.process.dto.req.ProcessLinkCreateReqDto; import com.nect.api.domain.team.process.dto.res.ProcessLinkCreateAndAttachResDto; -import com.nect.api.domain.team.process.dto.res.ProcessLinkCreateResDto; import com.nect.api.domain.team.process.facade.ProcessAttachmentFacade; import com.nect.api.domain.team.process.service.ProcessAttachmentService; import com.nect.api.global.jwt.JwtUtil; @@ -19,9 +18,7 @@ import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.FieldDescriptor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; @@ -152,7 +149,7 @@ void createLink() throws Exception { ) .requestFields( fieldWithPath("title").type(STRING).description("링크 제목"), - fieldWithPath("url").type(STRING).description("추가할 링크 URL") + fieldWithPath("link_url").type(STRING).description("추가할 링크 URL") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), From ff4a5a0cfd9dc88ff71192188b721ccc1d2f7e29 Mon Sep 17 00:00:00 2001 From: infiniment Date: Sat, 7 Feb 2026 23:58:12 +0900 Subject: [PATCH 42/66] =?UTF-8?q?[Feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=B2=A8=EB=B6=80=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PostAttachmentController.java | 57 +++++ .../dto/req/PostLinkCreateReqDto.java | 12 + .../dto/res/PostAttachmentResDto.java | 31 +++ .../facade/PostAttachmentFacade.java | 60 +++++ .../service/PostAttachmentService.java | 214 ++++++++++++++++++ ...ttachment.java => PostSharedDocument.java} | 28 ++- .../PostSharedDocumentRepository.java | 25 ++ 7 files changed, 421 insertions(+), 6 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/PostAttachmentController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/PostLinkCreateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostAttachmentResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/PostAttachmentFacade.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostAttachmentService.java rename nect-core/src/main/java/com/nect/core/entity/team/workspace/{PostAttachment.java => PostSharedDocument.java} (53%) create mode 100644 nect-core/src/main/java/com/nect/core/repository/team/workspace/PostSharedDocumentRepository.java diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/PostAttachmentController.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/PostAttachmentController.java new file mode 100644 index 00000000..4ec431ce --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/PostAttachmentController.java @@ -0,0 +1,57 @@ +package com.nect.api.domain.team.workspace.controller; + +import com.nect.api.domain.team.workspace.dto.req.PostLinkCreateReqDto; +import com.nect.api.domain.team.workspace.dto.res.PostAttachmentResDto; +import com.nect.api.domain.team.workspace.facade.PostAttachmentFacade; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}/boards/posts/{postId}/attachments") +public class PostAttachmentController { + + private final PostAttachmentFacade postAttachmentFacade; + + // 파일 업로드 + 첨부 + @PostMapping(value = "/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadAndAttachFile( + @PathVariable Long projectId, + @PathVariable Long postId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestPart("file") MultipartFile file + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(postAttachmentFacade.uploadAndAttachFile(projectId, userId, postId, file)); + } + + // 링크 생성 + 첨부 + @PostMapping("/links") + public ApiResponse createAndAttachLink( + @PathVariable Long projectId, + @PathVariable Long postId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody PostLinkCreateReqDto req + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(postAttachmentFacade.createAndAttachLink(projectId, userId, postId, req)); + } + + // 첨부 해제 (파일/링크 공통) + @DeleteMapping("/{documentId}") + public ApiResponse detach( + @PathVariable Long projectId, + @PathVariable Long postId, + @PathVariable Long documentId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + Long userId = userDetails.getUserId(); + postAttachmentFacade.detach(projectId, userId, postId, documentId); + return ApiResponse.ok(null); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/PostLinkCreateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/PostLinkCreateReqDto.java new file mode 100644 index 00000000..a372aaee --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/PostLinkCreateReqDto.java @@ -0,0 +1,12 @@ +package com.nect.api.domain.team.workspace.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record PostLinkCreateReqDto( + @JsonProperty("title") + String title, + + @JsonProperty("link_url") + String linkUrl +) { +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostAttachmentResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostAttachmentResDto.java new file mode 100644 index 00000000..37cb6bb1 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostAttachmentResDto.java @@ -0,0 +1,31 @@ +package com.nect.api.domain.team.workspace.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.enums.FileExt; + +public record PostAttachmentResDto( + @JsonProperty("document_id") + Long documentId, + + @JsonProperty("document_type") + DocumentType documentType, + + @JsonProperty("title") + String title, + + @JsonProperty("link_url") + String linkUrl, + + @JsonProperty("file_name") + String fileName, + + @JsonProperty("file_ext") + FileExt fileExt, + + @JsonProperty("file_size") + Long fileSize, + + @JsonProperty("download_url") + String downloadUrl +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/PostAttachmentFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/PostAttachmentFacade.java new file mode 100644 index 00000000..78da4d18 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/PostAttachmentFacade.java @@ -0,0 +1,60 @@ +package com.nect.api.domain.team.workspace.facade; + +import com.nect.api.domain.team.file.dto.res.FileUploadResDto; +import com.nect.api.domain.team.file.service.FileService; +import com.nect.api.domain.team.workspace.dto.req.PostLinkCreateReqDto; +import com.nect.api.domain.team.workspace.dto.res.PostAttachmentResDto; +import com.nect.api.domain.team.workspace.service.PostAttachmentService; +import com.nect.api.domain.team.workspace.enums.PostErrorCode; +import com.nect.api.domain.team.workspace.exception.PostException; +import com.nect.core.entity.user.User; +import com.nect.core.repository.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class PostAttachmentFacade { + + private final FileService fileService; + private final PostAttachmentService postAttachmentService; + private final UserRepository userRepository; + + // 파일 업로드, 첨부 + @Transactional + public PostAttachmentResDto uploadAndAttachFile(Long projectId, Long userId, Long postId, MultipartFile file) { + // SharedDocument(FILE) 생성 + 업로드 + FileUploadResDto uploaded = fileService.upload(projectId, userId, file); + + // Post에 attach + PostAttachmentResDto attached = postAttachmentService.attachFile(projectId, userId, postId, uploaded.fileId()); + + return new PostAttachmentResDto( + attached.documentId(), + attached.documentType(), + attached.title(), + null, + uploaded.fileName(), + uploaded.fileType(), + uploaded.fileSize(), + uploaded.downloadUrl() + ); + } + + // 링크 생성 및 첨부 + @Transactional + public PostAttachmentResDto createAndAttachLink(Long projectId, Long userId, Long postId, PostLinkCreateReqDto req) { + User actor = userRepository.findById(userId) + .orElseThrow(() -> new PostException(PostErrorCode.USER_NOT_FOUND, "userId=" + userId)); + + return postAttachmentService.createAndAttachLink(projectId, userId, postId, req, actor); + } + + // 파일 or 링크 첨부 해제 + @Transactional + public void detach(Long projectId, Long userId, Long postId, Long documentId) { + postAttachmentService.detach(projectId, userId, postId, documentId); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostAttachmentService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostAttachmentService.java new file mode 100644 index 00000000..bb665bcf --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostAttachmentService.java @@ -0,0 +1,214 @@ +package com.nect.api.domain.team.workspace.service; + +import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; +import com.nect.api.domain.team.workspace.dto.req.PostLinkCreateReqDto; +import com.nect.api.domain.team.workspace.dto.res.PostAttachmentResDto; +import com.nect.api.domain.team.workspace.enums.PostErrorCode; +import com.nect.api.domain.team.workspace.exception.PostException; +import com.nect.core.entity.team.SharedDocument; +import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.history.enums.HistoryAction; +import com.nect.core.entity.team.history.enums.HistoryTargetType; +import com.nect.core.entity.team.workspace.Post; +import com.nect.core.entity.team.workspace.PostSharedDocument; +import com.nect.core.entity.user.User; +import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.team.SharedDocumentRepository; +import com.nect.core.repository.team.workspace.PostRepository; +import com.nect.core.repository.team.workspace.PostSharedDocumentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + + +@Service +@RequiredArgsConstructor +public class PostAttachmentService { + private final ProjectRepository projectRepository; + private final ProjectUserRepository projectUserRepository; + + private final PostRepository postRepository; + private final SharedDocumentRepository sharedDocumentRepository; + private final PostSharedDocumentRepository postSharedDocumentRepository; + + private final ProjectHistoryPublisher historyPublisher; + + private void assertActiveMember(Long projectId, Long userId) { + if (!projectRepository.existsById(projectId)) { + throw new PostException(PostErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId); + } + if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { + throw new PostException(PostErrorCode.PROJECT_MEMBER_FORBIDDEN, "projectId=" + projectId + ", userId=" + userId); + } + } + + private Post getActivePost(Long projectId, Long postId) { + return postRepository.findByIdAndProjectIdAndDeletedAtIsNull(postId, projectId) + .orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND, "postId=" + postId)); + } + + private SharedDocument getActiveDocument(Long projectId, Long documentId) { + return sharedDocumentRepository.findByIdAndProjectIdAndDeletedAtIsNull(documentId, projectId) + .orElseThrow(() -> new PostException(PostErrorCode.INVALID_REQUEST, + "document not found. projectId=" + projectId + ", documentId=" + documentId)); + } + + private void assertAuthor(Post post, Long userId) { + Long authorId = post.getAuthor().getUserId(); + if (!Objects.equals(authorId, userId)) { + throw new PostException(PostErrorCode.POST_AUTHOR_FORBIDDEN, "postId=" + post.getId() + ", userId=" + userId); + } + } + + // 파일 첨부 서비스 + @Transactional + public PostAttachmentResDto attachFile(Long projectId, Long userId, Long postId, Long documentId) { + assertActiveMember(projectId, userId); + + Post post = getActivePost(projectId, postId); + assertAuthor(post, userId); + + SharedDocument doc = getActiveDocument(projectId, documentId); + + if (doc.getDocumentType() != DocumentType.FILE) { + throw new PostException(PostErrorCode.INVALID_REQUEST, + "only FILE can be attached by attachFile. documentId=" + doc.getId() + ); + } + + if (postSharedDocumentRepository.existsByPostIdAndDocumentIdAndDeletedAtIsNull(postId, documentId)) { + throw new PostException(PostErrorCode.INVALID_REQUEST, + "already attached. postId=" + postId + ", documentId=" + documentId + ); + } + + PostSharedDocument psd = PostSharedDocument.builder() + .post(post) + .document(doc) + .attachedAt(LocalDateTime.now()) + .build(); + + postSharedDocumentRepository.save(psd); + + Map meta = new LinkedHashMap<>(); + meta.put("postId", postId); + meta.put("documentId", doc.getId()); + meta.put("type", "FILE"); + + historyPublisher.publish( + projectId, + userId, + HistoryAction.DOCUMENT_ATTACHED, + HistoryTargetType.POST, + postId, + meta + ); + + // downloadUrl은 Facade에서 presigned 만들어서 채워줄 수도 있음(아래 Facade에서 처리) + return new PostAttachmentResDto( + doc.getId(), + doc.getDocumentType(), + doc.getTitle(), + null, + doc.getFileName(), + doc.getFileExt(), + doc.getFileSize(), + null + ); + } + + // 링크 생성 및 첨부 + @Transactional + public PostAttachmentResDto createAndAttachLink(Long projectId, Long userId, Long postId, PostLinkCreateReqDto req, User actor) { + assertActiveMember(projectId, userId); + + if (req == null || req.linkUrl() == null || req.linkUrl().isBlank()) { + throw new PostException(PostErrorCode.INVALID_REQUEST, "link_url is required"); + } + if (req.title() == null || req.title().isBlank()) { + throw new PostException(PostErrorCode.INVALID_REQUEST, "title is required"); + } + + Post post = getActivePost(projectId, postId); + assertAuthor(post, userId); + + SharedDocument doc = SharedDocument.ofLink( + actor, + post.getProject(), + req.title().trim(), + req.linkUrl().trim() + ); + SharedDocument saved = sharedDocumentRepository.save(doc); + + PostSharedDocument psd = PostSharedDocument.builder() + .post(post) + .document(saved) + .attachedAt(LocalDateTime.now()) + .build(); + + postSharedDocumentRepository.save(psd); + + Map meta = new LinkedHashMap<>(); + meta.put("postId", postId); + meta.put("documentId", saved.getId()); + meta.put("type", "LINK"); + meta.put("title", saved.getTitle()); + meta.put("url", saved.getLinkUrl()); + + historyPublisher.publish( + projectId, + userId, + HistoryAction.DOCUMENT_ATTACHED, + HistoryTargetType.POST, + postId, + meta + ); + + return new PostAttachmentResDto( + saved.getId(), + saved.getDocumentType(), + saved.getTitle(), + saved.getLinkUrl(), + null, + null, + 0L, + null + ); + } + + // 파일, 링크 첨부 해제 + @Transactional + public void detach(Long projectId, Long userId, Long postId, Long documentId) { + assertActiveMember(projectId, userId); + + Post post = getActivePost(projectId, postId); + assertAuthor(post, userId); + + PostSharedDocument psd = postSharedDocumentRepository + .findByPostIdAndDocumentIdAndDeletedAtIsNull(postId, documentId) + .orElseThrow(() -> new PostException(PostErrorCode.INVALID_REQUEST, + "not attached. postId=" + postId + ", documentId=" + documentId)); + + psd.softDelete(); + + Map meta = new LinkedHashMap<>(); + meta.put("postId", postId); + meta.put("documentId", documentId); + + historyPublisher.publish( + projectId, + userId, + HistoryAction.DOCUMENT_DETACHED, + HistoryTargetType.POST, + postId, + meta + ); + } + +} diff --git a/nect-core/src/main/java/com/nect/core/entity/team/workspace/PostAttachment.java b/nect-core/src/main/java/com/nect/core/entity/team/workspace/PostSharedDocument.java similarity index 53% rename from nect-core/src/main/java/com/nect/core/entity/team/workspace/PostAttachment.java rename to nect-core/src/main/java/com/nect/core/entity/team/workspace/PostSharedDocument.java index 0b8fecbb..32850710 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/workspace/PostAttachment.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/workspace/PostSharedDocument.java @@ -4,19 +4,22 @@ import com.nect.core.entity.team.SharedDocument; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table( - name = "post_attachment", + name = "post_shared_document", uniqueConstraints = { - @UniqueConstraint(name = "uk_post_attachment_post_document", columnNames = {"post_id", "document_id"}) + @UniqueConstraint(columnNames = {"post_id", "document_id"}) } ) -public class PostAttachment extends BaseEntity { +public class PostSharedDocument extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -29,11 +32,24 @@ public class PostAttachment extends BaseEntity { @JoinColumn(name = "document_id", nullable = false) private SharedDocument document; - void setPost(Post post) { + @Column(name = "attached_at", nullable = false) + private LocalDateTime attachedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder + private PostSharedDocument(Post post, SharedDocument document, LocalDateTime attachedAt) { this.post = post; + this.document = document; + this.attachedAt = attachedAt; } - void setDocument(SharedDocument document) { - this.document = document; + public boolean isDeleted() { + return deletedAt != null; + } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); } } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostSharedDocumentRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostSharedDocumentRepository.java new file mode 100644 index 00000000..9675f3fa --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostSharedDocumentRepository.java @@ -0,0 +1,25 @@ +package com.nect.core.repository.team.workspace; + +import com.nect.core.entity.team.workspace.PostSharedDocument; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + + +public interface PostSharedDocumentRepository extends JpaRepository { + boolean existsByPostIdAndDocumentIdAndDeletedAtIsNull(Long postId, Long documentId); + + Optional findByPostIdAndDocumentIdAndDeletedAtIsNull(Long postId, Long documentId); + + @Query(""" + select psd + from PostSharedDocument psd + join fetch psd.document d + where psd.post.id = :postId + and psd.deletedAt is null + """) + List findAllActiveByPostIdWithDocument(@Param("postId") Long postId); +} From ce74aa6ba68ddab4d22554541543e791c7d4c808 Mon Sep 17 00:00:00 2001 From: infiniment Date: Sat, 7 Feb 2026 23:58:18 +0900 Subject: [PATCH 43/66] =?UTF-8?q?[Refactor]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/controller/PostController.java | 7 +- .../workspace/dto/req/PostCreateReqDto.java | 7 +- .../workspace/dto/req/PostUpdateReqDto.java | 7 +- .../team/workspace/dto/res/PostGetResDto.java | 12 +- .../workspace/dto/res/PostListResDto.java | 3 - .../workspace/dto/res/PostsPreviewResDto.java | 3 - .../facade/BoardsOverviewFacade.java | 2 +- .../team/workspace/facade/PostFacade.java | 47 ++++- .../team/workspace/service/PostService.java | 186 ++++++++++++------ .../nect/core/entity/team/SharedDocument.java | 2 +- .../nect/core/entity/team/workspace/Post.java | 14 +- .../team/workspace/PostRepository.java | 25 ++- 12 files changed, 205 insertions(+), 110 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/PostController.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/PostController.java index 246ca328..4f96c10c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/PostController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/PostController.java @@ -46,12 +46,11 @@ public ApiResponse getPostList( @PathVariable Long projectId, @AuthenticationPrincipal UserDetailsImpl userDetails, @RequestParam(required = false) PostType type, - @RequestParam(defaultValue = "LATEST") PostSort sort, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size + @RequestParam(defaultValue = "0") int page ) { Long userId = userDetails.getUserId(); - return ApiResponse.ok(postFacade.getPostList(projectId, userId, type, sort, page, size)); + int fixedSize = 10; + return ApiResponse.ok(postFacade.getPostList(projectId, userId, type, page, fixedSize)); } // 게시글 수정 diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/PostCreateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/PostCreateReqDto.java index 4c30bf29..5acb96c5 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/PostCreateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/PostCreateReqDto.java @@ -6,17 +6,14 @@ import java.util.List; public record PostCreateReqDto( - @JsonProperty("post_type") - PostType postType, - @JsonProperty("title") String title, @JsonProperty("content") String content, - @JsonProperty("is_pinned") - Boolean isPinned, + @JsonProperty("is_notice") + Boolean isNotice, @JsonProperty("mention_user_ids") List mentionUserIds diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/PostUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/PostUpdateReqDto.java index e55e9a57..b7033ff3 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/PostUpdateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/PostUpdateReqDto.java @@ -6,17 +6,14 @@ import java.util.List; public record PostUpdateReqDto( - @JsonProperty("post_type") - PostType postType, - @JsonProperty("title") String title, @JsonProperty("content") String content, - @JsonProperty("is_pinned") - boolean isPinned, + @JsonProperty("is_notice") + Boolean isNotice, @JsonProperty("mention_user_ids") List mentionUserIds diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostGetResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostGetResDto.java index 315c2e04..2a7881f9 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostGetResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostGetResDto.java @@ -1,9 +1,13 @@ package com.nect.api.domain.team.workspace.dto.res; import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.api.domain.team.process.dto.res.AttachmentDto; +import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.enums.FileExt; import com.nect.core.entity.team.workspace.enums.PostType; import java.time.LocalDateTime; +import java.util.List; public record PostGetResDto( @JsonProperty("post_id") @@ -18,9 +22,6 @@ public record PostGetResDto( @JsonProperty("content") String content, - @JsonProperty("is_pinned") - Boolean isPinned, - @JsonProperty("like_count") Long likeCount, @@ -28,7 +29,10 @@ public record PostGetResDto( LocalDateTime createdAt, @JsonProperty("author") - AuthorDto author + AuthorDto author, + + @JsonProperty("attachments") + List attachments ) { public record AuthorDto( @JsonProperty("user_id") diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostListResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostListResDto.java index 9f1b4685..7d892523 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostListResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostListResDto.java @@ -26,9 +26,6 @@ public record PostSummaryDto( @JsonProperty("content_preview") String contentPreview, - @JsonProperty("is_pinned") - Boolean isPinned, - @JsonProperty("like_count") Long likeCount, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostsPreviewResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostsPreviewResDto.java index 0e5be047..350e7f4c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostsPreviewResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostsPreviewResDto.java @@ -20,9 +20,6 @@ public record Item( @JsonProperty("title") String title, - @JsonProperty("is_pinned") - Boolean isPinned, - @JsonProperty("created_at") LocalDateTime createdAt ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsOverviewFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsOverviewFacade.java index 965aec41..7f9bdf11 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsOverviewFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsOverviewFacade.java @@ -46,7 +46,7 @@ public BoardsOverviewResDto getOverview( // 기본값 : 공지로 설정 PostType safeType = (postType == null) ? PostType.NOTICE : postType; PostListResDto postsPreview = - postFacade.getPostList(projectId, userId, safeType, PostSort.LATEST, 0, postsLimit); + postFacade.getPostList(projectId, userId, safeType,0, postsLimit); CalendarMonthIndicatorsResDto calendarIndicators = null; if (year != null && month != null) { diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/PostFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/PostFacade.java index ee8fcf9e..31b95ced 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/PostFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/PostFacade.java @@ -1,10 +1,13 @@ package com.nect.api.domain.team.workspace.facade; +import com.nect.api.domain.team.file.dto.res.FileDownloadUrlResDto; +import com.nect.api.domain.team.file.service.FileService; import com.nect.api.domain.team.workspace.dto.req.PostCreateReqDto; import com.nect.api.domain.team.workspace.dto.req.PostUpdateReqDto; import com.nect.api.domain.team.workspace.dto.res.*; import com.nect.api.domain.team.workspace.enums.PostSort; import com.nect.api.domain.team.workspace.service.PostService; +import com.nect.core.entity.team.enums.DocumentType; import com.nect.core.entity.team.workspace.enums.PostType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -16,6 +19,7 @@ public class PostFacade { private final PostService postService; + private final FileService fileService; // 게시글 생성 public PostCreateResDto createPost(Long projectId, Long userId, PostCreateReqDto req) { @@ -24,12 +28,47 @@ public PostCreateResDto createPost(Long projectId, Long userId, PostCreateReqDto // 게시글 조회 public PostGetResDto getPost(Long projectId, Long userId, Long postId) { - return postService.getPost(projectId, userId, postId); + PostGetResDto base = postService.getPost(projectId, userId, postId); + + List filled = (base.attachments() == null) + ? List.of() + : base.attachments().stream() + .map(a -> { + if (a.documentType() != DocumentType.FILE) { + return a; + } + + FileDownloadUrlResDto urlDto = + fileService.getDownloadUrl(projectId, userId, a.documentId()); + + return new PostAttachmentResDto( + a.documentId(), + a.documentType(), + a.title(), + a.linkUrl(), + a.fileName(), + a.fileExt(), + a.fileSize(), + urlDto.downloadUrl() + ); + }) + .toList(); + + return new PostGetResDto( + base.postId(), + base.postType(), + base.title(), + base.content(), + base.likeCount(), + base.createdAt(), + base.author(), + filled + ); } // 게시글 목록 조회 - public PostListResDto getPostList(Long projectId, Long userId, PostType type, PostSort sort, int page, int size) { - return postService.getPostList(projectId, userId, type, sort, page, size); + public PostListResDto getPostList(Long projectId, Long userId, PostType type, int page, int size) { + return postService.getPostList(projectId, userId, type, page, size); } // 게시글 수정 @@ -55,7 +94,6 @@ public PostsPreviewResDto getPostsPreview(Long projectId, Long userId, PostType projectId, userId, type, - PostSort.LATEST, 0, safeLimit ); @@ -65,7 +103,6 @@ public PostsPreviewResDto getPostsPreview(Long projectId, Long userId, PostType p.postId(), p.postType(), p.title(), - p.isPinned(), p.createdAt() )) .toList(); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java index 504865cc..1b080f17 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java @@ -3,6 +3,7 @@ import com.nect.api.domain.notifications.command.NotificationCommand; import com.nect.api.domain.notifications.facade.NotificationFacade; import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; +import com.nect.api.domain.team.process.dto.res.AttachmentDto; import com.nect.api.domain.team.workspace.dto.req.PostCreateReqDto; import com.nect.api.domain.team.workspace.dto.req.PostUpdateReqDto; import com.nect.api.domain.team.workspace.dto.res.*; @@ -13,11 +14,13 @@ import com.nect.core.entity.notifications.enums.NotificationScope; import com.nect.core.entity.notifications.enums.NotificationType; import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.SharedDocument; import com.nect.core.entity.team.history.enums.HistoryAction; import com.nect.core.entity.team.history.enums.HistoryTargetType; import com.nect.core.entity.team.workspace.Post; import com.nect.core.entity.team.workspace.PostLike; import com.nect.core.entity.team.workspace.PostMention; +import com.nect.core.entity.team.workspace.PostSharedDocument; import com.nect.core.entity.team.workspace.enums.PostType; import com.nect.core.entity.user.User; import com.nect.core.repository.team.ProjectRepository; @@ -25,6 +28,7 @@ import com.nect.core.repository.team.workspace.PostLikeRepository; import com.nect.core.repository.team.workspace.PostMentionRepository; import com.nect.core.repository.team.workspace.PostRepository; +import com.nect.core.repository.team.workspace.PostSharedDocumentRepository; import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -46,6 +50,7 @@ public class PostService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final PostMentionRepository postMentionRepository; + private final PostSharedDocumentRepository postSharedDocumentRepository; private final ProjectHistoryPublisher historyPublisher; private final NotificationFacade notificationFacade; @@ -93,6 +98,21 @@ private void notifyBoardMention(Project project, User actor, Long targetBoardId, notificationFacade.notify(receivers, command); } + private void validateMaxLength(String value, int max, String fieldName) { + if (value == null) return; + // 공백 포함 길이(문자 수) 기준 + int len = value.codePointCount(0, value.length()); + if (len > max) { + throw new PostException(PostErrorCode.INVALID_REQUEST, + fieldName + " is too long. max=" + max + ", actual=" + len); + } + } + + private PostType resolvePostType(Boolean isNotice) { + return Boolean.TRUE.equals(isNotice) ? PostType.NOTICE : PostType.FREE; + } + + // 게시글 생성 서비스 @Transactional public PostCreateResDto createPost(Long projectId, Long userId, PostCreateReqDto req) { @@ -118,18 +138,21 @@ public PostCreateResDto createPost(Long projectId, Long userId, PostCreateReqDto if (req.content() == null || req.content().isBlank()) { throw new PostException(PostErrorCode.INVALID_REQUEST, "content is blank"); } - if (req.postType() == null) { - throw new PostException(PostErrorCode.INVALID_REQUEST, "postType is null"); - } + + // 길이 제한(공백 포함) + validateMaxLength(req.title(), 200, "title"); + validateMaxLength(req.content(), 1000, "content"); + + PostType postType = resolvePostType(req.isNotice()); + // 게시글 생성 Post post = Post.builder() .author(author) .project(project) - .postType(req.postType()) + .postType(postType) .title(req.title()) .content(req.content()) - .isPinned(Boolean.TRUE.equals(req.isPinned())) .build(); Post saved = postRepository.save(post); @@ -154,7 +177,6 @@ public PostCreateResDto createPost(Long projectId, Long userId, PostCreateReqDto Map.of( "postType", saved.getPostType().name(), "title", saved.getTitle(), - "isPinned", saved.getIsPinned(), "mentionUserIds", mentionIds ) ); @@ -182,19 +204,40 @@ public PostGetResDto getPost(Long projectId, Long userId, Long postId) { .orElseThrow(() -> new PostException(PostErrorCode.INVALID_REQUEST, "post not found or not in project. projectId=" + projectId + ", postId=" + postId)); + // attachments 조회 + List psds = postSharedDocumentRepository.findAllActiveByPostIdWithDocument(postId); + + List attachments = psds.stream() + .map(psd -> { + SharedDocument d = psd.getDocument(); + + + return new PostAttachmentResDto( + d.getId(), + d.getDocumentType(), + d.getTitle(), + d.getLinkUrl(), + d.getFileName(), + d.getFileExt(), + d.getFileSize(), + null + ); + }) + .toList(); + return new PostGetResDto( post.getId(), post.getPostType(), post.getTitle(), post.getContent(), - post.getIsPinned(), post.getLikeCount(), post.getCreatedAt(), new PostGetResDto.AuthorDto( post.getAuthor().getUserId(), post.getAuthor().getName(), post.getAuthor().getNickname() - ) + ), + attachments ); } @@ -209,7 +252,7 @@ private String preview(String content, int maxLen) { // 게시글 목록 조회 서비스 @Transactional(readOnly = true) - public PostListResDto getPostList(Long projectId, Long userId, PostType type, PostSort sort, int page, int size) { + public PostListResDto getPostList(Long projectId, Long userId, PostType type, int page, int size) { // 프로젝트 존재 확인 projectRepository.findById(projectId) .orElseThrow(() -> new PostException(PostErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId)); @@ -221,56 +264,84 @@ public PostListResDto getPostList(Long projectId, Long userId, PostType type, Po "projectId=" + projectId + ", userId=" + userId); } - if (page < 0 || size <= 0 || size > 50) { - throw new PostException(PostErrorCode.INVALID_REQUEST, "invalid page/size"); + // page 검증 + if (page < 0) { + throw new PostException(PostErrorCode.INVALID_REQUEST, "invalid page"); } - PostSort safeSort = (sort == null) ? PostSort.LATEST : sort; + int fixedSize = Math.min(Math.max(size, 1), 50); - // pinned 우선 + 정렬 - Sort jpaSort = switch (safeSort) { - case LATEST -> Sort.by( - Sort.Order.desc("isPinned"), - Sort.Order.desc("createdAt"), - Sort.Order.desc("id") - ); - case OLDEST -> Sort.by( - Sort.Order.desc("isPinned"), - Sort.Order.asc("createdAt"), - Sort.Order.asc("id") - ); - case POPULAR -> Sort.by( - Sort.Order.desc("isPinned"), - Sort.Order.desc("likeCount"), - Sort.Order.desc("createdAt"), - Sort.Order.desc("id") + Sort baseSort = Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")); + + // 공지만 + if (type == PostType.NOTICE) { + List notices = postRepository.findAllNotices(projectId, baseSort); + + List mapped = notices.stream() + .map(p -> new PostListResDto.PostSummaryDto( + p.getId(), + p.getPostType(), + p.getTitle(), + preview(p.getContent(), 100), + p.getLikeCount(), + p.getCreatedAt() + )) + .toList(); + + PostListResDto.PageInfo pageInfo = new PostListResDto.PageInfo( + 0, + fixedSize, + mapped.size(), + 1, + false ); - }; + return new PostListResDto(mapped, pageInfo); + } + - Pageable pageable = PageRequest.of(page, size, jpaSort); - Page result = postRepository.findPosts(projectId, type, pageable); + // FREE만 페이지네이션 + Pageable pageable = PageRequest.of(page, fixedSize, baseSort); + Page freePage = postRepository.findFreePosts(projectId, pageable); + + List result = new java.util.ArrayList<>(); + + // page==0 일 때만 공지 전부 상단에 붙이기 + if (type == null && page == 0) { + List notices = postRepository.findAllNotices(projectId, baseSort); + result.addAll(notices.stream() + .map(p -> new PostListResDto.PostSummaryDto( + p.getId(), + p.getPostType(), + p.getTitle(), + preview(p.getContent(), 100), + p.getLikeCount(), + p.getCreatedAt() + )) + .toList()); + } - List posts = result.getContent().stream() + // FREE 페이징 결과 붙이기 + result.addAll(freePage.getContent().stream() .map(p -> new PostListResDto.PostSummaryDto( p.getId(), p.getPostType(), p.getTitle(), preview(p.getContent(), 100), - p.getIsPinned(), p.getLikeCount(), p.getCreatedAt() )) - .toList(); + .toList()); + // pageInfo는 FREE 기준으로만 계산 (공지는 제외) PostListResDto.PageInfo pageInfo = new PostListResDto.PageInfo( - result.getNumber(), - result.getSize(), - result.getTotalElements(), - result.getTotalPages(), - result.hasNext() + freePage.getNumber(), + freePage.getSize(), + freePage.getTotalElements(), + freePage.getTotalPages(), + freePage.hasNext() ); - return new PostListResDto(posts, pageInfo); + return new PostListResDto(result, pageInfo); } // 게시글 수정 서비스 @@ -303,11 +374,23 @@ public PostUpdateResDto updatePost(Long projectId, Long userId, Long postId, Pos "postId=" + postId + ", userId=" + userId); } + // 값 검증(빈문자 방지) + if (req.title() != null && req.title().isBlank()) + throw new PostException(PostErrorCode.INVALID_REQUEST, "title is blank"); + if (req.content() != null && req.content().isBlank()) + throw new PostException(PostErrorCode.INVALID_REQUEST, "content is blank"); + + // 길이 제한(공백 포함) + validateMaxLength(req.title(), 200, "title"); + validateMaxLength(req.content(), 1000, "content"); + + // 공지 토글: req.isNotice()가 들어온 경우에만 바꿈 + PostType newType = (req.isNotice() == null) ? null : resolvePostType(req.isNotice()); + // before 스냅샷 final PostType beforeType = post.getPostType(); final String beforeTitle = post.getTitle(); final String beforeContent = post.getContent(); - final Boolean beforePinned = post.getIsPinned(); final List beforeMentionIds = postMentionRepository.findAllByPostId(post.getId()).stream() .filter(m -> !m.isDeleted()) @@ -317,15 +400,8 @@ public PostUpdateResDto updatePost(Long projectId, Long userId, Long postId, Pos .sorted() .toList(); - // 값 검증 - if (req.title() != null && req.title().isBlank()) { - throw new PostException(PostErrorCode.INVALID_REQUEST, "title is blank"); - } - if (req.content() != null && req.content().isBlank()) { - throw new PostException(PostErrorCode.INVALID_REQUEST, "content is blank"); - } - post.update(req.postType(), req.title(), req.content(), req.isPinned()); + post.update(newType, req.title(), req.content()); // 멘션 교체 (null이면 변경 안 함 / []이면 전부 제거) List mentionIdsForRes = syncMentions(post, req.mentionUserIds(), projectId); @@ -350,7 +426,6 @@ public PostUpdateResDto updatePost(Long projectId, Long userId, Long postId, Pos final PostType afterType = post.getPostType(); final String afterTitle = post.getTitle(); final String afterContent = post.getContent(); - final Boolean afterPinned = post.getIsPinned(); final List afterMentionIds = (mentionIdsForRes == null) ? null @@ -367,9 +442,6 @@ public PostUpdateResDto updatePost(Long projectId, Long userId, Long postId, Pos if (!Objects.equals(beforeContent, afterContent)) { changed.put("content", Map.of("before", beforeContent, "after", afterContent)); } - if (!Objects.equals(beforePinned, afterPinned)) { - changed.put("isPinned", Map.of("before", beforePinned, "after", afterPinned)); - } if (afterMentionIds != null && !Objects.equals(beforeMentionIds, afterMentionIds)) { changed.put("mentions", Map.of("before", beforeMentionIds, "after", afterMentionIds)); @@ -385,14 +457,12 @@ public PostUpdateResDto updatePost(Long projectId, Long userId, Long postId, Pos meta.put("before", Map.of( "postType", beforeType, "title", beforeTitle, - "content", beforeContent, - "isPinned", beforePinned + "content", beforeContent )); meta.put("after", Map.of( "postType", afterType, "title", afterTitle, "content", afterContent, - "isPinned", afterPinned, "mentionUserIds", (afterMentionIds != null ? afterMentionIds : beforeMentionIds) )); @@ -533,7 +603,6 @@ public void deletePost(Long projectId, Long userId, Long postId) { // before 스냅샷 final PostType beforeType = post.getPostType(); final String beforeTitle = post.getTitle(); - final Boolean beforePinned = post.getIsPinned(); // soft delete post.softDelete(); @@ -545,7 +614,6 @@ public void deletePost(Long projectId, Long userId, Long postId) { Map meta = new LinkedHashMap<>(); meta.put("postType", beforeType); meta.put("title", beforeTitle); - meta.put("isPinned", beforePinned); meta.put("deletedAt", post.getDeletedAt()); historyPublisher.publish( diff --git a/nect-core/src/main/java/com/nect/core/entity/team/SharedDocument.java b/nect-core/src/main/java/com/nect/core/entity/team/SharedDocument.java index d28d9b74..23dc0303 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/SharedDocument.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/SharedDocument.java @@ -45,7 +45,7 @@ public class SharedDocument extends BaseEntity { @Column(name = "description", columnDefinition = "TEXT") private String description; - @Column(name = "file_name", length = 200, nullable = false) + @Column(name = "file_name", length = 200) private String fileName; @Enumerated(EnumType.STRING) diff --git a/nect-core/src/main/java/com/nect/core/entity/team/workspace/Post.java b/nect-core/src/main/java/com/nect/core/entity/team/workspace/Post.java index 098ad336..55566288 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/workspace/Post.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/workspace/Post.java @@ -36,9 +36,6 @@ public class Post extends BaseEntity { @Column(name = "content", nullable = false, columnDefinition = "TEXT") private String content; - @Column(name = "is_pinned", nullable = false) - private Boolean isPinned; - @Column(name = "like_count", nullable = false) private Long likeCount = 0L; @@ -50,24 +47,19 @@ private Post(User author, Project project, PostType postType, String title, - String content, - Boolean isPinned) { + String content + ) { this.author = author; this.project = project; this.postType = postType; this.title = title; this.content = content; - this.isPinned = (isPinned != null) ? isPinned : false; } - public void update(PostType postType, - String title, - String content, - Boolean isPinned) { + public void update(PostType postType, String title, String content) { if (postType != null) this.postType = postType; if (title != null) this.title = title; if (content != null) this.content = content; - if (isPinned != null) this.isPinned = isPinned; } // 좋아요 증가 diff --git a/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostRepository.java index 02700a87..3f18758a 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostRepository.java @@ -4,25 +4,32 @@ import com.nect.core.entity.team.workspace.enums.PostType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface PostRepository extends JpaRepository { Optional findByIdAndProjectIdAndDeletedAtIsNull(Long id, Long projectId); + // 공지: 개수 제한 X, 상단 노출용, 정렬은 서비스에서 Sort 주입 @Query(""" - select p - from Post p + select p from Post p where p.project.id = :projectId and p.deletedAt is null - and (:type is null or p.postType = :type) - """) - Page findPosts( - @Param("projectId") Long projectId, - @Param("type") PostType type, - Pageable pageable - ); + and p.postType = com.nect.core.entity.team.workspace.enums.PostType.NOTICE + """) + List findAllNotices(@Param("projectId") Long projectId, Sort sort); + + // 자유글: 페이징 대상 (NOTICE 제외) + @Query(""" + select p from Post p + where p.project.id = :projectId + and p.deletedAt is null + and p.postType <> com.nect.core.entity.team.workspace.enums.PostType.NOTICE + """) + Page findFreePosts(@Param("projectId") Long projectId, Pageable pageable); } From 221d809ad936912e69adb2a5461861e2522b9440 Mon Sep 17 00:00:00 2001 From: infiniment Date: Sun, 8 Feb 2026 00:01:43 +0900 Subject: [PATCH 44/66] =?UTF-8?q?[Test]=20API=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BoardsOverviewControllerTest.java | 2 - .../PostAttachmentControllerTest.java | 291 ++++++++++++++++++ .../controller/PostControllerTest.java | 199 ++++-------- 3 files changed, 353 insertions(+), 139 deletions(-) create mode 100644 nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostAttachmentControllerTest.java diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsOverviewControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsOverviewControllerTest.java index 84b6085b..6e686fd0 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsOverviewControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsOverviewControllerTest.java @@ -177,7 +177,6 @@ void getOverview_withoutCalendarIndicators() throws Exception { PostType.NOTICE, "공지 제목", "공지 내용 프리뷰...", - true, 10L, LocalDateTime.of(2026, 1, 31, 12, 0, 0) ), @@ -186,7 +185,6 @@ void getOverview_withoutCalendarIndicators() throws Exception { PostType.FREE, "자유 글 제목", "자유 글 프리뷰...", - false, 3L, LocalDateTime.of(2026, 1, 30, 9, 30, 0) ) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostAttachmentControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostAttachmentControllerTest.java new file mode 100644 index 00000000..5fe71a55 --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostAttachmentControllerTest.java @@ -0,0 +1,291 @@ +package com.nect.api.domain.team.workspace.controller; + +import com.epages.restdocs.apispec.ResourceDocumentation; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.workspace.dto.req.PostLinkCreateReqDto; +import com.nect.api.domain.team.workspace.dto.res.PostAttachmentResDto; +import com.nect.api.domain.team.workspace.facade.PostAttachmentFacade; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.enums.FileExt; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class PostAttachmentControllerTest { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockitoBean private PostAttachmentFacade postAttachmentFacade; + @MockitoBean private JwtUtil jwtUtil; + @MockitoBean private UserDetailsServiceImpl userDetailsService; + @MockitoBean private TokenBlacklistService tokenBlacklistService; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_MEMBER")) + .build() + ); + } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_MEMBER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return SecurityMockMvcRequestPostProcessors.authentication(auth); + } + + @Test + @DisplayName("게시글 첨부 - 파일 업로드 + 첨부") + void uploadAndAttachFile() throws Exception { + long projectId = 1L; + long postId = 100L; + long userId = 1L; + + MockMultipartFile file = new MockMultipartFile( + "file", + "test.pdf", + "application/pdf", + "dummy".getBytes() + ); + + PostAttachmentResDto response = new PostAttachmentResDto( + 55L, + DocumentType.FILE, + "test.pdf", + null, + "test.pdf", + FileExt.PDF, + 1234L, + "https://download.example.com/test.pdf" + ); + + given(postAttachmentFacade.uploadAndAttachFile(eq(projectId), eq(userId), eq(postId), any())) + .willReturn(response); + + mockMvc.perform(multipart("/api/v1/projects/{projectId}/boards/boards/posts/{postId}/attachments/files", projectId, postId) + .file(file) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("post-attachment-upload-file", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("PostAttachment") + .summary("게시글 파일 업로드 + 첨부") + .description("게시글에 파일을 업로드하고 즉시 첨부합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("postId").description("게시글 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + // multipart라 requestFields 대신 part를 문서화하고 싶으면 requestParts를 쓰는 게 더 정확함. + // (epages apispec에서 requestParts 지원이 애매하면 생략해도 됨) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.document_id").type(NUMBER).description("문서 ID(SharedDocument ID)"), + fieldWithPath("body.document_type").type(STRING).description("문서 타입(FILE|LINK)"), + fieldWithPath("body.title").type(STRING).description("문서 제목"), + + fieldWithPath("body.link_url").optional().type(STRING).description("링크 URL (document_type=LINK일 때)"), + + fieldWithPath("body.file_name").optional().type(STRING).description("파일명 (document_type=FILE일 때)"), + fieldWithPath("body.file_ext").optional().type(STRING).description("파일 확장자 (document_type=FILE일 때)"), + fieldWithPath("body.file_size").optional().type(NUMBER).description("파일 크기(byte) (document_type=FILE일 때)"), + fieldWithPath("body.download_url").optional().type(STRING).description("다운로드 URL (document_type=FILE일 때)") + ) + .build() + ) + )); + + verify(postAttachmentFacade).uploadAndAttachFile(eq(projectId), eq(userId), eq(postId), any()); + } + + @Test + @DisplayName("게시글 첨부 - 링크 생성 + 첨부") + void createAndAttachLink() throws Exception { + long projectId = 1L; + long postId = 100L; + long userId = 1L; + + PostLinkCreateReqDto request = new PostLinkCreateReqDto( + "피그마 링크", + "https://figma.com/file/xxx" + ); + + PostAttachmentResDto response = new PostAttachmentResDto( + 77L, + DocumentType.LINK, + "피그마 링크", + "https://figma.com/file/xxx", + null, + null, + 0L, + null + ); + + given(postAttachmentFacade.createAndAttachLink(eq(projectId), eq(userId), eq(postId), any(PostLinkCreateReqDto.class))) + .willReturn(response); + + mockMvc.perform(post("/api/v1/projects/{projectId}/boards/boards/posts/{postId}/attachments/links", projectId, postId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("post-attachment-create-link", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("PostAttachment") + .summary("게시글 링크 생성 + 첨부") + .description("게시글에 링크(SharedDocument)를 생성하고 즉시 첨부합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("postId").description("게시글 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("title").type(STRING).description("링크 제목"), + fieldWithPath("link_url").type(STRING).description("링크 URL") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.document_id").type(NUMBER).description("문서 ID(SharedDocument ID)"), + fieldWithPath("body.document_type").type(STRING).description("문서 타입(FILE|LINK)"), + fieldWithPath("body.title").type(STRING).description("문서 제목"), + + fieldWithPath("body.link_url").type(STRING).description("링크 URL"), + + fieldWithPath("body.file_name").optional().type(STRING).description("파일명 (document_type=FILE일 때)"), + fieldWithPath("body.file_ext").optional().type(STRING).description("파일 확장자 (document_type=FILE일 때)"), + fieldWithPath("body.file_size").optional().type(NUMBER).description("파일 크기(byte) (document_type=FILE일 때)"), + fieldWithPath("body.download_url").optional().type(STRING).description("다운로드 URL (document_type=FILE일 때)") + ) + .build() + ) + )); + + verify(postAttachmentFacade).createAndAttachLink(eq(projectId), eq(userId), eq(postId), any(PostLinkCreateReqDto.class)); + } + + @Test + @DisplayName("게시글 첨부 - 첨부 해제(파일/링크 공통)") + void detach() throws Exception { + long projectId = 1L; + long postId = 100L; + long documentId = 55L; + long userId = 1L; + + willDoNothing().given(postAttachmentFacade).detach(eq(projectId), eq(userId), eq(postId), eq(documentId)); + + mockMvc.perform(delete("/api/v1/projects/{projectId}/boards/boards/posts/{postId}/attachments/{documentId}", projectId, postId, documentId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("post-attachment-detach", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("PostAttachment") + .summary("게시글 첨부 해제") + .description("게시글에 첨부된 문서(파일/링크)를 해제합니다.") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("postId").description("게시글 ID"), + ResourceDocumentation.parameterWithName("documentId").description("문서 ID(SharedDocument ID)") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + fieldWithPath("body").type(NULL).optional().description("응답 바디(없음)") + ) + .build() + ) + )); + + verify(postAttachmentFacade).detach(eq(projectId), eq(userId), eq(postId), eq(documentId)); + } +} diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java index 027bba8b..c30592bd 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java @@ -6,12 +6,13 @@ import com.nect.api.domain.team.workspace.dto.req.PostCreateReqDto; import com.nect.api.domain.team.workspace.dto.req.PostUpdateReqDto; import com.nect.api.domain.team.workspace.dto.res.*; -import com.nect.api.domain.team.workspace.enums.PostSort; import com.nect.api.domain.team.workspace.facade.PostFacade; import com.nect.api.global.jwt.JwtUtil; import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.enums.FileExt; import com.nect.core.entity.team.workspace.enums.PostType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -110,7 +111,6 @@ void createPost() throws Exception { long userId = 1L; PostCreateReqDto request = new PostCreateReqDto( - PostType.NOTICE, "공지 제목", "공지 내용", true, @@ -144,10 +144,9 @@ void createPost() throws Exception { headerWithName(AUTH_HEADER).description("Bearer Access Token") ) .requestFields( - fieldWithPath("post_type").type(STRING).description("게시글 타입"), fieldWithPath("title").type(STRING).description("제목"), fieldWithPath("content").type(STRING).description("내용"), - fieldWithPath("is_pinned").type(BOOLEAN).optional().description("상단 고정 여부"), + fieldWithPath("is_notice").type(BOOLEAN).optional().description("공지 여부(true면 공지)"), fieldWithPath("mention_user_ids").type(ARRAY).optional().description("멘션 유저 ID 목록") ) .responseFields( @@ -173,15 +172,38 @@ void getPost() throws Exception { long postId = 100L; long userId = 1L; + List attachments = List.of( + new PostAttachmentResDto( + 10L, + DocumentType.FILE, + "file-title", + null, + "spec.pdf", + FileExt.PDF, + 12345L, + "https://presigned-url.example.com/spec.pdf" + ), + new PostAttachmentResDto( + 11L, + DocumentType.LINK, + "피그마 링크", + "https://figma.com/xxx", + null, + null, + 0L, + null + ) + ); + PostGetResDto response = new PostGetResDto( postId, PostType.NOTICE, "공지 제목", "공지 내용", - true, 7L, LocalDateTime.of(2026, 1, 31, 10, 0), - new PostGetResDto.AuthorDto(1L, "노수민", "패트") + new PostGetResDto.AuthorDto(1L, "노수민", "패트"), + attachments ); given(postFacade.getPost(eq(projectId), eq(userId), eq(postId))) @@ -208,24 +230,33 @@ void getPost() throws Exception { headerWithName(AUTH_HEADER).description("Bearer Access Token") ) .responseFields( - fieldWithPath("status").type(OBJECT).description("응답 상태"), - fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), - fieldWithPath("status.message").type(STRING).description("메시지"), - fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), - - fieldWithPath("body").type(OBJECT).description("응답 바디"), - fieldWithPath("body.post_id").type(NUMBER).description("게시글 ID"), - fieldWithPath("body.post_type").type(STRING).description("게시글 타입"), - fieldWithPath("body.title").type(STRING).description("제목"), - fieldWithPath("body.content").type(STRING).description("내용"), - fieldWithPath("body.is_pinned").type(BOOLEAN).description("상단 고정 여부"), - fieldWithPath("body.like_count").type(NUMBER).description("좋아요 수"), - fieldWithPath("body.created_at").type(STRING).description("작성 시각(ISO-8601)"), - - fieldWithPath("body.author").type(OBJECT).description("작성자 정보"), - fieldWithPath("body.author.user_id").type(NUMBER).description("작성자 유저 ID"), - fieldWithPath("body.author.name").type(STRING).description("작성자 이름"), - fieldWithPath("body.author.nickname").type(STRING).description("작성자 별명") + fieldWithPath("status").type(JsonFieldType.OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("메시지"), + fieldWithPath("status.description").optional().type(JsonFieldType.STRING).description("상세 설명"), + + fieldWithPath("body").type(JsonFieldType.OBJECT).description("응답 바디"), + fieldWithPath("body.post_id").type(JsonFieldType.NUMBER).description("게시글 ID"), + fieldWithPath("body.post_type").type(JsonFieldType.STRING).description("게시글 타입"), + fieldWithPath("body.title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("body.content").type(JsonFieldType.STRING).description("내용"), + fieldWithPath("body.like_count").type(JsonFieldType.NUMBER).description("좋아요 수"), + fieldWithPath("body.created_at").type(JsonFieldType.STRING).description("작성 시각(ISO-8601)"), + + fieldWithPath("body.author").type(JsonFieldType.OBJECT).description("작성자 정보"), + fieldWithPath("body.author.user_id").type(JsonFieldType.NUMBER).description("작성자 유저 ID"), + fieldWithPath("body.author.name").type(JsonFieldType.STRING).description("작성자 이름"), + fieldWithPath("body.author.nickname").type(JsonFieldType.STRING).description("작성자 별명"), + + fieldWithPath("body.attachments").type(JsonFieldType.ARRAY).description("첨부 목록(FILE/LINK 통합)"), + fieldWithPath("body.attachments[].document_id").type(JsonFieldType.NUMBER).description("문서 ID"), + fieldWithPath("body.attachments[].document_type").type(JsonFieldType.STRING).description("문서 타입(FILE|LINK)"), + fieldWithPath("body.attachments[].title").type(JsonFieldType.STRING).description("제목(파일/링크 공통)"), + fieldWithPath("body.attachments[].link_url").type(JsonFieldType.STRING).optional().description("링크 URL(LINK일 때)"), + fieldWithPath("body.attachments[].file_name").type(JsonFieldType.STRING).optional().description("파일명(FILE일 때)"), + fieldWithPath("body.attachments[].file_ext").type(JsonFieldType.STRING).optional().description("파일 확장자(FILE일 때)"), + fieldWithPath("body.attachments[].file_size").type(JsonFieldType.NUMBER).description("파일 크기(FILE일 때, LINK는 0)"), + fieldWithPath("body.attachments[].download_url").type(JsonFieldType.STRING).optional().description("다운로드 URL(FILE일 때 presigned)") ) .build() ) @@ -247,7 +278,6 @@ void getPostList() throws Exception { PostType.NOTICE, "공지 제목", "공지 내용...", - true, 7L, LocalDateTime.of(2026, 1, 31, 10, 0) ), @@ -256,24 +286,21 @@ void getPostList() throws Exception { PostType.FREE, "자유글", "자유 내용...", - false, 1L, LocalDateTime.of(2026, 1, 31, 11, 0) ) ), - new PostListResDto.PageInfo(0, 20, 2L, 1, false) + new PostListResDto.PageInfo(0, 10, 2L, 1, false) ); - given(postFacade.getPostList(eq(projectId), eq(userId), any(), eq(PostSort.LATEST), eq(0), eq(20))) + given(postFacade.getPostList(eq(projectId), eq(userId), any(), eq(0), eq(10))) .willReturn(response); mockMvc.perform(get("/api/v1/projects/{projectId}/boards/posts", projectId) .with(mockUser(userId)) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) .param("type", "NOTICE") - .param("sort", "LATEST") .param("page", "0") - .param("size", "20") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andDo(document("post-list", @@ -283,7 +310,7 @@ void getPostList() throws Exception { ResourceSnippetParameters.builder() .tag("Post") .summary("게시글 목록 조회") - .description("프로젝트 게시판의 게시글 목록을 페이징 조회합니다. pinned 우선 정렬이 적용될 수 있습니다.") + .description("프로젝트 게시판의 게시글 목록을 페이징 조회합니다. (기본 최신순)") .pathParameters( ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID") ) @@ -292,9 +319,7 @@ void getPostList() throws Exception { ) .queryParameters( ResourceDocumentation.parameterWithName("type").optional().description("게시글 타입(미지정 시 전체)"), - ResourceDocumentation.parameterWithName("sort").optional().description("정렬 기준(LATEST|OLDEST|POPULAR)"), - ResourceDocumentation.parameterWithName("page").optional().description("페이지 번호(0부터)"), - ResourceDocumentation.parameterWithName("size").optional().description("페이지 크기(기본 20, 최대 50)") + ResourceDocumentation.parameterWithName("page").optional().description("페이지 번호(0부터)") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), @@ -308,7 +333,6 @@ void getPostList() throws Exception { fieldWithPath("body.posts[].post_type").type(STRING).description("게시글 타입"), fieldWithPath("body.posts[].title").type(STRING).description("제목"), fieldWithPath("body.posts[].content_preview").type(STRING).description("내용 프리뷰(일부)"), - fieldWithPath("body.posts[].is_pinned").type(BOOLEAN).description("상단 고정 여부"), fieldWithPath("body.posts[].like_count").type(NUMBER).description("좋아요 수"), fieldWithPath("body.posts[].created_at").type(STRING).description("작성 시각(ISO-8601)"), @@ -323,7 +347,7 @@ void getPostList() throws Exception { ) )); - verify(postFacade).getPostList(eq(projectId), eq(userId), any(), eq(PostSort.LATEST), eq(0), eq(20)); + verify(postFacade).getPostList(eq(projectId), eq(userId), any(), eq(0), eq(10)); } @Test @@ -333,9 +357,7 @@ void updatePost() throws Exception { long postId = 100L; long userId = 1L; - // PostUpdateReqDto에서 is_pinned가 primitive boolean이므로 null(변경 없음) 표현 불가 PostUpdateReqDto request = new PostUpdateReqDto( - PostType.FREE, "수정 제목", "수정 내용", false, @@ -370,10 +392,9 @@ void updatePost() throws Exception { headerWithName(AUTH_HEADER).description("Bearer Access Token") ) .requestFields( - fieldWithPath("post_type").type(STRING).optional().description("게시글 타입(미지정 시 유지)"), fieldWithPath("title").type(STRING).optional().description("제목(미지정 시 유지)"), fieldWithPath("content").type(STRING).optional().description("내용(미지정 시 유지)"), - fieldWithPath("is_pinned").type(BOOLEAN).description("상단 고정 여부"), + fieldWithPath("is_notice").type(BOOLEAN).optional().description("공지 여부(미지정 시 유지)"), fieldWithPath("mention_user_ids").type(ARRAY).optional().description("멘션 유저 ID 목록") ) .responseFields( @@ -393,100 +414,6 @@ void updatePost() throws Exception { verify(postFacade).updatePost(eq(projectId), eq(userId), eq(postId), any(PostUpdateReqDto.class)); } - @Test - @DisplayName("게시글 좋아요 토글") - void togglePostLike() throws Exception { - long projectId = 1L; - long postId = 100L; - long userId = 1L; - - PostLikeToggleResDto response = new PostLikeToggleResDto(postId, true, 8L); - - given(postFacade.togglePostLike(eq(projectId), eq(userId), eq(postId))) - .willReturn(response); - - mockMvc.perform(post("/api/v1/projects/{projectId}/boards/posts/{postId}/likes", projectId, postId) - .with(mockUser(userId)) - .header(AUTH_HEADER, TEST_ACCESS_TOKEN) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andDo(document("post-like-toggle", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - resource( - ResourceSnippetParameters.builder() - .tag("Post") - .summary("게시글 좋아요 토글") - .description("게시글 좋아요를 토글합니다.") - .pathParameters( - ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), - ResourceDocumentation.parameterWithName("postId").description("게시글 ID") - ) - .requestHeaders( - headerWithName(AUTH_HEADER).description("Bearer Access Token") - ) - .responseFields( - fieldWithPath("status").type(OBJECT).description("응답 상태"), - fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), - fieldWithPath("status.message").type(STRING).description("메시지"), - fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), - - fieldWithPath("body").type(OBJECT).description("응답 바디"), - fieldWithPath("body.post_id").type(NUMBER).description("게시글 ID"), - fieldWithPath("body.liked").type(BOOLEAN).description("좋아요 상태(true=좋아요됨)"), - fieldWithPath("body.like_count").type(NUMBER).description("요청 처리 후 좋아요 수") - ) - .build() - ) - )); - - verify(postFacade).togglePostLike(eq(projectId), eq(userId), eq(postId)); - } - - @Test - @DisplayName("게시글 삭제") - void deletePost() throws Exception { - long projectId = 1L; - long postId = 100L; - long userId = 1L; - - willDoNothing().given(postFacade).deletePost(eq(projectId), eq(userId), eq(postId)); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/boards/posts/{postId}", projectId, postId) - .with(mockUser(userId)) - .header(AUTH_HEADER, TEST_ACCESS_TOKEN) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andDo(document("post-delete", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - resource( - ResourceSnippetParameters.builder() - .tag("Post") - .summary("게시글 삭제") - .description("게시글을 삭제(soft delete)합니다. 작성자만 삭제할 수 있습니다.") - .pathParameters( - ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), - ResourceDocumentation.parameterWithName("postId").description("게시글 ID") - ) - .requestHeaders( - headerWithName(AUTH_HEADER).description("Bearer Access Token") - ) - .responseFields( - fieldWithPath("status").type(OBJECT).description("응답 상태"), - fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), - fieldWithPath("status.message").type(STRING).description("메시지"), - fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), - - fieldWithPath("body").type(NULL).optional().description("응답 바디(없음)") - ) - .build() - ) - )); - - verify(postFacade).deletePost(eq(projectId), eq(userId), eq(postId)); - } - @Test @DisplayName("게시글 프리뷰 조회") void getPostsPreview() throws Exception { @@ -499,14 +426,12 @@ void getPostsPreview() throws Exception { 100L, PostType.NOTICE, "공지 제목", - true, LocalDateTime.of(2026, 1, 31, 10, 0) ), new PostsPreviewResDto.Item( 101L, PostType.FREE, "자유글", - false, LocalDateTime.of(2026, 1, 31, 11, 0) ) ) @@ -551,7 +476,6 @@ void getPostsPreview() throws Exception { fieldWithPath("body.posts[].post_id").type(NUMBER).description("게시글 ID"), fieldWithPath("body.posts[].post_type").type(STRING).description("게시글 타입"), fieldWithPath("body.posts[].title").type(STRING).description("제목"), - fieldWithPath("body.posts[].is_pinned").type(BOOLEAN).description("상단 고정 여부"), fieldWithPath("body.posts[].created_at").type(STRING).description("작성 시각(ISO-8601)") ) .build() @@ -560,4 +484,5 @@ void getPostsPreview() throws Exception { verify(postFacade).getPostsPreview(eq(projectId), eq(userId), any(), eq(4)); } + } From 44ec0d5aa022e44e5bd3e60336021931773d071e Mon Sep 17 00:00:00 2001 From: infiniment Date: Sun, 8 Feb 2026 00:39:59 +0900 Subject: [PATCH 45/66] =?UTF-8?q?[Fix]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/controller/PostAttachmentControllerTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostAttachmentControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostAttachmentControllerTest.java index 5fe71a55..fc7b15ce 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostAttachmentControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostAttachmentControllerTest.java @@ -118,7 +118,7 @@ void uploadAndAttachFile() throws Exception { given(postAttachmentFacade.uploadAndAttachFile(eq(projectId), eq(userId), eq(postId), any())) .willReturn(response); - mockMvc.perform(multipart("/api/v1/projects/{projectId}/boards/boards/posts/{postId}/attachments/files", projectId, postId) + mockMvc.perform(multipart("/api/v1/projects/{projectId}/boards/posts/{postId}/attachments/files", projectId, postId) .file(file) .with(mockUser(userId)) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) @@ -193,7 +193,7 @@ void createAndAttachLink() throws Exception { given(postAttachmentFacade.createAndAttachLink(eq(projectId), eq(userId), eq(postId), any(PostLinkCreateReqDto.class))) .willReturn(response); - mockMvc.perform(post("/api/v1/projects/{projectId}/boards/boards/posts/{postId}/attachments/links", projectId, postId) + mockMvc.perform(post("/api/v1/projects/{projectId}/boards/posts/{postId}/attachments/links", projectId, postId) .with(mockUser(userId)) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) .contentType(MediaType.APPLICATION_JSON) @@ -254,7 +254,7 @@ void detach() throws Exception { willDoNothing().given(postAttachmentFacade).detach(eq(projectId), eq(userId), eq(postId), eq(documentId)); - mockMvc.perform(delete("/api/v1/projects/{projectId}/boards/boards/posts/{postId}/attachments/{documentId}", projectId, postId, documentId) + mockMvc.perform(delete("/api/v1/projects/{projectId}/boards/posts/{postId}/attachments/{documentId}", projectId, postId, documentId) .with(mockUser(userId)) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) .accept(MediaType.APPLICATION_JSON)) From 8607d3d377264def9d68b81ce119e513e24f5970 Mon Sep 17 00:00:00 2001 From: Juunbro Date: Sun, 8 Feb 2026 05:43:08 +0900 Subject: [PATCH 46/66] =?UTF-8?q?Feat/=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20api?= =?UTF-8?q?=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: 프로젝트 타이틀 추가 * Feat: 이미지 업로드 api * Fix: 테스트코드 부연설명 추가 --- .../domain/mypage/dto/ProfileSettingsDto.java | 2 +- .../domain/mypage/service/MypageService.java | 4 +- .../converter/ProjectUserConverter.java | 1 + .../team/project/dto/UserProjectDto.java | 1 + .../upload/controller/UploadController.java | 29 ++++++++ .../nect/api/domain/upload/dto/UploadDto.java | 9 +++ .../domain/upload/service/UploadService.java | 23 +++++++ .../api/global/config/SecurityConfig.java | 2 +- .../controller/MypageControllerTest.java | 6 +- .../controller/ProjectUserControllerTest.java | 5 +- .../controller/UploadControllerTest.java | 67 +++++++++++++++++++ 11 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/upload/controller/UploadController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/upload/dto/UploadDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/upload/service/UploadService.java create mode 100644 nect-api/src/test/java/com/nect/api/domain/upload/controller/UploadControllerTest.java diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/dto/ProfileSettingsDto.java b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/ProfileSettingsDto.java index 0db21cf7..13498e7c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/dto/ProfileSettingsDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/ProfileSettingsDto.java @@ -28,7 +28,7 @@ public record ProfileSettingsResponseDto( ) {} public record ProfileSettingsRequestDto( - String profileImageUrl, + String profileImageFileName, String bio, String coreCompetencies, String userStatus, diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/service/MypageService.java b/nect-api/src/main/java/com/nect/api/domain/mypage/service/MypageService.java index 70855232..ff4aba7d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/service/MypageService.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/service/MypageService.java @@ -151,8 +151,8 @@ public void updateProfile(Long userId, ProfileSettingsRequestDto request) { User user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다.")); - if (request.profileImageUrl() != null) { - user.setProfileImageName(request.profileImageUrl()); + if (request.profileImageFileName() != null) { + user.setProfileImageName(request.profileImageFileName()); } if (request.bio() != null) { user.setBio(request.bio()); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/converter/ProjectUserConverter.java b/nect-api/src/main/java/com/nect/api/domain/team/project/converter/ProjectUserConverter.java index d3973de4..1400271d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/converter/ProjectUserConverter.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/converter/ProjectUserConverter.java @@ -20,6 +20,7 @@ public static ProjectUserResDto toProjectUserResDto(ProjectUser projectUser){ public static UserProjectDto toUserProjectDto(ProjectUser projectUser){ return UserProjectDto.builder() .projectId(projectUser.getProject().getId()) + .projectTitle(projectUser.getProject().getTitle()) .memberType(projectUser.getMemberType()) .build(); } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/dto/UserProjectDto.java b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/UserProjectDto.java index 9a7a6411..70db711c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/dto/UserProjectDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/UserProjectDto.java @@ -6,6 +6,7 @@ @Builder public record UserProjectDto( Long projectId, + String projectTitle, ProjectMemberType memberType ) { } diff --git a/nect-api/src/main/java/com/nect/api/domain/upload/controller/UploadController.java b/nect-api/src/main/java/com/nect/api/domain/upload/controller/UploadController.java new file mode 100644 index 00000000..664acbd5 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/upload/controller/UploadController.java @@ -0,0 +1,29 @@ +package com.nect.api.domain.upload.controller; + +import com.nect.api.domain.upload.dto.UploadDto; +import com.nect.api.domain.upload.service.UploadService; +import com.nect.api.global.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@RestController +@RequestMapping("/api/v1/files") +@RequiredArgsConstructor +public class UploadController { + + private final UploadService uploadService; + + @PostMapping("/upload") + public ApiResponse uploadProfileImage( + @RequestParam("file") MultipartFile file + ) throws IOException { + UploadDto.ImageUploadResponseDto response = uploadService.uploadProfileImage(file); + return ApiResponse.ok(response); + } +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/upload/dto/UploadDto.java b/nect-api/src/main/java/com/nect/api/domain/upload/dto/UploadDto.java new file mode 100644 index 00000000..537bda54 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/upload/dto/UploadDto.java @@ -0,0 +1,9 @@ +package com.nect.api.domain.upload.dto; + +public class UploadDto { + + public record ImageUploadResponseDto( + String fileName, + String fileUrl + ) {} +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/upload/service/UploadService.java b/nect-api/src/main/java/com/nect/api/domain/upload/service/UploadService.java new file mode 100644 index 00000000..b7982eb2 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/upload/service/UploadService.java @@ -0,0 +1,23 @@ +package com.nect.api.domain.upload.service; + +import com.nect.api.domain.upload.dto.UploadDto; +import com.nect.api.global.infra.S3Service; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Service +@RequiredArgsConstructor +public class UploadService { + + private final S3Service s3Service; + + public UploadDto.ImageUploadResponseDto uploadProfileImage(MultipartFile file) throws IOException { + String fileName = s3Service.uploadFile(file); + String fileUrl = s3Service.getPresignedGetUrl(fileName); + + return new UploadDto.ImageUploadResponseDto(fileName, fileUrl); + } +} diff --git a/nect-api/src/main/java/com/nect/api/global/config/SecurityConfig.java b/nect-api/src/main/java/com/nect/api/global/config/SecurityConfig.java index 6184c054..9fabe861 100644 --- a/nect-api/src/main/java/com/nect/api/global/config/SecurityConfig.java +++ b/nect-api/src/main/java/com/nect/api/global/config/SecurityConfig.java @@ -45,7 +45,7 @@ public class SecurityConfig { "/api/v1/users/check", "/api/v1/users/signup", "/api/v1/users/login", "/api/v1/users/test-login", "/api/v1/users/refresh", "/api/v1/enums/**", "/api/v1/home/**","/ws-chat", - "/" + "/api/v1/files/upload" ); private static final List JWT_EXCLUDE_PATHS = EXCLUDE_PATHS.stream() diff --git a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java index 3cac3531..7603a28a 100644 --- a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java @@ -109,7 +109,7 @@ void updateProfile() throws Exception { // 요청 JSON (모든 필드 포함한 완전한 예시) String requestJson = "{" - + "\"profileImageUrl\": \"https://example.com/profile/kim-junhyeok.jpg\"," + + "\"profileImageFileName\": \"kim-junhyeok.jpg\"," + "\"bio\": \"안녕하세요! 3년차 백엔드 개발자 김준혁입니다. Spring Boot와 Java에 능숙하며 RESTful API 설계 및 구현을 전문으로 합니다.\"," + "\"coreCompetencies\": \"Spring Boot, Java, REST API, MySQL, Redis, Docker, Kubernetes, AWS\"," + "\"userStatus\": \"JOB_SEEKING\"," @@ -206,7 +206,7 @@ void updateProfile() throws Exception { .summary("마이페이지 프로필 수정") .description("마이페이지 프로필 정보를 부분 수정합니다.\n\n" + "**수정 가능한 필드**\n" + - "- 기본정보: 프로필 사진 (profileImageUrl), 자기소개 (bio), 핵심 역량 (coreCompetencies), 사용자 상태 (userStatus), 공개 매칭 여부 (isPublicMatching), 경력 기간 (careerDuration), 관심 직무 (interestedJob), 관심 직종 (interestedField)\n" + + "- 기본정보: 프로필 사진 파일명 (profileImageFileName), 자기소개 (bio), 핵심 역량 (coreCompetencies), 사용자 상태 (userStatus), 공개 매칭 여부 (isPublicMatching), 경력 기간 (careerDuration), 관심 직무 (interestedJob), 관심 직종 (interestedField)\n" + "- 경력관리: 경력 목록 (careers) - 프로젝트명, 산업분야, 기간, 역할, 주요 성과 저장 (projectName, industryField, startDate, endDate, isOngoing, role, achievements)\n" + "- 포트폴리오: 포트폴리오 목록 (portfolios) - 제목, 외부 링크, 파일 URL 관리 (title, link, fileUrl)\n" + "- 프로젝트 히스토리: 프로젝트 히스토리 목록 (projectHistories) - 프로젝트명, 이미지, 설명, 기간 관리\n\n" + @@ -217,7 +217,7 @@ void updateProfile() throws Exception { "- role도 응답에서 한국어로 변환됩니다 (개발자, 디자이너, 기획자, 마케터).\n" + "- 유효하지 않은 userStatus 값이면 M002 에러가 반환됩니다.") .requestFields( - fieldWithPath("profileImageUrl").type(JsonFieldType.STRING).description("프로필 사진 URL. 사용자 프로필 이미지 주소 (예: https://example.com/profile.jpg)").optional(), + fieldWithPath("profileImageFileName").type(JsonFieldType.STRING).description("프로필 사진 파일명. S3 업로드 후 반환받은 파일명 (예: 550e8400-e29b-41d4-a716-446655440000_profile.jpg)").optional(), fieldWithPath("bio").type(JsonFieldType.STRING).description("자기소개. 사용자가 작성한 자유로운 형식의 소개글 (예: 안녕하세요! 3년차 백엔드 개발자입니다)").optional(), fieldWithPath("coreCompetencies").type(JsonFieldType.STRING).description("핵심 역량. 보유 중인 주요 기술 및 역량을 쉼표로 구분하여 작성 (예: Spring Boot, Java, REST API, MySQL)").optional(), fieldWithPath("userStatus").type(JsonFieldType.STRING).description("사용자 상태. 현재 상태를 나타내는 한국어 값 (재학중, 구직중, 재직중)").optional(), diff --git a/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectUserControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectUserControllerTest.java index c7d4f80c..d01b79bd 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectUserControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectUserControllerTest.java @@ -71,7 +71,9 @@ void setUpAuth() { @Test void getProjectsByUser() throws Exception { UserProjectDto dto = UserProjectDto.builder() - .projectId(1L).memberType(ProjectMemberType.MEMBER).build(); + .projectId(1L) + .projectTitle("Nect 프로젝트") + .memberType(ProjectMemberType.MEMBER).build(); given(projectUserService.findProjectsByUser(anyLong())).willReturn(List.of(dto)); @@ -96,6 +98,7 @@ void getProjectsByUser() throws Exception { fieldWithPath("body").description("응답 데이터"), fieldWithPath("body[].projectId").description("프로젝트 ID"), + fieldWithPath("body[].projectTitle").description("프로젝트 제목"), fieldWithPath("body[].memberType").description("프로젝트 멤버 타입(MEMBER | LEADER)") ) .build() diff --git a/nect-api/src/test/java/com/nect/api/domain/upload/controller/UploadControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/upload/controller/UploadControllerTest.java new file mode 100644 index 00000000..a076d2f3 --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/upload/controller/UploadControllerTest.java @@ -0,0 +1,67 @@ +package com.nect.api.domain.upload.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.nect.api.NectDocumentApiTester; +import com.nect.api.domain.upload.dto.UploadDto; +import com.nect.api.domain.upload.service.UploadService; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class UploadControllerTest extends NectDocumentApiTester { + + @MockitoBean + private UploadService uploadService; + + @Test + void uploadProfileImage() throws Exception { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "profile.jpg", + "image/jpeg", + "fake image content".getBytes() + ); + + UploadDto.ImageUploadResponseDto mockResponse = new UploadDto.ImageUploadResponseDto( + "550e8400-e29b-41d4-a716-446655440000_profile.jpg", + "https://s3.example.com/nect/550e8400-e29b-41d4-a716-446655440000_profile.jpg?X-Amz-Algorithm=..." + ); + when(uploadService.uploadProfileImage(any())).thenReturn(mockResponse); + + // when + this.mockMvc.perform(multipart("/api/v1/files/upload") + .file(file)) + .andExpect(status().isOk()) + .andDo(document("file-upload-image", + resource( + ResourceSnippetParameters.builder() + .tag("files") + .summary("이미지 업로드") + .description("프로필 이미지를 업로드합니다. 업로드된 파일명과 Presigned URL을 반환합니다.\n\n" + + "**지원 포맷:** JPG, PNG, GIF, BMP, WebP 등\n\n" + + "**요청 파라미터:**\n" + + "- file (필수): 이미지 파일\n" + + " 예시: test-image.jpg, photo.png\n" + + " 형식: multipart/form-data") + .responseFields( + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), + fieldWithPath("status.description").type(JsonFieldType.STRING).description("상태 설명").optional(), + fieldWithPath("body.fileName").type(JsonFieldType.STRING).description("업로드된 파일명 (DB 저장 값)"), + fieldWithPath("body.fileUrl").type(JsonFieldType.STRING).description("S3 Presigned URL (5분 유효)") + ) + .build() + ) + )); + } +} \ No newline at end of file From dd4b0a83bd5e407715f2374f31a0d406e3ae4d1d Mon Sep 17 00:00:00 2001 From: KimMinKyu Date: Sun, 8 Feb 2026 11:04:08 +0900 Subject: [PATCH 47/66] =?UTF-8?q?Fix=20:=20=EC=9E=91=EC=97=85=EC=8B=A4=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=B0=8F=20=EA=B3=B5=EC=A7=80=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B0=9C=EC=84=A0=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix : 작업실 채팅 닉네임 조회 및 공지 로직 개선 및 분석 기반 프로젝트 생성 개선 * fix : 분석서 테스트 코드 리팩토링 --- .../converter/IdeaAnalysisResponseConverter.java | 1 + .../analysis/converter/IdeaAnalysisSchemaBuilder.java | 2 ++ .../domain/analysis/dto/res/IdeaAnalysisResponseDto.java | 1 + .../api/domain/analysis/service/IdeaAnalysisService.java | 3 +++ .../team/chat/controller/ChatWebSocketController.java | 1 - .../nect/api/domain/team/chat/service/ChatService.java | 9 +++++++++ .../api/domain/team/chat/service/TeamChatService.java | 6 +++--- .../api/domain/team/project/service/ProjectService.java | 6 +++++- nect-api/src/main/resources/prompts/idea-analysis.txt | 1 + .../nect/api/analysis/IdeaAnalysisControllerTest.java | 3 +++ .../nect/core/entity/analysis/ProjectIdeaAnalysis.java | 4 ++++ .../src/main/java/com/nect/core/entity/team/Project.java | 6 ++++++ .../core/repository/team/chat/ChatMessageRepository.java | 1 + 13 files changed, 39 insertions(+), 5 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisResponseConverter.java b/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisResponseConverter.java index 87cc0d92..400fbfc4 100644 --- a/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisResponseConverter.java +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisResponseConverter.java @@ -25,6 +25,7 @@ public IdeaAnalysisResponseDto toIdeaAnalysisResponse(OpenAiResponse openAiRespo return IdeaAnalysisResponseDto.builder() .recommendedProjectNames(parseRecommendedProjectNames(root)) + .description(root.get("description").asText()) .projectDuration(parseProjectDuration(root)) .teamComposition(parseTeamComposition(root)) .improvementPoints(parseImprovementPoints(root)) diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSchemaBuilder.java b/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSchemaBuilder.java index 4fa995fd..b0ae4936 100644 --- a/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSchemaBuilder.java +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSchemaBuilder.java @@ -21,6 +21,7 @@ public Map buildIdeaAnalysisSchema() { return Map.of( "type", "object", "properties", Map.of( + "description", Map.of("type", "string"), "recommended_project_names", Map.of( "type", "array", "items", Map.of("type", "string") @@ -92,6 +93,7 @@ public Map buildIdeaAnalysisSchema() { ) ), "required", List.of( + "description", "recommended_project_names", "project_duration", "team_composition", diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/dto/res/IdeaAnalysisResponseDto.java b/nect-api/src/main/java/com/nect/api/domain/analysis/dto/res/IdeaAnalysisResponseDto.java index e842a998..93314db5 100644 --- a/nect-api/src/main/java/com/nect/api/domain/analysis/dto/res/IdeaAnalysisResponseDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/dto/res/IdeaAnalysisResponseDto.java @@ -18,6 +18,7 @@ public class IdeaAnalysisResponseDto { private Long analysisId; + private String description; private List recommendedProjectNames; diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisService.java b/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisService.java index 5c415196..0827d94e 100644 --- a/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisService.java +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisService.java @@ -140,6 +140,7 @@ private ProjectIdeaAnalysis createAnalysisEntity(Long userId, IdeaAnalysisRespon // 메인 분석 엔티티 생성 ProjectIdeaAnalysis analysis = ProjectIdeaAnalysis.builder() .userId(userId) + .description(response.getDescription()) .recommendedProjectName1(projectNames.get(0)) .recommendedProjectName2(projectNames.size() > 1 ? projectNames.get(1) : null) .recommendedProjectName3(projectNames.size() > 2 ? projectNames.get(2) : null) @@ -222,6 +223,8 @@ public IdeaAnalysisPageResponseDto getAnalysisPage(Long userId, int page) { } + + @Transactional public void deleteAnalysis(Long userId, Long analysisId) { diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatWebSocketController.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatWebSocketController.java index 07de7f0e..8b03ed79 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatWebSocketController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatWebSocketController.java @@ -1,7 +1,6 @@ package com.nect.api.domain.team.chat.controller; -import com.nect.api.domain.team.chat.dto.req.ChatFileSendRequestDto; import com.nect.api.domain.team.chat.dto.req.ChatMessageSendRequestDto; import com.nect.api.domain.team.chat.service.ChatFileService; import com.nect.api.domain.team.chat.service.ChatService; diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatService.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatService.java index e8f67c64..f30cd5ef 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatService.java @@ -177,6 +177,15 @@ public ChatNoticeResponseDto createNotice(Long messageId, Boolean isPinned,Long chatRoomUserRepository.findMemberInRoom(roomId, userId) .orElseThrow(() -> new ChatException(ChatErrorCode.USER_NOT_FOUND)); + chatRoomUserRepository.findMemberInRoom(roomId, userId) + .orElseThrow(() -> new ChatException(ChatErrorCode.USER_NOT_FOUND)); + + if (Boolean.TRUE.equals(isPinned)) { + List existingPinnedMessages = chatMessageRepository.findAllByChatRoomIdAndIsPinnedTrue(roomId); + for (ChatMessage pinnedMessage : existingPinnedMessages) { + pinnedMessage.setIsPinned(false); + } + } message.setIsPinned(isPinned); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/TeamChatService.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/TeamChatService.java index 2af65ff5..9c1faf0b 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/TeamChatService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/TeamChatService.java @@ -181,13 +181,13 @@ public ChatRoomInviteResponseDto inviteMembers( .orElseThrow(() -> new ChatException(ChatErrorCode.USER_NOT_FOUND)); String invitedNames = newMembers.stream() - .map(User::getNickname) + .map(User::getName) .collect(Collectors.joining(", ")); //TODO : 초대메시지 String notificationMessage = String.format( "%s님이 %s님을 초대했습니다.", - inviter.getNickname(), + inviter.getName(), invitedNames ); @@ -195,7 +195,7 @@ public ChatRoomInviteResponseDto inviteMembers( List invitedUserNames = newMembers.stream() - .map(User::getNickname) + .map(User::getName) .collect(Collectors.toList()); List profileImages = newMembers.stream() diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java index 1cebaa0d..8a3e03af 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java @@ -33,6 +33,7 @@ import org.springframework.transaction.annotation.Transactional; import java.lang.reflect.Field; +import java.time.LocalDate; import java.util.List; import java.util.stream.Collectors; @@ -128,10 +129,12 @@ private Project createProject(ProjectIdeaAnalysis analysis) { try { Project project = Project.builder() .title(analysis.getRecommendedProjectName1()) - .description("AI 분석 기반으로 생성된 프로젝트입니다") //TODO 분석서에서 마땅한 값 고려중 + .description(analysis.getDescription()) .status(ProjectStatus.ACTIVE) .build(); + project.setProjectPeriod(analysis.getProjectStartDate(), analysis.getProjectEndDate()); + setRecruitmentStatus(project, RecruitmentStatus.OPEN); return projectRepository.save(project); } catch (Exception e) { @@ -139,6 +142,7 @@ private Project createProject(ProjectIdeaAnalysis analysis) { } } + private void saveTeamRoles(Long projectId, ProjectIdeaAnalysis analysis) { try { Project project = projectRepository.findById(projectId) diff --git a/nect-api/src/main/resources/prompts/idea-analysis.txt b/nect-api/src/main/resources/prompts/idea-analysis.txt index 4f25d760..cb0bbbbf 100644 --- a/nect-api/src/main/resources/prompts/idea-analysis.txt +++ b/nect-api/src/main/resources/prompts/idea-analysis.txt @@ -11,6 +11,7 @@ **⚠️ 매우 중요: weekly_roadmap 배열의 길이는 반드시 project_duration.total_weeks와 정확히 일치해야 합니다!** { + "description": "<아이디어 분석을 바탕으로 이 프로젝트의 핵심 가치, 목표 및 기대효과를 전문적인 어조로 기술 (1문장)>", "recommended_project_names": [ "실제 프로젝트에 어울리는 이름1", "실제 프로젝트에 어울리는 이름2", diff --git a/nect-api/src/test/java/com/nect/api/analysis/IdeaAnalysisControllerTest.java b/nect-api/src/test/java/com/nect/api/analysis/IdeaAnalysisControllerTest.java index 76c83406..4487eb4c 100644 --- a/nect-api/src/test/java/com/nect/api/analysis/IdeaAnalysisControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/analysis/IdeaAnalysisControllerTest.java @@ -150,6 +150,7 @@ void setUpAuth() { fieldWithPath("status.description").description("상세 설명").optional(), fieldWithPath("body.analysis_id").description("분석 결과 ID"), + fieldWithPath("body.description").description("AI 프로젝트 분석 상세 요약"), fieldWithPath("body.recommended_project_names[]").description("추천 프로젝트 이름 리스트 (최대 3개)"), fieldWithPath("body.project_duration.start_date").description("프로젝트 시작 예정일 (yyyy-MM-dd)"), @@ -217,6 +218,7 @@ void setUpAuth() { fieldWithPath("status.description").description("상세 설명").optional(), fieldWithPath("body.analysis.analysis_id").description("분석 결과 ID"), + fieldWithPath("body.analysis.description").description("AI 프로젝트 분석 상세 요약"), fieldWithPath("body.analysis.recommended_project_names[]").description("추천 프로젝트 이름 리스트"), fieldWithPath("body.analysis.project_duration.start_date").description("프로젝트 시작 예정일"), @@ -255,6 +257,7 @@ void setUpAuth() { private IdeaAnalysisResponseDto mockAnalysisResponse() { return IdeaAnalysisResponseDto.builder() .analysisId(1L) + .description("이 프로젝트는 대학생들의 학습 효율을 높이기 위한 AI 기반 스터디 매칭 플랫폼으로, 실시간 데이터 동기화를 통한 사용자 경험 증대를 목표로 합니다.") .recommendedProjectNames(List.of("StudyConnect", "CampusLink", "StudyMate")) .projectDuration(IdeaAnalysisResponseDto.ProjectDuration.builder() .startDate(LocalDate.of(2026, 3, 1)) diff --git a/nect-core/src/main/java/com/nect/core/entity/analysis/ProjectIdeaAnalysis.java b/nect-core/src/main/java/com/nect/core/entity/analysis/ProjectIdeaAnalysis.java index af1225ef..e4d1027b 100644 --- a/nect-core/src/main/java/com/nect/core/entity/analysis/ProjectIdeaAnalysis.java +++ b/nect-core/src/main/java/com/nect/core/entity/analysis/ProjectIdeaAnalysis.java @@ -26,6 +26,8 @@ public class ProjectIdeaAnalysis extends BaseEntity { @Column(name = "user_id", nullable = false) private Long userId; + @Column(name = "description", columnDefinition = "TEXT", nullable = false) + private String description; //TODO : 정규화 위반이긴하지만 추천명 3개를 담겠다고 별도의 엔티티를 만드는게 성능적으로 더 별로라 생각해서 별도 필드로 구현했습니다. @Column(name = "recommended_project_name_1", length = 100, nullable = false) @@ -62,6 +64,7 @@ public class ProjectIdeaAnalysis extends BaseEntity { @Builder public ProjectIdeaAnalysis(Long userId, + String description, String recommendedProjectName1, String recommendedProjectName2, String recommendedProjectName3, @@ -69,6 +72,7 @@ public ProjectIdeaAnalysis(Long userId, LocalDate projectEndDate, Integer totalWeeks) { this.userId = userId; + this.description = description; this.recommendedProjectName1 = recommendedProjectName1; this.recommendedProjectName2 = recommendedProjectName2; this.recommendedProjectName3 = recommendedProjectName3; diff --git a/nect-core/src/main/java/com/nect/core/entity/team/Project.java b/nect-core/src/main/java/com/nect/core/entity/team/Project.java index 7ba6f5c8..fe6470ff 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/Project.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/Project.java @@ -88,4 +88,10 @@ public void updateNoticeText(String noticeText) { public void updateRegularMeetingText(String regularMeetingText) { this.regularMeetingText = regularMeetingText; } + + + public void setProjectPeriod(LocalDate startDate, LocalDate endDate) { + this.plannedStartedOn = startDate; + this.plannedEndedOn = endDate; + } } \ No newline at end of file diff --git a/nect-core/src/main/java/com/nect/core/repository/team/chat/ChatMessageRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/chat/ChatMessageRepository.java index 3ea0a283..ce626787 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/chat/ChatMessageRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/chat/ChatMessageRepository.java @@ -73,5 +73,6 @@ Page searchByKeyword( Pageable pageable ); + List findAllByChatRoomIdAndIsPinnedTrue(Long chatRoomId); } From 2e60019e94e06b60c1a5f37932ca13a76477a458 Mon Sep 17 00:00:00 2001 From: infiniment Date: Sun, 8 Feb 2026 15:08:39 +0900 Subject: [PATCH 48/66] =?UTF-8?q?[Feat]=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=97=85=EC=8B=A4=20=ED=8C=80=20=ED=8C=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80,=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProjectPartsCommandController.java | 44 +++++ .../project/dto/ProjectPartCreateReqDto.java | 16 ++ .../project/dto/ProjectPartCreateResDto.java | 22 +++ .../project/dto/ProjectPartUpdateReqDto.java | 12 ++ .../project/dto/ProjectPartUpdateResDto.java | 23 +++ .../service/ProjectTeamCommandService.java | 179 ++++++++++++++++++ .../team/ProjectTeamRoleRepository.java | 16 ++ 7 files changed, 312 insertions(+) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsCommandController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartCreateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartCreateResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartUpdateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartUpdateResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamCommandService.java diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsCommandController.java b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsCommandController.java new file mode 100644 index 00000000..fe0bd87e --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsCommandController.java @@ -0,0 +1,44 @@ +package com.nect.api.domain.team.project.controller; + +import com.nect.api.domain.team.project.dto.ProjectPartCreateReqDto; +import com.nect.api.domain.team.project.dto.ProjectPartCreateResDto; +import com.nect.api.domain.team.project.dto.ProjectPartUpdateReqDto; +import com.nect.api.domain.team.project.dto.ProjectPartUpdateResDto; +import com.nect.api.domain.team.project.service.ProjectTeamCommandService; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}") +public class ProjectPartsCommandController { + + private final ProjectTeamCommandService projectTeamCommandService; + + // 프로젝트 파트 추가 (작업실 팀 레인/ 드롭다운 연동) + @PostMapping("/parts") + public ApiResponse createProjectPart ( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody ProjectPartCreateReqDto req + ) { + return ApiResponse.ok(projectTeamCommandService.createProjectPart(projectId, userDetails.getUserId(), req)); + } + + // 프로젝트 커스텀 파트 이름 수정(기존 roleField는 수정 불가) + @PatchMapping("/parts/{partId}") + public ApiResponse updateProjectPart( + @PathVariable Long projectId, + @PathVariable Long partId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody ProjectPartUpdateReqDto req + ) { + return ApiResponse.ok( + projectTeamCommandService.updateProjectPart(projectId, partId, userDetails.getUserId(), req) + ); + } + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartCreateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartCreateReqDto.java new file mode 100644 index 00000000..534b5278 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartCreateReqDto.java @@ -0,0 +1,16 @@ +package com.nect.api.domain.team.project.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +public record ProjectPartCreateReqDto( + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("required_count") + Integer requiredCount +) { +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartCreateResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartCreateResDto.java new file mode 100644 index 00000000..426cc7b0 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartCreateResDto.java @@ -0,0 +1,22 @@ +package com.nect.api.domain.team.project.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +public record ProjectPartCreateResDto( + @JsonProperty("part_id") + Long partId, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("part_label") + String part_label, + + @JsonProperty("required_count") + Integer requiredCount +) { +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartUpdateReqDto.java new file mode 100644 index 00000000..e9eb8153 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartUpdateReqDto.java @@ -0,0 +1,12 @@ +package com.nect.api.domain.team.project.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record ProjectPartUpdateReqDto( + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("required_count") + Integer requiredCount +) { +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartUpdateResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartUpdateResDto.java new file mode 100644 index 00000000..759702d6 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/dto/ProjectPartUpdateResDto.java @@ -0,0 +1,23 @@ +package com.nect.api.domain.team.project.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +public record ProjectPartUpdateResDto( + @JsonProperty("part_id") + Long partId, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("part_label") + String partLabel, + + @JsonProperty("required_count") + Integer requiredCount +) { +} + diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamCommandService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamCommandService.java new file mode 100644 index 00000000..a3c09b73 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamCommandService.java @@ -0,0 +1,179 @@ +package com.nect.api.domain.team.project.service; + +import com.nect.api.domain.team.project.dto.ProjectPartCreateReqDto; +import com.nect.api.domain.team.project.dto.ProjectPartCreateResDto; +import com.nect.api.domain.team.project.dto.ProjectPartUpdateReqDto; +import com.nect.api.domain.team.project.dto.ProjectPartUpdateResDto; +import com.nect.api.domain.team.project.enums.code.ProjectErrorCode; +import com.nect.api.domain.team.project.exception.ProjectException; +import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.ProjectTeamRole; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.user.enums.RoleField; +import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectTeamRoleRepository; +import com.nect.core.repository.team.ProjectUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProjectTeamCommandService { + private final ProjectTeamRoleRepository projectTeamRoleRepository; + private final ProjectUserRepository projectUserRepository; + private final ProjectRepository projectRepository; + + private void assertActiveProjectMember(Long projectId, Long userId) { + boolean ok = projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus( + projectId, userId, ProjectMemberStatus.ACTIVE + ); + if (!ok) { + throw new ProjectException(ProjectErrorCode.PROJECT_MEMBER_FORBIDDEN, + "projectId=" + projectId + ", userId=" + userId); + } + } + + private String normalize(String s) { + if (s == null) return null; + String t = s.trim(); + return t.isBlank() ? null : t; + } + + @Transactional + public ProjectPartCreateResDto createProjectPart(Long projectId, Long userId, ProjectPartCreateReqDto req) { + assertActiveProjectMember(projectId, userId); + + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new ProjectException(ProjectErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId)); + + + // roleField 필수 + RoleField roleField = req.roleField(); + if(roleField == null) { + throw new ProjectException(ProjectErrorCode.INVALID_REQUEST, "role_field is null"); + } + + // 필요인원은 기본 1 -> 아직 정해진게 없음 + int requiredCount = (req.requiredCount() == null) ? 1 : req.requiredCount(); + if(requiredCount < 1) { + throw new ProjectException(ProjectErrorCode.INVALID_REQUEST, + "required_count must be >= 1"); + } + + String customName = req.customRoleFieldName(); + + if(roleField == RoleField.CUSTOM) { + customName = normalize(customName); + if (customName == null || customName.isBlank()) { + throw new ProjectException(ProjectErrorCode.INVALID_CUSTOM_PART_NAME, + "custom_role_field is blank"); + } + + // CUSTOM 중복 방지 + boolean duplicate = projectTeamRoleRepository + .existsByProject_IdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldName( + projectId, RoleField.CUSTOM, customName + ); + + if(duplicate) { + throw new ProjectException(ProjectErrorCode.DUPLICATE_PART, + "duplicate custom_role_field_name=" + customName + ); + } + }else { + boolean duplicate = projectTeamRoleRepository + .existsByProject_IdAndDeletedAtIsNullAndRoleField(projectId, roleField); + + if (duplicate) { + throw new ProjectException(ProjectErrorCode.DUPLICATE_PART, + "duplicate role_field=" + roleField); + } + } + + if (customName != null && customName.length() > 50) { + throw new ProjectException(ProjectErrorCode.INVALID_CUSTOM_PART_NAME, "custom name too long"); + } + + // 저장 + ProjectTeamRole saved = projectTeamRoleRepository.save( + ProjectTeamRole.builder() + .project(project) + .roleField(roleField) + .customRoleFieldName(customName) + .requiredCount(requiredCount) + .build() + ); + + String label = (saved.getRoleField() == RoleField.CUSTOM) + ? saved.getCustomRoleFieldName() + : saved.getRoleField().getLabelEn(); + + return new ProjectPartCreateResDto( + saved.getId(), + saved.getRoleField(), + saved.getCustomRoleFieldName(), + label, + saved.getRequiredCount() + ); + } + + // 팀 파트 이름 수정 서비스 + @Transactional + public ProjectPartUpdateResDto updateProjectPart(Long projectId, Long partId, Long userId, ProjectPartUpdateReqDto req) { + assertActiveProjectMember(projectId, userId); + + ProjectTeamRole part = projectTeamRoleRepository.findByIdAndProject_IdAndDeletedAtIsNull(partId, projectId) + .orElseThrow(() -> new ProjectException(ProjectErrorCode.PROJECT_PART_NOT_FOUND, + "projectId=" + projectId + ", partId=" + partId) + ); + + // 필요인원 수정 + if (req.requiredCount() != null) { + if (req.requiredCount() < 1) { + throw new ProjectException(ProjectErrorCode.INVALID_REQUEST, "required_count must be >= 1"); + } + part.setRequiredCount(req.requiredCount()); + } + + // 커스텀 필드 수정 + if (req.customRoleFieldName() != null) { + if (part.getRoleField() != RoleField.CUSTOM) { + // 커스텀이 아닌 필드는 이름 수정 불가 + throw new ProjectException(ProjectErrorCode.INVALID_REQUEST, + "custom_role_field_name can be updated only when role_field=CUSTOM"); + } + + String newName = normalize(req.customRoleFieldName()); + if (newName == null || newName.length() > 50) { + throw new ProjectException(ProjectErrorCode.INVALID_CUSTOM_PART_NAME, "invalid custom name"); + } + + // 이름이 바뀌는 경우에만 중복 체크 + String oldName = part.getCustomRoleFieldName(); + if (!newName.equals(oldName)) { + boolean duplicate = projectTeamRoleRepository + .existsByProject_IdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldName( + projectId, RoleField.CUSTOM, newName + ); + if (duplicate) { + throw new ProjectException(ProjectErrorCode.DUPLICATE_PART, + "duplicate custom_role_field_name=" + newName); + } + part.setCustomRoleFieldName(newName); + } + } + + String label = (part.getRoleField() == RoleField.CUSTOM) + ? part.getCustomRoleFieldName() + : part.getRoleField().getLabelEn(); + + return new ProjectPartUpdateResDto( + part.getId(), + part.getRoleField(), + part.getCustomRoleFieldName(), + label, + part.getRequiredCount() + ); + } +} diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java index df756510..3a42b9b0 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectTeamRoleRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface ProjectTeamRoleRepository extends JpaRepository { List findByProjectId(Long projectId); @@ -43,4 +44,19 @@ boolean existsByProject_IdAndRoleFieldAndCustomRoleFieldName( RoleField roleField, String customRoleFieldName ); + + boolean existsByProject_IdAndDeletedAtIsNullAndRoleField(Long projectId, RoleField roleField); + + boolean existsByProject_IdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldName( + Long projectId, + RoleField roleField, + String customRoleFieldName + ); + + + Optional findByIdAndDeletedAtIsNull(Long id); + + Optional findByIdAndProject_IdAndDeletedAtIsNull(Long id, Long projectId); + + } From c63d1d4e6ca41fc8e3a2bcc99235f6704847bb88 Mon Sep 17 00:00:00 2001 From: infiniment Date: Sun, 8 Feb 2026 15:08:50 +0900 Subject: [PATCH 49/66] =?UTF-8?q?[Test]=20api=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProjectPartsCommandControllerTest.java | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsCommandControllerTest.java diff --git a/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsCommandControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsCommandControllerTest.java new file mode 100644 index 00000000..9cbe7118 --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsCommandControllerTest.java @@ -0,0 +1,241 @@ +package com.nect.api.domain.team.project.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.project.dto.ProjectPartCreateReqDto; +import com.nect.api.domain.team.project.dto.ProjectPartCreateResDto; +import com.nect.api.domain.team.project.dto.ProjectPartUpdateReqDto; +import com.nect.api.domain.team.project.dto.ProjectPartUpdateResDto; +import com.nect.api.domain.team.project.service.ProjectTeamCommandService; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.user.enums.RoleField; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class ProjectPartsCommandControllerTest { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ProjectTeamCommandService projectTeamCommandService; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_MEMBER")) + .build() + ); + } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_MEMBER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return SecurityMockMvcRequestPostProcessors.authentication(auth); + } + + @Test + @DisplayName("프로젝트 파트 추가 (작업실 팀 레인)") + void createProjectPart() throws Exception { + long projectId = 1L; + long userId = 1L; + + ProjectPartCreateReqDto req = new ProjectPartCreateReqDto( + RoleField.CUSTOM, + "기획", + 1 + ); + + ProjectPartCreateResDto res = new ProjectPartCreateResDto( + 10L, + RoleField.CUSTOM, + "기획", + "기획", + 1 + ); + + given(projectTeamCommandService.createProjectPart(eq(projectId), eq(userId), any(ProjectPartCreateReqDto.class))) + .willReturn(res); + + mockMvc.perform(post("/api/v1/projects/{projectId}/parts", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andDo(document("project-parts-create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Project") + .summary("팀 파트 추가") + .description("작업실(위크미션/파트별 작업현황 등)에서 사용할 프로젝트 파트를 추가합니다.") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("role_field").type(STRING).description("파트 타입(RoleField). 현재는 CUSTOM 주로 사용"), + fieldWithPath("custom_role_field_name").type(STRING).optional() + .description("CUSTOM 파트명(직접 입력)"), + fieldWithPath("required_count").type(NUMBER).optional() + .description("필요 인원(기본 1)") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("프로젝트 파트 추가 결과"), + fieldWithPath("body.part_id").type(NUMBER).description("추가된 파트 ID"), + fieldWithPath("body.role_field").type(STRING).description("파트 타입(RoleField)"), + fieldWithPath("body.custom_role_field_name").type(STRING).optional().description("CUSTOM 파트명"), + fieldWithPath("body.part_label").type(STRING).description("표시 라벨(=CUSTOM이면 custom name, 아니면 enum label)"), + fieldWithPath("body.required_count").type(NUMBER).description("필요 인원") + ) + .build() + ) + )); + + verify(projectTeamCommandService).createProjectPart(eq(projectId), eq(userId), any(ProjectPartCreateReqDto.class)); + } + + @Test + @DisplayName("프로젝트 파트 수정 (CUSTOM 이름/필요 인원)") + void updateProjectPart() throws Exception { + long projectId = 1L; + long partId = 10L; + long userId = 1L; + + ProjectPartUpdateReqDto req = new ProjectPartUpdateReqDto( + "데이터", + 2 + ); + + ProjectPartUpdateResDto res = new ProjectPartUpdateResDto( + partId, + RoleField.CUSTOM, + "데이터", + "데이터", + 2 + ); + + given(projectTeamCommandService.updateProjectPart(eq(projectId), eq(partId), eq(userId), any(ProjectPartUpdateReqDto.class))) + .willReturn(res); + + mockMvc.perform(patch("/api/v1/projects/{projectId}/parts/{partId}", projectId, partId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andDo(document("project-parts-update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Project") + .summary("팀 파트 수정") + .description("프로젝트 파트의 CUSTOM 이름 및 필요 인원을 수정합니다. (role_field 자체는 수정 불가)") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID"), + parameterWithName("partId").description("파트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("custom_role_field_name").type(STRING).optional().description("CUSTOM 파트명(직접 입력)"), + fieldWithPath("required_count").type(NUMBER).optional().description("필요 인원(>=1)") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("프로젝트 파트 수정 결과"), + fieldWithPath("body.part_id").type(NUMBER).description("수정된 파트 ID"), + fieldWithPath("body.role_field").type(STRING).description("파트 타입(RoleField)"), + fieldWithPath("body.custom_role_field_name").type(STRING).optional().description("CUSTOM 파트명"), + fieldWithPath("body.part_label").type(STRING).description("표시 라벨(=CUSTOM이면 custom name, 아니면 enum label)"), + fieldWithPath("body.required_count").type(NUMBER).description("필요 인원") + ) + .build() + ) + )); + + verify(projectTeamCommandService).updateProjectPart(eq(projectId), eq(partId), eq(userId), any(ProjectPartUpdateReqDto.class)); + } +} From ad760d71c837ca9b4e8b86e8f6d37fe9da4b51fb Mon Sep 17 00:00:00 2001 From: Junyong <93406666+ggamnunq@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:15:33 +0900 Subject: [PATCH 50/66] Feat/mypage/project settings (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Feat] 마이페이지-프로젝트 설정 service 분리 & 추가기능 구현 틀 작성 * [Feat] 진행 중인 프로젝트 목표, 주요기능, 서비스 사용자 작성 구현 * [Feat] 마이페이지 - 진행 중인 프로젝트 세부 기획서 파일 업로드•수정•삭제 API 구현 * [Feat] 마이페이지 - 진행 중인 프로젝트 프로젝트 분야 수정 API 구현 * [Refactor] 이미지 유효기간 변경 5분 -> 1시간 * [Fix] s3Service null 일 때 예외 발생 -> null 반환으로 수정 * [Chore] scheduler 모듈 삭제 * [Feat] 홈화면 하단 통계 구현 * [Docs] README 제거 --- README.md | 391 ++++++++++++------ .../home/controller/HomeController.java | 7 + .../home/dto/HomeStatisticResponse.java | 9 + .../domain/home/facade/MainHomeFacade.java | 26 +- .../service/HomeStatisticsQueryService.java | 38 ++ .../mypage/controller/MypageController.java | 120 ++++-- .../converter/ProjectListConverter.java | 55 +++ .../dto/MyProjectStringListRequest.java | 11 + .../domain/mypage/dto/ProfileSettingsDto.java | 1 - .../service/MyPageProjectCommandService.java | 215 +++++++++- .../domain/team/file/service/FileService.java | 60 +-- .../team/file/util/FileUploadValidator.java | 66 +++ .../team/project/service/ProjectService.java | 21 +- .../com/nect/api/global/infra/S3Service.java | 2 +- .../MyPageControllerRestDocsTest.java | 142 +++++++ .../home/controller/HomeControllerTest.java | 43 ++ .../controller/MypageControllerTest.java | 275 +++++++++++- .../com/nect/core/entity/team/Project.java | 20 +- .../core/entity/team/ProjectInterest.java | 42 ++ .../core/entity/team/ProjectPlanFile.java | 67 +++ .../nect/core/entity/team/enums/FileExt.java | 25 +- .../core/entity/team/enums/PlanFileType.java | 5 + .../matching/MatchingRepository.java | 8 + .../team/ProjectInterestFieldRepository.java | 11 + .../team/ProjectPlanFileRepository.java | 10 + .../team/ProjectUserRepository.java | 19 +- nect-scheduler/build.gradle | 13 - .../scheduler/NectSchedulerApplication.java | 28 -- .../scheduler/config/SchedulingConfig.java | 33 -- .../src/main/resources/application.yml | 9 - settings.gradle | 1 - 31 files changed, 1432 insertions(+), 341 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/home/dto/HomeStatisticResponse.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/home/service/HomeStatisticsQueryService.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/converter/ProjectListConverter.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/dto/MyProjectStringListRequest.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/file/util/FileUploadValidator.java create mode 100644 nect-core/src/main/java/com/nect/core/entity/team/ProjectInterest.java create mode 100644 nect-core/src/main/java/com/nect/core/entity/team/ProjectPlanFile.java create mode 100644 nect-core/src/main/java/com/nect/core/entity/team/enums/PlanFileType.java create mode 100644 nect-core/src/main/java/com/nect/core/repository/team/ProjectInterestFieldRepository.java create mode 100644 nect-core/src/main/java/com/nect/core/repository/team/ProjectPlanFileRepository.java delete mode 100644 nect-scheduler/build.gradle delete mode 100644 nect-scheduler/src/main/java/com/nect/scheduler/NectSchedulerApplication.java delete mode 100644 nect-scheduler/src/main/java/com/nect/scheduler/config/SchedulingConfig.java delete mode 100644 nect-scheduler/src/main/resources/application.yml diff --git a/README.md b/README.md index a2248dba..a55f5c2b 100644 --- a/README.md +++ b/README.md @@ -1,172 +1,297 @@ # Nect-Backend -
- Nect 로고 -

Nect Backend

-

Spring Boot 멀티 모듈 기반 백엔드 프로젝트

-
+[//]: # () +[//]: # (
) - - +[//]: # ( Nect 로고) -## 01. 프로젝트에 대한 정보 +[//]: # (

Nect Backend

) -### (1) 프로젝트 제목 +[//]: # (

Spring Boot 멀티 모듈 기반 백엔드 프로젝트

) -- Nect-Backend +[//]: # (
) -### (2) 프로젝트 로고나 이미지 +[//]: # () +[//]: # () -- `docs/logo.png` (필요 시 교체) +[//]: # () -### (3) Repository 방문 횟수 +[//]: # () +[//]: # (## 01. 프로젝트에 대한 정보) -- 선택 사항 (필요 시 카운터 이미지 추가) +[//]: # () +[//]: # (### (1) 프로젝트 제목) -### (4) 프로젝트 정보 +[//]: # () +[//]: # (- Nect-Backend) -- 진행 단체/목적: TODO -- 개발 기간: TODO (예: 2026.01.01 ~ 2026.02.28) +[//]: # () +[//]: # (### (2) 프로젝트 로고나 이미지) -### (5) 배포 주소 +[//]: # () +[//]: # (- `docs/logo.png` (필요 시 교체)) -- 웹/프론트: TODO -- 백엔드(API): TODO +[//]: # () +[//]: # (### (3) Repository 방문 횟수) -### (6) 팀 소개 +[//]: # () +[//]: # (- 선택 사항 (필요 시 카운터 이미지 추가)) -- TODO: 팀원 이름 / 역할 / GitHub / 소속 +[//]: # () +[//]: # (### (4) 프로젝트 정보) -### (7) 프로젝트 소개 +[//]: # () +[//]: # (- 진행 단체/목적: TODO) -Nect는 사용자 경험을 개선하는 서비스를 목표로 하는 백엔드 프로젝트입니다. -API 서버와 스케줄러 서버를 분리하고, 공용 모듈을 통해 코드 재사용성과 유지보수성을 높였습니다. -OAuth2 로그인과 JWT 인증을 지원하며, Redis를 활용한 캐싱/세션 관리가 가능합니다. -외부 연동을 위한 클라이언트 모듈을 별도 분리하여 확장성을 확보했습니다. -실행 환경에 따라 H2(로컬) 또는 PostgreSQL(운영)로 유연하게 전환할 수 있습니다. +[//]: # (- 개발 기간: TODO (예: 2026.01.01 ~ 2026.02.28)) -## 02. 시작 가이드 +[//]: # () +[//]: # (### (5) 배포 주소) -### (1) 요구 사항 +[//]: # () +[//]: # (- 웹/프론트: TODO) -- JDK 21 -- (선택) Docker / Docker Compose +[//]: # (- 백엔드(API): TODO) -### (2) 설치 및 실행 +[//]: # () +[//]: # (### (6) 팀 소개) -```bash -# API 서버 실행 -./gradlew :nect-api:bootRun +[//]: # () +[//]: # (- TODO: 팀원 이름 / 역할 / GitHub / 소속) -# Scheduler 실행 -./gradlew :nect-scheduler:bootRun -``` +[//]: # () +[//]: # (### (7) 프로젝트 소개) -기본 포트는 `8080` 입니다. +[//]: # () +[//]: # (Nect는 사용자 경험을 개선하는 서비스를 목표로 하는 백엔드 프로젝트입니다. ) -#### 환경 변수 (.env 예시) +[//]: # (API 서버와 스케줄러 서버를 분리하고, 공용 모듈을 통해 코드 재사용성과 유지보수성을 높였습니다. ) -`.env` 파일을 루트에 두면 자동 로딩됩니다. +[//]: # (OAuth2 로그인과 JWT 인증을 지원하며, Redis를 활용한 캐싱/세션 관리가 가능합니다. ) -```env -# Database -DB_URL=jdbc:postgresql://localhost:5432/nect -DB_USERNAME=postgres -DB_PASSWORD=postgres -DB_DRIVER=org.postgresql.Driver -JPA_DIALECT=org.hibernate.dialect.PostgreSQLDialect -JPA_DDL_AUTO=validate +[//]: # (외부 연동을 위한 클라이언트 모듈을 별도 분리하여 확장성을 확보했습니다. ) -# Redis -REDIS_HOST=localhost -REDIS_PORT=6379 +[//]: # (실행 환경에 따라 H2(로컬) 또는 PostgreSQL(운영)로 유연하게 전환할 수 있습니다.) -# JWT -JWT_SECRET=change-me -JWT_ACCESS_TOKEN_EXPIRATION=3600000 -JWT_REFRESH_TOKEN_EXPIRATION=86400000 +[//]: # () +[//]: # (## 02. 시작 가이드) -# OAuth2 -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= -GOOGLE_REDIRECT_URI= -KAKAO_CLIENT_ID= -KAKAO_CLIENT_SECRET= -KAKAO_REDIRECT_URI= -OAUTH2_REDIRECT_URI=http://localhost:3000/auth/callback +[//]: # () +[//]: # (### (1) 요구 사항) -# R2 (S3 compatible) -R2_ACCESS_KEY= -R2_SECRET_KEY= -R2_REGION=us-east-1 -R2_END_POINT= -BUCKET_NAME= +[//]: # () +[//]: # (- JDK 21) -# App -AUTH_KEY= +[//]: # (- (선택) Docker / Docker Compose) -# OpenAI -OPENAI_API_KEY= -OPENAI_BASE_URL=https://api.openai.com -OPENAI_MODEL=gpt-4o-mini -OPENAI_FALLBACK_MODEL=gpt-4.1 -``` +[//]: # () +[//]: # (### (2) 설치 및 실행) -## 03. 기술 스택 +[//]: # () +[//]: # (```bash) -- Java 21 -- Spring Boot 3.5.x -- Gradle (멀티 모듈) -- JPA / QueryDSL -- Redis -- OAuth2 (Google/Kakao), JWT -- OpenAI API (클라이언트 모듈) +[//]: # (# API 서버 실행) -## 04. 화면 구성 / API 주소 +[//]: # (./gradlew :nect-api:bootRun) -- API 문서: TODO (예: Swagger/OpenAPI 경로) - -## 05. 주요 기능 +[//]: # () +[//]: # (# Scheduler 실행) -- OAuth2 로그인 및 JWT 인증 -- Redis 기반 캐싱/세션 관리 -- 배치/스케줄러 작업 분리 운영 -- 외부 API 연동(클라이언트 모듈) - -## 06. 아키텍처 - -### 모듈 구성 - -- `nect-api`: 메인 API 서버 -- `nect-scheduler`: 배치/스케줄러 서버 -- `nect-core`: JPA/도메인 공용 모듈 -- `nect-client`: 외부 연동/클라이언트 공용 모듈 - -### 디렉토리 구조 - -```text -. -├── nect-api -│ ├── build.gradle -│ └── src -├── nect-scheduler -│ ├── build.gradle -│ └── src -├── nect-core -│ ├── build.gradle -│ └── src -├── nect-client -│ ├── build.gradle -│ └── src -├── gradle -├── build.gradle -├── settings.gradle -├── docker-compose.yml -├── Dockerfile -└── nginx.conf -``` - -## 07. 기타 - -- 기본값은 H2 in-memory DB로 동작합니다. (설정 미지정 시) -- `nect-api` 모듈에서 OpenAPI 문서를 생성합니다. +[//]: # (./gradlew :nect-scheduler:bootRun) + +[//]: # (```) + +[//]: # () +[//]: # (기본 포트는 `8080` 입니다.) + +[//]: # () +[//]: # (#### 환경 변수 (.env 예시)) + +[//]: # () +[//]: # (`.env` 파일을 루트에 두면 자동 로딩됩니다.) + +[//]: # () +[//]: # (```env) + +[//]: # (# Database) + +[//]: # (DB_URL=jdbc:postgresql://localhost:5432/nect) + +[//]: # (DB_USERNAME=postgres) + +[//]: # (DB_PASSWORD=postgres) + +[//]: # (DB_DRIVER=org.postgresql.Driver) + +[//]: # (JPA_DIALECT=org.hibernate.dialect.PostgreSQLDialect) + +[//]: # (JPA_DDL_AUTO=validate) + +[//]: # () +[//]: # (# Redis) + +[//]: # (REDIS_HOST=localhost) + +[//]: # (REDIS_PORT=6379) + +[//]: # () +[//]: # (# JWT) + +[//]: # (JWT_SECRET=change-me) + +[//]: # (JWT_ACCESS_TOKEN_EXPIRATION=3600000) + +[//]: # (JWT_REFRESH_TOKEN_EXPIRATION=86400000) + +[//]: # () +[//]: # (# OAuth2) + +[//]: # (GOOGLE_CLIENT_ID=) + +[//]: # (GOOGLE_CLIENT_SECRET=) + +[//]: # (GOOGLE_REDIRECT_URI=) + +[//]: # (KAKAO_CLIENT_ID=) + +[//]: # (KAKAO_CLIENT_SECRET=) + +[//]: # (KAKAO_REDIRECT_URI=) + +[//]: # (OAUTH2_REDIRECT_URI=http://localhost:3000/auth/callback) + +[//]: # () +[//]: # (# R2 (S3 compatible)) + +[//]: # (R2_ACCESS_KEY=) + +[//]: # (R2_SECRET_KEY=) + +[//]: # (R2_REGION=us-east-1) + +[//]: # (R2_END_POINT=) + +[//]: # (BUCKET_NAME=) + +[//]: # () +[//]: # (# App) + +[//]: # (AUTH_KEY=) + +[//]: # () +[//]: # (# OpenAI) + +[//]: # (OPENAI_API_KEY=) + +[//]: # (OPENAI_BASE_URL=https://api.openai.com) + +[//]: # (OPENAI_MODEL=gpt-4o-mini) + +[//]: # (OPENAI_FALLBACK_MODEL=gpt-4.1) + +[//]: # (```) + +[//]: # () +[//]: # (## 03. 기술 스택) + +[//]: # () +[//]: # (- Java 21) + +[//]: # (- Spring Boot 3.5.x) + +[//]: # (- Gradle (멀티 모듈)) + +[//]: # (- JPA / QueryDSL) + +[//]: # (- Redis) + +[//]: # (- OAuth2 (Google/Kakao), JWT) + +[//]: # (- OpenAI API (클라이언트 모듈)) + +[//]: # () +[//]: # (## 04. 화면 구성 / API 주소) + +[//]: # () +[//]: # (- API 문서: TODO (예: Swagger/OpenAPI 경로)) + +[//]: # () +[//]: # (## 05. 주요 기능) + +[//]: # () +[//]: # (- OAuth2 로그인 및 JWT 인증) + +[//]: # (- Redis 기반 캐싱/세션 관리) + +[//]: # (- 배치/스케줄러 작업 분리 운영) + +[//]: # (- 외부 API 연동(클라이언트 모듈)) + +[//]: # () +[//]: # (## 06. 아키텍처) + +[//]: # () +[//]: # (### 모듈 구성) + +[//]: # () +[//]: # (- `nect-api`: 메인 API 서버) + +[//]: # (- `nect-scheduler`: 배치/스케줄러 서버) + +[//]: # (- `nect-core`: JPA/도메인 공용 모듈) + +[//]: # (- `nect-client`: 외부 연동/클라이언트 공용 모듈) + +[//]: # () +[//]: # (### 디렉토리 구조) + +[//]: # () +[//]: # (```text) + +[//]: # (.) + +[//]: # (├── nect-api) + +[//]: # (│ ├── build.gradle) + +[//]: # (│ └── src) + +[//]: # (├── nect-scheduler) + +[//]: # (│ ├── build.gradle) + +[//]: # (│ └── src) + +[//]: # (├── nect-core) + +[//]: # (│ ├── build.gradle) + +[//]: # (│ └── src) + +[//]: # (├── nect-client) + +[//]: # (│ ├── build.gradle) + +[//]: # (│ └── src) + +[//]: # (├── gradle) + +[//]: # (├── build.gradle) + +[//]: # (├── settings.gradle) + +[//]: # (├── docker-compose.yml) + +[//]: # (├── Dockerfile) + +[//]: # (└── nginx.conf) + +[//]: # (```) + +[//]: # () +[//]: # (## 07. 기타) + +[//]: # () +[//]: # (- 기본값은 H2 in-memory DB로 동작합니다. (설정 미지정 시)) + +[//]: # (- `nect-api` 모듈에서 OpenAPI 문서를 생성합니다.) diff --git a/nect-api/src/main/java/com/nect/api/domain/home/controller/HomeController.java b/nect-api/src/main/java/com/nect/api/domain/home/controller/HomeController.java index 481cf8b3..4398f966 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/controller/HomeController.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/controller/HomeController.java @@ -3,6 +3,7 @@ import com.nect.api.domain.home.dto.HomeHeaderResponse; import com.nect.api.domain.home.dto.HomeMembersResponse; import com.nect.api.domain.home.dto.HomeProjectResponse; +import com.nect.api.domain.home.dto.HomeStatisticResponse; import com.nect.api.domain.home.facade.MainHomeFacade; import com.nect.api.global.response.ApiResponse; import com.nect.api.global.security.UserDetailsImpl; @@ -69,6 +70,12 @@ public ApiResponse headerProfile(@AuthenticationPrincipal Us return ApiResponse.ok(profileInfo); } + // 홈화면 통계 + @GetMapping("/statistics") + public ApiResponse statistics() { + return ApiResponse.ok(mainHomeFacade.statisticResponse()); + } + private Long resolveUserId(UserDetailsImpl userDetails) { return (userDetails == null) ? null : userDetails.getUserId(); } diff --git a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeStatisticResponse.java b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeStatisticResponse.java new file mode 100644 index 00000000..6a4b85fc --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeStatisticResponse.java @@ -0,0 +1,9 @@ +package com.nect.api.domain.home.dto; + +public record HomeStatisticResponse( + Integer totalProjectCount, + Integer matchingSuccessRate, + Integer reParticipateRate, + Integer totalUserCount +) { +} diff --git a/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java b/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java index 30765d8b..a3852d5f 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java @@ -1,13 +1,10 @@ package com.nect.api.domain.home.facade; -import com.nect.api.domain.home.dto.HomeMemberItem; -import com.nect.api.domain.home.dto.HomeMembersResponse; -import com.nect.api.domain.home.dto.HomeProjectItem; -import com.nect.api.domain.home.dto.HomeProjectResponse; -import com.nect.api.domain.home.dto.HomeHeaderResponse; +import com.nect.api.domain.home.dto.*; import com.nect.api.domain.home.exception.HomeInvalidParametersException; import com.nect.api.domain.home.service.HomeMemberQueryService; import com.nect.api.domain.home.service.HomeProjectQueryService; +import com.nect.api.domain.home.service.HomeStatisticsQueryService; import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.Project; import com.nect.core.entity.user.User; @@ -32,6 +29,7 @@ public class MainHomeFacade { private final HomeProjectQueryService homeProjectQueryService; private final HomeMemberQueryService homeMemberQueryService; + private final HomeStatisticsQueryService statisticsQueryService; private final S3Service s3Service; // 모집 중인 프로젝트 @@ -115,6 +113,16 @@ public HomeHeaderResponse getHeaderProfile(Long userId) { return homeMemberQueryService.getHeaderProfile(userId); } + // 홈화면 통계 조회 + public HomeStatisticResponse statisticResponse() { + return new HomeStatisticResponse( + statisticsQueryService.getTotalProjectCount(), + statisticsQueryService.getMatchingSuccessRate(), + statisticsQueryService.getReParticipantRate(), + statisticsQueryService.getTotalUserCount() + ); + } + // List -> List private List responsesFromProjects(List projects) { HomeProjectQueryService.HomeProjectBatch batch = homeProjectQueryService.loadHomeProjectBatch(projects); @@ -131,7 +139,7 @@ private List responsesFromProjects(List projects) { return HomeProjectItem.of( projectId, - resolveProjectImage(p), + s3Service.getPresignedGetUrl(p.getImageName()), p.getTitle(), author == null ? null : author.getName(), author == null ? null : author.getRole().name(), @@ -185,10 +193,4 @@ private int safeCount(int count) { return Math.max(1, count); } - private String resolveProjectImage(Project project) { - String imageName = project.getImageName(); - return imageName == null ? null : s3Service.getPresignedGetUrl(imageName); - } - - } diff --git a/nect-api/src/main/java/com/nect/api/domain/home/service/HomeStatisticsQueryService.java b/nect-api/src/main/java/com/nect/api/domain/home/service/HomeStatisticsQueryService.java new file mode 100644 index 00000000..4748619a --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/home/service/HomeStatisticsQueryService.java @@ -0,0 +1,38 @@ +package com.nect.api.domain.home.service; + +import com.nect.core.entity.matching.enums.MatchingStatus; +import com.nect.core.repository.matching.MatchingRepository; +import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class HomeStatisticsQueryService { + + private final ProjectUserRepository projectUserRepository; + private final UserRepository userRepository; + private final ProjectRepository projectRepository; + private final MatchingRepository matchingRepository; + + public int getTotalProjectCount(){ + return projectRepository.findAll().size(); + } + + public int getMatchingSuccessRate(){ + return (int)matchingRepository.countByStatus(MatchingStatus.ACCEPTED); + } + + public int getReParticipantRate() { + long rate = projectUserRepository.countRejoinedUsers().size() / projectUserRepository.countDistinctUsers(); + return (int) rate; + } + + public int getTotalUserCount(){ + return userRepository.findAll().size(); + } + + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java index 76ce8190..178a2b15 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java @@ -1,11 +1,13 @@ package com.nect.api.domain.mypage.controller; +import com.nect.api.domain.mypage.dto.MyProjectStringListRequest; import com.nect.api.domain.mypage.dto.MyProjectsResponseDto; import com.nect.api.domain.mypage.dto.ProfileSettingsDto; -import com.nect.api.domain.mypage.dto.ProfileSettingsDto.ProfileSettingsRequestDto; -import com.nect.api.domain.mypage.dto.ProfileSettingsDto.ProfileSettingsResponseDto; +import com.nect.api.domain.mypage.dto.ProfileSettingsDto.*; import com.nect.api.domain.mypage.service.MyPageProjectCommandService; import com.nect.api.domain.mypage.service.MyPageProjectQueryService; +import com.nect.api.domain.mypage.dto.ProfileSettingsDto.ProfileSettingsRequestDto; +import com.nect.api.domain.mypage.dto.ProfileSettingsDto.ProfileSettingsResponseDto; import com.nect.api.domain.mypage.service.MypageService; import com.nect.api.domain.team.project.dto.ProjectUserFieldReqDto; import com.nect.api.domain.team.project.dto.ProjectUserFieldResDto; @@ -14,11 +16,15 @@ import com.nect.api.domain.team.project.service.ProjectUserService; import com.nect.api.global.response.ApiResponse; import com.nect.api.global.security.UserDetailsImpl; +import com.nect.core.entity.team.enums.PlanFileType; +import com.nect.core.entity.user.enums.InterestField; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/api/v1/mypage") @@ -74,6 +80,88 @@ public ApiResponse getProfileAnal return ApiResponse.ok(mypageService.getProfileAnalysis(userDetails.getUserId())); } + // 프로젝트 분야 수정 + @PatchMapping("/projects/{projectId}/project-field") + public ApiResponse editField(@PathVariable Long projectId, @RequestParam("field") InterestField interestField) { + projectCommandService.changeProjectInterest(projectId, interestField); + return ApiResponse.ok(); + } + + + // 모집정보 추가 + + // 프로젝트 목표 작성 + @PatchMapping("/projects/{projectId}/purposes") + public ApiResponse writePurposes( + @PathVariable Long projectId, + @Valid @RequestBody MyProjectStringListRequest request + ) { + projectCommandService.changePurpose(projectId, request.contents()); + return ApiResponse.ok(); + } + + + // 주요기능 작성 + @PatchMapping("/projects/{projectId}/functions") + public ApiResponse writeMainFunctions( + @PathVariable Long projectId, + @Valid @RequestBody MyProjectStringListRequest request + ) { + projectCommandService.changeMainFunctions(projectId, request.contents()); + return ApiResponse.ok(); + } + + + // 서비스 사용자 작성 + @PatchMapping("/projects/{projectId}/service-users") + public ApiResponse writeServiceUsers( + @PathVariable Long projectId, + @Valid @RequestBody MyProjectStringListRequest request + ) { + projectCommandService.changeServiceUsers(projectId, request.contents()); + return ApiResponse.ok(); + } + + + // 프로젝트 세부 기획 파일 추가 + @PostMapping(value = "/projects/{projectId}/plan-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadPlanFile( + @PathVariable Long projectId, + @RequestPart("name") String name, + @RequestPart("planFileType") PlanFileType planFileType, + @RequestPart(value = "file", required = false) MultipartFile file, + @RequestPart(value = "link", required = false) String link + ) { + projectCommandService.addPlanFile(projectId, name, planFileType, file, link); + return ApiResponse.ok(); + } + + // 프로젝트 세부 기획 파일 수정 + @PatchMapping(value = "/projects/{projectId}/plan-file/{planFileId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse editPlanFile( + @PathVariable Long projectId, + @PathVariable Long planFileId, + @RequestPart("name") String name, + @RequestPart("planFileType") PlanFileType planFileType, + @RequestPart(value = "file", required = false) MultipartFile file, + @RequestPart(value = "link", required = false) String link + ) { + projectCommandService.editPlanFile(projectId, planFileId, name, planFileType, file, link); + return ApiResponse.ok(); + } + + // 프로젝트 세부 기획 파일 삭제 + @DeleteMapping(value = "/projects/{projectId}/plan-file/{planFileId}") + public ApiResponse removePlanFile( + @PathVariable Long projectId, + @PathVariable Long planFileId + ){ + projectCommandService.removePlanFile(projectId, planFileId); + return ApiResponse.ok(); + } + + + /** * 프로젝트 멤버 필드 변경 */ @@ -113,31 +201,5 @@ public ApiResponse updateProjectUserType( ); } - // TODO: 해주세요 - // 프로젝트 분야 수정 - - // 모집정보 추가 - - // 프로젝트 목표 추가 - - // 프로젝트 목표 수정 - - // 프로젝트 목표 삭제 - - // 주요기능 추가 - - // 주요기능 수정 - - // 주요기능 삭제 - - // 서비스 사용자 추가 - - // 서비스 사용자 수정 - - // 서비스 사용자 삭제 - - // 프로젝트 세부 기획 파일 추가 - - // 프로젝트 세부 기획 파일 삭제 -} +} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/converter/ProjectListConverter.java b/nect-api/src/main/java/com/nect/api/domain/mypage/converter/ProjectListConverter.java new file mode 100644 index 00000000..9e2f35a5 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/converter/ProjectListConverter.java @@ -0,0 +1,55 @@ +package com.nect.api.domain.mypage.converter; + +import com.nect.api.global.code.CommonResponseCode; +import com.nect.api.global.exception.CustomException; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; + +import java.util.Arrays; +import java.util.List; + +/** + * Project 엔티티의 속성 값을 + * DB(String) <-> Server(List) 변환을 담당합니다 + * String은 를 포함할 수 없습니다. + */ +@Component +@Converter +public class ProjectListConverter implements AttributeConverter, String> { + + // 요소 간 구분자 + private static final String SEPARATOR = "\u001F"; + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null || attribute.isEmpty()) { + return ""; + } + + return attribute.stream() + .filter(value -> value != null && !value.isBlank()) + .map(String::trim) + .peek(value -> { + if (value.contains(SEPARATOR)) { + throw new CustomException(CommonResponseCode.BAD_REQUEST_ERROR, "부적절한 단어가 포함되어있습니다. 내용에 \u001F 를 포함할 수 없습니다."); + } + }) + .reduce((left, right) -> left + SEPARATOR + right) + .orElse(""); + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return List.of(); + } + + return Arrays.stream(dbData.split(SEPARATOR)) + .map(String::trim) + .filter(value -> !value.isBlank()) + .toList(); + } + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/dto/MyProjectStringListRequest.java b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/MyProjectStringListRequest.java new file mode 100644 index 00000000..77de4d34 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/MyProjectStringListRequest.java @@ -0,0 +1,11 @@ +package com.nect.api.domain.mypage.dto; + +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record MyProjectStringListRequest( + @NotNull + List contents +) { +} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/dto/ProfileSettingsDto.java b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/ProfileSettingsDto.java index 13498e7c..06aba524 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/dto/ProfileSettingsDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/ProfileSettingsDto.java @@ -1,6 +1,5 @@ package com.nect.api.domain.mypage.dto; -import java.time.LocalDate; import java.util.List; public class ProfileSettingsDto { diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/service/MyPageProjectCommandService.java b/nect-api/src/main/java/com/nect/api/domain/mypage/service/MyPageProjectCommandService.java index 0350153b..7cec2bc1 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/service/MyPageProjectCommandService.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/service/MyPageProjectCommandService.java @@ -1,10 +1,29 @@ package com.nect.api.domain.mypage.service; +import com.nect.api.domain.mypage.converter.ProjectListConverter; +import com.nect.api.domain.team.project.enums.code.ProjectErrorCode; +import com.nect.api.domain.team.project.exception.ProjectException; +import com.nect.api.domain.team.file.util.FileUploadValidator; +import com.nect.api.global.code.CommonResponseCode; +import com.nect.api.global.exception.CustomException; +import com.nect.api.global.infra.S3Service; +import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.ProjectInterest; +import com.nect.core.entity.team.ProjectPlanFile; +import com.nect.core.entity.team.enums.FileExt; +import com.nect.core.entity.team.enums.PlanFileType; +import com.nect.core.entity.user.enums.InterestField; import com.nect.core.repository.matching.RecruitmentRepository; +import com.nect.core.repository.team.ProjectInterestFieldRepository; +import com.nect.core.repository.team.ProjectPlanFileRepository; import com.nect.core.repository.team.ProjectRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.function.BiConsumer; // 마이페이지-프로젝트 데이터 추가•수정•삭제 service @Service @@ -14,31 +33,209 @@ public class MyPageProjectCommandService { private final ProjectRepository projectRepository; private final RecruitmentRepository recruitmentRepository; + private final ProjectPlanFileRepository planFileRepository; + private final ProjectInterestFieldRepository projectInterestFieldRepository; + private final S3Service s3Service; + + private final ProjectListConverter listConverter; // 프로젝트 분야 수정 + public void changeProjectInterest(Long projectId, InterestField interestField){ + + // 검증 + if (projectId == null) { + throw new CustomException(CommonResponseCode.MISSING_REQUEST_PARAMETER_ERROR); + } + if (interestField == null) { + throw new CustomException(CommonResponseCode.REQUEST_BODY_MISSING_ERROR); + } + + findProject(projectId); + + ProjectInterest projectInterest = projectInterestFieldRepository.findByProjectIdAndInterestField(projectId, interestField) + .orElseThrow(() -> new CustomException(CommonResponseCode.NOT_FOUND_ERROR)); + + projectInterest.changeSelected(!projectInterest.getSelected()); + projectInterestFieldRepository.save(projectInterest); + } // 모집정보 추가 // 프로젝트 목표 추가 + public void changePurpose(Long projectId, List contentList) { + changeProjectList(projectId, contentList, Project::setPurposes); + } - // 프로젝트 목표 수정 + // 주요기능 추가 + public void changeMainFunctions(Long projectId, List contentList) { + changeProjectList(projectId, contentList, Project::setMainFunctions); + } - // 프로젝트 목표 삭제 + // 서비스 사용자 추가 + public void changeServiceUsers(Long projectId, List contentList) { + changeProjectList(projectId, contentList, Project::setServiceUsers); + } - // 주요기능 추가 + // 프로젝트 세부 기획 파일 추가 + public void addPlanFile(Long projectId, String name, PlanFileType planFileType, MultipartFile file, String link) { - // 주요기능 수정 + String fileName = null; + FileExt fileExt = null; - // 주요기능 삭제 + if (projectId == null) { // 필수 항목 + throw new CustomException(CommonResponseCode.MISSING_REQUEST_PARAMETER_ERROR); + } + if (name == null || planFileType == null) // 필수 항목들 + throw new CustomException(CommonResponseCode.REQUEST_BODY_MISSING_ERROR); - // 서비스 사용자 추가 - // 서비스 사용자 수정 + // FILE일 경우와 LINK일 경우를 나눔 + if (planFileType == PlanFileType.FILE) { // 파일일 경우 - // 서비스 사용자 삭제 + // 파일 존재하는지 검증 + FileUploadValidator.validateNotEmpty(file); + + String originalName = (file.getOriginalFilename() == null || file.getOriginalFilename().isBlank()) + ? "file" + : file.getOriginalFilename(); + fileExt = FileUploadValidator.resolveExtOrThrow(originalName); + + // 파일 사이즈 검증 + FileUploadValidator.validateSizeOrThrow(fileExt, file.getSize()); + + try { + fileName = s3Service.uploadFile(file); // 업로드 후 DB key 받기 + } catch (Exception e) { + throw new CustomException(CommonResponseCode.INTERNAL_SERVER_ERROR); + } + + } else if (planFileType == PlanFileType.LINK) { // LINK일 경우 + if (link == null || link.isBlank()) + throw new CustomException(CommonResponseCode.REQUEST_BODY_MISSING_ERROR); + + fileName = link; + } + + Project project = findProject(projectId); + ProjectPlanFile planFile = ProjectPlanFile.builder() + .name(name) + .fileName(fileName) + .planFileType(planFileType) + .fileExt(fileExt) + .project(project) + .build(); + + planFileRepository.save(planFile); + + + } + + // 프로젝트 세부 기획 파일 수정 + public void editPlanFile(Long projectId, Long planFileId, String name, PlanFileType planFileType, MultipartFile file, String link) { + + // null 검증 + if (projectId == null || planFileId == null) + throw new CustomException(CommonResponseCode.MISSING_REQUEST_PARAMETER_ERROR); + + if (name == null || planFileType == null) + throw new CustomException(CommonResponseCode.REQUEST_BODY_MISSING_ERROR); + + // 조회 + ProjectPlanFile planFile = planFileRepository.findByIdAndProjectId(planFileId, projectId) + .orElseThrow(() -> new ProjectException(ProjectErrorCode.PROJECT_NOT_FOUND)); + + // FILE과 LINK에 대한 처리 + if (planFileType == PlanFileType.FILE) { // FILE 수정 + + // 파일 비어있는지 확인 + FileUploadValidator.validateNotEmpty(file); + + String originalName = (file.getOriginalFilename() == null || file.getOriginalFilename().isBlank()) + ? "file" + : file.getOriginalFilename(); + FileExt fileExt = FileUploadValidator.resolveExtOrThrow(originalName); + + // 파일 크기 검증 + FileUploadValidator.validateSizeOrThrow(fileExt, file.getSize()); + + String fileName; + try { + fileName = s3Service.uploadFile(file); + } catch (Exception e) { + throw new CustomException(CommonResponseCode.INTERNAL_SERVER_ERROR); + } + + if (planFile.getPlanFileType() == PlanFileType.FILE && planFile.getFileName() != null) + s3Service.deleteByFileName(planFile.getFileName()); + + // 수정 + planFile.changeFile(fileName); + planFile.changePlanFileType(PlanFileType.FILE); + planFile.changeFileExt(fileExt); + + } else if (planFileType == PlanFileType.LINK) { // LINK 수정 + + if (link == null || link.isBlank()) + throw new CustomException(CommonResponseCode.REQUEST_BODY_MISSING_ERROR); + + + if (planFile.getPlanFileType() == PlanFileType.FILE && planFile.getFileName() != null) + s3Service.deleteByFileName(planFile.getFileName()); + + // 파일 수정 + planFile.changeFile(link); + planFile.changePlanFileType(PlanFileType.LINK); + planFile.changeFileExt(null); + } + + // 수정 후 저장 + planFile.changeName(name); + planFileRepository.save(planFile); + } - // 프로젝트 세부 기획 파일 추가 // 프로젝트 세부 기획 파일 삭제 + public void removePlanFile(Long projectId, Long planFileId) { + if (projectId == null || planFileId == null) + throw new CustomException(CommonResponseCode.MISSING_REQUEST_PARAMETER_ERROR); + + + ProjectPlanFile planFile = planFileRepository.findByIdAndProjectId(planFileId, projectId) + .orElseThrow(() -> new ProjectException(ProjectErrorCode.PROJECT_NOT_FOUND)); + + if (planFile.getPlanFileType() == PlanFileType.FILE && planFile.getFileName() != null) { + s3Service.deleteByFileName(planFile.getFileName()); + } + + planFileRepository.delete(planFile); + } + + + // 프로젝트 조회 + private Project findProject(Long projectId) { + return projectRepository.findById(projectId) + .orElseThrow(() -> new ProjectException(ProjectErrorCode.PROJECT_NOT_FOUND)); + } + + // 목차 형식으로 되어있는 데이터 수정 + // 프로젝트 목표, 주요기능, 서비스 사용자에서 사용 + private void changeProjectList( + Long projectId, + List contentList, + BiConsumer updater + ) { + if (projectId == null) { + throw new CustomException(CommonResponseCode.MISSING_REQUEST_PARAMETER_ERROR); + } + if (contentList == null) { // 변동하지 않음 + throw new CustomException(CommonResponseCode.REQUEST_BODY_MISSING_ERROR); + } + + Project project = findProject(projectId); + String convertedList = listConverter.convertToDatabaseColumn(contentList); + + updater.accept(project, convertedList); + projectRepository.save(project); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/file/service/FileService.java b/nect-api/src/main/java/com/nect/api/domain/team/file/service/FileService.java index e7d00508..907cbdc9 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/file/service/FileService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/file/service/FileService.java @@ -4,6 +4,7 @@ import com.nect.api.domain.team.file.dto.res.FileUploadResDto; import com.nect.api.domain.team.file.enums.FileErrorCode; import com.nect.api.domain.team.file.exception.FileException; +import com.nect.api.domain.team.file.util.FileUploadValidator; import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.Project; import com.nect.core.entity.team.SharedDocument; @@ -19,23 +20,12 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.util.EnumSet; -import java.util.Locale; -import java.util.Set; @Service @RequiredArgsConstructor @Transactional public class FileService { - private static final long MB = 1024L * 1024L; - - private static final long MAX_5MB = 5L * MB; - private static final long MAX_20MB = 20L * MB; - - private static final Set LIMIT_5MB = EnumSet.of(FileExt.JPG, FileExt.PNG, FileExt.SVG); - private static final Set LIMIT_20MB = EnumSet.of(FileExt.PDF, FileExt.DOCS, FileExt.PPTX, FileExt.FIG, FileExt.ZIP); - private final ProjectRepository projectRepository; private final ProjectUserRepository projectUserRepository; private final SharedDocumentRepository sharedDocumentRepository; @@ -84,13 +74,7 @@ public FileUploadResDto upload(Long projectId, Long userId, MultipartFile file) User user = userRepository.findById(userId) .orElseThrow(() -> new FileException(FileErrorCode.USER_NOT_FOUND, "userId=" + userId)); - if (file == null) { - throw new FileException(FileErrorCode.INVALID_REQUEST, "file is null"); - } - - if (file.isEmpty()) { - throw new FileException(FileErrorCode.EMPTY_FILE, "file is empty"); - } + FileUploadValidator.validateNotEmpty(file); long fileSize = file.getSize(); @@ -98,9 +82,9 @@ public FileUploadResDto upload(Long projectId, Long userId, MultipartFile file) ? "file" : file.getOriginalFilename(); - FileExt ext = resolveExtOrThrow(originalName); + FileExt ext = FileUploadValidator.resolveExtOrThrow(originalName); - validateSizeOrThrow(ext, fileSize); + FileUploadValidator.validateSizeOrThrow(ext, fileSize); // R2 업로드 String fileKey; @@ -135,40 +119,4 @@ public FileUploadResDto upload(Long projectId, Long userId, MultipartFile file) ); } - private void validateSizeOrThrow(FileExt ext, long fileSize) { - long max; - - if (LIMIT_5MB.contains(ext)) { - max = MAX_5MB; - } else if (LIMIT_20MB.contains(ext)) { - max = MAX_20MB; - } else { - throw new FileException(FileErrorCode.UNSUPPORTED_FILE_EXT, "fileExt = " + ext); - } - - if (fileSize > max) { - throw new FileException( - FileErrorCode.FILE_SIZE_EXCEEDED, - "fileExt = " + ext + ", size = " + fileSize + ", max = " + max - ); - } - } - - private FileExt resolveExtOrThrow(String fileName) { - String lower = fileName.toLowerCase(Locale.ROOT); - int dot = lower.lastIndexOf('.'); - String ext = (dot >= 0) ? lower.substring(dot + 1) : ""; - - return switch (ext) { - case "jpg", "jpeg" -> FileExt.JPG; - case "png" -> FileExt.PNG; - case "svg" -> FileExt.SVG; - case "pdf" -> FileExt.PDF; - case "docs" -> FileExt.DOCS; - case "pptx" -> FileExt.PPTX; - case "fig" -> FileExt.FIG; - case "zip" -> FileExt.ZIP; - default -> throw new FileException(FileErrorCode.UNSUPPORTED_FILE_EXT, "fileName=" + fileName); - }; - } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/file/util/FileUploadValidator.java b/nect-api/src/main/java/com/nect/api/domain/team/file/util/FileUploadValidator.java new file mode 100644 index 00000000..d19b56d4 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/file/util/FileUploadValidator.java @@ -0,0 +1,66 @@ +package com.nect.api.domain.team.file.util; + +import com.nect.api.domain.team.file.enums.FileErrorCode; +import com.nect.api.domain.team.file.exception.FileException; +import com.nect.core.entity.team.enums.FileExt; +import org.springframework.web.multipart.MultipartFile; + +import java.util.EnumSet; +import java.util.Locale; +import java.util.Set; + +public final class FileUploadValidator { + private static final long MB = 1024L * 1024L; + + private static final long MAX_5MB = 5L * MB; + private static final long MAX_20MB = 20L * MB; + + private static final Set LIMIT_5MB = EnumSet.of(FileExt.JPG, FileExt.PNG, FileExt.SVG); + private static final Set LIMIT_20MB = EnumSet.of(FileExt.PDF, FileExt.DOCS, FileExt.PPTX, FileExt.FIG, FileExt.ZIP); + + private FileUploadValidator() { + } + + public static void validateNotEmpty(MultipartFile file) { + if (file == null) { + throw new FileException(FileErrorCode.INVALID_REQUEST, "file is null"); + } + if (file.isEmpty()) { + throw new FileException(FileErrorCode.EMPTY_FILE, "file is empty"); + } + } + + public static void validateSizeOrThrow(FileExt ext, long fileSize) { + long max; + + if (LIMIT_5MB.contains(ext)) { + max = MAX_5MB; + } else if (LIMIT_20MB.contains(ext)) { + max = MAX_20MB; + } else { + throw new FileException(FileErrorCode.UNSUPPORTED_FILE_EXT, "fileExt = " + ext); + } + + if (fileSize > max) { + throw new FileException(FileErrorCode.FILE_SIZE_EXCEEDED, "fileExt = " + ext + ", size = " + fileSize + ", max = " + max); + } + } + + public static FileExt resolveExtOrThrow(String fileName) { + String lower = fileName.toLowerCase(Locale.ROOT); + int dot = lower.lastIndexOf('.'); + String ext = (dot >= 0) ? lower.substring(dot + 1) : ""; + + return switch (ext) { + case "jpg", "jpeg" -> FileExt.JPG; + case "png" -> FileExt.PNG; + case "svg" -> FileExt.SVG; + case "pdf" -> FileExt.PDF; + case "docs" -> FileExt.DOCS; + case "pptx" -> FileExt.PPTX; + case "fig" -> FileExt.FIG; + case "zip" -> FileExt.ZIP; + default -> throw new FileException(FileErrorCode.UNSUPPORTED_FILE_EXT, "fileName=" + fileName); + }; + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java index 8a3e03af..b9931f35 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java @@ -6,6 +6,7 @@ import com.nect.api.domain.team.project.exception.ProjectException; import com.nect.api.domain.user.enums.UserErrorCode; import com.nect.api.domain.user.service.UserService; +import com.nect.core.entity.team.ProjectInterest; import com.nect.core.entity.team.process.Process; import com.nect.core.entity.analysis.*; import com.nect.core.entity.team.Project; @@ -13,27 +14,25 @@ import com.nect.core.entity.team.enums.ProjectMemberStatus; import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.enums.ProjectStatus; -import com.nect.core.entity.team.enums.ProjectStatus; -import com.nect.core.entity.team.enums.ProjectStatus; import com.nect.core.entity.team.enums.RecruitmentStatus; import com.nect.core.entity.team.process.ProcessTaskItem; import com.nect.core.entity.team.ProjectTeamRole; import com.nect.core.entity.user.User; +import com.nect.core.entity.user.enums.InterestField; import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.analysis.*; import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectInterestFieldRepository; import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.process.ProcessRepository; import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import com.nect.core.entity.team.enums.ProjectStatus; import com.nect.core.repository.analysis.ProjectIdeaAnalysisRepository; import org.springframework.transaction.annotation.Transactional; import java.lang.reflect.Field; -import java.time.LocalDate; import java.util.List; import java.util.stream.Collectors; @@ -53,6 +52,7 @@ public class ProjectService { private final ProjectUserRepository projectUserRepository; private final ProcessRepository processRepository; private final UserService userService; + private final ProjectInterestFieldRepository projectInterestFieldRepository; public Project getProject(Long projectId){ return projectRepository.findById(projectId) @@ -136,13 +136,24 @@ private Project createProject(ProjectIdeaAnalysis analysis) { project.setProjectPeriod(analysis.getProjectStartDate(), analysis.getProjectEndDate()); setRecruitmentStatus(project, RecruitmentStatus.OPEN); - return projectRepository.save(project); + Project savedProject = projectRepository.save(project); + saveDefaultInterestFields(savedProject); + return savedProject; } catch (Exception e) { throw new ProjectException(ProjectErrorCode.INVALID_ANALYSIS_DATA); } } + private void saveDefaultInterestFields(Project project) { + List fields = java.util.Arrays.stream(InterestField.values()) + .filter(field -> field != InterestField.OTHER) + .map(field -> new ProjectInterest(project, field, false)) + .collect(Collectors.toList()); + + projectInterestFieldRepository.saveAll(fields); + } + private void saveTeamRoles(Long projectId, ProjectIdeaAnalysis analysis) { try { Project project = projectRepository.findById(projectId) diff --git a/nect-api/src/main/java/com/nect/api/global/infra/S3Service.java b/nect-api/src/main/java/com/nect/api/global/infra/S3Service.java index 6b33d50a..acbf3468 100644 --- a/nect-api/src/main/java/com/nect/api/global/infra/S3Service.java +++ b/nect-api/src/main/java/com/nect/api/global/infra/S3Service.java @@ -20,7 +20,7 @@ @Service public class S3Service { - private static final long PRESIGNED_EXPIRE_MILLIS = 5 * 60 * 1000; // 5분 + private static final long PRESIGNED_EXPIRE_MILLIS = 60 * 60 * 1000; // 1시간 private final AmazonS3 amazonS3; @Value("${spring.cloud.cloud-flare.r2.bucket}") private String bucket; diff --git a/nect-api/src/test/java/com/nect/api/analysis/MyPageControllerRestDocsTest.java b/nect-api/src/test/java/com/nect/api/analysis/MyPageControllerRestDocsTest.java index c3bacf21..108ea396 100644 --- a/nect-api/src/test/java/com/nect/api/analysis/MyPageControllerRestDocsTest.java +++ b/nect-api/src/test/java/com/nect/api/analysis/MyPageControllerRestDocsTest.java @@ -2,7 +2,9 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.mypage.dto.MyProjectStringListRequest; import com.nect.api.domain.mypage.dto.MyProjectsResponseDto; +import com.nect.api.domain.mypage.service.MyPageProjectCommandService; import com.nect.api.domain.mypage.service.MyPageProjectQueryService; import com.nect.api.global.jwt.JwtUtil; import com.nect.api.global.jwt.service.TokenBlacklistService; @@ -27,11 +29,13 @@ import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; import static com.epages.restdocs.apispec.ResourceDocumentation.resource; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doNothing; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -53,6 +57,9 @@ class MyPageControllerRestDocsTest { @MockitoBean private MyPageProjectQueryService myPageProjectQueryService; + @MockitoBean + private MyPageProjectCommandService myPageProjectCommandService; + @MockitoBean private JwtUtil jwtUtil; @@ -236,4 +243,139 @@ void getMyProjects() throws Exception { )); } + @Test + @DisplayName("마이페이지 프로젝트 목표 작성 API") + void writePurposes() throws Exception { + MyProjectStringListRequest request = new MyProjectStringListRequest( + List.of("팀 협업 플랫폼 완성", "MVP 출시") + ); + + doNothing().when(myPageProjectCommandService) + .changePurpose(eq(1L), anyList()); + + mockMvc.perform( + patch("/api/v1/mypage/projects/{projectId}/purposes", 1L) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status.statusCode").value("C000")) + .andExpect(jsonPath("$.status.message").value("success")) + .andDo(document("mypage-projects-purposes-write", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag("마이페이지") + .summary("프로젝트 목표 작성") + .description("프로젝트의 목표 목록을 작성 또는 교체합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestFields( + fieldWithPath("contents[]").description("프로젝트 목표 항목 목록") + ) + .responseFields( + fieldWithPath("status.statusCode").description("응답 상태 코드"), + fieldWithPath("status.message").description("응답 메시지"), + fieldWithPath("status.description").description("상세 설명").optional() + ) + .build() + ) + )); + } + + @Test + @DisplayName("마이페이지 프로젝트 주요 기능 작성 API") + void writeMainFunctions() throws Exception { + MyProjectStringListRequest request = new MyProjectStringListRequest( + List.of("실시간 채팅", "파일 공유") + ); + + doNothing().when(myPageProjectCommandService) + .changeMainFunctions(eq(1L), anyList()); + + mockMvc.perform( + patch("/api/v1/mypage/projects/{projectId}/functions", 1L) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status.statusCode").value("C000")) + .andExpect(jsonPath("$.status.message").value("success")) + .andDo(document("mypage-projects-functions-write", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag("마이페이지") + .summary("프로젝트 주요 기능 작성") + .description("프로젝트의 주요 기능 목록을 작성 또는 교체합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestFields( + fieldWithPath("contents[]").description("프로젝트 주요 기능 항목 목록") + ) + .responseFields( + fieldWithPath("status.statusCode").description("응답 상태 코드"), + fieldWithPath("status.message").description("응답 메시지"), + fieldWithPath("status.description").description("상세 설명").optional() + ) + .build() + ) + )); + } + + @Test + @DisplayName("마이페이지 프로젝트 서비스 사용자 작성 API") + void writeServiceUsers() throws Exception { + MyProjectStringListRequest request = new MyProjectStringListRequest( + List.of("대학생", "초기 창업자") + ); + + doNothing().when(myPageProjectCommandService) + .changeServiceUsers(eq(1L), anyList()); + + mockMvc.perform( + patch("/api/v1/mypage/projects/{projectId}/service-users", 1L) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status.statusCode").value("C000")) + .andExpect(jsonPath("$.status.message").value("success")) + .andDo(document("mypage-projects-service-users-write", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag("마이페이지") + .summary("프로젝트 서비스 사용자 작성") + .description("프로젝트의 서비스 사용자 목록을 작성 또는 교체합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestFields( + fieldWithPath("contents[]").description("프로젝트 서비스 사용자 항목 목록") + ) + .responseFields( + fieldWithPath("status.statusCode").description("응답 상태 코드"), + fieldWithPath("status.message").description("응답 메시지"), + fieldWithPath("status.description").description("상세 설명").optional() + ) + .build() + ) + )); + } + } \ No newline at end of file diff --git a/nect-api/src/test/java/com/nect/api/domain/home/controller/HomeControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/home/controller/HomeControllerTest.java index 3daa5f54..90ad0284 100644 --- a/nect-api/src/test/java/com/nect/api/domain/home/controller/HomeControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/home/controller/HomeControllerTest.java @@ -6,6 +6,7 @@ import com.nect.api.domain.home.dto.HomeMembersResponse; import com.nect.api.domain.home.dto.HomeProjectItem; import com.nect.api.domain.home.dto.HomeProjectResponse; +import com.nect.api.domain.home.dto.HomeStatisticResponse; import com.nect.api.domain.home.facade.MainHomeFacade; import com.nect.api.global.jwt.JwtUtil; import com.nect.api.global.jwt.service.TokenBlacklistService; @@ -145,6 +146,48 @@ void setUpAuth() { )); } + @Test + @DisplayName("홈화면 통계 조회 API") + void 홈화면_통계_조회_API() throws Exception { + HomeStatisticResponse response = new HomeStatisticResponse( + 120, + 65, + 40, + 3200 + ); + + given(mainHomeFacade.statisticResponse()).willReturn(response); + + mockMvc.perform(get("/api/v1/home/statistics") + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(document("home-statistics", + resource(ResourceSnippetParameters.builder() + .tag("홈") + .summary("홈화면 통계 조회") + .description("홈 화면에 표시되는 통계 정보를 조회합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .responseFields( + fieldWithPath("status").type(JsonFieldType.OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("메시지"), + fieldWithPath("status.description").optional().type(JsonFieldType.STRING).description("상세 설명"), + + fieldWithPath("body").type(JsonFieldType.OBJECT).description("응답 바디"), + fieldWithPath("body.totalProjectCount").type(JsonFieldType.NUMBER).description("전체 프로젝트 수"), + fieldWithPath("body.matchingSuccessRate").type(JsonFieldType.NUMBER).description("매칭 성공률(%)"), + fieldWithPath("body.reParticipateRate").type(JsonFieldType.NUMBER).description("재참여율(%)"), + fieldWithPath("body.totalUserCount").type(JsonFieldType.NUMBER).description("전체 사용자 수") + ) + .build() + ) + )); + } + @Test @DisplayName("홈화면 매칭 가능한 넥터 API") void 홈화면_매칭_가능한_넥터_API() throws Exception { diff --git a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java index 7603a28a..f673e4a3 100644 --- a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java @@ -1,7 +1,13 @@ package com.nect.api.domain.mypage.controller; +import com.nect.api.domain.mypage.dto.ProfileSettingsDto; +import com.nect.api.domain.mypage.service.MyPageProjectCommandService; +import com.nect.api.domain.mypage.service.MyPageProjectQueryService; +import com.nect.api.domain.mypage.service.MypageService; import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.nect.api.NectDocumentApiTester; +import com.nect.core.entity.team.enums.PlanFileType; +import com.nect.core.entity.user.enums.InterestField; import com.nect.api.domain.mypage.dto.ProfileSettingsDto; import com.nect.api.domain.mypage.service.MypageService; import com.nect.api.domain.team.project.dto.ProjectUserFieldReqDto; @@ -14,21 +20,35 @@ import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.ArrayList; import java.util.List; import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doNothing; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class MypageControllerTest extends NectDocumentApiTester { @@ -39,6 +59,12 @@ class MypageControllerTest extends NectDocumentApiTester { @MockitoBean private ProjectUserService projectUserService; + @MockitoBean + private MyPageProjectCommandService projectCommandService; + + @MockitoBean + private MyPageProjectQueryService projectQueryService; + @Test void getProfile() throws Exception { ProfileSettingsDto.ProfileSettingsResponseDto mockResponse = new ProfileSettingsDto.ProfileSettingsResponseDto( @@ -70,7 +96,7 @@ void getProfile() throws Exception { .andDo(document("mypage-get-profile", resource( ResourceSnippetParameters.builder() - .tag("mypage") + .tag("마이페이지") .summary("마이페이지 프로필 조회") .description("사용자의 마이페이지 프로필 정보를 조회합니다.") .responseFields( @@ -202,7 +228,7 @@ void updateProfile() throws Exception { .andDo(document("mypage-patch-profile", resource( ResourceSnippetParameters.builder() - .tag("mypage") + .tag("마이페이지") .summary("마이페이지 프로필 수정") .description("마이페이지 프로필 정보를 부분 수정합니다.\n\n" + "**수정 가능한 필드**\n" + @@ -251,6 +277,249 @@ void updateProfile() throws Exception { )); } + @Test + void editProjectField() throws Exception { + long projectId = 1L; + + doNothing().when(projectCommandService) + .changeProjectInterest(eq(projectId), eq(InterestField.IT_WEB_MOBILE)); + + mockMvc.perform( + patch("/api/v1/mypage/projects/{projectId}/project-field?field=IT_WEB_MOBILE", projectId) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(document("mypage-project-field-edit", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag("마이페이지") + .summary("프로젝트 분야 수정") + .description("프로젝트 관심 분야 선택 상태를 변경합니다.") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .queryParameters( + parameterWithName("field").description("프로젝트 관심 분야(InterestField)") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer AccessToken") + ) + .responseFields( + fieldWithPath("status").type(JsonFieldType.OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), + fieldWithPath("status.description").optional().type(JsonFieldType.STRING).description("상태 설명"), + fieldWithPath("body").type(JsonFieldType.NULL).optional().description("응답 바디 (없음)") + ) + .build() + ) + )); + } + + @Test + void uploadPlanFile_FILE() throws Exception { + long projectId = 1L; + + MockMultipartFile name = new MockMultipartFile( + "name", + "", + MediaType.TEXT_PLAIN_VALUE, + "기획서".getBytes() + ); + MockMultipartFile planFileType = new MockMultipartFile( + "planFileType", + "", + MediaType.APPLICATION_JSON_VALUE, + "\"FILE\"".getBytes() + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "sample.pdf", + MediaType.APPLICATION_PDF_VALUE, + "dummy pdf bytes".getBytes() + ); + + doNothing().when(projectCommandService) + .addPlanFile(eq(projectId), anyString(), eq(PlanFileType.FILE), any(), any()); + + mockMvc.perform( + multipart("/api/v1/mypage/projects/{projectId}/plan-file", projectId) + .file(name) + .file(planFileType) + .file(file) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(document("mypage-plan-file-upload", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestParts( + partWithName("name").description("파일 표시명"), + partWithName("planFileType").description("파일 타입 (FILE 또는 LINK)"), + partWithName("file").description("업로드할 파일(MultipartFile)"), + partWithName("link").description("링크 URL (planFileType=LINK일 때만 사용)").optional() + ), + resource(ResourceSnippetParameters.builder() + .tag("마이페이지") + .summary("프로젝트 세부 기획 파일 추가") + .description( + "프로젝트의 세부 기획 파일(업로드 또는 링크)을 추가합니다.\n\n" + + "**설명 작성 가이드(Description 텍스트 규칙)**\n" + + "- 1줄 요약: 무엇을 하는 API인지 간단히 서술\n" + + "- 입력 규칙: planFileType별 필수 파트를 명시\n" + + "- 제약/예외: 파일 확장자/용량 제한 등 핵심 제약을 적기\n\n" + + "**입력 규칙**\n" + + "- planFileType=FILE: name, planFileType, file 필수 (link는 무시)\n" + + "- planFileType=LINK: name, planFileType, link 필수 (file은 무시)\n" + ) + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(JsonFieldType.OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), + fieldWithPath("status.description").optional().type(JsonFieldType.STRING).description("상태 설명"), + fieldWithPath("body").type(JsonFieldType.NULL).optional().description("응답 바디 (없음)") + ) + .build() + ) + )); + } + + @Test + void editPlanFile_LINK() throws Exception { + long projectId = 1L; + long planFileId = 10L; + + MockMultipartFile name = new MockMultipartFile( + "name", + "", + MediaType.TEXT_PLAIN_VALUE, + "Figma 링크".getBytes() + ); + MockMultipartFile planFileType = new MockMultipartFile( + "planFileType", + "", + MediaType.APPLICATION_JSON_VALUE, + "\"LINK\"".getBytes() + ); + MockMultipartFile link = new MockMultipartFile( + "link", + "", + MediaType.TEXT_PLAIN_VALUE, + "https://figma.com/file/abc".getBytes() + ); + + doNothing().when(projectCommandService) + .editPlanFile(eq(projectId), eq(planFileId), anyString(), eq(PlanFileType.LINK), any(), anyString()); + + mockMvc.perform( + multipart("/api/v1/mypage/projects/{projectId}/plan-file/{planFileId}", projectId, planFileId) + .file(name) + .file(planFileType) + .file(link) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.APPLICATION_JSON) + .with(request -> { + request.setMethod("PATCH"); + return request; + }) + ) + .andExpect(status().isOk()) + .andDo(document("mypage-plan-file-edit", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestParts( + partWithName("name").description("파일 표시명"), + partWithName("planFileType").description("파일 타입 (FILE 또는 LINK)"), + partWithName("file").description("업로드할 파일(MultipartFile) - planFileType=FILE일 때만 사용").optional(), + partWithName("link").description("링크 URL - planFileType=LINK일 때만 사용").optional() + ), + resource(ResourceSnippetParameters.builder() + .tag("마이페이지") + .summary("프로젝트 세부 기획 파일 수정") + .description( + "프로젝트 세부 기획 파일의 내용을 수정합니다.\n\n" + + "**설명 작성 가이드(Description 텍스트 규칙)**\n" + + "- 1줄 요약으로 변경 범위를 먼저 설명\n" + + "- planFileType 변경 가능 여부와 필수 파트를 명시\n" + + "- 기존 FILE ↔ LINK 전환 시 처리(기존 파일 삭제 등) 요약\n\n" + + "**입력 규칙**\n" + + "- planFileType=FILE: name, planFileType, file 필수\n" + + "- planFileType=LINK: name, planFileType, link 필수\n" + ) + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID"), + parameterWithName("planFileId").description("세부 기획 파일 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(JsonFieldType.OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), + fieldWithPath("status.description").optional().type(JsonFieldType.STRING).description("상태 설명"), + fieldWithPath("body").type(JsonFieldType.NULL).optional().description("응답 바디 (없음)") + ) + .build() + ) + )); + } + + @Test + void removePlanFile() throws Exception { + long projectId = 1L; + long planFileId = 10L; + + doNothing().when(projectCommandService).removePlanFile(eq(projectId), eq(planFileId)); + + mockMvc.perform( + delete("/api/v1/mypage/projects/{projectId}/plan-file/{planFileId}", projectId, planFileId) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(document("mypage-plan-file-remove", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag("마이페이지") + .summary("프로젝트 세부 기획 파일 삭제") + .description( + "프로젝트 세부 기획 파일을 삭제합니다.\n\n" + + "**설명 작성 가이드(Description 텍스트 규칙)**\n" + + "- 1줄 요약으로 삭제 대상과 범위를 명확히\n" + + "- 삭제 시 파일 스토리지 제거 여부를 간단히 명시\n" + ) + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID"), + parameterWithName("planFileId").description("세부 기획 파일 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(JsonFieldType.OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), + fieldWithPath("status.description").optional().type(JsonFieldType.STRING).description("상태 설명"), + fieldWithPath("body").type(JsonFieldType.NULL).optional().description("응답 바디 (없음)") + ) + .build() + ) + )); + } + @Test void getProfileAnalysis() throws Exception { // given @@ -267,7 +536,7 @@ void getProfileAnalysis() throws Exception { .andDo(document("mypage-get-profile-analysis", resource( ResourceSnippetParameters.builder() - .tag("mypage") + .tag("마이페이지") .summary("마이페이지 프로필 분석 불러오기") .description("데이터베이스에 저장된 AI 프로필 분석 결과를 조회합니다. 분석 결과가 없으면 profileType과 tags는 null입니다.") .responseFields( diff --git a/nect-core/src/main/java/com/nect/core/entity/team/Project.java b/nect-core/src/main/java/com/nect/core/entity/team/Project.java index fe6470ff..72496af0 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/Project.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/Project.java @@ -4,10 +4,7 @@ import com.nect.core.entity.team.enums.ProjectStatus; import com.nect.core.entity.team.enums.RecruitmentStatus; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.time.LocalDate; import java.time.LocalDateTime; @@ -58,7 +55,17 @@ public class Project extends BaseEntity { @Column(name = "planned_ended_on") private LocalDate plannedEndedOn; + @Column(name = "purposes", columnDefinition = "TEXT DEFAULT ''", nullable = false) + @Setter + private String purposes; + @Column(name = "service_users", columnDefinition = "TEXT DEFAULT ''", nullable = false) + @Setter + private String serviceUsers; + + @Column(name = "main_functions", columnDefinition = "TEXT DEFAULT ''", nullable = false) + @Setter + private String mainFunctions; @Builder protected Project(String title, @@ -73,6 +80,9 @@ protected Project(String title, this.status = (status == null) ? ProjectStatus.ACTIVE : status; this.noticeText = noticeText; this.regularMeetingText = regularMeetingText; + this.purposes = ""; + this.serviceUsers = ""; + this.mainFunctions = ""; } public void end() { @@ -94,4 +104,4 @@ public void setProjectPeriod(LocalDate startDate, LocalDate endDate) { this.plannedStartedOn = startDate; this.plannedEndedOn = endDate; } -} \ No newline at end of file +} diff --git a/nect-core/src/main/java/com/nect/core/entity/team/ProjectInterest.java b/nect-core/src/main/java/com/nect/core/entity/team/ProjectInterest.java new file mode 100644 index 00000000..88c939ef --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/entity/team/ProjectInterest.java @@ -0,0 +1,42 @@ +package com.nect.core.entity.team; + +import com.nect.core.entity.user.enums.InterestField; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "team_interest_field", + uniqueConstraints = @UniqueConstraint(columnNames = {"project_id", "interest_field"}) +) +public class ProjectInterest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + @Enumerated(EnumType.STRING) + @Column(name = "interest_field", nullable = false) + private InterestField interestField; + + @Column(name = "is_selected", nullable = false) + private Boolean selected; + + @Builder + public ProjectInterest(Project project, InterestField interestField, boolean selected) { + this.project = project; + this.interestField = interestField; + this.selected = selected; + } + + public void changeSelected(boolean selected) { + this.selected = selected; + } +} diff --git a/nect-core/src/main/java/com/nect/core/entity/team/ProjectPlanFile.java b/nect-core/src/main/java/com/nect/core/entity/team/ProjectPlanFile.java new file mode 100644 index 00000000..7224264d --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/entity/team/ProjectPlanFile.java @@ -0,0 +1,67 @@ +package com.nect.core.entity.team; + +import com.nect.core.entity.BaseEntity; +import com.nect.core.entity.team.enums.FileExt; +import com.nect.core.entity.team.enums.PlanFileType; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProjectPlanFile extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false, length = 50) + private String name; // 사용자가 입력한 파일 명칭 + + @Column(name = "file_name", nullable = false, columnDefinition = "TEXT") + private String fileName; // r2에 올라갈 파일 이름 ( 링크 X ) + + @Enumerated(EnumType.STRING) + @Column(name = "plan_file_type", nullable = false) + private PlanFileType planFileType; + + @Enumerated(EnumType.STRING) + @Column(name = "file_ext", nullable = true) + private FileExt fileExt; // planFileType = LINK이면 fileExt = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id") + private Project project; + + @Builder + protected ProjectPlanFile( + String name, + String fileName, + PlanFileType planFileType, + FileExt fileExt, + Project project + ){ + this.name = name; + this.fileName = fileName; + this.planFileType = planFileType; + this.fileExt = planFileType == PlanFileType.FILE ? fileExt : null; // planFileType = LINK이면 fileExt = null + this.project = project; + } + + public void changeName(String name) { + this.name = name; + } + + public void changeFile(String fileName) { + this.fileName = fileName; + } + + public void changePlanFileType(PlanFileType planFileType) { + this.planFileType = planFileType; + } + + public void changeFileExt(FileExt fileExt) { + this.fileExt = fileExt; + } + +} diff --git a/nect-core/src/main/java/com/nect/core/entity/team/enums/FileExt.java b/nect-core/src/main/java/com/nect/core/entity/team/enums/FileExt.java index 37b0ea18..8fc51458 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/enums/FileExt.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/enums/FileExt.java @@ -1,5 +1,28 @@ package com.nect.core.entity.team.enums; +import java.util.Locale; + public enum FileExt { - JPG, PNG, SVG, PDF, DOCS, PPTX, FIG, ZIP + JPG, PNG, SVG, PDF, DOCS, PPTX, FIG, ZIP; + + public static FileExt fromFilename(String fileName) { + if (fileName == null) { + return null; + } + String lower = fileName.toLowerCase(Locale.ROOT); + int dot = lower.lastIndexOf('.'); + String ext = (dot >= 0) ? lower.substring(dot + 1) : ""; + + return switch (ext) { + case "jpg", "jpeg" -> JPG; + case "png" -> PNG; + case "svg" -> SVG; + case "pdf" -> PDF; + case "docs" -> DOCS; + case "pptx" -> PPTX; + case "fig" -> FIG; + case "zip" -> ZIP; + default -> null; + }; + } } diff --git a/nect-core/src/main/java/com/nect/core/entity/team/enums/PlanFileType.java b/nect-core/src/main/java/com/nect/core/entity/team/enums/PlanFileType.java new file mode 100644 index 00000000..04acb376 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/entity/team/enums/PlanFileType.java @@ -0,0 +1,5 @@ +package com.nect.core.entity.team.enums; + +public enum PlanFileType { + FILE, LINK +} diff --git a/nect-core/src/main/java/com/nect/core/repository/matching/MatchingRepository.java b/nect-core/src/main/java/com/nect/core/repository/matching/MatchingRepository.java index 5e14ba6c..6611e15a 100644 --- a/nect-core/src/main/java/com/nect/core/repository/matching/MatchingRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/matching/MatchingRepository.java @@ -99,4 +99,12 @@ List findReceivedMatchingsOrderByExpiresAt( @Param("user") User user, @Param("matchingStatus") MatchingStatus matchingStatus ); + + @Query(""" + SELECT COUNT(m) + FROM Matching m + WHERE m.matchingStatus = :status + """) + long countByStatus(@Param("status") MatchingStatus status); + } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectInterestFieldRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectInterestFieldRepository.java new file mode 100644 index 00000000..b9cf0bbc --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectInterestFieldRepository.java @@ -0,0 +1,11 @@ +package com.nect.core.repository.team; + +import com.nect.core.entity.team.ProjectInterest; +import com.nect.core.entity.user.enums.InterestField; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ProjectInterestFieldRepository extends JpaRepository { + Optional findByProjectIdAndInterestField(Long projectId, InterestField interestField); +} diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectPlanFileRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectPlanFileRepository.java new file mode 100644 index 00000000..02351682 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectPlanFileRepository.java @@ -0,0 +1,10 @@ +package com.nect.core.repository.team; + +import com.nect.core.entity.team.ProjectPlanFile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ProjectPlanFileRepository extends JpaRepository { + Optional findByIdAndProjectId(Long id, Long projectId); +} diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java index a17d1bbd..cd296762 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java @@ -215,8 +215,6 @@ boolean existsActiveLeadInProject( ); - Optional findByProjectIdAndMemberType(Long projectId, ProjectMemberType memberType); - boolean existsByProjectIdAndUserIdAndMemberTypeAndMemberStatus(Long projectId, Long userId, ProjectMemberType projectMemberType, ProjectMemberStatus projectMemberStatus); @Query(""" @@ -265,6 +263,23 @@ interface MemberBoardRow { ProjectMemberType getMemberType(); } + Optional findByProjectIdAndMemberType(Long projectId, ProjectMemberType memberType); + + @Query(""" + SELECT COUNT(pu.userId) + FROM ProjectUser pu + GROUP BY pu.userId + HAVING COUNT(pu) >= 2 +""") + List countRejoinedUsers(); + + @Query(""" + SELECT COUNT(DISTINCT pu.userId) + FROM ProjectUser pu +""") + long countDistinctUsers(); + + interface ProjectLeaderProfileRow { Long getUserId(); String getNickname(); diff --git a/nect-scheduler/build.gradle b/nect-scheduler/build.gradle deleted file mode 100644 index 30971ef6..00000000 --- a/nect-scheduler/build.gradle +++ /dev/null @@ -1,13 +0,0 @@ -dependencies { - implementation project(':nect-core') - implementation project(':nect-client') - implementation project(':nect-api') -} - -bootJar { - enabled = true -} - -jar { - enabled = false -} diff --git a/nect-scheduler/src/main/java/com/nect/scheduler/NectSchedulerApplication.java b/nect-scheduler/src/main/java/com/nect/scheduler/NectSchedulerApplication.java deleted file mode 100644 index bdee9ea4..00000000 --- a/nect-scheduler/src/main/java/com/nect/scheduler/NectSchedulerApplication.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.nect.scheduler; - -import jakarta.annotation.PostConstruct; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.context.annotation.EnableAspectJAutoProxy; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -import org.springframework.scheduling.annotation.EnableAsync; - -import java.util.TimeZone; - -@EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = true) -@SpringBootApplication(scanBasePackages = "com.nect") -@EnableJpaRepositories(basePackages = "com.nect") -@EntityScan(basePackages = "com.nect") -@EnableAsync -public class NectSchedulerApplication { - - public static void main(String[] args) { - SpringApplication.run(NectSchedulerApplication.class, args); - } - - @PostConstruct - void init() { - TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); - } -} \ No newline at end of file diff --git a/nect-scheduler/src/main/java/com/nect/scheduler/config/SchedulingConfig.java b/nect-scheduler/src/main/java/com/nect/scheduler/config/SchedulingConfig.java deleted file mode 100644 index d20d6fae..00000000 --- a/nect-scheduler/src/main/java/com/nect/scheduler/config/SchedulingConfig.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.nect.scheduler.config; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.annotation.SchedulingConfigurer; -import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; -import org.springframework.scheduling.config.ScheduledTaskRegistrar; - -@Configuration -@EnableScheduling -@Slf4j -public class SchedulingConfig implements SchedulingConfigurer { - - private static final int POOL_SIZE = 5; - - @Bean - public TaskScheduler taskScheduler() { - ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); - scheduler.setPoolSize(POOL_SIZE); - scheduler.setThreadNamePrefix("nect-scheduler-"); - scheduler.initialize(); - log.info("TaskScheduler initialized with pool size: {}", POOL_SIZE); - return scheduler; - } - - @Override - public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { - taskRegistrar.setTaskScheduler(taskScheduler()); - } -} \ No newline at end of file diff --git a/nect-scheduler/src/main/resources/application.yml b/nect-scheduler/src/main/resources/application.yml deleted file mode 100644 index e60b7362..00000000 --- a/nect-scheduler/src/main/resources/application.yml +++ /dev/null @@ -1,9 +0,0 @@ -spring: - application: - name: kkambbak-scheduler - - profiles: - include: - - core - - client - - scheduler diff --git a/settings.gradle b/settings.gradle index 1b1b9072..8c30e5b4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,4 +3,3 @@ rootProject.name = 'nect-backend' include 'nect-core' include 'nect-client' include 'nect-api' -include 'nect-scheduler' From b57f7c404621400ed282ea766ee94c7dc33034a7 Mon Sep 17 00:00:00 2001 From: infiniment Date: Sun, 8 Feb 2026 16:53:46 +0900 Subject: [PATCH 51/66] =?UTF-8?q?[Refactor]=20=EB=A6=AC=EB=8D=94=EB=A7=8C?= =?UTF-8?q?=20=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/service/ProjectTeamCommandService.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamCommandService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamCommandService.java index a3c09b73..bb7db0f4 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamCommandService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamCommandService.java @@ -34,6 +34,14 @@ private void assertActiveProjectMember(Long projectId, Long userId) { } } + private void assertActiveLeader(Long projectId, Long userId) { + boolean isLeader = projectUserRepository.existsActiveLeader(projectId, userId); + if (!isLeader) { + throw new ProjectException(ProjectErrorCode.LEADER_ONLY_ACTION, + "projectId=" + projectId + ", userId=" + userId); + } + } + private String normalize(String s) { if (s == null) return null; String t = s.trim(); @@ -43,6 +51,7 @@ private String normalize(String s) { @Transactional public ProjectPartCreateResDto createProjectPart(Long projectId, Long userId, ProjectPartCreateReqDto req) { assertActiveProjectMember(projectId, userId); + assertActiveLeader(projectId, userId); Project project = projectRepository.findById(projectId) .orElseThrow(() -> new ProjectException(ProjectErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId)); @@ -122,6 +131,7 @@ public ProjectPartCreateResDto createProjectPart(Long projectId, Long userId, Pr @Transactional public ProjectPartUpdateResDto updateProjectPart(Long projectId, Long partId, Long userId, ProjectPartUpdateReqDto req) { assertActiveProjectMember(projectId, userId); + assertActiveLeader(projectId, userId); ProjectTeamRole part = projectTeamRoleRepository.findByIdAndProject_IdAndDeletedAtIsNull(partId, projectId) .orElseThrow(() -> new ProjectException(ProjectErrorCode.PROJECT_PART_NOT_FOUND, From c97473c8fd852a8b100afb5838df9f181a0f2ca7 Mon Sep 17 00:00:00 2001 From: KimMinKyu Date: Sun, 8 Feb 2026 17:20:48 +0900 Subject: [PATCH 52/66] Feat/team/chat (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix : 작업실 채팅 닉네임 조회 및 공지 로직 개선 및 분석 기반 프로젝트 생성 개선 * fix : 분석서 테스트 코드 리팩토링 * Feat : 작업실 채팅 파일 공유 문서 등록 API 구현 & 아이디어 분석 생성 로직 개선 --- .../analysis/service/IdeaAnalysisService.java | 14 ++-- .../chat/controller/ChatFileController.java | 14 ++++ .../SharedDocumentCreateByChatRequestDto.java | 8 ++ .../dto/res/SharedDocumentCreateResDto.java | 13 +++ .../team/chat/service/ChatFileService.java | 68 ++++++++++++++++ .../BoardsSharedDocumentController.java | 2 +- .../controller/ChatFileControllerTest.java | 79 +++++++++++++++++-- .../nect/core/entity/team/ProjectUser.java | 1 + .../team/ProjectUserRepository.java | 2 +- 9 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/chat/dto/req/SharedDocumentCreateByChatRequestDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/chat/dto/res/SharedDocumentCreateResDto.java diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisService.java b/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisService.java index 0827d94e..e987e121 100644 --- a/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisService.java +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisService.java @@ -53,21 +53,24 @@ public IdeaAnalysisResponseDto analyzeProjectIdea(Long userId, IdeaAnalysisReque OpenAiResponse openAiResponse = openAiClient.createResponse(openAiRequest); IdeaAnalysisResponseDto response = responseConverter.toIdeaAnalysisResponse(openAiResponse); - LocalDate targetDate = requestDto.getTargetCompletionDate(); + + LocalDate startDate = LocalDate.now(); + int totalWeeks = response.getProjectDuration().getTotalWeeks(); - LocalDate startDate = targetDate.minusWeeks(totalWeeks).plusDays(1); + + LocalDate endDate = startDate.plusWeeks(totalWeeks).minusDays(1); + response.getProjectDuration().setStartDate(startDate); - response.getProjectDuration().setEndDate(targetDate); + response.getProjectDuration().setEndDate(endDate); response.getProjectDuration().setDisplayText( totalWeeks + "주 (" + startDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + " ~ " + - targetDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + endDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + ")" ); - calculateWeeklyDates(response.getWeeklyRoadmap(), startDate); validateRoleFieldConsistency(response); @@ -82,6 +85,7 @@ public IdeaAnalysisResponseDto analyzeProjectIdea(Long userId, IdeaAnalysisReque throw new IdeaAnalysisException(IdeaAnalysisErrorCode.ANALYSIS_FAILED, "AI 분석 중 오류가 발생했습니다.", e); } } + /** * 주차별 날짜 계산 */ diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatFileController.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatFileController.java index 65b67d67..fcb6632c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatFileController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/controller/ChatFileController.java @@ -2,6 +2,7 @@ import com.nect.api.domain.team.chat.dto.req.ChatMessageDto; +import com.nect.api.domain.team.chat.dto.req.SharedDocumentCreateByChatRequestDto; import com.nect.api.domain.team.chat.dto.res.*; import com.nect.api.global.response.ApiResponse; import com.nect.api.domain.team.chat.service.ChatFileService; @@ -91,6 +92,19 @@ public RedirectView downloadFile(@PathVariable Long fileId, return new RedirectView(downloadUrl); } + @PostMapping("/rooms/{roomId}/shared-documents") + public ApiResponse createSharedDocumentFromChat( + @PathVariable Long roomId, + @RequestParam Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody SharedDocumentCreateByChatRequestDto req + ) { + Long userId = userDetails.getUserId(); + + SharedDocumentCreateResDto response = + chatFileService.createFromChatFile(projectId, roomId, userId, req.chatFileId()); + return ApiResponse.ok(response); + } } \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/req/SharedDocumentCreateByChatRequestDto.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/req/SharedDocumentCreateByChatRequestDto.java new file mode 100644 index 00000000..43d5315f --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/req/SharedDocumentCreateByChatRequestDto.java @@ -0,0 +1,8 @@ +package com.nect.api.domain.team.chat.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record SharedDocumentCreateByChatRequestDto( + @JsonProperty("chat_file_id") + Long chatFileId +) {} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/res/SharedDocumentCreateResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/res/SharedDocumentCreateResDto.java new file mode 100644 index 00000000..d9685600 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/dto/res/SharedDocumentCreateResDto.java @@ -0,0 +1,13 @@ +package com.nect.api.domain.team.chat.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.enums.DocumentType; + +public record SharedDocumentCreateResDto( + @JsonProperty("document_id") + Long documentId, + @JsonProperty("title") + String title, + @JsonProperty("document_type") + DocumentType documentType +) {} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatFileService.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatFileService.java index 0d4e26fe..45b12195 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatFileService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/service/ChatFileService.java @@ -2,18 +2,30 @@ import com.nect.api.domain.team.chat.converter.FileConverter; import com.nect.api.domain.team.chat.dto.req.ChatMessageDto; import com.nect.api.domain.team.chat.dto.res.*; +import com.nect.api.domain.team.chat.enums.ChatErrorCode; +import com.nect.api.domain.team.chat.exeption.ChatException; import com.nect.api.domain.team.chat.util.FileValidator; +import com.nect.api.domain.team.file.enums.FileErrorCode; +import com.nect.api.domain.team.file.exception.FileException; +import com.nect.api.domain.team.workspace.enums.BoardsErrorCode; +import com.nect.api.domain.team.workspace.exception.BoardsException; import com.nect.api.domain.user.enums.UserErrorCode; import com.nect.api.global.code.StorageErrorCode; import com.nect.api.global.infra.S3Service; import com.nect.api.global.infra.exception.StorageException; import com.nect.api.global.infra.redis.RedisPublisher; +import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.ProjectUser; +import com.nect.core.entity.team.SharedDocument; import com.nect.core.entity.team.chat.ChatFile; import com.nect.core.entity.team.chat.ChatMessage; import com.nect.core.entity.team.chat.ChatRoom; import com.nect.core.entity.team.chat.ChatRoomUser; +import com.nect.core.entity.team.enums.FileExt; import com.nect.core.entity.user.User; +import com.nect.core.repository.team.ProjectRepository; import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.team.SharedDocumentRepository; import com.nect.core.repository.team.chat.ChatFileRepository; import com.nect.core.repository.team.chat.ChatMessageRepository; import com.nect.core.repository.team.chat.ChatRoomRepository; @@ -47,6 +59,8 @@ public class ChatFileService { private final RedisPublisher redisPublisher; private final S3Service s3Service; private final ProjectUserRepository projectUserRepository; + private final ProjectRepository projectRepository; + private final SharedDocumentRepository sharedDocumentRepository; private String uploadDir; @@ -248,4 +262,58 @@ private String getSafePresignedUrl(String fileName) { } return s3Service.getPresignedGetUrl(fileName); } + + @Transactional + public SharedDocumentCreateResDto createFromChatFile(Long projectId, Long roomId,Long userId, Long chatFileId) { + + ProjectUser projectUser = projectUserRepository.findByProjectIdAndUserId(projectId, userId) + .orElseThrow(() -> new BoardsException(BoardsErrorCode.PROJECT_MEMBER_FORBIDDEN)); + + + User registrar = userRepository.findById(userId) + .orElseThrow(() -> new BoardsException(BoardsErrorCode.USER_NOT_FOUND)); + + Project project = projectUser.getProject(); + + ChatFile chatFile = chatFileRepository.findById(chatFileId) + .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_FILE_NOT_FOUND)); + + if (!chatFile.getChatRoom().getId().equals(roomId)) { + throw new ChatException(ChatErrorCode.CHAT_FILE_NOT_FOUND); + } + + FileExt ext = extractFileExt(chatFile.getOriginalFileName()); + + SharedDocument sharedDocument = SharedDocument.ofFile( + registrar, + project, + chatFile.getOriginalFileName(), + chatFile.getOriginalFileName(), + ext, + chatFile.getStoredFileName(), + chatFile.getFileSize() + ); + + SharedDocument savedDoc = sharedDocumentRepository.save(sharedDocument); + + return new SharedDocumentCreateResDto( + savedDoc.getId(), + savedDoc.getTitle(), + savedDoc.getDocumentType() + ); + } + + private FileExt extractFileExt(String fileName) { + if (fileName == null || !fileName.contains(".")) { + throw new FileException(FileErrorCode.UNSUPPORTED_FILE_EXT, "확장자가 없는 파일입니다. fileName=" + fileName); + } + + String extStr = fileName.substring(fileName.lastIndexOf(".") + 1).toUpperCase(); + + try { + return FileExt.valueOf(extStr); + } catch (IllegalArgumentException e) { + throw new FileException(FileErrorCode.UNSUPPORTED_FILE_EXT, "지원하지 않는 파일 확장자입니다. fileName=" + fileName); + } + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java index afbca4c1..20416441 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java @@ -16,7 +16,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/projects/{projectId}/boards") -public class BoardsSharedDocumentController { +public class BoardsSharedDocumentController { private final BoardsSharedDocumentFacade facade; diff --git a/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatFileControllerTest.java b/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatFileControllerTest.java index be479ef8..697982b8 100644 --- a/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatFileControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/team/chat/controller/ChatFileControllerTest.java @@ -2,18 +2,17 @@ import com.epages.restdocs.apispec.ResourceDocumentation; import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; import com.nect.api.domain.team.chat.dto.req.ChatMessageDto; -import com.nect.api.domain.team.chat.dto.res.ChatFileDetailDto; -import com.nect.api.domain.team.chat.dto.res.ChatFileResponseDto; -import com.nect.api.domain.team.chat.dto.res.ChatFileUploadResponseDto; -import com.nect.api.domain.team.chat.dto.res.ChatRoomAlbumDetailDto; -import com.nect.api.domain.team.chat.dto.res.ChatRoomAlbumResponseDto; +import com.nect.api.domain.team.chat.dto.req.SharedDocumentCreateByChatRequestDto; +import com.nect.api.domain.team.chat.dto.res.*; import com.nect.api.domain.team.chat.service.ChatFileService; import com.nect.api.global.jwt.JwtUtil; import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; import com.nect.api.global.security.UserDetailsServiceImpl; import com.nect.core.entity.team.chat.enums.MessageType; +import com.nect.core.entity.team.enums.DocumentType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -60,6 +59,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; @SpringBootTest @AutoConfigureMockMvc @@ -85,6 +85,9 @@ class ChatFileControllerTest { @MockitoBean private TokenBlacklistService tokenBlacklistService; + @Autowired + private ObjectMapper objectMapper; + @BeforeEach void setUpAuth() { doNothing().when(jwtUtil).validateToken(any()); @@ -501,4 +504,70 @@ void downloadFile() throws Exception { verify(chatFileService).getDownloadUrl(eq(fileId), eq(userId)); } + + @Test + @DisplayName("채팅 파일을 공유 문서함으로 등록 API") + void createSharedDocumentFromChat() throws Exception { + // Given + Long roomId = 1L; + Long projectId = 10L; + Long chatFileId = 100L; + Long userId = 1L; + + SharedDocumentCreateByChatRequestDto request = new SharedDocumentCreateByChatRequestDto(chatFileId); + + SharedDocumentCreateResDto response = new SharedDocumentCreateResDto( + 50L, // 생성된 문서 ID + "회의록_최종.pdf", // 제목 + DocumentType.FILE // 문서 타입 + ); + + // chatFileService (또는 위 컨트롤러 구조에 따라 주입된 서비스) Mocking + given(chatFileService.createFromChatFile(eq(projectId), eq(roomId), eq(userId), eq(chatFileId))) + .willReturn(response); + + // When & Then + mockMvc.perform( + post("/api/v1/chats/rooms/{roomId}/shared-documents", roomId) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .with(mockUser(userId)) + .param("projectId", String.valueOf(projectId)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.body.document_id").value(50)) + .andExpect(jsonPath("$.body.title").value("회의록_최종.pdf")) + .andDo(document("chat-file-to-shared-document", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag("채팅") + .summary("채팅 파일을 공유 문서함으로 등록 API") + .description("채팅방에 업로드된 파일을 해당 프로젝트의 공유 문서함 자산으로 등록합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .pathParameters( + parameterWithName("roomId").description("채팅방 ID") + ) + .queryParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestFields( + fieldWithPath("chat_file_id").description("등록할 채팅 파일 엔티티 ID") + ) + .responseFields( + fieldWithPath("status.statusCode").description("상태 코드"), + fieldWithPath("status.message").description("상태 메시지"), + fieldWithPath("status.description").description("상세 설명").optional(), + fieldWithPath("body.document_id").description("생성된 공유 문서 ID"), + fieldWithPath("body.title").description("문서 제목"), + fieldWithPath("body.document_type").description("문서 타입 (FILE/LINK)") + ) + .build() + ) + )); + } } diff --git a/nect-core/src/main/java/com/nect/core/entity/team/ProjectUser.java b/nect-core/src/main/java/com/nect/core/entity/team/ProjectUser.java index 8dff34d6..0a23e1f4 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/ProjectUser.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/ProjectUser.java @@ -21,6 +21,7 @@ ) } ) + public class ProjectUser { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java index cd296762..2d2d5fdc 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectUserRepository.java @@ -299,6 +299,6 @@ Optional findActiveUserByProjectIdAndUserId( @Param("userId") Long userId ); - + Optional findByProjectIdAndUserId(Long projectId, Long userId); } From d2245ea589b79532b37ab7ea16a1f2dded92d0ec Mon Sep 17 00:00:00 2001 From: infiniment Date: Sun, 8 Feb 2026 19:23:06 +0900 Subject: [PATCH 53/66] =?UTF-8?q?[Feat]=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=8C=80=20=ED=8C=8C=ED=8A=B8(=EC=A7=81?= =?UTF-8?q?=EC=97=85)=20=EC=B6=94=EA=B0=80/=EC=88=98=EC=A0=95=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserTeamRoleController.java | 44 ++++ .../mypage/dto/UserTeamRoleCreateReqDto.java | 16 ++ .../mypage/dto/UserTeamRoleCreateResDto.java | 21 ++ .../mypage/dto/UserTeamRoleUpdateReqDto.java | 11 + .../mypage/dto/UserTeamRoleUpdateResDto.java | 21 ++ .../mypage/enums/UserTeamRoleErrorCode.java | 26 +++ .../exception/UserTeamRoleException.java | 14 ++ .../mypage/service/UserTeamRoleService.java | 210 ++++++++++++++++++ .../service/ProjectTeamQueryService.java | 1 + .../nect/core/entity/user/UserTeamRole.java | 94 ++++++++ .../user/UserTeamRoleRepository.java | 21 ++ 11 files changed, 479 insertions(+) create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/controller/UserTeamRoleController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleCreateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleCreateResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleUpdateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleUpdateResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/enums/UserTeamRoleErrorCode.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/exception/UserTeamRoleException.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleService.java create mode 100644 nect-core/src/main/java/com/nect/core/entity/user/UserTeamRole.java create mode 100644 nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/UserTeamRoleController.java b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/UserTeamRoleController.java new file mode 100644 index 00000000..3640d9ee --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/UserTeamRoleController.java @@ -0,0 +1,44 @@ +package com.nect.api.domain.mypage.controller; + +import com.nect.api.domain.mypage.dto.UserTeamRoleCreateReqDto; +import com.nect.api.domain.mypage.dto.UserTeamRoleCreateResDto; +import com.nect.api.domain.mypage.dto.UserTeamRoleUpdateReqDto; +import com.nect.api.domain.mypage.dto.UserTeamRoleUpdateResDto; +import com.nect.api.domain.mypage.service.UserTeamRoleService; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/mypage/projects/{projectId}/team-roles") +public class UserTeamRoleController { + + private final UserTeamRoleService userTeamRoleService; + + + // 마이페이지 팀 파트 생성(추가) + @PostMapping + public ApiResponse create( + @PathVariable("projectId") Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody UserTeamRoleCreateReqDto req + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(userTeamRoleService.create(projectId, userId, req)); + } + + // 마이페이지 팀 파트 수정 (CUSTOM만 가능) + @PatchMapping("/{userTeamRoleId}") + public ApiResponse update( + @PathVariable("projectId") Long projectId, + @PathVariable("userTeamRoleId") Long userTeamRoleId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody UserTeamRoleUpdateReqDto req + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(userTeamRoleService.update(projectId, userId, userTeamRoleId, req)); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleCreateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleCreateReqDto.java new file mode 100644 index 00000000..eb01630f --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleCreateReqDto.java @@ -0,0 +1,16 @@ +package com.nect.api.domain.mypage.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +public record UserTeamRoleCreateReqDto( + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("required_count") + Integer requiredCount +) { +} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleCreateResDto.java b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleCreateResDto.java new file mode 100644 index 00000000..e496c492 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleCreateResDto.java @@ -0,0 +1,21 @@ +package com.nect.api.domain.mypage.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +public record UserTeamRoleCreateResDto( + @JsonProperty("team_role_id") + Long teamRoleId, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("part_label") + String partLabel, + + @JsonProperty("required_count") + Integer requiredCount +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleUpdateReqDto.java new file mode 100644 index 00000000..a8ffd62f --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleUpdateReqDto.java @@ -0,0 +1,11 @@ +package com.nect.api.domain.mypage.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record UserTeamRoleUpdateReqDto( + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("required_count") + Integer requiredCount +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleUpdateResDto.java b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleUpdateResDto.java new file mode 100644 index 00000000..837ff90e --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRoleUpdateResDto.java @@ -0,0 +1,21 @@ +package com.nect.api.domain.mypage.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +public record UserTeamRoleUpdateResDto( + @JsonProperty("user_team_role_id") + Long userTeamRoleId, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("part_label") + String partLabel, + + @JsonProperty("required_count") + Integer requiredCount +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/enums/UserTeamRoleErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/mypage/enums/UserTeamRoleErrorCode.java new file mode 100644 index 00000000..e546e7e6 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/enums/UserTeamRoleErrorCode.java @@ -0,0 +1,26 @@ +package com.nect.api.domain.mypage.enums; + +import com.nect.api.global.code.ResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UserTeamRoleErrorCode implements ResponseCode { + + INVALID_REQUEST("UTR4000", "요청 값이 올바르지 않습니다"), + INVALID_REQUIRED_COUNT("UTR4001", "모집 인원은 1 이상이어야 합니다"), + INVALID_CUSTOM_ROLE_NAME("UTR4002", "커스텀 파트명은 필수입니다"), + + FORBIDDEN_NOT_LEADER("UTR4030", "프로젝트 리더만 파트 설정이 가능합니다"), + + USER_NOT_FOUND("UTR4040", "존재하지 않는 사용자입니다"), + PROJECT_NOT_FOUND("UTR4041", "존재하지 않는 프로젝트입니다"), + ROLE_NOT_FOUND("UTR4042", "해당 파트를 찾을 수 없습니다"), + + DUPLICATE_ROLE("UTR4090", "이미 존재하는 파트입니다"); + + + private final String statusCode; + private final String message; +} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/exception/UserTeamRoleException.java b/nect-api/src/main/java/com/nect/api/domain/mypage/exception/UserTeamRoleException.java new file mode 100644 index 00000000..1fe444da --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/exception/UserTeamRoleException.java @@ -0,0 +1,14 @@ +package com.nect.api.domain.mypage.exception; + +import com.nect.api.domain.mypage.enums.UserTeamRoleErrorCode; +import com.nect.api.global.exception.CustomException; + +public class UserTeamRoleException extends CustomException { + public UserTeamRoleException(UserTeamRoleErrorCode errorCode) { + super(errorCode); + } + + public UserTeamRoleException(UserTeamRoleErrorCode errorCode, String message) { + super(errorCode, message); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleService.java b/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleService.java new file mode 100644 index 00000000..586504da --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleService.java @@ -0,0 +1,210 @@ +package com.nect.api.domain.mypage.service; + +import com.nect.api.domain.mypage.dto.UserTeamRoleCreateReqDto; +import com.nect.api.domain.mypage.dto.UserTeamRoleCreateResDto; +import com.nect.api.domain.mypage.dto.UserTeamRoleUpdateReqDto; +import com.nect.api.domain.mypage.dto.UserTeamRoleUpdateResDto; +import com.nect.api.domain.mypage.enums.UserTeamRoleErrorCode; +import com.nect.api.domain.mypage.exception.UserTeamRoleException; +import com.nect.core.entity.team.Project; +import com.nect.core.entity.user.User; +import com.nect.core.entity.user.UserTeamRole; +import com.nect.core.entity.user.enums.RoleField; +import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.user.UserRepository; +import com.nect.core.repository.user.UserTeamRoleRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserTeamRoleService { + private static final int DEFAULT_REQUIRED_COUNT = 1; + + private final UserTeamRoleRepository userTeamRoleRepository; + private final UserRepository userRepository; + private final ProjectRepository projectRepository; + private final ProjectUserRepository projectUserRepository; + + private String normalize(String raw) { + if (raw == null) return null; + String t = raw.trim(); + return t.isEmpty() ? null : t; + } + + // 마이페이지 팀 파트 생성 서비스 + @Transactional + public UserTeamRoleCreateResDto create(Long projectId, Long userId, UserTeamRoleCreateReqDto req) { + // 요청 검증 + if (projectId == null) { + throw new UserTeamRoleException(UserTeamRoleErrorCode.INVALID_REQUEST, + "projectId is required" + ); + } + + if(req == null || req.roleField() == null) { + throw new UserTeamRoleException(UserTeamRoleErrorCode.INVALID_REQUEST, + "roleField is required" + ); + } + + // 리더 검증 + boolean isLeader = projectUserRepository.existsActiveLeader(projectId, userId); + if (!isLeader) { + throw new UserTeamRoleException( + UserTeamRoleErrorCode.FORBIDDEN_NOT_LEADER, + "projectId=" + projectId + ", userId=" + userId + ); + } + + RoleField roleField = req.roleField(); + String customName = normalize(req.customRoleFieldName()); + int requiredCount = (req.requiredCount() == null) ? DEFAULT_REQUIRED_COUNT : req.requiredCount(); + + if(requiredCount < 1) { + throw new UserTeamRoleException( + UserTeamRoleErrorCode.INVALID_REQUEST, + "requiredCount=" + requiredCount + ); + } + + if(roleField == RoleField.CUSTOM) { + if (customName == null || customName.isBlank()) { + throw new UserTeamRoleException(UserTeamRoleErrorCode.INVALID_CUSTOM_ROLE_NAME, "customRoleName is required"); + } + + boolean duplicate = userTeamRoleRepository + .existsByProject_IdAndUser_UserIdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( + projectId, userId, roleField, customName); + + if(duplicate) { + throw new UserTeamRoleException( + UserTeamRoleErrorCode.DUPLICATE_ROLE, + "customRoleName=" + customName + ); + } + }else { + boolean duplicate = userTeamRoleRepository + .existsByProject_IdAndUser_UserIdAndRoleFieldAndDeletedAtIsNull(projectId, userId, roleField); + + if (duplicate) { + throw new UserTeamRoleException( + UserTeamRoleErrorCode.DUPLICATE_ROLE, + "roleField=" + roleField.name() + ); + } + + customName = null; + } + + // 유저 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserTeamRoleException(UserTeamRoleErrorCode.USER_NOT_FOUND, + "userId=" + userId + )); + + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new UserTeamRoleException(UserTeamRoleErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId)); + + UserTeamRole saved = userTeamRoleRepository.save( + UserTeamRole.builder() + .project(project) + .user(user) + .roleField(roleField) + .customRoleFieldName(customName) + .requiredCount(requiredCount) + .build() + ); + + return new UserTeamRoleCreateResDto( + saved.getId(), + saved.getRoleField(), + saved.getCustomRoleFieldName(), + saved.getLabel(), + saved.getRequiredCount() + ); + } + + // 마이페이지 팀 수정 서비스(이름 or 총원) + @Transactional + public UserTeamRoleUpdateResDto update(Long projectId, Long userId, Long userTeamRoleId, UserTeamRoleUpdateReqDto req) { + if (projectId == null) { + throw new UserTeamRoleException(UserTeamRoleErrorCode.INVALID_REQUEST, "projectId is required"); + } + if (userTeamRoleId == null) { + throw new UserTeamRoleException(UserTeamRoleErrorCode.INVALID_REQUEST, "userTeamRoleId is required"); + } + if (req == null) { + throw new UserTeamRoleException(UserTeamRoleErrorCode.INVALID_REQUEST, "req is required"); + } + + // 리더 검증 + boolean isLeader = projectUserRepository.existsActiveLeader(projectId, userId); + if (!isLeader) { + throw new UserTeamRoleException( + UserTeamRoleErrorCode.FORBIDDEN_NOT_LEADER, + "projectId=" + projectId + ", userId=" + userId + ); + } + + // 수정 대상 조회(soft delete 제외) + UserTeamRole role = userTeamRoleRepository + .findByIdAndProject_IdAndDeletedAtIsNull(userTeamRoleId, projectId) + .orElseThrow(() -> new UserTeamRoleException( + UserTeamRoleErrorCode.ROLE_NOT_FOUND, + "projectId=" + projectId + ", userTeamRoleId=" + userTeamRoleId + )); + + // CUSTOM만 수정 가능 + if (role.getRoleField() != RoleField.CUSTOM) { + throw new UserTeamRoleException( + UserTeamRoleErrorCode.INVALID_REQUEST, + "only CUSTOM role can be updated. roleField=" + role.getRoleField() + ); + } + + // required_count 수정 + if (req.requiredCount() != null) { + int requiredCount = req.requiredCount(); + if (requiredCount < 1) { + throw new UserTeamRoleException(UserTeamRoleErrorCode.INVALID_REQUEST, "requiredCount=" + requiredCount); + } + role.updateRequiredCount(requiredCount); + } + + // custom 이름 수정 + if (req.customRoleFieldName() != null) { + String newName = normalize(req.customRoleFieldName()); + + if (newName == null || newName.isBlank()) { + throw new UserTeamRoleException(UserTeamRoleErrorCode.INVALID_CUSTOM_ROLE_NAME, "customRoleName is required"); + } + if (newName.length() > 50) { + throw new UserTeamRoleException(UserTeamRoleErrorCode.INVALID_CUSTOM_ROLE_NAME, "customRoleName too long"); + } + + String oldName = role.getCustomRoleFieldName(); + if (oldName == null || !oldName.equalsIgnoreCase(newName)) { + boolean duplicate = userTeamRoleRepository + .existsByProject_IdAndUser_UserIdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( + projectId, role.getUser().getUserId(), RoleField.CUSTOM, newName + ); + + if (duplicate) { + throw new UserTeamRoleException(UserTeamRoleErrorCode.DUPLICATE_ROLE, "customRoleName=" + newName); + } + role.updateCustomRoleName(newName); + } + } + + return new UserTeamRoleUpdateResDto( + role.getId(), + role.getRoleField(), + role.getCustomRoleFieldName(), + role.getLabel(), // JSON 키는 DTO에서 part_label로 매핑 + role.getRequiredCount() + ); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java index 7794e937..3186989e 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java @@ -64,6 +64,7 @@ public ProjectPartsResDto readProjectParts(Long projectId, Long userId) { return new ProjectPartsResDto(parts); } + // 프로젝트 멤버 전체 조회 서비스 @Transactional(readOnly = true) public ProjectUsersResDto readProjectUsers(Long projectId, Long userId) { diff --git a/nect-core/src/main/java/com/nect/core/entity/user/UserTeamRole.java b/nect-core/src/main/java/com/nect/core/entity/user/UserTeamRole.java new file mode 100644 index 00000000..edf9b5a0 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/entity/user/UserTeamRole.java @@ -0,0 +1,94 @@ +package com.nect.core.entity.user; + +import com.nect.core.entity.team.Project; +import com.nect.core.entity.user.enums.RoleField; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table( + name = "user_team_roles", + indexes = { + @Index(name = "idx_user_team_roles_project_id", columnList = "project_id"), + @Index(name = "idx_user_team_roles_user_id", columnList = "user_id"), + @Index(name = "idx_user_team_roles_project_user", columnList = "project_id, user_id"), + @Index(name = "idx_user_team_roles_project_user_role", columnList = "project_id, user_id, role_field") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserTeamRole { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 마이페이지 설정은 "프로젝트별"이어야 함 + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "role_field", nullable = false, length = 50) + private RoleField roleField; + + // CUSTOM일 때만 사용 + @Column(name = "custom_role_field_name", length = 50) + private String customRoleFieldName; + + // UI의 “1명” + @Column(name = "required_count", nullable = false) + private Integer requiredCount; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder + private UserTeamRole(Project project, User user, RoleField roleField, String customRoleFieldName, Integer requiredCount) { + this.project = project; + this.user = user; + this.roleField = roleField; + this.customRoleFieldName = customRoleFieldName; + this.requiredCount = requiredCount; + } + + public static UserTeamRole of(Project project, User user, RoleField roleField, String customRoleFieldName, Integer requiredCount) { + return UserTeamRole.builder() + .project(project) + .user(user) + .roleField(roleField) + .customRoleFieldName(customRoleFieldName) + .requiredCount(requiredCount) + .build(); + } + + public boolean isDeleted() { + return deletedAt != null; + } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } + + public String getLabel() { + if (roleField == RoleField.CUSTOM) return customRoleFieldName; + return roleField.getLabelEn(); + } + + public void updateCustomRoleName(String customRoleFieldName) { + this.customRoleFieldName = customRoleFieldName; + } + + public void updateRequiredCount(Integer requiredCount) { + this.requiredCount = requiredCount; + } +} diff --git a/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java new file mode 100644 index 00000000..eb4cfda8 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java @@ -0,0 +1,21 @@ +package com.nect.core.repository.user; + +import com.nect.core.entity.user.UserTeamRole; +import com.nect.core.entity.user.enums.RoleField; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserTeamRoleRepository extends JpaRepository { + + boolean existsByProject_IdAndUser_UserIdAndRoleFieldAndDeletedAtIsNull( + Long projectId, Long userId, RoleField roleField + ); + + boolean existsByProject_IdAndUser_UserIdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( + Long projectId, Long userId, RoleField roleField, String customRoleFieldName + ); + + Optional findByIdAndProject_IdAndDeletedAtIsNull(Long id, Long projectId); + +} From 7d8f932cebf8339f9081e94e5e7689c76d206219 Mon Sep 17 00:00:00 2001 From: infiniment Date: Sun, 8 Feb 2026 19:23:19 +0900 Subject: [PATCH 54/66] =?UTF-8?q?[Test]=20API=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserTeamRoleControllerTest.java | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 nect-api/src/test/java/com/nect/api/domain/mypage/controller/UserTeamRoleControllerTest.java diff --git a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/UserTeamRoleControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/UserTeamRoleControllerTest.java new file mode 100644 index 00000000..5a680bfe --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/UserTeamRoleControllerTest.java @@ -0,0 +1,241 @@ +package com.nect.api.domain.mypage.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.mypage.dto.UserTeamRoleCreateReqDto; +import com.nect.api.domain.mypage.dto.UserTeamRoleCreateResDto; +import com.nect.api.domain.mypage.dto.UserTeamRoleUpdateReqDto; +import com.nect.api.domain.mypage.dto.UserTeamRoleUpdateResDto; +import com.nect.api.domain.mypage.service.UserTeamRoleService; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.user.enums.RoleField; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class UserTeamRoleControllerTest { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UserTeamRoleService userTeamRoleService; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_MEMBER")) + .build() + ); + } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_MEMBER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return SecurityMockMvcRequestPostProcessors.authentication(auth); + } + + @Test + @DisplayName("마이페이지 팀 파트 생성(추가)") + void createUserTeamRole() throws Exception { + long projectId = 1L; + long userId = 1L; + + UserTeamRoleCreateReqDto request = new UserTeamRoleCreateReqDto( + RoleField.CUSTOM, + "데이터", + 2 + ); + + UserTeamRoleCreateResDto response = new UserTeamRoleCreateResDto( + 10L, + RoleField.CUSTOM, + "데이터", + "데이터", + 2 + ); + + given(userTeamRoleService.create(eq(projectId), eq(userId), any(UserTeamRoleCreateReqDto.class))) + .willReturn(response); + + mockMvc.perform(post("/api/v1/mypage/projects/{projectId}/team-roles", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("mypage-team-role-create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("MyPage") + .summary("마이페이지 팀 파트 생성") + .description("마이페이지에서 프로젝트별 팀 파트를 생성합니다. (리더만 가능)") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("role_field").type(STRING).description("파트 타입(RoleField)"), + fieldWithPath("custom_role_field_name").optional().type(STRING).description("CUSTOM 파트명(직접 입력)"), + fieldWithPath("required_count").optional().type(NUMBER).description("모집 인원(기본 1)") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("마이페이지 팀 파트 생성 결과"), + fieldWithPath("body.team_role_id").type(NUMBER).description("생성된 팀 파트 ID"), + fieldWithPath("body.role_field").type(STRING).description("파트 타입(RoleField)"), + fieldWithPath("body.custom_role_field_name").optional().type(STRING).description("CUSTOM 파트명"), + fieldWithPath("body.part_label").type(STRING).description("표시 라벨(part_label)"), + fieldWithPath("body.required_count").type(NUMBER).description("모집 인원") + ) + .build() + ) + )); + + verify(userTeamRoleService).create(eq(projectId), eq(userId), any(UserTeamRoleCreateReqDto.class)); + } + + @Test + @DisplayName("마이페이지 팀 파트 수정(CUSTOM만 가능)") + void updateUserTeamRole() throws Exception { + long projectId = 1L; + long userTeamRoleId = 10L; + long userId = 1L; + + UserTeamRoleUpdateReqDto request = new UserTeamRoleUpdateReqDto( + "데이터분석", + 3 + ); + + UserTeamRoleUpdateResDto response = new UserTeamRoleUpdateResDto( + userTeamRoleId, + RoleField.CUSTOM, + "데이터분석", + "데이터분석", + 3 + ); + + given(userTeamRoleService.update(eq(projectId), eq(userId), eq(userTeamRoleId), any(UserTeamRoleUpdateReqDto.class))) + .willReturn(response); + + mockMvc.perform(patch("/api/v1/mypage/projects/{projectId}/team-roles/{userTeamRoleId}", projectId, userTeamRoleId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("mypage-team-role-update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("MyPage") + .summary("마이페이지 팀 파트 수정") + .description("마이페이지에서 프로젝트별 팀 파트를 수정합니다. (CUSTOM만 가능, 리더만 가능)") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID"), + parameterWithName("userTeamRoleId").description("마이페이지 팀 파트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("custom_role_field_name").optional().type(STRING).description("CUSTOM 파트명(직접 입력)"), + fieldWithPath("required_count").optional().type(NUMBER).description("모집 인원(1 이상)") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("마이페이지 팀 파트 수정 결과"), + fieldWithPath("body.user_team_role_id").type(NUMBER).description("수정된 팀 파트 ID"), + fieldWithPath("body.role_field").type(STRING).description("파트 타입(RoleField)"), + fieldWithPath("body.custom_role_field_name").optional().type(STRING).description("CUSTOM 파트명"), + fieldWithPath("body.part_label").type(STRING).description("표시 라벨(part_label)"), + fieldWithPath("body.required_count").type(NUMBER).description("모집 인원") + ) + .build() + ) + )); + verify(userTeamRoleService).update(eq(projectId), eq(userId), eq(userTeamRoleId), any(UserTeamRoleUpdateReqDto.class)); + } +} From e85897403b30c5b353f1588918cfe6bce60129fe Mon Sep 17 00:00:00 2001 From: infiniment Date: Sun, 8 Feb 2026 20:11:36 +0900 Subject: [PATCH 55/66] =?UTF-8?q?[Refactor]=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80,=20=EC=9E=91=EC=97=85=EC=8B=A4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B3=B5=EC=9A=A9=ED=99=94=20.=20=ED=8C=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=95=EC=B1=85=20=EB=B6=84=EB=A6=AC(=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80,=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EC=8B=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MypageProjectMemberController.java | 30 +++++++++ .../controller/UserTeamRoleController.java | 17 +++-- .../mypage/dto/UserTeamRolesResDto.java | 28 +++++++++ .../mypage/enums/UserTeamRoleErrorCode.java | 1 + .../service/UserTeamRoleQueryService.java | 54 ++++++++++++++++ .../controller/ProjectMemberController.java | 27 ++++++++ .../controller/ProjectPartsController.java | 43 ------------- .../controller/ProjectRoleController.java | 27 ++++++++ ...ce.java => ProjectMemberQueryService.java} | 44 ++----------- .../service/ProjectRoleQueryService.java | 62 +++++++++++++++++++ .../user/UserTeamRoleRepository.java | 3 + 11 files changed, 249 insertions(+), 87 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageProjectMemberController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRolesResDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleQueryService.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectMemberController.java delete mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectRoleController.java rename nect-api/src/main/java/com/nect/api/domain/team/project/service/{ProjectTeamQueryService.java => ProjectMemberQueryService.java} (69%) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectRoleQueryService.java diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageProjectMemberController.java b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageProjectMemberController.java new file mode 100644 index 00000000..8175bdad --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageProjectMemberController.java @@ -0,0 +1,30 @@ +package com.nect.api.domain.mypage.controller; + +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.service.ProjectMemberQueryService; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/mypage/projects/{projectId}/users") +public class MypageProjectMemberController { + + private final ProjectMemberQueryService projectMemberQueryService; + + // 마이페이지 전용 프로젝트 유저 조회 + @GetMapping + public ApiResponse readProjectUsers( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(projectMemberQueryService.readProjectUsers(projectId, userId)); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/UserTeamRoleController.java b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/UserTeamRoleController.java index 3640d9ee..21567b55 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/UserTeamRoleController.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/UserTeamRoleController.java @@ -1,9 +1,7 @@ package com.nect.api.domain.mypage.controller; -import com.nect.api.domain.mypage.dto.UserTeamRoleCreateReqDto; -import com.nect.api.domain.mypage.dto.UserTeamRoleCreateResDto; -import com.nect.api.domain.mypage.dto.UserTeamRoleUpdateReqDto; -import com.nect.api.domain.mypage.dto.UserTeamRoleUpdateResDto; +import com.nect.api.domain.mypage.dto.*; +import com.nect.api.domain.mypage.service.UserTeamRoleQueryService; import com.nect.api.domain.mypage.service.UserTeamRoleService; import com.nect.api.global.response.ApiResponse; import com.nect.api.global.security.UserDetailsImpl; @@ -17,6 +15,7 @@ public class UserTeamRoleController { private final UserTeamRoleService userTeamRoleService; + private final UserTeamRoleQueryService userTeamRoleQueryService; // 마이페이지 팀 파트 생성(추가) @@ -41,4 +40,14 @@ public ApiResponse update( Long userId = userDetails.getUserId(); return ApiResponse.ok(userTeamRoleService.update(projectId, userId, userTeamRoleId, req)); } + + // 마이페이지 파트 목록 조회 + @GetMapping + public ApiResponse readMyPageParts( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(userTeamRoleQueryService.readMyPageParts(projectId, userId)); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRolesResDto.java b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRolesResDto.java new file mode 100644 index 00000000..2fa76ffd --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/dto/UserTeamRolesResDto.java @@ -0,0 +1,28 @@ +package com.nect.api.domain.mypage.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +import java.util.List; + +public record UserTeamRolesResDto( + @JsonProperty("parts") + List parts +) { + public record PartDto( + @JsonProperty("id") + Long id, + + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("label") + String label, + + @JsonProperty("required_count") + Integer requiredCount + ) {} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/enums/UserTeamRoleErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/mypage/enums/UserTeamRoleErrorCode.java index e546e7e6..7b494270 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/enums/UserTeamRoleErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/enums/UserTeamRoleErrorCode.java @@ -13,6 +13,7 @@ public enum UserTeamRoleErrorCode implements ResponseCode { INVALID_CUSTOM_ROLE_NAME("UTR4002", "커스텀 파트명은 필수입니다"), FORBIDDEN_NOT_LEADER("UTR4030", "프로젝트 리더만 파트 설정이 가능합니다"), + FORBIDDEN_NOT_PROJECT_MEMBER("UTR4031", "프로젝트 멤버만 접근할 수 있습니다"), USER_NOT_FOUND("UTR4040", "존재하지 않는 사용자입니다"), PROJECT_NOT_FOUND("UTR4041", "존재하지 않는 프로젝트입니다"), diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleQueryService.java b/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleQueryService.java new file mode 100644 index 00000000..670cf2b0 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleQueryService.java @@ -0,0 +1,54 @@ +package com.nect.api.domain.mypage.service; + +import com.nect.api.domain.mypage.dto.UserTeamRolesResDto; +import com.nect.api.domain.mypage.enums.UserTeamRoleErrorCode; +import com.nect.api.domain.mypage.exception.UserTeamRoleException; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.user.UserTeamRole; +import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.user.UserTeamRoleRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserTeamRoleQueryService { + + private final UserTeamRoleRepository userTeamRoleRepository; + private final ProjectUserRepository projectUserRepository; + + // 마이페이지 팀 파트 조회(UserTeamRole 사용) + @Transactional(readOnly = true) + public UserTeamRolesResDto readMyPageParts(Long projectId, Long requesterUserId) { + + if (projectId == null) { + throw new UserTeamRoleException(UserTeamRoleErrorCode.INVALID_REQUEST, "projectId is required"); + } + + boolean isActiveMember = projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus( + projectId, requesterUserId, ProjectMemberStatus.ACTIVE + ); + if (!isActiveMember) { + throw new UserTeamRoleException(UserTeamRoleErrorCode.FORBIDDEN_NOT_PROJECT_MEMBER, + "projectId=" + projectId + ", userId=" + requesterUserId); + } + + List roles = + userTeamRoleRepository.findAllByProject_IdAndUser_UserIdAndDeletedAtIsNullOrderByIdAsc(projectId, requesterUserId); + + List parts = roles.stream() + .map(r -> new UserTeamRolesResDto.PartDto( + r.getId(), + r.getRoleField(), + r.getCustomRoleFieldName(), + r.getLabel(), + r.getRequiredCount() + )) + .toList(); + + return new UserTeamRolesResDto(parts); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectMemberController.java b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectMemberController.java new file mode 100644 index 00000000..0b9750ee --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectMemberController.java @@ -0,0 +1,27 @@ +package com.nect.api.domain.team.project.controller; + +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.service.ProjectMemberQueryService; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}/users") +public class ProjectMemberController { + + private final ProjectMemberQueryService projectMemberQueryService; + + // 작업실용 프로젝트 유저 조회 + @GetMapping + public ApiResponse readProjectUsers( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(projectMemberQueryService.readProjectUsers(projectId, userId)); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java deleted file mode 100644 index c57c0e88..00000000 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectPartsController.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.nect.api.domain.team.project.controller; - -import com.nect.api.domain.team.project.dto.ProjectPartsResDto; -import com.nect.api.domain.team.project.dto.ProjectUsersResDto; -import com.nect.api.domain.team.project.service.ProjectTeamQueryService; -import com.nect.api.global.response.ApiResponse; -import com.nect.api.global.security.UserDetailsImpl; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/projects/{projectId}") -public class ProjectPartsController { - - private final ProjectTeamQueryService projectTeamQueryService; - - // 팀 파트 조회 (드롭다운) - @GetMapping("/parts") - public ApiResponse readProjectParts( - @PathVariable Long projectId, - @AuthenticationPrincipal UserDetailsImpl userDetails - ) { - return ApiResponse.ok( - projectTeamQueryService.readProjectParts(projectId, userDetails.getUserId()) - ); - } - - // 프로젝트 전체 인원 조회 - @GetMapping("/users") - public ApiResponse readProjectUsers( - @PathVariable Long projectId, - @AuthenticationPrincipal UserDetailsImpl userDetails - ) { - return ApiResponse.ok( - projectTeamQueryService.readProjectUsers(projectId, userDetails.getUserId()) - ); - } -} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectRoleController.java b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectRoleController.java new file mode 100644 index 00000000..09a3ac14 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/controller/ProjectRoleController.java @@ -0,0 +1,27 @@ +package com.nect.api.domain.team.project.controller; + +import com.nect.api.domain.team.project.dto.ProjectPartsResDto; +import com.nect.api.domain.team.project.service.ProjectRoleQueryService; +import com.nect.api.global.response.ApiResponse; +import com.nect.api.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}/roles") +public class ProjectRoleController { + + private final ProjectRoleQueryService projectRoleQueryService; + + // 작업실 전용 프로젝트 파트 목록 조회 + @GetMapping + public ApiResponse readProjectParts( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(projectRoleQueryService.readProjectParts(projectId, userId)); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectMemberQueryService.java similarity index 69% rename from nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java rename to nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectMemberQueryService.java index 3186989e..fa3ccb05 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectTeamQueryService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectMemberQueryService.java @@ -1,15 +1,12 @@ package com.nect.api.domain.team.project.service; -import com.nect.api.domain.team.project.dto.ProjectPartsResDto; import com.nect.api.domain.team.project.dto.ProjectUsersResDto; import com.nect.api.domain.team.project.enums.code.ProjectErrorCode; import com.nect.api.domain.team.project.exception.ProjectException; import com.nect.api.global.infra.S3Service; -import com.nect.core.entity.team.ProjectTeamRole; import com.nect.core.entity.team.enums.ProjectMemberStatus; import com.nect.core.entity.user.User; import com.nect.core.entity.user.enums.RoleField; -import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; @@ -24,8 +21,7 @@ @Service @RequiredArgsConstructor -public class ProjectTeamQueryService { - private final ProjectTeamRoleRepository projectTeamRoleRepository; +public class ProjectMemberQueryService { private final ProjectUserRepository projectUserRepository; private final UserRepository userRepository; private final S3Service s3Service; @@ -35,40 +31,10 @@ private String toPresignedUserImage(String fileKey) { return s3Service.getPresignedGetUrl(fileKey); } - // 프로젝트 파트 목록 조회 서비스 + // 프로젝트 멤버 전체 조회 서비스 (작업실 / 마이페이지 둘 다 사용) @Transactional(readOnly = true) - public ProjectPartsResDto readProjectParts(Long projectId, Long userId) { - assertActiveProjectMember(projectId, userId); - - List roles = projectTeamRoleRepository.findAllActiveByProjectId(projectId); - - List parts = roles.stream() - .map(ptr -> { - RoleField rf = ptr.getRoleField(); - String customName = ptr.getCustomRoleFieldName(); - - String label = (rf == RoleField.CUSTOM) - ? customName - : rf.getLabelEn(); - - return new ProjectPartsResDto.PartDto( - ptr.getId(), - rf, - customName, - label, - ptr.getRequiredCount() - ); - }) - .toList(); - - return new ProjectPartsResDto(parts); - } - - - // 프로젝트 멤버 전체 조회 서비스 - @Transactional(readOnly = true) - public ProjectUsersResDto readProjectUsers(Long projectId, Long userId) { - assertActiveProjectMember(projectId, userId); + public ProjectUsersResDto readProjectUsers(Long projectId, Long requesterUserId) { + assertActiveProjectMember(projectId, requesterUserId); List rows = projectUserRepository.findActiveMemberBoardRows(projectId); @@ -111,7 +77,6 @@ public ProjectUsersResDto readProjectUsers(Long projectId, Long userId) { return new ProjectUsersResDto(users); } - private void assertActiveProjectMember(Long projectId, Long userId) { boolean ok = projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus( projectId, userId, ProjectMemberStatus.ACTIVE @@ -120,6 +85,5 @@ private void assertActiveProjectMember(Long projectId, Long userId) { throw new ProjectException(ProjectErrorCode.PROJECT_MEMBER_FORBIDDEN, "projectId=" + projectId + ", userId=" + userId); } - } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectRoleQueryService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectRoleQueryService.java new file mode 100644 index 00000000..6922a238 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectRoleQueryService.java @@ -0,0 +1,62 @@ +package com.nect.api.domain.team.project.service; + +import com.nect.api.domain.team.project.dto.ProjectPartsResDto; +import com.nect.api.domain.team.project.enums.code.ProjectErrorCode; +import com.nect.api.domain.team.project.exception.ProjectException; +import com.nect.core.entity.team.ProjectTeamRole; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.user.enums.RoleField; +import com.nect.core.repository.team.ProjectTeamRoleRepository; +import com.nect.core.repository.team.ProjectUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ProjectRoleQueryService { + + private final ProjectTeamRoleRepository projectTeamRoleRepository; + private final ProjectUserRepository projectUserRepository; + + // 작업실 전용: 프로젝트 파트 목록 조회 (ProjectTeamRole사용) + @Transactional(readOnly = true) + public ProjectPartsResDto readProjectParts(Long projectId, Long requesterUserId) { + assertActiveProjectMember(projectId, requesterUserId); + + List roles = projectTeamRoleRepository.findAllActiveByProjectId(projectId); + + List parts = roles.stream() + .map(ptr -> { + RoleField rf = ptr.getRoleField(); + String customName = ptr.getCustomRoleFieldName(); + + String label = (rf == RoleField.CUSTOM) + ? customName + : rf.getLabelEn(); + + return new ProjectPartsResDto.PartDto( + ptr.getId(), + rf, + customName, + label, + ptr.getRequiredCount() + ); + }) + .toList(); + + return new ProjectPartsResDto(parts); + } + + private void assertActiveProjectMember(Long projectId, Long userId) { + boolean ok = projectUserRepository.existsByProjectIdAndUserIdAndMemberStatus( + projectId, userId, ProjectMemberStatus.ACTIVE + ); + if (!ok) { + throw new ProjectException(ProjectErrorCode.PROJECT_MEMBER_FORBIDDEN, + "projectId=" + projectId + ", userId=" + userId); + } + } +} diff --git a/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java index eb4cfda8..c33a54f4 100644 --- a/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java @@ -4,6 +4,7 @@ import com.nect.core.entity.user.enums.RoleField; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface UserTeamRoleRepository extends JpaRepository { @@ -18,4 +19,6 @@ boolean existsByProject_IdAndUser_UserIdAndRoleFieldAndCustomRoleFieldNameIgnore Optional findByIdAndProject_IdAndDeletedAtIsNull(Long id, Long projectId); + List findAllByProject_IdAndUser_UserIdAndDeletedAtIsNullOrderByIdAsc(Long projectId, Long userId); + } From 81e572e85e6426141b84c368686fb56a5e15a332 Mon Sep 17 00:00:00 2001 From: infiniment Date: Sun, 8 Feb 2026 20:11:56 +0900 Subject: [PATCH 56/66] =?UTF-8?q?[Test]=20API=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MypageProjectMemberControllerTest.java | 182 +++++++++++++ .../UserTeamRoleControllerTest.java | 76 +++++- .../ProjectMemberControllerTest.java | 182 +++++++++++++ .../ProjectPartsControllerTest.java | 242 ------------------ .../controller/ProjectRoleControllerTest.java | 171 +++++++++++++ 5 files changed, 607 insertions(+), 246 deletions(-) create mode 100644 nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageProjectMemberControllerTest.java create mode 100644 nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectMemberControllerTest.java delete mode 100644 nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java create mode 100644 nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectRoleControllerTest.java diff --git a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageProjectMemberControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageProjectMemberControllerTest.java new file mode 100644 index 00000000..049df574 --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageProjectMemberControllerTest.java @@ -0,0 +1,182 @@ +package com.nect.api.domain.mypage.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.service.ProjectMemberQueryService; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.user.enums.RoleField; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class MypageProjectMemberControllerTest { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ProjectMemberQueryService projectMemberQueryService; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_MEMBER")) + .build() + ); + } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_MEMBER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return SecurityMockMvcRequestPostProcessors.authentication(auth); + } + + @Test + @DisplayName("마이페이지 전용 프로젝트 유저 조회") + void readProjectUsers() throws Exception { + long projectId = 1L; + long userId = 1L; + + ProjectUsersResDto response = new ProjectUsersResDto(List.of( + new ProjectUsersResDto.UserDto( + 1L, + "홍길동", + "gildong", + "https://example.com/profile.png", + "bio", + RoleField.BACKEND, + null, + "Backend", + ProjectMemberType.LEADER + ), + new ProjectUsersResDto.UserDto( + 2L, + "김철수", + "chulsoo", + null, + null, + RoleField.CUSTOM, + "데이터", + "데이터", + ProjectMemberType.MEMBER + ) + )); + + given(projectMemberQueryService.readProjectUsers(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/mypage/projects/{projectId}/users", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("mypage-project-users-read", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("MyPage") + .summary("마이페이지 전용 프로젝트 유저 조회") + .description("마이페이지에서 프로젝트별 유저(멤버) 목록을 조회합니다. (공용 멤버 조회 서비스 재사용)") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("프로젝트 유저 조회 결과"), + fieldWithPath("body.users").type(ARRAY).description("유저 목록"), + fieldWithPath("body.users[].user_id").type(NUMBER).description("유저 ID"), + fieldWithPath("body.users[].name").type(STRING).description("이름"), + fieldWithPath("body.users[].nickname").type(STRING).description("닉네임"), + fieldWithPath("body.users[].profile_image_url").optional().type(STRING).description("프로필 이미지 URL(프리사인드 URL)"), + fieldWithPath("body.users[].bio").optional().type(STRING).description("소개글(bio)"), + + fieldWithPath("body.users[].role_field").type(STRING).description("작업실 기준 파트(RoleField, ProjectUser 기준)"), + fieldWithPath("body.users[].custom_role_field_name").optional().type(STRING).description("CUSTOM 파트명"), + fieldWithPath("body.users[].part_label").type(STRING).description("표시 라벨(part_label)"), + + fieldWithPath("body.users[].member_type").type(STRING).description("프로젝트 멤버 타입(LEADER/MEMBER)") + ) + .build() + ) + )); + + verify(projectMemberQueryService).readProjectUsers(eq(projectId), eq(userId)); + } +} diff --git a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/UserTeamRoleControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/UserTeamRoleControllerTest.java index 5a680bfe..b91db0f6 100644 --- a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/UserTeamRoleControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/UserTeamRoleControllerTest.java @@ -2,10 +2,8 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.fasterxml.jackson.databind.ObjectMapper; -import com.nect.api.domain.mypage.dto.UserTeamRoleCreateReqDto; -import com.nect.api.domain.mypage.dto.UserTeamRoleCreateResDto; -import com.nect.api.domain.mypage.dto.UserTeamRoleUpdateReqDto; -import com.nect.api.domain.mypage.dto.UserTeamRoleUpdateResDto; +import com.nect.api.domain.mypage.dto.*; +import com.nect.api.domain.mypage.service.UserTeamRoleQueryService; import com.nect.api.domain.mypage.service.UserTeamRoleService; import com.nect.api.global.jwt.JwtUtil; import com.nect.api.global.jwt.service.TokenBlacklistService; @@ -45,6 +43,7 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @@ -65,6 +64,9 @@ class UserTeamRoleControllerTest { @MockitoBean private UserTeamRoleService userTeamRoleService; + @MockitoBean + private UserTeamRoleQueryService userTeamRoleQueryService; + @MockitoBean private JwtUtil jwtUtil; @@ -238,4 +240,70 @@ void updateUserTeamRole() throws Exception { )); verify(userTeamRoleService).update(eq(projectId), eq(userId), eq(userTeamRoleId), any(UserTeamRoleUpdateReqDto.class)); } + + @Test + @DisplayName("마이페이지 파트 목록 조회") + void readMyPageParts() throws Exception { + long projectId = 1L; + long userId = 1L; + + UserTeamRolesResDto response = new UserTeamRolesResDto(List.of( + new UserTeamRolesResDto.PartDto( + 10L, + RoleField.BACKEND, + null, + "Backend", + 1 + ), + new UserTeamRolesResDto.PartDto( + 11L, + RoleField.CUSTOM, + "데이터", + "데이터", + 2 + ) + )); + + given(userTeamRoleQueryService.readMyPageParts(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/mypage/projects/{projectId}/team-roles", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("mypage-team-role-read", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("MyPage") + .summary("마이페이지 파트 목록 조회") + .description("마이페이지에서 프로젝트별 팀 파트(칩) 목록을 조회합니다. (프로젝트 ACTIVE 멤버 가능)") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("마이페이지 파트 목록 조회 결과"), + fieldWithPath("body.parts").type(ARRAY).description("파트 목록"), + fieldWithPath("body.parts[].id").type(NUMBER).description("마이페이지 팀 파트 ID"), + fieldWithPath("body.parts[].role_field").type(STRING).description("파트 타입(RoleField)"), + fieldWithPath("body.parts[].custom_role_field_name").optional().type(STRING).description("CUSTOM 파트명"), + fieldWithPath("body.parts[].label").type(STRING).description("표시 라벨(label)"), + fieldWithPath("body.parts[].required_count").type(NUMBER).description("모집 인원") + ) + .build() + ) + )); + + verify(userTeamRoleQueryService).readMyPageParts(eq(projectId), eq(userId)); + } } diff --git a/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectMemberControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectMemberControllerTest.java new file mode 100644 index 00000000..0f8adf3a --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectMemberControllerTest.java @@ -0,0 +1,182 @@ +package com.nect.api.domain.team.project.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.project.dto.ProjectUsersResDto; +import com.nect.api.domain.team.project.service.ProjectMemberQueryService; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.user.enums.RoleField; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class ProjectMemberControllerTest { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ProjectMemberQueryService projectMemberQueryService; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_MEMBER")) + .build() + ); + } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_MEMBER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return SecurityMockMvcRequestPostProcessors.authentication(auth); + } + + @Test + @DisplayName("작업실용 프로젝트 유저 조회") + void readProjectUsers() throws Exception { + long projectId = 1L; + long userId = 1L; + + ProjectUsersResDto response = new ProjectUsersResDto(List.of( + new ProjectUsersResDto.UserDto( + 1L, + "홍길동", + "gildong", + "https://example.com/profile.png", + "bio", + RoleField.BACKEND, + null, + "Backend", + ProjectMemberType.LEADER + ), + new ProjectUsersResDto.UserDto( + 2L, + "김철수", + "chulsoo", + null, + null, + RoleField.CUSTOM, + "데이터", + "데이터", + ProjectMemberType.MEMBER + ) + )); + + given(projectMemberQueryService.readProjectUsers(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/users", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("project-users-read", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Project") + .summary("작업실용 프로젝트 유저 조회") + .description("작업실에서 프로젝트별 유저(멤버) 목록을 조회합니다. (공용 멤버 조회 서비스 사용)") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("프로젝트 유저 조회 결과"), + fieldWithPath("body.users").type(ARRAY).description("유저 목록"), + fieldWithPath("body.users[].user_id").type(NUMBER).description("유저 ID"), + fieldWithPath("body.users[].name").type(STRING).description("이름"), + fieldWithPath("body.users[].nickname").type(STRING).description("닉네임"), + fieldWithPath("body.users[].profile_image_url").optional().type(STRING).description("프로필 이미지 URL(프리사인드 URL)"), + fieldWithPath("body.users[].bio").optional().type(STRING).description("소개글(bio)"), + + fieldWithPath("body.users[].role_field").type(STRING).description("작업실 기준 파트(RoleField, ProjectUser 기준)"), + fieldWithPath("body.users[].custom_role_field_name").optional().type(STRING).description("CUSTOM 파트명"), + fieldWithPath("body.users[].part_label").type(STRING).description("표시 라벨(part_label)"), + + fieldWithPath("body.users[].member_type").type(STRING).description("프로젝트 멤버 타입(LEADER/MEMBER)") + ) + .build() + ) + )); + + verify(projectMemberQueryService).readProjectUsers(eq(projectId), eq(userId)); + } +} diff --git a/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java deleted file mode 100644 index aaa7e69b..00000000 --- a/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectPartsControllerTest.java +++ /dev/null @@ -1,242 +0,0 @@ -package com.nect.api.domain.team.project.controller; - -import com.epages.restdocs.apispec.ResourceSnippetParameters; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.nect.api.domain.team.project.dto.ProjectPartsResDto; -import com.nect.api.domain.team.project.dto.ProjectUsersResDto; -import com.nect.api.domain.team.project.service.ProjectTeamQueryService; -import com.nect.api.global.jwt.JwtUtil; -import com.nect.api.global.jwt.service.TokenBlacklistService; -import com.nect.api.global.security.UserDetailsImpl; -import com.nect.api.global.security.UserDetailsServiceImpl; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.request.RequestPostProcessor; -import org.springframework.transaction.annotation.Transactional; - -import java.lang.reflect.Constructor; -import java.lang.reflect.RecordComponent; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; -import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; -import static com.epages.restdocs.apispec.ResourceDocumentation.resource; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.verify; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; -import static org.springframework.restdocs.payload.JsonFieldType.OBJECT; -import static org.springframework.restdocs.payload.JsonFieldType.STRING; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@AutoConfigureMockMvc -@AutoConfigureRestDocs -@Transactional -class ProjectPartsControllerTest { - - protected static final String AUTH_HEADER = "Authorization"; - protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockitoBean - private ProjectTeamQueryService projectTeamQueryService; - - @MockitoBean - private JwtUtil jwtUtil; - - @MockitoBean - private UserDetailsServiceImpl userDetailsService; - - @MockitoBean - private TokenBlacklistService tokenBlacklistService; - - @BeforeEach - void setUpAuth() { - doNothing().when(jwtUtil).validateToken(anyString()); - given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); - given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); - given(userDetailsService.loadUserByUsername(anyString())).willReturn( - UserDetailsImpl.builder() - .userId(1L) - .roles(List.of("ROLE_MEMBER")) - .build() - ); - } - - private RequestPostProcessor mockUser(Long userId) { - UserDetailsImpl principal = UserDetailsImpl.builder() - .userId(userId) - .roles(List.of("ROLE_MEMBER")) - .build(); - - Authentication auth = new UsernamePasswordAuthenticationToken( - principal, - "", - principal.getAuthorities() - ); - - return SecurityMockMvcRequestPostProcessors.authentication(auth); - } - - private T newRecord(Class recordType) { - try { - if (!recordType.isRecord()) return null; - - RecordComponent[] components = recordType.getRecordComponents(); - Class[] paramTypes = new Class[components.length]; - Object[] args = new Object[components.length]; - - for (int i = 0; i < components.length; i++) { - Class t = components[i].getType(); - paramTypes[i] = t; - args[i] = defaultValue(t); - } - - Constructor ctor = recordType.getDeclaredConstructor(paramTypes); - ctor.setAccessible(true); - return ctor.newInstance(args); - } catch (Exception e) { - throw new IllegalStateException("Failed to instantiate record: " + recordType.getName(), e); - } - } - - private Object defaultValue(Class t) { - if (t == String.class) return "sample"; - if (t == Long.class || t == long.class) return 1L; - if (t == Integer.class || t == int.class) return 1; - if (t == Boolean.class || t == boolean.class) return false; - if (t == LocalDate.class) return LocalDate.of(2026, 1, 19); - if (t == LocalDateTime.class) return LocalDateTime.of(2026, 1, 19, 0, 0, 0); - - if (List.class.isAssignableFrom(t)) return List.of(); - - if (t.isEnum()) { - Object[] constants = t.getEnumConstants(); - return (constants != null && constants.length > 0) ? constants[0] : null; - } - - if (t.isRecord()) { - @SuppressWarnings("unchecked") - Class rt = (Class) t; - return newRecord(rt); - } - - return null; - } - - @Test - @DisplayName("팀 파트 조회 (드롭다운)") - void readProjectParts() throws Exception { - long projectId = 1L; - long userId = 1L; - - ProjectPartsResDto response = newRecord(ProjectPartsResDto.class); - - given(projectTeamQueryService.readProjectParts(eq(projectId), eq(userId))) - .willReturn(response); - - mockMvc.perform(get("/api/v1/projects/{projectId}/parts", projectId) - .with(mockUser(userId)) - .header(AUTH_HEADER, TEST_ACCESS_TOKEN) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andDo(document("project-parts-read", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - resource( - ResourceSnippetParameters.builder() - .tag("Project") - .summary("팀 파트 조회") - .description("현재 프로젝트에 설정된 파트 목록을 조회합니다. (드롭다운)") - .pathParameters( - com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName("projectId") - .description("프로젝트 ID") - ) - .requestHeaders( - headerWithName(AUTH_HEADER).description("Bearer Access Token") - ) - .responseFields( - fieldWithPath("status").type(OBJECT).description("응답 상태"), - fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), - fieldWithPath("status.message").type(STRING).description("메시지"), - fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), - - subsectionWithPath("body").type(OBJECT).description("팀 파트 조회 결과") - ) - .build() - ) - )); - - verify(projectTeamQueryService).readProjectParts(eq(projectId), eq(userId)); - } - - @Test - @DisplayName("프로젝트 전체 인원 조회") - void readProjectUsers() throws Exception { - long projectId = 1L; - long userId = 1L; - - ProjectUsersResDto response = newRecord(ProjectUsersResDto.class); - - given(projectTeamQueryService.readProjectUsers(eq(projectId), eq(userId))) - .willReturn(response); - - mockMvc.perform(get("/api/v1/projects/{projectId}/users", projectId) - .with(mockUser(userId)) - .header(AUTH_HEADER, TEST_ACCESS_TOKEN) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andDo(document("project-users-read", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - resource( - ResourceSnippetParameters.builder() - .tag("Project") - .summary("프로젝트 전체 인원 조회") - .description("프로젝트에 속한 전체 인원 목록을 조회합니다.") - .pathParameters( - com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName("projectId") - .description("프로젝트 ID") - ) - .requestHeaders( - headerWithName(AUTH_HEADER).description("Bearer Access Token") - ) - .responseFields( - fieldWithPath("status").type(OBJECT).description("응답 상태"), - fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), - fieldWithPath("status.message").type(STRING).description("메시지"), - fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), - - subsectionWithPath("body").type(OBJECT).description("프로젝트 전체 인원 조회 결과") - ) - .build() - ) - )); - - verify(projectTeamQueryService).readProjectUsers(eq(projectId), eq(userId)); - } -} diff --git a/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectRoleControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectRoleControllerTest.java new file mode 100644 index 00000000..471c5507 --- /dev/null +++ b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectRoleControllerTest.java @@ -0,0 +1,171 @@ +package com.nect.api.domain.team.project.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.project.dto.ProjectPartsResDto; +import com.nect.api.domain.team.project.service.ProjectRoleQueryService; +import com.nect.api.global.jwt.JwtUtil; +import com.nect.api.global.jwt.service.TokenBlacklistService; +import com.nect.api.global.security.UserDetailsImpl; +import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.user.enums.RoleField; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class ProjectRoleControllerTest { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String TEST_ACCESS_TOKEN = "Bearer testAccessToken"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ProjectRoleQueryService projectRoleQueryService; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + @BeforeEach + void setUpAuth() { + doNothing().when(jwtUtil).validateToken(anyString()); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(jwtUtil.getUserIdFromToken(anyString())).willReturn(1L); + given(userDetailsService.loadUserByUsername(anyString())).willReturn( + UserDetailsImpl.builder() + .userId(1L) + .roles(List.of("ROLE_MEMBER")) + .build() + ); + } + + private RequestPostProcessor mockUser(Long userId) { + UserDetailsImpl principal = UserDetailsImpl.builder() + .userId(userId) + .roles(List.of("ROLE_MEMBER")) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + + return SecurityMockMvcRequestPostProcessors.authentication(auth); + } + + @Test + @DisplayName("작업실 전용 프로젝트 파트 목록 조회") + void readProjectParts() throws Exception { + long projectId = 1L; + long userId = 1L; + + ProjectPartsResDto response = new ProjectPartsResDto( + List.of( + new ProjectPartsResDto.PartDto( + 1L, + RoleField.BACKEND, + null, + "Backend", + 2 + ), + new ProjectPartsResDto.PartDto( + 2L, + RoleField.CUSTOM, + "데이터", + "데이터", + 1 + ) + ) + ); + + given(projectRoleQueryService.readProjectParts(eq(projectId), eq(userId))) + .willReturn(response); + + mockMvc.perform(get("/api/v1/projects/{projectId}/roles", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("project-role-read-parts", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Project") + .summary("프로젝트 파트 목록 조회(작업실)") + .description("작업실에서 프로젝트의 파트(역할) 목록을 조회합니다.") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("프로젝트 파트 목록 조회 결과"), + fieldWithPath("body.parts").type(ARRAY).description("파트 목록"), + + fieldWithPath("body.parts[].part_id").type(NUMBER).description("파트 ID"), + + fieldWithPath("body.parts[].role_field").type(STRING).description("파트 타입(RoleField)"), + fieldWithPath("body.parts[].custom_role_field_name").optional().type(STRING).description("CUSTOM 파트명"), + fieldWithPath("body.parts[].part_label").type(STRING).description("표시 라벨(part_label)"), + fieldWithPath("body.parts[].required_count").type(NUMBER).description("모집 인원") + ) + .build() + ) + )); + + verify(projectRoleQueryService).readProjectParts(eq(projectId), eq(userId)); + } +} From 6653aa034b962cebef40304b6908b64020e79d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=84=9C=EC=97=B0?= <163366999+zeoueon@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:13:14 +0900 Subject: [PATCH 57/66] =?UTF-8?q?[Feat]=20=EB=AA=A8=EC=A7=91=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EB=93=B1=EB=A1=9D/=EC=88=98=EC=A0=95/=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=EC=A1=B0=ED=9A=8C=20=EB=B0=98=ED=99=98=EA=B0=92=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Feat] matching 24시간 만료 정책 적용 스케쥴러 구현 - application을 분리하지 않고 ApiApplication으로 스케쥴러를 통합하였습니다. - SchedulingConfig파일을 api모듈로 이동시켰습니다. * [Refactor] 매칭/모집 도메인 구조 개선 및 응답/요청 필드 확장 - 매칭/모집 도메인의 연관관계 및 필드들의 구조를 개선했습니다. - 응답/요청 DTO에 필요한 필드를 추가했습니다. * [Feat] 매칭 도메인 거절 및 조회 API 추가 및 구조 개선 - 매칭 요청 거절 API에 거절 사유를 받도록 수정하였습니다. - 매칭 요청을 보낸/받은 개수를 조회하는 API를 구현했습니다. - 보낸/받은 매칭 요청을 조회하는 API를 분리하고, 구조를 개선했습니다. * [Feat] 모집 도메인 API 구현 - 모집과 관련된 조회 API를 구현했습니다. * [Test] 모집/매칭 도메인의 컨트롤러 테스트 구현 및 API 문서화 * [Feat] 매칭 도메인 엔드포인트 통일 * [Fix] merge conflict 해결 * [Feat] 매칭 조회시 반환값 수정 및 업데이트 * [Feat] 마이페이지 모집 등록/수정/조회 API 구현 - 모집 요구사항 정보를 저장하기 위한 RecruitmentRequirement엔티티를 추가로 구현했습니다. --- .../matching/converter/MatchingConverter.java | 13 +- .../converter/RecruitmentConverter.java | 22 ++ .../domain/matching/dto/MatchingResDto.java | 2 + .../matching/dto/RecruitmentReqDto.java | 20 ++ .../matching/dto/RecruitmentResDto.java | 11 + .../enums/code/RecruitmentErrorCode.java | 4 + .../matching/service/MatchingService.java | 18 +- .../matching/service/RecruitmentService.java | 84 +++++++ .../mypage/controller/MypageController.java | 61 ++++- .../team/project/service/ProjectService.java | 5 + .../matching/MatchingControllerTest.java | 8 + .../controller/MypageControllerTest.java | 216 ++++++++++++++++-- .../core/entity/matching/Recruitment.java | 38 ++- .../matching/RecruitmentRequirement.java | 32 +++ .../matching/RecruitmentRepository.java | 3 + .../RecruitmentRequirementRepository.java | 9 + 16 files changed, 507 insertions(+), 39 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/matching/converter/RecruitmentConverter.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/matching/dto/RecruitmentReqDto.java create mode 100644 nect-core/src/main/java/com/nect/core/entity/matching/RecruitmentRequirement.java create mode 100644 nect-core/src/main/java/com/nect/core/repository/matching/RecruitmentRequirementRepository.java diff --git a/nect-api/src/main/java/com/nect/api/domain/matching/converter/MatchingConverter.java b/nect-api/src/main/java/com/nect/api/domain/matching/converter/MatchingConverter.java index 00d4ed06..b18eabf0 100644 --- a/nect-api/src/main/java/com/nect/api/domain/matching/converter/MatchingConverter.java +++ b/nect-api/src/main/java/com/nect/api/domain/matching/converter/MatchingConverter.java @@ -41,21 +41,22 @@ public static Matching toMatching( .build(); } - public static MatchingResDto.UserSummary toUserSummary(User user){ - // TODO: 프로필 구현 완성 시 수정 + public static MatchingResDto.UserSummary toUserSummary(User user, String profileImageUrl){ return MatchingResDto.UserSummary.builder() + .userId(user.getUserId()) .nickname(user.getNickname()) - .bio("") + .bio(user.getBio()) .field(RoleField.BACKEND) - .profileUrl("") + .profileUrl(profileImageUrl) .build(); } - public static MatchingResDto.ProjectSummary toProjectSummary(Project project, long countUserNum) { + public static MatchingResDto.ProjectSummary toProjectSummary(Project project, long countUserNum, String projectImageUrl) { return MatchingResDto.ProjectSummary.builder() + .projectId(project.getId()) .title(project.getTitle()) .description(project.getDescription()) - .imageUrl(project.getImageName()) + .imageUrl(projectImageUrl) .currentMembersNum(countUserNum) .build(); } diff --git a/nect-api/src/main/java/com/nect/api/domain/matching/converter/RecruitmentConverter.java b/nect-api/src/main/java/com/nect/api/domain/matching/converter/RecruitmentConverter.java new file mode 100644 index 00000000..200d258e --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/matching/converter/RecruitmentConverter.java @@ -0,0 +1,22 @@ +package com.nect.api.domain.matching.converter; + +import com.nect.api.domain.matching.dto.RecruitmentResDto; +import com.nect.core.entity.matching.Recruitment; +import com.nect.core.entity.matching.RecruitmentRequirement; + +public class RecruitmentConverter { + + public static RecruitmentResDto.EnrollRecruitmentResDto toEnrollResDto( + Recruitment recruitment + ) { + return RecruitmentResDto.EnrollRecruitmentResDto.builder() + .recruitmentId(recruitment.getId()) + .roleField(recruitment.getField()) + .customField(recruitment.getCustomField()) + .capacity(recruitment.getCapacity()) + .requirements(recruitment.getRequirements().stream() + .map(RecruitmentRequirement::getContent) + .toList()) + .build(); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/matching/dto/MatchingResDto.java b/nect-api/src/main/java/com/nect/api/domain/matching/dto/MatchingResDto.java index 33207093..93e59a4e 100644 --- a/nect-api/src/main/java/com/nect/api/domain/matching/dto/MatchingResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/matching/dto/MatchingResDto.java @@ -46,6 +46,7 @@ public record MatchingListRes( @Builder public record UserSummary( + Long userId, String nickname, String bio, RoleField field, @@ -54,6 +55,7 @@ public record UserSummary( @Builder public record ProjectSummary( + Long projectId, String title, String description, long currentMembersNum, diff --git a/nect-api/src/main/java/com/nect/api/domain/matching/dto/RecruitmentReqDto.java b/nect-api/src/main/java/com/nect/api/domain/matching/dto/RecruitmentReqDto.java new file mode 100644 index 00000000..9b241220 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/matching/dto/RecruitmentReqDto.java @@ -0,0 +1,20 @@ +package com.nect.api.domain.matching.dto; + +import com.nect.core.entity.user.enums.RoleField; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import java.util.List; + +public class RecruitmentReqDto { + + @Builder + public record EnrollRecruitmentReqDto( + @NotNull + RoleField roleField, + String customField, + @NotNull + Integer capacity, + List requirements + ){} +} diff --git a/nect-api/src/main/java/com/nect/api/domain/matching/dto/RecruitmentResDto.java b/nect-api/src/main/java/com/nect/api/domain/matching/dto/RecruitmentResDto.java index a2fc35bf..6866bc3d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/matching/dto/RecruitmentResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/matching/dto/RecruitmentResDto.java @@ -3,6 +3,8 @@ import com.nect.core.entity.user.enums.RoleField; import lombok.Builder; +import java.util.List; + public class RecruitmentResDto { @Builder @@ -10,4 +12,13 @@ public record RecruitingFieldDto( RoleField field, String customField ){} + + @Builder + public record EnrollRecruitmentResDto( + Long recruitmentId, + RoleField roleField, + String customField, + Integer capacity, + List requirements + ){} } diff --git a/nect-api/src/main/java/com/nect/api/domain/matching/enums/code/RecruitmentErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/matching/enums/code/RecruitmentErrorCode.java index cd876a1d..49a9e74b 100644 --- a/nect-api/src/main/java/com/nect/api/domain/matching/enums/code/RecruitmentErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/matching/enums/code/RecruitmentErrorCode.java @@ -9,6 +9,10 @@ public enum RecruitmentErrorCode implements ResponseCode { RECRUITMENT_NOT_OPEN("R400_1", "프로젝트의 해당 분야는 모집중이 아닙니다."), + + ONLY_LEADER_ACCESS("R403_1", "리더만 접근할 수 있는 기능입니다."), + + NOT_FOUND_RECRUITMENT("R404_1", "해당 모집은 존재하지 않습니다."), ; diff --git a/nect-api/src/main/java/com/nect/api/domain/matching/service/MatchingService.java b/nect-api/src/main/java/com/nect/api/domain/matching/service/MatchingService.java index ef826156..69bef04a 100644 --- a/nect-api/src/main/java/com/nect/api/domain/matching/service/MatchingService.java +++ b/nect-api/src/main/java/com/nect/api/domain/matching/service/MatchingService.java @@ -7,6 +7,7 @@ import com.nect.api.domain.matching.exception.MatchingException; import com.nect.api.domain.team.project.service.ProjectService; import com.nect.api.domain.user.service.UserService; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.matching.Matching; import com.nect.core.entity.matching.enums.MatchingRejectReason; import com.nect.core.entity.matching.enums.MatchingRequestType; @@ -29,6 +30,7 @@ public class MatchingService { private final MatchingRepository matchingRepository; private final UserService userService; private final ProjectService projectService; + private final S3Service s3Service; public Matching createUserToProjectMatching( User requestUser, @@ -129,7 +131,10 @@ public MatchingResDto.MatchingListRes getReceivedMatchingsByTarget( List userSummaries = pendingMatchings.stream() .map(Matching::getRequestUser) - .map(MatchingConverter::toUserSummary) + .map(u -> MatchingConverter.toUserSummary( + u, + s3Service.getPresignedGetUrl(u.getProfileImageName())) + ) .toList(); return MatchingResDto.MatchingListRes.builder() @@ -148,7 +153,8 @@ public MatchingResDto.MatchingListRes getReceivedMatchingsByTarget( .map(Matching::getProject) .map(project -> MatchingConverter.toProjectSummary( project, - projectService.getUserNumberOfProject(project) + projectService.getUserNumberOfProject(project), + s3Service.getPresignedGetUrl(project.getImageName()) ) ) .toList(); @@ -179,7 +185,10 @@ public MatchingResDto.MatchingListRes getSentMatchingsByTarget( List userSummaries = pendingMatchings.stream() .map(Matching::getTargetUser) - .map(MatchingConverter::toUserSummary) + .map(u -> MatchingConverter.toUserSummary( + u, + s3Service.getPresignedGetUrl(u.getProfileImageName()) + )) .toList(); return MatchingResDto.MatchingListRes.builder() @@ -196,7 +205,8 @@ public MatchingResDto.MatchingListRes getSentMatchingsByTarget( .map(Matching::getProject) .map(project -> MatchingConverter.toProjectSummary( project, - projectService.getUserNumberOfProject(project) + projectService.getUserNumberOfProject(project), + s3Service.getPresignedGetUrl(project.getImageName()) ) ) .toList(); diff --git a/nect-api/src/main/java/com/nect/api/domain/matching/service/RecruitmentService.java b/nect-api/src/main/java/com/nect/api/domain/matching/service/RecruitmentService.java index a6db690b..8bd629d2 100644 --- a/nect-api/src/main/java/com/nect/api/domain/matching/service/RecruitmentService.java +++ b/nect-api/src/main/java/com/nect/api/domain/matching/service/RecruitmentService.java @@ -1,18 +1,24 @@ package com.nect.api.domain.matching.service; +import com.nect.api.domain.matching.converter.RecruitmentConverter; +import com.nect.api.domain.matching.dto.RecruitmentReqDto; import com.nect.api.domain.matching.dto.RecruitmentResDto; import com.nect.api.domain.matching.enums.code.RecruitmentErrorCode; import com.nect.api.domain.matching.exception.RecruitmentException; import com.nect.api.domain.team.project.converter.ProjectConverter; import com.nect.api.domain.team.project.dto.RecruitingProjectResDto; import com.nect.api.domain.team.project.service.ProjectService; +import com.nect.api.domain.user.service.UserService; import com.nect.core.entity.matching.Matching; import com.nect.core.entity.matching.Recruitment; +import com.nect.core.entity.matching.RecruitmentRequirement; import com.nect.core.entity.team.Project; +import com.nect.core.entity.user.User; import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.matching.RecruitmentRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -22,6 +28,7 @@ public class RecruitmentService { private final RecruitmentRepository recruitmentRepository; private final ProjectService projectService; + private final UserService userService; public void validateRecruitable(Project project, RoleField field){ Recruitment recruitment = recruitmentRepository @@ -69,4 +76,81 @@ public List getMyRecruitingProjectAsLeader(Long userId) return projects.stream().map(ProjectConverter::toRecruitingProjectResDto).toList(); } + + @Transactional + public RecruitmentResDto.EnrollRecruitmentResDto enrollRecruitment( + Long userId, + Long projectId, + RecruitmentReqDto.EnrollRecruitmentReqDto reqDto + ) { + User user = userService.getUser(userId); + Project project = projectService.getProject(projectId); + + if (!(user.getUserId().equals(projectService.getLeader(project).getUserId()))){ + throw new RecruitmentException(RecruitmentErrorCode.ONLY_LEADER_ACCESS); + } + + Recruitment recruitment = Recruitment.builder() + .project(project) + .field(reqDto.roleField()) + .capacity(reqDto.capacity()) + .customField(reqDto.customField()) + .build(); + + for (int i = 0; i < reqDto.requirements().size(); i++) { + recruitment.addRequirement(RecruitmentRequirement.builder() + .content(reqDto.requirements().get(i)) + .sortOrder(i) + .build() + ); + } + + Recruitment saved = recruitmentRepository.save(recruitment); + return RecruitmentConverter.toEnrollResDto(saved); + } + + @Transactional + public RecruitmentResDto.EnrollRecruitmentResDto updateRecruitment( + Long userId, + Long projectId, + Long recruitmentId, + RecruitmentReqDto.EnrollRecruitmentReqDto reqDto + ) { + User user = userService.getUser(userId); + Project project = projectService.getProject(projectId); + + if (!(user.getUserId().equals(projectService.getLeader(project).getUserId()))){ + throw new RecruitmentException(RecruitmentErrorCode.ONLY_LEADER_ACCESS); + } + + Recruitment recruitment = recruitmentRepository.findByIdAndProject(recruitmentId, project) + .orElseThrow(() -> new RecruitmentException(RecruitmentErrorCode.NOT_FOUND_RECRUITMENT)); + + recruitment.updateField(reqDto.roleField()); + recruitment.updateCustomField(reqDto.customField()); + recruitment.updateCapacity(reqDto.capacity()); + + recruitment.getRequirements().clear(); + + for (int i = 0; i < reqDto.requirements().size(); i++) { + recruitment.addRequirement( + RecruitmentRequirement.builder() + .content(reqDto.requirements().get(i)) + .sortOrder(i) + .build() + ); + } + + return RecruitmentConverter.toEnrollResDto(recruitment); + } + + public List getRecruitmentsByProject(Long projectId) { + Project project = projectService.getProject(projectId); + + List recruitments = recruitmentRepository.findByProject(project); + + return recruitments.stream() + .map(RecruitmentConverter::toEnrollResDto) + .toList(); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java index 178a2b15..bd4a54e3 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java @@ -1,6 +1,9 @@ package com.nect.api.domain.mypage.controller; import com.nect.api.domain.mypage.dto.MyProjectStringListRequest; +import com.nect.api.domain.matching.dto.RecruitmentReqDto; +import com.nect.api.domain.matching.dto.RecruitmentResDto; +import com.nect.api.domain.matching.service.RecruitmentService; import com.nect.api.domain.mypage.dto.MyProjectsResponseDto; import com.nect.api.domain.mypage.dto.ProfileSettingsDto; import com.nect.api.domain.mypage.dto.ProfileSettingsDto.*; @@ -26,6 +29,8 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.util.List; + @RestController @RequestMapping("/api/v1/mypage") @RequiredArgsConstructor @@ -35,6 +40,7 @@ public class MypageController { private final MyPageProjectQueryService projectQueryService; private final MyPageProjectCommandService projectCommandService; private final ProjectUserService projectUserService; + private final RecruitmentService recruitmentService; /** * 프로필 조회 @@ -201,5 +207,58 @@ public ApiResponse updateProjectUserType( ); } + // TODO: 해주세요 + // 프로젝트 분야 수정 + + // 모집정보 추가 + @PostMapping("/{projectId}/recruitments") + public ApiResponse createRecruitment( + @AuthenticationPrincipal UserDetailsImpl user, + @PathVariable @Positive Long projectId, + @RequestBody @Valid RecruitmentReqDto.EnrollRecruitmentReqDto reqDto + ){ + return ApiResponse.ok(recruitmentService.enrollRecruitment(user.getUserId(), projectId, reqDto)); + } + + // 모집정보 수정 + @PutMapping("/{projectId}/recruitments/{recruitmentId}") + public ApiResponse updateRecruitment( + @AuthenticationPrincipal UserDetailsImpl user, + @PathVariable @Positive Long projectId, + @PathVariable @Positive Long recruitmentId, + @RequestBody @Valid RecruitmentReqDto.EnrollRecruitmentReqDto reqDto + ) { + return ApiResponse.ok(recruitmentService.updateRecruitment(user.getUserId(), projectId, recruitmentId, reqDto)); + } + + // 프로젝트의 모집정보 전체 조회 + @GetMapping("/{projectId}/recruitments") + public ApiResponse> getRecruitmentsByProject( + @PathVariable @Positive Long projectId + ){ + return ApiResponse.ok(recruitmentService.getRecruitmentsByProject(projectId)); + } + + // 프로젝트 목표 추가 + + // 프로젝트 목표 수정 + + // 프로젝트 목표 삭제 + + // 주요기능 추가 + + // 주요기능 수정 + + // 주요기능 삭제 + + // 서비스 사용자 추가 + + // 서비스 사용자 수정 + + // 서비스 사용자 삭제 + + // 프로젝트 세부 기획 파일 추가 + + // 프로젝트 세부 기획 파일 삭제 -} \ No newline at end of file +} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java index b9931f35..55c0056a 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java @@ -4,17 +4,21 @@ import com.nect.api.domain.analysis.dto.res.ProjectCreateResponseDto; import com.nect.api.domain.team.project.enums.code.ProjectErrorCode; import com.nect.api.domain.team.project.exception.ProjectException; +import com.nect.api.domain.user.service.UserService; import com.nect.api.domain.user.enums.UserErrorCode; import com.nect.api.domain.user.service.UserService; import com.nect.core.entity.team.ProjectInterest; import com.nect.core.entity.team.process.Process; import com.nect.core.entity.analysis.*; import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectStatus; import com.nect.core.entity.team.ProjectUser; import com.nect.core.entity.team.enums.ProjectMemberStatus; import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.enums.ProjectStatus; import com.nect.core.entity.team.enums.RecruitmentStatus; +import com.nect.core.entity.user.User; import com.nect.core.entity.team.process.ProcessTaskItem; import com.nect.core.entity.team.ProjectTeamRole; import com.nect.core.entity.user.User; @@ -22,6 +26,7 @@ import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.analysis.*; import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.ProjectInterestFieldRepository; import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.team.ProjectUserRepository; diff --git a/nect-api/src/test/java/com/nect/api/domain/matching/MatchingControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/matching/MatchingControllerTest.java index 3ec6c315..ecac07b5 100644 --- a/nect-api/src/test/java/com/nect/api/domain/matching/MatchingControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/matching/MatchingControllerTest.java @@ -404,6 +404,7 @@ void rejectMatchingRequest() throws Exception{ @Test void getReceivedMatchingsByProject() throws Exception { MatchingResDto.ProjectSummary projectSummary = MatchingResDto.ProjectSummary.builder() + .projectId(1L) .title("NECT") .description("Project description") .imageUrl("https://example.com/image.jpg") @@ -411,6 +412,7 @@ void getReceivedMatchingsByProject() throws Exception { .build(); MatchingResDto.UserSummary userSummary = MatchingResDto.UserSummary.builder() + .userId(1L) .nickname("seoyeon") .bio("Designer") .field(RoleField.BACKEND) @@ -455,12 +457,14 @@ void getReceivedMatchingsByProject() throws Exception { fieldWithPath("body.counterParty").description("대상 타입 (PROJECT | USER)"), fieldWithPath("body.userMatchings").description("유저 매칭 요약 목록(대상이 USER일 때 채워짐)"), + fieldWithPath("body.userMatchings[].userId").description("유저 ID"), fieldWithPath("body.userMatchings[].nickname").description("닉네임"), fieldWithPath("body.userMatchings[].bio").description("한줄 소개"), fieldWithPath("body.userMatchings[].field").description("분야"), fieldWithPath("body.userMatchings[].profileUrl").description("프로필 URL"), fieldWithPath("body.projectMatchings").description("프로젝트 매칭 요약 목록(대상이 PROJECT일 때 채워짐)"), + fieldWithPath("body.projectMatchings[].projectId").description("프로젝트 ID"), fieldWithPath("body.projectMatchings[].title").description("프로젝트 제목"), fieldWithPath("body.projectMatchings[].description").description("프로젝트 설명"), fieldWithPath("body.projectMatchings[].imageUrl").description("프로젝트 대표 이미지"), @@ -477,6 +481,7 @@ void getSentMatchingsByUser() throws Exception { Authentication authentication = new UsernamePasswordAuthenticationToken(testUser, null, Collections.emptyList()); MatchingResDto.UserSummary userSummary = MatchingResDto.UserSummary.builder() + .userId(1L) .nickname("seoyeon") .bio("Designer") .field(RoleField.BACKEND) @@ -484,6 +489,7 @@ void getSentMatchingsByUser() throws Exception { .build(); MatchingResDto.ProjectSummary projectSummary = MatchingResDto.ProjectSummary.builder() + .projectId(1L) .title("NECT") .description("Project description") .imageUrl("https://example.com/image.jpg") @@ -529,12 +535,14 @@ void getSentMatchingsByUser() throws Exception { fieldWithPath("body.counterParty").description("대상 타입 (PROJECT | USER)"), fieldWithPath("body.userMatchings").description("유저 매칭 요약 목록(대상이 USER일 때 채워짐)"), + fieldWithPath("body.userMatchings[].userId").description("유저 ID"), fieldWithPath("body.userMatchings[].nickname").description("닉네임"), fieldWithPath("body.userMatchings[].bio").description("한줄 소개"), fieldWithPath("body.userMatchings[].field").description("분야"), fieldWithPath("body.userMatchings[].profileUrl").description("프로필 URL"), fieldWithPath("body.projectMatchings").description("프로젝트 매칭 요약 목록(대상이 PROJECT일 때 채워짐)"), + fieldWithPath("body.projectMatchings[].projectId").description("프로젝트 ID"), fieldWithPath("body.projectMatchings[].title").description("프로젝트 제목"), fieldWithPath("body.projectMatchings[].description").description("프로젝트 설명"), fieldWithPath("body.projectMatchings[].imageUrl").description("프로젝트 대표 이미지"), diff --git a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java index f673e4a3..b1522153 100644 --- a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java @@ -1,54 +1,42 @@ package com.nect.api.domain.mypage.controller; -import com.nect.api.domain.mypage.dto.ProfileSettingsDto; -import com.nect.api.domain.mypage.service.MyPageProjectCommandService; -import com.nect.api.domain.mypage.service.MyPageProjectQueryService; -import com.nect.api.domain.mypage.service.MypageService; import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.nect.api.NectDocumentApiTester; -import com.nect.core.entity.team.enums.PlanFileType; -import com.nect.core.entity.user.enums.InterestField; +import com.nect.api.domain.matching.dto.RecruitmentReqDto; +import com.nect.api.domain.matching.dto.RecruitmentResDto; +import com.nect.api.domain.matching.service.RecruitmentService; import com.nect.api.domain.mypage.dto.ProfileSettingsDto; +import com.nect.api.domain.mypage.service.MyPageProjectCommandService; +import com.nect.api.domain.mypage.service.MyPageProjectQueryService; import com.nect.api.domain.mypage.service.MypageService; import com.nect.api.domain.team.project.dto.ProjectUserFieldReqDto; import com.nect.api.domain.team.project.dto.ProjectUserFieldResDto; import com.nect.api.domain.team.project.dto.ProjectUserResDto; import com.nect.api.domain.team.project.service.ProjectUserService; +import com.nect.core.entity.team.enums.PlanFileType; import com.nect.core.entity.team.enums.ProjectMemberStatus; import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.user.enums.InterestField; import com.nect.core.entity.user.enums.RoleField; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.ArrayList; import java.util.List; import static com.epages.restdocs.apispec.ResourceDocumentation.resource; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doNothing; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.request.RequestDocumentation.partWithName; -import static org.springframework.restdocs.request.RequestDocumentation.requestParts; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.request.RequestDocumentation.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class MypageControllerTest extends NectDocumentApiTester { @@ -65,6 +53,9 @@ class MypageControllerTest extends NectDocumentApiTester { @MockitoBean private MyPageProjectQueryService projectQueryService; + @MockitoBean + private RecruitmentService recruitmentService; + @Test void getProfile() throws Exception { ProfileSettingsDto.ProfileSettingsResponseDto mockResponse = new ProfileSettingsDto.ProfileSettingsResponseDto( @@ -580,7 +571,7 @@ void updateProjectUserField() throws Exception { preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), resource(ResourceSnippetParameters.builder() - .tag("mypage") + .tag("마이페이지") .summary("프로젝트 멤버 필드(파트) 변경") .description("프로젝트 내 멤버의 필드(파트) 및 커스텀 필드를 변경합니다.") .requestHeaders( @@ -627,7 +618,7 @@ void kickProjectUser() throws Exception { preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), resource(ResourceSnippetParameters.builder() - .tag("mypage") + .tag("마이페이지") .summary("프로젝트 멤버 강퇴") .description("프로젝트에서 특정 멤버를 강퇴합니다.") .requestHeaders( @@ -680,7 +671,7 @@ void updateProjectUserType() throws Exception { preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), resource(ResourceSnippetParameters.builder() - .tag("mypage") + .tag("마이페이지") .summary("프로젝트 멤버 타입 변경") .description("프로젝트에서 특정 멤버의 타입을 변경합니다. (LEADER | LEAD | MEMBER)") .requestHeaders( @@ -706,4 +697,181 @@ void updateProjectUserType() throws Exception { ) )); } + + @Test + void createRecruitment() throws Exception { + Long projectId = 1L; + + RecruitmentResDto.EnrollRecruitmentResDto resDto = RecruitmentResDto.EnrollRecruitmentResDto.builder() + .recruitmentId(10L) + .roleField(RoleField.BACKEND) + .customField(null) + .capacity(3) + .requirements(List.of("Spring Boot 프레임워크를 사용한 경험이 있어야 합니다.", "Java언어를 능숙하게 다룰 수 있어야 합니다.")) + .build(); + + given(recruitmentService.enrollRecruitment(anyLong(), eq(projectId), any(RecruitmentReqDto.EnrollRecruitmentReqDto.class))) + .willReturn(resDto); + + String requestJson = """ + { + "roleField": "BACKEND", + "capacity": 3, + "requirements": ["Spring Boot 프레임워크를 사용한 경험이 있어야 합니다.", "Java언어를 능숙하게 다룰 수 있어야 합니다."] + } + """; + + mockMvc.perform(post("/api/v1/mypage/{projectId}/recruitments", projectId) + .header("Authorization", "Bearer AccessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isOk()) + .andDo(document("mypage-post-recruitment", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag("마이페이지") + .summary("모집정보 생성") + .description("프로젝트에 대한 모집정보를 추가합니다. 작성자는 프로젝트 리더여야 합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .requestFields( + fieldWithPath("roleField").description("모집 분야 식별자 (RoleField enum)"), + fieldWithPath("customField").type(JsonFieldType.STRING) + .description("커스텀 필드명 (roleField가 CUSTOM일 때 필수)").optional(), + fieldWithPath("capacity").description("모집 인원 수"), + fieldWithPath("requirements").type(JsonFieldType.ARRAY) + .description("요구사항 목록").optional() + ) + .responseFields( + fieldWithPath("status.statusCode").description("상태 코드"), + fieldWithPath("status.message").description("상태 메시지"), + fieldWithPath("status.description").description("상태 설명").optional(), + + fieldWithPath("body.recruitmentId").description("생성된 모집 ID"), + fieldWithPath("body.roleField").description("모집 분야"), + fieldWithPath("body.customField").description("커스텀 필드명").optional(), + fieldWithPath("body.capacity").description("모집 인원 수"), + fieldWithPath("body.requirements").description("요구사항 목록") + ) + .build() + ) + )); + } + + @Test + void updateRecruitment() throws Exception { + Long projectId = 1L; + Long recruitmentId = 10L; + + RecruitmentResDto.EnrollRecruitmentResDto resDto = RecruitmentResDto.EnrollRecruitmentResDto.builder() + .recruitmentId(recruitmentId) + .roleField(RoleField.BACKEND) + .customField(null) + .capacity(2) + .requirements(List.of("Django 프레임워크를 사용한 경험이 있어야 합니다.", "python언어를 능숙하게 다룰 줄 알아야 합니다.")) + .build(); + + given(recruitmentService.updateRecruitment(anyLong(), eq(projectId), eq(recruitmentId), any(RecruitmentReqDto.EnrollRecruitmentReqDto.class))) + .willReturn(resDto); + + String requestJson = """ + { + "roleField": "BACKEND", + "capacity": 2, + "requirements": ["Django 프레임워크를 사용한 경험이 있어야 합니다.", "python언어를 능숙하게 다룰 줄 알아야 합니다."] + } + """; + + mockMvc.perform(put("/api/v1/mypage/{projectId}/recruitments/{recruitmentId}", projectId, recruitmentId) + .header("Authorization", "Bearer AccessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isOk()) + .andDo(document("mypage-put-recruitment", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag("마이페이지") + .summary("모집정보 수정") + .description("기존 모집정보를 수정합니다. 작성자는 프로젝트 리더여야 합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .requestFields( + fieldWithPath("roleField").description("모집 분야 식별자 (RoleField enum)"), + fieldWithPath("customField").type(JsonFieldType.STRING) + .description("커스텀 필드명 (roleField가 CUSTOM일 때 필수)").optional(), + fieldWithPath("capacity").description("모집 인원 수"), + fieldWithPath("requirements").type(JsonFieldType.ARRAY) + .description("요구사항 목록").optional() + ) + .responseFields( + fieldWithPath("status.statusCode").description("상태 코드"), + fieldWithPath("status.message").description("상태 메시지"), + fieldWithPath("status.description").description("상태 설명").optional(), + + fieldWithPath("body.recruitmentId").description("모집 ID"), + fieldWithPath("body.roleField").description("모집 분야"), + fieldWithPath("body.customField").description("커스텀 필드명").optional(), + fieldWithPath("body.capacity").description("모집 인원 수"), + fieldWithPath("body.requirements").description("요구사항 목록") + ) + .build() + ) + )); + } + + @Test + void getRecruitmentsByProject() throws Exception { + Long projectId = 1L; + + List resList = List.of( + RecruitmentResDto.EnrollRecruitmentResDto.builder() + .recruitmentId(10L) + .roleField(RoleField.BACKEND) + .customField(null) + .capacity(3) + .requirements(List.of("Django 프레임워크를 사용한 경험이 있어야 합니다.", "python언어를 능숙하게 다룰 줄 알아야 합니다.")) + .build(), + RecruitmentResDto.EnrollRecruitmentResDto.builder() + .recruitmentId(11L) + .roleField(RoleField.FRONTEND) + .customField(null) + .capacity(1) + .requirements(List.of("디자인 경험이 있으신 분을 선호합니다.", "모두 환영해요.")) + .build() + ); + + given(recruitmentService.getRecruitmentsByProject(projectId)).willReturn(resList); + + mockMvc.perform(get("/api/v1/mypage/{projectId}/recruitments", projectId) + .header("Authorization", "Bearer AccessToken")) + .andExpect(status().isOk()) + .andDo(document("mypage-get-recruitments", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag("마이페이지") + .summary("프로젝트 모집정보 조회") + .description("프로젝트에 등록된 모든 모집정보를 조회합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .responseFields( + fieldWithPath("status.statusCode").description("상태 코드"), + fieldWithPath("status.message").description("상태 메시지"), + fieldWithPath("status.description").description("상태 설명").optional(), + + fieldWithPath("body[].recruitmentId").description("모집 ID"), + fieldWithPath("body[].roleField").description("모집 분야"), + fieldWithPath("body[].customField").description("커스텀 필드").optional(), + fieldWithPath("body[].capacity").description("모집 인원 수"), + fieldWithPath("body[].requirements").description("요구사항 목록") + ) + .build() + ) + )); + } } diff --git a/nect-core/src/main/java/com/nect/core/entity/matching/Recruitment.java b/nect-core/src/main/java/com/nect/core/entity/matching/Recruitment.java index 3135c9bd..2e30e624 100644 --- a/nect-core/src/main/java/com/nect/core/entity/matching/Recruitment.java +++ b/nect-core/src/main/java/com/nect/core/entity/matching/Recruitment.java @@ -6,6 +6,9 @@ import jakarta.persistence.*; import lombok.*; +import java.util.ArrayList; +import java.util.List; + @Entity @Getter @Table(name = "recruitment") @@ -16,23 +19,50 @@ public class Recruitment extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - Long id; + private Long id; @ManyToOne @JoinColumn(name = "project_id", nullable = false) - Project project; + private Project project; @Enumerated(EnumType.STRING) @Column(name = "field", nullable = false) - RoleField field; + private RoleField field; @Column(name = "capacity", nullable = false) - Integer capacity; + private Integer capacity; @Column(name = "custom_field") private String customField; + @OneToMany( + mappedBy = "recruitment", + cascade = CascadeType.ALL, + orphanRemoval = true + ) + @OrderBy("sortOrder asc") + @Builder.Default + private List requirements = new ArrayList<>(); + public void decreaseCapacity(){ this.capacity -= 1; } + + public void addRequirement(RecruitmentRequirement requirement){ + requirements.add(requirement); + requirement.setRecruitment(this); + } + + public void updateField(RoleField field) { + this.field = field; + + } + public void updateCapacity(Integer capacity) { + this.capacity = capacity; + + } + public void updateCustomField(String customField) { + this.customField = customField; + } + } diff --git a/nect-core/src/main/java/com/nect/core/entity/matching/RecruitmentRequirement.java b/nect-core/src/main/java/com/nect/core/entity/matching/RecruitmentRequirement.java new file mode 100644 index 00000000..d9346eff --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/entity/matching/RecruitmentRequirement.java @@ -0,0 +1,32 @@ +package com.nect.core.entity.matching; + +import com.nect.core.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Table(name = "recruitment_requirement") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class RecruitmentRequirement extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "recruitment_id", nullable = false) + private Recruitment recruitment; + + @Column(name = "content") + private String content; + + @Column(name = "sort_order") + private int sortOrder; + + protected void setRecruitment(Recruitment recruitment) { + this.recruitment = recruitment; + } +} diff --git a/nect-core/src/main/java/com/nect/core/repository/matching/RecruitmentRepository.java b/nect-core/src/main/java/com/nect/core/repository/matching/RecruitmentRepository.java index 40d143c4..e4c4f333 100644 --- a/nect-core/src/main/java/com/nect/core/repository/matching/RecruitmentRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/matching/RecruitmentRepository.java @@ -41,6 +41,9 @@ List findOpenFieldsByProject( @Param("project") Project project ); + Optional findByIdAndProject(Long recruitmentId, Project project); + + interface ProjectCapacityRow { Long getProjectId(); Integer getCapacitySum(); diff --git a/nect-core/src/main/java/com/nect/core/repository/matching/RecruitmentRequirementRepository.java b/nect-core/src/main/java/com/nect/core/repository/matching/RecruitmentRequirementRepository.java new file mode 100644 index 00000000..2ff884d0 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/repository/matching/RecruitmentRequirementRepository.java @@ -0,0 +1,9 @@ +package com.nect.core.repository.matching; + +import com.nect.core.entity.matching.RecruitmentRequirement; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RecruitmentRequirementRepository extends JpaRepository { +} From 9541c5a1aa049099498ac8d72ab97fca886c124d Mon Sep 17 00:00:00 2001 From: infiniment Date: Sun, 8 Feb 2026 22:34:49 +0900 Subject: [PATCH 58/66] =?UTF-8?q?[Refactor]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=ED=94=84=EB=A6=AC=EB=B7=B0=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/controller/PostController.java | 6 ++-- .../dto/res/BoardsOverviewResDto.java | 4 +-- .../facade/BoardsOverviewFacade.java | 7 ++-- .../team/workspace/facade/PostFacade.java | 23 ++----------- .../team/workspace/service/PostService.java | 32 +++++++++++++++++++ .../team/workspace/PostRepository.java | 16 ++++++++++ 6 files changed, 57 insertions(+), 31 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/PostController.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/PostController.java index 4f96c10c..fbacfa12 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/PostController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/PostController.java @@ -92,12 +92,10 @@ public ApiResponse deletePost( @GetMapping("/preview") public ApiResponse getPostsPreview( @PathVariable Long projectId, - @AuthenticationPrincipal UserDetailsImpl userDetails, - @RequestParam(required = false) PostType type, - @RequestParam(defaultValue = "4") int limit + @AuthenticationPrincipal UserDetailsImpl userDetails ) { return ApiResponse.ok( - postFacade.getPostsPreview(projectId, userDetails.getUserId(), type, limit) + postFacade.getPostsPreview(projectId, userDetails.getUserId()) ); } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/BoardsOverviewResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/BoardsOverviewResDto.java index 8a6ddda9..bfc3207c 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/BoardsOverviewResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/BoardsOverviewResDto.java @@ -20,7 +20,7 @@ public record BoardsOverviewResDto( SharedDocumentsPreviewResDto sharedDocumentsPreview, @JsonProperty("posts_preview") - PostListResDto postsPreview, + PostsPreviewResDto postsPreview, @JsonProperty("calendar_month_indicators") CalendarMonthIndicatorsResDto calendarMonthIndicators @@ -31,7 +31,7 @@ public static BoardsOverviewResDto of( MemberBoardResDto members, ScheduleUpcomingResDto upcomingSchedules, SharedDocumentsPreviewResDto sharedDocumentsPreview, - PostListResDto postsPreview, + PostsPreviewResDto postsPreview, CalendarMonthIndicatorsResDto calendarMonthIndicators ) { return new BoardsOverviewResDto( diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsOverviewFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsOverviewFacade.java index 7f9bdf11..d2ae97ea 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsOverviewFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsOverviewFacade.java @@ -43,10 +43,9 @@ public BoardsOverviewResDto getOverview( SharedDocumentsPreviewResDto sharedDocumentsPreview = sharedDocumentFacade.getPreview(projectId, userId, docsLimit); - // 기본값 : 공지로 설정 - PostType safeType = (postType == null) ? PostType.NOTICE : postType; - PostListResDto postsPreview = - postFacade.getPostList(projectId, userId, safeType,0, postsLimit); + // 공지 2 + 자유글 2 + PostsPreviewResDto postsPreview = + postFacade.getPostsPreview(projectId, userId); CalendarMonthIndicatorsResDto calendarIndicators = null; if (year != null && month != null) { diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/PostFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/PostFacade.java index 31b95ced..4701a1f6 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/PostFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/PostFacade.java @@ -87,26 +87,7 @@ public void deletePost(Long projectId, Long userId, Long postId) { } // 게시글 목록 프리뷰 - public PostsPreviewResDto getPostsPreview(Long projectId, Long userId, PostType type, int limit) { - int safeLimit = Math.max(1, Math.min(limit, 20)); - - PostListResDto list = postService.getPostList( - projectId, - userId, - type, - 0, - safeLimit - ); - - List items = list.posts().stream() - .map(p -> new PostsPreviewResDto.Item( - p.postId(), - p.postType(), - p.title(), - p.createdAt() - )) - .toList(); - - return new PostsPreviewResDto(items); + public PostsPreviewResDto getPostsPreview(Long projectId, Long userId) { + return postService.getOverviewPostsPreview(projectId, userId); } } \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java index 1b080f17..0dc27a47 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java @@ -625,4 +625,36 @@ public void deletePost(Long projectId, Long userId, Long postId) { meta ); } + + @Transactional(readOnly = true) + public PostsPreviewResDto getOverviewPostsPreview(Long projectId, Long userId) { + + projectRepository.findById(projectId) + .orElseThrow(() -> new PostException(PostErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId)); + + boolean isMember = projectUserRepository.existsByProjectIdAndUserId(projectId, userId); + if (!isMember) { + throw new PostException(PostErrorCode.PROJECT_MEMBER_FORBIDDEN, + "projectId=" + projectId + ", userId=" + userId); + } + + Sort sort = Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")); + Pageable top2 = PageRequest.of(0, 2, sort); + + List notices = postRepository.findNoticePosts(projectId, top2).getContent(); + List frees = postRepository.findFreeOnlyPosts(projectId, top2).getContent(); + + // 공지 상단 고정 + (각 그룹 최신순) + List items = new ArrayList<>(4); + + items.addAll(notices.stream() + .map(p -> new PostsPreviewResDto.Item(p.getId(), p.getPostType(), p.getTitle(), p.getCreatedAt())) + .toList()); + + items.addAll(frees.stream() + .map(p -> new PostsPreviewResDto.Item(p.getId(), p.getPostType(), p.getTitle(), p.getCreatedAt())) + .toList()); + + return new PostsPreviewResDto(items); + } } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostRepository.java index 3f18758a..3353970a 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostRepository.java @@ -32,4 +32,20 @@ public interface PostRepository extends JpaRepository { and p.postType <> com.nect.core.entity.team.workspace.enums.PostType.NOTICE """) Page findFreePosts(@Param("projectId") Long projectId, Pageable pageable); + + @Query(""" + select p from Post p + where p.project.id = :projectId + and p.deletedAt is null + and p.postType = com.nect.core.entity.team.workspace.enums.PostType.NOTICE + """) + Page findNoticePosts(@Param("projectId") Long projectId, Pageable pageable); + + @Query(""" + select p from Post p + where p.project.id = :projectId + and p.deletedAt is null + and p.postType = com.nect.core.entity.team.workspace.enums.PostType.FREE + """) + Page findFreeOnlyPosts(@Param("projectId") Long projectId, Pageable pageable); } From 96d3cf3e93422b2ad45c7fd41f5ddcc29ee45e77 Mon Sep 17 00:00:00 2001 From: infiniment Date: Mon, 9 Feb 2026 00:02:17 +0900 Subject: [PATCH 59/66] =?UTF-8?q?[Docs]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20AP?= =?UTF-8?q?I=20=ED=85=8C=EA=B7=B8=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/controller/FileControllerTest.java | 7 +- .../ProjectHistoryControllerTest.java | 4 +- .../controller/ProcessControllerTest.java | 170 ++++++++++- .../controller/WeekMissionControllerTest.java | 10 +- .../controller/ProjectUserControllerTest.java | 2 +- .../BoardsMemberBoardControllerTest.java | 80 +++-- .../BoardsOverviewControllerTest.java | 279 +++++++++++++++--- .../BoardsScheduleControllerTest.java | 50 ++-- .../BoardsSharedDocumentControllerTest.java | 90 +++++- .../PostAttachmentControllerTest.java | 6 +- .../controller/PostControllerTest.java | 26 +- 11 files changed, 593 insertions(+), 131 deletions(-) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/file/controller/FileControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/file/controller/FileControllerTest.java index 19312d58..5639d850 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/file/controller/FileControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/file/controller/FileControllerTest.java @@ -2,7 +2,6 @@ import com.epages.restdocs.apispec.ResourceDocumentation; import com.epages.restdocs.apispec.ResourceSnippetParameters; -import com.fasterxml.jackson.databind.ObjectMapper; import com.nect.api.domain.team.file.dto.res.FileDownloadUrlResDto; import com.nect.api.domain.team.file.dto.res.FileUploadResDto; import com.nect.api.domain.team.file.service.FileService; @@ -23,7 +22,6 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.request.RequestPostProcessor; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -47,7 +45,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @SpringBootTest @@ -141,7 +138,7 @@ void uploadFile() throws Exception { partWithName("file").description("업로드할 파일(MultipartFile)") ), resource(ResourceSnippetParameters.builder() - .tag("File") + .tag("Project") .summary("프로젝트 파일 업로드") .description("프로젝트 파일을 업로드합니다. 업로드 성공 시 file_id/file_url 등을 반환합니다.") .pathParameters( @@ -200,7 +197,7 @@ void downloadRedirect() throws Exception { preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), resource(ResourceSnippetParameters.builder() - .tag("File") + .tag("Project") .summary("프로젝트 파일 다운로드(리다이렉트)") .description("파일 다운로드 URL을 조회한 뒤 302(FOUND)로 Location 헤더에 담아 리다이렉트합니다.") .pathParameters( diff --git a/nect-api/src/test/java/com/nect/api/domain/team/history/controller/ProjectHistoryControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/history/controller/ProjectHistoryControllerTest.java index 9f727bf6..08e5b3cb 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/history/controller/ProjectHistoryControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/history/controller/ProjectHistoryControllerTest.java @@ -146,7 +146,7 @@ void getHistories() throws Exception { preprocessResponse(prettyPrint()), resource( ResourceSnippetParameters.builder() - .tag("History") + .tag("Process") .summary("팀 히스토리 로그 조회") .description("프로젝트 내 팀 히스토리 로그를 커서 기반으로 조회합니다. cursor 미입력 시 최신부터 조회합니다. (서버 정책: 최근 10개 고정)") .pathParameters( @@ -206,7 +206,7 @@ void getHistories_withoutParams() throws Exception { preprocessResponse(prettyPrint()), resource( ResourceSnippetParameters.builder() - .tag("History") + .tag("Process") .summary("팀 히스토리 로그 조회(기본)") .description("cursor 미입력 시 서버 정책으로 최신 로그부터 조회합니다. (서버 정책: 최근 10개 고정)") .pathParameters( diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java index d6d77f3c..4dc53e4e 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java @@ -147,8 +147,8 @@ void createProcess() throws Exception { LocalDate.of(2026, 1, 19), LocalDate.of(2026, 1, 25), - List.of(), - List.of(), + List.of(3L), + List.of(123L), List.of( new ProcessCreateReqDto.ProcessLinkItemReqDto("백엔드 Repo", "https://github.com/nect/nect-backend"), @@ -274,8 +274,8 @@ void getProcessDetail() throws Exception { LocalDate.of(2026, 1, 25), 0, - List.of(), // role_fields - List.of("디자인"), // custom_fields + List.of(RoleField.BACKEND, RoleField.FRONTEND), + List.of("디자인"), List.of( new AssigneeResDto(1L, "유저1", "유저1닉", "https://img.com/1.png"), @@ -597,16 +597,124 @@ void getWeekProcesses() throws Exception { long projectId = 1L; long userId = 1L; - ProcessWeekResDto w1 = new ProcessWeekResDto( + // ===== week1 common lane ===== + ProcessCardResDto common1 = new ProcessCardResDto( + 101L, + ProcessStatus.PLANNING, + "기획 초안 작성", + 1, + 3, LocalDate.of(2026, 1, 19), + LocalDate.of(2026, 1, 25), + 6, + List.of(RoleField.BACKEND, RoleField.FRONTEND), + List.of("영상편집"), + 1, + List.of( + new AssigneeResDto(1L, "홍길동", "길동", "https://img.com/u1.png"), + new AssigneeResDto(2L, "김철수", "철수", null) + ) + ); + + ProcessCardResDto common2 = new ProcessCardResDto( + 102L, + ProcessStatus.IN_PROGRESS, + "API 설계", + 2, + 5, + LocalDate.of(2026, 1, 20), + LocalDate.of(2026, 1, 24), + 5, + List.of(RoleField.BACKEND), List.of(), - List.of() + 1, + List.of( + new AssigneeResDto(3L, "박영희", "영희", "https://img.com/u3.png") + ) + ); + + ProcessCardResDto backend1 = new ProcessCardResDto( + 201L, + ProcessStatus.DONE, + "DB 스키마 확정", + 4, + 4, + LocalDate.of(2026, 1, 19), + LocalDate.of(2026, 1, 21), + 2, + List.of(RoleField.BACKEND), + List.of(), + 1, + List.of( + new AssigneeResDto(1L, "홍길동", "길동", "https://img.com/u1.png") + ) + ); + + ProcessCardResDto custom1 = new ProcessCardResDto( + 301L, + ProcessStatus.PLANNING, + "시연 영상 콘티", + 0, + 2, + LocalDate.of(2026, 1, 22), + LocalDate.of(2026, 1, 25), + 3, + List.of(RoleField.PHOTO_VIDEO), + List.of("영상편집"), + 1, + List.of( + new AssigneeResDto(4L, "이민수", "민수", null) + ) + ); + + FieldGroupResDto fgBackend = new FieldGroupResDto( + "ROLE:BACKEND", + "BACKEND", + 0, + List.of(backend1) + ); + + FieldGroupResDto fgCustom = new FieldGroupResDto( + "CUSTOM:영상편집", + "영상편집", + 1, + List.of(custom1) + ); + + ProcessWeekResDto w1 = new ProcessWeekResDto( + LocalDate.of(2026, 1, 19), + List.of(common1, common2), + List.of(fgBackend, fgCustom) + ); + + ProcessCardResDto w2Common = new ProcessCardResDto( + 401L, + ProcessStatus.IN_PROGRESS, + "주간 회고", + 0, + 1, + LocalDate.of(2026, 1, 26), + LocalDate.of(2026, 2, 1), + 7, + List.of(RoleField.APP_WEB, RoleField.OPERATIONS_CS), + List.of("운영"), + 2, + List.of( + new AssigneeResDto(2L, "김철수", "철수", "https://img.com/u2.png") + ) + ); + + FieldGroupResDto fgPlanner = new FieldGroupResDto( + "ROLE:APP_WEB", + "APP_WEB", + 0, + List.of(w2Common) ); ProcessWeekResDto w2 = new ProcessWeekResDto( LocalDate.of(2026, 1, 26), - List.of(), - List.of() + List.of(), // common lane empty + List.of(fgPlanner) ); ProcessWeeksResDto response = new ProcessWeeksResDto(List.of(w1, w2)); @@ -649,8 +757,50 @@ void getWeekProcesses() throws Exception { fieldWithPath("body.weeks").type(ARRAY).description("주차별 결과 목록"), fieldWithPath("body.weeks[].start_date").type(STRING).description("주 시작일(yyyy-MM-dd)"), - subsectionWithPath("body.weeks[].common_lane").type(ARRAY).description("공통 레인 프로세스 카드 목록"), - subsectionWithPath("body.weeks[].by_field").type(ARRAY).description("분야별(Field) 그룹 목록") + + // ===== common_lane ===== + fieldWithPath("body.weeks[].common_lane").type(ARRAY).description("공통 레인 프로세스 카드 목록"), + fieldWithPath("body.weeks[].common_lane[].process_id").type(NUMBER).description("프로세스 ID"), + fieldWithPath("body.weeks[].common_lane[].process_status").type(STRING).description("프로세스 상태"), + fieldWithPath("body.weeks[].common_lane[].title").type(STRING).description("프로세스 제목"), + fieldWithPath("body.weeks[].common_lane[].complete_check_list").type(NUMBER).description("완료 체크리스트 개수"), + fieldWithPath("body.weeks[].common_lane[].whole_check_list").type(NUMBER).description("전체 체크리스트 개수"), + fieldWithPath("body.weeks[].common_lane[].start_date").type(STRING).description("시작일(yyyy-MM-dd)"), + fieldWithPath("body.weeks[].common_lane[].dead_line").type(STRING).description("마감일(yyyy-MM-dd)"), + fieldWithPath("body.weeks[].common_lane[].left_day").type(NUMBER).description("남은 일수"), + fieldWithPath("body.weeks[].common_lane[].role_fields").type(ARRAY).description("역할 분야(RoleField enum) 목록"), + fieldWithPath("body.weeks[].common_lane[].custom_fields").type(ARRAY).description("커스텀 분야명 목록"), + fieldWithPath("body.weeks[].common_lane[].mission_number").type(NUMBER).description("미션 번호"), + fieldWithPath("body.weeks[].common_lane[].assignee").type(ARRAY).description("담당자 목록"), + fieldWithPath("body.weeks[].common_lane[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), + fieldWithPath("body.weeks[].common_lane[].assignee[].user_name").type(STRING).description("담당자 이름"), + fieldWithPath("body.weeks[].common_lane[].assignee[].nickname").type(STRING).description("담당자 닉네임"), + fieldWithPath("body.weeks[].common_lane[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL"), + + // ===== by_field ===== + fieldWithPath("body.weeks[].by_field").type(ARRAY).description("분야별(Field) 그룹 목록"), + fieldWithPath("body.weeks[].by_field[].field_id").type(STRING).description("fieldId (예: ROLE:BACKEND / CUSTOM:영상편집)"), + fieldWithPath("body.weeks[].by_field[].field_name").type(STRING).description("fieldName (예: BACKEND / 영상편집)"), + fieldWithPath("body.weeks[].by_field[].field_order").type(NUMBER).description("fieldOrder"), + fieldWithPath("body.weeks[].by_field[].processes").type(ARRAY).description("해당 그룹의 프로세스 목록"), + + // by_field[].processes[] (ProcessCardResDto 동일) + fieldWithPath("body.weeks[].by_field[].processes[].process_id").type(NUMBER).description("프로세스 ID"), + fieldWithPath("body.weeks[].by_field[].processes[].process_status").type(STRING).description("프로세스 상태"), + fieldWithPath("body.weeks[].by_field[].processes[].title").type(STRING).description("프로세스 제목"), + fieldWithPath("body.weeks[].by_field[].processes[].complete_check_list").type(NUMBER).description("완료 체크리스트 개수"), + fieldWithPath("body.weeks[].by_field[].processes[].whole_check_list").type(NUMBER).description("전체 체크리스트 개수"), + fieldWithPath("body.weeks[].by_field[].processes[].start_date").type(STRING).description("시작일(yyyy-MM-dd)"), + fieldWithPath("body.weeks[].by_field[].processes[].dead_line").type(STRING).description("마감일(yyyy-MM-dd)"), + fieldWithPath("body.weeks[].by_field[].processes[].left_day").type(NUMBER).description("남은 일수"), + fieldWithPath("body.weeks[].by_field[].processes[].role_fields").type(ARRAY).description("역할 분야(RoleField enum) 목록"), + fieldWithPath("body.weeks[].by_field[].processes[].custom_fields").type(ARRAY).description("커스텀 분야명 목록"), + fieldWithPath("body.weeks[].by_field[].processes[].mission_number").type(NUMBER).description("미션 번호"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee").type(ARRAY).description("담당자 목록"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_name").type(STRING).description("담당자 이름"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].nickname").type(STRING).description("담당자 닉네임"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL") ) .build() ) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java index d47eb84b..658b1300 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java @@ -183,7 +183,7 @@ void getWeekMissions() throws Exception { preprocessResponse(prettyPrint()), resource( ResourceSnippetParameters.builder() - .tag("Week-Mission") + .tag("Process") .summary("주차별 위크미션 조회") .description("start_date 기준으로 weeks 만큼 위크미션 주차 목록을 조회합니다. start_date 미입력 시 서버 정책에 따른 기본 시작일로 동작합니다.") .pathParameters( @@ -233,7 +233,7 @@ void getWeekMissionDetail() throws Exception { preprocessResponse(prettyPrint()), resource( ResourceSnippetParameters.builder() - .tag("Week-Mission") + .tag("Process") .summary("위크미션 상세 조회") .description("위크미션(프로세스) 상세를 조회합니다. (체크리스트 포함)") .pathParameters( @@ -282,7 +282,7 @@ void updateWeekMissionStatus() throws Exception { preprocessResponse(prettyPrint()), resource( ResourceSnippetParameters.builder() - .tag("Week-Mission") + .tag("Process") .summary("위크미션 상태 변경") .description("위크미션 프로세스의 상태를 변경합니다.") .pathParameters( @@ -341,7 +341,7 @@ void updateWeekMissionTaskItem() throws Exception { preprocessResponse(prettyPrint()), resource( ResourceSnippetParameters.builder() - .tag("Week-Mission") + .tag("Process") .summary("위크미션 TASK 항목 수정") .description("위크미션 프로세스 내 TaskItem의 내용을 수정합니다.") .pathParameters( @@ -399,7 +399,7 @@ void readMissionDropdown() throws Exception { preprocessResponse(prettyPrint()), resource( ResourceSnippetParameters.builder() - .tag("Week-Mission") + .tag("Process") .summary("미션 주차 드롭다운 조회") .description("멤버형 모달에서 미션(주차) 선택을 위한 드롭다운 목록을 조회합니다.") .pathParameters( diff --git a/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectUserControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectUserControllerTest.java index d01b79bd..0c237fd2 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectUserControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/project/controller/ProjectUserControllerTest.java @@ -85,7 +85,7 @@ void getProjectsByUser() throws Exception { preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), resource(ResourceSnippetParameters.builder() - .tag("ProjectUser") + .tag("Project") .summary("현재 참여하고 있는 프로젝트 조회") .description("로그인한 유저가 현재 참여하고 있는 프로젝트를 조회합니다.") .requestHeaders( diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsMemberBoardControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsMemberBoardControllerTest.java index 745903aa..12c09807 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsMemberBoardControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsMemberBoardControllerTest.java @@ -4,11 +4,14 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.fasterxml.jackson.databind.ObjectMapper; import com.nect.api.domain.team.workspace.dto.res.MemberBoardResDto; +import com.nect.api.domain.team.workspace.dto.res.RoleFieldDto; import com.nect.api.domain.team.workspace.facade.BoardsMemberBoardFacade; import com.nect.api.global.jwt.JwtUtil; import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.user.enums.RoleField; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,6 +28,7 @@ import org.springframework.test.web.servlet.request.RequestPostProcessor; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; @@ -104,31 +108,43 @@ void getMemberBoard() throws Exception { long projectId = 1L; long userId = 1L; - String bodyJson = """ - { - "members": [ - { - "user_id": 1, - "name": "홍길동", - "nickname": "길동", - "profile_image_url": "TODO", - "field": null, - "member_type": "MEMBER", - "counts": { - "planning": 2, - "in_progress": 1, - "done": 3 - }, - "is_working": true, - "today_work_seconds": 3600, - "working_started_at": "2026-01-31T10:00:00" - } - ] - } - """; - - - MemberBoardResDto response = objectMapper.readValue(bodyJson, MemberBoardResDto.class); + MemberBoardResDto response = new MemberBoardResDto( + List.of( + new MemberBoardResDto.MemberDto( + 1L, + "홍길동", + "길동", + "https://img.com/u1.png", + RoleFieldDto.of(RoleField.BACKEND), + ProjectMemberType.MEMBER, + new MemberBoardResDto.CountsDto( + 2, // planning + 1, // in_progress + 3 // done + ), + true, + 3600L, + LocalDateTime.of(2026, 1, 31, 10, 0, 0) + ), + new MemberBoardResDto.MemberDto( + 2L, + "김철수", + "철수", + "https://img.com/u2.png", + RoleFieldDto.of(RoleField.CUSTOM, "기획-운영"), + ProjectMemberType.LEADER, + new MemberBoardResDto.CountsDto( + 1, + 4, + 2 + ), + false, + 1800L, + null + ) + ) + ); + given(facade.getMemberBoard(eq(projectId), eq(userId))).willReturn(response); mockMvc.perform(get("/api/v1/projects/{projectId}/boards/members", projectId) @@ -157,20 +173,28 @@ void getMemberBoard() throws Exception { fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.members").type(ARRAY).description("프로젝트 멤버 목록"), + fieldWithPath("body.members[].user_id").type(NUMBER).description("멤버 유저 ID"), fieldWithPath("body.members[].name").type(STRING).description("멤버 이름"), fieldWithPath("body.members[].nickname").type(STRING).description("멤버 닉네임"), - fieldWithPath("body.members[].profile_image_url").optional().type(STRING).description("프로필 이미지 URL (TODO/NULL 가능)"), - fieldWithPath("body.members[].field").optional().type(STRING).description("역할 분야(RoleField enum name 또는 CUSTOM:직접입력, null 가능)"), + fieldWithPath("body.members[].profile_image_url").optional().type(STRING).description("프로필 이미지 URL (없으면 null)"), + + fieldWithPath("body.members[].field").optional().type(OBJECT).description("역할 분야"), + fieldWithPath("body.members[].field.type").optional().type(STRING).description("역할 분야 타입(RoleField enum name)"), + fieldWithPath("body.members[].field.custom_name").optional().type(STRING).description("CUSTOM일 때 직접 입력 값 (CUSTOM이 아니면 null)"), + fieldWithPath("body.members[].member_type").type(STRING).description("프로젝트 멤버 타입(enum)"), + fieldWithPath("body.members[].counts").type(OBJECT).description("멤버별 담당 프로세스 상태 카운트"), fieldWithPath("body.members[].counts.planning").type(NUMBER).description("진행 전 개수"), fieldWithPath("body.members[].counts.in_progress").type(NUMBER).description("진행 중 개수"), fieldWithPath("body.members[].counts.done").type(NUMBER).description("완료 개수"), + fieldWithPath("body.members[].is_working").type(BOOLEAN).description("현재 근무중 여부"), fieldWithPath("body.members[].today_work_seconds").type(NUMBER).description("오늘 누적 근무 시간(초)"), - fieldWithPath("body.members[].working_started_at").optional().type(STRING).description("근무 시작 시각(yyyy-MM-dd'T'HH:mm:ss) (근무중이 아니면 null 가능)") + fieldWithPath("body.members[].working_started_at").optional().type(STRING).description("근무 시작 시각(yyyy-MM-dd'T'HH:mm:ss) (근무중이 아니면 null)") ) .build() ) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsOverviewControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsOverviewControllerTest.java index 6e686fd0..60d88130 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsOverviewControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsOverviewControllerTest.java @@ -8,6 +8,7 @@ import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.team.enums.FileExt; import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.workspace.enums.PostType; import com.nect.core.entity.user.enums.RoleField; @@ -170,35 +171,76 @@ void getOverview_withoutCalendarIndicators() throws Exception { ) ); - PostListResDto postsPreview = new PostListResDto( + PostsPreviewResDto postsPreview = new PostsPreviewResDto( List.of( - new PostListResDto.PostSummaryDto( + new PostsPreviewResDto.Item( 1L, PostType.NOTICE, "공지 제목", - "공지 내용 프리뷰...", - 10L, LocalDateTime.of(2026, 1, 31, 12, 0, 0) ), - new PostListResDto.PostSummaryDto( + new PostsPreviewResDto.Item( 2L, PostType.FREE, "자유 글 제목", - "자유 글 프리뷰...", - 3L, LocalDateTime.of(2026, 1, 30, 9, 30, 0) ) - ), - new PostListResDto.PageInfo( - 0, // page - 4, // size - 12L, // total_elements - 3, // total_pages - true // has_next ) ); - SharedDocumentsPreviewResDto sharedDocs = new SharedDocumentsPreviewResDto(List.of()); + SharedDocumentsPreviewResDto sharedDocs = new SharedDocumentsPreviewResDto( + List.of( + new SharedDocumentsPreviewResDto.DocumentDto( + 2001L, // document_id + true, // is_pinned + "API 명세서", // title + "api-spec.pdf", // file_name + FileExt.PDF, // file_ext + "https://s3.amazonaws.com/nect/docs/api-spec.pdf", // file_url + 1024L, // file_size + LocalDateTime.of(2026, 1, 30, 12, 0, 0), // created_at + new SharedDocumentsPreviewResDto.UploaderDto( + 1L, + "홍길동", + "길동", + "https://img.com/u1.png" + ) + ), + new SharedDocumentsPreviewResDto.DocumentDto( + 2002L, + false, + "ERD v2", + "erd.png", + FileExt.PNG, + "https://s3.amazonaws.com/nect/docs/erd.png", + 2048L, + LocalDateTime.of(2026, 1, 29, 18, 30, 0), + new SharedDocumentsPreviewResDto.UploaderDto( + 2L, + "김철수", + "철수", + "https://img.com/u2.png" + ) + ), + new SharedDocumentsPreviewResDto.DocumentDto( + 2003L, + false, + "회의록(1/28)", + "meeting-notes-0128.docx", + FileExt.DOCS, + "https://s3.amazonaws.com/nect/docs/meeting-notes-0128.docx", + 4096L, + LocalDateTime.of(2026, 1, 28, 21, 10, 0), + new SharedDocumentsPreviewResDto.UploaderDto( + 3L, + "박영희", + "영희", + null + ) + ) + ) + ); + BoardsOverviewResDto response = BoardsOverviewResDto.of( basicInfo, @@ -282,6 +324,183 @@ void getOverview_withCalendarIndicators() throws Exception { long projectId = 1L; long userId = 1L; + BoardsBasicInfoGetResDto basicInfo = new BoardsBasicInfoGetResDto( + projectId, + "프로젝트 제목", + "프로젝트 설명", + "공지 텍스트", + "정기회의 텍스트", + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 2, 1), + 10L, + true + ); + + MissionProgressResDto missionProgress = new MissionProgressResDto( + new MissionProgressResDto.TotalDto( + 12L, // total_count + 7L, // completed_count + 0.5833333333 // completion_rate + ), + List.of( + new MissionProgressResDto.TeamDto( + RoleFieldDto.of(RoleField.BACKEND), + 5L, + 3L, + 0.6 + ), + new MissionProgressResDto.TeamDto( + RoleFieldDto.of(RoleField.FRONTEND), + 4L, + 2L, + 0.5 + ), + new MissionProgressResDto.TeamDto( + RoleFieldDto.of(RoleField.CUSTOM, "기획-운영"), + 3L, + 2L, + 0.6666666667 + ) + ) + ); + + MemberBoardResDto members = new MemberBoardResDto( + List.of( + new MemberBoardResDto.MemberDto( + 1L, + "홍길동", + "길동", + "https://img.com/u1.png", + RoleFieldDto.of(RoleField.BACKEND), + ProjectMemberType.LEADER, + new MemberBoardResDto.CountsDto( + 1, // planning + 2, // in_progress + 3 // done + ), + true, // is_working + 3600L, // today_work_seconds + LocalDateTime.of(2026, 1, 31, 10, 0, 0) // working_started_at + ), + new MemberBoardResDto.MemberDto( + 2L, + "김철수", + "철수", + "https://img.com/u2.png", + RoleFieldDto.of(RoleField.FRONTEND), + ProjectMemberType.MEMBER, + new MemberBoardResDto.CountsDto( + 0, + 1, + 5 + ), + false, + 1800L, + null + ), + new MemberBoardResDto.MemberDto( + 3L, + "박영희", + "영희", + null, + RoleFieldDto.of(RoleField.CUSTOM, "기획-운영"), + ProjectMemberType.MEMBER, + new MemberBoardResDto.CountsDto( + 2, + 0, + 1 + ), + true, + 900L, + LocalDateTime.of(2026, 1, 31, 9, 30, 0) + ) + ) + ); + + ScheduleUpcomingResDto upcomingSchedules = new ScheduleUpcomingResDto( + List.of( + new ScheduleUpcomingResDto.Item( + 101L, + "주간 회의", + LocalDateTime.of(2026, 2, 1, 10, 0, 0), + LocalDateTime.of(2026, 2, 1, 11, 0, 0), + false, // all_day + false // is_multi_day + ), + new ScheduleUpcomingResDto.Item( + 102L, + "해커톤(멀티데이)", + LocalDateTime.of(2026, 2, 3, 0, 0, 0), + LocalDateTime.of(2026, 2, 5, 23, 59, 59), + true, + true + ), + new ScheduleUpcomingResDto.Item( + 103L, + "중간 점검 발표", + LocalDateTime.of(2026, 2, 7, 14, 0, 0), + LocalDateTime.of(2026, 2, 7, 15, 0, 0), + false, + false + ) + ) + ); + + SharedDocumentsPreviewResDto sharedDocs = new SharedDocumentsPreviewResDto( + List.of( + new SharedDocumentsPreviewResDto.DocumentDto( + 2001L, + true, // is_pinned + "API 명세서", + "api-spec.pdf", + FileExt.PDF, + "https://s3.amazonaws.com/nect/docs/api-spec.pdf", + 1024L, + LocalDateTime.of(2026, 1, 30, 12, 0, 0), + new SharedDocumentsPreviewResDto.UploaderDto( + 1L, + "홍길동", + "길동", + "https://img.com/u1.png" + ) + ), + new SharedDocumentsPreviewResDto.DocumentDto( + 2002L, + false, + "ERD v2", + "erd.png", + FileExt.PNG, + "https://s3.amazonaws.com/nect/docs/erd.png", + 2048L, + LocalDateTime.of(2026, 1, 29, 18, 30, 0), + new SharedDocumentsPreviewResDto.UploaderDto( + 2L, + "김철수", + "철수", + "https://img.com/u2.png" + ) + ) + ) + ); + + PostsPreviewResDto postsPreview = new PostsPreviewResDto( + List.of( + new PostsPreviewResDto.Item( + 3001L, + PostType.NOTICE, + "공지 제목", + LocalDateTime.of(2026, 1, 31, 12, 0, 0) + ), + new PostsPreviewResDto.Item( + 3002L, + PostType.FREE, + "자유 글 제목", + LocalDateTime.of(2026, 1, 30, 9, 30, 0) + ) + ) + ); + + CalendarMonthIndicatorsResDto indicators = new CalendarMonthIndicatorsResDto( 2026, 1, @@ -292,32 +511,16 @@ void getOverview_withCalendarIndicators() throws Exception { ); BoardsOverviewResDto response = BoardsOverviewResDto.of( - new BoardsBasicInfoGetResDto( - projectId, "프로젝트 제목", "프로젝트 설명", - "공지 텍스트", "정기회의 텍스트", - LocalDate.of(2026, 1, 1), LocalDate.of(2026, 2, 1), - 10L, true - ), - new MissionProgressResDto( - new MissionProgressResDto.TotalDto(12L, 7L, 0.58), - List.of() - ), - new MemberBoardResDto(List.of()), - new ScheduleUpcomingResDto(List.of()), - new SharedDocumentsPreviewResDto(List.of()), - new PostListResDto( - List.of(), - new PostListResDto.PageInfo( - 0, // page - 0, // size - 0L, // total_elements - 0, // total_pages - false // has_next - ) - ), + basicInfo, + missionProgress, + members, + upcomingSchedules, + sharedDocs, + postsPreview, indicators ); + given(boardsOverviewFacade.getOverview( eq(projectId), eq(userId), eq(2026), eq(1), diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsScheduleControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsScheduleControllerTest.java index 6343da4a..acec25fe 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsScheduleControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsScheduleControllerTest.java @@ -179,24 +179,26 @@ void getUpcoming() throws Exception { String from = "2026-01-31"; int limit = 6; - // TODO: ScheduleUpcomingResDto 실제 JSON 구조에 맞춰 bodyJson 수정 - String bodyJson = """ - { - "from": "2026-01-31", - "limit": 6, - "schedules": [ - { - "schedule_id": 1, - "title": "회의", - "start_at": "2026-02-01T10:00:00", - "end_at": "2026-02-01T11:00:00" - } - ] - } - """; - - ScheduleUpcomingResDto response = - objectMapper.readValue(bodyJson, ScheduleUpcomingResDto.class); + ScheduleUpcomingResDto response = new ScheduleUpcomingResDto( + List.of( + new ScheduleUpcomingResDto.Item( + 1L, + "회의", + LocalDateTime.of(2026, 2, 1, 10, 0, 0), + LocalDateTime.of(2026, 2, 1, 11, 0, 0), + false, + false + ), + new ScheduleUpcomingResDto.Item( + 2L, + "해커톤(멀티데이)", + LocalDateTime.of(2026, 2, 3, 0, 0, 0), + LocalDateTime.of(2026, 2, 5, 23, 59, 59), + true, + true + ) + ) + ); given(facade.getUpcoming(eq(projectId), eq(userId), eq(from), eq(limit))).willReturn(response); @@ -230,8 +232,18 @@ void getUpcoming() throws Exception { fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), fieldWithPath("status.message").type(STRING).description("메시지"), fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), - subsectionWithPath("body").type(OBJECT).description("응답 바디 (다가오는 일정 리스트)") + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.items").type(ARRAY).description("다가오는 일정 목록"), + + fieldWithPath("body.items[].schedule_id").type(NUMBER).description("일정 ID"), + fieldWithPath("body.items[].title").type(STRING).description("일정 제목"), + fieldWithPath("body.items[].start_at").type(STRING).description("시작 시각(yyyy-MM-dd'T'HH:mm:ss)"), + fieldWithPath("body.items[].end_at").type(STRING).description("종료 시각(yyyy-MM-dd'T'HH:mm:ss)"), + fieldWithPath("body.items[].all_day").type(BOOLEAN).description("종일 일정 여부"), + fieldWithPath("body.items[].is_multi_day").type(BOOLEAN).description("멀티데이 일정 여부(2일 이상)") ) + .build() ) )); diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java index 1c4d7762..99b094e8 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java @@ -216,8 +216,72 @@ void getSharedDocuments() throws Exception { DocumentType type = DocumentType.FILE; SharedDocumentsSort sort = SharedDocumentsSort.RECENT; - // SharedDocumentsGetResDto 구조를 여기서 정확히 모르므로 mock으로 대체 (body는 {} 로 직렬화) - SharedDocumentsGetResDto response = org.mockito.Mockito.mock(SharedDocumentsGetResDto.class); + SharedDocumentsGetResDto response = new SharedDocumentsGetResDto( + page, + size, + 3L, + 1, + List.of( + new SharedDocumentsGetResDto.DocumentDto( + 2001L, + true, + DocumentType.FILE, + "API 명세서", + "api-spec.pdf", + FileExt.PDF, + "https://s3.amazonaws.com/nect/docs/api-spec.pdf", + null, + 1024L, + LocalDateTime.of(2026, 1, 30, 12, 0, 0), // created_at + new SharedDocumentsGetResDto.UploaderDto( + 1L, + "홍길동", + "길동", + "https://img.com/u1.png" + ) + ), + + // 2) FILE 문서 + new SharedDocumentsGetResDto.DocumentDto( + 2002L, + false, + DocumentType.FILE, + "ERD v2", + "erd.png", + FileExt.PNG, + "https://s3.amazonaws.com/nect/docs/erd.png", + null, + 2048L, + LocalDateTime.of(2026, 1, 29, 18, 30, 0), + new SharedDocumentsGetResDto.UploaderDto( + 2L, + "김철수", + "철수", + "https://img.com/u2.png" + ) + ), + + // 3) LINK 문서 + new SharedDocumentsGetResDto.DocumentDto( + 3001L, + false, + DocumentType.LINK, + "Backend Repo", + null, + null, + null, + "https://github.com/nect/nect-backend", + null, + LocalDateTime.of(2026, 1, 28, 9, 0, 0), + new SharedDocumentsGetResDto.UploaderDto( + 3L, + "박영희", + "영희", + null + ) + ) + ) + ); given(facade.getDocuments(eq(projectId), eq(userId), eq(page), eq(size), eq(type), eq(sort))) .willReturn(response); @@ -262,8 +326,28 @@ void getSharedDocuments() throws Exception { fieldWithPath("body.size").type(NUMBER).description("페이지 크기"), fieldWithPath("body.total_elements").type(NUMBER).description("전체 요소 수"), fieldWithPath("body.total_pages").type(NUMBER).description("전체 페이지 수"), - fieldWithPath("body.documents").type(ARRAY).description("문서 목록") + fieldWithPath("body.documents").type(ARRAY).description("문서 목록"), + + fieldWithPath("body.documents[].document_id").type(NUMBER).description("문서 ID"), + fieldWithPath("body.documents[].is_pinned").type(BOOLEAN).description("상단 고정 여부"), + fieldWithPath("body.documents[].document_type").type(STRING).description("문서 타입(FILE/LINK)"), + fieldWithPath("body.documents[].title").type(STRING).description("문서 제목"), + + fieldWithPath("body.documents[].file_name").optional().type(STRING).description("파일명 (FILE일 때만, LINK면 null)"), + fieldWithPath("body.documents[].file_ext").optional().type(STRING).description("파일 확장자 (FILE일 때만, LINK면 null)"), + fieldWithPath("body.documents[].file_url").optional().type(STRING).description("파일 URL (FILE일 때만, LINK면 null)"), + fieldWithPath("body.documents[].link_url").optional().type(STRING).description("링크 URL (LINK일 때만, FILE면 null)"), + fieldWithPath("body.documents[].file_size").optional().type(NUMBER).description("파일 크기(byte) (FILE일 때만, LINK면 null)"), + + fieldWithPath("body.documents[].created_at").type(STRING).description("생성 시각(yyyy-MM-dd'T'HH:mm:ss)"), + + fieldWithPath("body.documents[].uploader").type(OBJECT).description("업로더 정보"), + fieldWithPath("body.documents[].uploader.user_id").type(NUMBER).description("업로더 유저 ID"), + fieldWithPath("body.documents[].uploader.name").type(STRING).description("업로더 이름"), + fieldWithPath("body.documents[].uploader.nickname").type(STRING).description("업로더 닉네임"), + fieldWithPath("body.documents[].uploader.profile_image_url").optional().type(STRING).description("업로더 프로필 이미지 URL (null 가능)") ) + .build() ) )); diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostAttachmentControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostAttachmentControllerTest.java index fc7b15ce..52b86aa4 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostAttachmentControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostAttachmentControllerTest.java @@ -130,7 +130,7 @@ void uploadAndAttachFile() throws Exception { preprocessResponse(prettyPrint()), resource( ResourceSnippetParameters.builder() - .tag("PostAttachment") + .tag("Post") .summary("게시글 파일 업로드 + 첨부") .description("게시글에 파일을 업로드하고 즉시 첨부합니다.") .pathParameters( @@ -205,7 +205,7 @@ void createAndAttachLink() throws Exception { preprocessResponse(prettyPrint()), resource( ResourceSnippetParameters.builder() - .tag("PostAttachment") + .tag("Post") .summary("게시글 링크 생성 + 첨부") .description("게시글에 링크(SharedDocument)를 생성하고 즉시 첨부합니다.") .pathParameters( @@ -264,7 +264,7 @@ void detach() throws Exception { preprocessResponse(prettyPrint()), resource( ResourceSnippetParameters.builder() - .tag("PostAttachment") + .tag("Post") .summary("게시글 첨부 해제") .description("게시글에 첨부된 문서(파일/링크)를 해제합니다.") .pathParameters( diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java index c30592bd..b06621fd 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java @@ -39,7 +39,6 @@ import static com.epages.restdocs.apispec.ResourceDocumentation.resource; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.verify; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; @@ -420,25 +419,18 @@ void getPostsPreview() throws Exception { long projectId = 1L; long userId = 1L; - PostsPreviewResDto response = new PostsPreviewResDto( + PostsPreviewResDto postsPreview = new PostsPreviewResDto( List.of( - new PostsPreviewResDto.Item( - 100L, - PostType.NOTICE, - "공지 제목", - LocalDateTime.of(2026, 1, 31, 10, 0) - ), - new PostsPreviewResDto.Item( - 101L, - PostType.FREE, - "자유글", - LocalDateTime.of(2026, 1, 31, 11, 0) - ) + new PostsPreviewResDto.Item(1001L, PostType.NOTICE, "공지 1", LocalDateTime.of(2026, 1, 31, 10, 0)), + new PostsPreviewResDto.Item(1002L, PostType.NOTICE, "공지 2", LocalDateTime.of(2026, 1, 30, 9, 0)), + new PostsPreviewResDto.Item(2001L, PostType.FREE, "자유 1", LocalDateTime.of(2026, 1, 31, 11, 0)), + new PostsPreviewResDto.Item(2002L, PostType.FREE, "자유 2", LocalDateTime.of(2026, 1, 29, 18, 0)) ) ); - given(postFacade.getPostsPreview(eq(projectId), eq(userId), any(), eq(4))) - .willReturn(response); + + given(postFacade.getPostsPreview(eq(projectId), eq(userId))) + .willReturn(postsPreview); mockMvc.perform(get("/api/v1/projects/{projectId}/boards/posts/preview", projectId) .with(mockUser(userId)) @@ -482,7 +474,7 @@ void getPostsPreview() throws Exception { ) )); - verify(postFacade).getPostsPreview(eq(projectId), eq(userId), any(), eq(4)); + verify(postFacade).getPostsPreview(eq(projectId), eq(userId)); } } From 98bf6920fc859432a1ed27828a31dac229b61eae Mon Sep 17 00:00:00 2001 From: infiniment Date: Mon, 9 Feb 2026 11:19:43 +0900 Subject: [PATCH 60/66] =?UTF-8?q?[Refactor]=20=ED=94=BC=EB=93=9C=EB=B0=B1?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9C=84=ED=81=AC=EB=AF=B8=EC=85=98=20TASK=20&=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=ED=98=95=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20reo?= =?UTF-8?q?rder=20API=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WeekMissionController.java | 18 ++- .../dto/req/ProcessFeedbackUpdateReqDto.java | 6 +- .../dto/req/ProcessTaskItemReorderReqDto.java | 9 +- ...WeekMissionTaskItemGroupReorderReqDto.java | 17 +++ .../process/dto/res/ProcessCardResDto.java | 3 + .../service/ProcessFeedbackService.java | 55 +++++++-- .../team/process/service/ProcessService.java | 58 +++++++-- .../service/ProcessTaskItemService.java | 109 ----------------- .../process/service/WeekMissionService.java | 114 ++++++++++++++++++ .../controller/ProcessControllerTest.java | 41 ++++--- .../ProcessFeedbackControllerTest.java | 23 ++-- .../ProcessTaskItemControllerTest.java | 4 +- .../controller/WeekMissionControllerTest.java | 83 ++++++++++++- .../entity/team/process/ProcessFeedback.java | 5 + .../process/ProcessFeedbackRepository.java | 26 ++++ .../process/ProcessTaskItemRepository.java | 20 +++ 16 files changed, 418 insertions(+), 173 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemGroupReorderReqDto.java diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java index a78088ee..f84cd54d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/controller/WeekMissionController.java @@ -1,11 +1,9 @@ package com.nect.api.domain.team.process.controller; import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; +import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemGroupReorderReqDto; import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; -import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; -import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; -import com.nect.api.domain.team.process.dto.res.WeekMissionDropdownResDto; -import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; +import com.nect.api.domain.team.process.dto.res.*; import com.nect.api.domain.team.process.service.WeekMissionService; import com.nect.api.global.response.ApiResponse; import com.nect.api.global.security.UserDetailsImpl; @@ -85,4 +83,16 @@ public ApiResponse readMissionDropdown( ); } + // 위크미션 TASK 파트별 항목 순서 변경(리더형) + @PatchMapping("/{processId}/task-items/reorder") + public ApiResponse reorderWeekMissionTaskItems( + @PathVariable Long projectId, + @PathVariable Long processId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody WeekMissionTaskItemGroupReorderReqDto req + ) { + return ApiResponse.ok( + weekMissionService.reorderTaskItemsByGroup(projectId, userDetails.getUserId(), processId, req) + ); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessFeedbackUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessFeedbackUpdateReqDto.java index 6e75798f..103551f8 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessFeedbackUpdateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessFeedbackUpdateReqDto.java @@ -1,8 +1,12 @@ package com.nect.api.domain.team.process.dto.req; import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.process.enums.ProcessFeedbackStatus; public record ProcessFeedbackUpdateReqDto( @JsonProperty("content") - String content + String content, + + @JsonProperty("feedback_status") + ProcessFeedbackStatus status ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java index aec05c72..f9a03d57 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessTaskItemReorderReqDto.java @@ -7,12 +7,5 @@ public record ProcessTaskItemReorderReqDto( @JsonProperty("ordered_task_item_ids") - List orderedTaskItemIds, - - @JsonProperty("role_field") - RoleField roleField, - - @JsonProperty("custom_role_field_name") - String customRoleFieldName - + List orderedTaskItemIds ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemGroupReorderReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemGroupReorderReqDto.java new file mode 100644 index 00000000..652a2ee4 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/WeekMissionTaskItemGroupReorderReqDto.java @@ -0,0 +1,17 @@ +package com.nect.api.domain.team.process.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.user.enums.RoleField; + +import java.util.List; + +public record WeekMissionTaskItemGroupReorderReqDto( + @JsonProperty("role_field") + RoleField roleField, + + @JsonProperty("custom_role_field_name") + String customRoleFieldName, + + @JsonProperty("ordered_task_item_ids") + List orderedTaskItemIds +) {} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java index 7b1ccdc1..b3471504 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java @@ -40,6 +40,9 @@ public record ProcessCardResDto( @JsonProperty("mission_number") Integer missionNumber, + @JsonProperty("has_open_feedback") + boolean hasOpenFeedback, + @JsonProperty("assignee") List assignee ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java index 1afd0b6c..e64d3aa5 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessFeedbackService.java @@ -30,7 +30,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationAdapter; import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.*; @@ -218,23 +217,58 @@ public ProcessFeedbackCreateResDto createFeedback(Long projectId, Long userId, L public ProcessFeedbackUpdateResDto updateFeedback(Long projectId, Long userId, Long processId, Long feedbackId, ProcessFeedbackUpdateReqDto req) { assertWritableMember(projectId, userId); - validateContent(req.content()); + if (req == null) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "request is null"); + } - // 부모 프로세스가 살아있는지 + 프로젝트 소속인지 한 번에 검증 + boolean hasContent = (req.content() != null); + boolean hasStatus = (req.status() != null); + + if (!hasContent && !hasStatus) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "content or status is required"); + } + + // 부모 프로세스 검증 getActiveProcess(projectId, processId); ProcessFeedback feedback = getFeedback(processId, feedbackId); String beforeContent = feedback.getContent(); - feedback.updateContent(req.content()); - String afterContent = feedback.getContent(); + var beforeStatus = feedback.getStatus(); + + boolean changed = false; + + // content 변경(있을 때만) + if (hasContent) { + validateContent(req.content()); // null/blank 방지 + String after = req.content().trim(); + if (!Objects.equals(beforeContent, after)) { + feedback.updateContent(after); + changed = true; + } + } - if (!Objects.equals(beforeContent, afterContent)) { + // status 변경(있을 때만) + if (hasStatus) { + if (beforeStatus != req.status()) { + feedback.updateStatus(req.status()); + changed = true; + } + } + + // 변경 없으면 그대로 응답 + if (changed) { Map meta = new LinkedHashMap<>(); meta.put("processId", processId); meta.put("feedbackId", feedbackId); - meta.put("before", Map.of("content", beforeContent)); - meta.put("after", Map.of("content", afterContent)); + meta.put("before", Map.of( + "content", beforeContent, + "status", beforeStatus == null ? null : beforeStatus.name() + )); + meta.put("after", Map.of( + "content", feedback.getContent(), + "status", feedback.getStatus() == null ? null : feedback.getStatus().name() + )); historyPublisher.publish( projectId, @@ -246,7 +280,10 @@ public ProcessFeedbackUpdateResDto updateFeedback(Long projectId, Long userId, L ); } - // createdBy 응답 채우기 (User + ProjectUser fieldIds) + return toFeedbackUpdateRes(projectId, feedback); + } + + private ProcessFeedbackUpdateResDto toFeedbackUpdateRes(Long projectId, ProcessFeedback feedback) { User createdBy = feedback.getCreatedBy(); Long createdByUserId = (createdBy != null) ? createdBy.getUserId() : null; String createdByUserName = (createdBy != null) ? createdBy.getName() : null; diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index 54b1a269..cbb179c9 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -22,6 +22,7 @@ import com.nect.core.entity.team.process.*; import com.nect.core.entity.team.process.Process; import com.nect.core.entity.team.process.enums.AssignmentRole; +import com.nect.core.entity.team.process.enums.ProcessFeedbackStatus; import com.nect.core.entity.team.process.enums.ProcessStatus; import com.nect.core.entity.team.process.enums.ProcessType; import com.nect.core.entity.user.User; @@ -30,6 +31,7 @@ import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.SharedDocumentRepository; +import com.nect.core.repository.team.process.ProcessFeedbackRepository; import com.nect.core.repository.team.process.ProcessLaneOrderRepository; import com.nect.core.repository.team.process.ProcessMentionRepository; import com.nect.core.repository.team.process.ProcessRepository; @@ -45,7 +47,6 @@ import java.time.temporal.TemporalAdjusters; import java.util.*; import java.util.stream.Collectors; -import java.util.stream.Stream; @Service @RequiredArgsConstructor @@ -58,6 +59,7 @@ public class ProcessService { private final UserRepository userRepository; private final ProcessLaneOrderRepository processLaneOrderRepository; private final ProjectTeamRoleRepository projectTeamRoleRepository; + private final ProcessFeedbackRepository processFeedbackRepository; private final S3Service s3Service; private final ProcessLaneOrderService processLaneOrderService; @@ -1418,7 +1420,7 @@ private LocalDate normalizeWeekStart(LocalDate date) { return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); } - private ProcessCardResDto toProcessCardResDTO(Process p) { + private ProcessCardResDto toProcessCardResDTO(Process p, boolean hasOpenFeedback) { int whole = (p.getTaskItems() == null) ? 0 : p.getTaskItems().size(); int done = (p.getTaskItems() == null) ? 0 : (int) p.getTaskItems().stream() .filter(ProcessTaskItem::isDone) @@ -1469,6 +1471,7 @@ private ProcessCardResDto toProcessCardResDTO(Process p) { roleFields, customFields, missionNumber, + hasOpenFeedback, assignees ); } @@ -1598,6 +1601,21 @@ public ProcessWeeksResDto getWeekProcesses(Long projectId, Long userId, LocalDat LocalDate rangeEnd = rangeStart.plusDays((long) weeks * 7 - 1); List processes = processRepository.findAllInRangeOrdered(projectId, rangeStart, rangeEnd); + if (processes == null) processes = List.of(); + + Set openFeedbackProcessIds = new HashSet<>(); + if (!processes.isEmpty()) { + List processIds = processes.stream() + .map(Process::getId) + .filter(Objects::nonNull) + .toList(); + + if (!processIds.isEmpty()) { + openFeedbackProcessIds.addAll( + processFeedbackRepository.findOpenFeedbackProcessIds(processIds) + ); + } + } // 프로세스를 주차별로 묶기 // startAt이 null이면 rangeStart 주로 보내거나, common 처리 가능 @@ -1622,9 +1640,14 @@ public ProcessWeeksResDto getWeekProcesses(Long projectId, Long userId, LocalDat List weekDtos = byWeek.entrySet().stream() .map(entry -> { LocalDate weekStartKey = entry.getKey(); + List cards = entry.getValue().stream() - .map(this::toProcessCardResDTO) + .map(p -> { + boolean hasOpenFeedback = openFeedbackProcessIds.contains(p.getId()); + return toProcessCardResDTO(p, hasOpenFeedback); + }) .toList(); + return buildWeekDto(weekStartKey, cards); }) .toList(); @@ -1723,12 +1746,25 @@ public ProcessPartResDto getPartProcesses(Long projectId, Long userId, String la throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "invalid lane_key prefix. laneKey=" + laneKey); } + Set openFeedbackProcessIds = Collections.emptySet(); + if (laneProcesses != null && !laneProcesses.isEmpty()) { + List processIds = laneProcesses.stream() + .map(Process::getId) + .filter(Objects::nonNull) + .toList(); + + if (!processIds.isEmpty()) { + openFeedbackProcessIds = new HashSet<>( + processFeedbackRepository.findOpenFeedbackProcessIds(processIds) + ); + } + } List groups = List.of( - buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.PLANNING, laneProcesses), - buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.IN_PROGRESS, laneProcesses), - buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.DONE, laneProcesses), - buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.BACKLOG, laneProcesses) + buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.PLANNING, laneProcesses, openFeedbackProcessIds), + buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.IN_PROGRESS, laneProcesses, openFeedbackProcessIds), + buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.DONE, laneProcesses, openFeedbackProcessIds), + buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.BACKLOG, laneProcesses, openFeedbackProcessIds) ); return new ProcessPartResDto(toApiLaneKey(dbLaneKey), groups); @@ -1738,7 +1774,8 @@ private ProcessStatusGroupResDto buildStatusGroupOrdered( Long projectId, String laneKey, ProcessStatus status, - List laneProcessesAll + List laneProcessesAll, + Set openFeedbackProcessIds ) { // 해당 status인 프로세스만 List laneProcesses = laneProcessesAll.stream() @@ -1761,7 +1798,10 @@ private ProcessStatusGroupResDto buildStatusGroupOrdered( List cards = new ArrayList<>(); for (Long id : orderedIds) { Process p = map.get(id); - if (p != null) cards.add(toProcessCardResDTO(p)); + if (p != null) { + boolean hasOpenFeedback = openFeedbackProcessIds.contains(p.getId()); + cards.add(toProcessCardResDTO(p, hasOpenFeedback)); + } } return new ProcessStatusGroupResDto(status, cards.size(), cards); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java index 01c928d6..2308d08d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java @@ -381,115 +381,6 @@ public ProcessTaskItemReorderResDto reorder(Long projectId, Long userId, Long pr throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ordered_task_item_ids contains duplicates"); } - // 위크미션 TASK내에 필드별 항목 리스트 / 전체(멤버형) - RoleField roleField = req.roleField(); - String customName = req.customRoleFieldName(); - - boolean groupMode = (roleField != null); - - if (groupMode) { - // 분야별 모드 유효성 검사 - if (roleField == RoleField.CUSTOM) { - if (customName == null || customName.isBlank()) { - throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "CUSTOM이면 custom_role_field_name 필수"); - } - customName = customName.trim(); - } else { - // CUSTOM이 아니면 null로 고정 - customName = null; - } - - // 분야별 정규화(꼬임 방지) - normalizeSortOrderByGroup(processId, roleField, customName); - - // beforeIds (분야별) - List groupAll = taskItemRepository - .findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameOrderBySortOrderAsc( - processId, roleField, customName - ); - - List beforeIds = groupAll.stream().map(ProcessTaskItem::getId).toList(); - - // 변경 없으면 그대로 반환 - if (beforeIds.equals(orderedIds)) { - List resItems = groupAll.stream() - .map(t -> new ProcessTaskItemResDto(t.getId(), t.getContent(), t.isDone(), t.getSortOrder(), t.getDoneAt())) - .toList(); - return new ProcessTaskItemReorderResDto(processId, resItems); - } - - // 전체 포함 정책(그룹 단위) - if (groupAll.size() != orderedIds.size()) { - throw new ProcessException( - ProcessErrorCode.INVALID_REQUEST, - "ordered_task_item_ids must include all task items of the group" - ); - } - - // 요청 ids가 모두 해당 그룹의 항목인지 검증 - List targets = - taskItemRepository.findAllByProcessIdAndDeletedAtIsNullAndRoleFieldAndCustomRoleFieldNameAndIdIn( - processId, roleField, customName, orderedIds - ); - - if (targets.size() != orderedIds.size()) { - throw new ProcessException( - ProcessErrorCode.INVALID_REQUEST, - "ordered_task_item_ids contains invalid taskItemId(s) for the group" - ); - } - - Map map = targets.stream() - .collect(Collectors.toMap(ProcessTaskItem::getId, t -> t)); - - // 재정렬 반영(분야별 그룹 내부 0..n-1) - int i = 0; - for (Long id : orderedIds) { - ProcessTaskItem item = map.get(id); - item.updateSortOrder(i++); - } - - Map meta = new LinkedHashMap<>(); - meta.put("processId", processId); - meta.put("processType", process.getProcessType() == null ? null : process.getProcessType().name()); - meta.put("missionNumber", process.getMissionNumber()); - meta.put("title", process.getTitle()); - - meta.put("groupMode", true); - meta.put("roleField", roleField.name()); - meta.put("customRoleFieldName", customName); - - meta.put("beforeOrderedTaskItemIds", beforeIds); - meta.put("afterOrderedTaskItemIds", orderedIds); - - historyPublisher.publish( - projectId, - userId, - HistoryAction.TASK_ITEM_REORDERED, - HistoryTargetType.PROCESS, - processId, - meta - ); - - notifyWorkspaceWeekMissionUpdated(process, userId); - - // 응답(요청 순서대로) - List resItems = orderedIds.stream() - .map(id -> { - ProcessTaskItem t = map.get(id); - return new ProcessTaskItemResDto( - t.getId(), t.getContent(), t.isDone(), t.getSortOrder(), t.getDoneAt() - ); - }) - .toList(); - - return new ProcessTaskItemReorderResDto(processId, resItems); - } - - /* - * 멤버형 프로세스 모달 전용 - * */ - // 꼬임 방지용 정규화 normalizeSortOrder(processId); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java index ab87182e..a4efdb06 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -4,6 +4,7 @@ import com.nect.api.domain.notifications.facade.NotificationFacade; import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; +import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemGroupReorderReqDto; import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; import com.nect.api.domain.team.process.dto.res.*; import com.nect.api.domain.team.process.enums.AttachmentType; @@ -579,4 +580,117 @@ private LocalDate resolveWeekStart(LocalDate requested, LocalDate fallbackBaseDa LocalDate base = (requested != null) ? requested : fallbackBaseDate; return toMonday(base); } + + + @Transactional + public ProcessTaskItemReorderResDto reorderTaskItemsByGroup( + Long projectId, Long userId, Long processId, WeekMissionTaskItemGroupReorderReqDto req + ) { + assertActiveLeader(projectId, userId); + + if (req == null) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "request is null"); + } + + List orderedIds = validateOrderedIds(req.orderedTaskItemIds()); + + RoleField roleField = req.roleField(); + if (roleField == null) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "role_field is required"); + } + String customName = normalizeCustom(roleField, req.customRoleFieldName()); // CUSTOM이면 trim + 필수검사, 아니면 null + + // 위크미션 존재 검증 (프로젝트 + 프로세스) + Process process = processRepository.findWeekMissionDetail(projectId, processId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.PROCESS_NOT_FOUND)); + + // (꼬임 방지) 해당 그룹 현재 sort_order 정규화 + normalizeGroupOrders(processId, roleField, customName); + + // 그룹 전체 항목 조회 (정렬된 상태) + List groupAll = processTaskItemRepository + .findWeekMissionGroupItemsOrdered(processId, roleField, customName); + + List beforeIds = groupAll.stream().map(ProcessTaskItem::getId).toList(); + + // 변경 없으면 그대로 반환 + if (beforeIds.equals(orderedIds)) { + List resItems = groupAll.stream() + .map(t -> new ProcessTaskItemResDto(t.getId(), t.getContent(), t.isDone(), t.getSortOrder(), t.getDoneAt())) + .toList(); + return new ProcessTaskItemReorderResDto(processId, resItems); + } + + // 그룹 전체 포함 정책 + if (groupAll.size() != orderedIds.size()) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "ordered_task_item_ids must include all task items of the group" + ); + } + + // 요청 ids가 모두 해당 그룹에 속하는지 검증 + List targets = processTaskItemRepository + .findWeekMissionGroupItemsByIds(processId, roleField, customName, orderedIds); + + if (targets.size() != orderedIds.size()) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "ordered_task_item_ids contains invalid taskItemId(s) for the group" + ); + } + + Map map = targets.stream() + .collect(Collectors.toMap(ProcessTaskItem::getId, t -> t)); + + // reorder 반영 (0..n-1) + int i = 0; + for (Long id : orderedIds) { + map.get(id).updateSortOrder(i++); + } + + // 히스토리 + 알림 + User actor = userRepository.findById(userId) + .orElseThrow(() -> new ProcessException(ProcessErrorCode.USER_NOT_FOUND, "userId=" + userId)); + Project project = process.getProject(); + + notifyWorkspaceWeekMissionUpdated(project, actor, process); + + Map meta = new LinkedHashMap<>(); + meta.put("processId", processId); + meta.put("processType", "WEEK_MISSION"); + meta.put("missionNumber", process.getMissionNumber()); + meta.put("title", process.getTitle()); + meta.put("groupMode", true); + meta.put("roleField", roleField.name()); + meta.put("customRoleFieldName", customName); + meta.put("beforeOrderedTaskItemIds", beforeIds); + meta.put("afterOrderedTaskItemIds", orderedIds); + + publishWeekMissionHistory(projectId, userId, processId, HistoryAction.TASK_ITEM_REORDERED, meta); + + // 응답(요청 순서대로) + List resItems = orderedIds.stream() + .map(id -> { + ProcessTaskItem t = map.get(id); + return new ProcessTaskItemResDto(t.getId(), t.getContent(), t.isDone(), t.getSortOrder(), t.getDoneAt()); + }) + .toList(); + + return new ProcessTaskItemReorderResDto(processId, resItems); + } + + private List validateOrderedIds(List raw) { + if (raw == null || raw.isEmpty()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ordered_task_item_ids is empty"); + } + List ids = raw.stream().filter(Objects::nonNull).toList(); + if (ids.isEmpty()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ordered_task_item_ids is empty"); + } + if (new HashSet<>(ids).size() != ids.size()) { + throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "ordered_task_item_ids contains duplicates"); + } + return ids; + } } \ No newline at end of file diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java index 4dc53e4e..6ec1f3b7 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java @@ -597,7 +597,6 @@ void getWeekProcesses() throws Exception { long projectId = 1L; long userId = 1L; - // ===== week1 common lane ===== ProcessCardResDto common1 = new ProcessCardResDto( 101L, ProcessStatus.PLANNING, @@ -610,6 +609,7 @@ void getWeekProcesses() throws Exception { List.of(RoleField.BACKEND, RoleField.FRONTEND), List.of("영상편집"), 1, + true, List.of( new AssigneeResDto(1L, "홍길동", "길동", "https://img.com/u1.png"), new AssigneeResDto(2L, "김철수", "철수", null) @@ -628,6 +628,7 @@ void getWeekProcesses() throws Exception { List.of(RoleField.BACKEND), List.of(), 1, + true, List.of( new AssigneeResDto(3L, "박영희", "영희", "https://img.com/u3.png") ) @@ -645,6 +646,7 @@ void getWeekProcesses() throws Exception { List.of(RoleField.BACKEND), List.of(), 1, + true, List.of( new AssigneeResDto(1L, "홍길동", "길동", "https://img.com/u1.png") ) @@ -662,6 +664,7 @@ void getWeekProcesses() throws Exception { List.of(RoleField.PHOTO_VIDEO), List.of("영상편집"), 1, + false, List.of( new AssigneeResDto(4L, "이민수", "민수", null) ) @@ -699,6 +702,7 @@ void getWeekProcesses() throws Exception { List.of(RoleField.APP_WEB, RoleField.OPERATIONS_CS), List.of("운영"), 2, + false, List.of( new AssigneeResDto(2L, "김철수", "철수", "https://img.com/u2.png") ) @@ -758,44 +762,43 @@ void getWeekProcesses() throws Exception { fieldWithPath("body.weeks[].start_date").type(STRING).description("주 시작일(yyyy-MM-dd)"), - // ===== common_lane ===== fieldWithPath("body.weeks[].common_lane").type(ARRAY).description("공통 레인 프로세스 카드 목록"), fieldWithPath("body.weeks[].common_lane[].process_id").type(NUMBER).description("프로세스 ID"), fieldWithPath("body.weeks[].common_lane[].process_status").type(STRING).description("프로세스 상태"), fieldWithPath("body.weeks[].common_lane[].title").type(STRING).description("프로세스 제목"), fieldWithPath("body.weeks[].common_lane[].complete_check_list").type(NUMBER).description("완료 체크리스트 개수"), fieldWithPath("body.weeks[].common_lane[].whole_check_list").type(NUMBER).description("전체 체크리스트 개수"), - fieldWithPath("body.weeks[].common_lane[].start_date").type(STRING).description("시작일(yyyy-MM-dd)"), - fieldWithPath("body.weeks[].common_lane[].dead_line").type(STRING).description("마감일(yyyy-MM-dd)"), - fieldWithPath("body.weeks[].common_lane[].left_day").type(NUMBER).description("남은 일수"), + fieldWithPath("body.weeks[].common_lane[].start_date").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), + fieldWithPath("body.weeks[].common_lane[].dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)"), + fieldWithPath("body.weeks[].common_lane[].left_day").optional().type(NUMBER).description("남은 일수(null 가능)"), fieldWithPath("body.weeks[].common_lane[].role_fields").type(ARRAY).description("역할 분야(RoleField enum) 목록"), fieldWithPath("body.weeks[].common_lane[].custom_fields").type(ARRAY).description("커스텀 분야명 목록"), - fieldWithPath("body.weeks[].common_lane[].mission_number").type(NUMBER).description("미션 번호"), + fieldWithPath("body.weeks[].common_lane[].mission_number").optional().type(NUMBER).description("미션 번호(null 가능)"), + fieldWithPath("body.weeks[].common_lane[].has_open_feedback").type(BOOLEAN).description("OPEN 피드백 존재 여부"), fieldWithPath("body.weeks[].common_lane[].assignee").type(ARRAY).description("담당자 목록"), fieldWithPath("body.weeks[].common_lane[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), fieldWithPath("body.weeks[].common_lane[].assignee[].user_name").type(STRING).description("담당자 이름"), fieldWithPath("body.weeks[].common_lane[].assignee[].nickname").type(STRING).description("담당자 닉네임"), fieldWithPath("body.weeks[].common_lane[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL"), - // ===== by_field ===== fieldWithPath("body.weeks[].by_field").type(ARRAY).description("분야별(Field) 그룹 목록"), fieldWithPath("body.weeks[].by_field[].field_id").type(STRING).description("fieldId (예: ROLE:BACKEND / CUSTOM:영상편집)"), fieldWithPath("body.weeks[].by_field[].field_name").type(STRING).description("fieldName (예: BACKEND / 영상편집)"), fieldWithPath("body.weeks[].by_field[].field_order").type(NUMBER).description("fieldOrder"), fieldWithPath("body.weeks[].by_field[].processes").type(ARRAY).description("해당 그룹의 프로세스 목록"), - // by_field[].processes[] (ProcessCardResDto 동일) fieldWithPath("body.weeks[].by_field[].processes[].process_id").type(NUMBER).description("프로세스 ID"), fieldWithPath("body.weeks[].by_field[].processes[].process_status").type(STRING).description("프로세스 상태"), fieldWithPath("body.weeks[].by_field[].processes[].title").type(STRING).description("프로세스 제목"), fieldWithPath("body.weeks[].by_field[].processes[].complete_check_list").type(NUMBER).description("완료 체크리스트 개수"), fieldWithPath("body.weeks[].by_field[].processes[].whole_check_list").type(NUMBER).description("전체 체크리스트 개수"), - fieldWithPath("body.weeks[].by_field[].processes[].start_date").type(STRING).description("시작일(yyyy-MM-dd)"), - fieldWithPath("body.weeks[].by_field[].processes[].dead_line").type(STRING).description("마감일(yyyy-MM-dd)"), - fieldWithPath("body.weeks[].by_field[].processes[].left_day").type(NUMBER).description("남은 일수"), + fieldWithPath("body.weeks[].by_field[].processes[].start_date").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), + fieldWithPath("body.weeks[].by_field[].processes[].dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)"), + fieldWithPath("body.weeks[].by_field[].processes[].left_day").optional().type(NUMBER).description("남은 일수(null 가능)"), fieldWithPath("body.weeks[].by_field[].processes[].role_fields").type(ARRAY).description("역할 분야(RoleField enum) 목록"), fieldWithPath("body.weeks[].by_field[].processes[].custom_fields").type(ARRAY).description("커스텀 분야명 목록"), - fieldWithPath("body.weeks[].by_field[].processes[].mission_number").type(NUMBER).description("미션 번호"), + fieldWithPath("body.weeks[].by_field[].processes[].mission_number").optional().type(NUMBER).description("미션 번호(null 가능)"), + fieldWithPath("body.weeks[].by_field[].processes[].has_open_feedback").type(BOOLEAN).description("OPEN 피드백 존재 여부"), fieldWithPath("body.weeks[].by_field[].processes[].assignee").type(ARRAY).description("담당자 목록"), fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_name").type(STRING).description("담당자 이름"), @@ -832,6 +835,7 @@ void getPartProcesses() throws Exception { List.of(RoleField.BACKEND), List.of("AI"), 1, + true, List.of(a1, a2) // assignee ); @@ -847,6 +851,7 @@ void getPartProcesses() throws Exception { List.of(RoleField.BACKEND, RoleField.FRONTEND), List.of("DevOps"), null, + true, List.of(a2) ); @@ -871,6 +876,7 @@ void getPartProcesses() throws Exception { List.of(RoleField.BACKEND), List.of(), null, + false, List.of(a1) ); @@ -893,6 +899,7 @@ void getPartProcesses() throws Exception { List.of(RoleField.BACKEND), List.of("Auth"), 1, + false, List.of(a1, a2) ); @@ -915,6 +922,7 @@ void getPartProcesses() throws Exception { List.of(RoleField.BACKEND), List.of("TechDebt"), 1, + false, List.of(a2) ); @@ -982,15 +990,16 @@ void getPartProcesses() throws Exception { fieldWithPath("body.groups[].processes[].role_fields").type(ARRAY).description("RoleField 목록"), fieldWithPath("body.groups[].processes[].custom_fields").type(ARRAY).description("커스텀 필드명 목록"), - fieldWithPath("body.groups[].processes[].mission_number").optional().type(VARIES).description("위크미션 번호(미션 프로세스면 1..n, 일반 프로세스면 null)"), - - fieldWithPath("body.groups[].processes[].assignee").type(ARRAY).description("담당자 목록"), + fieldWithPath("body.groups[].processes[].mission_number").optional().type(VARIES) + .description("위크미션 번호(미션 프로세스면 1..n, 일반 프로세스면 null)"), + fieldWithPath("body.groups[].processes[].has_open_feedback").type(BOOLEAN) + .description("OPEN 피드백 존재 여부"), fieldWithPath("body.groups[].processes[].assignee").type(ARRAY).description("담당자 목록"), fieldWithPath("body.groups[].processes[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), fieldWithPath("body.groups[].processes[].assignee[].user_name").type(STRING).description("담당자 이름"), fieldWithPath("body.groups[].processes[].assignee[].nickname").type(STRING).description("담당자 닉네임"), - fieldWithPath("body.groups[].processes[].assignee[].user_image").type(STRING).description("담당자 이미지 URL") + fieldWithPath("body.groups[].processes[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL") ) .build() ) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessFeedbackControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessFeedbackControllerTest.java index eb6d5d3d..ed147ca3 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessFeedbackControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessFeedbackControllerTest.java @@ -185,28 +185,29 @@ void createFeedback() throws Exception { } @Test - @DisplayName("피드백 수정") - void updateFeedback() throws Exception { + @DisplayName("피드백_수정_요청") + void updateFeedback_statusOnly() throws Exception { long projectId = 1L; long processId = 10L; long feedbackId = 100L; long userId = 1L; ProcessFeedbackUpdateReqDto request = new ProcessFeedbackUpdateReqDto( - "수정된 피드백 내용" + "수정된 피드백 내용", + ProcessFeedbackStatus.OPEN ); FeedbackCreatedByResDto createdBy = new FeedbackCreatedByResDto( userId, "임시유저", "패트", - List.of() + List.of("디자인") ); ProcessFeedbackUpdateResDto response = new ProcessFeedbackUpdateResDto( feedbackId, - "수정된 피드백 내용", - ProcessFeedbackStatus.OPEN, + "기존 피드백 내용", + ProcessFeedbackStatus.RESOLVED, createdBy, LocalDateTime.of(2026, 1, 25, 10, 0), LocalDateTime.of(2026, 1, 26, 11, 0) @@ -215,20 +216,21 @@ void updateFeedback() throws Exception { given(processFeedbackService.updateFeedback(eq(projectId), eq(userId), eq(processId), eq(feedbackId), any(ProcessFeedbackUpdateReqDto.class))) .willReturn(response); - mockMvc.perform(patch("/api/v1/projects/{projectId}/processes/{processId}/feedbacks/{feedbackId}", projectId, processId, feedbackId) + mockMvc.perform(patch("/api/v1/projects/{projectId}/processes/{processId}/feedbacks/{feedbackId}", + projectId, processId, feedbackId) .with(mockUser(userId)) .header(AUTH_HEADER, TEST_ACCESS_TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) - .andDo(document("process-feedback-update", + .andDo(document("process-feedback-update-status", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), resource( ResourceSnippetParameters.builder() .tag("Process-Feedback") .summary("피드백 수정") - .description("피드백 내용을 수정합니다.") + .description("피드백 내용과 상태(OPEN/RESOLVED)를 수정합니다.") .pathParameters( ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), ResourceDocumentation.parameterWithName("processId").description("프로세스 ID"), @@ -238,7 +240,8 @@ void updateFeedback() throws Exception { headerWithName(AUTH_HEADER).description("Bearer Access Token") ) .requestFields( - fieldWithPath("content").type(STRING).description("수정할 피드백 내용") + fieldWithPath("content").optional().type(STRING).description("수정할 피드백 내용"), + fieldWithPath("feedback_status").optional().type(STRING).description("피드백 상태(OPEN/RESOLVED)") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java index 690084b7..533d7cfa 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessTaskItemControllerTest.java @@ -302,9 +302,7 @@ void reorderTaskItems() throws Exception { long userId = 1L; ProcessTaskItemReorderReqDto request = new ProcessTaskItemReorderReqDto( - List.of(100L, 101L, 102L), - RoleField.BACKEND, - null + List.of(100L, 101L, 102L) ); ProcessTaskItemResDto i0 = new ProcessTaskItemResDto(100L, "A", false, 0, null); diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java index 658b1300..0899c49b 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/WeekMissionControllerTest.java @@ -4,17 +4,16 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.fasterxml.jackson.databind.ObjectMapper; import com.nect.api.domain.team.process.dto.req.WeekMissionStatusUpdateReqDto; +import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemGroupReorderReqDto; import com.nect.api.domain.team.process.dto.req.WeekMissionTaskItemUpdateReqDto; -import com.nect.api.domain.team.process.dto.res.ProcessTaskItemResDto; -import com.nect.api.domain.team.process.dto.res.WeekMissionDetailResDto; -import com.nect.api.domain.team.process.dto.res.WeekMissionDropdownResDto; -import com.nect.api.domain.team.process.dto.res.WeekMissionWeekResDto; +import com.nect.api.domain.team.process.dto.res.*; import com.nect.api.domain.team.process.service.WeekMissionService; import com.nect.api.global.jwt.JwtUtil; import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; import com.nect.api.global.security.UserDetailsServiceImpl; import com.nect.core.entity.team.process.enums.ProcessStatus; +import com.nect.core.entity.user.enums.RoleField; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -422,4 +421,80 @@ void readMissionDropdown() throws Exception { verify(weekMissionService).getMissionDropdown(eq(projectId), eq(userId)); } + + @Test + @DisplayName("위크미션 TASK 파트별 항목 순서 변경(리더형)") + void reorderWeekMissionTaskItems() throws Exception { + long projectId = 1L; + long processId = 1L; + long userId = 1L; + + WeekMissionTaskItemGroupReorderReqDto request = new WeekMissionTaskItemGroupReorderReqDto( + RoleField.BACKEND, + null, + List.of(6L, 4L, 5L) + ); + + ProcessTaskItemReorderResDto response = new ProcessTaskItemReorderResDto( + processId, + List.of( + new ProcessTaskItemResDto(6L, "API 설계 시작", false, 0, null), + new ProcessTaskItemResDto(4L, "서버 환경 설정", false, 1, null), + new ProcessTaskItemResDto(5L, "데이터베이스 초기화", false, 2, null) + ) + ); + + given(weekMissionService.reorderTaskItemsByGroup( + eq(projectId), eq(userId), eq(processId), any(WeekMissionTaskItemGroupReorderReqDto.class) + )).willReturn(response); + + mockMvc.perform(patch("/api/v1/projects/{projectId}/week-missions/{processId}/task-items/reorder", projectId, processId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("week-mission-taskitem-reorder-by-group", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Process") + .summary("위크미션 TASK 파트별 항목 순서 변경(리더형)") + .description("위크미션 프로세스의 특정 파트(ROLE_FIELD) 그룹 내부에서 TaskItem 순서를 재정렬합니다. ordered_task_item_ids에는 해당 그룹의 전체 TaskItem ID를 모두 포함해야 합니다.") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID"), + parameterWithName("processId").description("위크미션 프로세스 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("ordered_task_item_ids").type(ARRAY).description("재정렬할 TaskItem ID 목록(그룹 전체 포함, 요청 순서대로 0..n-1 부여)"), + fieldWithPath("role_field").type(STRING).description("파트(RoleField) (예: BACKEND/FRONTEND/...)"), + fieldWithPath("custom_role_field_name").optional().type(STRING).description("CUSTOM인 경우 커스텀 파트명, 그 외 null") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.process_id").type(NUMBER).description("프로세스 ID"), + fieldWithPath("body.ordered_task_items").type(ARRAY).description("요청 순서대로 정렬된 TaskItem 목록"), + fieldWithPath("body.ordered_task_items[].task_item_id").type(NUMBER).description("TaskItem ID"), + fieldWithPath("body.ordered_task_items[].content").type(STRING).description("내용"), + fieldWithPath("body.ordered_task_items[].is_done").type(BOOLEAN).description("완료 여부"), + fieldWithPath("body.ordered_task_items[].sort_order").type(NUMBER).description("정렬 순서(0..n-1)"), + fieldWithPath("body.ordered_task_items[].done_at").optional().type(STRING).description("완료일(yyyy-MM-dd, null 가능)") + ) + .build() + ) + )); + + verify(weekMissionService).reorderTaskItemsByGroup( + eq(projectId), eq(userId), eq(processId), any(WeekMissionTaskItemGroupReorderReqDto.class) + ); + } } diff --git a/nect-core/src/main/java/com/nect/core/entity/team/process/ProcessFeedback.java b/nect-core/src/main/java/com/nect/core/entity/team/process/ProcessFeedback.java index 619cb5b4..e43cf46d 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/process/ProcessFeedback.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/process/ProcessFeedback.java @@ -53,6 +53,11 @@ public void updateContent(String content) { this.content = content; } + public void updateStatus(ProcessFeedbackStatus status) { + if (status == null) return; + this.status = status; + } + void setProcess(Process process) { this.process = process; } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessFeedbackRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessFeedbackRepository.java index c7e2329b..363490ed 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessFeedbackRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessFeedbackRepository.java @@ -1,11 +1,37 @@ package com.nect.core.repository.team.process; import com.nect.core.entity.team.process.ProcessFeedback; +import com.nect.core.entity.team.process.enums.ProcessFeedbackStatus; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface ProcessFeedbackRepository extends JpaRepository { Optional findByIdAndProcessIdAndDeletedAtIsNull(Long feedbackId, Long processId); + + @Query(""" + select distinct f.process.id + from ProcessFeedback f + where f.deletedAt is null + and f.status = :status + and f.process.id in :processIds + """) + List findProcessIdsHavingStatusIn( + @Param("processIds") List processIds, + @Param("status") ProcessFeedbackStatus status + ); + + @Query(""" + select distinct f.process.id + from ProcessFeedback f + where f.deletedAt is null + and f.status = com.nect.core.entity.team.process.enums.ProcessFeedbackStatus.OPEN + and f.process.id in :processIds + """) + List findOpenFeedbackProcessIds(@Param("processIds") List processIds); + } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java index 18854e9b..471b8738 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessTaskItemRepository.java @@ -44,4 +44,24 @@ List findWeekMissionGroupItemsOrdered( @Param("roleField") RoleField roleField, @Param("customName") String customName ); + + // 요청 ids가 모두 해당 그룹인지 검증용 조회 + @Query(""" + select ti + from ProcessTaskItem ti + where ti.process.id = :processId + and ti.deletedAt is null + and ti.roleField = :roleField + and ( + (:customName is null and ti.customRoleFieldName is null) + or (:customName is not null and ti.customRoleFieldName = :customName) + ) + and ti.id in :ids + """) + List findWeekMissionGroupItemsByIds( + @Param("processId") Long processId, + @Param("roleField") RoleField roleField, + @Param("customName") String customName, + @Param("ids") List ids + ); } From 5d26688bcbee715c4dba98d71f4dd563dc70ea2c Mon Sep 17 00:00:00 2001 From: infiniment Date: Mon, 9 Feb 2026 11:46:04 +0900 Subject: [PATCH 61/66] =?UTF-8?q?[Refactor]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/dto/res/PostListResDto.java | 14 ++++++ .../team/workspace/service/PostService.java | 48 ++++++++----------- .../controller/PostControllerTest.java | 14 ++++++ 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostListResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostListResDto.java index 7d892523..213077f7 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostListResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/PostListResDto.java @@ -29,10 +29,24 @@ public record PostSummaryDto( @JsonProperty("like_count") Long likeCount, + @JsonProperty("author") + AuthorDto author, + @JsonProperty("created_at") LocalDateTime createdAt ) {} + public record AuthorDto( + @JsonProperty("user_id") + Long userId, + + @JsonProperty("user_name") + String userName, + + @JsonProperty("nickname") + String nickname + ) {} + public record PageInfo( @JsonProperty("page") int page, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java index 0dc27a47..410b1fff 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java @@ -278,14 +278,7 @@ public PostListResDto getPostList(Long projectId, Long userId, PostType type, in List notices = postRepository.findAllNotices(projectId, baseSort); List mapped = notices.stream() - .map(p -> new PostListResDto.PostSummaryDto( - p.getId(), - p.getPostType(), - p.getTitle(), - preview(p.getContent(), 100), - p.getLikeCount(), - p.getCreatedAt() - )) + .map(this::toSummary) .toList(); PostListResDto.PageInfo pageInfo = new PostListResDto.PageInfo( @@ -308,29 +301,11 @@ public PostListResDto getPostList(Long projectId, Long userId, PostType type, in // page==0 일 때만 공지 전부 상단에 붙이기 if (type == null && page == 0) { List notices = postRepository.findAllNotices(projectId, baseSort); - result.addAll(notices.stream() - .map(p -> new PostListResDto.PostSummaryDto( - p.getId(), - p.getPostType(), - p.getTitle(), - preview(p.getContent(), 100), - p.getLikeCount(), - p.getCreatedAt() - )) - .toList()); + result.addAll(notices.stream().map(this::toSummary).toList()); } // FREE 페이징 결과 붙이기 - result.addAll(freePage.getContent().stream() - .map(p -> new PostListResDto.PostSummaryDto( - p.getId(), - p.getPostType(), - p.getTitle(), - preview(p.getContent(), 100), - p.getLikeCount(), - p.getCreatedAt() - )) - .toList()); + result.addAll(freePage.getContent().stream().map(this::toSummary).toList()); // pageInfo는 FREE 기준으로만 계산 (공지는 제외) PostListResDto.PageInfo pageInfo = new PostListResDto.PageInfo( @@ -344,6 +319,23 @@ public PostListResDto getPostList(Long projectId, Long userId, PostType type, in return new PostListResDto(result, pageInfo); } + private PostListResDto.PostSummaryDto toSummary(Post p) { + var u = p.getAuthor(); + PostListResDto.AuthorDto authorDto = (u == null) + ? null + : new PostListResDto.AuthorDto(u.getUserId(), u.getName(), u.getNickname()); + + return new PostListResDto.PostSummaryDto( + p.getId(), + p.getPostType(), + p.getTitle(), + preview(p.getContent(), 100), + p.getLikeCount(), + authorDto, + p.getCreatedAt() + ); + } + // 게시글 수정 서비스 @Transactional public PostUpdateResDto updatePost(Long projectId, Long userId, Long postId, PostUpdateReqDto req) { diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java index b06621fd..e1767fae 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java @@ -270,6 +270,11 @@ void getPostList() throws Exception { long projectId = 1L; long userId = 1L; + PostListResDto.AuthorDto author1 = + new PostListResDto.AuthorDto(1L, "홍길동", "길동"); + PostListResDto.AuthorDto author2 = + new PostListResDto.AuthorDto(2L, "김철수", "철수"); + PostListResDto response = new PostListResDto( List.of( new PostListResDto.PostSummaryDto( @@ -278,6 +283,7 @@ void getPostList() throws Exception { "공지 제목", "공지 내용...", 7L, + author1, LocalDateTime.of(2026, 1, 31, 10, 0) ), new PostListResDto.PostSummaryDto( @@ -286,6 +292,7 @@ void getPostList() throws Exception { "자유글", "자유 내용...", 1L, + author2, LocalDateTime.of(2026, 1, 31, 11, 0) ) ), @@ -333,6 +340,12 @@ void getPostList() throws Exception { fieldWithPath("body.posts[].title").type(STRING).description("제목"), fieldWithPath("body.posts[].content_preview").type(STRING).description("내용 프리뷰(일부)"), fieldWithPath("body.posts[].like_count").type(NUMBER).description("좋아요 수"), + + fieldWithPath("body.posts[].author").type(OBJECT).description("작성자 정보"), + fieldWithPath("body.posts[].author.user_id").type(NUMBER).description("작성자 유저 ID"), + fieldWithPath("body.posts[].author.user_name").type(STRING).description("작성자 이름"), + fieldWithPath("body.posts[].author.nickname").type(STRING).description("작성자 닉네임"), + fieldWithPath("body.posts[].created_at").type(STRING).description("작성 시각(ISO-8601)"), fieldWithPath("body.page_info").type(OBJECT).description("페이지 정보"), @@ -349,6 +362,7 @@ void getPostList() throws Exception { verify(postFacade).getPostList(eq(projectId), eq(userId), any(), eq(0), eq(10)); } + @Test @DisplayName("게시글 수정") void updatePost() throws Exception { From 936e3d8c6d80c6505d9ff3a0b5737e3b222f0206 Mon Sep 17 00:00:00 2001 From: infiniment Date: Mon, 9 Feb 2026 14:13:46 +0900 Subject: [PATCH 62/66] =?UTF-8?q?[Refactor]=20=EC=9C=84=ED=81=AC=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20NPE=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ProcessTaskItemService.java | 10 ++++++- .../process/service/WeekMissionService.java | 30 ++++++++++--------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java index 2308d08d..70e400f0 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java @@ -193,7 +193,15 @@ public ProcessTaskItemResDto create(Long projectId, Long userId, Long processId, public ProcessTaskItemResDto update(Long projectId, Long userId, Long processId, Long taskItemId, ProcessTaskItemUpsertReqDto req) { assertWritableMember(projectId, userId); - getActiveProcess(projectId, processId); + Process process = getActiveProcess(projectId, processId); + + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + throw new ProcessException( + ProcessErrorCode.WEEK_MISSION_FORBIDDEN, + "위크 미션 TASK는 processes 경로에서 수정할 수 없습니다. projectId=" + projectId + ", processId=" + processId + ); + } + ProcessTaskItem item = getTaskItem(processId, taskItemId); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java index a4efdb06..012be924 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -508,20 +508,22 @@ public ProcessTaskItemResDto updateWeekMissionTaskItem( meta.put("title", process.getTitle()); meta.put("taskItemId", item.getId()); - meta.put("before", Map.of( - "content", beforeContent, - "isDone", beforeDone, - "sortOrder", beforeSortOrder, - "roleField", beforeRole == null ? null : beforeRole.name(), - "customRoleFieldName", beforeCustom - )); - meta.put("after", Map.of( - "content", item.getContent(), - "isDone", item.isDone(), - "sortOrder", item.getSortOrder(), - "roleField", item.getRoleField() == null ? null : item.getRoleField().name(), - "customRoleFieldName", item.getCustomRoleFieldName() - )); + Map beforeMap = new LinkedHashMap<>(); + beforeMap.put("content", beforeContent); + beforeMap.put("isDone", beforeDone); + beforeMap.put("sortOrder", beforeSortOrder); + beforeMap.put("roleField", beforeRole == null ? null : beforeRole.name()); + beforeMap.put("customRoleFieldName", beforeCustom); + + Map afterMap = new LinkedHashMap<>(); + afterMap.put("content", item.getContent()); + afterMap.put("isDone", item.isDone()); + afterMap.put("sortOrder", item.getSortOrder()); + afterMap.put("roleField", item.getRoleField() == null ? null : item.getRoleField().name()); + afterMap.put("customRoleFieldName", item.getCustomRoleFieldName()); + + meta.put("before", beforeMap); + meta.put("after", afterMap); publishWeekMissionHistory( projectId, userId, processId, From 9ee22155104d97bf6d9274fce2835a2d49b20a1a Mon Sep 17 00:00:00 2001 From: infiniment Date: Mon, 9 Feb 2026 15:05:07 +0900 Subject: [PATCH 63/66] =?UTF-8?q?[Refactor]=20=ED=94=84=EB=A1=9C=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=EC=A3=BC=EC=B0=A8=EB=B3=84,=20=ED=8C=8C=ED=8A=B8?= =?UTF-8?q?=EB=B3=84=20=EC=A1=B0=ED=9A=8C=20=ED=8C=8C=EC=9D=BC=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../process/dto/res/AttachmentMetaDto.java | 21 ++ .../process/dto/res/AttachmentSummaryDto.java | 24 +++ .../process/dto/res/ProcessCardResDto.java | 8 +- .../team/process/service/ProcessService.java | 192 ++++++++++++++---- .../controller/ProcessControllerTest.java | 126 ++++++++++-- .../ProcessSharedDocumentRepository.java | 50 +++++ 6 files changed, 366 insertions(+), 55 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentMetaDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentSummaryDto.java diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentMetaDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentMetaDto.java new file mode 100644 index 00000000..433eb737 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentMetaDto.java @@ -0,0 +1,21 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.api.domain.team.process.enums.AttachmentType; +import com.nect.core.entity.team.enums.FileExt; + +import java.time.LocalDateTime; + +public record AttachmentMetaDto( + @JsonProperty("type") + AttachmentType type, + + @JsonProperty("document_id") + Long documentId, + + @JsonProperty("attached_at") + LocalDateTime attachedAt, + + @JsonProperty("file_ext") + FileExt fileExt +) {} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentSummaryDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentSummaryDto.java new file mode 100644 index 00000000..27259f73 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentSummaryDto.java @@ -0,0 +1,24 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import com.nect.core.entity.team.enums.FileExt; + +public record AttachmentSummaryDto( + @JsonProperty("total_count") + long totalCount, + + @JsonProperty("file_count") + long fileCount, + + @JsonProperty("link_count") + long linkCount, + + @JsonProperty("file_extensions") + List fileExtensions +) { + public static AttachmentSummaryDto empty() { + return new AttachmentSummaryDto(0, 0, 0, List.of()); + } +} + diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java index b3471504..89f40eb7 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java @@ -44,5 +44,11 @@ public record ProcessCardResDto( boolean hasOpenFeedback, @JsonProperty("assignee") - List assignee + List assignee, + + @JsonProperty("attachment_summary") + AttachmentSummaryDto attachmentSummary, + + @JsonProperty("attachments_meta") + List attachmentsMeta ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index cbb179c9..212a525d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -15,6 +15,7 @@ import com.nect.core.entity.notifications.enums.NotificationType; import com.nect.core.entity.team.ProjectUser; import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.enums.FileExt; import com.nect.core.entity.team.history.enums.HistoryAction; import com.nect.core.entity.team.history.enums.HistoryTargetType; import com.nect.core.entity.team.Project; @@ -31,10 +32,7 @@ import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.SharedDocumentRepository; -import com.nect.core.repository.team.process.ProcessFeedbackRepository; -import com.nect.core.repository.team.process.ProcessLaneOrderRepository; -import com.nect.core.repository.team.process.ProcessMentionRepository; -import com.nect.core.repository.team.process.ProcessRepository; +import com.nect.core.repository.team.process.*; import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -60,6 +58,7 @@ public class ProcessService { private final ProcessLaneOrderRepository processLaneOrderRepository; private final ProjectTeamRoleRepository projectTeamRoleRepository; private final ProcessFeedbackRepository processFeedbackRepository; + private final ProcessSharedDocumentRepository processSharedDocumentRepository; private final S3Service s3Service; private final ProcessLaneOrderService processLaneOrderService; @@ -1413,6 +1412,43 @@ public void deleteProcess(Long projectId, Long userId, Long processId) { ); } + private Map buildAttachmentSummaryMap(List processIds) { + if (processIds == null || processIds.isEmpty()) return Map.of(); + + var rows = processSharedDocumentRepository.aggregateAttachmentsByProcessIds(processIds); + + Map fileCount = new HashMap<>(); + Map linkCount = new HashMap<>(); + Map> extSet = new HashMap<>(); + + for (var r : rows) { + Long pid = r.getProcessId(); + DocumentType type = r.getDocumentType(); + FileExt ext = r.getFileExt(); + long cnt = (r.getCnt() == null) ? 0L : r.getCnt(); + + if (type == DocumentType.LINK) { + linkCount.put(pid, linkCount.getOrDefault(pid, 0L) + cnt); + } else { // FILE + fileCount.put(pid, fileCount.getOrDefault(pid, 0L) + cnt); + if (ext != null) { + extSet.computeIfAbsent(pid, k -> new LinkedHashSet<>()).add(ext); + } + } + } + + Map out = new HashMap<>(); + for (Long pid : processIds) { + long f = fileCount.getOrDefault(pid, 0L); + long l = linkCount.getOrDefault(pid, 0L); + + List exts = extSet.getOrDefault(pid, new LinkedHashSet<>()).stream().toList(); + + out.put(pid, new AttachmentSummaryDto(f + l, f, l, exts)); + } + return out; + } + private LocalDate normalizeWeekStart(LocalDate date) { if (date == null) { throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "startDate must not be null"); @@ -1420,7 +1456,12 @@ private LocalDate normalizeWeekStart(LocalDate date) { return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); } - private ProcessCardResDto toProcessCardResDTO(Process p, boolean hasOpenFeedback) { + private ProcessCardResDto toProcessCardResDTO( + Process p, + boolean hasOpenFeedback, + AttachmentSummaryDto attachmentSummary, + List attachmentsMeta + ) { int whole = (p.getTaskItems() == null) ? 0 : p.getTaskItems().size(); int done = (p.getTaskItems() == null) ? 0 : (int) p.getTaskItems().stream() .filter(ProcessTaskItem::isDone) @@ -1432,7 +1473,7 @@ private ProcessCardResDto toProcessCardResDTO(Process p, boolean hasOpenFeedbac .filter(pf -> pf.getDeletedAt() == null) .map(ProcessField::getRoleField) .filter(Objects::nonNull) - .filter(rf -> rf != RoleField.CUSTOM) // 커스텀은 별도 리스트로 + .filter(rf -> rf != RoleField.CUSTOM) .distinct() .toList(); @@ -1452,8 +1493,7 @@ private ProcessCardResDto toProcessCardResDTO(Process p, boolean hasOpenFeedbac .map(pu -> { User u = pu.getUser(); String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); - String nickname = u.getNickname(); - return new AssigneeResDto(u.getUserId(), u.getName(), nickname, profileUrl); + return new AssigneeResDto(u.getUserId(), u.getName(), u.getNickname(), profileUrl); }) .toList(); @@ -1472,10 +1512,53 @@ private ProcessCardResDto toProcessCardResDTO(Process p, boolean hasOpenFeedbac customFields, missionNumber, hasOpenFeedback, - assignees + assignees, + (attachmentSummary == null ? AttachmentSummaryDto.empty() : attachmentSummary), + (attachmentsMeta == null ? List.of() : attachmentsMeta) ); } + private Map> buildAttachmentMetaMap(List processIds) { + if (processIds == null || processIds.isEmpty()) return Map.of(); + + var rows = processSharedDocumentRepository.findAttachmentMetasByProcessIds(processIds); + + Map> out = new HashMap<>(); + + for (var r : rows) { + Long pid = r.getProcessId(); + if (pid == null) continue; + + AttachmentType type = (r.getDocumentType() == DocumentType.LINK) + ? AttachmentType.LINK + : AttachmentType.FILE; + + LocalDateTime attachedAt = (r.getAttachedAt() != null) + ? r.getAttachedAt() + : r.getCreatedAt(); + + AttachmentMetaDto dto = new AttachmentMetaDto( + type, + r.getDocumentId(), + attachedAt, + r.getFileExt() // LINK면 null 가능 + ); + + out.computeIfAbsent(pid, k -> new ArrayList<>()).add(dto); + } + + // attachedAt desc 정렬 (원하는 정책) + out.replaceAll((pid, list) -> + list.stream() + .sorted(Comparator.comparing( + (AttachmentMetaDto m) -> m.attachedAt() == null ? LocalDateTime.MIN : m.attachedAt() + ).reversed()) + .toList() + ); + + return out; + } + private Integer calcLeftDay(LocalDate deadLine) { if (deadLine == null) return null; long diff = ChronoUnit.DAYS.between(LocalDate.now(), deadLine); @@ -1603,20 +1686,34 @@ public ProcessWeeksResDto getWeekProcesses(Long projectId, Long userId, LocalDat List processes = processRepository.findAllInRangeOrdered(projectId, rangeStart, rangeEnd); if (processes == null) processes = List.of(); - Set openFeedbackProcessIds = new HashSet<>(); - if (!processes.isEmpty()) { + final Set openFeedbackProcessIds; + final Map attachmentSummaryMap; + final Map> attachmentMetaMap; + + if (processes.isEmpty()) { + openFeedbackProcessIds = Set.of(); + attachmentSummaryMap = Map.of(); + attachmentMetaMap = Map.of(); + } else { List processIds = processes.stream() .map(Process::getId) .filter(Objects::nonNull) .toList(); - if (!processIds.isEmpty()) { - openFeedbackProcessIds.addAll( - processFeedbackRepository.findOpenFeedbackProcessIds(processIds) - ); - } + openFeedbackProcessIds = processIds.isEmpty() + ? Set.of() + : new HashSet<>(processFeedbackRepository.findOpenFeedbackProcessIds(processIds)); + + attachmentSummaryMap = processIds.isEmpty() + ? Map.of() + : buildAttachmentSummaryMap(processIds); + + attachmentMetaMap = processIds.isEmpty() + ? Map.of() + : buildAttachmentMetaMap(processIds); } + // 프로세스를 주차별로 묶기 // startAt이 null이면 rangeStart 주로 보내거나, common 처리 가능 Map> byWeek = new LinkedHashMap<>(); @@ -1644,7 +1741,9 @@ public ProcessWeeksResDto getWeekProcesses(Long projectId, Long userId, LocalDat List cards = entry.getValue().stream() .map(p -> { boolean hasOpenFeedback = openFeedbackProcessIds.contains(p.getId()); - return toProcessCardResDTO(p, hasOpenFeedback); + AttachmentSummaryDto summary = attachmentSummaryMap.getOrDefault(p.getId(), AttachmentSummaryDto.empty()); + List metas = attachmentMetaMap.getOrDefault(p.getId(), List.of()); + return toProcessCardResDTO(p, hasOpenFeedback, summary, metas); }) .toList(); @@ -1727,7 +1826,7 @@ public ProcessPartResDto getPartProcesses(Long projectId, Long userId, String la */ // lane 대상 프로세스 목록 - List laneProcesses = List.of(); + List laneProcesses; if (TEAM_LANE_KEY.equals(dbLaneKey)) { // 팀(전체) laneProcesses = processRepository.findAllForTeamBoard(projectId); @@ -1746,25 +1845,40 @@ public ProcessPartResDto getPartProcesses(Long projectId, Long userId, String la throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "invalid lane_key prefix. laneKey=" + laneKey); } - Set openFeedbackProcessIds = Collections.emptySet(); - if (laneProcesses != null && !laneProcesses.isEmpty()) { + if (laneProcesses == null) laneProcesses = List.of(); + + final Set openFeedbackProcessIds; + final Map attachmentSummaryMap; + final Map> attachmentMetaMap; + + if (laneProcesses.isEmpty()) { + openFeedbackProcessIds = Set.of(); + attachmentSummaryMap = Map.of(); + attachmentMetaMap = Map.of(); + } else { List processIds = laneProcesses.stream() .map(Process::getId) .filter(Objects::nonNull) .toList(); - if (!processIds.isEmpty()) { - openFeedbackProcessIds = new HashSet<>( - processFeedbackRepository.findOpenFeedbackProcessIds(processIds) - ); - } + openFeedbackProcessIds = processIds.isEmpty() + ? Set.of() + : new HashSet<>(processFeedbackRepository.findOpenFeedbackProcessIds(processIds)); + + attachmentSummaryMap = processIds.isEmpty() + ? Map.of() + : buildAttachmentSummaryMap(processIds); + + attachmentMetaMap = processIds.isEmpty() + ? Map.of() + : buildAttachmentMetaMap(processIds); } List groups = List.of( - buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.PLANNING, laneProcesses, openFeedbackProcessIds), - buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.IN_PROGRESS, laneProcesses, openFeedbackProcessIds), - buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.DONE, laneProcesses, openFeedbackProcessIds), - buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.BACKLOG, laneProcesses, openFeedbackProcessIds) + buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.PLANNING, laneProcesses, openFeedbackProcessIds, attachmentSummaryMap, attachmentMetaMap), + buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.IN_PROGRESS, laneProcesses, openFeedbackProcessIds, attachmentSummaryMap, attachmentMetaMap), + buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.DONE, laneProcesses, openFeedbackProcessIds, attachmentSummaryMap, attachmentMetaMap), + buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.BACKLOG, laneProcesses, openFeedbackProcessIds, attachmentSummaryMap, attachmentMetaMap) ); return new ProcessPartResDto(toApiLaneKey(dbLaneKey), groups); @@ -1775,32 +1889,30 @@ private ProcessStatusGroupResDto buildStatusGroupOrdered( String laneKey, ProcessStatus status, List laneProcessesAll, - Set openFeedbackProcessIds + Set openFeedbackProcessIds, + Map attachmentSummaryMap, + Map> attachmentMetaMap ) { - // 해당 status인 프로세스만 List laneProcesses = laneProcessesAll.stream() .filter(p -> p.getStatus() == status) .toList(); - // order row 없으면 생성 (tail 부여) - processLaneOrderService.ensureLaneOrderRowsExistWriteTx( - projectId, status, laneKey, laneProcesses - ); + processLaneOrderService.ensureLaneOrderRowsExistWriteTx(projectId, status, laneKey, laneProcesses); - // order row 기준 processId 순서 확보 List orders = processLaneOrderRepository.findLaneOrders(projectId, laneKey, status); List orderedIds = orders.stream().map(o -> o.getProcess().getId()).toList(); - // 혹시라도 (order에는 있는데 laneProcesses에는 없는) 케이스 방어 - // laneProcesses 기준으로 map - Map map = laneProcesses.stream().collect(Collectors.toMap(Process::getId, p -> p)); + Map map = laneProcesses.stream() + .collect(Collectors.toMap(Process::getId, p -> p)); List cards = new ArrayList<>(); for (Long id : orderedIds) { Process p = map.get(id); if (p != null) { boolean hasOpenFeedback = openFeedbackProcessIds.contains(p.getId()); - cards.add(toProcessCardResDTO(p, hasOpenFeedback)); + AttachmentSummaryDto summary = attachmentSummaryMap.getOrDefault(p.getId(), AttachmentSummaryDto.empty()); + List metas = attachmentMetaMap.getOrDefault(p.getId(), List.of()); + cards.add(toProcessCardResDTO(p, hasOpenFeedback, summary, metas)); } } diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java index 6ec1f3b7..bcda9f60 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.nect.api.domain.team.process.dto.req.*; import com.nect.api.domain.team.process.dto.res.*; +import com.nect.api.domain.team.process.enums.AttachmentType; import com.nect.api.domain.team.process.enums.LaneType; import com.nect.api.domain.team.process.service.ProcessService; import com.nect.api.global.jwt.JwtUtil; @@ -597,6 +598,37 @@ void getWeekProcesses() throws Exception { long projectId = 1L; long userId = 1L; + AttachmentSummaryDto summary1 = new AttachmentSummaryDto( + 3L, // total + 2L, // file + 1L, // link + List.of(FileExt.PDF, FileExt.PNG) + ); + + List metas1 = List.of( + new AttachmentMetaDto( + AttachmentType.FILE, + 9001L, + LocalDateTime.of(2026, 1, 20, 10, 0), + FileExt.PDF + ), + new AttachmentMetaDto( + AttachmentType.LINK, + 9002L, + LocalDateTime.of(2026, 1, 20, 11, 0), + null + ), + new AttachmentMetaDto( + AttachmentType.FILE, + 9003L, + LocalDateTime.of(2026, 1, 20, 12, 0), + FileExt.PNG + ) + ); + + AttachmentSummaryDto summaryEmpty = AttachmentSummaryDto.empty(); + List metasEmpty = List.of(); + ProcessCardResDto common1 = new ProcessCardResDto( 101L, ProcessStatus.PLANNING, @@ -613,7 +645,9 @@ void getWeekProcesses() throws Exception { List.of( new AssigneeResDto(1L, "홍길동", "길동", "https://img.com/u1.png"), new AssigneeResDto(2L, "김철수", "철수", null) - ) + ), + summary1, + metas1 ); ProcessCardResDto common2 = new ProcessCardResDto( @@ -631,7 +665,9 @@ void getWeekProcesses() throws Exception { true, List.of( new AssigneeResDto(3L, "박영희", "영희", "https://img.com/u3.png") - ) + ), + summaryEmpty, + metasEmpty ); ProcessCardResDto backend1 = new ProcessCardResDto( @@ -649,6 +685,15 @@ void getWeekProcesses() throws Exception { true, List.of( new AssigneeResDto(1L, "홍길동", "길동", "https://img.com/u1.png") + ), + new AttachmentSummaryDto(1L, 0L, 1L, List.of()), + List.of( + new AttachmentMetaDto( + AttachmentType.LINK, + 9101L, + LocalDateTime.of(2026, 1, 21, 9, 0), + null + ) ) ); @@ -667,7 +712,9 @@ void getWeekProcesses() throws Exception { false, List.of( new AssigneeResDto(4L, "이민수", "민수", null) - ) + ), + summaryEmpty, + metasEmpty ); FieldGroupResDto fgBackend = new FieldGroupResDto( @@ -705,6 +752,11 @@ void getWeekProcesses() throws Exception { false, List.of( new AssigneeResDto(2L, "김철수", "철수", "https://img.com/u2.png") + ), + new AttachmentSummaryDto(2L, 2L, 0L, List.of(FileExt.JPG, FileExt.SVG)), + List.of( + new AttachmentMetaDto(AttachmentType.FILE, 9201L, LocalDateTime.of(2026, 1, 27, 13, 0), FileExt.JPG), + new AttachmentMetaDto(AttachmentType.FILE, 9202L, LocalDateTime.of(2026, 1, 27, 13, 10), FileExt.SVG) ) ); @@ -717,7 +769,7 @@ void getWeekProcesses() throws Exception { ProcessWeekResDto w2 = new ProcessWeekResDto( LocalDate.of(2026, 1, 26), - List.of(), // common lane empty + List.of(), List.of(fgPlanner) ); @@ -781,6 +833,17 @@ void getWeekProcesses() throws Exception { fieldWithPath("body.weeks[].common_lane[].assignee[].nickname").type(STRING).description("담당자 닉네임"), fieldWithPath("body.weeks[].common_lane[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary").type(OBJECT).description("첨부 요약"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary.total_count").type(NUMBER).description("총 첨부 수(file+link)"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary.file_count").type(NUMBER).description("파일 첨부 수"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary.link_count").type(NUMBER).description("링크 첨부 수"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary.file_exts").type(ARRAY).description("첨부된 파일 확장자 목록(중복 제거)"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta").type(ARRAY).description("첨부 메타 목록(파일/링크 각각 documentId 기준)"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta[].type").type(STRING).description("첨부 타입(FILE/LINK)"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta[].document_id").type(NUMBER).description("SharedDocument ID"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta[].attached_at").type(STRING).description("첨부 시각(ISO LocalDateTime)"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta[].file_ext").optional().type(STRING).description("파일 확장자(FILE만, LINK는 null)"), + fieldWithPath("body.weeks[].by_field").type(ARRAY).description("분야별(Field) 그룹 목록"), fieldWithPath("body.weeks[].by_field[].field_id").type(STRING).description("fieldId (예: ROLE:BACKEND / CUSTOM:영상편집)"), fieldWithPath("body.weeks[].by_field[].field_name").type(STRING).description("fieldName (예: BACKEND / 영상편집)"), @@ -803,7 +866,18 @@ void getWeekProcesses() throws Exception { fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_name").type(STRING).description("담당자 이름"), fieldWithPath("body.weeks[].by_field[].processes[].assignee[].nickname").type(STRING).description("담당자 닉네임"), - fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL") + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL"), + + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary").type(OBJECT).description("첨부 요약"), + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.total_count").type(NUMBER).description("총 첨부 수(file+link)"), + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.file_count").type(NUMBER).description("파일 첨부 수"), + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.link_count").type(NUMBER).description("링크 첨부 수"), + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.file_exts").type(ARRAY).description("첨부된 파일 확장자 목록(중복 제거)"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta").type(ARRAY).description("첨부 메타 목록"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].type").type(STRING).description("첨부 타입(FILE/LINK)"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].document_id").type(NUMBER).description("SharedDocument ID"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].attached_at").type(STRING).description("첨부 시각(ISO LocalDateTime)"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].file_ext").optional().type(STRING).description("파일 확장자(FILE만, LINK는 null)") ) .build() ) @@ -822,7 +896,12 @@ void getPartProcesses() throws Exception { AssigneeResDto a1 = new AssigneeResDto(1L, "유저1", "유저1닉", "https://img.com/1.png"); AssigneeResDto a2 = new AssigneeResDto(2L, "유저2", "유저2닉", "https://img.com/2.png"); - // IN_PROGRESS 카드 2개 + AttachmentSummaryDto s1 = new AttachmentSummaryDto(2L, 1L, 1L, List.of(FileExt.PDF)); + List m1 = List.of( + new AttachmentMetaDto(AttachmentType.FILE, 7001L, LocalDateTime.of(2026, 2, 4, 10, 0), FileExt.PDF), + new AttachmentMetaDto(AttachmentType.LINK, 7002L, LocalDateTime.of(2026, 2, 4, 10, 30), null) + ); + ProcessCardResDto p10 = new ProcessCardResDto( 10L, ProcessStatus.IN_PROGRESS, @@ -836,7 +915,9 @@ void getPartProcesses() throws Exception { List.of("AI"), 1, true, - List.of(a1, a2) // assignee + List.of(a1, a2), + s1, + m1 ); ProcessCardResDto p12 = new ProcessCardResDto( @@ -852,11 +933,11 @@ void getPartProcesses() throws Exception { List.of("DevOps"), null, true, - List.of(a2) + List.of(a2), + AttachmentSummaryDto.empty(), + List.of() ); - - ProcessStatusGroupResDto inProgressGroup = new ProcessStatusGroupResDto( ProcessStatus.IN_PROGRESS, 2, @@ -877,7 +958,9 @@ void getPartProcesses() throws Exception { List.of(), null, false, - List.of(a1) + List.of(a1), + AttachmentSummaryDto.empty(), + List.of() ); ProcessStatusGroupResDto planningGroup = new ProcessStatusGroupResDto( @@ -900,7 +983,9 @@ void getPartProcesses() throws Exception { List.of("Auth"), 1, false, - List.of(a1, a2) + List.of(a1, a2), + new AttachmentSummaryDto(1L, 1L, 0L, List.of(FileExt.SVG)), + List.of(new AttachmentMetaDto(AttachmentType.FILE, 7301L, LocalDateTime.of(2026, 1, 24, 18, 0), FileExt.SVG)) ); ProcessStatusGroupResDto doneGroup = new ProcessStatusGroupResDto( @@ -923,7 +1008,9 @@ void getPartProcesses() throws Exception { List.of("TechDebt"), 1, false, - List.of(a2) + List.of(a2), + AttachmentSummaryDto.empty(), + List.of() ); ProcessStatusGroupResDto backlogGroup = new ProcessStatusGroupResDto( @@ -999,7 +1086,18 @@ void getPartProcesses() throws Exception { fieldWithPath("body.groups[].processes[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), fieldWithPath("body.groups[].processes[].assignee[].user_name").type(STRING).description("담당자 이름"), fieldWithPath("body.groups[].processes[].assignee[].nickname").type(STRING).description("담당자 닉네임"), - fieldWithPath("body.groups[].processes[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL") + fieldWithPath("body.groups[].processes[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL"), + + fieldWithPath("body.groups[].processes[].attachment_summary").type(OBJECT).description("첨부 요약"), + fieldWithPath("body.groups[].processes[].attachment_summary.total_count").type(NUMBER).description("총 첨부 수(file+link)"), + fieldWithPath("body.groups[].processes[].attachment_summary.file_count").type(NUMBER).description("파일 첨부 수"), + fieldWithPath("body.groups[].processes[].attachment_summary.link_count").type(NUMBER).description("링크 첨부 수"), + fieldWithPath("body.groups[].processes[].attachment_summary.file_extensions").type(ARRAY).description("첨부된 파일 확장자 목록(중복 제거)"), + fieldWithPath("body.groups[].processes[].attachments_meta").type(ARRAY).description("첨부 메타 목록"), + fieldWithPath("body.groups[].processes[].attachments_meta[].type").type(STRING).description("첨부 타입(FILE/LINK)"), + fieldWithPath("body.groups[].processes[].attachments_meta[].document_id").type(NUMBER).description("SharedDocument ID"), + fieldWithPath("body.groups[].processes[].attachments_meta[].attached_at").type(STRING).description("첨부 시각(ISO LocalDateTime)"), + fieldWithPath("body.groups[].processes[].attachments_meta[].file_ext").optional().type(STRING).description("파일 확장자(FILE만, LINK는 null)") ) .build() ) diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java index 0af622df..34638c03 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java @@ -1,11 +1,14 @@ package com.nect.core.repository.team.process; +import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.enums.FileExt; import com.nect.core.entity.team.process.ProcessSharedDocument; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -32,4 +35,51 @@ public interface ProcessSharedDocumentRepository extends JpaRepository findAliveAttachmentsWithDoc(@Param("processId") Long processId); + + interface AttachmentAggRow { + Long getProcessId(); + DocumentType getDocumentType(); + FileExt getFileExt(); + Long getCnt(); + } + + @Query(""" + select + psd.process.id as processId, + doc.documentType as documentType, + doc.fileExt as fileExt, + count(psd.id) as cnt + from ProcessSharedDocument psd + join psd.document doc + where psd.deletedAt is null + and doc.deletedAt is null + and psd.process.id in :processIds + group by psd.process.id, doc.documentType, doc.fileExt + """) + List aggregateAttachmentsByProcessIds(@Param("processIds") List processIds); + + interface AttachmentMetaRow { + Long getProcessId(); + Long getDocumentId(); + DocumentType getDocumentType(); + FileExt getFileExt(); + LocalDateTime getAttachedAt(); + LocalDateTime getCreatedAt(); + } + + @Query(""" + select + psd.process.id as processId, + d.id as documentId, + d.documentType as documentType, + d.fileExt as fileExt, + psd.attachedAt as attachedAt, + psd.createdAt as createdAt + from ProcessSharedDocument psd + join psd.document d + where psd.deletedAt is null + and d.deletedAt is null + and psd.process.id in :processIds + """) + List findAttachmentMetasByProcessIds(@Param("processIds") List processIds); } From 38b85d77af1536b1cd218c1e4db8e7e3940a2f0a Mon Sep 17 00:00:00 2001 From: infiniment Date: Mon, 9 Feb 2026 22:39:55 +0900 Subject: [PATCH 64/66] =?UTF-8?q?[Refactor]=20UserRoleField=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=ED=95=84=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/service/UserTeamRoleQueryService.java | 4 ++-- .../mypage/service/UserTeamRoleService.java | 16 ++++------------ .../com/nect/core/entity/user/UserTeamRole.java | 16 ++++------------ .../repository/user/UserTeamRoleRepository.java | 10 +++++----- 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleQueryService.java b/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleQueryService.java index 670cf2b0..81958234 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleQueryService.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleQueryService.java @@ -20,7 +20,7 @@ public class UserTeamRoleQueryService { private final UserTeamRoleRepository userTeamRoleRepository; private final ProjectUserRepository projectUserRepository; - // 마이페이지 팀 파트 조회(UserTeamRole 사용) + // 마이페이지 팀 파트 조회(UserTeamRole 사용), 프로젝트 멤버면 조회 가능 @Transactional(readOnly = true) public UserTeamRolesResDto readMyPageParts(Long projectId, Long requesterUserId) { @@ -37,7 +37,7 @@ public UserTeamRolesResDto readMyPageParts(Long projectId, Long requesterUserId) } List roles = - userTeamRoleRepository.findAllByProject_IdAndUser_UserIdAndDeletedAtIsNullOrderByIdAsc(projectId, requesterUserId); + userTeamRoleRepository.findAllByProject_IdAndDeletedAtIsNullOrderByIdAsc(projectId); List parts = roles.stream() .map(r -> new UserTeamRolesResDto.PartDto( diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleService.java b/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleService.java index 586504da..8e1ca5e4 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleService.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleService.java @@ -24,7 +24,6 @@ public class UserTeamRoleService { private static final int DEFAULT_REQUIRED_COUNT = 1; private final UserTeamRoleRepository userTeamRoleRepository; - private final UserRepository userRepository; private final ProjectRepository projectRepository; private final ProjectUserRepository projectUserRepository; @@ -76,8 +75,7 @@ public UserTeamRoleCreateResDto create(Long projectId, Long userId, UserTeamRole } boolean duplicate = userTeamRoleRepository - .existsByProject_IdAndUser_UserIdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( - projectId, userId, roleField, customName); + .existsByProject_IdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull(projectId, roleField, customName); if(duplicate) { throw new UserTeamRoleException( @@ -87,7 +85,7 @@ public UserTeamRoleCreateResDto create(Long projectId, Long userId, UserTeamRole } }else { boolean duplicate = userTeamRoleRepository - .existsByProject_IdAndUser_UserIdAndRoleFieldAndDeletedAtIsNull(projectId, userId, roleField); + .existsByProject_IdAndRoleFieldAndDeletedAtIsNull(projectId, roleField); if (duplicate) { throw new UserTeamRoleException( @@ -99,11 +97,6 @@ public UserTeamRoleCreateResDto create(Long projectId, Long userId, UserTeamRole customName = null; } - // 유저 조회 - User user = userRepository.findById(userId) - .orElseThrow(() -> new UserTeamRoleException(UserTeamRoleErrorCode.USER_NOT_FOUND, - "userId=" + userId - )); Project project = projectRepository.findById(projectId) .orElseThrow(() -> new UserTeamRoleException(UserTeamRoleErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId)); @@ -111,7 +104,6 @@ public UserTeamRoleCreateResDto create(Long projectId, Long userId, UserTeamRole UserTeamRole saved = userTeamRoleRepository.save( UserTeamRole.builder() .project(project) - .user(user) .roleField(roleField) .customRoleFieldName(customName) .requiredCount(requiredCount) @@ -188,8 +180,8 @@ public UserTeamRoleUpdateResDto update(Long projectId, Long userId, Long userTea String oldName = role.getCustomRoleFieldName(); if (oldName == null || !oldName.equalsIgnoreCase(newName)) { boolean duplicate = userTeamRoleRepository - .existsByProject_IdAndUser_UserIdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( - projectId, role.getUser().getUserId(), RoleField.CUSTOM, newName + .existsByProject_IdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( + projectId, RoleField.CUSTOM, newName ); if (duplicate) { diff --git a/nect-core/src/main/java/com/nect/core/entity/user/UserTeamRole.java b/nect-core/src/main/java/com/nect/core/entity/user/UserTeamRole.java index edf9b5a0..2c11b446 100644 --- a/nect-core/src/main/java/com/nect/core/entity/user/UserTeamRole.java +++ b/nect-core/src/main/java/com/nect/core/entity/user/UserTeamRole.java @@ -16,9 +16,7 @@ name = "user_team_roles", indexes = { @Index(name = "idx_user_team_roles_project_id", columnList = "project_id"), - @Index(name = "idx_user_team_roles_user_id", columnList = "user_id"), - @Index(name = "idx_user_team_roles_project_user", columnList = "project_id, user_id"), - @Index(name = "idx_user_team_roles_project_user_role", columnList = "project_id, user_id, role_field") + @Index(name = "idx_user_team_roles_project_role", columnList = "project_id, role_field") } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -33,10 +31,6 @@ public class UserTeamRole { @JoinColumn(name = "project_id", nullable = false) private Project project; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id", nullable = false) - private User user; - @Enumerated(EnumType.STRING) @Column(name = "role_field", nullable = false, length = 50) private RoleField roleField; @@ -53,24 +47,22 @@ public class UserTeamRole { private LocalDateTime deletedAt; @Builder - private UserTeamRole(Project project, User user, RoleField roleField, String customRoleFieldName, Integer requiredCount) { + private UserTeamRole(Project project, RoleField roleField, String customRoleFieldName, Integer requiredCount) { this.project = project; - this.user = user; this.roleField = roleField; this.customRoleFieldName = customRoleFieldName; this.requiredCount = requiredCount; } - public static UserTeamRole of(Project project, User user, RoleField roleField, String customRoleFieldName, Integer requiredCount) { + public static UserTeamRole of(Project project, RoleField roleField, String customRoleFieldName, Integer requiredCount) { return UserTeamRole.builder() .project(project) - .user(user) .roleField(roleField) .customRoleFieldName(customRoleFieldName) .requiredCount(requiredCount) .build(); } - + public boolean isDeleted() { return deletedAt != null; } diff --git a/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java index c33a54f4..3877bd25 100644 --- a/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java @@ -9,16 +9,16 @@ public interface UserTeamRoleRepository extends JpaRepository { - boolean existsByProject_IdAndUser_UserIdAndRoleFieldAndDeletedAtIsNull( - Long projectId, Long userId, RoleField roleField + boolean existsByProject_IdAndRoleFieldAndDeletedAtIsNull( + Long projectId, RoleField roleField ); - boolean existsByProject_IdAndUser_UserIdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( - Long projectId, Long userId, RoleField roleField, String customRoleFieldName + boolean existsByProject_IdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( + Long projectId, RoleField roleField, String customRoleFieldName ); Optional findByIdAndProject_IdAndDeletedAtIsNull(Long id, Long projectId); - List findAllByProject_IdAndUser_UserIdAndDeletedAtIsNullOrderByIdAsc(Long projectId, Long userId); + List findAllByProject_IdAndDeletedAtIsNullOrderByIdAsc(Long projectId); } From 91da782d317a501815fd592150b2ca4a0d4674b9 Mon Sep 17 00:00:00 2001 From: infiniment Date: Tue, 10 Feb 2026 00:46:34 +0900 Subject: [PATCH 65/66] =?UTF-8?q?[Refactor]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B3=B5=EC=9C=A0=20=EB=AC=B8=EC=84=9C=ED=95=A8?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC,=20=EB=A7=81=ED=81=AC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BoardsSharedDocumentController.java | 26 +++ .../req/SharedDocumentLinkCreateReqDto.java | 11 ++ .../dto/res/SharedDocumentCreatedResDto.java | 31 ++++ .../facade/BoardsSharedDocumentFacade.java | 75 +++++++++ .../service/BoardsSharedDocumentService.java | 47 ++++++ .../team/workspace/service/PostService.java | 28 +++- .../BoardsSharedDocumentControllerTest.java | 155 +++++++++++++++++- .../controller/PostControllerTest.java | 44 +++++ .../team/history/enums/HistoryAction.java | 3 + .../ProcessSharedDocumentRepository.java | 8 + .../PostSharedDocumentRepository.java | 23 +++ 11 files changed, 445 insertions(+), 6 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/SharedDocumentLinkCreateReqDto.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentCreatedResDto.java diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java index 20416441..1d44d8e9 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java @@ -1,6 +1,8 @@ package com.nect.api.domain.team.workspace.controller; +import com.nect.api.domain.team.workspace.dto.req.SharedDocumentLinkCreateReqDto; import com.nect.api.domain.team.workspace.dto.req.SharedDocumentNameUpdateReqDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentCreatedResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentNameUpdateResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsGetResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsPreviewResDto; @@ -10,8 +12,10 @@ import com.nect.api.global.security.UserDetailsImpl; import com.nect.core.entity.team.enums.DocumentType; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @@ -73,4 +77,26 @@ public ApiResponse deleteSharedDocument( facade.delete(projectId, userId, documentId); return ApiResponse.ok(null); } + + // 파일 첨부 + 업로드 + @PostMapping(value = "/shared-documents/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadSharedDocumentFile( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestPart("file") MultipartFile file + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(facade.uploadFile(projectId, userId, file)); + } + + // 링크 첨부 + @PostMapping("/shared-documents/links") + public ApiResponse createSharedDocumentLink( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody SharedDocumentLinkCreateReqDto req + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(facade.createLink(projectId, userId, req)); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/SharedDocumentLinkCreateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/SharedDocumentLinkCreateReqDto.java new file mode 100644 index 00000000..9da0f94d --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/SharedDocumentLinkCreateReqDto.java @@ -0,0 +1,11 @@ +package com.nect.api.domain.team.workspace.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record SharedDocumentLinkCreateReqDto( + @JsonProperty("title") + String title, + + @JsonProperty("link_url") + String linkUrl +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentCreatedResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentCreatedResDto.java new file mode 100644 index 00000000..087f0293 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentCreatedResDto.java @@ -0,0 +1,31 @@ +package com.nect.api.domain.team.workspace.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.enums.FileExt; + +public record SharedDocumentCreatedResDto( + @JsonProperty("document_id") + Long documentId, + + @JsonProperty("document_type") + DocumentType documentType, + + @JsonProperty("title") + String title, + + @JsonProperty("link_url") + String linkUrl, + + @JsonProperty("file_name") + String fileName, + + @JsonProperty("file_ext") + FileExt fileExt, + + @JsonProperty("file_size") + Long fileSize, + + @JsonProperty("download_url") + String downloadUrl +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsSharedDocumentFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsSharedDocumentFacade.java index 5ea6fb73..200a410d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsSharedDocumentFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsSharedDocumentFacade.java @@ -1,20 +1,40 @@ package com.nect.api.domain.team.workspace.facade; +import com.nect.api.domain.team.file.dto.res.FileUploadResDto; +import com.nect.api.domain.team.file.service.FileService; +import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; +import com.nect.api.domain.team.workspace.dto.req.SharedDocumentLinkCreateReqDto; import com.nect.api.domain.team.workspace.dto.req.SharedDocumentNameUpdateReqDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentCreatedResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentNameUpdateResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsGetResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsPreviewResDto; +import com.nect.api.domain.team.workspace.enums.BoardsErrorCode; import com.nect.api.domain.team.workspace.enums.SharedDocumentsSort; +import com.nect.api.domain.team.workspace.exception.BoardsException; import com.nect.api.domain.team.workspace.service.BoardsSharedDocumentService; +import com.nect.core.entity.team.SharedDocument; import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.history.enums.HistoryAction; +import com.nect.core.entity.team.history.enums.HistoryTargetType; +import com.nect.core.entity.user.User; +import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.LinkedHashMap; +import java.util.Map; @Service @RequiredArgsConstructor public class BoardsSharedDocumentFacade { + private final FileService fileService; private final BoardsSharedDocumentService service; + private final ProjectHistoryPublisher projectHistoryPublisher; + private final UserRepository userRepository; public SharedDocumentsPreviewResDto getPreview(Long projectId, Long userId, int limit) { return service.getPreview(projectId, userId, limit); @@ -36,4 +56,59 @@ public void delete(Long projectId, Long userId, Long documentId) { service.delete(projectId, userId, documentId); } + + // 공유 문서함: 파일 업로드 + @Transactional + public SharedDocumentCreatedResDto uploadFile(Long projectId, Long userId, MultipartFile file) { + FileUploadResDto uploaded = fileService.upload(projectId, userId, file); + + + Map meta = new LinkedHashMap<>(); + meta.put("documentId", uploaded.fileId()); + meta.put("type", "FILE"); + meta.put("title", uploaded.fileName()); + meta.put("fileName", uploaded.fileName()); + meta.put("fileExt", uploaded.fileType()); + meta.put("fileSize", uploaded.fileSize()); + + projectHistoryPublisher.publish( + projectId, + userId, + HistoryAction.DOCUMENT_CREATED, + HistoryTargetType.DOCUMENT, + uploaded.fileId(), + meta + ); + + return new SharedDocumentCreatedResDto( + uploaded.fileId(), + DocumentType.FILE, + uploaded.fileName(), + null, + uploaded.fileName(), + uploaded.fileType(), + uploaded.fileSize(), + uploaded.downloadUrl() + ); + } + + // 공유 문서함: 링크 생성 + @Transactional + public SharedDocumentCreatedResDto createLink(Long projectId, Long userId, SharedDocumentLinkCreateReqDto req) { + User actor = userRepository.findById(userId) + .orElseThrow(() -> new BoardsException(BoardsErrorCode.USER_NOT_FOUND, "userId=" + userId)); + + SharedDocument saved = service.createLink(projectId, userId, req, actor); + + return new SharedDocumentCreatedResDto( + saved.getId(), + saved.getDocumentType(), // LINK + saved.getTitle(), + saved.getLinkUrl(), + null, + null, + 0L, + null + ); + } } \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java index a58812d5..f6778011 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java @@ -1,6 +1,7 @@ package com.nect.api.domain.team.workspace.service; import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; +import com.nect.api.domain.team.workspace.dto.req.SharedDocumentLinkCreateReqDto; import com.nect.api.domain.team.workspace.dto.req.SharedDocumentNameUpdateReqDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentNameUpdateResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsGetResDto; @@ -258,4 +259,50 @@ public void delete(Long projectId, Long userId, Long documentId) { meta ); } + + // 링크 생성 서비스 + @Transactional + public SharedDocument createLink(Long projectId, Long userId, SharedDocumentLinkCreateReqDto req, User actor) { + + if (req == null || req.linkUrl() == null || req.linkUrl().isBlank()) { + throw new BoardsException(BoardsErrorCode.INVALID_REQUEST, "link_url is required"); + } + if (req.title() == null || req.title().isBlank()) { + throw new BoardsException(BoardsErrorCode.INVALID_REQUEST, "title is required"); + } + + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BoardsException(BoardsErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId)); + + if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { + throw new BoardsException(BoardsErrorCode.PROJECT_MEMBER_FORBIDDEN, + "projectId=" + projectId + ", userId=" + userId); + } + + SharedDocument doc = SharedDocument.ofLink( + actor, + project, + req.title().trim(), + req.linkUrl().trim() + ); + + SharedDocument saved = sharedDocumentRepository.save(doc); + + Map meta = new LinkedHashMap<>(); + meta.put("documentId", saved.getId()); + meta.put("type", "LINK"); + meta.put("title", saved.getTitle()); + meta.put("url", saved.getLinkUrl()); + + historyPublisher.publish( + projectId, + userId, + HistoryAction.LINK_CREATED, + HistoryTargetType.DOCUMENT, + saved.getId(), + meta + ); + + return saved; + } } \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java index 410b1fff..fca638ab 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java @@ -25,6 +25,7 @@ import com.nect.core.entity.user.User; import com.nect.core.repository.team.ProjectRepository; import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.team.process.ProcessSharedDocumentRepository; import com.nect.core.repository.team.workspace.PostLikeRepository; import com.nect.core.repository.team.workspace.PostMentionRepository; import com.nect.core.repository.team.workspace.PostRepository; @@ -38,6 +39,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.*; @Service @@ -51,6 +53,7 @@ public class PostService { private final PostLikeRepository postLikeRepository; private final PostMentionRepository postMentionRepository; private final PostSharedDocumentRepository postSharedDocumentRepository; + private final ProcessSharedDocumentRepository processSharedDocumentRepository; private final ProjectHistoryPublisher historyPublisher; private final NotificationFacade notificationFacade; @@ -585,6 +588,28 @@ public void deletePost(Long projectId, Long userId, Long postId) { .orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND, "projectId=" + projectId + ", postId=" + postId)); + // 삭제 전 첨부 목록 조회 + List attached = postSharedDocumentRepository.findAllActiveByPostIdWithDocument(postId); + + // soft delete + post.softDelete(); + + LocalDateTime now = LocalDateTime.now(); + + for (PostSharedDocument psd : attached) { + // 연결부터 끊기 + psd.softDelete(); + + SharedDocument doc = psd.getDocument(); + if (doc == null || doc.getDeletedAt() != null) continue; + + // 공유문서함에서도 삭제 + doc.softDelete(); + + // 프로세스 첨부도 끊기 + processSharedDocumentRepository.softDeleteAllAttachments(projectId, doc.getId()); + } + // 작성자만 삭제 가능 Long authorId = post.getAuthor().getUserId(); if (!authorId.equals(userId)) { @@ -596,9 +621,6 @@ public void deletePost(Long projectId, Long userId, Long postId) { final PostType beforeType = post.getPostType(); final String beforeTitle = post.getTitle(); - // soft delete - post.softDelete(); - // 멘션도 soft delete 처리 postMentionRepository.findAllByPostId(post.getId()).forEach(PostMention::softDelete); diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java index 99b094e8..2b138d79 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java @@ -3,7 +3,9 @@ import com.epages.restdocs.apispec.ResourceDocumentation; import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.workspace.dto.req.SharedDocumentLinkCreateReqDto; import com.nect.api.domain.team.workspace.dto.req.SharedDocumentNameUpdateReqDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentCreatedResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentNameUpdateResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsGetResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsPreviewResDto; @@ -23,6 +25,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; @@ -30,6 +33,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; import java.util.List; @@ -42,14 +46,12 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.verify; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; import static org.springframework.restdocs.payload.JsonFieldType.*; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @@ -454,4 +456,151 @@ void deleteSharedDocument() throws Exception { verify(facade).delete(eq(projectId), eq(userId), eq(documentId)); } + + @Test + @DisplayName("공유 문서함 파일 업로드") + void uploadSharedDocumentFile() throws Exception { + long projectId = 1L; + long userId = 1L; + + MockMultipartFile file = new MockMultipartFile( + "file", + "api-spec.pdf", + "application/pdf", + "dummy pdf content".getBytes() + ); + + SharedDocumentCreatedResDto res = new SharedDocumentCreatedResDto( + 500L, + DocumentType.FILE, + "api-spec.pdf", + null, + "api-spec.pdf", + FileExt.PDF, + 1234L, + "https://example.com/presigned/500" + ); + + given(facade.uploadFile(eq(projectId), eq(userId), any(MultipartFile.class))) + .willReturn(res); + + mockMvc.perform(multipart("/api/v1/projects/{projectId}/boards/shared-documents/files", projectId) + .file(file) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("boards-shared-documents-file-upload", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Boards") + .summary("공유 문서함 파일 업로드") + .description("공유 문서함에 파일을 업로드합니다. (R2 업로드 + SharedDocument(FILE) 생성)") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.document_id").type(NUMBER).description("문서 ID"), + fieldWithPath("body.document_type").type(STRING).description("문서 타입(FILE)"), + fieldWithPath("body.title").type(STRING).description("표시명(title)"), + fieldWithPath("body.link_url").optional().type(STRING).description("링크 URL (FILE이면 null)"), + + fieldWithPath("body.file_name").type(STRING).description("파일명"), + fieldWithPath("body.file_ext").type(STRING).description("파일 확장자"), + fieldWithPath("body.file_size").type(NUMBER).description("파일 크기(byte)"), + fieldWithPath("body.download_url").type(STRING).description("Presigned 다운로드 URL") + ) + .build() + ) + )); + + verify(facade).uploadFile(eq(projectId), eq(userId), any(MultipartFile.class)); + } + + @Test + @DisplayName("공유 문서함 링크 생성") + void createSharedDocumentLink() throws Exception { + long projectId = 1L; + long userId = 1L; + + SharedDocumentLinkCreateReqDto req = new SharedDocumentLinkCreateReqDto( + "Backend Repo", + "https://github.com/nect/nect-backend" + ); + + SharedDocumentCreatedResDto res = new SharedDocumentCreatedResDto( + 600L, + DocumentType.LINK, + "Backend Repo", + "https://github.com/nect/nect-backend", + null, + null, + 0L, + null + ); + + given(facade.createLink(eq(projectId), eq(userId), any(SharedDocumentLinkCreateReqDto.class))) + .willReturn(res); + + mockMvc.perform(post("/api/v1/projects/{projectId}/boards/shared-documents/links", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("boards-shared-documents-link-create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Boards") + .summary("공유 문서함 링크 생성") + .description("공유 문서함에 링크를 생성합니다. (SharedDocument(LINK) 생성)") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("title").type(STRING).description("표시명(title)"), + fieldWithPath("link_url").type(STRING).description("링크 URL") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.document_id").type(NUMBER).description("문서 ID"), + fieldWithPath("body.document_type").type(STRING).description("문서 타입(LINK)"), + fieldWithPath("body.title").type(STRING).description("표시명(title)"), + fieldWithPath("body.link_url").type(STRING).description("링크 URL"), + + fieldWithPath("body.file_name").optional().type(STRING).description("파일명 (LINK면 null)"), + fieldWithPath("body.file_ext").optional().type(STRING).description("파일 확장자 (LINK면 null)"), + fieldWithPath("body.file_size").optional().type(NUMBER).description("파일 크기 (LINK면 0 또는 null)"), + fieldWithPath("body.download_url").optional().type(STRING).description("다운로드 URL (LINK면 null)") + ) + .build() + ) + )); + + verify(facade).createLink(eq(projectId), eq(userId), any(SharedDocumentLinkCreateReqDto.class)); + } + + } diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java index e1767fae..9996c0f3 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java @@ -427,6 +427,50 @@ void updatePost() throws Exception { verify(postFacade).updatePost(eq(projectId), eq(userId), eq(postId), any(PostUpdateReqDto.class)); } + @Test + @DisplayName("게시글 삭제") + void deletePost() throws Exception { + long projectId = 1L; + long postId = 100L; + long userId = 1L; + + doNothing().when(postFacade).deletePost(eq(projectId), eq(userId), eq(postId)); + + mockMvc.perform(delete("/api/v1/projects/{projectId}/boards/posts/{postId}", projectId, postId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("post-delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Post") + .summary("게시글 삭제") + .description("게시글을 삭제합니다. 작성자만 삭제할 수 있습니다. (soft delete)") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("postId").description("게시글 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + fieldWithPath("body").optional().type(NULL).description("응답 바디(삭제는 null)") + ) + .build() + ) + )); + + verify(postFacade).deletePost(eq(projectId), eq(userId), eq(postId)); + } + + @Test @DisplayName("게시글 프리뷰 조회") void getPostsPreview() throws Exception { diff --git a/nect-core/src/main/java/com/nect/core/entity/team/history/enums/HistoryAction.java b/nect-core/src/main/java/com/nect/core/entity/team/history/enums/HistoryAction.java index 1d27fddb..aeb11898 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/history/enums/HistoryAction.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/history/enums/HistoryAction.java @@ -24,6 +24,9 @@ public enum HistoryAction { LINK_ATTACHED, LINK_DETACHED, + DOCUMENT_CREATED, + LINK_CREATED, + DOCUMENT_RENAMED, DOCUMENT_DELETED, diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java index 34638c03..7b1247ef 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java @@ -82,4 +82,12 @@ interface AttachmentMetaRow { and psd.process.id in :processIds """) List findAttachmentMetasByProcessIds(@Param("processIds") List processIds); + + @Query(""" + select (count(psd) > 0) + from ProcessSharedDocument psd + where psd.document.id = :documentId + and psd.deletedAt is null + """) + boolean existsActiveByDocumentId(@Param("documentId") Long documentId); } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostSharedDocumentRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostSharedDocumentRepository.java index 9675f3fa..052ac77c 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostSharedDocumentRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostSharedDocumentRepository.java @@ -2,9 +2,11 @@ import com.nect.core.entity.team.workspace.PostSharedDocument; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -22,4 +24,25 @@ public interface PostSharedDocumentRepository extends JpaRepository findAllActiveByPostIdWithDocument(@Param("postId") Long postId); + + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update PostSharedDocument psd + set psd.deletedAt = :deletedAt + where psd.post.id = :postId + and psd.deletedAt is null + """) + int softDeleteAllByPostId(@Param("postId") Long postId, @Param("deletedAt") LocalDateTime deletedAt); + + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update PostSharedDocument psd + set psd.deletedAt = :deletedAt + where psd.document.id = :documentId + and psd.deletedAt is null +""") + int softDeleteAllByDocumentId(@Param("documentId") Long documentId, + @Param("deletedAt") LocalDateTime deletedAt); } From 95d0827f7e903b73ce6b00338e733181c5b01944 Mon Sep 17 00:00:00 2001 From: infiniment Date: Tue, 10 Feb 2026 01:12:10 +0900 Subject: [PATCH 66/66] =?UTF-8?q?[Test]=20API=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ProcessControllerTest.java | 133 +++++++++--------- 1 file changed, 69 insertions(+), 64 deletions(-) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java index bcda9f60..593bc90a 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java @@ -24,6 +24,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; @@ -814,70 +815,74 @@ void getWeekProcesses() throws Exception { fieldWithPath("body.weeks[].start_date").type(STRING).description("주 시작일(yyyy-MM-dd)"), - fieldWithPath("body.weeks[].common_lane").type(ARRAY).description("공통 레인 프로세스 카드 목록"), - fieldWithPath("body.weeks[].common_lane[].process_id").type(NUMBER).description("프로세스 ID"), - fieldWithPath("body.weeks[].common_lane[].process_status").type(STRING).description("프로세스 상태"), - fieldWithPath("body.weeks[].common_lane[].title").type(STRING).description("프로세스 제목"), - fieldWithPath("body.weeks[].common_lane[].complete_check_list").type(NUMBER).description("완료 체크리스트 개수"), - fieldWithPath("body.weeks[].common_lane[].whole_check_list").type(NUMBER).description("전체 체크리스트 개수"), - fieldWithPath("body.weeks[].common_lane[].start_date").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), - fieldWithPath("body.weeks[].common_lane[].dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)"), - fieldWithPath("body.weeks[].common_lane[].left_day").optional().type(NUMBER).description("남은 일수(null 가능)"), - fieldWithPath("body.weeks[].common_lane[].role_fields").type(ARRAY).description("역할 분야(RoleField enum) 목록"), - fieldWithPath("body.weeks[].common_lane[].custom_fields").type(ARRAY).description("커스텀 분야명 목록"), - fieldWithPath("body.weeks[].common_lane[].mission_number").optional().type(NUMBER).description("미션 번호(null 가능)"), - fieldWithPath("body.weeks[].common_lane[].has_open_feedback").type(BOOLEAN).description("OPEN 피드백 존재 여부"), - fieldWithPath("body.weeks[].common_lane[].assignee").type(ARRAY).description("담당자 목록"), - fieldWithPath("body.weeks[].common_lane[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), - fieldWithPath("body.weeks[].common_lane[].assignee[].user_name").type(STRING).description("담당자 이름"), - fieldWithPath("body.weeks[].common_lane[].assignee[].nickname").type(STRING).description("담당자 닉네임"), - fieldWithPath("body.weeks[].common_lane[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL"), - - fieldWithPath("body.weeks[].common_lane[].attachment_summary").type(OBJECT).description("첨부 요약"), - fieldWithPath("body.weeks[].common_lane[].attachment_summary.total_count").type(NUMBER).description("총 첨부 수(file+link)"), - fieldWithPath("body.weeks[].common_lane[].attachment_summary.file_count").type(NUMBER).description("파일 첨부 수"), - fieldWithPath("body.weeks[].common_lane[].attachment_summary.link_count").type(NUMBER).description("링크 첨부 수"), - fieldWithPath("body.weeks[].common_lane[].attachment_summary.file_exts").type(ARRAY).description("첨부된 파일 확장자 목록(중복 제거)"), - fieldWithPath("body.weeks[].common_lane[].attachments_meta").type(ARRAY).description("첨부 메타 목록(파일/링크 각각 documentId 기준)"), - fieldWithPath("body.weeks[].common_lane[].attachments_meta[].type").type(STRING).description("첨부 타입(FILE/LINK)"), - fieldWithPath("body.weeks[].common_lane[].attachments_meta[].document_id").type(NUMBER).description("SharedDocument ID"), - fieldWithPath("body.weeks[].common_lane[].attachments_meta[].attached_at").type(STRING).description("첨부 시각(ISO LocalDateTime)"), - fieldWithPath("body.weeks[].common_lane[].attachments_meta[].file_ext").optional().type(STRING).description("파일 확장자(FILE만, LINK는 null)"), - - fieldWithPath("body.weeks[].by_field").type(ARRAY).description("분야별(Field) 그룹 목록"), - fieldWithPath("body.weeks[].by_field[].field_id").type(STRING).description("fieldId (예: ROLE:BACKEND / CUSTOM:영상편집)"), - fieldWithPath("body.weeks[].by_field[].field_name").type(STRING).description("fieldName (예: BACKEND / 영상편집)"), - fieldWithPath("body.weeks[].by_field[].field_order").type(NUMBER).description("fieldOrder"), - fieldWithPath("body.weeks[].by_field[].processes").type(ARRAY).description("해당 그룹의 프로세스 목록"), - - fieldWithPath("body.weeks[].by_field[].processes[].process_id").type(NUMBER).description("프로세스 ID"), - fieldWithPath("body.weeks[].by_field[].processes[].process_status").type(STRING).description("프로세스 상태"), - fieldWithPath("body.weeks[].by_field[].processes[].title").type(STRING).description("프로세스 제목"), - fieldWithPath("body.weeks[].by_field[].processes[].complete_check_list").type(NUMBER).description("완료 체크리스트 개수"), - fieldWithPath("body.weeks[].by_field[].processes[].whole_check_list").type(NUMBER).description("전체 체크리스트 개수"), - fieldWithPath("body.weeks[].by_field[].processes[].start_date").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), - fieldWithPath("body.weeks[].by_field[].processes[].dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)"), - fieldWithPath("body.weeks[].by_field[].processes[].left_day").optional().type(NUMBER).description("남은 일수(null 가능)"), - fieldWithPath("body.weeks[].by_field[].processes[].role_fields").type(ARRAY).description("역할 분야(RoleField enum) 목록"), - fieldWithPath("body.weeks[].by_field[].processes[].custom_fields").type(ARRAY).description("커스텀 분야명 목록"), - fieldWithPath("body.weeks[].by_field[].processes[].mission_number").optional().type(NUMBER).description("미션 번호(null 가능)"), - fieldWithPath("body.weeks[].by_field[].processes[].has_open_feedback").type(BOOLEAN).description("OPEN 피드백 존재 여부"), - fieldWithPath("body.weeks[].by_field[].processes[].assignee").type(ARRAY).description("담당자 목록"), - fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), - fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_name").type(STRING).description("담당자 이름"), - fieldWithPath("body.weeks[].by_field[].processes[].assignee[].nickname").type(STRING).description("담당자 닉네임"), - fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL"), - - fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary").type(OBJECT).description("첨부 요약"), - fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.total_count").type(NUMBER).description("총 첨부 수(file+link)"), - fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.file_count").type(NUMBER).description("파일 첨부 수"), - fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.link_count").type(NUMBER).description("링크 첨부 수"), - fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.file_exts").type(ARRAY).description("첨부된 파일 확장자 목록(중복 제거)"), - fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta").type(ARRAY).description("첨부 메타 목록"), - fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].type").type(STRING).description("첨부 타입(FILE/LINK)"), - fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].document_id").type(NUMBER).description("SharedDocument ID"), - fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].attached_at").type(STRING).description("첨부 시각(ISO LocalDateTime)"), - fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].file_ext").optional().type(STRING).description("파일 확장자(FILE만, LINK는 null)") + fieldWithPath("body.weeks[].common_lane").type(JsonFieldType.ARRAY).description("공통 레인 프로세스 목록"), + fieldWithPath("body.weeks[].common_lane[].process_id").type(JsonFieldType.NUMBER).description("프로세스 ID"), + fieldWithPath("body.weeks[].common_lane[].process_status").type(JsonFieldType.STRING).description("프로세스 상태"), + fieldWithPath("body.weeks[].common_lane[].title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("body.weeks[].common_lane[].complete_check_list").type(JsonFieldType.NUMBER).description("완료 체크리스트 수"), + fieldWithPath("body.weeks[].common_lane[].whole_check_list").type(JsonFieldType.NUMBER).description("전체 체크리스트 수"), + fieldWithPath("body.weeks[].common_lane[].start_date").type(JsonFieldType.STRING).description("시작일(YYYY-MM-DD)"), + fieldWithPath("body.weeks[].common_lane[].dead_line").type(JsonFieldType.STRING).description("마감일(YYYY-MM-DD)"), + fieldWithPath("body.weeks[].common_lane[].left_day").type(JsonFieldType.NUMBER).description("마감까지 남은 일수"), + fieldWithPath("body.weeks[].common_lane[].role_fields").type(JsonFieldType.ARRAY).description("역할 필드(enum)"), + fieldWithPath("body.weeks[].common_lane[].custom_fields").type(JsonFieldType.ARRAY).description("커스텀 필드"), + fieldWithPath("body.weeks[].common_lane[].mission_number").type(JsonFieldType.NUMBER).description("미션 번호"), + fieldWithPath("body.weeks[].common_lane[].has_open_feedback").type(JsonFieldType.BOOLEAN).description("열린 피드백 존재 여부"), + + fieldWithPath("body.weeks[].common_lane[].assignee").type(JsonFieldType.ARRAY).description("담당자 목록"), + fieldWithPath("body.weeks[].common_lane[].assignee[].user_id").type(JsonFieldType.NUMBER).description("유저 ID"), + fieldWithPath("body.weeks[].common_lane[].assignee[].user_name").type(JsonFieldType.STRING).description("유저 이름"), + fieldWithPath("body.weeks[].common_lane[].assignee[].nickname").type(JsonFieldType.STRING).description("닉네임"), + fieldWithPath("body.weeks[].common_lane[].assignee[].user_image").type(JsonFieldType.STRING).optional().description("프로필 이미지 URL(없으면 null)"), + + fieldWithPath("body.weeks[].common_lane[].attachment_summary").type(JsonFieldType.OBJECT).description("첨부 요약"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary.total_count").type(JsonFieldType.NUMBER).description("첨부 총 개수"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary.file_count").type(JsonFieldType.NUMBER).description("파일 첨부 개수"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary.link_count").type(JsonFieldType.NUMBER).description("링크 첨부 개수"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary.file_extensions").type(JsonFieldType.ARRAY).optional().description("첨부 파일 확장자 목록(enum, 예: PDF, PNG). 파일이 없으면 빈 배열"), + + fieldWithPath("body.weeks[].common_lane[].attachments_meta").type(JsonFieldType.ARRAY).description("첨부 메타 목록"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta[].type").type(JsonFieldType.STRING).description("첨부 타입(FILE/LINK)"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta[].document_id").type(JsonFieldType.NUMBER).description("문서 ID"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta[].attached_at").type(JsonFieldType.STRING).description("첨부 시각(ISO-8601)"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta[].file_ext").type(JsonFieldType.STRING).optional().description("파일 확장자(enum). 링크면 null"), + + fieldWithPath("body.weeks[].by_field").type(JsonFieldType.ARRAY).description("필드별 레인 목록"), + fieldWithPath("body.weeks[].by_field[].field_id").type(JsonFieldType.STRING).description("필드 ID (예: ROLE:BACKEND, CUSTOM:영상편집)"), + fieldWithPath("body.weeks[].by_field[].field_name").type(JsonFieldType.STRING).description("필드명"), + fieldWithPath("body.weeks[].by_field[].field_order").type(JsonFieldType.NUMBER).description("필드 순서"), + fieldWithPath("body.weeks[].by_field[].processes").type(JsonFieldType.ARRAY).description("해당 필드의 프로세스 목록"), + + fieldWithPath("body.weeks[].by_field[].processes[].process_id").type(JsonFieldType.NUMBER).description("프로세스 ID"), + fieldWithPath("body.weeks[].by_field[].processes[].process_status").type(JsonFieldType.STRING).description("프로세스 상태"), + fieldWithPath("body.weeks[].by_field[].processes[].title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("body.weeks[].by_field[].processes[].complete_check_list").type(JsonFieldType.NUMBER).description("완료 체크리스트 수"), + fieldWithPath("body.weeks[].by_field[].processes[].whole_check_list").type(JsonFieldType.NUMBER).description("전체 체크리스트 수"), + fieldWithPath("body.weeks[].by_field[].processes[].start_date").type(JsonFieldType.STRING).description("시작일(YYYY-MM-DD)"), + fieldWithPath("body.weeks[].by_field[].processes[].dead_line").type(JsonFieldType.STRING).description("마감일(YYYY-MM-DD)"), + fieldWithPath("body.weeks[].by_field[].processes[].left_day").type(JsonFieldType.NUMBER).description("마감까지 남은 일수"), + fieldWithPath("body.weeks[].by_field[].processes[].role_fields").type(JsonFieldType.ARRAY).description("역할 필드(enum)"), + fieldWithPath("body.weeks[].by_field[].processes[].custom_fields").type(JsonFieldType.ARRAY).description("커스텀 필드"), + fieldWithPath("body.weeks[].by_field[].processes[].mission_number").type(JsonFieldType.NUMBER).description("미션 번호"), + fieldWithPath("body.weeks[].by_field[].processes[].has_open_feedback").type(JsonFieldType.BOOLEAN).description("열린 피드백 존재 여부"), + + fieldWithPath("body.weeks[].by_field[].processes[].assignee").type(JsonFieldType.ARRAY).description("담당자 목록"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_id").type(JsonFieldType.NUMBER).description("유저 ID"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_name").type(JsonFieldType.STRING).description("유저 이름"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].nickname").type(JsonFieldType.STRING).description("닉네임"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_image").type(JsonFieldType.STRING).optional().description("프로필 이미지 URL(없으면 null)"), + + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary").type(JsonFieldType.OBJECT).description("첨부 요약"), + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.total_count").type(JsonFieldType.NUMBER).description("첨부 총 개수"), + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.file_count").type(JsonFieldType.NUMBER).description("파일 첨부 개수"), + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.link_count").type(JsonFieldType.NUMBER).description("링크 첨부 개수"), + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.file_extensions").type(JsonFieldType.ARRAY).optional().description("첨부 파일 확장자 목록(enum). 파일이 없으면 빈 배열"), + + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta").type(JsonFieldType.ARRAY).description("첨부 메타 목록"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].type").type(JsonFieldType.STRING).description("첨부 타입(FILE/LINK)"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].document_id").type(JsonFieldType.NUMBER).description("문서 ID"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].attached_at").type(JsonFieldType.STRING).description("첨부 시각(ISO-8601)"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].file_ext").type(JsonFieldType.STRING).optional().description("파일 확장자(enum). 링크면 null") ) .build() )