diff --git a/.gitignore b/.gitignore
index 8409de82..01ba56db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,4 @@ out/
### VS Code ###
.vscode/
+app.log
diff --git a/README.md b/README.md
index c53e9b40..6a823dc2 100644
--- a/README.md
+++ b/README.md
@@ -13,5 +13,39 @@ VECO는 **AI를 활용해 분산된 정보를 한곳에 모으고, 목표를 명
- Redis
- WebClient
- AWS S3
-- Security
+- Spring Security
---
+## 🏗️ 프로젝트 구조
+- 도메인 주도 설계 (DDD)
+- main - develop 브랜치 구조: main = 배포 환경 / develop = 개발 환경
+- 새로운 브랜치명은 #이슈번호-이슈제목 으로 통일
+- 새로운 기능은 Feat/, 리팩토링은 Refactor/, 핫픽스는 Fix/로 통일
+
+### 📋 커밋 메시지 컨벤션
+| Gitmoji | Tag | Description |
+|:-----------:|:----------:| --- |
+| ✨ | `feat` | 새로운 기능 추가 |
+| 🐛 | `fix` | 버그 수정 |
+| 📝 | `docs` | 문서 추가, 수정, 삭제 |
+| ✅ | `test` | 테스트 코드 추가, 수정, 삭제 |
+| 💄 | `style` | 코드 형식 변경 |
+| ♻️ | `refactor` | 코드 리팩토링 |
+| ⚡️ | `perf` | 성능 개선 |
+| 💚 | `ci` | CI 관련 설정 수정 |
+| 🚀 | `chore` | 기타 변경사항 |
+| 🔥️ | `remove` | 코드 및 파일 제거 |
+
+---
+## 👤 담당 도메인
+- 이람/박승범: 외부이슈, Github 연동
+- 나우/고낭연: 워크스페이스, 사용자 세팅
+- 비니/문소빈: OAuth, 이슈
+- 이영/이은영: 이슈, 알림
+- 마크/김주헌: 목표, Slack 연동
+---
+## 🛜 서버 현황
+
+
+- main-develop 브랜치 구조에 따라 배포환경, 개발환경을 따로 구성
+- 개발 환경(localhost:5173)과 배포 환경(web.vecoservice.shop)간 테스트를 진행
+- 추후, 서버를 통합 관리해 무중단 배포 전략(블루-그린) 사용
diff --git a/src/main/java/com/example/Veco/domain/assignee/converter/AssigneeConverter.java b/src/main/java/com/example/Veco/domain/assignee/converter/AssigneeConverter.java
index 777d4e7e..966d2f82 100644
--- a/src/main/java/com/example/Veco/domain/assignee/converter/AssigneeConverter.java
+++ b/src/main/java/com/example/Veco/domain/assignee/converter/AssigneeConverter.java
@@ -1,6 +1,7 @@
package com.example.Veco.domain.assignee.converter;
import com.example.Veco.domain.assignee.entity.Assignee;
+import com.example.Veco.domain.external.entity.External;
import com.example.Veco.domain.goal.entity.Goal;
import com.example.Veco.domain.issue.entity.Issue;
import com.example.Veco.domain.mapping.entity.MemberTeam;
@@ -34,4 +35,17 @@ public static Assignee toIssueAssignee(
.issue(issue)
.build();
}
+
+ public static Assignee toExternalAssignee(
+ MemberTeam memberTeam,
+ Category type,
+ External external
+ ) {
+ return Assignee.builder()
+ .targetId(external.getId())
+ .type(type)
+ .memberTeam(memberTeam)
+ .external(external)
+ .build();
+ }
}
diff --git a/src/main/java/com/example/Veco/domain/assignee/entity/Assignee.java b/src/main/java/com/example/Veco/domain/assignee/entity/Assignee.java
index 7919eec7..137aaf6d 100644
--- a/src/main/java/com/example/Veco/domain/assignee/entity/Assignee.java
+++ b/src/main/java/com/example/Veco/domain/assignee/entity/Assignee.java
@@ -45,4 +45,9 @@ public class Assignee extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_team_id")
private MemberTeam memberTeam;
+
+ // update
+ public void updateMemberTeam(MemberTeam memberTeam) {
+ this.memberTeam = memberTeam;
+ }
}
diff --git a/src/main/java/com/example/Veco/domain/assignee/repository/AssigneeQueryDsl.java b/src/main/java/com/example/Veco/domain/assignee/repository/AssigneeQueryDsl.java
index 05cb8316..9d9cb828 100644
--- a/src/main/java/com/example/Veco/domain/assignee/repository/AssigneeQueryDsl.java
+++ b/src/main/java/com/example/Veco/domain/assignee/repository/AssigneeQueryDsl.java
@@ -12,5 +12,6 @@ public interface AssigneeQueryDsl {
List findByGoalIdIn(List goalIds);
List findByExternalIdIn(List externalIds);
void deleteAllByTypeAndTargetIds(Category type, List targetIds);
+ List findByTypeAndTargetId(Category type, Long targetId);
}
diff --git a/src/main/java/com/example/Veco/domain/assignee/repository/AssigneeQueryDslImpl.java b/src/main/java/com/example/Veco/domain/assignee/repository/AssigneeQueryDslImpl.java
index c360b9b2..a15856c9 100644
--- a/src/main/java/com/example/Veco/domain/assignee/repository/AssigneeQueryDslImpl.java
+++ b/src/main/java/com/example/Veco/domain/assignee/repository/AssigneeQueryDslImpl.java
@@ -89,4 +89,19 @@ public void deleteAllByTypeAndTargetIds(Category type, List targetIds) {
)
.execute();
}
+
+ @Override
+ public List findByTypeAndTargetId(Category type, Long targetId) {
+ QAssignee assignee = QAssignee.assignee;
+ QMember member = QMember.member;
+
+ return queryFactory.selectFrom(assignee)
+ .innerJoin(member).on(member.id.eq(assignee.memberTeam.member.id))
+ .where(
+ assignee.type.eq(type),
+ assignee.targetId.eq(targetId),
+ member.deletedAt.isNull()
+ )
+ .fetch();
+ }
}
diff --git a/src/main/java/com/example/Veco/domain/assignee/repository/AssigneeRepository.java b/src/main/java/com/example/Veco/domain/assignee/repository/AssigneeRepository.java
index 84a457f7..949319a9 100644
--- a/src/main/java/com/example/Veco/domain/assignee/repository/AssigneeRepository.java
+++ b/src/main/java/com/example/Veco/domain/assignee/repository/AssigneeRepository.java
@@ -12,5 +12,7 @@ public interface AssigneeRepository extends JpaRepository , Assi
void deleteAllByTypeAndTargetId(Category type, Long targetId);
- List findAllByTypeAndMemberTeam_Team_id(Category type, Long memberTeamTeamId);
+ List findAllByMemberTeamIdIn(List memberTeamIds);
+
+ List findAllByGoal_TeamIdAndMemberTeamIsNull(Long teamId);
}
diff --git a/src/main/java/com/example/Veco/domain/comment/entity/Comment.java b/src/main/java/com/example/Veco/domain/comment/entity/Comment.java
index fd4cd877..3155c48c 100644
--- a/src/main/java/com/example/Veco/domain/comment/entity/Comment.java
+++ b/src/main/java/com/example/Veco/domain/comment/entity/Comment.java
@@ -1,11 +1,15 @@
package com.example.Veco.domain.comment.entity;
import com.example.Veco.domain.common.BaseEntity;
+import com.example.Veco.domain.mapping.entity.CommentMember;
import com.example.Veco.domain.member.entity.Member;
import jakarta.persistence.*;
import lombok.*;
+import java.util.ArrayList;
+import java.util.List;
+
@Entity
@Getter
@Builder
@@ -27,6 +31,9 @@ public class Comment extends BaseEntity {
@JoinColumn(name = "comment_room_id")
private CommentRoom commentRoom;
+ @OneToMany(mappedBy = "comment", cascade = CascadeType.REMOVE, orphanRemoval = true)
+ private List commentMembers = new ArrayList<>();
+
public void setMember(Member member) {
this.member = member;
}
diff --git a/src/main/java/com/example/Veco/domain/comment/repository/CommentQueryDsl.java b/src/main/java/com/example/Veco/domain/comment/repository/CommentQueryDsl.java
new file mode 100644
index 00000000..99eb071b
--- /dev/null
+++ b/src/main/java/com/example/Veco/domain/comment/repository/CommentQueryDsl.java
@@ -0,0 +1,10 @@
+package com.example.Veco.domain.comment.repository;
+
+import com.example.Veco.domain.comment.entity.Comment;
+import com.example.Veco.domain.comment.entity.CommentRoom;
+
+import java.util.List;
+
+public interface CommentQueryDsl {
+ List findByCommentRoomOrderByIdDesc(CommentRoom commentRoom);
+}
diff --git a/src/main/java/com/example/Veco/domain/comment/repository/CommentQueryDslImpl.java b/src/main/java/com/example/Veco/domain/comment/repository/CommentQueryDslImpl.java
new file mode 100644
index 00000000..2b071a1d
--- /dev/null
+++ b/src/main/java/com/example/Veco/domain/comment/repository/CommentQueryDslImpl.java
@@ -0,0 +1,36 @@
+package com.example.Veco.domain.comment.repository;
+
+import com.example.Veco.domain.comment.entity.Comment;
+import com.example.Veco.domain.comment.entity.CommentRoom;
+import com.example.Veco.domain.comment.entity.QComment;
+import com.example.Veco.domain.comment.entity.QCommentRoom;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Repository;
+
+import java.util.Collections;
+import java.util.List;
+
+@Repository
+@RequiredArgsConstructor
+@Slf4j
+public class CommentQueryDslImpl implements CommentQueryDsl {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public List findByCommentRoomOrderByIdDesc(CommentRoom commentRoom) {
+ QComment comment = QComment.comment;
+
+ if (commentRoom == null) {
+ return Collections.emptyList();
+ }
+
+ return queryFactory
+ .selectFrom(comment)
+ .where(comment.commentRoom.eq(commentRoom))
+ .orderBy(comment.id.desc())
+ .fetch();
+ }
+}
diff --git a/src/main/java/com/example/Veco/domain/comment/repository/CommentRepository.java b/src/main/java/com/example/Veco/domain/comment/repository/CommentRepository.java
index 8fa97ed3..98c443f9 100644
--- a/src/main/java/com/example/Veco/domain/comment/repository/CommentRepository.java
+++ b/src/main/java/com/example/Veco/domain/comment/repository/CommentRepository.java
@@ -2,10 +2,14 @@
import com.example.Veco.domain.comment.entity.Comment;
import com.example.Veco.domain.comment.entity.CommentRoom;
+import com.example.Veco.domain.member.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
-public interface CommentRepository extends JpaRepository {
+public interface CommentRepository extends JpaRepository, CommentQueryDsl {
List findAllByCommentRoomOrderByIdDesc(CommentRoom commentRoom);
+ List findAllByCommentRoomOrderByIdAsc(CommentRoom commentRoom);
+ List findByCommentRoomOrderByIdAsc(CommentRoom commentRoom);
+ void deleteAllByMember(Member member);
}
diff --git a/src/main/java/com/example/Veco/domain/external/controller/ExternalController.java b/src/main/java/com/example/Veco/domain/external/controller/ExternalController.java
index 6773a070..03ae7e49 100644
--- a/src/main/java/com/example/Veco/domain/external/controller/ExternalController.java
+++ b/src/main/java/com/example/Veco/domain/external/controller/ExternalController.java
@@ -1,18 +1,15 @@
package com.example.Veco.domain.external.controller;
-import com.example.Veco.domain.external.dto.paging.ExternalCursorPageResponse;
import com.example.Veco.domain.external.dto.paging.ExternalSearchCriteria;
import com.example.Veco.domain.external.dto.request.ExternalRequestDTO;
-import com.example.Veco.domain.external.dto.response.ExternalApiResponse;
import com.example.Veco.domain.external.dto.response.ExternalResponseDTO;
import com.example.Veco.domain.external.dto.response.ExternalGroupedResponseDTO;
import com.example.Veco.domain.external.exception.code.ExternalSuccessCode;
import com.example.Veco.domain.external.service.ExternalService;
+import com.example.Veco.domain.goal.dto.request.GoalReqDTO;
+import com.example.Veco.domain.goal.dto.response.GoalResDTO;
import com.example.Veco.global.apiPayload.ApiResponse;
-import com.example.Veco.global.apiPayload.page.CursorPage;
-import com.example.Veco.global.enums.ExtServiceType;
-import com.example.Veco.global.enums.Priority;
-import com.example.Veco.global.enums.State;
+import com.example.Veco.global.auth.user.AuthUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
@@ -22,218 +19,90 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
+import java.util.List;
+
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/teams/{teamId}/externals")
@Tag(name = "외부이슈 API")
-public class ExternalController {
+public class ExternalController implements ExternalSwaggerDocs{
private final ExternalService externalService;
- @Operation(
- summary = "팀 내 외부이슈 상세조회 API",
- description = "외부이슈 식별자를 통해서 외부이슈의 상세 데이터를 가져옵니다."
- )
- @ApiResponses({
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "200",
- description = "외부이슈 조회 성공",
- content = @Content(
- schema = @Schema(implementation = ExternalApiResponse.class)
- )
- ),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "404",
- description = "외부이슈를 찾을 수 없음",
- content = @Content(
- schema = @Schema(implementation = ApiResponse.class),
- examples = @ExampleObject(
- name = "외부이슈 없음",
- value = "{\"isSuccess\": false, \"code\": \"EXTERNAL400\", \"message\": \"해당하는 외부 이슈가 존재하지 않습니다.\", \"result\": null}"
- )
- )
- )
- })
+ @GetMapping("/externals-simple")
+ public ApiResponse getSimpleExternals(@PathVariable Long teamId) {
+ return ApiResponse.onSuccess(externalService.getSimpleExternals(teamId));
+ }
+
@GetMapping("/{externalId}")
- public ApiResponse getExternal(
- @Parameter(description = "외부이슈 ID", required = true) @PathVariable Long externalId) {
- return ApiResponse.onSuccess(externalService.getExternalById(externalId));
+ public ApiResponse getExternal(@PathVariable Long teamId,
+ @PathVariable Long externalId) {
+ return ApiResponse.onSuccess(externalService.getExternalById(externalId, teamId));
}
- @Operation(
- summary = "팀 내 외부이슈 전체 조회 API",
- description = "팀 내에서 생성된 외부이슈들을 모두 조회하는 API입니다. 커서기반 페이지네이션으로 전달하고, " +
- "필터조건으로 상태, 담당자의 식별자, 우선순위를 받습니다. 초기 조회 시 커서를 비워두시고, 이어서 조회할 때, 전달받은 다음 커서 값을 파라미터로 넘겨주시면 됩니다."
- )
- @ApiResponses({
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "200",
- description = "외부이슈 목록 조회 성공",
- content = @Content(schema = @Schema(implementation = ExternalCursorPageResponse.class))
- ),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "400",
- description = "잘못된 요청 파라미터",
- content = @Content(
- schema = @Schema(implementation = ApiResponse.class),
- examples = @ExampleObject(
- name = "잘못된 파라미터",
- value = "{\"isSuccess\": false, \"code\": \"COMMON400\", \"message\": \"잘못된 요청입니다.\", \"result\": null}"
- )
- )
- )
- })
+
@GetMapping
public ApiResponse getExternals(
- @Parameter(description = "팀 ID", required = true) @PathVariable("teamId") Long teamId,
- @Parameter(description = "이슈 상태 (선택)", required = false) @RequestParam(value = "state", required = false) State state,
- @Parameter(description = "우선순위 (선택)", required = false) @RequestParam(value = "priority", required = false) Priority priority,
- @Parameter(description = "담당자 ID (선택)", required = false) @RequestParam(value = "assigneeId", required = false) Long assigneeId,
- @Parameter(description = "목표 ID (선택)", required = false) @RequestParam(value = "goalId", required = false) Long goalId,
- @Parameter(description = "외부 연동 툴", required = false) @RequestParam(value = "extType", required = false) ExtServiceType extType,
- @Parameter(description = "페이징 커서 (다음 페이지 조회용)", required = false) @RequestParam(value = "cursor", required = false) String cursor,
- @Parameter(description = "페이지 크기 (기본값: 50)", required = false) @RequestParam(value = "size", defaultValue = "50") Integer size) {
+ @PathVariable("teamId") Long teamId,
+ @RequestParam(value = "query", defaultValue = "STATE") ExternalRequestDTO.ExternalGroupedSearchRequestDTO.FilterType query,
+ @RequestParam(value = "cursor", required = false) String cursor,
+ @RequestParam(value = "size", defaultValue = "50") Integer size) {
ExternalSearchCriteria searchCriteria = ExternalSearchCriteria.builder()
.teamId(teamId)
- .state(state)
- .priority(priority)
- .goalId(goalId)
- .extServiceType(extType)
- .assigneeId(assigneeId).build();
+ .filterType(query)
+ .build();
return ApiResponse.onSuccess(externalService.getExternalsWithGroupedPagination(searchCriteria, cursor, size));
}
+ @GetMapping("/external-name")
+ public ApiResponse> getExternalName(@PathVariable("teamId") Long teamId) {
+ return ApiResponse.onSuccess(externalService.getExternalName(teamId));
+ }
+
+ @GetMapping("/links")
+ public ApiResponse getExternalLinks(@PathVariable("teamId") Long teamId) {
+ return ApiResponse.onSuccess(externalService.getExternalServices(teamId));
+ }
+
+ @GetMapping("/deleted-externals")
+ public ApiResponse> getDeletedExternals(@PathVariable("teamId") Long teamId) {
+ return ApiResponse.onSuccess(externalService.getDeletedExternals(teamId));
+ }
+
// TODO : 깃허브 REST API 를 통해서 깃허브 레포지토리에도 이슈가 등록되도록! -> 양방향 동기화
- @Operation(
- summary = "팀 내에서 외부관련 이슈를 생성하는 API",
- description = "팀 내에서 외부 연동 툴과 관련된 이슈를 생성합니다."
- )
- @ApiResponses({
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "200",
- description = "외부이슈 생성 성공",
- content = @Content(schema = @Schema(implementation = ApiResponse.class))
- ),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "400",
- description = "잘못된 요청 데이터",
- content = @Content(
- schema = @Schema(implementation = ApiResponse.class),
- examples = @ExampleObject(
- name = "유효성 검증 실패",
- value = "{\"isSuccess\": false, \"code\": \"COMMON400\", \"message\": \"잘못된 요청입니다.\", \"result\": null}"
- )
- )
- ),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "404",
- description = "팀 또는 목표를 찾을 수 없음",
- content = @Content(
- schema = @Schema(implementation = ApiResponse.class),
- examples = {
- @ExampleObject(
- name = "팀 없음",
- value = "{\"isSuccess\": false, \"code\": \"TEAM400\", \"message\": \"해당하는 팀이 존재하지 않습니다.\", \"result\": null}"
- ),
- @ExampleObject(
- name = "목표 없음",
- value = "{\"isSuccess\": false, \"code\": \"GOAL400\", \"message\": \"해당하는 목표가 존재하지 않습니다.\", \"result\": null}"
- )
- }
- )
- )
- })
+
@PostMapping
public ApiResponse createExternal(
- @Parameter(description = "팀 ID", required = true) @PathVariable("teamId") Long teamId,
- @Valid @RequestBody ExternalRequestDTO.ExternalCreateRequestDTO requestDTO) {
+ @PathVariable("teamId") Long teamId,
+ @Valid @RequestBody ExternalRequestDTO.ExternalCreateRequestDTO requestDTO,
+ @AuthenticationPrincipal AuthUser user) {
+ return ApiResponse.onSuccess(externalService.createExternal(teamId, requestDTO, user));
+ }
- return ApiResponse.onSuccess(externalService.createExternal(teamId, requestDTO));
+ @PostMapping("/restore")
+ public ApiResponse> restoreGoals(
+ @RequestBody ExternalRequestDTO.ExternalDeleteRequestDTO dto
+ ){
+ return ApiResponse.onSuccess(externalService.restoreGoals(dto));
}
- @Operation(
- summary = "팀 내 외부이슈를 수정하는 API",
- description = "팀 내 외부이슈를 수정하는 API입니다. 담당자, 제목, 내용, 마감기한, 우선순위, 상태 값을 수정할 수 있습니다. " +
- "수정할 데이터 값들을 요청 바디에 담아 보내주시면 됩니다."
- )
- @ApiResponses({
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "200",
- description = "외부이슈 수정 성공",
- content = @Content(schema = @Schema(implementation = ApiResponse.class))
- ),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "400",
- description = "잘못된 요청 데이터",
- content = @Content(
- schema = @Schema(implementation = ApiResponse.class),
- examples = @ExampleObject(
- name = "유효성 검증 실패",
- value = "{\"isSuccess\": false, \"code\": \"COMMON400\", \"message\": \"잘못된 요청입니다.\", \"result\": null}"
- )
- )
- ),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "404",
- description = "외부이슈를 찾을 수 없음",
- content = @Content(
- schema = @Schema(implementation = ApiResponse.class),
- examples = @ExampleObject(
- name = "외부이슈 없음",
- value = "{\"isSuccess\": false, \"code\": \"EXTERNAL400\", \"message\": \"해당하는 외부 이슈가 존재하지 않습니다.\", \"result\": null}"
- )
- )
- )
- })
@PatchMapping("/{externalId}")
public ApiResponse modifyExternal(
- @Parameter(description = "외부이슈 ID", required = true) @PathVariable Long externalId,
+ @PathVariable Long externalId,
@Valid @RequestBody ExternalRequestDTO.ExternalUpdateRequestDTO requestDTO) {
return ApiResponse.onSuccess(externalService.updateExternal(externalId, requestDTO));
}
- @Operation(
- summary = "팀 내 외부이슈들을 한번에 삭제하는 API",
- description = "외부이슈들의 식별자들을 리스트로 받아서 한번에 삭제처리하는 API입니다. 해당 외부이슈들은 실제 데이터베이스에서 삭제되는 것이 아닌 " +
- "Soft Delete 방식으로, 휴지통으로 담기게 됩니다."
- )
- @ApiResponses({
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "200",
- description = "외부이슈 삭제 성공",
- content = @Content(schema = @Schema(implementation = ApiResponse.class))
- ),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "400",
- description = "잘못된 요청 데이터",
- content = @Content(
- schema = @Schema(implementation = ApiResponse.class),
- examples = @ExampleObject(
- name = "유효성 검증 실패",
- value = "{\"isSuccess\": false, \"code\": \"COMMON400\", \"message\": \"잘못된 요청입니다.\", \"result\": null}"
- )
- )
- )
- })
@DeleteMapping
public ApiResponse> deleteExternal(@Valid @RequestBody ExternalRequestDTO.ExternalDeleteRequestDTO requestDTO) {
- externalService.deleteExternals(requestDTO);
+ externalService.softDeleteExternals(requestDTO);
return ApiResponse.onSuccess(ExternalSuccessCode.DELETE);
}
-
- @GetMapping("/external-name")
- public ApiResponse> getExternalName(@PathVariable("teamId") Long teamId) {
- return ApiResponse.onSuccess(externalService.getExternalName(teamId));
- }
-
- @GetMapping("/links")
- public ApiResponse getExternalLinks(@PathVariable("teamId") Long teamId) {
- return ApiResponse.onSuccess(externalService.getExternalServices(teamId));
- }
}
diff --git a/src/main/java/com/example/Veco/domain/external/controller/ExternalSwaggerDocs.java b/src/main/java/com/example/Veco/domain/external/controller/ExternalSwaggerDocs.java
new file mode 100644
index 00000000..b74cde05
--- /dev/null
+++ b/src/main/java/com/example/Veco/domain/external/controller/ExternalSwaggerDocs.java
@@ -0,0 +1,202 @@
+package com.example.Veco.domain.external.controller;
+
+import com.example.Veco.domain.external.dto.paging.ExternalCursorPageResponse;
+import com.example.Veco.domain.external.dto.request.ExternalRequestDTO;
+import com.example.Veco.domain.external.dto.response.ExternalApiResponse;
+import com.example.Veco.domain.external.dto.response.ExternalGroupedResponseDTO;
+import com.example.Veco.domain.external.dto.response.ExternalResponseDTO;
+import com.example.Veco.global.apiPayload.ApiResponse;
+import com.example.Veco.global.auth.user.AuthUser;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import jakarta.validation.Valid;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import java.util.List;
+
+public interface ExternalSwaggerDocs {
+
+
+ @Operation(
+ summary = "팀 내 외부 이슈 간단 조회",
+ description = "팀의 모든 외부 이슈를 간단히 조회합니다."
+ )
+ ApiResponse getSimpleExternals(@PathVariable Long teamId);
+
+ @Operation(
+ summary = "팀 내 외부이슈 상세조회 API",
+ description = "외부이슈 식별자를 통해서 외부이슈의 상세 데이터를 가져옵니다."
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "200",
+ description = "외부이슈 조회 성공",
+ content = @Content(
+ schema = @Schema(implementation = ExternalResponseDTO.ExternalInfoDTO.class)
+ )
+ ),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "404",
+ description = "외부이슈를 찾을 수 없음",
+ content = @Content(
+ schema = @Schema(implementation = ApiResponse.class),
+ examples = @ExampleObject(
+ name = "외부이슈 없음",
+ value = "{\"isSuccess\": false, \"code\": \"EXTERNAL400\", \"message\": \"해당하는 외부 이슈가 존재하지 않습니다.\", \"result\": null}"
+ )
+ )
+ )
+ })
+ ApiResponse getExternal(@Parameter(description = "외부이슈 ID", required = true) @PathVariable Long externalId, @PathVariable Long teamId);
+
+ @Operation(
+ summary = "팀 내 외부이슈 전체 조회 API",
+ description = "팀 내에서 생성된 외부이슈들을 모두 조회하는 API입니다. 커서기반 페이지네이션으로 전달하고, " +
+ "필터조건으로 상태, 담당자, 우선순위, 목표, 외부 연동 툴을 받습니다. 한번에 하나의 필터만 적용가능합니다. 초기 조회 시 커서를 비워두시고, 이어서 조회할 때, 전달받은 다음 커서 값을 파라미터로 넘겨주시면 됩니다."
+ )
+ ApiResponse getExternals(
+ @Parameter(description = "팀 ID", required = true) @PathVariable("teamId") Long teamId,
+ @Parameter(description = "필터 타입 (STATE, PRIORITY, ASSIGNEE, GOAL, EXT_TYPE)", required = false) @RequestParam(value = "query", defaultValue = "STATE") ExternalRequestDTO.ExternalGroupedSearchRequestDTO.FilterType query,
+ @Parameter(description = "페이징 커서 (다음 페이지 조회용)", required = false) @RequestParam(value = "cursor", required = false) String cursor,
+ @Parameter(description = "페이지 크기 (기본값: 50)", required = false) @RequestParam(value = "size", defaultValue = "50") Integer size);
+
+ @Operation(
+ summary = "팀 내에서 외부관련 이슈를 생성하는 API",
+ description = "팀 내에서 외부 연동 툴과 관련된 이슈를 생성합니다."
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "200",
+ description = "외부이슈 생성 성공",
+ content = @Content(schema = @Schema(implementation = ApiResponse.class))
+ ),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "400",
+ description = "잘못된 요청 데이터",
+ content = @Content(
+ schema = @Schema(implementation = ApiResponse.class),
+ examples = @ExampleObject(
+ name = "유효성 검증 실패",
+ value = "{\"isSuccess\": false, \"code\": \"COMMON400\", \"message\": \"잘못된 요청입니다.\", \"result\": null}"
+ )
+ )
+ ),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "404",
+ description = "팀 또는 목표를 찾을 수 없음",
+ content = @Content(
+ schema = @Schema(implementation = ApiResponse.class),
+ examples = {
+ @ExampleObject(
+ name = "팀 없음",
+ value = "{\"isSuccess\": false, \"code\": \"TEAM400\", \"message\": \"해당하는 팀이 존재하지 않습니다.\", \"result\": null}"
+ ),
+ @ExampleObject(
+ name = "목표 없음",
+ value = "{\"isSuccess\": false, \"code\": \"GOAL400\", \"message\": \"해당하는 목표가 존재하지 않습니다.\", \"result\": null}"
+ )
+ }
+ )
+ )
+ })
+ ApiResponse createExternal(
+ @PathVariable("teamId") Long teamId,
+ @Valid @RequestBody ExternalRequestDTO.ExternalCreateRequestDTO requestDTO,
+ @AuthenticationPrincipal AuthUser user);
+
+ @Operation(
+ summary = "팀 내 외부이슈를 수정하는 API",
+ description = "팀 내 외부이슈를 수정하는 API입니다. 담당자, 제목, 내용, 마감기한, 우선순위, 상태 값을 수정할 수 있습니다. " +
+ "수정할 데이터 값들을 요청 바디에 담아 보내주시면 됩니다."
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "200",
+ description = "외부이슈 수정 성공",
+ content = @Content(schema = @Schema(implementation = ApiResponse.class))
+ ),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "400",
+ description = "잘못된 요청 데이터",
+ content = @Content(
+ schema = @Schema(implementation = ApiResponse.class),
+ examples = @ExampleObject(
+ name = "유효성 검증 실패",
+ value = "{\"isSuccess\": false, \"code\": \"COMMON400\", \"message\": \"잘못된 요청입니다.\", \"result\": null}"
+ )
+ )
+ ),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "404",
+ description = "외부이슈를 찾을 수 없음",
+ content = @Content(
+ schema = @Schema(implementation = ApiResponse.class),
+ examples = @ExampleObject(
+ name = "외부이슈 없음",
+ value = "{\"isSuccess\": false, \"code\": \"EXTERNAL400\", \"message\": \"해당하는 외부 이슈가 존재하지 않습니다.\", \"result\": null}"
+ )
+ )
+ )
+ })
+ ApiResponse modifyExternal(
+ @Parameter(description = "외부이슈 ID", required = true) @PathVariable Long externalId,
+ @Valid @RequestBody ExternalRequestDTO.ExternalUpdateRequestDTO requestDTO);
+
+ @Operation(
+ summary = "팀 내 외부이슈들을 한번에 삭제하는 API",
+ description = "외부이슈들의 식별자들을 리스트로 받아서 한번에 삭제처리하는 API입니다. 해당 외부이슈들은 실제 데이터베이스에서 삭제되는 것이 아닌 " +
+ "Soft Delete 방식으로, 휴지통으로 담기게 됩니다."
+ )
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "200",
+ description = "외부이슈 삭제 성공",
+ content = @Content(schema = @Schema(implementation = ApiResponse.class))
+ ),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(
+ responseCode = "400",
+ description = "잘못된 요청 데이터",
+ content = @Content(
+ schema = @Schema(implementation = ApiResponse.class),
+ examples = @ExampleObject(
+ name = "유효성 검증 실패",
+ value = "{\"isSuccess\": false, \"code\": \"COMMON400\", \"message\": \"잘못된 요청입니다.\", \"result\": null}"
+ )
+ )
+ )
+ })
+ ApiResponse> deleteExternal(@Valid @RequestBody ExternalRequestDTO.ExternalDeleteRequestDTO requestDTO);
+
+ @Operation(
+ summary = "외부 이슈 작성 시 필요한 ID 조회",
+ description = "외부 이슈 작성 시 필요한 ID를 가져옵니다. (팀명)-e(번호) 형식으로 구성됩니다."
+ )
+ ApiResponse> getExternalName(@PathVariable("teamId") Long teamId);
+
+ @Operation(
+ summary = "해당 팀과 연동된 모든 연동 툴 조회",
+ description = "해당 팀과 연동되어 있는 모든 연동 툴 내역을 조회합니다."
+ )
+ ApiResponse getExternalLinks(@PathVariable("teamId") Long teamId);
+
+ @Operation(
+ summary = "삭제된 외부이슈 복원 API",
+ description = "삭제된 외부이슈를 복원합니다."
+ )
+ ApiResponse> restoreGoals(
+ @RequestBody ExternalRequestDTO.ExternalDeleteRequestDTO dto
+ );
+
+ @Operation(
+ summary = "삭제된 외부이슈 목록 가져오기",
+ description = "삭제된 모든 외부이슈들을 가져옵니다."
+ )
+ ApiResponse> getDeletedExternals(@PathVariable("teamId") Long teamId);
+}
diff --git a/src/main/java/com/example/Veco/domain/external/controller/GitHubController.java b/src/main/java/com/example/Veco/domain/external/controller/GitHubController.java
deleted file mode 100644
index e31e6fbe..00000000
--- a/src/main/java/com/example/Veco/domain/external/controller/GitHubController.java
+++ /dev/null
@@ -1,86 +0,0 @@
-package com.example.Veco.domain.external.controller;
-
-import com.example.Veco.domain.external.config.GitHubConfig;
-import com.example.Veco.domain.external.service.GitHubService;
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.Parameter;
-import io.swagger.v3.oas.annotations.responses.ApiResponses;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Controller;
-import org.springframework.ui.Model;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-
-@Controller
-@RequiredArgsConstructor
-@RequestMapping("/github")
-@Slf4j
-@Tag(name = "GitHub 연동 API", description = "GitHub 외부 툴 연동을 위한 인증 및 콜백 API")
-public class GitHubController {
-
- private final GitHubService gitHubService;
- private final GitHubConfig gitHubConfig;
-
- @Operation(
- summary = "GitHub 연동 시작",
- description = "GitHub OAuth 인증을 시작하여 GitHub으로 리다이렉트합니다. " +
- "이 API는 브라우저에서 직접 호출해야 하며, GitHub 인증 페이지로 이동합니다.",
- hidden = false
- )
- @ApiResponses({
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "302",
- description = "GitHub OAuth 인증 페이지로 리다이렉트"
- )
- })
- @GetMapping("/connect")
- public String connectGithub(
- @Parameter(description = "연동할 팀 ID", required = true, example = "1")
- @RequestParam("teamId") Long teamId){
- String authUrl = String.format(
- "https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&scope=%s&state=%d",
- gitHubConfig.getOauth().getClientId(),
- gitHubConfig.getOauth().getRedirectUri(),
- "read:user,repo,admin:repo_hook",
- teamId
- );
-
- return "redirect:" + authUrl;
- }
-
- // TODO : 깃허브 연동 시 어느 팀과 연동되는 건지 저장이 필요
-
- @Operation(
- summary = "GitHub OAuth 콜백",
- description = "GitHub OAuth 인증 완료 후 호출되는 콜백 API입니다. " +
- "GitHub에서 자동으로 호출하며, 사용자가 직접 호출할 필요는 없습니다.",
- hidden = true
- )
- @GetMapping("/oauth/callback")
- public String githubOAuthCallback(
- @Parameter(description = "GitHub에서 발급한 인증 코드") @RequestParam("code") String code,
- @Parameter(description = "팀 ID (상태 값)") @RequestParam("state") String state,
- Model model) {
- try {
-
- // GitHub Access Token 획득 및 사용자 정보 저장
-// User user = gitHubService.connectGitHubAccount(code);
-
- // GitHub App 설치 페이지로 리다이렉트
- String githubAppInstallUrl = String.format(
- "https://github.com/apps/VecoApp/installations/new?state=%s",
- state
- );
-
- return "redirect:" + githubAppInstallUrl;
-
- } catch (Exception e) {
- model.addAttribute("error", "GitHub 연동 중 오류가 발생했습니다: " + e.getMessage());
- return "dashboard/index";
- }
- }
-
-}
diff --git a/src/main/java/com/example/Veco/domain/external/controller/GitHubRestController.java b/src/main/java/com/example/Veco/domain/external/controller/GitHubRestController.java
deleted file mode 100644
index 934c0386..00000000
--- a/src/main/java/com/example/Veco/domain/external/controller/GitHubRestController.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.example.Veco.domain.external.controller;
-
-import com.example.Veco.domain.external.dto.response.GitHubApiResponseDTO;
-import com.example.Veco.domain.external.dto.response.GitHubResponseDTO;
-import com.example.Veco.domain.external.exception.code.GitHubSuccessCode;
-import com.example.Veco.domain.external.service.GitHubRepositoryService;
-import com.example.Veco.domain.external.service.GitHubService;
-import com.example.Veco.global.apiPayload.ApiResponse;
-import io.swagger.v3.oas.annotations.Parameter;
-import lombok.RequiredArgsConstructor;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-import reactor.core.publisher.Mono;
-
-import java.util.List;
-
-@RestController
-@RequiredArgsConstructor
-public class GitHubRestController {
-
- private final GitHubService gitHubService;
- private final GitHubRepositoryService gitHubRepositoryService;
-
- @GetMapping("/github/installation/callback")
- public ApiResponse callbackInstallation(
- @Parameter(description = "팀 ID") @RequestParam("state") Long state,
- @Parameter(description = "GitHub App 설치 ID") @RequestParam("installation_id") Long installationId
- ) {
- return ApiResponse.onSuccess(GitHubSuccessCode.GITHUB_APP_INSTALL_SUCCESS,
- gitHubService.saveInstallationInfo(state, installationId));
- }
-
- @GetMapping("/api/github/teams/{teamId}/repositories")
- public Mono>> getRepositories(@PathVariable("teamId") Long teamId) {
-
- // owner, repo 정보 추출해서 클라이언트에 제공
- return gitHubService.getInstallationIdAsync(teamId) // 비동기 DB 조회
- .flatMap(gitHubRepositoryService::getInstallationRepositories)
- .map(ApiResponse::onSuccess)
- .onErrorReturn(ApiResponse.onFailure("REPO_FETCH_FAILED", "레포지토리 조회 실패", null));
- }
-}
diff --git a/src/main/java/com/example/Veco/domain/external/controller/GitHubWebhookController.java b/src/main/java/com/example/Veco/domain/external/controller/GitHubWebhookController.java
deleted file mode 100644
index c8542c6e..00000000
--- a/src/main/java/com/example/Veco/domain/external/controller/GitHubWebhookController.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package com.example.Veco.domain.external.controller;
-
-import com.example.Veco.domain.external.dto.GitHubWebhookPayload;
-import com.example.Veco.domain.external.service.GitHubIssueService;
-import jakarta.servlet.http.HttpServletRequest;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.*;
-
-@RestController
-@RequestMapping("/api/github")
-@RequiredArgsConstructor
-@Slf4j
-public class GitHubWebhookController {
- private final GitHubIssueService gitHubIssueService;
-
- @PostMapping("/webhook")
- public ResponseEntity handleWebhook(
- @RequestBody GitHubWebhookPayload payload,
- @RequestHeader("X-GitHub-Event") String eventType,
- @RequestHeader("X-GitHub-Delivery") String deliveryId,
- @RequestHeader(value = "X-Hub-Signature-256", required = false) String signature,
- HttpServletRequest request) {
-
- log.info("Received GitHub webhook. Event: {}, Delivery: {}", eventType, deliveryId);
-
- try {
- // 서명 검증
-// if (!webhookSecurityService.verifySignature(request, signature)) {
-// log.warn("Invalid webhook signature for delivery: {}", deliveryId);
-// return ResponseEntity.status(401).body("Unauthorized");
-// }
-
- // Issues 이벤트만 처리
- if (!"issues".equals(eventType)) {
- log.debug("Ignoring non-issues event: {}", eventType);
- return ResponseEntity.ok("Event ignored");
- }
-
- // 이슈 처리
- gitHubIssueService.processIssueWebhook(payload);
-
- log.info("Successfully processed {} action for issue #{} in {}",
- payload.getAction(),
- payload.getIssue().getNumber(),
- payload.getRepository().getFullName());
-
- return ResponseEntity.ok("Webhook processed successfully");
-
- } catch (Exception e) {
- log.error("Error processing webhook for delivery: {}", deliveryId, e);
- return ResponseEntity.status(500).body("Internal server error");
- }
- }
-}
diff --git a/src/main/java/com/example/Veco/domain/external/converter/ExternalConverter.java b/src/main/java/com/example/Veco/domain/external/converter/ExternalConverter.java
index f9c91f6e..fb29d61d 100644
--- a/src/main/java/com/example/Veco/domain/external/converter/ExternalConverter.java
+++ b/src/main/java/com/example/Veco/domain/external/converter/ExternalConverter.java
@@ -1,30 +1,80 @@
package com.example.Veco.domain.external.converter;
import com.example.Veco.domain.comment.entity.Comment;
+import com.example.Veco.domain.external.exception.ExternalException;
+import com.example.Veco.domain.external.exception.code.ExternalErrorCode;
+import com.example.Veco.domain.github.dto.webhook.GitHubPullRequestPayload;
import com.example.Veco.domain.external.dto.request.ExternalRequestDTO;
import com.example.Veco.domain.external.dto.response.ExternalResponseDTO;
import com.example.Veco.domain.external.dto.response.ExternalGroupedResponseDTO;
-import com.example.Veco.domain.external.dto.GitHubWebhookPayload;
+import com.example.Veco.domain.github.dto.webhook.GitHubWebhookPayload;
import com.example.Veco.domain.external.entity.External;
import com.example.Veco.domain.goal.entity.Goal;
+import com.example.Veco.domain.goal.exception.GoalException;
+import com.example.Veco.domain.goal.exception.code.GoalErrorCode;
import com.example.Veco.domain.mapping.Assignment;
+import com.example.Veco.domain.member.entity.Member;
import com.example.Veco.domain.team.entity.Team;
import com.example.Veco.global.enums.ExtServiceType;
import com.example.Veco.global.enums.Priority;
import com.example.Veco.global.enums.State;
+import lombok.extern.slf4j.Slf4j;
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+@Slf4j
public class ExternalConverter {
- public static External toExternal(Team team, Goal goal, ExternalRequestDTO.ExternalCreateRequestDTO dto, String externalCode){
+
+ public static ExternalResponseDTO.SimpleExternalDTO toSimpleExternalDTO(External external) {
+ return ExternalResponseDTO.SimpleExternalDTO.builder()
+ .id(external.getId())
+ .title(external.getTitle())
+ .build();
+ }
+
+ public static ExternalResponseDTO.SimpleListDTO toSimpleListDTO(List externals) {
+ List simpleDTOs = externals.stream().map(
+ external -> ExternalResponseDTO.SimpleExternalDTO.builder()
+ .id(external.getId())
+ .title(external.getTitle())
+ .build()
+ ).toList();
+
+ return ExternalResponseDTO.SimpleListDTO.builder()
+ .cnt(externals.size())
+ .info(simpleDTOs)
+ .build();
+ }
+
+ public static External toExternal(Team team, Goal goal,
+ ExternalRequestDTO.ExternalCreateRequestDTO dto,
+ String externalCode,
+ Member author){
+
+ LocalDate start = null;
+ LocalDate end = null;
+ try {
+ if (dto.getDeadline().getStart() != null) {
+ start = LocalDate.parse(dto.getDeadline().getStart());
+ }
+ if (dto.getDeadline().getEnd() != null) {
+ end = LocalDate.parse(dto.getDeadline().getEnd());
+ }
+ } catch (DateTimeParseException e) {
+ throw new ExternalException(ExternalErrorCode.DEADLINE_INVALID);
+ }
+
External external = External.builder()
+ .member(author)
.description(dto.getContent())
.name(externalCode)
- .startDate(dto.getDeadline() != null ? dto.getDeadline().getStart() : null)
- .endDate(dto.getDeadline() != null ? dto.getDeadline().getEnd() : null)
+ .startDate(start)
+ .endDate(end)
.type(dto.getExtServiceType())
.priority(dto.getPriority())
.title(dto.getTitle())
@@ -56,7 +106,7 @@ public static ExternalResponseDTO.ExternalInfoDTO toExternalInfoDTO(External ext
ExternalResponseDTO.CommentResponseDTO commentDTO = ExternalResponseDTO.CommentResponseDTO.builder()
.profileUrl(comment.getMember().getProfile().getProfileImageUrl())
- .nickname(comment.getMember().getNickname())
+ .name(comment.getMember().getNickname())
.createdAt(comment.getCreatedAt())
.content(comment.getContent())
.build();
@@ -159,6 +209,24 @@ public static External byGitHubIssue(GitHubWebhookPayload payload, Team team, St
.build();
}
+ public static External byGitHubPullRequest(GitHubPullRequestPayload payload, Team team, String externalCode){
+
+ log.info("GitHub pull request started");
+
+ log.info("pr 제목 : {} pr 내용 : {}" , payload.getPullRequest().getTitle(), payload.getPullRequest().getBody());
+
+ return External.builder()
+ .title(payload.getPullRequest().getTitle())
+ .githubDataId(payload.getPullRequest().getId())
+ .description(payload.getPullRequest().getBody())
+ .name(externalCode)
+ .team(team)
+ .type(ExtServiceType.GITHUB)
+ .state(State.NONE)
+ .priority(Priority.NONE)
+ .build();
+ }
+
public static ExternalResponseDTO.UpdateResponseDTO updateResponseDTO(External external){
return ExternalResponseDTO.UpdateResponseDTO.builder()
.externalId(external.getId())
@@ -208,7 +276,7 @@ public static ExternalGroupedResponseDTO.ExternalGroupedPageResponse toGroupedPa
.build();
}
- private static ExternalGroupedResponseDTO.ExternalItemDTO toExternalItemDTO(External external) {
+ public static ExternalGroupedResponseDTO.ExternalItemDTO toExternalItemDTO(External external) {
ExternalGroupedResponseDTO.DeadlineDTO deadline = ExternalGroupedResponseDTO.DeadlineDTO.builder()
.start(external.getStartDate() != null ? external.getStartDate().toString() : null)
.end(external.getEndDate() != null ? external.getEndDate().toString() : null)
@@ -217,7 +285,7 @@ private static ExternalGroupedResponseDTO.ExternalItemDTO toExternalItemDTO(Exte
List managerInfos = external.getAssignments().stream()
.map(assignment -> ExternalGroupedResponseDTO.ManagerInfoDTO.builder()
.profileUrl(assignment.getProfileUrl())
- .managerName(assignment.getAssigneeName())
+ .name(assignment.getAssigneeName())
.build())
.collect(Collectors.toList());
@@ -234,6 +302,7 @@ private static ExternalGroupedResponseDTO.ExternalItemDTO toExternalItemDTO(Exte
.priority(external.getPriority().name())
.deadline(deadline)
.managers(managers)
+ .extServiceType(external.getType())
.build();
}
diff --git a/src/main/java/com/example/Veco/domain/external/dto/paging/ExternalCursor.java b/src/main/java/com/example/Veco/domain/external/dto/paging/ExternalCursor.java
index 7a86a4c0..c86267e3 100644
--- a/src/main/java/com/example/Veco/domain/external/dto/paging/ExternalCursor.java
+++ b/src/main/java/com/example/Veco/domain/external/dto/paging/ExternalCursor.java
@@ -1,53 +1,53 @@
package com.example.Veco.domain.external.dto.paging;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
-import lombok.Setter;
+import lombok.*;
import java.nio.charset.StandardCharsets;
-import java.time.LocalDateTime;
import java.util.Base64;
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Setter
+@ToString
public class ExternalCursor {
- private Integer statusPriority;
- private LocalDateTime createdAt;
private Long id;
- private Boolean isStatusFiltered;
+ private String groupValue;
public String encode(){
try{
- String data = String.format("%s|%d|%s|%d",
- Boolean.TRUE.equals(isStatusFiltered) ? "SIMPLE" : "COMPLEX",
- statusPriority != null ? statusPriority : 0,
- createdAt.toString(),
- id);
- return Base64.getEncoder().encodeToString(data.getBytes(StandardCharsets.UTF_8));
+ // null 안전장치 추가
+ if (id == null) {
+ throw new IllegalArgumentException("id는 null일 수 없습니다");
+ }
+
+ // ID와 groupValue만 사용하는 간단한 형태
+ String simpleData = String.format("%d_%s",
+ id,
+ groupValue != null ? groupValue : "NULL"
+ );
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(simpleData.getBytes(StandardCharsets.UTF_8));
}catch (Exception e){
- throw new IllegalArgumentException("커서 인코딩 실패");
+ throw new IllegalArgumentException("커서 인코딩 실패: " + e.getMessage());
}
}
public static ExternalCursor decode(String encodedCursor){
try {
- String decoded = new String(Base64.getDecoder().decode(encodedCursor), StandardCharsets.UTF_8);
- String[] parts = decoded.split("\\|");
+ String decoded = new String(Base64.getUrlDecoder().decode(encodedCursor), StandardCharsets.UTF_8);
+ String[] parts = decoded.split("_", 2);
+
+ if (parts.length < 2) {
+ throw new IllegalArgumentException("커서 형식이 올바르지 않습니다");
+ }
ExternalCursor cursor = new ExternalCursor();
- cursor.setIsStatusFiltered("SIMPLE".equals(parts[0]));
-
- int statusPriorityValue = Integer.parseInt(parts[1]);
- cursor.setStatusPriority(statusPriorityValue > 0 ? statusPriorityValue : null);
- cursor.setCreatedAt(LocalDateTime.parse(parts[2]));
- cursor.setId(Long.parseLong(parts[3]));
+ cursor.setId(Long.parseLong(parts[0]));
+ cursor.setGroupValue("NULL".equals(parts[1]) ? null : parts[1]);
return cursor;
} catch (Exception e) {
- throw new IllegalArgumentException("잘못된 커서 형식", e);
+ throw new IllegalArgumentException("잘못된 커서 형식: " + e.getMessage(), e);
}
}
}
diff --git a/src/main/java/com/example/Veco/domain/external/dto/paging/ExternalSearchCriteria.java b/src/main/java/com/example/Veco/domain/external/dto/paging/ExternalSearchCriteria.java
index 63a90ade..62093bed 100644
--- a/src/main/java/com/example/Veco/domain/external/dto/paging/ExternalSearchCriteria.java
+++ b/src/main/java/com/example/Veco/domain/external/dto/paging/ExternalSearchCriteria.java
@@ -1,5 +1,6 @@
package com.example.Veco.domain.external.dto.paging;
+import com.example.Veco.domain.external.dto.request.ExternalRequestDTO;
import com.example.Veco.global.enums.ExtServiceType;
import com.example.Veco.global.enums.Priority;
import com.example.Veco.global.enums.State;
@@ -19,14 +20,25 @@ public class ExternalSearchCriteria {
private ExtServiceType extServiceType;
private Long goalId;
private Long teamId;
+ private ExternalRequestDTO.ExternalGroupedSearchRequestDTO.FilterType filterType;
public FilterType getActiveFilterType() {
+ if(filterType != null) {
+ return switch (filterType) {
+ case STATE -> FilterType.STATE;
+ case PRIORITY -> FilterType.PRIORITY;
+ case ASSIGNEE -> FilterType.ASSIGNEE;
+ case EXT_TYPE -> FilterType.EXT_TYPE;
+ case GOAL -> FilterType.GOAL;
+ };
+ }
+
if(state != null) return FilterType.STATE;
if(priority != null) return FilterType.PRIORITY;
if(assigneeId != null) return FilterType.ASSIGNEE;
if(extServiceType != null) return FilterType.EXT_TYPE;
if(goalId != null) return FilterType.GOAL;
- return FilterType.NONE;
+ return FilterType.STATE;
}
public enum FilterType{
diff --git a/src/main/java/com/example/Veco/domain/external/dto/request/ExternalRequestDTO.java b/src/main/java/com/example/Veco/domain/external/dto/request/ExternalRequestDTO.java
index b9ce9387..a1ef81fc 100644
--- a/src/main/java/com/example/Veco/domain/external/dto/request/ExternalRequestDTO.java
+++ b/src/main/java/com/example/Veco/domain/external/dto/request/ExternalRequestDTO.java
@@ -3,13 +3,19 @@
import com.example.Veco.global.enums.ExtServiceType;
import com.example.Veco.global.enums.Priority;
import com.example.Veco.global.enums.State;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
+import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
import java.util.List;
+import java.util.Optional;
public class ExternalRequestDTO {
@@ -18,22 +24,25 @@ public static class ExternalCreateRequestDTO{
private String owner;
private String repo;
private Long installationId;
+ @NotBlank(message = "제목은 필수입니다.")
private String title;
private String content;
private State state;
private Priority priority;
private List managersId;
private DeadlineRequestDTO deadline;
+ @NotNull(message = "외부는 반드시 설정해야합니다.")
private ExtServiceType extServiceType;
private Long goalId;
}
@Getter
public static class DeadlineRequestDTO{
- private LocalDate start;
- private LocalDate end;
+ private String start;
+ private String end;
}
+
@Getter
public static class ExternalDeleteRequestDTO{
private List externalIds;
@@ -51,6 +60,21 @@ public static class ExternalUpdateRequestDTO{
private List managersId;
private DeadlineRequestDTO deadline;
private Long goalId;
- private ExtServiceType extServiceType;
+ }
+
+ @Getter
+ @AllArgsConstructor
+ @NoArgsConstructor
+ @Builder
+ public static class ExternalGroupedSearchRequestDTO {
+ @NotNull(message = "필터 타입은 필수입니다.")
+ private FilterType filterType;
+ private String cursor;
+ @Builder.Default
+ private Integer size = 50;
+
+ public enum FilterType {
+ ASSIGNEE, GOAL, PRIORITY, STATE, EXT_TYPE
+ }
}
}
diff --git a/src/main/java/com/example/Veco/domain/external/dto/response/ExternalGroupedResponseDTO.java b/src/main/java/com/example/Veco/domain/external/dto/response/ExternalGroupedResponseDTO.java
index 6e8ea5d4..fcf58e75 100644
--- a/src/main/java/com/example/Veco/domain/external/dto/response/ExternalGroupedResponseDTO.java
+++ b/src/main/java/com/example/Veco/domain/external/dto/response/ExternalGroupedResponseDTO.java
@@ -1,5 +1,6 @@
package com.example.Veco.domain.external.dto.response;
+import com.example.Veco.global.enums.ExtServiceType;
import com.example.Veco.global.enums.State;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
@@ -72,6 +73,9 @@ public static class ExternalItemDTO {
@Schema(description = "담당자 정보")
private ManagersDTO managers;
+
+ @Schema(description = "외부 연동 툴 정보")
+ private ExtServiceType extServiceType;
}
@Getter
@@ -110,6 +114,6 @@ public static class ManagerInfoDTO {
private String profileUrl;
@Schema(description = "담당자 이름")
- private String managerName;
+ private String name;
}
}
\ No newline at end of file
diff --git a/src/main/java/com/example/Veco/domain/external/dto/response/ExternalResponseDTO.java b/src/main/java/com/example/Veco/domain/external/dto/response/ExternalResponseDTO.java
index 536183ed..151592e8 100644
--- a/src/main/java/com/example/Veco/domain/external/dto/response/ExternalResponseDTO.java
+++ b/src/main/java/com/example/Veco/domain/external/dto/response/ExternalResponseDTO.java
@@ -14,6 +14,23 @@
import java.util.List;
public class ExternalResponseDTO {
+ @Getter
+ @AllArgsConstructor
+ @NoArgsConstructor
+ @Builder
+ public static class SimpleListDTO {
+ private int cnt;
+ private List info;
+ }
+
+ @Getter
+ @AllArgsConstructor
+ @NoArgsConstructor
+ @Builder
+ public static class SimpleExternalDTO {
+ private Long id;
+ private String title;
+ }
@Getter
@AllArgsConstructor
@@ -23,33 +40,34 @@ public class ExternalResponseDTO {
public static class ExternalInfoDTO {
@Schema(description = "이슈 ID", example = "1")
private Long id;
-
+
+ @Schema(description = "외부 이슈 코드", example = "EXT-001")
+ private String name;
+
@Schema(description = "이슈 제목", example = "외부 이슈 제목")
private String title;
-
+
@Schema(description = "이슈 설명", example = "이슈에 대한 상세 설명")
private String content;
-
+
@Schema(description = "우선순위", example = "HIGH")
private Priority priority;
-
+
@Schema(description = "이슈 상태", example = "TODO")
private State state;
-
+
@Schema(description = "시작일", example = "2024-01-01")
private LocalDate startDate;
-
+
@Schema(description = "마감일", example = "2024-01-31")
private LocalDate endDate;
-
+
@Schema(description = "목표 ID", example = "1")
private Long goalId;
-
+
@Schema(description = "목표 제목", example = "목표 제목")
private String goalTitle;
-
- @Schema(description = "외부 이슈 코드", example = "EXT-001")
- private String name;
+
@Schema(description = "연동된 외부 툴", example = "GITHUB")
private ExtServiceType extServiceType;
@@ -173,7 +191,7 @@ public static class ExternalCommentResponseDTO {
@Builder
public static class CommentResponseDTO {
private String profileUrl;
- private String nickname;
+ private String name;
private LocalDateTime createdAt;
private String content;
}
diff --git a/src/main/java/com/example/Veco/domain/external/entity/External.java b/src/main/java/com/example/Veco/domain/external/entity/External.java
index c009bbfc..89c5d934 100644
--- a/src/main/java/com/example/Veco/domain/external/entity/External.java
+++ b/src/main/java/com/example/Veco/domain/external/entity/External.java
@@ -1,8 +1,9 @@
package com.example.Veco.domain.external.entity;
import com.example.Veco.domain.common.BaseEntity;
+import com.example.Veco.domain.github.dto.webhook.GitHubPullRequestPayload;
import com.example.Veco.domain.external.dto.request.ExternalRequestDTO;
-import com.example.Veco.domain.external.dto.GitHubWebhookPayload;
+import com.example.Veco.domain.github.dto.webhook.GitHubWebhookPayload;
import com.example.Veco.domain.team.entity.Team;
import com.example.Veco.domain.goal.entity.Goal;
import com.example.Veco.domain.mapping.Assignment;
@@ -82,7 +83,7 @@ public class External extends BaseEntity {
@JoinColumn(name = "goal_id")
private Goal goal;
- @OneToMany(mappedBy = "external")
+ @OneToMany(mappedBy = "external", cascade = CascadeType.PERSIST)
@Builder.Default
private List assignments = new ArrayList<>();
@@ -101,6 +102,9 @@ public void addAssignment(Assignment assignment) {
}
public void updateExternal(ExternalRequestDTO.ExternalUpdateRequestDTO requestDTO) {
+
+ startDate = null;
+
if(requestDTO.getTitle() != null) {
this.title = requestDTO.getTitle();
}
@@ -110,24 +114,25 @@ public void updateExternal(ExternalRequestDTO.ExternalUpdateRequestDTO requestDT
if(requestDTO.getState() != null) {
this.state = requestDTO.getState();
}
- if (requestDTO.getDeadline().getStart() != null) {
- this.startDate = requestDTO.getDeadline().getStart();
- }
- if (requestDTO.getDeadline().getEnd() != null) {
- this.endDate = requestDTO.getDeadline().getEnd() ;
- }
if(requestDTO.getPriority() != null) {
this.priority = requestDTO.getPriority();
}
- if(requestDTO.getExtServiceType() != null) {
- this.type = requestDTO.getExtServiceType();
- }
+ }
+
+ public void updateStartDate(LocalDate startDate) {
+ this.startDate = startDate;
+ }
+
+ public void updateEndDate(LocalDate endDate) {
+ this.endDate = endDate;
}
public void closeIssue(){
this.state = State.FINISH;
}
+ public void restore(){ this.deletedAt = null; }
+
public void updateExternalByGithubIssue(GitHubWebhookPayload.Issue issue ){
if(issue.getTitle() != null) {
@@ -138,6 +143,16 @@ public void updateExternalByGithubIssue(GitHubWebhookPayload.Issue issue ){
}
}
+ public void updateByPullRequest(GitHubPullRequestPayload.PullRequest pullRequest){
+
+ if(pullRequest.getTitle() != null) {
+ this.title = pullRequest.getTitle();
+ }
+ if(pullRequest.getBody() != null) {
+ this.description = pullRequest.getBody();
+ }
+ }
+
public void softDelete(){
deletedAt = LocalDateTime.now();
}
diff --git a/src/main/java/com/example/Veco/domain/external/exception/code/ExternalErrorCode.java b/src/main/java/com/example/Veco/domain/external/exception/code/ExternalErrorCode.java
index ea0486ee..61dd102a 100644
--- a/src/main/java/com/example/Veco/domain/external/exception/code/ExternalErrorCode.java
+++ b/src/main/java/com/example/Veco/domain/external/exception/code/ExternalErrorCode.java
@@ -9,7 +9,12 @@
@Getter
@AllArgsConstructor
public enum ExternalErrorCode implements BaseErrorStatus {
- NOT_FOUND(HttpStatus.NOT_FOUND, "해당하는 외부이슈가 존재하지 않습니다.", "EXTERNAL404");
+ NOT_FOUND(HttpStatus.NOT_FOUND, "해당하는 외부이슈가 존재하지 않습니다.", "EXTERNAL404"),
+ DEADLINE_INVALID(HttpStatus.BAD_REQUEST, "올바르지 않은 날짜 형식입니다.", "EXTERNAL400"),
+ NOT_FOUND_DELETE_EXTERNALS(HttpStatus.NOT_FOUND, "삭제된 외부이슈들을 발견하지 못했습니다.", "EXTERNAL404"),
+ NOT_A_DELETE(HttpStatus.NOT_FOUND, "복원할 외부이슈가 없습니다.", "EXTERNAL404"),
+ NOT_SAME_TEAM(HttpStatus.BAD_REQUEST, "동일한 팀의 요청이 아닙니다.", "EXTERNAL400"),
+ ;
private HttpStatus httpStatus;
private String message;
diff --git a/src/main/java/com/example/Veco/domain/external/repository/ExternalCursorRepository.java b/src/main/java/com/example/Veco/domain/external/repository/ExternalCursorRepository.java
index c9921382..0ba28d62 100644
--- a/src/main/java/com/example/Veco/domain/external/repository/ExternalCursorRepository.java
+++ b/src/main/java/com/example/Veco/domain/external/repository/ExternalCursorRepository.java
@@ -5,188 +5,649 @@
import com.example.Veco.domain.external.dto.response.ExternalResponseDTO;
import com.example.Veco.domain.external.dto.response.ExternalGroupedResponseDTO;
import com.example.Veco.domain.external.dto.paging.ExternalSearchCriteria;
+import com.example.Veco.domain.external.dto.paging.ExternalSearchCriteria.FilterType;
import com.example.Veco.domain.external.entity.External;
import com.example.Veco.domain.external.entity.QExternal;
-import com.example.Veco.global.apiPayload.page.CursorPage;
import com.example.Veco.global.enums.State;
-import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.BooleanExpression;
-import com.querydsl.core.types.dsl.CaseBuilder;
-import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import java.util.List;
+@Slf4j
@Repository
public class ExternalCursorRepository implements ExternalCustomRepository{
private final JPAQueryFactory queryFactory;
private final QExternal external = QExternal.external;
+ /**
+ * NULL 값 처리를 위한 상수
+ */
+ private static final String NULL_GROUP_VALUE = "NULL_GROUP";
+
+
public ExternalCursorRepository(EntityManager entityManager) {
this.queryFactory = new JPAQueryFactory(entityManager);
}
- // TODO : 메서드 테스트 필요
- @Override
- public CursorPage findExternalWithCursor(ExternalSearchCriteria criteria, String cursor, int size) {
- JPAQuery query = queryFactory.selectFrom(external)
- .where(
- buildFilterConditions(criteria),
- buildCursorCondition(criteria, cursor)
- )
- .orderBy(buildOrderClause(criteria))
- .limit(size + 1);
+ public ExternalGroupedResponseDTO.ExternalGroupedPageResponse findExternalWithGroupedResponse(ExternalSearchCriteria criteria, String cursor, int size) {
+ FilterType filterType = criteria.getActiveFilterType();
+
+ return switch (filterType) {
+ case STATE -> findExternalsGroupedByState(criteria, cursor, size);
+ case PRIORITY -> findExternalsGroupedByPriority(criteria, cursor, size);
+ case ASSIGNEE -> findExternalsGroupedByAssignee(criteria, cursor, size);
+ case EXT_TYPE -> findExternalsGroupedByExtType(criteria, cursor, size);
+ case GOAL -> findExternalsGroupedByGoal(criteria, cursor, size);
+ case NONE -> findExternalsGroupedByState(criteria, cursor, size); // 기본값은 상태별 그룹핑
+ };
+ }
- List externals = query.fetch();
- return buildCursorPage(externals, size, criteria);
+ private ExternalGroupedResponseDTO.ExternalGroupedPageResponse findExternalsGroupedByState(ExternalSearchCriteria criteria, String cursor, int size) {
+ State[] stateOrder = {State.NONE, State.IN_PROGRESS, State.TODO, State.FINISH, State.REVIEW};
+ return findExternalsGroupedByField(criteria, cursor, size, stateOrder, "state");
}
- private BooleanExpression buildFilterConditions(ExternalSearchCriteria criteria) {
- BooleanExpression teamCondition = external.team.id.eq(criteria.getTeamId());
-
- BooleanExpression filterCondition = switch (criteria.getActiveFilterType()){
- case STATE -> external.state.eq(criteria.getState());
- case PRIORITY -> external.priority.eq(criteria.getPriority());
- case ASSIGNEE -> external.assignments.any().assignee.id.eq(criteria.getAssigneeId());
- case EXT_TYPE -> external.type.eq(criteria.getExtServiceType());
- case GOAL -> external.goal.id.eq(criteria.getGoalId());
- case NONE -> null;
+ private ExternalGroupedResponseDTO.ExternalGroupedPageResponse findExternalsGroupedByPriority(ExternalSearchCriteria criteria, String cursor, int size) {
+ com.example.Veco.global.enums.Priority[] priorityOrder = {
+ com.example.Veco.global.enums.Priority.NONE,
+ com.example.Veco.global.enums.Priority.URGENT,
+ com.example.Veco.global.enums.Priority.HIGH,
+ com.example.Veco.global.enums.Priority.NORMAL,
+ com.example.Veco.global.enums.Priority.LOW
};
-
- return filterCondition != null ? teamCondition.and(filterCondition) : teamCondition;
+ return findExternalsGroupedByField(criteria, cursor, size, priorityOrder, "priority");
}
- private BooleanExpression buildCursorCondition(ExternalSearchCriteria criteria, String cursor) {
+ private ExternalGroupedResponseDTO.ExternalGroupedPageResponse findExternalsGroupedByAssignee(ExternalSearchCriteria criteria, String cursor, int size) {
+ com.example.Veco.domain.mapping.QAssignment assignment = com.example.Veco.domain.mapping.QAssignment.assignment;
- if(cursor == null) return null;
+ // 먼저 팀에 속한 모든 담당자들을 조회 (정렬된 순서)
+ List assigneeResults = queryFactory
+ .select(assignment.assignee.id, assignment.assignee.name)
+ .from(external)
+ .leftJoin(external.assignments, assignment)
+ .leftJoin(assignment.assignee)
+ .where(external.team.id.eq(criteria.getTeamId())
+ .and(external.deletedAt.isNull())
+ .and(assignment.assignee.id.isNotNull()))
+ .groupBy(assignment.assignee.id, assignment.assignee.name)
+ .orderBy(assignment.assignee.name.asc())
+ .fetch();
- ExternalCursor decodedCursor = ExternalCursor.decode(cursor);
- boolean hasStateFilter = criteria.getState() != null;
+ List groups = new java.util.ArrayList<>();
+ int totalFetched = 0;
+ boolean hasNext = false;
+ String nextCursor = null;
- if(hasStateFilter) {
- return external.createdAt.eq(decodedCursor.getCreatedAt())
- .or(
- external.createdAt.eq(decodedCursor.getCreatedAt())
- .and(external.id.gt(decodedCursor.getId()))
- );
- }else{
- NumberExpression statusPriorityExpr = getStatusPriorityExpression();
+ ExternalCursor decodedCursor = cursor != null ? ExternalCursor.decode(cursor) : null;
- return statusPriorityExpr.gt(decodedCursor.getStatusPriority())
- .or(
- statusPriorityExpr.eq(decodedCursor.getStatusPriority())
- .and(external.createdAt.lt(decodedCursor.getCreatedAt()))
- )
- .or(
- statusPriorityExpr.eq(decodedCursor.getStatusPriority())
- .and(external.createdAt.eq(decodedCursor.getCreatedAt()))
- .and(external.id.gt(decodedCursor.getId()))
- );
+ // "담당자 없음" + 일반 담당자들의 전체 리스트 생성
+ List allAssignees = new java.util.ArrayList<>();
+ // 담당자 없음을 첫 번째로 추가
+ allAssignees.add(new AssigneeInfo(null, "담당자 없음"));
+ // 일반 담당자들 추가
+ for (com.querydsl.core.Tuple result : assigneeResults) {
+ Long assigneeId = result.get(assignment.assignee.id);
+ String assigneeName = result.get(assignment.assignee.name);
+ allAssignees.add(new AssigneeInfo(assigneeId, assigneeName != null ? assigneeName : "Unknown"));
}
- }
+ // 커서가 있는 경우 시작 인덱스 찾기
+ int startIndex = 0;
+ if (decodedCursor != null && decodedCursor.getGroupValue() != null) {
+ startIndex = findAssigneeStartIndex(allAssignees, decodedCursor.getGroupValue());
+ log.info("Found start index: {} for cursor group: {}", startIndex, decodedCursor.getGroupValue());
+ }
+
+ // 시작 인덱스부터 처리
+ for (int i = startIndex; i < allAssignees.size(); i++) {
+ AssigneeInfo assigneeInfo = allAssignees.get(i);
+
+ if (totalFetched >= size) {
+ hasNext = true;
+ if (nextCursor == null) {
+ ExternalCursor groupCursor = new ExternalCursor();
+ groupCursor.setId(0L); // 다음 그룹의 시작점
+ groupCursor.setGroupValue(assigneeInfo.getId() != null ? assigneeInfo.getId().toString() : "NULL_GROUP");
+ nextCursor = groupCursor.encode();
+ log.info("Created next cursor for assignee: {}", assigneeInfo.getId());
+ }
+ break;
+ }
+
+ // 해당 담당자의 외부 이슈들 조회
+ JPAQuery query;
+
+ if (assigneeInfo.getId() == null) {
+ // 담당자가 없는 외부 이슈들 조회
+ query = queryFactory.selectFrom(external)
+ .leftJoin(external.assignments).fetchJoin()
+ .where(external.team.id.eq(criteria.getTeamId())
+ .and(external.deletedAt.isNull())
+ .and(external.assignments.isEmpty()));
+ } else {
+ // 특정 담당자가 담당하는 외부 이슈들 조회
+ com.example.Veco.domain.mapping.QAssignment assignmentForFilter = new com.example.Veco.domain.mapping.QAssignment("assignmentForFilter");
+ query = queryFactory.selectFrom(external)
+ .leftJoin(external.assignments).fetchJoin()
+ .leftJoin(external.assignments, assignmentForFilter)
+ .where(external.team.id.eq(criteria.getTeamId())
+ .and(external.deletedAt.isNull())
+ .and(assignmentForFilter.assignee.id.eq(assigneeInfo.getId())));
+ }
- private CursorPage buildCursorPage(List externals, int size, ExternalSearchCriteria criteria) {
+ // 같은 그룹인 경우 커서 조건 적용
+ if (decodedCursor != null && isSameAssigneeGroup(assigneeInfo.getId(), decodedCursor)) {
+ query = query.where(external.id.gt(decodedCursor.getId()));
+ log.info("Applied cursor condition for assignee: {} with id > {}", assigneeInfo.getId(), decodedCursor.getId());
+ }
- boolean hasNext = externals.size() > size;
+ query = query.orderBy(external.id.asc());
- if(hasNext) {
- externals = externals.subList(0, size);
+ int remainingSize = size - totalFetched;
+ List groupExternals = query.limit(remainingSize + 1).fetch();
+
+ // 그룹 내에서 페이지네이션 처리
+ boolean groupHasNext = groupExternals.size() > remainingSize;
+ if (groupHasNext) {
+ groupExternals = groupExternals.subList(0, remainingSize);
+ hasNext = true;
+ if (!groupExternals.isEmpty()) {
+ External lastExternal = groupExternals.getLast();
+ ExternalCursor groupCursor = new ExternalCursor();
+ groupCursor.setId(lastExternal.getId());
+ groupCursor.setGroupValue(assigneeInfo.getId() != null ? assigneeInfo.getId().toString() : "NULL_GROUP");
+ nextCursor = groupCursor.encode();
+ log.info("Created cursor for same assignee: {} with lastId: {}", assigneeInfo.getId(), lastExternal.getId());
+ }
+ }
+
+ List externalDTOs = groupExternals.stream()
+ .map(ExternalConverter::toExternalItemDTO)
+ .toList();
+
+ ExternalGroupedResponseDTO.FilteredExternalGroup group = ExternalGroupedResponseDTO.FilteredExternalGroup.builder()
+ .filterName(assigneeInfo.getName())
+ .dataCnt(externalDTOs.size())
+ .externals(externalDTOs)
+ .build();
+
+ groups.add(group);
+ totalFetched += groupExternals.size();
+
+ log.info("Assignee: {} ({}), fetched: {}, totalFetched: {}",
+ assigneeInfo.getName(), assigneeInfo.getId(), groupExternals.size(), totalFetched);
}
+ return ExternalGroupedResponseDTO.ExternalGroupedPageResponse.builder()
+ .data(groups)
+ .hasNext(hasNext)
+ .nextCursor(nextCursor)
+ .pageSize(size)
+ .build();
+ }
+
+ private ExternalGroupedResponseDTO.ExternalGroupedPageResponse findExternalsGroupedByExtType(ExternalSearchCriteria criteria, String cursor, int size) {
+ com.example.Veco.global.enums.ExtServiceType[] extTypeOrder = com.example.Veco.global.enums.ExtServiceType.values();
+ return findExternalsGroupedByField(criteria, cursor, size, extTypeOrder, "extType");
+ }
+
+ private ExternalGroupedResponseDTO.ExternalGroupedPageResponse findExternalsGroupedByGoal(ExternalSearchCriteria criteria, String cursor, int size) {
+ // 먼저 팀에 속한 모든 목표들을 조회 (정렬된 순서 유지)
+ List goalResults = queryFactory
+ .select(external.goal.id, external.goal.name)
+ .from(external)
+ .leftJoin(external.goal)
+ .where(external.team.id.eq(criteria.getTeamId()).and(external.deletedAt.isNull()))
+ .groupBy(external.goal.id, external.goal.name)
+ .orderBy(external.goal.name.asc().nullsFirst()) // null 목표(목표 없음)를 먼저 표시
+ .fetch();
+
+ List groups = new java.util.ArrayList<>();
+ int totalFetched = 0;
+ boolean hasNext = false;
String nextCursor = null;
- if(hasNext && !externals.isEmpty()) {
- External last = externals.getLast();
- ExternalCursor cursor = createCursorFromExternal(last, criteria);
- nextCursor = cursor.encode();
+ ExternalCursor decodedCursor = cursor != null ? ExternalCursor.decode(cursor) : null;
+
+ // 디버깅을 위한 상세 로그
+ if (decodedCursor != null) {
+ log.info("=== CURSOR ANALYSIS ===");
+ log.info("Decoded cursor - ID: {}, GroupValue: {}", decodedCursor.getId(), decodedCursor.getGroupValue());
+ } else {
+ log.info("=== FIRST REQUEST (NO CURSOR) ===");
}
+ // 커서가 있는 경우 시작 인덱스 찾기
+ int startIndex = 0;
+ if (decodedCursor != null && decodedCursor.getGroupValue() != null) {
+ startIndex = findGoalStartIndex(goalResults, decodedCursor.getGroupValue());
+ log.info("Found start index: {} for cursor group: {}", startIndex, decodedCursor.getGroupValue());
+ }
- List externalDTOS = externals.stream()
- .map(e -> ExternalConverter.toExternalDTO(e, e.getAssignments())).toList();
+ // 시작 인덱스부터 처리
+ for (int i = startIndex; i < goalResults.size(); i++) {
+ com.querydsl.core.Tuple goalResult = goalResults.get(i);
- return CursorPage.of(externalDTOS, nextCursor, hasNext);
- }
+ if (totalFetched >= size) {
+ hasNext = true;
+ // 다음 그룹이 있으므로 현재 그룹의 첫 번째 항목으로 커서 생성
+ Long goalId = goalResult.get(external.goal.id);
+ if (nextCursor == null) {
+ ExternalCursor groupCursor = new ExternalCursor();
+ groupCursor.setId(0L); // 다음 그룹의 첫 번째부터 시작
+ // ✅ 일관된 NULL 처리 사용
+ groupCursor.setGroupValue(goalIdToGroupValue(goalId));
+ nextCursor = groupCursor.encode();
+ log.info("Created next cursor for goal: {} with ID: 0", goalId);
+ }
+ break;
+ }
- private ExternalCursor createCursorFromExternal(External last, ExternalSearchCriteria criteria) {
- ExternalCursor externalCursor = new ExternalCursor();
- externalCursor.setId(last.getId());
- externalCursor.setCreatedAt(last.getCreatedAt());
- externalCursor.setIsStatusFiltered(criteria.getState() != null);
+ Long goalId = goalResult.get(external.goal.id);
+ String goalName = goalResult.get(external.goal.name);
+ String displayName = goalName != null ? goalName : "목표 없음";
- if (criteria.getState() == null) {
- Integer statePriority = switch (last.getState()) {
- case NONE -> 1;
- case TODO -> 2;
- case IN_PROGRESS -> 3;
- case FINISH -> 4;
- case REVIEW -> 5;
- default -> null;
- };
- externalCursor.setStatusPriority(statePriority);
+ log.info("=== PROCESSING GOAL: {} (ID: {}) ===", displayName, goalId);
+
+ // 해당 목표의 외부 이슈들 조회
+ BooleanExpression goalCondition = goalId != null ?
+ external.goal.id.eq(goalId) : external.goal.isNull();
+
+ JPAQuery query = queryFactory.selectFrom(external)
+ .leftJoin(external.assignments).fetchJoin()
+ .leftJoin(external.goal).fetchJoin()
+ .where(external.team.id.eq(criteria.getTeamId())
+ .and(external.deletedAt.isNull())
+ .and(goalCondition));
+
+ // 같은 그룹인 경우 커서 조건 적용 (ID 기준으로 이후 데이터 조회)
+ boolean isSameGroup = decodedCursor != null && isSameGoalGroup(goalId, decodedCursor);
+ if (isSameGroup) {
+ query = query.where(external.id.gt(decodedCursor.getId()));
+ log.info("✅ APPLIED CURSOR CONDITION: external.id > {} for goal: {}",
+ decodedCursor.getId(), goalId);
+ } else {
+ log.info("❌ NO CURSOR CONDITION: isSameGroup = false for goal: {}", goalId);
+ }
+
+ query = query.orderBy(external.id.asc());
+
+ int remainingSize = size - totalFetched;
+ List groupExternals = query.limit(remainingSize + 1).fetch();
+
+ log.info("Goal: {} - Query returned {} items (remainingSize: {}, limit: {})",
+ displayName, groupExternals.size(), remainingSize, remainingSize + 1);
+
+ // 실제 조회된 External ID들 로그
+ if (!groupExternals.isEmpty()) {
+ List ids = groupExternals.stream().map(External::getId).toList();
+ log.info("Retrieved External IDs: {}", ids);
+ }
+
+ // 그룹 내에서 페이지네이션 처리
+ boolean groupHasNext = groupExternals.size() > remainingSize;
+ if (groupHasNext) {
+ groupExternals = groupExternals.subList(0, remainingSize);
+ hasNext = true;
+ if (!groupExternals.isEmpty()) {
+ External lastExternal = groupExternals.getLast();
+ ExternalCursor groupCursor = new ExternalCursor();
+ groupCursor.setId(lastExternal.getId());
+ // ✅ 일관된 NULL 처리 사용
+ groupCursor.setGroupValue(goalIdToGroupValue(goalId));
+ nextCursor = groupCursor.encode();
+ log.info("Created cursor for same goal: {} with lastId: {}", goalId, lastExternal.getId());
+ }
+ }
+
+ List externalDTOs = groupExternals.stream()
+ .map(ExternalConverter::toExternalItemDTO)
+ .toList();
+
+ ExternalGroupedResponseDTO.FilteredExternalGroup group = ExternalGroupedResponseDTO.FilteredExternalGroup.builder()
+ .filterName(displayName)
+ .dataCnt(externalDTOs.size())
+ .externals(externalDTOs)
+ .build();
+
+ groups.add(group);
+ totalFetched += groupExternals.size();
+
+ log.info("Goal: {} ({}), fetched: {}, totalFetched: {}",
+ displayName, goalId, groupExternals.size(), totalFetched);
}
- return externalCursor;
+
+ log.info("=== FINAL RESULT ===");
+ log.info("TotalFetched: {}, hasNext: {}, nextCursor: {}",
+ totalFetched, hasNext, nextCursor);
+
+ return ExternalGroupedResponseDTO.ExternalGroupedPageResponse.builder()
+ .data(groups)
+ .hasNext(hasNext)
+ .nextCursor(nextCursor)
+ .pageSize(size)
+ .build();
}
- private OrderSpecifier>[] buildOrderClause(ExternalSearchCriteria criteria) {
- if (criteria.getState() != null) {
- // 상태 필터링이 있는 경우: 생성일 내림차순, ID 오름차순
- return new OrderSpecifier[]{
- external.createdAt.desc(),
- external.id.asc()
- };
+ private int findGoalStartIndex(List goalResults, String cursorGroupValue) {
+ log.info("Finding start index for cursor group value: {}", cursorGroupValue);
+
+ if (NULL_GROUP_VALUE.equals(cursorGroupValue)) {
+ // NULL 목표를 찾기 (name이 null인 경우)
+ for (int i = 0; i < goalResults.size(); i++) {
+ String goalName = goalResults.get(i).get(external.goal.name);
+ if (goalName == null) {
+ log.info("Found NULL goal at index: {}", i);
+ return i;
+ }
+ }
+ log.warn("NULL goal not found in results");
} else {
- // 상태 필터링이 없는 경우: 상태 우선순위, 생성일 내림차순, ID 오름차순
- NumberExpression statusPriorityExpr = getStatusPriorityExpression();
-
- return new OrderSpecifier[]{
- statusPriorityExpr.asc(),
- external.createdAt.desc(),
- external.id.asc()
- };
+ // 특정 goalId를 찾기
+ try {
+ Long targetGoalId = Long.parseLong(cursorGroupValue);
+ for (int i = 0; i < goalResults.size(); i++) {
+ Long goalId = goalResults.get(i).get(external.goal.id);
+ if (targetGoalId.equals(goalId)) {
+ log.info("Found goal {} at index: {}", targetGoalId, i);
+ return i;
+ }
+ }
+ log.warn("Goal {} not found in results", targetGoalId);
+ } catch (NumberFormatException e) {
+ log.warn("Invalid goal ID in cursor: {}", cursorGroupValue);
+ }
+ }
+
+ // 찾지 못한 경우 처음부터 시작
+ log.warn("Goal not found for cursor: {}, starting from beginning", cursorGroupValue);
+ return 0;
+ }
+
+ private > ExternalGroupedResponseDTO.ExternalGroupedPageResponse findExternalsGroupedByField(
+ ExternalSearchCriteria criteria, String cursor, int size, T[] enumOrder, String fieldType) {
+
+ List groups = new java.util.ArrayList<>();
+ int totalFetched = 0;
+ boolean hasNext = false;
+ String nextCursor = null;
+
+ ExternalCursor decodedCursor = cursor != null ? ExternalCursor.decode(cursor) : null;
+
+ // 커서가 있는 경우, 해당 그룹부터 시작하도록 시작 인덱스 계산
+ int startIndex = 0;
+ if (decodedCursor != null && decodedCursor.getGroupValue() != null) {
+ String[] fieldOrder = getFieldOrder(fieldType);
+ for (int i = 0; i < fieldOrder.length; i++) {
+ if (fieldOrder[i].equals(decodedCursor.getGroupValue())) {
+ startIndex = i;
+ break;
+ }
+ }
+ }
+
+ for (int i = startIndex; i < enumOrder.length; i++) {
+ T enumValue = enumOrder[i];
+
+ if (totalFetched >= size) {
+ hasNext = true;
+ // 다음 그룹의 첫 번째 항목으로 커서 생성
+ if (nextCursor == null && i < enumOrder.length) {
+ ExternalCursor groupCursor = new ExternalCursor();
+ groupCursor.setId(0L); // 다음 그룹의 시작점
+ groupCursor.setGroupValue(enumValue.name());
+ nextCursor = groupCursor.encode();
+ }
+ break;
+ }
+
+ BooleanExpression condition = external.team.id.eq(criteria.getTeamId())
+ .and(external.deletedAt.isNull())
+ .and(buildEnumCondition(enumValue, fieldType));
+
+ JPAQuery query = queryFactory.selectFrom(external)
+ .leftJoin(external.assignments).fetchJoin()
+ .where(condition);
+
+ // 같은 그룹인 경우 커서 조건 적용
+ if (decodedCursor != null && isSameGroup(enumValue, decodedCursor, fieldType)) {
+ query = query.where(external.id.gt(decodedCursor.getId()));
+ log.info("Applied cursor condition for group: {} with id > {}",
+ enumValue.name(), decodedCursor.getId());
+ }
+
+ query = query.orderBy(external.id.asc());
+
+ int remainingSize = size - totalFetched;
+ List groupExternals = query.limit(remainingSize + 1).fetch();
+
+ boolean groupHasNext = groupExternals.size() > remainingSize;
+ if (groupHasNext) {
+ groupExternals = groupExternals.subList(0, remainingSize);
+ hasNext = true;
+ if (!groupExternals.isEmpty()) {
+ External lastExternal = groupExternals.getLast();
+ ExternalCursor groupCursor = new ExternalCursor();
+ groupCursor.setId(lastExternal.getId());
+ groupCursor.setGroupValue(enumValue.name());
+ nextCursor = groupCursor.encode();
+ }
+ }
+
+ // 빈 그룹도 포함하여 일관된 응답 구조 유지
+ List externalDTOs = groupExternals.stream()
+ .map(ExternalConverter::toExternalItemDTO)
+ .toList();
+
+ ExternalGroupedResponseDTO.FilteredExternalGroup group = ExternalGroupedResponseDTO.FilteredExternalGroup.builder()
+ .filterName(getDisplayName(enumValue))
+ .dataCnt(externalDTOs.size())
+ .externals(externalDTOs)
+ .build();
+
+ groups.add(group);
+ totalFetched += groupExternals.size();
+
+ log.info("Group: {}, fetched: {}, totalFetched: {}",
+ enumValue.name(), groupExternals.size(), totalFetched);
}
+
+ return ExternalGroupedResponseDTO.ExternalGroupedPageResponse.builder()
+ .data(groups)
+ .hasNext(hasNext)
+ .nextCursor(nextCursor)
+ .pageSize(size)
+ .build();
}
- private NumberExpression getStatusPriorityExpression() {
- return new CaseBuilder()
- .when(external.state.eq(State.NONE)).then(1)
- .when(external.state.eq(State.TODO)).then(2)
- .when(external.state.eq(State.IN_PROGRESS)).then(3)
- .when(external.state.eq(State.FINISH)).then(4)
- .when(external.state.eq(State.REVIEW)).then(5)
- .otherwise(6);
+ private BooleanExpression buildEnumCondition(Enum> enumValue, String fieldType) {
+ return switch (fieldType) {
+ case "state" -> external.state.eq((State) enumValue);
+ case "priority" -> external.priority.eq((com.example.Veco.global.enums.Priority) enumValue);
+ case "extType" -> external.type.eq((com.example.Veco.global.enums.ExtServiceType) enumValue);
+ default -> null;
+ };
}
- public ExternalGroupedResponseDTO.ExternalGroupedPageResponse findExternalWithGroupedResponse(ExternalSearchCriteria criteria, String cursor, int size) {
- JPAQuery query = queryFactory.selectFrom(external)
- .leftJoin(external.assignments).fetchJoin()
- .where(
- buildFilterConditions(criteria),
- buildCursorCondition(criteria, cursor)
- )
- .orderBy(buildOrderClause(criteria))
- .limit(size + 1);
-
- List externals = query.fetch();
+ private boolean isSameGroup(Enum> enumValue, ExternalCursor cursor, String fieldType) {
+ return cursor.getGroupValue() != null && cursor.getGroupValue().equals(enumValue.name());
+ }
+
+ private String[] getFieldOrder(String fieldType) {
+ return switch (fieldType) {
+ case "state" -> new String[]{"NONE", "IN_PROGRESS", "TODO", "FINISH", "REVIEW"};
+ case "priority" -> new String[]{"NONE", "URGENT", "HIGH", "NORMAL", "LOW"};
+ case "extType" -> java.util.Arrays.stream(com.example.Veco.global.enums.ExtServiceType.values()).map(Enum::name).toArray(String[]::new);
+ default -> new String[]{};
+ };
+ }
+
+ private BooleanExpression buildCursorConditionForGroup(ExternalCursor cursor) {
+ // ID 기반 커서: 커서 ID보다 큰 ID의 데이터를 조회
+ return external.id.gt(cursor.getId());
+ }
+
+ private boolean shouldSkipGoalGroup(Long goalId, ExternalCursor cursor) {
+ if (cursor.getGroupValue() == null) return false;
+
+ String currentGoalValue = goalId != null ? goalId.toString() : "NULL";
+
+ // 커서의 목표보다 이전 목표인지 확인 (목표 없음이 먼저, 그 다음은 ID 순)
+ if ("NULL".equals(cursor.getGroupValue())) {
+ return false; // 목표 없음이 첫 번째이므로 건너뛸 목표 없음
+ }
+
+ if ("NULL".equals(currentGoalValue)) {
+ return true; // 현재가 목표 없음인데 커서는 특정 목표를 가리키므로 건너뛰기
+ }
- boolean hasNext = externals.size() > size;
- if(hasNext) {
- externals = externals.subList(0, size);
+ try {
+ Long cursorGoalId = Long.parseLong(cursor.getGroupValue());
+ return goalId < cursorGoalId;
+ } catch (NumberFormatException e) {
+ return false;
}
+ }
- String nextCursor = null;
- if(hasNext && !externals.isEmpty()) {
- External last = externals.getLast();
- ExternalCursor externalCursor = createCursorFromExternal(last, criteria);
- nextCursor = externalCursor.encode();
+ private boolean isSameGoalGroup(Long goalId, ExternalCursor cursor) {
+ if (cursor.getGroupValue() == null) {
+ log.debug("Cursor group value is null, returning false");
+ return false;
}
- return ExternalConverter.toGroupedPageResponse(externals, hasNext, nextCursor, size);
+ // ✅ 일관된 NULL 처리
+ String currentGoalValue = goalId != null ? goalId.toString() : NULL_GROUP_VALUE;
+ boolean isSame = cursor.getGroupValue().equals(currentGoalValue);
+
+ log.info("Comparing goal groups - current: {}, cursor: {}, isSame: {}",
+ currentGoalValue, cursor.getGroupValue(), isSame);
+
+ return isSame;
+ }
+
+ private boolean shouldSkipAssigneeGroup(String assigneeValue, ExternalCursor cursor) {
+ if (cursor.getGroupValue() == null) return false;
+
+ // 담당자 없음("NULL")이 먼저, 그 다음은 담당자 ID 순
+ if ("NULL".equals(cursor.getGroupValue())) {
+ return false; // 담당자 없음이 첫 번째이므로 건너뛸 그룹 없음
+ }
+
+ if ("NULL".equals(assigneeValue)) {
+ return true; // 현재가 담당자 없음인데 커서는 특정 담당자를 가리키므로 건너뛰기
+ }
+
+ try {
+ Long currentAssigneeId = Long.parseLong(assigneeValue);
+ Long cursorAssigneeId = Long.parseLong(cursor.getGroupValue());
+ return currentAssigneeId < cursorAssigneeId;
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+
+ private boolean isSameAssigneeGroup(Long assigneeId, ExternalCursor cursor) {
+ if (cursor.getGroupValue() == null) return false;
+
+ String currentAssigneeValue = assigneeId != null ? assigneeId.toString() : "NULL_GROUP";
+ return cursor.getGroupValue().equals(currentAssigneeValue);
+ }
+
+ private String getDisplayName(Enum> enumValue) {
+ return enumValue.name();
+ }
+
+ private List convertToExternalItemDTOs(List externalDTOs) {
+ return externalDTOs.stream()
+ .map(dto -> ExternalGroupedResponseDTO.ExternalItemDTO.builder()
+ .id(dto.getId())
+ .name(dto.getName())
+ .title(dto.getTitle())
+ .state(dto.getState())
+ .priority(dto.getPriority() != null ? dto.getPriority().name() : "없음")
+ .deadline(dto.getDeadlines() != null ?
+ ExternalGroupedResponseDTO.DeadlineDTO.builder()
+ .start(dto.getDeadlines().getStart() != null ? dto.getDeadlines().getStart().toString() : null)
+ .end(dto.getDeadlines().getEnd() != null ? dto.getDeadlines().getEnd().toString() : null)
+ .build() : null)
+ .managers(dto.getManagers() != null ?
+ ExternalGroupedResponseDTO.ManagersDTO.builder()
+ .cnt(dto.getManagers().getCnt())
+ .info(dto.getManagers().getInfo().stream()
+ .map(info -> ExternalGroupedResponseDTO.ManagerInfoDTO.builder()
+ .profileUrl(info.getProfileUrl())
+ .name(info.getNickname())
+ .build())
+ .toList())
+ .build() : null)
+ .extServiceType(dto.getExtServiceType())
+ .build())
+ .toList();
+ }
+
+ private ExternalGroupedResponseDTO.ExternalGroupedPageResponse buildGroupedResponseForAssignee(JPAQuery baseQuery, String cursor, int size, ExternalSearchCriteria criteria) {
+ return ExternalGroupedResponseDTO.ExternalGroupedPageResponse.builder()
+ .data(new java.util.ArrayList<>())
+ .hasNext(false)
+ .nextCursor(null)
+ .pageSize(size)
+ .build();
+ }
+
+ private ExternalGroupedResponseDTO.ExternalGroupedPageResponse buildGroupedResponseForGoal(JPAQuery baseQuery, String cursor, int size, ExternalSearchCriteria criteria) {
+ return ExternalGroupedResponseDTO.ExternalGroupedPageResponse.builder()
+ .data(new java.util.ArrayList<>())
+ .hasNext(false)
+ .nextCursor(null)
+ .pageSize(size)
+ .build();
+ }
+
+ private int findAssigneeStartIndex(List allAssignees, String cursorGroupValue) {
+ if ("NULL_GROUP".equals(cursorGroupValue)) {
+ // 담당자 없음 찾기 (첫 번째가 담당자 없음)
+ return 0;
+ } else {
+ // 특정 assigneeId 찾기
+ try {
+ Long targetAssigneeId = Long.parseLong(cursorGroupValue);
+ for (int i = 0; i < allAssignees.size(); i++) {
+ if (targetAssigneeId.equals(allAssignees.get(i).getId())) {
+ return i;
+ }
+ }
+ } catch (NumberFormatException e) {
+ log.warn("Invalid assignee ID in cursor: {}", cursorGroupValue);
+ }
+ }
+
+ // 찾지 못한 경우 처음부터 시작
+ log.warn("Assignee not found for cursor: {}, starting from beginning", cursorGroupValue);
+ return 0;
+ }
+
+ private static class AssigneeInfo {
+ private final Long id;
+ private final String name;
+
+ public AssigneeInfo(Long id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ public Long getId() { return id; }
+ public String getName() { return name; }
+ }
+
+ private String goalIdToGroupValue(Long goalId) {
+ return goalId != null ? goalId.toString() : NULL_GROUP_VALUE;
}
}
diff --git a/src/main/java/com/example/Veco/domain/external/repository/ExternalCustomRepository.java b/src/main/java/com/example/Veco/domain/external/repository/ExternalCustomRepository.java
index 0e63df23..6bd0db69 100644
--- a/src/main/java/com/example/Veco/domain/external/repository/ExternalCustomRepository.java
+++ b/src/main/java/com/example/Veco/domain/external/repository/ExternalCustomRepository.java
@@ -5,5 +5,4 @@
import com.example.Veco.global.apiPayload.page.CursorPage;
public interface ExternalCustomRepository {
- CursorPage findExternalWithCursor(ExternalSearchCriteria criteria, String cursor, int size);
}
diff --git a/src/main/java/com/example/Veco/domain/external/repository/ExternalRepository.java b/src/main/java/com/example/Veco/domain/external/repository/ExternalRepository.java
index 8b479526..06c46f2e 100644
--- a/src/main/java/com/example/Veco/domain/external/repository/ExternalRepository.java
+++ b/src/main/java/com/example/Veco/domain/external/repository/ExternalRepository.java
@@ -1,7 +1,9 @@
package com.example.Veco.domain.external.repository;
import com.example.Veco.domain.external.entity.External;
+import com.example.Veco.domain.goal.entity.Goal;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
@@ -10,4 +12,13 @@ public interface ExternalRepository extends JpaRepository {
Optional findByGithubDataId(Long githubDataId);
List findByIdIn(List externalIds);
+
+ List findByTeamId(Long teamId);
+
+ @Query(value = "SELECT * FROM external WHERE deleted_at IS NOT NULL AND team_id =:teamId", nativeQuery = true)
+ List findAllByTeamIdAndDeleted(Long teamId);
+
+ // 삭제된 목표 ID로 조회
+ @Query(value = "SELECT * FROM external WHERE deleted_at IS NOT NULL AND id IN :externalIds", nativeQuery = true)
+ List findDeletedExternalsById(List externalIds);
}
diff --git a/src/main/java/com/example/Veco/domain/external/service/ExternalService.java b/src/main/java/com/example/Veco/domain/external/service/ExternalService.java
index af801c37..46d7ccd6 100644
--- a/src/main/java/com/example/Veco/domain/external/service/ExternalService.java
+++ b/src/main/java/com/example/Veco/domain/external/service/ExternalService.java
@@ -1,50 +1,58 @@
package com.example.Veco.domain.external.service;
+import com.example.Veco.domain.assignee.repository.AssigneeRepository;
+import com.example.Veco.domain.comment.entity.Comment;
import com.example.Veco.domain.comment.entity.CommentRoom;
+import com.example.Veco.domain.comment.repository.CommentRepository;
import com.example.Veco.domain.external.converter.ExternalConverter;
+import com.example.Veco.domain.external.dto.paging.ExternalSearchCriteria;
import com.example.Veco.domain.external.dto.request.ExternalRequestDTO;
-import com.example.Veco.domain.external.dto.response.ExternalResponseDTO;
import com.example.Veco.domain.external.dto.response.ExternalGroupedResponseDTO;
-import com.example.Veco.domain.external.dto.paging.ExternalSearchCriteria;
+import com.example.Veco.domain.external.dto.response.ExternalResponseDTO;
import com.example.Veco.domain.external.entity.External;
+import com.example.Veco.domain.external.exception.ExternalException;
+import com.example.Veco.domain.external.exception.code.ExternalErrorCode;
import com.example.Veco.domain.external.repository.ExternalCustomRepository;
import com.example.Veco.domain.external.repository.ExternalRepository;
+import com.example.Veco.domain.github.service.GitHubIssueService;
+import com.example.Veco.domain.goal.converter.GoalConverter;
+import com.example.Veco.domain.goal.dto.request.GoalReqDTO;
+import com.example.Veco.domain.goal.dto.response.GoalResDTO;
import com.example.Veco.domain.goal.entity.Goal;
import com.example.Veco.domain.goal.repository.GoalRepository;
import com.example.Veco.domain.mapping.Assignment;
-import com.example.Veco.domain.mapping.GithubInstallation;
-import com.example.Veco.domain.mapping.entity.Link;
-import com.example.Veco.domain.mapping.repository.AssigmentRepository;
-import com.example.Veco.domain.mapping.repository.CommentRoomRepository;
-import com.example.Veco.domain.mapping.repository.GitHubInstallationRepository;
-import com.example.Veco.domain.mapping.repository.LinkRepository;
+import com.example.Veco.domain.mapping.converter.AssignmentConverter;
+import com.example.Veco.domain.mapping.entity.MemberTeam;
+import com.example.Veco.domain.mapping.repository.*;
import com.example.Veco.domain.member.entity.Member;
+import com.example.Veco.domain.member.error.MemberErrorStatus;
+import com.example.Veco.domain.member.error.MemberHandler;
import com.example.Veco.domain.member.repository.MemberRepository;
-import com.example.Veco.domain.slack.dto.SlackResDTO;
import com.example.Veco.domain.slack.exception.SlackException;
import com.example.Veco.domain.slack.exception.code.SlackErrorCode;
import com.example.Veco.domain.slack.util.SlackUtil;
-import com.example.Veco.domain.team.converter.AssigneeConverter;
-import com.example.Veco.domain.team.dto.AssigneeResponseDTO;
import com.example.Veco.domain.team.dto.NumberSequenceResponseDTO;
import com.example.Veco.domain.team.entity.Team;
-import com.example.Veco.domain.team.enums.Category;
import com.example.Veco.domain.team.exception.TeamException;
import com.example.Veco.domain.team.exception.code.TeamErrorCode;
import com.example.Veco.domain.team.repository.TeamRepository;
import com.example.Veco.domain.team.service.NumberSequenceService;
import com.example.Veco.global.apiPayload.code.ErrorStatus;
import com.example.Veco.global.apiPayload.exception.VecoException;
-import com.example.Veco.global.apiPayload.page.CursorPage;
+import com.example.Veco.global.auth.user.AuthUser;
+import com.example.Veco.global.enums.Category;
import com.example.Veco.global.enums.ExtServiceType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
+import java.util.stream.Collectors;
@Slf4j
@Service
@@ -56,19 +64,27 @@ public class ExternalService {
private final NumberSequenceService numberSequenceService;
private final AssigmentRepository assigmentRepository;
private final ExternalCustomRepository externalCustomRepository;
+ private final MemberTeamRepository memberTeamRepository;
private final TeamRepository teamRepository;
private final GoalRepository goalRepository;
private final MemberRepository memberRepository;
private final LinkRepository linkRepository;
+ private final AssigneeRepository assigneeRepository;
// 유틸
private final SlackUtil slackUtil;
private final CommentRoomRepository commentRoomRepository;
private final GitHubIssueService gitHubIssueService;
private final GitHubInstallationRepository githubInstallationRepository;
+ private final CommentRepository commentRepository;
@Transactional
- public ExternalResponseDTO.CreateResponseDTO createExternal(Long teamId, ExternalRequestDTO.ExternalCreateRequestDTO request){
+ public ExternalResponseDTO.CreateResponseDTO createExternal(Long teamId,
+ ExternalRequestDTO.ExternalCreateRequestDTO request,
+ AuthUser user) {
+
+ Member author = memberRepository.findBySocialUid(user.getSocialUid())
+ .orElseThrow(() -> new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND));
Team team = teamRepository.findById(teamId)
.orElseThrow(() -> new TeamException(TeamErrorCode._NOT_FOUND));
@@ -76,36 +92,76 @@ public ExternalResponseDTO.CreateResponseDTO createExternal(Long teamId, Externa
NumberSequenceResponseDTO sequenceDTO = numberSequenceService
.allocateNextNumber(team.getWorkSpace().getName(), teamId, Category.EXTERNAL);
+
+ List members = memberRepository.findAllByIdIn(request.getManagersId());
+
+ if(request.getManagersId().size() != members.size()){
+ throw new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND);
+ }
+
+ List memberTeamList = memberTeamRepository.findAllByMemberIdInAndTeamId(request.getManagersId(), teamId);
+ if (memberTeamList.size() != request.getManagersId().size()) {
+ throw new MemberHandler(MemberErrorStatus._FORBIDDEN);
+ }
+
Goal goal = null;
if(request.getGoalId() != null){
goal = findGoalById(request.getGoalId());
}
- External external = ExternalConverter.toExternal(team, goal, request, sequenceDTO.getNextCode());
+ External external = ExternalConverter.toExternal(team, goal, request, sequenceDTO.getNextCode(), author);
+
+ members.forEach(member -> {
+ Assignment assignment = AssignmentConverter.toAssignment(member, external, Category.EXTERNAL);
+ external.addAssignment(assignment);
+ });
externalRepository.save(external);
if(request.getExtServiceType() == ExtServiceType.GITHUB){
gitHubIssueService.createGitHubIssue(request);
+ } else if (request.getExtServiceType() == ExtServiceType.SLACK){
+ // accessToken, DefaultChannelId, message
+ // 연동 정보 조회
+ com.example.Veco.domain.external.entity.ExternalService externalService =
+ linkRepository.findLinkByWorkspaceAndExternalService_ServiceType(
+ team.getWorkSpace(), ExtServiceType.SLACK)
+ .orElseThrow(() -> new SlackException(SlackErrorCode.NOT_LINKED))
+ .getExternalService();
+
+ String message = team.getName() + "에서 " +
+ external.getTitle() + "을(를) 생성했습니다.";
+
+ slackUtil.PostSlackMessage(
+ externalService.getAccessToken(),
+ externalService.getSlackDefaultChannelId(),
+ message
+ );
}
return ExternalConverter.createResponseDTO(external);
}
- public ExternalResponseDTO.ExternalInfoDTO getExternalById(Long externalId) {
+ public ExternalResponseDTO.ExternalInfoDTO getExternalById(Long externalId, Long teamId) {
+
+ External external = findExternalById(externalId);
CommentRoom commentRoom = commentRoomRepository
.findByRoomTypeAndTargetId(com.example.Veco.global.enums.Category.EXTERNAL, externalId);
- External external = findExternalById(externalId);
+ if(!external.getTeam().getId().equals(teamId)){
+ throw new ExternalException(ExternalErrorCode.NOT_SAME_TEAM);
+ }
- return ExternalConverter.toExternalInfoDTO(external, external.getAssignments(),
- commentRoom != null ? commentRoom.getComments() : null);
- }
+ List comments = new ArrayList<>();
- public CursorPage getExternalsWithPagination(ExternalSearchCriteria criteria, String cursor, int size){
- return externalCustomRepository.findExternalWithCursor(criteria, cursor, size);
+ if (commentRoom != null) {
+ comments = commentRepository.findAllByCommentRoomOrderByIdAsc(commentRoom);
+ }
+
+
+ return ExternalConverter.toExternalInfoDTO(external, external.getAssignments(), comments);
}
public ExternalGroupedResponseDTO.ExternalGroupedPageResponse getExternalsWithGroupedPagination(ExternalSearchCriteria criteria, String cursor, int size){
@@ -113,6 +169,10 @@ public ExternalGroupedResponseDTO.ExternalGroupedPageResponse getExternalsWithGr
.findExternalWithGroupedResponse(criteria, cursor, size);
}
+ public ExternalResponseDTO.SimpleListDTO getSimpleExternals(Long teamId) {
+ return ExternalConverter.toSimpleListDTO(externalRepository.findByTeamId(teamId));
+ }
+
@Transactional
public void deleteExternals(ExternalRequestDTO.ExternalDeleteRequestDTO request) {
externalRepository.findByIdIn(request.getExternalIds());
@@ -120,11 +180,47 @@ public void deleteExternals(ExternalRequestDTO.ExternalDeleteRequestDTO request)
@Transactional
public void softDeleteExternals(ExternalRequestDTO.ExternalDeleteRequestDTO request) {
+
+ log.info("Delete external {}", request.getExternalIds());
+
List externals = externalRepository.findByIdIn(request.getExternalIds());
externals.forEach(External::softDelete);
}
+ // 삭제된 목표 리스트 조회
+ public List getDeletedExternals(
+ Long teamId
+ ) {
+
+ List result = externalRepository.findAllByTeamIdAndDeleted(teamId);
+
+ if (result.isEmpty()){
+ throw new ExternalException(ExternalErrorCode.NOT_FOUND_DELETE_EXTERNALS);
+ }
+
+ return result.stream().map(ExternalConverter::toSimpleExternalDTO).toList();
+ }
+
+
+ @Transactional
+ public List restoreGoals(
+ ExternalRequestDTO.ExternalDeleteRequestDTO dto
+ ) {
+ // 삭제된 목표들 조회
+ List result = externalRepository.findDeletedExternalsById(dto.getExternalIds());
+
+ if (result.isEmpty()){
+ throw new ExternalException(ExternalErrorCode.NOT_A_DELETE);
+ }
+
+ for (External external : result) {
+ external.restore();
+ }
+
+ return result.stream().map(ExternalConverter::toSimpleExternalDTO).collect(Collectors.toList());
+ }
+
@Transactional
public ExternalResponseDTO.UpdateResponseDTO updateExternal(Long externalId, ExternalRequestDTO.ExternalUpdateRequestDTO request) {
External external = findExternalById(externalId);
@@ -139,11 +235,55 @@ public ExternalResponseDTO.UpdateResponseDTO updateExternal(Long externalId, Ext
external.setGoal(goal);
}
+ if(request.getDeadline() != null){
+ updateExternalDates(external, request.getDeadline());
+ }
+
external.updateExternal(request);
return ExternalConverter.updateResponseDTO(external);
}
+ private void updateExternalDates(External external, ExternalRequestDTO.DeadlineRequestDTO deadline) {
+
+ // 기한 변경
+ if (deadline != null) {
+
+ try {
+ if (deadline.getStart() != null) {
+ LocalDate start;
+ if (deadline.getStart().equals("null")){
+ start = null;
+ } else {
+ start = LocalDate.parse(deadline.getStart());
+ }
+ external.updateStartDate(start);
+ }
+ if (deadline.getEnd() != null) {
+ LocalDate end;
+ if (deadline.getEnd().equals("null")){
+ end = null;
+ } else {
+ end = LocalDate.parse(deadline.getEnd());
+ }
+ external.updateEndDate(end);
+ }
+ } catch (DateTimeParseException e) {
+ throw new ExternalException(ExternalErrorCode.DEADLINE_INVALID);
+ }
+
+ }
+
+ validateDates(external.getStartDate(), external.getEndDate());
+ }
+
+ private void validateDates(LocalDate startDate, LocalDate endDate) {
+ if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
+ throw new IllegalArgumentException("시작일은 마감일보다 늦을 수 없습니다.");
+ }
+ }
+
+
public String getExternalName(Long teamId) {
Team team = teamRepository.findById(teamId)
.orElseThrow(() -> new TeamException(TeamErrorCode._NOT_FOUND));
diff --git a/src/main/java/com/example/Veco/domain/github/controller/GitHubRestController.java b/src/main/java/com/example/Veco/domain/github/controller/GitHubRestController.java
new file mode 100644
index 00000000..27c06ac9
--- /dev/null
+++ b/src/main/java/com/example/Veco/domain/github/controller/GitHubRestController.java
@@ -0,0 +1,71 @@
+package com.example.Veco.domain.github.controller;
+
+import com.example.Veco.domain.github.dto.response.GitHubApiResponseDTO;
+import com.example.Veco.domain.github.dto.response.GitHubResponseDTO;
+import com.example.Veco.domain.github.service.GitHubRepositoryService;
+import com.example.Veco.domain.github.service.GitHubService;
+import com.example.Veco.global.apiPayload.ApiResponse;
+import io.swagger.v3.oas.annotations.Hidden;
+import io.swagger.v3.oas.annotations.Parameter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import reactor.core.publisher.Mono;
+
+import java.net.URI;
+import java.util.List;
+
+@RestController
+@RequiredArgsConstructor
+public class GitHubRestController implements GitHubSwaggerDocs {
+
+ private final GitHubService gitHubService;
+ private final GitHubRepositoryService gitHubRepositoryService;
+ private static final String FRONTEND_URL = "http://localhost:5173";
+
+ @Hidden
+ @GetMapping("/github/installation/callback")
+ public ResponseEntity callbackInstallation(
+ @Parameter(description = "팀 ID") @RequestParam("state") Long state,
+ @Parameter(description = "GitHub App 설치 ID") @RequestParam("installation_id") Long installationId
+ ) {
+
+ gitHubService.saveInstallationInfo(state, installationId);
+
+ String redirectUrl = String.format(
+ "%s/github/complete?teamId=%d",
+ FRONTEND_URL, state
+ );
+
+ return ResponseEntity.status(HttpStatus.FOUND)
+ .location(URI.create(redirectUrl))
+ .build();
+ }
+
+ @GetMapping("/api/teams/{teamId}/github/repositories")
+ public Mono>> getRepositories(@PathVariable("teamId") Long teamId) {
+
+ // owner, repo 정보 추출해서 클라이언트에 제공
+ return gitHubService.getInstallationIdAsync(teamId) // 비동기 DB 조회
+ .flatMap(gitHubRepositoryService::getInstallationRepositories)
+ .map(ApiResponse::onSuccess)
+ .onErrorReturn(ApiResponse.onFailure("REPO_FETCH_FAILED", "레포지토리 조회 실패", null));
+ }
+
+ @GetMapping("/api/github/connect")
+ public ApiResponse> connectGitHub(@RequestParam("teamId") Long teamId) {
+ String appInstallUrl = String.format(
+ "https://github.com/apps/VecoApp/installations/new?state=%s",
+ teamId
+ );
+
+ return ApiResponse.onSuccess(appInstallUrl);
+ }
+
+ @GetMapping("/api/teams/{teamId}/github/installation")
+ public ApiResponse getInstallation(@PathVariable("teamId") Long teamId) {
+ return ApiResponse.onSuccess(gitHubService.getInstallationInfo(teamId));
+ }
+}
+
diff --git a/src/main/java/com/example/Veco/domain/github/controller/GitHubSwaggerDocs.java b/src/main/java/com/example/Veco/domain/github/controller/GitHubSwaggerDocs.java
new file mode 100644
index 00000000..3893e880
--- /dev/null
+++ b/src/main/java/com/example/Veco/domain/github/controller/GitHubSwaggerDocs.java
@@ -0,0 +1,34 @@
+package com.example.Veco.domain.github.controller;
+
+import com.example.Veco.domain.github.dto.response.GitHubApiResponseDTO;
+import com.example.Veco.domain.github.dto.response.GitHubResponseDTO;
+import com.example.Veco.global.apiPayload.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestParam;
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+
+@Tag(name = "GitHub 연동 API", description = "GitHub 외부 툴 연동을 위한 인증 및 콜백 API")
+public interface GitHubSwaggerDocs {
+
+ @Operation(
+ summary = "GitHub App이 접근 가능한 레포지토리 목록 조회",
+ description = "해당 팀이 설치한 GitHub App 이 접근 가능한 레포지토리 목록을 조회합니다."
+ )
+ Mono>> getRepositories(@PathVariable("teamId") Long teamId);
+
+ @Operation(
+ summary = "GitHub 연동을 위한 App 설치 페이지 URL 조회",
+ description = "GitHub App 설치를 위한 App 설치 페이지 URL을 전달합니다."
+ )
+ ApiResponse> connectGitHub(@RequestParam("teamId") Long teamId);
+
+ @Operation(
+ summary = "GitHub 연동을 마친 팀의 연동 ID를 조회",
+ description = "GitHub 연동을 성공적으로 마친 팀의 ID르 통해서 연동 ID를 조회합니다."
+ )
+ ApiResponse getInstallation(@PathVariable("teamId") Long teamId);
+}
diff --git a/src/main/java/com/example/Veco/domain/github/controller/GitHubWebhookController.java b/src/main/java/com/example/Veco/domain/github/controller/GitHubWebhookController.java
new file mode 100644
index 00000000..708bb3c4
--- /dev/null
+++ b/src/main/java/com/example/Veco/domain/github/controller/GitHubWebhookController.java
@@ -0,0 +1,54 @@
+package com.example.Veco.domain.github.controller;
+
+import com.example.Veco.domain.github.service.GitHubIssueService;
+import com.example.Veco.domain.github.service.GitHubPullRequestService;
+import io.swagger.v3.oas.annotations.Hidden;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/github")
+@RequiredArgsConstructor
+@Slf4j
+public class GitHubWebhookController {
+
+ private final GitHubIssueService gitHubIssueService;
+ private final GitHubPullRequestService gitHubPullRequestService;
+
+ @Hidden
+ @PostMapping("/webhook")
+ public ResponseEntity handleWebhook(
+ @RequestBody String payload,
+ @RequestHeader("X-GitHub-Event") String eventType,
+ @RequestHeader("X-GitHub-Delivery") String deliveryId) {
+
+ log.info("Received GitHub webhook. Event: {}, Delivery: {}", eventType, deliveryId);
+
+ try {
+
+ switch (eventType) {
+ case "issues":
+ gitHubIssueService.processIssueWebhook(payload);
+ break;
+ case "pull_request":
+
+ log.info(payload);
+
+ gitHubPullRequestService.handlePullRequestEvent(payload);
+ break;
+ case "issue_comment":
+ break;
+ }
+
+ return ResponseEntity.ok().build();
+
+ } catch (Exception e) {
+ log.error("Error processing webhook for delivery: {}", deliveryId, e);
+ return ResponseEntity.status(500).body("Internal server error");
+ }
+
+ }
+}
diff --git a/src/main/java/com/example/Veco/domain/external/converter/GitHubConverter.java b/src/main/java/com/example/Veco/domain/github/converter/GitHubConverter.java
similarity index 81%
rename from src/main/java/com/example/Veco/domain/external/converter/GitHubConverter.java
rename to src/main/java/com/example/Veco/domain/github/converter/GitHubConverter.java
index 4e23d8f5..6000660f 100644
--- a/src/main/java/com/example/Veco/domain/external/converter/GitHubConverter.java
+++ b/src/main/java/com/example/Veco/domain/github/converter/GitHubConverter.java
@@ -1,12 +1,10 @@
-package com.example.Veco.domain.external.converter;
+package com.example.Veco.domain.github.converter;
import com.example.Veco.domain.external.dto.request.ExternalRequestDTO;
-import com.example.Veco.domain.external.dto.request.GitHubApiRequestDTO;
-import com.example.Veco.domain.external.dto.response.GitHubResponseDTO;
+import com.example.Veco.domain.github.dto.request.GitHubApiRequestDTO;
+import com.example.Veco.domain.github.dto.response.GitHubResponseDTO;
import com.example.Veco.domain.mapping.GithubInstallation;
-import java.util.List;
-
public class GitHubConverter {
public static GitHubResponseDTO.GitHubAppInstallationDTO toGitHubAppInstallationDTO(GithubInstallation installation) {
diff --git a/src/main/java/com/example/Veco/domain/external/dto/request/GitHubApiRequestDTO.java b/src/main/java/com/example/Veco/domain/github/dto/request/GitHubApiRequestDTO.java
similarity index 85%
rename from src/main/java/com/example/Veco/domain/external/dto/request/GitHubApiRequestDTO.java
rename to src/main/java/com/example/Veco/domain/github/dto/request/GitHubApiRequestDTO.java
index e866d539..506dbe8c 100644
--- a/src/main/java/com/example/Veco/domain/external/dto/request/GitHubApiRequestDTO.java
+++ b/src/main/java/com/example/Veco/domain/github/dto/request/GitHubApiRequestDTO.java
@@ -1,4 +1,4 @@
-package com.example.Veco.domain.external.dto.request;
+package com.example.Veco.domain.github.dto.request;
import lombok.Builder;
import lombok.Data;
diff --git a/src/main/java/com/example/Veco/domain/external/dto/response/GitHubApiResponseDTO.java b/src/main/java/com/example/Veco/domain/github/dto/response/GitHubApiResponseDTO.java
similarity index 94%
rename from src/main/java/com/example/Veco/domain/external/dto/response/GitHubApiResponseDTO.java
rename to src/main/java/com/example/Veco/domain/github/dto/response/GitHubApiResponseDTO.java
index 3a0fb690..9c9abb85 100644
--- a/src/main/java/com/example/Veco/domain/external/dto/response/GitHubApiResponseDTO.java
+++ b/src/main/java/com/example/Veco/domain/github/dto/response/GitHubApiResponseDTO.java
@@ -1,6 +1,5 @@
-package com.example.Veco.domain.external.dto.response;
+package com.example.Veco.domain.github.dto.response;
-import com.example.Veco.domain.external.entity.GitHubRepository;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
diff --git a/src/main/java/com/example/Veco/domain/external/dto/response/GitHubResponseDTO.java b/src/main/java/com/example/Veco/domain/github/dto/response/GitHubResponseDTO.java
similarity index 86%
rename from src/main/java/com/example/Veco/domain/external/dto/response/GitHubResponseDTO.java
rename to src/main/java/com/example/Veco/domain/github/dto/response/GitHubResponseDTO.java
index 7a286e5a..732af6a3 100644
--- a/src/main/java/com/example/Veco/domain/external/dto/response/GitHubResponseDTO.java
+++ b/src/main/java/com/example/Veco/domain/github/dto/response/GitHubResponseDTO.java
@@ -1,4 +1,4 @@
-package com.example.Veco.domain.external.dto.response;
+package com.example.Veco.domain.github.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
diff --git a/src/main/java/com/example/Veco/domain/external/dto/response/GitHubTokenResponse.java b/src/main/java/com/example/Veco/domain/github/dto/response/GitHubTokenResponse.java
similarity index 89%
rename from src/main/java/com/example/Veco/domain/external/dto/response/GitHubTokenResponse.java
rename to src/main/java/com/example/Veco/domain/github/dto/response/GitHubTokenResponse.java
index be163e68..f6a76fc6 100644
--- a/src/main/java/com/example/Veco/domain/external/dto/response/GitHubTokenResponse.java
+++ b/src/main/java/com/example/Veco/domain/github/dto/response/GitHubTokenResponse.java
@@ -1,4 +1,4 @@
-package com.example.Veco.domain.external.dto.response;
+package com.example.Veco.domain.github.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
diff --git a/src/main/java/com/example/Veco/domain/github/dto/webhook/GitHubPullRequestPayload.java b/src/main/java/com/example/Veco/domain/github/dto/webhook/GitHubPullRequestPayload.java
new file mode 100644
index 00000000..09dd3cd6
--- /dev/null
+++ b/src/main/java/com/example/Veco/domain/github/dto/webhook/GitHubPullRequestPayload.java
@@ -0,0 +1,114 @@
+package com.example.Veco.domain.github.dto.webhook;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+import lombok.Getter;
+
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Getter
+@Data
+public class GitHubPullRequestPayload {
+ private String action; // PR 액션 타입
+ private Long number;
+ @JsonProperty("pull_request")
+ private PullRequest pullRequest; // PR 상세 정보
+ private Repository repository; // 저장소 정보
+ private Installation installation; // GitHub App 설치 정보
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @Getter
+ @Data
+ public static class PullRequest {
+ private Long id; // PR 고유 ID
+ private String nodeId; // GraphQL Node ID
+ private Long number; // PR 번호 (#123)
+ private String title; // PR 제목
+ private String body; // PR 설명
+ private String state; // "open", "closed"
+ private Boolean locked; // 잠김 상태
+ private Boolean draft; // 드래프트 여부
+ private Boolean merged; // 머지 여부
+ private Boolean mergeable; // 머지 가능 여부
+ private String mergeableState; // "clean", "dirty", "unstable", "blocked"
+ private User user; // PR 생성자
+ private User assignee; // 담당자
+ private List assignees; // 담당자 목록
+ private List requestedReviewers; // 리뷰 요청된 사용자들
+ private String createdAt; // 생성 시간
+ private String updatedAt; // 수정 시간
+ private String closedAt; // 닫힌 시간
+ private String mergedAt; // 머지 시간
+ private String mergeCommitSha; // 머지 커밋 SHA
+
+ // Head와 Base 브랜치 정보
+ private Branch head; // 소스 브랜치
+ private Branch base; // 대상 브랜치
+
+ // URL 정보
+ private String url; // API URL
+ private String htmlUrl; // 웹 URL
+ private String diffUrl; // Diff URL
+ private String patchUrl; // Patch URL
+
+ // 통계 정보
+ private Integer commits; // 커밋 수
+ private Integer additions; // 추가된 라인 수
+ private Integer deletions; // 삭제된 라인 수
+ private Integer changedFiles; // 변경된 파일 수
+
+ // getters and setters...
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @Data
+ public static class Branch {
+ private String label; // "owner:branch_name"
+ private String ref; // "branch_name"
+ private String sha; // 커밋 SHA
+ private User user; // 브랜치 소유자
+ private Repository repo; // 저장소 정보
+
+ // getters and setters...
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @Data
+ public static class Repository {
+ private Long id;
+ private String nodeId;
+ private String name; // 저장소 이름
+ private String fullName; // "owner/repo"
+ private Boolean isPrivate; // 비공개 여부
+ private User owner; // 소유자
+ private String htmlUrl; // 웹 URL
+ private String cloneUrl; // Git 클론 URL
+ private String defaultBranch; // 기본 브랜치
+
+ // getters and setters...
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class User {
+ private Long id;
+ private String nodeId;
+ private String login; // 사용자명
+ private String avatarUrl; // 아바타 이미지 URL
+ private String url; // API URL
+ private String htmlUrl; // 프로필 URL
+ private String type; // "User" or "Bot"
+
+ // getters and setters...
+ }
+
+ @Data
+ public static class Installation {
+ @JsonProperty("id")
+ private Long id;
+
+ @JsonProperty("node_id")
+ private String nodeId;
+ }
+}
diff --git a/src/main/java/com/example/Veco/domain/external/dto/GitHubWebhookPayload.java b/src/main/java/com/example/Veco/domain/github/dto/webhook/GitHubWebhookPayload.java
similarity index 82%
rename from src/main/java/com/example/Veco/domain/external/dto/GitHubWebhookPayload.java
rename to src/main/java/com/example/Veco/domain/github/dto/webhook/GitHubWebhookPayload.java
index 7e247aff..668ffa0a 100644
--- a/src/main/java/com/example/Veco/domain/external/dto/GitHubWebhookPayload.java
+++ b/src/main/java/com/example/Veco/domain/github/dto/webhook/GitHubWebhookPayload.java
@@ -1,13 +1,14 @@
-package com.example.Veco.domain.external.dto;
+package com.example.Veco.domain.github.dto.webhook;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
-import lombok.Getter;
import java.time.LocalDateTime;
import java.util.List;
@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
public class GitHubWebhookPayload {
private String action;
@@ -17,6 +18,7 @@ public class GitHubWebhookPayload {
private Installation installation;
@Data
+ @JsonIgnoreProperties(ignoreUnknown = true)
public static class Issue {
private Long id;
@@ -48,19 +50,20 @@ public static class Issue {
private Integer comments;
@JsonProperty("closed_at")
- private LocalDateTime closedAt;
+ private String closedAt;
@JsonProperty("created_at")
- private LocalDateTime createdAt;
+ private String createdAt;
@JsonProperty("updated_at")
- private LocalDateTime updatedAt;
+ private String updatedAt;
@JsonProperty("author_association")
private String authorAssociation;
}
@Data
+ @JsonIgnoreProperties(ignoreUnknown = true)
public static class Repository {
private Long id;
@@ -85,13 +88,14 @@ public static class Repository {
private String url;
@JsonProperty("created_at")
- private LocalDateTime createdAt;
+ private String createdAt;
@JsonProperty("updated_at")
- private LocalDateTime updatedAt;
+ private String updatedAt;
}
@Data
+ @JsonIgnoreProperties(ignoreUnknown = true)
public static class User {
private Long id;
@@ -118,6 +122,7 @@ public static class User {
}
@Data
+ @JsonIgnoreProperties(ignoreUnknown = true)
public static class Label {
private Long id;
@@ -134,6 +139,7 @@ public static class Label {
}
@Data
+ @JsonIgnoreProperties(ignoreUnknown = true)
public static class Milestone {
private Long id;
@@ -158,19 +164,20 @@ public static class Milestone {
private Integer closedIssues;
@JsonProperty("created_at")
- private LocalDateTime createdAt;
+ private String createdAt;
@JsonProperty("updated_at")
- private LocalDateTime updatedAt;
+ private String updatedAt;
@JsonProperty("closed_at")
- private LocalDateTime closedAt;
+ private String closedAt;
@JsonProperty("due_on")
- private LocalDateTime dueOn;
+ private String dueOn;
}
@Data
+ @JsonIgnoreProperties(ignoreUnknown = true)
public static class Installation {
@JsonProperty("id")
private Long id;
diff --git a/src/main/java/com/example/Veco/domain/github/dto/webhook/IssueCommentWebhookDTO.java b/src/main/java/com/example/Veco/domain/github/dto/webhook/IssueCommentWebhookDTO.java
new file mode 100644
index 00000000..f936b03e
--- /dev/null
+++ b/src/main/java/com/example/Veco/domain/github/dto/webhook/IssueCommentWebhookDTO.java
@@ -0,0 +1,24 @@
+package com.example.Veco.domain.github.dto.webhook;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+public class IssueCommentWebhookDTO {
+
+ private String action;
+ private Comment comment;
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @Data
+ public static class Comment {
+ private Long id; // 댓글 ID
+ private String nodeId; // GraphQL Node ID
+ private String body; // 댓글 내용
+ private String createdAt; // 작성 시간
+ private String updatedAt; // 수정 시간
+ private String htmlUrl; // 댓글 URL
+ private String authorAssociation; // "OWNER", "MEMBER", "CONTRIBUTOR", "NONE" 등
+ }
+
+
+}
diff --git a/src/main/java/com/example/Veco/domain/external/entity/GitHubIssue.java b/src/main/java/com/example/Veco/domain/github/entity/GitHubIssue.java
similarity index 97%
rename from src/main/java/com/example/Veco/domain/external/entity/GitHubIssue.java
rename to src/main/java/com/example/Veco/domain/github/entity/GitHubIssue.java
index ac83162d..3be0bf92 100644
--- a/src/main/java/com/example/Veco/domain/external/entity/GitHubIssue.java
+++ b/src/main/java/com/example/Veco/domain/github/entity/GitHubIssue.java
@@ -1,4 +1,4 @@
-package com.example.Veco.domain.external.entity;
+package com.example.Veco.domain.github.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
diff --git a/src/main/java/com/example/Veco/domain/external/entity/GitHubRepository.java b/src/main/java/com/example/Veco/domain/github/entity/GitHubRepository.java
similarity index 87%
rename from src/main/java/com/example/Veco/domain/external/entity/GitHubRepository.java
rename to src/main/java/com/example/Veco/domain/github/entity/GitHubRepository.java
index 430ba3c0..26402218 100644
--- a/src/main/java/com/example/Veco/domain/external/entity/GitHubRepository.java
+++ b/src/main/java/com/example/Veco/domain/github/entity/GitHubRepository.java
@@ -1,4 +1,4 @@
-package com.example.Veco.domain.external.entity;
+package com.example.Veco.domain.github.entity;
import jakarta.persistence.*;
import lombok.*;
diff --git a/src/main/java/com/example/Veco/domain/external/exception/GitHubException.java b/src/main/java/com/example/Veco/domain/github/exception/GitHubException.java
similarity index 84%
rename from src/main/java/com/example/Veco/domain/external/exception/GitHubException.java
rename to src/main/java/com/example/Veco/domain/github/exception/GitHubException.java
index a8f84387..8c9cb76f 100644
--- a/src/main/java/com/example/Veco/domain/external/exception/GitHubException.java
+++ b/src/main/java/com/example/Veco/domain/github/exception/GitHubException.java
@@ -1,4 +1,4 @@
-package com.example.Veco.domain.external.exception;
+package com.example.Veco.domain.github.exception;
import com.example.Veco.global.apiPayload.code.BaseErrorStatus;
import com.example.Veco.global.apiPayload.exception.VecoException;
diff --git a/src/main/java/com/example/Veco/domain/external/exception/code/GitHubErrorCode.java b/src/main/java/com/example/Veco/domain/github/exception/code/GitHubErrorCode.java
similarity index 91%
rename from src/main/java/com/example/Veco/domain/external/exception/code/GitHubErrorCode.java
rename to src/main/java/com/example/Veco/domain/github/exception/code/GitHubErrorCode.java
index d77688b7..1264c76a 100644
--- a/src/main/java/com/example/Veco/domain/external/exception/code/GitHubErrorCode.java
+++ b/src/main/java/com/example/Veco/domain/github/exception/code/GitHubErrorCode.java
@@ -1,4 +1,4 @@
-package com.example.Veco.domain.external.exception.code;
+package com.example.Veco.domain.github.exception.code;
import com.example.Veco.global.apiPayload.ErrorReasonDTO;
import com.example.Veco.global.apiPayload.code.BaseErrorStatus;
diff --git a/src/main/java/com/example/Veco/domain/external/exception/code/GitHubSuccessCode.java b/src/main/java/com/example/Veco/domain/github/exception/code/GitHubSuccessCode.java
similarity index 93%
rename from src/main/java/com/example/Veco/domain/external/exception/code/GitHubSuccessCode.java
rename to src/main/java/com/example/Veco/domain/github/exception/code/GitHubSuccessCode.java
index 63d9fdc7..a8210fe8 100644
--- a/src/main/java/com/example/Veco/domain/external/exception/code/GitHubSuccessCode.java
+++ b/src/main/java/com/example/Veco/domain/github/exception/code/GitHubSuccessCode.java
@@ -1,4 +1,4 @@
-package com.example.Veco.domain.external.exception.code;
+package com.example.Veco.domain.github.exception.code;
import com.example.Veco.global.apiPayload.code.BaseSuccessStatus;
import com.example.Veco.global.apiPayload.dto.SuccessReasonDTO;
diff --git a/src/main/java/com/example/Veco/domain/external/repository/GitHubIssueRepository.java b/src/main/java/com/example/Veco/domain/github/repository/GitHubIssueRepository.java
similarity index 56%
rename from src/main/java/com/example/Veco/domain/external/repository/GitHubIssueRepository.java
rename to src/main/java/com/example/Veco/domain/github/repository/GitHubIssueRepository.java
index 7bbfe70c..507ed2fe 100644
--- a/src/main/java/com/example/Veco/domain/external/repository/GitHubIssueRepository.java
+++ b/src/main/java/com/example/Veco/domain/github/repository/GitHubIssueRepository.java
@@ -1,6 +1,6 @@
-package com.example.Veco.domain.external.repository;
+package com.example.Veco.domain.github.repository;
-import com.example.Veco.domain.external.entity.GitHubIssue;
+import com.example.Veco.domain.github.entity.GitHubIssue;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GitHubIssueRepository extends JpaRepository {
diff --git a/src/main/java/com/example/Veco/domain/github/service/GitHubCommentService.java b/src/main/java/com/example/Veco/domain/github/service/GitHubCommentService.java
new file mode 100644
index 00000000..8b923497
--- /dev/null
+++ b/src/main/java/com/example/Veco/domain/github/service/GitHubCommentService.java
@@ -0,0 +1,13 @@
+package com.example.Veco.domain.github.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+@Slf4j
+public class GitHubCommentService {
+}
diff --git a/src/main/java/com/example/Veco/domain/github/service/GitHubIssueCommentService.java b/src/main/java/com/example/Veco/domain/github/service/GitHubIssueCommentService.java
new file mode 100644
index 00000000..b81745d6
--- /dev/null
+++ b/src/main/java/com/example/Veco/domain/github/service/GitHubIssueCommentService.java
@@ -0,0 +1,14 @@
+package com.example.Veco.domain.github.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+@Slf4j
+public class GitHubIssueCommentService { // TODO : 이슈 댓글 정보를 가져오는 서비스 구현 필요
+
+}
diff --git a/src/main/java/com/example/Veco/domain/external/service/GitHubIssueService.java b/src/main/java/com/example/Veco/domain/github/service/GitHubIssueService.java
similarity index 64%
rename from src/main/java/com/example/Veco/domain/external/service/GitHubIssueService.java
rename to src/main/java/com/example/Veco/domain/github/service/GitHubIssueService.java
index 4fa1eda5..704a0c01 100644
--- a/src/main/java/com/example/Veco/domain/external/service/GitHubIssueService.java
+++ b/src/main/java/com/example/Veco/domain/github/service/GitHubIssueService.java
@@ -1,25 +1,25 @@
-package com.example.Veco.domain.external.service;
+package com.example.Veco.domain.github.service;
import com.example.Veco.domain.external.converter.ExternalConverter;
-import com.example.Veco.domain.external.converter.GitHubConverter;
-import com.example.Veco.domain.external.dto.GitHubWebhookPayload;
+import com.example.Veco.domain.github.converter.GitHubConverter;
+import com.example.Veco.domain.github.dto.webhook.GitHubWebhookPayload;
import com.example.Veco.domain.external.dto.request.ExternalRequestDTO;
-import com.example.Veco.domain.external.dto.response.GitHubApiResponseDTO;
+import com.example.Veco.domain.github.dto.response.GitHubApiResponseDTO;
import com.example.Veco.domain.external.entity.External;
-import com.example.Veco.domain.external.entity.GitHubIssue;
+import com.example.Veco.domain.github.entity.GitHubIssue;
import com.example.Veco.domain.external.exception.ExternalException;
-import com.example.Veco.domain.external.exception.GitHubException;
+import com.example.Veco.domain.github.exception.GitHubException;
import com.example.Veco.domain.external.exception.code.ExternalErrorCode;
-import com.example.Veco.domain.external.exception.code.GitHubErrorCode;
+import com.example.Veco.domain.github.exception.code.GitHubErrorCode;
import com.example.Veco.domain.external.repository.ExternalRepository;
-import com.example.Veco.domain.external.repository.GitHubIssueRepository;
+import com.example.Veco.domain.github.repository.GitHubIssueRepository;
import com.example.Veco.domain.mapping.GithubInstallation;
import com.example.Veco.domain.mapping.repository.GitHubInstallationRepository;
import com.example.Veco.domain.team.dto.NumberSequenceResponseDTO;
import com.example.Veco.domain.team.entity.Team;
-import com.example.Veco.domain.team.enums.Category;
-import com.example.Veco.domain.team.repository.TeamRepository;
import com.example.Veco.domain.team.service.NumberSequenceService;
+import com.example.Veco.global.enums.Category;
+import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatusCode;
@@ -41,6 +41,7 @@ public class GitHubIssueService {
private final ExternalRepository externalRepository;
private final NumberSequenceService numberSequenceService;
private final GitHubInstallationRepository gitHubInstallationRepository;
+ private ObjectMapper objectMapper = new ObjectMapper();
private final GitHubTokenService gitHubTokenService;
private final WebClient webClient = WebClient.builder()
.baseUrl("https://api.github.com")
@@ -50,30 +51,37 @@ public class GitHubIssueService {
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024)) // 1MB
.build();
- public void processIssueWebhook(GitHubWebhookPayload payload) {
-
- log.info("payload.getIssue: {}", payload.getIssue());
- log.info("paload.getRepository: {}", payload.getRepository());
- log.info("paload.getAction: {}", payload.getAction());
- log.info("paload.getSender: {}", payload.getSender());
- log.info("payload.getInstallation: {}", payload.getInstallation());
-
- String action = payload.getAction();
-
- GitHubWebhookPayload.Issue issue = payload.getIssue();
-
- switch (action) {
- case "opened":
- log.info("Issue {} opened", issue.getNumber());
- createIssue(payload);
- break;
- case "closed":
- log.info("Issue {} closed", issue.getNumber());
- closeIssue(payload);
- break;
- case "edited":
- log.info("Issue {} edited", issue.getNumber());
- updateIssue(payload);
+ public void processIssueWebhook(String payload) {
+
+
+ try{
+ GitHubWebhookPayload issuePayload = objectMapper.readValue(payload, GitHubWebhookPayload.class);
+
+ log.info("payload.getIssue: {}", issuePayload.getIssue());
+ log.info("paload.getRepository: {}", issuePayload.getRepository());
+ log.info("paload.getAction: {}", issuePayload.getAction());
+ log.info("paload.getSender: {}", issuePayload.getSender());
+ log.info("payload.getInstallation: {}", issuePayload.getInstallation());
+
+ String action = issuePayload.getAction();
+
+ GitHubWebhookPayload.Issue issue = issuePayload.getIssue();
+
+ switch (action) {
+ case "opened":
+ log.info("Issue {} opened", issue.getNumber());
+ createIssue(issuePayload);
+ break;
+ case "closed":
+ log.info("Issue {} closed", issue.getNumber());
+ closeIssue(issuePayload);
+ break;
+ case "edited":
+ log.info("Issue {} edited", issue.getNumber());
+ updateIssue(issuePayload);
+ }
+ }catch (Exception e){
+ log.error(e.getMessage(), e);
}
}
@@ -132,32 +140,29 @@ private void createIssue(GitHubWebhookPayload payload) {
createVecoExternal(payload);
- GitHubIssue issue = GitHubIssue.builder()
- .githubIssueId(issueData.getId())
- .nodeId(issueData.getNodeId())
- .number(issueData.getNumber())
- .title(issueData.getTitle())
- .body(issueData.getBody())
- .state(GitHubIssue.IssueState.valueOf(issueData.getState().toUpperCase()))
- .repositoryFullName(repoData.getFullName())
- .repositoryUrl(repoData.getUrl())
- .htmlUrl(issueData.getHtmlUrl())
- .creatorLogin(issueData.getUser().getLogin())
- .creatorId(issueData.getUser().getId())
- .creatorAvatarUrl(issueData.getUser().getAvatarUrl())
- .assigneeLogin(issueData.getAssignee() != null ? issueData.getAssignee().getLogin() : null)
- .assigneeId(issueData.getAssignee() != null ? issueData.getAssignee().getId() : null)
- .labels(extractLabelNames(issueData.getLabels()))
- .githubCreatedAt(issueData.getCreatedAt())
- .githubUpdatedAt(issueData.getUpdatedAt())
- .githubClosedAt(issueData.getClosedAt())
- .commentsCount(issueData.getComments())
- .locked(issueData.getLocked())
- .lockReason(issueData.getActiveLockReason())
- .build();
-
- gitHubIssueRepository.save(issue);
- log.info("Created new issue: #{} - {}", issue.getNumber(), issue.getTitle());
+// GitHubIssue issue = GitHubIssue.builder()
+// .githubIssueId(issueData.getId())
+// .nodeId(issueData.getNodeId())
+// .number(issueData.getNumber())
+// .title(issueData.getTitle())
+// .body(issueData.getBody())
+// .state(GitHubIssue.IssueState.valueOf(issueData.getState().toUpperCase()))
+// .repositoryFullName(repoData.getFullName())
+// .repositoryUrl(repoData.getUrl())
+// .htmlUrl(issueData.getHtmlUrl())
+// .creatorLogin(issueData.getUser().getLogin())
+// .creatorId(issueData.getUser().getId())
+// .creatorAvatarUrl(issueData.getUser().getAvatarUrl())
+// .assigneeLogin(issueData.getAssignee() != null ? issueData.getAssignee().getLogin() : null)
+// .assigneeId(issueData.getAssignee() != null ? issueData.getAssignee().getId() : null)
+// .labels(extractLabelNames(issueData.getLabels()))
+// .commentsCount(issueData.getComments())
+// .locked(issueData.getLocked())
+// .lockReason(issueData.getActiveLockReason())
+// .build();
+//
+// gitHubIssueRepository.save(issue);
+// log.info("Created new issue: #{} - {}", issue.getNumber(), issue.getTitle());
}
private void createVecoExternal(GitHubWebhookPayload payload) {
diff --git a/src/main/java/com/example/Veco/domain/github/service/GitHubPullRequestService.java b/src/main/java/com/example/Veco/domain/github/service/GitHubPullRequestService.java
new file mode 100644
index 00000000..748ce877
--- /dev/null
+++ b/src/main/java/com/example/Veco/domain/github/service/GitHubPullRequestService.java
@@ -0,0 +1,105 @@
+package com.example.Veco.domain.github.service;
+
+import com.example.Veco.domain.external.converter.ExternalConverter;
+import com.example.Veco.domain.github.dto.webhook.GitHubPullRequestPayload;
+import com.example.Veco.domain.external.entity.External;
+import com.example.Veco.domain.external.exception.ExternalException;
+import com.example.Veco.domain.github.exception.GitHubException;
+import com.example.Veco.domain.external.exception.code.ExternalErrorCode;
+import com.example.Veco.domain.github.exception.code.GitHubErrorCode;
+import com.example.Veco.domain.external.repository.ExternalRepository;
+import com.example.Veco.domain.mapping.GithubInstallation;
+import com.example.Veco.domain.mapping.repository.GitHubInstallationRepository;
+import com.example.Veco.domain.team.dto.NumberSequenceResponseDTO;
+import com.example.Veco.domain.team.entity.Team;
+import com.example.Veco.domain.team.service.NumberSequenceService;
+import com.example.Veco.global.enums.Category;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+@Transactional
+public class GitHubPullRequestService {
+
+ private ObjectMapper objectMapper = new ObjectMapper();
+ private final ExternalRepository externalRepository;
+ private final GitHubInstallationRepository gitHubInstallationRepository;
+ private final NumberSequenceService numberSequenceService;
+
+ public void handlePullRequestEvent(String payload) {
+
+ try{
+
+ GitHubPullRequestPayload prPayload = objectMapper.readValue(payload, GitHubPullRequestPayload.class);
+
+ switch (prPayload.getAction()) {
+ case "opened":
+ handlePullRequestOpened(prPayload);
+ break;
+ case "closed":
+ closeIssue(prPayload);
+ break;
+ case "edited":
+ updateIssue(prPayload);
+ break;
+ default:
+ log.debug("Unhandled PR action: {}", prPayload.getAction());
+ }
+
+ }catch (Exception e){
+
+ }
+ }
+
+ private void handlePullRequestOpened(GitHubPullRequestPayload payload) {
+
+ log.info(payload.toString());
+
+ Long installationId = payload.getInstallation().getId();
+
+ GithubInstallation installInfo = gitHubInstallationRepository.findByInstallationId(installationId)
+ .orElseThrow(() -> new GitHubException(GitHubErrorCode.INSTALLATION_INFO_NOT_FOUND));
+
+ Team team = installInfo.getTeam();
+
+ NumberSequenceResponseDTO code =
+ numberSequenceService.allocateNextNumber(team.getWorkSpace().getName(),
+ team.getId(),
+ Category.EXTERNAL);
+
+ log.info("다음 외부이슈 코드 : " + code.getNextCode());
+
+ External external = ExternalConverter.byGitHubPullRequest(payload, installInfo.getTeam(), code.getNextCode());
+
+ log.info("외부 이슈 제목 : {} 내용 : {}", external.getTitle(), external.getDescription());
+
+ externalRepository.save(external);
+
+ log.info("외부 이슈 id : {} ", external.getId());
+
+ }
+
+ private void closeIssue(GitHubPullRequestPayload payload) {
+ External external = externalRepository.findByGithubDataId(payload.getPullRequest().getId())
+ .orElseThrow(() -> new ExternalException(ExternalErrorCode.NOT_FOUND));
+
+ external.closeIssue();
+ externalRepository.save(external);
+ }
+
+ private void updateIssue(GitHubPullRequestPayload payload) {
+ GitHubPullRequestPayload.PullRequest pullRequest = payload.getPullRequest();
+
+ External external = externalRepository.findByGithubDataId(pullRequest.getId())
+ .orElseThrow(() -> new ExternalException(ExternalErrorCode.NOT_FOUND));
+
+ external.updateByPullRequest(pullRequest);
+
+ }
+
+}
diff --git a/src/main/java/com/example/Veco/domain/external/service/GitHubRepositoryService.java b/src/main/java/com/example/Veco/domain/github/service/GitHubRepositoryService.java
similarity index 92%
rename from src/main/java/com/example/Veco/domain/external/service/GitHubRepositoryService.java
rename to src/main/java/com/example/Veco/domain/github/service/GitHubRepositoryService.java
index 502dc4a1..9abf2db4 100644
--- a/src/main/java/com/example/Veco/domain/external/service/GitHubRepositoryService.java
+++ b/src/main/java/com/example/Veco/domain/github/service/GitHubRepositoryService.java
@@ -1,7 +1,6 @@
-package com.example.Veco.domain.external.service;
+package com.example.Veco.domain.github.service;
-import com.example.Veco.domain.external.dto.response.GitHubApiResponseDTO;
-import com.example.Veco.domain.external.util.GitHubJwtProvider;
+import com.example.Veco.domain.github.dto.response.GitHubApiResponseDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
diff --git a/src/main/java/com/example/Veco/domain/external/service/GitHubService.java b/src/main/java/com/example/Veco/domain/github/service/GitHubService.java
similarity index 63%
rename from src/main/java/com/example/Veco/domain/external/service/GitHubService.java
rename to src/main/java/com/example/Veco/domain/github/service/GitHubService.java
index 68a47e70..11775d68 100644
--- a/src/main/java/com/example/Veco/domain/external/service/GitHubService.java
+++ b/src/main/java/com/example/Veco/domain/github/service/GitHubService.java
@@ -1,15 +1,15 @@
-package com.example.Veco.domain.external.service;
+package com.example.Veco.domain.github.service;
-import com.example.Veco.domain.external.converter.GitHubConverter;
-import com.example.Veco.domain.external.dto.response.GitHubResponseDTO;
+import com.example.Veco.domain.github.converter.GitHubConverter;
+import com.example.Veco.domain.github.dto.response.GitHubResponseDTO;
+import com.example.Veco.domain.github.exception.GitHubException;
+import com.example.Veco.domain.github.exception.code.GitHubErrorCode;
import com.example.Veco.domain.mapping.GithubInstallation;
import com.example.Veco.domain.mapping.repository.GitHubInstallationRepository;
-import com.example.Veco.domain.member.entity.Member;
import com.example.Veco.domain.team.entity.Team;
import com.example.Veco.domain.team.exception.TeamException;
import com.example.Veco.domain.team.exception.code.TeamErrorCode;
import com.example.Veco.domain.team.repository.TeamRepository;
-import com.example.Veco.global.apiPayload.exception.VecoException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -24,7 +24,7 @@ public class GitHubService {
private final GitHubInstallationRepository gitHubInstallationRepository;
private final TeamRepository teamRepository;
- public GitHubResponseDTO.GitHubAppInstallationDTO saveInstallationInfo(Long teamId, Long installationId) {
+ public void saveInstallationInfo(Long teamId, Long installationId) {
Team team = teamRepository.findById(teamId)
.orElseThrow(() -> new TeamException(TeamErrorCode._NOT_FOUND));
@@ -35,12 +35,19 @@ public GitHubResponseDTO.GitHubAppInstallationDTO saveInstallationInfo(Long team
.installationId(installationId)
.build();
- GithubInstallation savedInstallationInfo = gitHubInstallationRepository.save(info);
+ gitHubInstallationRepository.save(info);
- return GitHubConverter.toGitHubAppInstallationDTO(savedInstallationInfo);
}
- public Long getInstallationId(Long teamId) {
+ public GitHubResponseDTO.GitHubAppInstallationDTO getInstallationInfo(Long teamId) {
+
+ GithubInstallation githubInstallation = gitHubInstallationRepository.findById(teamId)
+ .orElseThrow(() -> new GitHubException(GitHubErrorCode.INSTALLATION_INFO_NOT_FOUND));
+
+ return GitHubConverter.toGitHubAppInstallationDTO(githubInstallation);
+ }
+
+ private Long getInstallationId(Long teamId) {
return gitHubInstallationRepository.findByTeamId(teamId)
.orElseThrow().getInstallationId();
}
diff --git a/src/main/java/com/example/Veco/domain/external/service/GitHubTokenService.java b/src/main/java/com/example/Veco/domain/github/service/GitHubTokenService.java
similarity index 97%
rename from src/main/java/com/example/Veco/domain/external/service/GitHubTokenService.java
rename to src/main/java/com/example/Veco/domain/github/service/GitHubTokenService.java
index 71ef7d5e..a339713c 100644
--- a/src/main/java/com/example/Veco/domain/external/service/GitHubTokenService.java
+++ b/src/main/java/com/example/Veco/domain/github/service/GitHubTokenService.java
@@ -1,6 +1,6 @@
-package com.example.Veco.domain.external.service;
+package com.example.Veco.domain.github.service;
-import com.example.Veco.domain.external.dto.response.GitHubTokenResponse;
+import com.example.Veco.domain.github.dto.response.GitHubTokenResponse;
import com.example.Veco.domain.external.util.GitHubJwtProvider;
import lombok.AllArgsConstructor;
import lombok.Data;
@@ -18,6 +18,7 @@
@RequiredArgsConstructor
@Slf4j
public class GitHubTokenService {
+
private final GitHubJwtProvider jwtProvider;
WebClient webClient = WebClient.builder()
diff --git a/src/main/java/com/example/Veco/domain/goal/controller/GoalController.java b/src/main/java/com/example/Veco/domain/goal/controller/GoalController.java
index 72108197..9dbe7681 100644
--- a/src/main/java/com/example/Veco/domain/goal/controller/GoalController.java
+++ b/src/main/java/com/example/Veco/domain/goal/controller/GoalController.java
@@ -2,17 +2,21 @@
import com.example.Veco.domain.goal.dto.request.GoalReqDTO;
import com.example.Veco.domain.goal.dto.response.GoalResDTO.*;
+import com.example.Veco.domain.goal.exception.code.GoalErrorCode;
import com.example.Veco.domain.goal.exception.code.GoalSuccessCode;
import com.example.Veco.domain.goal.service.command.GoalCommandService;
import com.example.Veco.domain.goal.service.query.GoalQueryService;
import com.example.Veco.global.apiPayload.ApiResponse;
+import com.example.Veco.global.apiPayload.code.BaseErrorStatus;
import com.example.Veco.global.auth.user.AuthUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -22,6 +26,7 @@
@RequiredArgsConstructor
@RequestMapping("/api")
@Tag(name = "목표 API")
+@Validated
public class GoalController {
// 리포지토리
@@ -34,7 +39,8 @@ public class GoalController {
summary = "팀 내 모든 목표 조회 API By 김주헌",
description = "팀의 모든 목표를 조회합니다. 쿼리를 이용해서 필터 적용이 가능합니다." +
" 디폴트로 상태(진행 중, 진행 완료)를 기준으로 조회합니다." +
- "커서 기반 페이지네이션, 최신 순으로 정렬합니다."
+ "커서 기반 페이지네이션, 최신 순으로 정렬합니다." +
+ "query는 state, priority, manager 가능합니다."
)
@GetMapping("/teams/{teamId}/goals")
public ApiResponse>> getTeamGoals(
@@ -51,7 +57,12 @@ public ApiResponse>> getTeamGoals(
if (result != null){
return ApiResponse.onSuccess(GoalSuccessCode.OK, result);
} else {
- return ApiResponse.onSuccess(GoalSuccessCode.NO_CONTENT, null);
+ BaseErrorStatus status = GoalErrorCode.NOT_FOUND_IN_TEAM;
+ return ApiResponse.onFailure(
+ status.getReasonHttpStatus().getCode(),
+ status.getReasonHttpStatus().getMessage(),
+ null
+ );
}
}
@@ -72,7 +83,7 @@ public ApiResponse> getSimpleGoal(
// 목표 상세 조회
@Operation(
summary = "목표 상세 조회 API By 김주헌",
- description = "목표 상세 정보를 조회합니다. 댓글 데이터는 최신순으로 정렬되어 있습니다."
+ description = "목표 상세 정보를 조회합니다. 댓글 데이터는 오래된순으로 정렬되어 있습니다."
)
@GetMapping("/goals/{goalId}")
public ApiResponse getGoalDetail(
@@ -131,12 +142,14 @@ public ApiResponse> getDeletedGoals(
// 목표 작성: 변경 가능성 O
@Operation(
summary = "목표 작성 API By 김주헌",
- description = "목표를 작성합니다."
+ description = "목표를 작성합니다." +
+ "기한은 YYYY-MM-DD 형식으로 보내주세요. start 또는 end 없이 요청하면" +
+ "기한이 null로 표기됩니다."
)
@PostMapping("/teams/{teamId}/goals")
public ApiResponse createGoal(
@PathVariable Long teamId,
- @RequestBody GoalReqDTO.CreateGoal dto,
+ @RequestBody @Valid GoalReqDTO.CreateGoal dto,
@AuthenticationPrincipal AuthUser user
){
return ApiResponse.onSuccess(GoalSuccessCode.CREATE, goalCommandService.createGoal(teamId, dto, user));
@@ -172,7 +185,8 @@ public ApiResponse> restoreGoals(
summary = "목표 수정 API By 김주헌",
description = "목표를 수정합니다. " +
"수정할 내용을 추가하면 됩니다. 담당자, 이슈를 수정할 경우 수정된 리스트를 업로드하시면 됩니다. " +
- "변경 사항이 없는 속성은 RequestBody에서 제거 후 요청하면 됩니다."
+ "변경 사항이 없는 속성은 RequestBody에서 제거 후 요청하면 됩니다. " +
+ "기한은 YYYY-MM-DD 형식으로, null으로 전송시 해당 기한이 삭제(null)처리 됩니다."
)
@PatchMapping("/teams/{teamId}/goals/{goalId}")
public ApiResponse updateGoal(
diff --git a/src/main/java/com/example/Veco/domain/goal/converter/GoalConverter.java b/src/main/java/com/example/Veco/domain/goal/converter/GoalConverter.java
index 3445c615..49555ff6 100644
--- a/src/main/java/com/example/Veco/domain/goal/converter/GoalConverter.java
+++ b/src/main/java/com/example/Veco/domain/goal/converter/GoalConverter.java
@@ -5,11 +5,15 @@
import com.example.Veco.domain.goal.dto.request.GoalReqDTO;
import com.example.Veco.domain.goal.dto.response.GoalResDTO;
import com.example.Veco.domain.goal.entity.Goal;
+import com.example.Veco.domain.goal.exception.GoalException;
+import com.example.Veco.domain.goal.exception.code.GoalErrorCode;
import com.example.Veco.domain.issue.entity.Issue;
import com.example.Veco.domain.mapping.entity.MemberTeam;
import com.example.Veco.domain.team.entity.Team;
+import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.time.format.DateTimeParseException;
import java.util.List;
public class GoalConverter {
@@ -20,12 +24,25 @@ public static Goal toGoal (
Team team,
String name
){
+ LocalDate start = null;
+ LocalDate end = null;
+ try {
+ if (dto.deadline().start() != null) {
+ start = LocalDate.parse(dto.deadline().start());
+ }
+ if (dto.deadline().end() != null) {
+ end = LocalDate.parse(dto.deadline().end());
+ }
+ } catch (DateTimeParseException e) {
+ throw new GoalException(GoalErrorCode.DEADLINE_INVALID);
+ }
+
return Goal.builder()
.state(dto.state())
.content(dto.content())
.title(dto.title())
- .deadlineStart(dto.deadline().start())
- .deadlineEnd(dto.deadline().end())
+ .deadlineStart(start)
+ .deadlineEnd(end)
.priority(dto.priority())
.team(team)
.name(name)
@@ -55,10 +72,12 @@ public static GoalResDTO.FullGoal toFullGoal (
List comment
){
return GoalResDTO.FullGoal.builder()
+ .id(goal.getId())
.name(goal.getName())
.title(goal.getTitle())
.content(goal.getContent())
- .priority(goal.getPriority().toString())
+ .state(goal.getState().name())
+ .priority(goal.getPriority().name())
.managers(toData(assignee))
.deadline(toDeadline(goal))
.issues(toData(issue))
@@ -156,6 +175,7 @@ public static GoalResDTO.CommentInfo toCommentInfo (
Comment comment
){
return GoalResDTO.CommentInfo.builder()
+ .id(comment.getId())
.profileUrl(comment.getMember().getProfile().getProfileImageUrl())
.nickname(comment.getMember().getNickname())
.createdAt(comment.getCreatedAt())
diff --git a/src/main/java/com/example/Veco/domain/goal/dto/request/GoalReqDTO.java b/src/main/java/com/example/Veco/domain/goal/dto/request/GoalReqDTO.java
index 9218095c..1b51bf65 100644
--- a/src/main/java/com/example/Veco/domain/goal/dto/request/GoalReqDTO.java
+++ b/src/main/java/com/example/Veco/domain/goal/dto/request/GoalReqDTO.java
@@ -2,22 +2,24 @@
import com.example.Veco.global.enums.Priority;
import com.example.Veco.global.enums.State;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
-import java.time.LocalDate;
import java.util.List;
public class GoalReqDTO {
// 목표 작성
public record CreateGoal (
+ @Size(max = 20, message = "최대 20자까지 작성할 수 있습니다.")
+ @NotBlank(message = "제목은 반드시 작성해야 합니다.")
String title,
String content,
State state,
Priority priority,
List managersId,
- Boolean isIncludeMe,
Deadline deadline,
- List issueId
+ List issuesId
){}
// 목표 수정
@@ -39,7 +41,7 @@ public record DeleteGoal(
// 세부 속성들
// 기한
public record Deadline (
- LocalDate start,
- LocalDate end
+ String start,
+ String end
){}
}
diff --git a/src/main/java/com/example/Veco/domain/goal/dto/response/GoalResDTO.java b/src/main/java/com/example/Veco/domain/goal/dto/response/GoalResDTO.java
index 264c9ece..e195e20f 100644
--- a/src/main/java/com/example/Veco/domain/goal/dto/response/GoalResDTO.java
+++ b/src/main/java/com/example/Veco/domain/goal/dto/response/GoalResDTO.java
@@ -42,9 +42,11 @@ public record SimpleGoal (
// 자세한 조회: 목표 상세 조회
@Builder
public record FullGoal (
+ Long id,
String name,
String title,
String content,
+ String state,
String priority,
Data managers,
Deadline deadline,
@@ -111,6 +113,7 @@ public record IssueInfo (
// 댓글 정보
@Builder
public record CommentInfo (
+ Long id,
String profileUrl,
String nickname,
LocalDateTime createdAt,
diff --git a/src/main/java/com/example/Veco/domain/goal/exception/code/GoalErrorCode.java b/src/main/java/com/example/Veco/domain/goal/exception/code/GoalErrorCode.java
index 163db59b..ac570b93 100644
--- a/src/main/java/com/example/Veco/domain/goal/exception/code/GoalErrorCode.java
+++ b/src/main/java/com/example/Veco/domain/goal/exception/code/GoalErrorCode.java
@@ -10,6 +10,18 @@
@Getter
public enum GoalErrorCode implements BaseErrorStatus {
+ CURSOR_INVALID(HttpStatus.BAD_REQUEST,
+ "GOAL400_0",
+ "커서값이 잘못되었습니다."),
+ QUERY_INVALID(HttpStatus.BAD_REQUEST,
+ "GOAL400_1",
+ "query값이 잘못되었습니다."),
+ NOT_A_DELETED(HttpStatus.BAD_REQUEST,
+ "GOAL400_2",
+ "복원할 목표가 없습니다."),
+ DEADLINE_INVALID(HttpStatus.BAD_REQUEST,
+ "GOAL400_3",
+ "기한의 형식이 잘못되었습니다."),
FORBIDDEN(HttpStatus.FORBIDDEN,
"GOAL403_0",
"권한이 없습니다."),
@@ -19,18 +31,9 @@ public enum GoalErrorCode implements BaseErrorStatus {
NOT_FOUND(HttpStatus.NOT_FOUND,
"GOAL404_1",
"해당 목표가 존재하지 않습니다."),
- CURSOR_INVALID(HttpStatus.BAD_REQUEST,
- "GOAL400_0",
- "커서값이 잘못되었습니다."),
- QUERY_INVALID(HttpStatus.BAD_REQUEST,
- "GOAL400_1",
- "query값이 잘못되었습니다."),
NOT_FOUND_DELETE_GOALS(HttpStatus.NOT_FOUND,
"GOAL404_2",
"해당 팀에 삭제된 목표가 존재하지 않습니다."),
- NOT_A_DELETED(HttpStatus.BAD_REQUEST,
- "GOAL400_2",
- "복원할 목표가 없습니다.")
;
private final HttpStatus httpStatus;
diff --git a/src/main/java/com/example/Veco/domain/goal/service/command/GoalCommandService.java b/src/main/java/com/example/Veco/domain/goal/service/command/GoalCommandService.java
index 7046b40b..457c3a63 100644
--- a/src/main/java/com/example/Veco/domain/goal/service/command/GoalCommandService.java
+++ b/src/main/java/com/example/Veco/domain/goal/service/command/GoalCommandService.java
@@ -35,7 +35,9 @@
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
+import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -67,18 +69,21 @@ public CreateGoal createGoal(
GoalReqDTO.CreateGoal dto,
AuthUser user
) {
- // 담당자 존재 여부, 같은 팀 여부 검증 + 본인 포함 여부 확인 후 업데이트
- List memberIds = new ArrayList<>(dto.managersId());
- if (dto.isIncludeMe()) {
- // 인증 객체에서 가져오기
- Member member = memberRepository.findBySocialUid(user.getSocialUid()).orElseThrow(() ->
- new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND));
- memberIds.add(member.getId());
+
+ // 작성자가 같은 팀인지 검증
+ Member author = memberRepository.findBySocialUid(user.getSocialUid())
+ .orElseThrow(() -> new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND));
+ if (!memberTeamRepository.existsByMemberIdAndTeamId(author.getId(), teamId)) {
+ throw new GoalException(GoalErrorCode.FORBIDDEN);
}
- // 사용자 존재 여부 검증
- List memberList = memberRepository.findAllById(memberIds);
- if (memberList.size() != memberIds.size()) {
+ // 사용자 존재 여부 검증: 한명이라도 없으면 생성 X
+ List memberIds = memberRepository.findAllById(dto.managersId())
+ .stream()
+ .map(Member::getId)
+ .toList();
+
+ if (memberIds.size() != dto.managersId().size()) {
throw new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND);
}
@@ -88,18 +93,30 @@ public CreateGoal createGoal(
throw new TeamException(TeamErrorCode._NOT_FOUND);
}
- // 같은 팀원 여부 검증
+ // 같은 팀원 여부 검증: 한명이라도 같은 팀이 아니면 X
List memberTeamList = memberTeamRepository.findAllByMemberIdInAndTeamId(memberIds, teamId);
if (memberTeamList.size() != memberIds.size()) {
throw new MemberHandler(MemberErrorStatus._FORBIDDEN);
}
- // 이슈 존재 여부 검증
- List issueList = issueRepository.findAllById(dto.issueId());
- if (issueList.size() != dto.issueId().size()) {
+ // 이슈 존재 여부 검증: 하나라도 없으면 X
+ List issueList = issueRepository.findAllById(dto.issuesId());
+ if (issueList.size() != dto.issuesId().size()) {
throw new IssueException(IssueErrorCode.NOT_FOUND);
}
+ // 기한 입력값이 정확한지 검증
+ try {
+ if (dto.deadline().start() != null){
+ LocalDate.parse(dto.deadline().start());
+ }
+ if (dto.deadline().end() != null){
+ LocalDate.parse(dto.deadline().end());
+ }
+ } catch (DateTimeParseException e) {
+ throw new GoalException(GoalErrorCode.DEADLINE_INVALID);
+ }
+
// 목표 생성: DTO, Team, Name 필요, @Transactional
// 목표 이름(Veco-g3) 생성 로직: 분산 락 걸고 이름 조회, +1
RLock lock = redissonClient.getLock("lock:goal:" + teamId);
diff --git a/src/main/java/com/example/Veco/domain/goal/service/command/GoalTransactionalService.java b/src/main/java/com/example/Veco/domain/goal/service/command/GoalTransactionalService.java
index d69bedbd..7cc8b6c0 100644
--- a/src/main/java/com/example/Veco/domain/goal/service/command/GoalTransactionalService.java
+++ b/src/main/java/com/example/Veco/domain/goal/service/command/GoalTransactionalService.java
@@ -24,6 +24,8 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
@@ -54,7 +56,7 @@ protected Long createGoal(
String name = team.getWorkSpace().getName()+"-g"+team.getGoalNumber();
Goal goal = goalRepository.save(GoalConverter.toGoal(dto,team,name));
- // 목표 <-> 담당자 연결
+ // 목표 <-> 담당자 연결: 없으면 null로 저장
List assigneeList = new ArrayList<>();
memberTeamList.forEach(
value -> assigneeList.add(
@@ -65,6 +67,10 @@ protected Long createGoal(
);
assigneeRepository.saveAll(assigneeList);
+ if (assigneeList.isEmpty()){
+ assigneeRepository.save(AssigneeConverter.toAssignee(null, Category.GOAL, goal));
+ }
+
// 목표 <-> 이슈 연결
issueList.forEach(
value -> value.updateGoal(goal)
@@ -76,23 +82,6 @@ protected Long createGoal(
return goal.getId();
}
- // 목표 삭제
- @Transactional
- protected void deleteGoal(
- Long goalId
- ){
-
- // 객체 조회
- Goal goal = goalRepository.findById(goalId).orElseThrow(() ->
- new GoalException(GoalErrorCode.NOT_FOUND));
-
- // 목표 삭제
- goalRepository.delete(goal);
-
- // 담당자 삭제
- assigneeRepository.deleteAllByTypeAndTargetId(Category.GOAL, goal.getId());
- }
-
// 목표 수정
@Transactional
protected boolean updateGoal(
@@ -126,39 +115,71 @@ protected boolean updateGoal(
}
// 담당자 변경
if (dto.managersId() != null) {
+
+ // 신규 담당자 존재여부 확인, 조회
+ List memberTeamList = memberTeamRepository
+ .findAllByMemberIdInAndTeamId(dto.managersId(), teamId);
+
// 기존 담당자 삭제
assigneeRepository.deleteAllByTypeAndTargetId(Category.GOAL, goalId);
+
// 신규 담당자 추가
- List memberTeamList = memberTeamRepository
- .findAllByMemberIdInAndTeamId(dto.managersId(), teamId);
memberTeamList.forEach(
value -> assigneeRepository.save(
AssigneeConverter.toAssignee(value, Category.GOAL, goal)
)
);
+
+ // 신규 담당자가 없는 경우, null로
+ if (memberTeamList.isEmpty()){
+ assigneeRepository.save(AssigneeConverter.toAssignee(null, Category.GOAL, goal));
+ }
+
isRestore = true;
}
// 기한 변경
if (dto.deadline() != null) {
- goal.updateDeadlineStart(dto.deadline().start());
- goal.updateDeadlineEnd(dto.deadline().end());
- isRestore = true;
+
+ try {
+ if (dto.deadline().start() != null) {
+ LocalDate start;
+ if (dto.deadline().start().equals("null")){
+ start = null;
+ } else {
+ start = LocalDate.parse(dto.deadline().start());
+ }
+ goal.updateDeadlineStart(start);
+ }
+ if (dto.deadline().end() != null) {
+ LocalDate end;
+ if (dto.deadline().end().equals("null")){
+ end = null;
+ } else {
+ end = LocalDate.parse(dto.deadline().end());
+ }
+ goal.updateDeadlineEnd(end);
+ }
+ isRestore = true;
+ } catch (DateTimeParseException e) {
+ throw new GoalException(GoalErrorCode.DEADLINE_INVALID);
+ }
+
}
// 이슈 변경
if (dto.issuesId() != null){
- // 기존 이슈 조회, 목표 해제
- List oldIssueList = issueRepository.findAllByGoal(goal).orElse(new ArrayList<>());
- oldIssueList.forEach(
- value -> value.updateGoal(null)
- );
-
// 새로운 이슈 존재 여부 검증
List issueList = issueRepository.findAllById(dto.issuesId());
if (issueList.size() != dto.issuesId().size()){
throw new IssueException(IssueErrorCode.NOT_FOUND);
}
+ // 기존 이슈 조회, 목표 해제
+ List oldIssueList = issueRepository.findAllByGoal(goal).orElse(new ArrayList<>());
+ oldIssueList.forEach(
+ value -> value.updateGoal(null)
+ );
+
// 이슈 목표 변경
issueList.forEach(
value -> value.updateGoal(goal)
diff --git a/src/main/java/com/example/Veco/domain/goal/service/query/GoalQueryService.java b/src/main/java/com/example/Veco/domain/goal/service/query/GoalQueryService.java
index b5cfb978..e29ed708 100644
--- a/src/main/java/com/example/Veco/domain/goal/service/query/GoalQueryService.java
+++ b/src/main/java/com/example/Veco/domain/goal/service/query/GoalQueryService.java
@@ -33,6 +33,7 @@
import java.util.*;
import java.util.regex.PatternSyntaxException;
+import java.util.stream.Collectors;
@Service
@Slf4j
@@ -103,7 +104,7 @@ public Pageable> getGoals(
throw new GoalException(GoalErrorCode.CURSOR_INVALID);
}
} else {
- // 커서가 없는 경우, 기본값 NONE 설정, 담당자는 null
+ // 커서가 없는 경우, 기본값 NONE 설정, 담당자는 없음
switch (query.toLowerCase()) {
case "state": {
firstCursor = State.NONE.name();
@@ -113,6 +114,10 @@ public Pageable> getGoals(
firstCursor = Priority.NONE.name();
break;
}
+ case "manager": {
+ firstCursor = "없음";
+ break;
+ }
}
}
@@ -245,6 +250,7 @@ public Pageable> getGoals(
// 담당자 리스트 뽑아와서 Map 처리: 담당자 이름 : 개수
List managers = goalRepository.findGoalsAssigneeInTeam(teamId);
Map map = new HashMap<>();
+
for (String name : managers){
if (map.containsKey(name)){
map.put(name, map.get(name) + 1);
@@ -255,17 +261,30 @@ public Pageable> getGoals(
// firstCursor가 담당자 리스트에 포함되어있는지 확인
if (!managers.contains(firstCursor)){
- // 없으면 사전순 처음으로 설정
- firstCursor = managers.stream().sorted().toList().getFirst();
+ // 없으면 없음으로 설정, 담당자 없는 목표 조회
+ firstCursor = "없음";
}
- // 담당자 별 데이터 분류
- for (String filter : map.keySet().stream().sorted().toList()){
+ // 담당자 별 데이터 분류, 없음 먼저 조회
+ List filterQueue = map.keySet().stream().sorted().collect(Collectors.toList());
+
+ // 담당자가 없는 경우 조회
+ Integer notAssigned = assigneeRepository.findAllByGoal_TeamIdAndMemberTeamIsNull(teamId).size();
+ map.put("없음", notAssigned);
+
+ filterQueue.addFirst("없음");
+ for (String filter : filterQueue){
List result = new ArrayList<>();
// firstCursor가 일치할때, 그떄 조회 시작
if ((size > 0) && (filter.equals(firstCursor) || isContinue)){
- builder.and(assignee.memberTeam.member.name.eq(filter));
+
+ // 담당자 없음 / 특정 담당자 조건 설정
+ if (!filter.equals("없음")) {
+ builder.and(assignee.memberTeam.member.name.eq(filter));
+ } else {
+ builder.and(assignee.memberTeam.isNull());
+ }
result = goalRepository.findGoalsByTeamId(builder, size);
@@ -349,13 +368,19 @@ public FullGoal getGoalDetail(
List assignees = assigneeRepository.findAllByTypeAndTargetId(Category.GOAL, goalId)
.orElse(new ArrayList<>());
+ // Assignee 무조건 있음: MemberTeam = null / MemberTeam >= 1
+ // 조회했을때 MemberTeam이 null이면 []
+ if (assignees.getFirst().getMemberTeam() == null){
+ assignees = new ArrayList<>();
+ }
+
// 이슈 조회: 없으면 []
List issues = issueRepository.findAllByGoal(goal)
.orElse(new ArrayList<>());
- // 댓글 조회(댓글방 조회 -> 댓글 조회, 댓글 최신순): 없으면 []
+ // 댓글 조회(댓글방 조회 -> 댓글 조회, 댓글 오래된순): 없으면 []
CommentRoom commentRooms = commentRoomRepository.findByRoomTypeAndTargetId(Category.GOAL, goalId);
- List comments = commentRepository.findAllByCommentRoomOrderByIdDesc(commentRooms);
+ List comments = commentRepository.findAllByCommentRoomOrderByIdAsc(commentRooms);
// 조회한 요소들 DTO 변환
return GoalConverter.toFullGoal(
diff --git a/src/main/java/com/example/Veco/domain/issue/controller/IssueController.java b/src/main/java/com/example/Veco/domain/issue/controller/IssueController.java
index 20363da7..6c54cb0c 100644
--- a/src/main/java/com/example/Veco/domain/issue/controller/IssueController.java
+++ b/src/main/java/com/example/Veco/domain/issue/controller/IssueController.java
@@ -1,15 +1,16 @@
package com.example.Veco.domain.issue.controller;
-import com.example.Veco.domain.goal.dto.request.GoalReqDTO;
-import com.example.Veco.domain.goal.dto.response.GoalResDTO;
-import com.example.Veco.domain.goal.exception.code.GoalSuccessCode;
import com.example.Veco.domain.issue.dto.IssueReqDTO;
import com.example.Veco.domain.issue.dto.IssueResponseDTO;
-
+import com.example.Veco.domain.issue.dto.IssueResponseDTO.Pageable;
+import com.example.Veco.domain.issue.dto.IssueResponseDTO.FilteringIssue;
+import com.example.Veco.domain.issue.dto.IssueResponseDTO.IssueWithManagers;
+import com.example.Veco.domain.issue.exception.code.IssueErrorCode;
import com.example.Veco.domain.issue.exception.code.IssueSuccessCode;
import com.example.Veco.domain.issue.service.IssueQueryService;
import com.example.Veco.domain.issue.service.command.IssueCommandService;
import com.example.Veco.global.apiPayload.ApiResponse;
+import com.example.Veco.global.apiPayload.code.BaseErrorStatus;
import com.example.Veco.global.auth.user.AuthUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -98,16 +99,27 @@ public ApiResponse> deleteIssue(
"커서 기반 페이지네이션, 최신 순으로 정렬합니다."
)
@GetMapping("/teams/{teamId}/issues")
- public ApiResponse>> getTeamIssues (
- @PathVariable Long teamId,
- @RequestParam(defaultValue = "-1") @Min(value = -1, message = "커서는 -1보다 큰 정수여야 합니다.")
+ public ApiResponse>> getTeamIssues (
+ @PathVariable
+ Long teamId,
+ @RequestParam(defaultValue = "-1")
String cursor,
@RequestParam(defaultValue = "1") @Min(value = 1, message = "불러올 데이터 수는 1 이상이어야 합니다.")
Integer size,
@RequestParam(required = false, defaultValue = "state")
String query
) {
- return ApiResponse.onSuccess(IssueSuccessCode.OK, issueQueryService.getIssuesByTeamId(teamId, cursor, size, query));
+ Pageable> result = issueQueryService.getIssuesByTeamId(teamId, cursor, size, query);
+ if (result != null) {
+ return ApiResponse.onSuccess(IssueSuccessCode.OK, result);
+ } else {
+ BaseErrorStatus status = IssueErrorCode.NOT_FOUND_IN_TEAM;
+ return ApiResponse.onFailure(
+ status.getReasonHttpStatus().getCode(),
+ status.getReasonHttpStatus().getMessage(),
+ null
+ );
+ }
}
@Operation(
diff --git a/src/main/java/com/example/Veco/domain/issue/converter/IssueConverter.java b/src/main/java/com/example/Veco/domain/issue/converter/IssueConverter.java
index 582ed57f..0f3b6d55 100644
--- a/src/main/java/com/example/Veco/domain/issue/converter/IssueConverter.java
+++ b/src/main/java/com/example/Veco/domain/issue/converter/IssueConverter.java
@@ -1,18 +1,19 @@
package com.example.Veco.domain.issue.converter;
-import com.example.Veco.domain.goal.dto.response.GoalResDTO;
-import com.example.Veco.domain.issue.dto.IssueReqDTO;
-import com.example.Veco.domain.team.entity.Team;
-import org.springframework.stereotype.Component;
import com.example.Veco.domain.assignee.entity.Assignee;
import com.example.Veco.domain.comment.entity.Comment;
import com.example.Veco.domain.goal.entity.Goal;
-import com.example.Veco.domain.issue.entity.Issue;
+import com.example.Veco.domain.issue.dto.IssueReqDTO;
import com.example.Veco.domain.issue.dto.IssueResponseDTO;
+import com.example.Veco.domain.issue.entity.Issue;
+import com.example.Veco.domain.issue.exception.IssueException;
+import com.example.Veco.domain.issue.exception.code.IssueErrorCode;
+import com.example.Veco.domain.team.entity.Team;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.List;
@@ -130,6 +131,7 @@ public static IssueResponseDTO.CommentInfo toCommentInfo(
Comment comment
) {
return IssueResponseDTO.CommentInfo.builder()
+ .id(comment.getId())
.name(comment.getMember().getName())
.profileUrl(comment.getMember().getProfile().getProfileImageUrl())
.content(comment.getContent())
@@ -183,12 +185,27 @@ public static Issue toIssue(
String name,
Goal goal
){
+ LocalDate start = null, end = null;
+ try {
+ if (dto.deadline().start() != null) {
+ start = LocalDate.parse(dto.deadline().start());
+ }
+ if (dto.deadline().end() != null) {
+ end = LocalDate.parse(dto.deadline().end());
+ }
+ } catch (DateTimeParseException e) {
+ throw new IssueException(IssueErrorCode.DEADLINE_INVALID);
+ }
+ if (start != null && end != null && start.isAfter(end)) {
+ throw new IssueException(IssueErrorCode.DEADLINE_ORDER_INVALID);
+ }
+
return Issue.builder()
.state(dto.state())
.content(dto.content())
.title(dto.title())
- .deadlineStart(dto.deadline().start())
- .deadlineEnd(dto.deadline().end())
+ .deadlineStart(start)
+ .deadlineEnd(end)
.priority(dto.priority())
.team(team)
.name(name)
diff --git a/src/main/java/com/example/Veco/domain/issue/dto/IssueReqDTO.java b/src/main/java/com/example/Veco/domain/issue/dto/IssueReqDTO.java
index 2625e62e..9299ecff 100644
--- a/src/main/java/com/example/Veco/domain/issue/dto/IssueReqDTO.java
+++ b/src/main/java/com/example/Veco/domain/issue/dto/IssueReqDTO.java
@@ -14,7 +14,6 @@ public record CreateIssue (
State state,
Priority priority,
List managersId,
- Boolean isIncludeMe,
Deadline deadline,
Long goalId
){}
@@ -30,8 +29,8 @@ public record UpdateIssue(
){}
public record Deadline (
- LocalDate start,
- LocalDate end
+ String start,
+ String end
){}
public record DeleteIssue(
diff --git a/src/main/java/com/example/Veco/domain/issue/dto/IssueResponseDTO.java b/src/main/java/com/example/Veco/domain/issue/dto/IssueResponseDTO.java
index 1983b742..6a2b4b0f 100644
--- a/src/main/java/com/example/Veco/domain/issue/dto/IssueResponseDTO.java
+++ b/src/main/java/com/example/Veco/domain/issue/dto/IssueResponseDTO.java
@@ -20,12 +20,11 @@ public record SimpleIssue(
Priority priority,
Deadline deadline,
GoalInfo goal
- ) {
- }
+ ){}
// 데이터 틀: 이슈 정보, 댓글 정보, 담당자 정보
@Builder
- public record Data (
+ public record Data(
Integer cnt,
List info
){}
@@ -34,31 +33,19 @@ public record Data (
public record Deadline(
LocalDate start,
LocalDate end
- ) {
- }
-
- @Builder
- public record ManagerInfo(
- Long id,
- Long issueId,
- String profileImage,
- String name
- ) {
- }
+ ){}
@Builder
public record SimpleManagerInfo(
String profileUrl,
String name
- ) {
- }
+ ){}
@Builder
public record GoalInfo(
Long id,
String title
- ) {
- }
+ ){}
// 필터 적용 틀: 팀 내 모든 이슈 조회
@Builder
@@ -66,8 +53,7 @@ public record FilteringIssue(
String filterName,
Integer dataCnt,
List issues
- ) {
- }
+ ){}
// 이슈 + 담당자 정보 응답용 DTO
@Builder
@@ -93,8 +79,7 @@ public record Pageable(
Boolean hasNext,
String nextCursor,
Integer pageSize
- ) {
- }
+ ){}
@Builder
public record DetailIssue(
@@ -108,17 +93,16 @@ public record DetailIssue(
GoalInfo goal,
Data managers,
Data comments
- ) {
- }
+ ){}
@Builder
public record CommentInfo(
+ Long id,
String name,
String profileUrl,
String content,
LocalDateTime createdAt
- ) {
- }
+ ){}
@Builder
public record UpdateIssue(
@@ -127,7 +111,7 @@ public record UpdateIssue(
){}
@Builder
- public record CreateIssue (
+ public record CreateIssue(
Long issueId,
LocalDateTime createdAt
){}
@@ -136,7 +120,6 @@ public record CreateIssue (
public record IssueInfo(
Long id,
String title
- ) {
- }
+ ){}
}
diff --git a/src/main/java/com/example/Veco/domain/issue/entity/Issue.java b/src/main/java/com/example/Veco/domain/issue/entity/Issue.java
index a2b4e68a..0c93d6cc 100644
--- a/src/main/java/com/example/Veco/domain/issue/entity/Issue.java
+++ b/src/main/java/com/example/Veco/domain/issue/entity/Issue.java
@@ -7,7 +7,11 @@
import com.example.Veco.global.enums.State;
import jakarta.persistence.*;
import lombok.*;
+import org.hibernate.annotations.SQLDelete;
+import org.hibernate.annotations.SQLRestriction;
+
import java.time.LocalDate;
+import java.time.LocalDateTime;
@Entity
@Table(name = "issue")
@@ -15,6 +19,8 @@
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@SQLDelete(sql = "UPDATE issue SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
+@SQLRestriction("deleted_at IS NULL")
public class Issue extends BaseEntity {
@Id
@@ -49,6 +55,9 @@ public class Issue extends BaseEntity {
@Builder.Default
private LocalDate deadlineEnd = null;
+ @Column(name = "deleted_at")
+ private LocalDateTime deletedAt;
+
// 이슈가 속한 팀
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
diff --git a/src/main/java/com/example/Veco/domain/issue/exception/code/IssueErrorCode.java b/src/main/java/com/example/Veco/domain/issue/exception/code/IssueErrorCode.java
index ecfcc54a..d4f24424 100644
--- a/src/main/java/com/example/Veco/domain/issue/exception/code/IssueErrorCode.java
+++ b/src/main/java/com/example/Veco/domain/issue/exception/code/IssueErrorCode.java
@@ -19,6 +19,15 @@ public enum IssueErrorCode implements BaseErrorStatus {
CURSOR_INVALID(HttpStatus.BAD_REQUEST,
"ISSUE400_0",
"유효하지 않은 커서입니다."),
+ QUERY_INVALID(HttpStatus.BAD_REQUEST,
+ "ISSUE400_1",
+ "query값이 잘못되었습니다."),
+ DEADLINE_INVALID(HttpStatus.BAD_REQUEST,
+ "ISSUE400_2",
+ "기한의 형식이 잘못되었습니다."),
+ DEADLINE_ORDER_INVALID(HttpStatus.BAD_REQUEST,
+ "ISSUE400_3",
+ "시작일은 마감일보다 늦을 수 없습니다."),
FORBIDDEN(HttpStatus.FORBIDDEN,
"ISSUE403_0",
"권한이 없습니다."),
diff --git a/src/main/java/com/example/Veco/domain/issue/repository/CustomIssueRepository.java b/src/main/java/com/example/Veco/domain/issue/repository/CustomIssueRepository.java
index ac556dbc..6dd0011a 100644
--- a/src/main/java/com/example/Veco/domain/issue/repository/CustomIssueRepository.java
+++ b/src/main/java/com/example/Veco/domain/issue/repository/CustomIssueRepository.java
@@ -15,15 +15,29 @@ List findIssuesByTeamId(
int size
);
+ List findUnassignedIssuesByTeamId(
+ Long teamId,
+ Predicate query,
+ int size
+ );
+
Long findIssuesCountByFilter(
Predicate query
);
- List findIssuesAssigneeInTeam (
+ Long findUnassignedIssuesCountByTeamId(
+ Long teamId
+ );
+
+ List findIssuesAssigneeInTeam(
+ Long teamId
+ );
+
+ Long findNoGoalIssuesCountByTeamId(
Long teamId
);
- List findGoalInfoByTeamId(
+ List findGoalsByTeamId(
Long teamId
);
diff --git a/src/main/java/com/example/Veco/domain/issue/repository/CustomIssueRepositoryImpl.java b/src/main/java/com/example/Veco/domain/issue/repository/CustomIssueRepositoryImpl.java
index f3b0d8de..b0bb6436 100644
--- a/src/main/java/com/example/Veco/domain/issue/repository/CustomIssueRepositoryImpl.java
+++ b/src/main/java/com/example/Veco/domain/issue/repository/CustomIssueRepositoryImpl.java
@@ -6,8 +6,6 @@
import com.example.Veco.domain.issue.dto.IssueResponseDTO;
import com.example.Veco.domain.issue.entity.Issue;
import com.example.Veco.domain.issue.entity.QIssue;
-import com.example.Veco.domain.issue.exception.IssueException;
-import com.example.Veco.domain.issue.exception.code.IssueErrorCode;
import com.example.Veco.domain.member.entity.QMember;
import com.example.Veco.global.enums.Category;
import com.querydsl.core.group.GroupBy;
@@ -39,10 +37,11 @@ public List findIssuesByTeamId(Predicate query, in
.from(issue)
.where(query)
.leftJoin(assignee).on(assignee.type.eq(Category.ISSUE).and(assignee.targetId.eq(issue.id)))
- .leftJoin(member).on(member.eq(assignee.memberTeam.member))
+ .leftJoin(member).on(member.id.eq(assignee.memberTeam.member.id))
.leftJoin(goal).on(goal.id.eq(issue.goal.id))
.orderBy(issue.id.desc())
.groupBy(issue.id, assignee.id)
+ .limit(size)
.transform(GroupBy.groupBy(issue.id).list(
Projections.constructor(
IssueResponseDTO.SimpleIssue.class,
@@ -61,20 +60,59 @@ public List findIssuesByTeamId(Predicate query, in
goal.id.coalesce(-1L),
goal.title.coalesce("목표 없음")
)
-
)
)
);
- // 이슈가 없는 경우 throw
- if (result.isEmpty()){
- throw new IssueException(IssueErrorCode.NOT_FOUND_IN_TEAM);
- }
+ return result;
+ }
+
+ @Override
+ public List findUnassignedIssuesByTeamId(
+ Long teamId,
+ Predicate query,
+ int size
+ ) {
+ // 객체 생성
+ QIssue issue = QIssue.issue;
+ QGoal goal = QGoal.goal;
+ QAssignee assignee = QAssignee.assignee;
+
+ // 쿼리 실행
+ List result = queryFactory
+ .select(Projections.constructor(
+ IssueResponseDTO.SimpleIssue.class,
+ issue.id,
+ issue.name,
+ issue.title,
+ issue.state,
+ issue.priority,
+ Projections.constructor(
+ IssueResponseDTO.Deadline.class,
+ issue.deadlineStart,
+ issue.deadlineEnd
+ ),
+ Projections.constructor(
+ IssueResponseDTO.GoalInfo.class,
+ goal.id.coalesce(-1L),
+ goal.title.coalesce("목표 없음")
+ )
+ ))
+ .from(issue)
+ .leftJoin(goal).on(goal.id.eq(issue.goal.id))
+ .leftJoin(assignee).on(assignee.type.eq(Category.ISSUE)
+ .and(assignee.targetId.eq(issue.id)))
+ .where(issue.team.id.eq(teamId)
+ .and(query)
+ .and(assignee.targetId.isNull()))
+ .orderBy(issue.id.desc())
+ .limit(size)
+ .fetch();
return result;
}
- // 필터에 맞는 모든 목표 개수 조회
+ // 필터에 맞는 모든 이슈 개수 조회
@Override
public Long findIssuesCountByFilter(
Predicate query
@@ -89,7 +127,7 @@ public Long findIssuesCountByFilter(
.fetchFirst();
}
- // 모든 목표 담당자 리스트 조회
+ // 모든 이슈 담당자 리스트 조회
@Override
public List findIssuesAssigneeInTeam(
Long teamId
@@ -102,13 +140,45 @@ public List findIssuesAssigneeInTeam(
.select(assignee.memberTeam.member.name)
.from(issue)
.leftJoin(assignee).on(assignee.type.eq(Category.ISSUE)
- .and(assignee.targetId.eq(issue.id)))
- .where(issue.goal.team.id.eq(teamId))
+ .and(assignee.targetId.eq(issue.id))
+ )
+ .where(issue.team.id.eq(teamId))
.fetch();
}
@Override
- public List findGoalInfoByTeamId(
+ public Long findUnassignedIssuesCountByTeamId(Long teamId) {
+ // 객체 생성
+ QIssue issue = QIssue.issue;
+ QAssignee assignee = QAssignee.assignee;
+
+ return queryFactory
+ .select(issue.count())
+ .from(issue)
+ .leftJoin(assignee).on(assignee.type.eq(Category.ISSUE)
+ .and(assignee.targetId.eq(issue.id)))
+ .where(issue.team.id.eq(teamId)
+ .and(assignee.targetId.isNull()))
+ .fetchOne();
+ }
+
+ @Override
+ public Long findNoGoalIssuesCountByTeamId(
+ Long teamId
+ ) {
+ // 객체 생성
+ QIssue issue = QIssue.issue;
+
+ return queryFactory
+ .select(issue.count())
+ .from(issue)
+ .where(issue.goal.isNull()
+ .and(issue.team.id.eq(teamId)))
+ .fetchOne();
+ }
+
+ @Override
+ public List findGoalsByTeamId(
Long teamId
) {
// 객체 생성
@@ -116,17 +186,12 @@ public List findGoalInfoByTeamId(
QGoal goal = QGoal.goal;
return queryFactory
- .select(Projections.constructor(
- IssueResponseDTO.GoalInfo.class,
- goal.id.coalesce(-1L),
- goal.title.coalesce("목표 없음")
- ))
+ .select(goal.title)
.from(issue)
.leftJoin(goal)
.on(goal.id.eq(issue.goal.id)
.and(goal.deletedAt.isNull()))
- .where(issue.team.id.eq(teamId)
- .or(goal.id.isNull()))
+ .where(goal.team.id.eq(teamId))
.fetch();
}
@@ -142,8 +207,8 @@ public Map> findManagerInfoByTeamId(
Map> result = queryFactory
.from(issue)
.leftJoin(assignee).on(assignee.type.eq(Category.ISSUE)
- .and(assignee.targetId.eq(issue.id)))
- .leftJoin(member).on(member.id.eq(assignee.memberTeam.member.id))
+ .and(assignee.targetId.eq(issue.id))
+ )
.transform(
GroupBy.groupBy(issue.id).as(
GroupBy.list(assignee)
diff --git a/src/main/java/com/example/Veco/domain/issue/service/IssueQueryService.java b/src/main/java/com/example/Veco/domain/issue/service/IssueQueryService.java
index bbc9c208..d441459a 100644
--- a/src/main/java/com/example/Veco/domain/issue/service/IssueQueryService.java
+++ b/src/main/java/com/example/Veco/domain/issue/service/IssueQueryService.java
@@ -1,17 +1,19 @@
package com.example.Veco.domain.issue.service;
import com.example.Veco.domain.assignee.entity.Assignee;
+import com.example.Veco.domain.assignee.entity.QAssignee;
import com.example.Veco.domain.assignee.repository.AssigneeRepository;
import com.example.Veco.domain.comment.entity.Comment;
import com.example.Veco.domain.comment.entity.CommentRoom;
import com.example.Veco.domain.comment.repository.CommentRepository;
import com.example.Veco.domain.goal.entity.Goal;
-import com.example.Veco.domain.goal.exception.GoalException;
-import com.example.Veco.domain.goal.exception.code.GoalErrorCode;
import com.example.Veco.domain.goal.repository.GoalRepository;
import com.example.Veco.domain.issue.converter.IssueConverter;
import com.example.Veco.domain.issue.dto.IssueResponseDTO;
+import com.example.Veco.domain.issue.dto.IssueResponseDTO.FilteringIssue;
+import com.example.Veco.domain.issue.dto.IssueResponseDTO.IssueWithManagers;
import com.example.Veco.domain.issue.dto.IssueResponseDTO.SimpleIssue;
+import com.example.Veco.domain.issue.dto.IssueResponseDTO.Pageable;
import com.example.Veco.domain.issue.entity.Issue;
import com.example.Veco.domain.issue.entity.QIssue;
import com.example.Veco.domain.issue.exception.IssueException;
@@ -32,6 +34,8 @@
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
+import java.util.regex.PatternSyntaxException;
+import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
@@ -46,7 +50,7 @@ public class IssueQueryService {
private final CommentRepository commentRepository;
private final TeamRepository teamRepository;
- public IssueResponseDTO.Pageable> getIssuesByTeamId(
+ public Pageable> getIssuesByTeamId(
Long teamId,
String cursor,
Integer size,
@@ -54,88 +58,181 @@ public IssueResponseDTO.Pageable state.name().equals(finalFirstCursor))
+ ) {
+ firstCursor = State.NONE.name();
+ }
+ break;
+ }
+ case "priority": {
+ String finalFirstCursor1 = firstCursor;
+ if (Arrays.stream(Priority.values()).noneMatch(
+ priority -> priority.name().equals(finalFirstCursor1))
+ ) {
+ firstCursor = Priority.NONE.name();
+ }
+ break;
+ }
+ }
+ } catch (NumberFormatException | PatternSyntaxException ex) {
throw new IssueException(IssueErrorCode.CURSOR_INVALID);
}
+ } else {
+ // 커서가 없는 경우, 기본값 NONE 설정, 담당자는 null
+ switch (query.toLowerCase()) {
+ case "state": {
+ firstCursor = State.NONE.name();
+ break;
+ }
+ case "priority": {
+ firstCursor = Priority.NONE.name();
+ break;
+ }
+ }
}
- // 데이터 조회
- List result = issueRepository.findIssuesByTeamId(builder, size);
-
- // 페이지네이션 메타데이터 설정
- boolean hasNext = result.size() > size;
- int pageSize = Math.min(result.size(), size);
- String nextCursor = hasNext ? result.get(pageSize).id().toString() : result.get(pageSize - 1).id().toString();
-
- // 조회한 데이터 사이즈 조절
- if (hasNext) {
- result = result.subList(0, size);
- }
+ // 요청 데이터 수 +1 많게 조회: 메타데이터 설정용
+ size += 1;
// 필터별 분류
// 상태: 없음 → 진행중 → 해야할 일 → 완료 → 검토 → 삭제
// 우선순위: 없음 → 긴급 → 높음 → 보통 → 낮음
// 담당자: 사전순(프론트에서 처리)
- List> filterResult = new ArrayList<>();
- Map> assignees = issueRepository.findManagerInfoByTeamId(teamId);
- List issueWithManagers = new ArrayList<>();
-
- result.forEach(issue -> {
- List issueManagers = assignees.getOrDefault(issue.id(), Collections.emptyList());
-
- issueWithManagers.add(IssueConverter.toIssueWithManagers(issue, IssueConverter.toSimpleManagerInfos(issueManagers)));
- });
+ List> filterResult = new ArrayList<>();
+ boolean isContinue = false;
+ BooleanBuilder dataQuery = new BooleanBuilder();
+ dataQuery.and(qIssue.team.id.eq(teamId));
+ // 페이지네이션 메타데이터 설정
+ boolean hasNext = false;
+ int pageSize = 0;
+ String nextCursor = "";
switch (query.toLowerCase()) {
case "state": {
// 필터 설정
for (State filter : State.values()) {
- // 필터링에 맞는 모든 목표 개수 조회
- builder = new BooleanBuilder();
- builder.and(qIssue.state.eq(filter))
- .and(qIssue.team.id.eq(teamId));
- Long dataCnt = issueRepository.findIssuesCountByFilter(builder);
- List temp = new ArrayList<>();
- // 순서별 데이터 분류: O(6N) = O(N)
- issueWithManagers.forEach(
- value -> {
- if (value.getState().equals(filter)) {
- temp.add(value);
- }
- }
- );
+ List result = new ArrayList<>();
+
+ // 해당 필터 총 데이터 수 조회
+ dataQuery.and(qIssue.state.eq(filter));
+ Long dataCnt = issueRepository.findIssuesCountByFilter(dataQuery);
+
+ // firstCursor가 일치할 때, 조회 시작
+ if ((size > 0) && (filter.name().equals(firstCursor) || isContinue)) {
+ builder.and(qIssue.state.eq(filter));
+
+ result = issueRepository.findIssuesByTeamId(builder, size);
+
+ // 조회 시작했을때, 설정한 사이즈를 넘을때까지 조회
+ isContinue = true;
+ size -= result.size();
+ pageSize += result.size();
+
+ // ID 조건 초기화
+ builder = new BooleanBuilder();
+ builder.and(qIssue.team.id.eq(teamId));
+ }
+
+ // 사이즈를 넘어 조회한 경우: 다음 데이터 존재 -> 메타데이터로 설정
+ if (size <= 0 && isContinue) {
+ // 그만 조회
+ isContinue = false;
+
+ hasNext = true;
+ pageSize -= 1;
+ nextCursor = filter + ":" + result.getLast().id();
+
+ // 사이즈 조절
+ if (result.size() > 1) {
+ result = result.subList(0, result.size() - 1);
+ } else {
+ result = new ArrayList<>();
+ }
+ } else if (isContinue && !result.isEmpty()) {
+ nextCursor = filter + ":" + result.getLast().id();
+ }
+
// 분류한 데이터 filterResult 삽입
- filterResult.add(IssueConverter.toFilteringIssue(temp, filter.name(), Math.toIntExact(dataCnt)));
+ filterResult.add(IssueConverter.toFilteringIssue(result, filter.name(), Math.toIntExact(dataCnt)));
+
+ // 조건 초기화
+ dataQuery = new BooleanBuilder();
+ dataQuery.and(qIssue.team.id.eq(teamId));
}
break;
}
case "priority": {
// 필터 설정
for (Priority filter : Priority.values()) {
- // 필터링에 맞는 모든 목표 개수 조회
- builder = new BooleanBuilder();
- builder.and(qIssue.priority.eq(filter))
- .and(qIssue.team.id.eq(teamId));
- Long dataCnt = issueRepository.findIssuesCountByFilter(builder);
- List temp = new ArrayList<>();
- // 순서별 데이터 분류: O(5N) = O(N)
- issueWithManagers.forEach(
- value -> {
- if (value.getPriority().equals(filter)) {
- temp.add(value);
- }
- }
- );
+ List result = new ArrayList<>();
+
+ // 해당 필터 총 데이터 수 조회
+ dataQuery.and(qIssue.priority.eq(filter));
+ Long dataCnt = issueRepository.findIssuesCountByFilter(dataQuery);
+
+ // firstCursor가 일치할 때, 조회 시작
+ if ((size > 0) && (filter.name().equals(firstCursor) || isContinue)) {
+ builder.and(qIssue.priority.eq(filter));
+
+ result = issueRepository.findIssuesByTeamId(builder, size);
+
+ // 조회 시작했을때, 설정한 사이즈를 넘을때까지 조회
+ isContinue = true;
+ size -= result.size();
+ pageSize += result.size();
+
+ // ID 조건 초기화
+ builder = new BooleanBuilder();
+ builder.and(qIssue.team.id.eq(teamId));
+ }
+
+ // 사이즈를 넘어 조회한 경우: 다음 데이터 존재 -> 메타데이터로 설정
+ if (size <= 0 && isContinue) {
+ // 그만 조회
+ isContinue = false;
+
+ hasNext = true;
+ pageSize -= 1;
+ nextCursor = filter + ":" + result.getLast().id();
+
+ // 사이즈 조절
+ if (result.size() > 1) {
+ result = result.subList(0, result.size() - 1);
+ } else {
+ result = new ArrayList<>();
+ }
+ } else if (isContinue && !result.isEmpty()) {
+ nextCursor = filter + ":" + result.getLast().id();
+ }
+
// 분류한 데이터 filterResult 삽입
- filterResult.add(IssueConverter.toFilteringIssue(temp, filter.name(), Math.toIntExact(dataCnt)));
+ filterResult.add(IssueConverter.toFilteringIssue(result, filter.name(), Math.toIntExact(dataCnt)));
+
+ // 조건 초기화
+ dataQuery = new BooleanBuilder();
+ dataQuery.and(qIssue.team.id.eq(teamId));
}
break;
}
@@ -150,35 +247,70 @@ public IssueResponseDTO.Pageable temp = new ArrayList<>();
- issueWithManagers.forEach(
- value -> {
- if (value.getManagers().info().stream()
- .anyMatch(dto -> dto.name().equals(filter))
- ) {
- temp.add(value);
- } else if (value.getManagers().cnt() == 0 && filter.equals("담당자 없음")) {
- // 담당자가 없는 경우
- temp.add(value);
- map.put("담당자 없음", map.get("담당자 없음") + 1);
- }
- }
- );
+ List result = new ArrayList<>();
+
+ // firstCursor가 일치할 때, 조회 시작
+ if ((size > 0) && (filter.equals(firstCursor) || isContinue)) {
+ if (filter.equals("담당자 없음")) {
+ result = issueRepository.findUnassignedIssuesByTeamId(teamId, builder, size);
+ } else {
+ builder.and(qAssignee.memberTeam.member.name.eq(filter));
+ result = issueRepository.findIssuesByTeamId(builder, size);
+ }
+
+ // 조회 시작했을때, 설정한 사이즈를 넘을때까지 조회
+ isContinue = true;
+ size -= result.size();
+ pageSize += result.size();
+
+ // ID 조건 초기화
+ builder = new BooleanBuilder();
+ builder.and(qIssue.team.id.eq(teamId));
+ }
+
+ // 사이즈를 넘어 조회한 경우: 다음 데이터 존재 -> 메타데이터로 설정
+ if (size <= 0 && isContinue) {
+ // 그만 조회
+ isContinue = false;
+
+ hasNext = true;
+ pageSize -= 1;
+ nextCursor = filter + ":" + result.getLast().id();
+
+ // 사이즈 조절
+ if (result.size() > 1) {
+ result = result.subList(0, result.size() - 1);
+ } else {
+ result = new ArrayList<>();
+ }
+ } else if (isContinue && !result.isEmpty()) {
+ nextCursor = filter + ":" + result.getLast().id();
+ }
// 분류한 데이터 filterResult 삽입
- filterResult.add(IssueConverter.toFilteringIssue(temp, filter, map.get(filter)));
+ filterResult.add(IssueConverter.toFilteringIssue(result, filter, map.get(filter)));
}
break;
}
case "goal": {
- // 목표 리스트 뽑아와서 Map 처리: 목표 : 개수
- List goals = issueRepository.findGoalInfoByTeamId(teamId);
- Map map = new HashMap<>();
- for (IssueResponseDTO.GoalInfo goal : goals) {
+ // 목표 리스트 뽑아와서 Map 처리: 목표 이름 : 개수
+ List goals = issueRepository.findGoalsByTeamId(teamId);
+ Map map = new HashMap<>();
+ for (String goal : goals) {
if (map.containsKey(goal)) {
map.put(goal, map.get(goal) + 1);
} else {
@@ -186,28 +318,95 @@ public IssueResponseDTO.Pageable temp = new ArrayList<>();
- issueWithManagers.forEach(
- value -> {
- if (value.getGoal().equals(filter)) {
- temp.add(value);
- }
- }
- );
+ for (String filter : map.keySet().stream().sorted().toList()) {
+ List result = new ArrayList<>();
+
+ // firstCursor가 일치할 때, 조회 시작
+ if ((size > 0) && (filter.equals(firstCursor) || isContinue)) {
+ builder.and(qIssue.team.id.eq(teamId));
+ if (filter.equals("목표 없음")) {
+ builder.and(qIssue.goal.isNull());
+ } else {
+ builder.and(qIssue.goal.title.eq(filter));
+ }
+ result = issueRepository.findIssuesByTeamId(builder, size);
+
+ // 조회 시작했을때, 설정한 사이즈를 넘을때까지 조회
+ isContinue = true;
+ size -= result.size();
+ pageSize += result.size();
+
+ // ID 조건 초기화
+ builder = new BooleanBuilder();
+ builder.and(qIssue.team.id.eq(teamId));
+ }
+
+ // 사이즈를 넘어 조회한 경우: 다음 데이터 존재 -> 메타데이터로 설정
+ if (size <= 0 && isContinue) {
+ // 그만 조회
+ isContinue = false;
+
+ hasNext = true;
+ pageSize -= 1;
+ nextCursor = filter + ":" + result.getLast().id();
+
+ // 사이즈 조절
+ if (result.size() > 1) {
+ result = result.subList(0, result.size() - 1);
+ } else {
+ result = new ArrayList<>();
+ }
+ } else if (isContinue && !result.isEmpty()) {
+ nextCursor = filter + ":" + result.getLast().id();
+ }
+
// 분류한 데이터 filterResult 삽입
- filterResult.add(IssueConverter.toFilteringIssue(temp, filter.title(), map.get(filter)));
+ filterResult.add(IssueConverter.toFilteringIssue(result, filter, map.get(filter)));
}
break;
}
default: {
- throw new GoalException(GoalErrorCode.QUERY_INVALID);
+ throw new IssueException(IssueErrorCode.QUERY_INVALID);
}
}
- return IssueConverter.toPageable(filterResult, hasNext, nextCursor, pageSize);
+ // 데이터들이 없는 경우
+ if (filterResult.stream().allMatch(
+ value -> value.dataCnt().equals(0))
+ ) {
+ return null;
+ }
+
+ Map> assignees = issueRepository.findManagerInfoByTeamId(teamId);
+
+ List> resultWithManagers = filterResult.stream()
+ .map(filterGroup -> {
+ // 현재 필터 그룹의 이슈들을 IssueWithManagers로 변환
+ List issuesWithManagers = filterGroup.issues().stream()
+ .map(simpleIssue -> {
+ List issueManagers = assignees.getOrDefault(simpleIssue.id(), Collections.emptyList());
+ return IssueConverter.toIssueWithManagers(simpleIssue, IssueConverter.toSimpleManagerInfos(issueManagers));
+ })
+ .collect(Collectors.toList());
+ // 담당자 정보가 포함된 새로운 필터 그룹 생성
+ return IssueConverter.toFilteringIssue(issuesWithManagers, filterGroup.filterName(), filterGroup.dataCnt());
+ })
+ .collect(Collectors.toList());
+
+
+ return IssueConverter.toPageable(resultWithManagers, hasNext, nextCursor, pageSize);
}
public IssueResponseDTO.DetailIssue getIssueDetailById(Long issueId) {
@@ -215,8 +414,7 @@ public IssueResponseDTO.DetailIssue getIssueDetailById(Long issueId) {
new IssueException(IssueErrorCode.NOT_FOUND));
// 담당자 조회: 없으면 []
- List assignees = assigneeRepository.findAllByTypeAndTargetId(Category.ISSUE, issueId)
- .orElse(new ArrayList<>());
+ List assignees = assigneeRepository.findByTypeAndTargetId(Category.ISSUE, issueId);
// 목표 조회
Goal goal = null;
@@ -236,7 +434,10 @@ public IssueResponseDTO.DetailIssue getIssueDetailById(Long issueId) {
}
CommentRoom commentRooms = commentRoomRepository.findByRoomTypeAndTargetId(Category.ISSUE, issueId);
- List comments = commentRepository.findAllByCommentRoomOrderByIdDesc(commentRooms);
+ List comments = null;
+ if (commentRooms != null) {
+ comments = commentRepository.findByCommentRoomOrderByIdAsc(commentRooms);
+ }
return IssueConverter.toDetailIssue(
issue,
@@ -250,21 +451,21 @@ public String getIssueName(
Long teamId
) {
// 현재 이슈 숫자 조회
- Team team = teamRepository.findById(teamId).orElseThrow(()->
+ Team team = teamRepository.findById(teamId).orElseThrow(() ->
new TeamException(TeamErrorCode._NOT_FOUND));
Long issueNumber = team.getIssueNumber() != null ? team.getIssueNumber() : 1L;
- return team.getWorkSpace().getName()+"-i"+issueNumber;
+ return team.getWorkSpace().getName() + "-i" + issueNumber;
}
public IssueResponseDTO.Data getSimpleIssue(
Long teamId
) {
- teamRepository.findById(teamId).orElseThrow(()->
+ teamRepository.findById(teamId).orElseThrow(() ->
new TeamException(TeamErrorCode._NOT_FOUND));
List issues = issueRepository.findAllByTeamId(teamId);
- if (issues.isEmpty()){
+ if (issues.isEmpty()) {
throw new IssueException(IssueErrorCode.NOT_FOUND_IN_TEAM);
}
return IssueConverter.toData(issues.stream().map(IssueConverter::toIssueInfo).toList());
diff --git a/src/main/java/com/example/Veco/domain/issue/service/command/IssueCommandServiceImpl.java b/src/main/java/com/example/Veco/domain/issue/service/command/IssueCommandServiceImpl.java
index 7d30f2b7..d038caca 100644
--- a/src/main/java/com/example/Veco/domain/issue/service/command/IssueCommandServiceImpl.java
+++ b/src/main/java/com/example/Veco/domain/issue/service/command/IssueCommandServiceImpl.java
@@ -1,8 +1,5 @@
package com.example.Veco.domain.issue.service.command;
-import com.example.Veco.domain.assignee.converter.AssigneeConverter;
-import com.example.Veco.domain.assignee.repository.AssigneeRepository;
-import com.example.Veco.domain.goal.converter.GoalConverter;
import com.example.Veco.domain.goal.entity.Goal;
import com.example.Veco.domain.goal.exception.GoalException;
import com.example.Veco.domain.goal.exception.code.GoalErrorCode;
@@ -24,7 +21,6 @@
import com.example.Veco.domain.team.exception.code.TeamErrorCode;
import com.example.Veco.domain.team.repository.TeamRepository;
import com.example.Veco.global.auth.user.AuthUser;
-import com.example.Veco.global.enums.Category;
import com.example.Veco.global.redis.exception.RedisException;
import com.example.Veco.global.redis.exception.code.RedisErrorCode;
import lombok.RequiredArgsConstructor;
@@ -45,7 +41,6 @@ public class IssueCommandServiceImpl implements IssueCommandService {
private final MemberRepository memberRepository;
private final MemberTeamRepository memberTeamRepository;
private final IssueRepository issueRepository;
- private final AssigneeRepository assigneeRepository;
private final GoalRepository goalRepository;
private final TeamRepository teamRepository;
private final RedissonClient redissonClient;
@@ -78,10 +73,17 @@ public IssueResponseDTO.UpdateIssue updateIssue(AuthUser user, IssueReqDTO.Updat
@Transactional
public List deleteIssue(AuthUser user, Long teamId, IssueReqDTO.DeleteIssue dto){
List issues = issueRepository.findAllById(dto.issueIds());
+ if (issues.isEmpty() || issues.size() != dto.issueIds().size()){
+ throw new IssueException(IssueErrorCode.NOT_FOUND);
+ }
+ // 사용자 검증
Member member = memberRepository.findBySocialUid(user.getSocialUid())
.orElseThrow(() -> new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND));
+ // 팀 존재 검증
+ teamRepository.findById(teamId)
+ .orElseThrow(() -> new TeamException(TeamErrorCode._NOT_FOUND));
memberTeamRepository.findByMemberIdAndTeamId(member.getId(), teamId)
.orElseThrow(() -> new MemberHandler(MemberErrorStatus._FORBIDDEN));
@@ -93,7 +95,6 @@ public List deleteIssue(AuthUser user, Long teamId, IssueReqDTO.DeleteIssu
issues.forEach(issue -> result.add(issue.getId()));
issueRepository.deleteAll(issues);
- assigneeRepository.deleteAllByTypeAndTargetIds(Category.ISSUE, result);
return result;
}
@@ -101,27 +102,32 @@ public List deleteIssue(AuthUser user, Long teamId, IssueReqDTO.DeleteIssu
@Override
public IssueResponseDTO.CreateIssue createIssue(AuthUser user, Long teamId, IssueReqDTO.CreateIssue dto){
+ // 사용자 검증
+ Member member = memberRepository.findBySocialUid(user.getSocialUid())
+ .orElseThrow(() -> new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND));
+
+ // 담당자 검증
List memberIds = new ArrayList<>(dto.managersId());
- if (dto.isIncludeMe()) {
- Member member = memberRepository.findBySocialUid(user.getSocialUid()).orElseThrow(() ->
- new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND));
- memberIds.add(member.getId());
- }
List memberList = memberRepository.findAllById(memberIds);
if (memberList.size() != memberIds.size()) {
throw new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND);
}
+ // 팀 존재 검증
if (!teamRepository.existsById(teamId)) {
throw new TeamException(TeamErrorCode._NOT_FOUND);
}
+ // 팀원 검증
List memberTeamList = memberTeamRepository.findAllByMemberIdInAndTeamId(memberIds, teamId);
if (memberTeamList.size() != memberIds.size()) {
throw new MemberHandler(MemberErrorStatus._FORBIDDEN);
}
+ memberTeamRepository.findByMemberIdAndTeamId(member.getId(), teamId)
+ .orElseThrow(() -> new MemberHandler(MemberErrorStatus._FORBIDDEN));
+ // 목표 존재 검증
Goal goal = goalRepository.findById(dto.goalId()).orElseThrow(()->
new GoalException(GoalErrorCode.NOT_FOUND));
diff --git a/src/main/java/com/example/Veco/domain/issue/service/command/IssueTransactionalService.java b/src/main/java/com/example/Veco/domain/issue/service/command/IssueTransactionalService.java
index 58e9e455..4c9407e8 100644
--- a/src/main/java/com/example/Veco/domain/issue/service/command/IssueTransactionalService.java
+++ b/src/main/java/com/example/Veco/domain/issue/service/command/IssueTransactionalService.java
@@ -3,8 +3,6 @@
import com.example.Veco.domain.assignee.converter.AssigneeConverter;
import com.example.Veco.domain.assignee.entity.Assignee;
import com.example.Veco.domain.assignee.repository.AssigneeRepository;
-import com.example.Veco.domain.goal.converter.GoalConverter;
-import com.example.Veco.domain.goal.dto.request.GoalReqDTO;
import com.example.Veco.domain.goal.entity.Goal;
import com.example.Veco.domain.goal.exception.GoalException;
import com.example.Veco.domain.goal.exception.code.GoalErrorCode;
@@ -26,6 +24,8 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
@@ -113,9 +113,39 @@ protected boolean updateIssue(
}
if (dto.deadline() != null) {
- issue.updateDeadlineStart(dto.deadline().start());
- issue.updateDeadlineEnd(dto.deadline().end());
- isRestore = true;
+
+ try {
+ if (dto.deadline().start() != null && dto.deadline().end() != null) {
+ String start = dto.deadline().start();
+ String end = dto.deadline().end();
+ if(!start.equals("null") && !end.equals("null")) {
+ if(LocalDate.parse(start).isAfter(LocalDate.parse(end))){
+ throw new IssueException(IssueErrorCode.DEADLINE_ORDER_INVALID);
+ }
+ }
+ }
+ if (dto.deadline().start() != null) {
+ LocalDate start;
+ if (dto.deadline().start().equals("null")){ // LocalDate -> String 변경
+ start = null;
+ } else {
+ start = LocalDate.parse(dto.deadline().start());
+ }
+ issue.updateDeadlineStart(start);
+ }
+ if (dto.deadline().end() != null) {
+ LocalDate end;
+ if (dto.deadline().end().equals("null")){
+ end = null;
+ } else {
+ end = LocalDate.parse(dto.deadline().end());
+ }
+ issue.updateDeadlineEnd(end);
+ }
+ isRestore = true;
+ } catch (DateTimeParseException e) {
+ throw new IssueException(IssueErrorCode.DEADLINE_INVALID);
+ }
}
if (dto.goalId() != null) {
diff --git a/src/main/java/com/example/Veco/domain/mapping/Assignment.java b/src/main/java/com/example/Veco/domain/mapping/Assignment.java
index 22b30f81..11fcefaa 100644
--- a/src/main/java/com/example/Veco/domain/mapping/Assignment.java
+++ b/src/main/java/com/example/Veco/domain/mapping/Assignment.java
@@ -1,12 +1,11 @@
package com.example.Veco.domain.mapping;
-import com.example.Veco.domain.assignee.entity.Assignee;
import com.example.Veco.domain.common.BaseEntity;
import com.example.Veco.domain.external.entity.External;
import com.example.Veco.domain.goal.entity.Goal;
import com.example.Veco.domain.issue.entity.Issue;
import com.example.Veco.domain.member.entity.Member;
-import com.example.Veco.domain.team.enums.Category;
+import com.example.Veco.global.enums.Category;
import jakarta.persistence.*;
import lombok.*;
@@ -21,6 +20,7 @@ public class Assignment extends BaseEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ @Enumerated(EnumType.STRING)
private Category category;
private String assigneeName;
diff --git a/src/main/java/com/example/Veco/domain/mapping/TeamRepository.java b/src/main/java/com/example/Veco/domain/mapping/TeamRepository.java
index 6d6259ce..941184bd 100644
--- a/src/main/java/com/example/Veco/domain/mapping/TeamRepository.java
+++ b/src/main/java/com/example/Veco/domain/mapping/TeamRepository.java
@@ -1,7 +1,7 @@
package com.example.Veco.domain.mapping;
import com.example.Veco.domain.common.BaseEntity;
-import com.example.Veco.domain.external.entity.GitHubRepository;
+import com.example.Veco.domain.github.entity.GitHubRepository;
import com.example.Veco.domain.team.entity.Team;
import jakarta.persistence.*;
import lombok.*;
diff --git a/src/main/java/com/example/Veco/domain/mapping/converter/AssignmentConverter.java b/src/main/java/com/example/Veco/domain/mapping/converter/AssignmentConverter.java
new file mode 100644
index 00000000..1e89c73c
--- /dev/null
+++ b/src/main/java/com/example/Veco/domain/mapping/converter/AssignmentConverter.java
@@ -0,0 +1,18 @@
+package com.example.Veco.domain.mapping.converter;
+
+import com.example.Veco.domain.external.entity.External;
+import com.example.Veco.domain.mapping.Assignment;
+import com.example.Veco.domain.member.entity.Member;
+import com.example.Veco.global.enums.Category;
+
+public class AssignmentConverter {
+ public static Assignment toAssignment(Member member, External external, Category category) {
+ return Assignment.builder()
+ .assigneeName(member.getName())
+ .profileUrl(member.getProfile().getProfileImageUrl())
+ .category(category)
+ .assignee(member)
+ .external(external)
+ .build();
+ }
+}
diff --git a/src/main/java/com/example/Veco/domain/mapping/entity/MemberTeam.java b/src/main/java/com/example/Veco/domain/mapping/entity/MemberTeam.java
index ebf059fb..9319826f 100644
--- a/src/main/java/com/example/Veco/domain/mapping/entity/MemberTeam.java
+++ b/src/main/java/com/example/Veco/domain/mapping/entity/MemberTeam.java
@@ -2,6 +2,7 @@
+import com.example.Veco.domain.assignee.entity.Assignee;
import com.example.Veco.domain.common.BaseEntity;
import com.example.Veco.domain.member.entity.Member;
import com.example.Veco.domain.team.entity.Team;
@@ -9,6 +10,9 @@
import jakarta.persistence.*;
import lombok.*;
+import java.util.ArrayList;
+import java.util.List;
+
@Entity
@Getter
@Builder
@@ -33,4 +37,7 @@ public class MemberTeam extends BaseEntity {
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
+
+ @OneToMany(mappedBy = "memberTeam", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List assignees = new ArrayList<>();
}
diff --git a/src/main/java/com/example/Veco/domain/mapping/repository/MemberTeamRepository.java b/src/main/java/com/example/Veco/domain/mapping/repository/MemberTeamRepository.java
index 5ff55d6c..14e5c496 100644
--- a/src/main/java/com/example/Veco/domain/mapping/repository/MemberTeamRepository.java
+++ b/src/main/java/com/example/Veco/domain/mapping/repository/MemberTeamRepository.java
@@ -1,12 +1,15 @@
package com.example.Veco.domain.mapping.repository;
import com.example.Veco.domain.mapping.entity.MemberTeam;
+import com.example.Veco.domain.member.entity.Member;
+import com.example.Veco.domain.team.entity.Team;
import com.example.Veco.domain.workspace.dto.TeamMemberCountDto;
import io.lettuce.core.dynamic.annotation.Param;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
@@ -28,4 +31,10 @@ public interface MemberTeamRepository extends JpaRepository {
List countMembersByTeamIds(@Param("teamIds") List teamIds);
boolean existsByMemberIdAndTeamId(Long memberId, Long teamId);
+
+ List findAllByMemberIdAndTeamIn(Long memberId, Collection teams);
+
+ List member(Member member);
+
+ void deleteAllByMember(Member member);
}
diff --git a/src/main/java/com/example/Veco/domain/member/entity/Member.java b/src/main/java/com/example/Veco/domain/member/entity/Member.java
index 91c71449..35e70a5d 100644
--- a/src/main/java/com/example/Veco/domain/member/entity/Member.java
+++ b/src/main/java/com/example/Veco/domain/member/entity/Member.java
@@ -10,6 +10,8 @@
import jakarta.validation.constraints.NotNull;
import lombok.*;
+import java.time.LocalDateTime;
+
@Entity
@Table(name = "member")
@Getter
@@ -50,13 +52,24 @@ public class Member extends BaseEntity {
@JoinColumn(name = "workspace_id")
private WorkSpace workSpace;
- @OneToOne(fetch = FetchType.EAGER)
+ @OneToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
@JoinColumn(name = "profile_id", nullable = true)
private Profile profile;
+ @Column(name = "deleted_at")
+ private LocalDateTime deletedAt;
+
+
+
// update
public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public void updateWorkspace(WorkSpace workSpace) { this.workSpace = workSpace; }
+ public void softDelete(){
+ deletedAt = LocalDateTime.now();
+ socialUid = null;
+ workSpace = null;
+ email = "";
+ }
}
diff --git a/src/main/java/com/example/Veco/domain/member/error/MemberErrorStatus.java b/src/main/java/com/example/Veco/domain/member/error/MemberErrorStatus.java
index a900a415..d6a48983 100644
--- a/src/main/java/com/example/Veco/domain/member/error/MemberErrorStatus.java
+++ b/src/main/java/com/example/Veco/domain/member/error/MemberErrorStatus.java
@@ -12,7 +12,10 @@ public enum MemberErrorStatus implements BaseErrorStatus {
_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER4001", "해당 멤버가 없습니다."),
_PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "PROFILE4001", "프로필 정보가 존재하지 않습니다."),
_FORBIDDEN(HttpStatus.FORBIDDEN, "MEMBER403_0", "접근이 금지되었습니다."),
- _PROFILE_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "PROFILE4002", "프로필 이미지가 존재하지 않습니다.")
+ _PROFILE_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "PROFILE4002", "프로필 이미지가 존재하지 않습니다."),
+ _MEMBER_NOT_IN_WORKSPACE(HttpStatus.NOT_FOUND, "MEMBER4002", "멤버가 해당 워크스페이스에 없습니다."),
+ _INVALID_MEMBER_INCLUDE(HttpStatus.NOT_FOUND, "MEMBER4003", "존재하지 않는 멤버가 포함되어 있습니다."),
+ _MEMBER_WORKSPACE_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_3","해당 사용자의 워크스페이스를 찾을 수 없습니다.")
;
private final HttpStatus httpStatus;
diff --git a/src/main/java/com/example/Veco/domain/member/service/MemberCommandService.java b/src/main/java/com/example/Veco/domain/member/service/MemberCommandService.java
index 59028b42..89b61174 100644
--- a/src/main/java/com/example/Veco/domain/member/service/MemberCommandService.java
+++ b/src/main/java/com/example/Veco/domain/member/service/MemberCommandService.java
@@ -1,6 +1,7 @@
package com.example.Veco.domain.member.service;
import com.example.Veco.domain.member.entity.Member;
+import com.example.Veco.global.auth.user.userdetails.CustomUserDetails;
import org.springframework.web.multipart.MultipartFile;
public interface MemberCommandService {
@@ -11,5 +12,9 @@ public interface MemberCommandService {
Member updateProfileImage(MultipartFile file, Member member);
- void deleteProfileImage(Member member);
+ Member deleteProfileImage(Member member);
+
+ Member softDeleteMember(Member member);
+
+ Member withdrawMember(CustomUserDetails customUserDetails);
}
diff --git a/src/main/java/com/example/Veco/domain/member/service/MemberCommandServiceImpl.java b/src/main/java/com/example/Veco/domain/member/service/MemberCommandServiceImpl.java
index 03b2305a..f0eed7bd 100644
--- a/src/main/java/com/example/Veco/domain/member/service/MemberCommandServiceImpl.java
+++ b/src/main/java/com/example/Veco/domain/member/service/MemberCommandServiceImpl.java
@@ -1,26 +1,43 @@
package com.example.Veco.domain.member.service;
+import com.example.Veco.domain.comment.repository.CommentRepository;
+import com.example.Veco.domain.mapping.repository.MemberTeamRepository;
import com.example.Veco.domain.member.entity.Member;
+import com.example.Veco.domain.member.enums.Provider;
import com.example.Veco.domain.member.error.MemberErrorStatus;
import com.example.Veco.domain.member.error.MemberHandler;
import com.example.Veco.domain.member.repository.MemberRepository;
+import com.example.Veco.domain.profile.entity.Profile;
+import com.example.Veco.global.auth.oauth2.service.OAuth2UserService;
+import com.example.Veco.global.auth.user.userdetails.CustomUserDetails;
import com.example.Veco.global.aws.exception.S3Exception;
import com.example.Veco.global.aws.exception.code.S3ErrorCode;
import com.example.Veco.global.aws.util.S3Util;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
+import org.springframework.security.oauth2.core.user.OAuth2User;
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
+@Slf4j
public class MemberCommandServiceImpl implements MemberCommandService {
private final MemberRepository memberRepository;
private final MemberQueryService memberQueryService;
private final S3Util s3Util;
+ private final OAuth2AuthorizedClientService clientService;
+ private final OAuth2UserService oAuth2UserService;
+ private final MemberTeamRepository memberTeamRepository;
+ private final CommentRepository commentRepository;
@Transactional
@@ -47,20 +64,27 @@ public Member updateProfileImage(MultipartFile file, Member member) {
@Transactional
@Override
- public void deleteProfileImage(Member member) {
+ public Member deleteProfileImage(Member member) {
+ Profile profile = member.getProfile();
//프로필 존재 확인
- if (member.getProfile() == null) {
+ if (profile == null) {
throw new MemberHandler(MemberErrorStatus._PROFILE_NOT_FOUND);
}
- if (member.getProfile().getProfileImageUrl() == null) {
+ if (profile.getProfileImageUrl() == null || profile.getProfileImageUrl().isEmpty() || profile.getProfileImageUrl().equals("https://s3.ap-northeast-2.amazonaws.com/s3.veco/default/default-profile.png")) {
throw new S3Exception(MemberErrorStatus._PROFILE_IMAGE_NOT_FOUND);
}
String imageUrl = member.getProfile().getProfileImageUrl();
+
+ if (imageUrl == null || imageUrl.isBlank()) {
+ throw new S3Exception(MemberErrorStatus._PROFILE_IMAGE_NOT_FOUND);
+ }
+
s3Util.deleteFile(imageUrl);
- member.getProfile().updateProfileImageUrl(null);
+ member.getProfile().updateProfileImageUrl("https://s3.ap-northeast-2.amazonaws.com/s3.veco/default/default-profile.png");
+ return member;
}
@Override
@@ -68,4 +92,35 @@ public void deleteProfileImage(Member member) {
public Member saveMember(Member member) {
return memberRepository.save(member);
}
+
+ @Transactional
+ @Override
+ public Member softDeleteMember(Member member) {
+ memberTeamRepository.deleteAllByMember(member);
+ commentRepository.deleteAllByMember(member);
+ member.softDelete();
+ return memberRepository.save(member);
+ }
+
+ @Override
+ @Transactional
+ public Member withdrawMember(CustomUserDetails customUserDetails) {
+
+ Member member = memberRepository.findBySocialUid(customUserDetails.getSocialUid())
+ .orElseThrow(() -> new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND));
+
+ Provider provider = member.getProvider();
+
+ if (provider.equals(Provider.GOOGLE)) {
+ oAuth2UserService.unlinkGoogleAccess(customUserDetails);
+ } else if (provider.equals(Provider.KAKAO)) {
+ oAuth2UserService.unlinkKakaoAccount(Long.parseLong(customUserDetails.getSocialUid()));
+ }
+
+ // Spring Security에서 인증된 클라이언트 정보 제거
+ clientService.removeAuthorizedClient(provider.toString().toLowerCase(), customUserDetails.getUsername());
+
+ // DB에서 사용자 정보 삭제 (soft delete)
+ return softDeleteMember(member);
+ }
}
diff --git a/src/main/java/com/example/Veco/domain/notification/controller/NotiController.java b/src/main/java/com/example/Veco/domain/notification/controller/NotiController.java
index 5f4cd188..4d388c3d 100644
--- a/src/main/java/com/example/Veco/domain/notification/controller/NotiController.java
+++ b/src/main/java/com/example/Veco/domain/notification/controller/NotiController.java
@@ -1,30 +1,15 @@
package com.example.Veco.domain.notification.controller;
-import com.example.Veco.domain.goal.entity.Goal;
-import com.example.Veco.domain.goal.exception.GoalException;
-import com.example.Veco.domain.goal.exception.code.GoalErrorCode;
-import com.example.Veco.domain.issue.converter.IssueConverter;
-import com.example.Veco.domain.issue.dto.IssueReqDTO;
-import com.example.Veco.domain.issue.dto.IssueResponseDTO;
-import com.example.Veco.domain.mapping.entity.MemberTeam;
-import com.example.Veco.domain.member.entity.Member;
-import com.example.Veco.domain.member.error.MemberErrorStatus;
-import com.example.Veco.domain.member.error.MemberHandler;
import com.example.Veco.domain.notification.dto.NotiResDTO.*;
import com.example.Veco.domain.notification.exception.code.NotiSuccessCode;
import com.example.Veco.domain.notification.service.NotiQueryService;
-import com.example.Veco.domain.team.exception.TeamException;
-import com.example.Veco.domain.team.exception.code.TeamErrorCode;
import com.example.Veco.global.apiPayload.ApiResponse;
import com.example.Veco.global.auth.user.AuthUser;
import com.example.Veco.global.enums.Category;
-import com.example.Veco.global.redis.exception.RedisException;
-import com.example.Veco.global.redis.exception.code.RedisErrorCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
-import org.redisson.api.RLock;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
diff --git a/src/main/java/com/example/Veco/domain/notification/converter/NotiConverter.java b/src/main/java/com/example/Veco/domain/notification/converter/NotiConverter.java
index 670fa6dc..7423d40d 100644
--- a/src/main/java/com/example/Veco/domain/notification/converter/NotiConverter.java
+++ b/src/main/java/com/example/Veco/domain/notification/converter/NotiConverter.java
@@ -188,7 +188,7 @@ public ExternalPreViewDTO toExternalPreViewDTO(External external, MemberNotifica
.priority(external.getPriority())
.goalTitle(external.getGoal().getTitle())
.managerList(toManagerInfoList(assignees))
- .externalTool(external.getType())
+ .extServiceType(external.getType())
.isRead(memberNoti.getIsRead())
.build();
}
@@ -220,7 +220,7 @@ public GroupedNotiList toExternalPreviewListByGoal(List toExternalPreviewListByExternal(List list, LocalDate deadline) {
List order = Arrays.asList(ExtServiceType.NONE, ExtServiceType.SLACK, ExtServiceType.GITHUB, ExtServiceType.NOTION);
- return buildGroupedList(Category.EXTERNAL, deadline, groupByEnum(list, ExternalPreViewDTO::getExternalTool, order, ExternalPreViewDTO::getAlarmId), list.size());
+ return buildGroupedList(Category.EXTERNAL, deadline, groupByEnum(list, ExternalPreViewDTO::getExtServiceType, order, ExternalPreViewDTO::getAlarmId), list.size());
}
}
\ No newline at end of file
diff --git a/src/main/java/com/example/Veco/domain/notification/dto/NotiResDTO.java b/src/main/java/com/example/Veco/domain/notification/dto/NotiResDTO.java
index f7de4102..b892582c 100644
--- a/src/main/java/com/example/Veco/domain/notification/dto/NotiResDTO.java
+++ b/src/main/java/com/example/Veco/domain/notification/dto/NotiResDTO.java
@@ -90,7 +90,7 @@ public static class ExternalPreViewDTO {
private Priority priority;
private String goalTitle;
private List managerList;
- private ExtServiceType externalTool;
+ private ExtServiceType extServiceType;
private boolean isRead;
}
diff --git a/src/main/java/com/example/Veco/domain/profile/entity/Profile.java b/src/main/java/com/example/Veco/domain/profile/entity/Profile.java
index 61b1b2db..68ee1866 100644
--- a/src/main/java/com/example/Veco/domain/profile/entity/Profile.java
+++ b/src/main/java/com/example/Veco/domain/profile/entity/Profile.java
@@ -20,8 +20,9 @@ public class Profile extends BaseEntity {
@Column(name = "name")
private String name;
+ @Builder.Default
@Column(name = "profile_image_url")
- private String profileImageUrl;
+ private String profileImageUrl = "https://s3.ap-northeast-2.amazonaws.com/s3.veco/default/default-profile.png";
public void updateProfileImageUrl(String profileImageUrl) {
this.profileImageUrl = profileImageUrl;
diff --git a/src/main/java/com/example/Veco/domain/slack/controller/SlackController.java b/src/main/java/com/example/Veco/domain/slack/controller/SlackController.java
index ee83e157..ff103e40 100644
--- a/src/main/java/com/example/Veco/domain/slack/controller/SlackController.java
+++ b/src/main/java/com/example/Veco/domain/slack/controller/SlackController.java
@@ -1,7 +1,5 @@
package com.example.Veco.domain.slack.controller;
-import com.example.Veco.domain.slack.dto.SlackResDTO;
-import com.example.Veco.domain.slack.exception.code.SlackSuccessCode;
import com.example.Veco.domain.slack.service.SlackCommandService;
import com.example.Veco.global.apiPayload.ApiResponse;
import io.swagger.v3.oas.annotations.Hidden;
@@ -9,6 +7,8 @@
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.view.RedirectView;
@@ -29,20 +29,23 @@ public class SlackController {
"요청이 오면 Slack OAuth 화면으로 리다이렉트되는 방식입니다."
)
@GetMapping("/connect")
- public RedirectView slackConnect(
+ public ApiResponse slackConnect(
@RequestHeader("Authorization") @Parameter(hidden = true)
- String token
+ String token,
+ @AuthenticationPrincipal UserDetails user
){
- return slackCommandService.redirectSlackOAuth(token);
+ return ApiResponse.onSuccess(slackCommandService.redirectSlackOAuth(token, user));
}
// Slack Callback: 비즈니스 로직 시작 지점
@Hidden
@GetMapping("/callback")
- public ApiResponse installApp(
+ public RedirectView installApp(
@RequestParam String code,
@RequestParam String state
){
- return ApiResponse.onSuccess(SlackSuccessCode.CONNECTING, slackCommandService.installApp(code, state));
+ Long teamId = slackCommandService.installApp(code, state);
+ String URL = "http://localhost:5173/slack/complete?teamId="+teamId;
+ return new RedirectView(URL);
}
}
diff --git a/src/main/java/com/example/Veco/domain/slack/exception/code/SlackErrorCode.java b/src/main/java/com/example/Veco/domain/slack/exception/code/SlackErrorCode.java
index e5e5fcdb..eec6515b 100644
--- a/src/main/java/com/example/Veco/domain/slack/exception/code/SlackErrorCode.java
+++ b/src/main/java/com/example/Veco/domain/slack/exception/code/SlackErrorCode.java
@@ -22,6 +22,9 @@ public enum SlackErrorCode implements BaseErrorStatus {
JOIN_FAILED(HttpStatus.BAD_REQUEST,
"SLACK400_3",
"기본 채널 참여에 실패했습니다."),
+ NOT_LINKED(HttpStatus.BAD_REQUEST,
+ "SLACK400_4",
+ "해당 워크스페이스가 Slack과 연동되어 있지 않습니다."),
REINSTALL(HttpStatus.UNAUTHORIZED,
"SLACK401_0",
"Slack Access Token이 만료되었습니다. App을 재설치해야 합니다."),
diff --git a/src/main/java/com/example/Veco/domain/slack/service/SlackCommandService.java b/src/main/java/com/example/Veco/domain/slack/service/SlackCommandService.java
index 50f3c474..9dcfad26 100644
--- a/src/main/java/com/example/Veco/domain/slack/service/SlackCommandService.java
+++ b/src/main/java/com/example/Veco/domain/slack/service/SlackCommandService.java
@@ -10,7 +10,6 @@
import com.example.Veco.domain.member.error.MemberErrorStatus;
import com.example.Veco.domain.member.error.MemberHandler;
import com.example.Veco.domain.member.repository.MemberRepository;
-import com.example.Veco.domain.slack.converter.SlackConverter;
import com.example.Veco.domain.slack.dto.SlackResDTO;
import com.example.Veco.domain.slack.exception.SlackException;
import com.example.Veco.domain.slack.exception.code.SlackErrorCode;
@@ -21,9 +20,9 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import org.springframework.web.servlet.view.RedirectView;
import java.time.LocalDateTime;
import java.util.Objects;
@@ -50,25 +49,35 @@ public class SlackCommandService {
private String scope;
// 리다이렉트 링크 생성
- public RedirectView redirectSlackOAuth(
- String token
+ public String redirectSlackOAuth(
+ String token,
+ UserDetails user
){
+
// 토큰 Bearer 제거
token = token.replace("Bearer ", "");
- String url = "https://slack.com/oauth/v2/authorize?" +
+ // 로그
+ log.info("[ .yml 파싱한 Client ID ]:{}", clientId);
+
+ // 해당 사용자가 특정 워크스페이스에 속해있는지 검증
+ String uid = user.getUsername();
+ Member member = memberRepository.findBySocialUid(uid)
+ .orElseThrow(() -> new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND));
+
+ if (member.getWorkSpace() == null) {
+ throw new MemberHandler(MemberErrorStatus._MEMBER_WORKSPACE_NOT_FOUND);
+ }
+
+ return "https://slack.com/oauth/v2/authorize?" +
"client_id="+clientId+
"&scope="+scope+
"&state="+token;
-
- RedirectView redirectView = new RedirectView();
- redirectView.setUrl(url);
- return redirectView;
}
// Slack 연동 비즈니스 로직
@Transactional
- public SlackResDTO.InstallApp installApp(
+ public Long installApp(
String code,
String state
){
@@ -77,6 +86,9 @@ public SlackResDTO.InstallApp installApp(
Member member = memberRepository.findBySocialUid(jwtUtil.getUsername(state))
.orElseThrow(() -> new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND));
+ // 워크스페이스에 속해있는지 확인
+ WorkSpace workspace = member.getWorkSpace();
+
// Bot Access Token 발급
SlackResDTO.ExchangeAccessToken tokenResult = slackUtil.ExchangeAccessToken(code);
@@ -122,8 +134,6 @@ public SlackResDTO.InstallApp installApp(
}
// 조회한 정보들 모두 저장 (연동)
- WorkSpace workspace = member.getWorkSpace();
-
// 이미 존재하면 update
Optional link = linkRepository.findLinkByWorkspaceAndExternalService_ServiceType(
workspace, ExtServiceType.SLACK
@@ -134,9 +144,6 @@ public SlackResDTO.InstallApp installApp(
link.get().getExternalService().updateAccessToken(tokenResult.access_token());
link.get().updateLinkedAt(now);
- return SlackConverter.toInstallApp(
- link.get().getWorkspace().getId(), link.get().getLinkedAt()
- );
} else { // 존재하지 않는 경우
// 객체 생성
@@ -149,9 +156,11 @@ public SlackResDTO.InstallApp installApp(
Link newLink = LinkConverter.toLink(now, workspace, externalService);
externalServiceRepository.save(externalService);
- Link result = linkRepository.save(newLink);
+ linkRepository.save(newLink);
- return SlackConverter.toInstallApp(result.getWorkspace().getId(), result.getLinkedAt());
}
+
+ // 기본 팀 ID로 리다이렉트
+ return workspace.getTeams().getFirst().getId();
}
}
diff --git a/src/main/java/com/example/Veco/domain/team/controller/NumberSequenceController.java b/src/main/java/com/example/Veco/domain/team/controller/NumberSequenceController.java
index ce7d9312..b1e67bc3 100644
--- a/src/main/java/com/example/Veco/domain/team/controller/NumberSequenceController.java
+++ b/src/main/java/com/example/Veco/domain/team/controller/NumberSequenceController.java
@@ -1,9 +1,9 @@
package com.example.Veco.domain.team.controller;
import com.example.Veco.domain.team.dto.NumberSequenceResponseDTO;
-import com.example.Veco.domain.team.enums.Category;
import com.example.Veco.domain.team.service.NumberSequenceService;
import com.example.Veco.global.apiPayload.ApiResponse;
+import com.example.Veco.global.enums.Category;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
diff --git a/src/main/java/com/example/Veco/domain/team/converter/NumberSequenceConverter.java b/src/main/java/com/example/Veco/domain/team/converter/NumberSequenceConverter.java
index d1399827..3073ff08 100644
--- a/src/main/java/com/example/Veco/domain/team/converter/NumberSequenceConverter.java
+++ b/src/main/java/com/example/Veco/domain/team/converter/NumberSequenceConverter.java
@@ -3,7 +3,7 @@
import com.example.Veco.domain.team.dto.NumberSequenceResponseDTO;
import com.example.Veco.domain.team.entity.NumberSequence;
import com.example.Veco.domain.team.entity.Team;
-import com.example.Veco.domain.team.enums.Category;
+import com.example.Veco.global.enums.Category;
public class NumberSequenceConverter {
diff --git a/src/main/java/com/example/Veco/domain/team/dto/NumberSequenceResponseDTO.java b/src/main/java/com/example/Veco/domain/team/dto/NumberSequenceResponseDTO.java
index 878d4a2f..8c57d192 100644
--- a/src/main/java/com/example/Veco/domain/team/dto/NumberSequenceResponseDTO.java
+++ b/src/main/java/com/example/Veco/domain/team/dto/NumberSequenceResponseDTO.java
@@ -1,6 +1,6 @@
package com.example.Veco.domain.team.dto;
-import com.example.Veco.domain.team.enums.Category;
+import com.example.Veco.global.enums.Category;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
diff --git a/src/main/java/com/example/Veco/domain/team/entity/NumberSequence.java b/src/main/java/com/example/Veco/domain/team/entity/NumberSequence.java
index 4988b929..4d949d0a 100644
--- a/src/main/java/com/example/Veco/domain/team/entity/NumberSequence.java
+++ b/src/main/java/com/example/Veco/domain/team/entity/NumberSequence.java
@@ -1,6 +1,6 @@
package com.example.Veco.domain.team.entity;
-import com.example.Veco.domain.team.enums.Category;
+import com.example.Veco.global.enums.Category;
import jakarta.persistence.*;
import lombok.*;
diff --git a/src/main/java/com/example/Veco/domain/team/entity/Team.java b/src/main/java/com/example/Veco/domain/team/entity/Team.java
index 14eb6d72..1c81a597 100644
--- a/src/main/java/com/example/Veco/domain/team/entity/Team.java
+++ b/src/main/java/com/example/Veco/domain/team/entity/Team.java
@@ -27,8 +27,9 @@ public class Team extends BaseEntity {
@Column(name = "name")
private String name;
+ @Builder.Default
@Column(name = "profile_url")
- private String profileUrl;
+ private String profileUrl = "https://s3.ap-northeast-2.amazonaws.com/s3.veco/default/default-team.png";
@Column(name = "goal_number")
private Long goalNumber;
diff --git a/src/main/java/com/example/Veco/domain/team/exception/code/TeamErrorCode.java b/src/main/java/com/example/Veco/domain/team/exception/code/TeamErrorCode.java
index ad353e98..29394a9a 100644
--- a/src/main/java/com/example/Veco/domain/team/exception/code/TeamErrorCode.java
+++ b/src/main/java/com/example/Veco/domain/team/exception/code/TeamErrorCode.java
@@ -9,8 +9,10 @@
public enum TeamErrorCode implements BaseErrorStatus {
_NOT_FOUND(HttpStatus.NOT_FOUND, "TEAM404_0", "해당 팀을 찾을 수 없습니다."),
- _TEAM_NOT_IN_WORKSPACE(HttpStatus.BAD_REQUEST, "TEAM404_1", "팀이 해당 워크스페이스에 없습니다."),
- _TEAM_COUNT_MISMATCH(HttpStatus.BAD_REQUEST, "TEAM404_2", "요청한 팀 개수와 워크스페이스의 팀 개수가 일치하지 않습니다.")
+ _TEAM_NOT_IN_WORKSPACE(HttpStatus.NOT_FOUND, "TEAM404_1", "팀이 해당 워크스페이스에 없습니다."),
+ _TEAM_COUNT_MISMATCH(HttpStatus.BAD_REQUEST, "TEAM404_2", "요청한 팀 개수와 워크스페이스의 팀 개수가 일치하지 않습니다."),
+ _DUPLICATE_TEAM_NAME(HttpStatus.BAD_REQUEST, "TEAM404_3", "중복된 팀 이름입니다."),
+ _DEFAULT_TEAM_MUST_BE_FIRST(HttpStatus.BAD_REQUEST, "TEAM404_4", "기본팀은 항상 첫번째 순서입니다.")
;
private final HttpStatus httpStatus;
diff --git a/src/main/java/com/example/Veco/domain/team/repository/NumberSequenceRepository.java b/src/main/java/com/example/Veco/domain/team/repository/NumberSequenceRepository.java
index 02deff36..8fbde759 100644
--- a/src/main/java/com/example/Veco/domain/team/repository/NumberSequenceRepository.java
+++ b/src/main/java/com/example/Veco/domain/team/repository/NumberSequenceRepository.java
@@ -1,7 +1,7 @@
package com.example.Veco.domain.team.repository;
import com.example.Veco.domain.team.entity.NumberSequence;
-import com.example.Veco.domain.team.enums.Category;
+import com.example.Veco.global.enums.Category;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
diff --git a/src/main/java/com/example/Veco/domain/team/repository/TeamRepository.java b/src/main/java/com/example/Veco/domain/team/repository/TeamRepository.java
index cd24c599..b395a3e3 100644
--- a/src/main/java/com/example/Veco/domain/team/repository/TeamRepository.java
+++ b/src/main/java/com/example/Veco/domain/team/repository/TeamRepository.java
@@ -5,6 +5,8 @@
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;
+import org.springframework.data.repository.query.Param;
import java.util.Optional;
@@ -16,4 +18,14 @@ public interface TeamRepository extends JpaRepository {
Team findFirstByWorkSpaceOrderById(WorkSpace workSpace);
int countByWorkSpace(WorkSpace workSpace);
+
+ boolean existsByName(String name);
+
+ boolean existsByNameAndWorkSpace(String name, WorkSpace workSpace);
+
+ @Query("select min(t.id) from Team t where t.workSpace = :workspace")
+ Long findMinTeamIdByWorkSpace(@Param("workspace") WorkSpace workspace);
+
+ @Query("select max(t.order) from Team t where t.workSpace = :workspace")
+ Optional findMaxOrderByWorkSpace(@Param("workspace") WorkSpace workSpace);
}
diff --git a/src/main/java/com/example/Veco/domain/team/service/NumberSequenceService.java b/src/main/java/com/example/Veco/domain/team/service/NumberSequenceService.java
index d8c35fbf..bc121809 100644
--- a/src/main/java/com/example/Veco/domain/team/service/NumberSequenceService.java
+++ b/src/main/java/com/example/Veco/domain/team/service/NumberSequenceService.java
@@ -4,9 +4,9 @@
import com.example.Veco.domain.team.dto.NumberSequenceResponseDTO;
import com.example.Veco.domain.team.entity.NumberSequence;
import com.example.Veco.domain.team.entity.Team;
-import com.example.Veco.domain.team.enums.Category;
import com.example.Veco.domain.team.repository.NumberSequenceRepository;
import com.example.Veco.domain.team.repository.TeamRepository;
+import com.example.Veco.global.enums.Category;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.retry.annotation.Backoff;
diff --git a/src/main/java/com/example/Veco/domain/workspace/controller/SettingRestController.java b/src/main/java/com/example/Veco/domain/workspace/controller/SettingRestController.java
index bc166369..fa670513 100644
--- a/src/main/java/com/example/Veco/domain/workspace/controller/SettingRestController.java
+++ b/src/main/java/com/example/Veco/domain/workspace/controller/SettingRestController.java
@@ -3,26 +3,29 @@
import com.example.Veco.domain.member.converter.MemberConverter;
import com.example.Veco.domain.member.dto.MemberResponseDTO;
import com.example.Veco.domain.member.entity.Member;
-import com.example.Veco.domain.member.repository.MemberRepository;
import com.example.Veco.domain.member.service.MemberCommandService;
import com.example.Veco.domain.member.service.MemberQueryService;
-import com.example.Veco.domain.team.service.TeamQueryService;
import com.example.Veco.domain.workspace.converter.WorkspaceConverter;
import com.example.Veco.domain.workspace.dto.WorkspaceRequestDTO;
import com.example.Veco.domain.workspace.dto.WorkspaceResponseDTO;
import com.example.Veco.domain.workspace.entity.WorkSpace;
+import com.example.Veco.domain.workspace.error.SettingSuccessCode;
import com.example.Veco.domain.workspace.service.WorkspaceCommandService;
import com.example.Veco.domain.workspace.service.WorkspaceQueryService;
import com.example.Veco.global.apiPayload.ApiResponse;
+import com.example.Veco.global.auth.jwt.util.JwtUtil;
import com.example.Veco.global.auth.user.userdetails.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;
+import org.springframework.http.ResponseCookie;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -38,14 +41,15 @@
public class SettingRestController {
private final WorkspaceQueryService workspaceQueryService;
- private final TeamQueryService teamQueryService;
private final WorkspaceCommandService workspaceCommandService;
- private final MemberRepository memberRepository;
private final MemberQueryService memberQueryService;
private final MemberCommandService memberCommandService;
+ private final JwtUtil jwtUtil;
/**
* 유저 프로필 조회 API
+ * - 로그인된 유저의 프로필 정보를 조회
+ * - 응답에 profileImage가 null인 경우 프론트에서 기본 이미지 사용
*/
@GetMapping("/setting/my-profile")
@Operation(
@@ -54,8 +58,7 @@ public class SettingRestController {
public ApiResponse getProfile(
@AuthenticationPrincipal CustomUserDetails userDetails
) {
- String socialUid = userDetails.getSocialUid();
- Member member = memberQueryService.getMemberBySocialUid(socialUid);
+ Member member = memberQueryService.getMemberBySocialUid(userDetails.getSocialUid());
return ApiResponse.onSuccess(MemberConverter.toProfileResponseDTO(member));
}
@@ -64,14 +67,13 @@ public ApiResponse getProfile(
* 유저 프로필 이미지 수정 API
* - 로그인된 유저의 프로필 이미지를 MulipartFile로 수정
*/
- @PatchMapping(value = "/setting/my-profile/profileImage", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @PatchMapping(value = "/setting/my-profile/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "유저의 프로필 이미지를 수정합니다.")
public ApiResponse patchProfileImage(
@RequestParam MultipartFile image,
@AuthenticationPrincipal CustomUserDetails userDetails
) {
- String socialUid = userDetails.getSocialUid();
- Member member = memberQueryService.getMemberBySocialUid(socialUid);
+ Member member = memberQueryService.getMemberBySocialUid(userDetails.getSocialUid());
Member member1 = memberCommandService.updateProfileImage(image, member);
return ApiResponse.onSuccess(MemberConverter.toMemberProfileImageResponseDTO(member1));
@@ -80,14 +82,13 @@ public ApiResponse patchProfile
/**
* 유저 프로필 이미지 삭제 API
*/
- @DeleteMapping("/setting/my-profile/profileImage")
+ @DeleteMapping("/setting/my-profile/profile-image")
@Operation(summary = "유저의 프로필 이미지를 삭제합니다.")
- public ApiResponse deleteProfileImage(@AuthenticationPrincipal CustomUserDetails userDetails) {
- String socialUid = userDetails.getSocialUid();
- Member member = memberQueryService.getMemberBySocialUid(socialUid);
+ public ApiResponse deleteProfileImage(@AuthenticationPrincipal CustomUserDetails userDetails) {
+ Member member = memberQueryService.getMemberBySocialUid(userDetails.getSocialUid());
- memberCommandService.deleteProfileImage(member);
- return ApiResponse.onSuccess(null);
+ Member member1 = memberCommandService.deleteProfileImage(member);
+ return ApiResponse.onSuccess(MemberConverter.toMemberProfileImageResponseDTO(member1));
}
/**
@@ -99,8 +100,7 @@ public ApiResponse deleteProfileImage(@AuthenticationPrincipal CustomUserD
public ApiResponse getWorkspaceInfo(
@AuthenticationPrincipal CustomUserDetails userDetails // 로그인된 사용자 정보
) {
- String socialUid = userDetails.getSocialUid();
- Member member = memberQueryService.getMemberBySocialUid(socialUid);
+ Member member = memberQueryService.getMemberBySocialUid(userDetails.getSocialUid());
WorkSpace workspace = workspaceQueryService.getWorkSpaceByMember(member); // 워크스페이스 조회
return ApiResponse.onSuccess(WorkspaceConverter.toWorkspaceResponse(workspace));
@@ -118,10 +118,7 @@ public ApiResponse getTeamList(
@RequestParam(defaultValue = "0") int page, // 페이지 번호 (기본 0)
@RequestParam(defaultValue = "20") int size // 페이지 크기 (기본 20)
) {
- String socialUid = userDetails.getSocialUid();
- Member member = memberQueryService.getMemberBySocialUid(socialUid);
-
- WorkSpace workspace = workspaceQueryService.getWorkSpaceByMember(member);
+ WorkSpace workspace = workspaceQueryService.getWorkspaceBySocialUid(userDetails.getSocialUid());
Pageable pageable = PageRequest.of(page, size, Sort.by("order").ascending()); // order를 기준으로 페이지 설정
WorkspaceResponseDTO.WorkspaceTeamListDto result =
@@ -140,11 +137,7 @@ public ApiResponse createTeam(
@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestBody @Valid WorkspaceRequestDTO.CreateTeamRequestDto request // 팀 이름 + 멤버 ID 리스트
) {
-
- String socialUid = userDetails.getSocialUid();
- Member member = memberQueryService.getMemberBySocialUid(socialUid);
-
- WorkSpace workspace = workspaceQueryService.getWorkSpaceByMember(member);
+ WorkSpace workspace = workspaceQueryService.getWorkspaceBySocialUid(userDetails.getSocialUid());
WorkspaceResponseDTO.CreateTeamResponseDto response =
workspaceCommandService.createTeam(workspace, request); // 팀 생성
@@ -160,9 +153,7 @@ public ApiResponse createTeam(
@Operation(summary = "워크스페이스 내의 멤버 정보를 조회합니다.")
public ApiResponse> getWorkspaceMembers(
@AuthenticationPrincipal CustomUserDetails userDetails) {
-
- String socialUid = userDetails.getSocialUid();
- Member member = memberQueryService.getMemberBySocialUid(socialUid);
+ Member member = memberQueryService.getMemberBySocialUid(userDetails.getSocialUid());
List result =
workspaceQueryService.getWorkspaceMembers(member);
@@ -171,7 +162,10 @@ public ApiResponse> getWo
}
/**
- *
+ * 사이드 바 팀 목록 순서 수정 API
+ * - 사이드바에서 표시된 팀들의 순서를 수정
+ * - 요청으로 전달받은 팀 ID 리스트 순서대로 정렬
+ * - 실제 팀 개수와 요청으로 전달받은 팀 개수가 같아야함
*/
@PatchMapping("/setting/teams")
@Operation(summary = "사이드 바의 팀 목록 순서를 수정합니다.")
@@ -179,27 +173,50 @@ public ApiResponse updateTeamOrder(
@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestBody @Valid WorkspaceRequestDTO.TeamOrderRequestDto request
) {
- String socialUid = userDetails.getSocialUid();
- Member member = memberQueryService.getMemberBySocialUid(socialUid);
- WorkSpace workspace = workspaceQueryService.getWorkSpaceByMember(member);
+ WorkSpace workspace = workspaceQueryService.getWorkspaceBySocialUid(userDetails.getSocialUid());
workspaceCommandService.updateTeamOrder(workspace, request.getTeamIdList());
return ApiResponse.onSuccess(null);
}
+ /**
+ * 워크스페이스 초대 정보 조회 API
+ * - 초대 링크와 암호를 반환
+ */
@GetMapping("/setting/invite")
@Operation(
summary = "워크스페이스에 팀원을 초대합니다.",
description = "초대링크와 암호를 보여줍니다."
)
public ApiResponse inviteWorkspace(
- @AuthenticationPrincipal CustomUserDetails user
- ){
- String socialUid = user.getSocialUid();
- Member member = memberQueryService.getMemberBySocialUid(socialUid);
- WorkSpace workspace = workspaceQueryService.getWorkSpaceByMember(member);
+ @AuthenticationPrincipal CustomUserDetails userDetails
+ ) {
+ Member member = memberQueryService.getMemberBySocialUid(userDetails.getSocialUid());
+ WorkSpace workspace = workspaceQueryService.getWorkspaceBySocialUid(userDetails.getSocialUid());
return ApiResponse.onSuccess(WorkspaceConverter.toInviteInfoResponseDto(workspace, member));
}
+
+ @DeleteMapping("/setting/my-profile")
+ @Operation(summary = "유저의 계정을 삭제합니다.")
+ public ApiResponse> softDeleteMember(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ @AuthenticationPrincipal CustomUserDetails userDetails
+ ) {
+ Member member = memberQueryService.getMemberBySocialUid(userDetails.getSocialUid());
+ memberCommandService.withdrawMember(userDetails);
+
+ // 액세스 토큰 블랙리스트 처리
+ String token = request.getHeader("Authorization")
+ .replace("Bearer ", "");
+ jwtUtil.setBlackList(token);
+
+ // 리프레쉬 토큰 쿠키 삭제
+ ResponseCookie refreshTokenCookie = jwtUtil.expireRefreshTokenCookie();
+ response.addHeader("Set-Cookie", refreshTokenCookie.toString());
+
+ return ApiResponse.onSuccess(SettingSuccessCode.DELETE, null);
+ }
}
diff --git a/src/main/java/com/example/Veco/domain/workspace/controller/WorkspaceRestController.java b/src/main/java/com/example/Veco/domain/workspace/controller/WorkspaceRestController.java
index 2c8481ef..f97faceb 100644
--- a/src/main/java/com/example/Veco/domain/workspace/controller/WorkspaceRestController.java
+++ b/src/main/java/com/example/Veco/domain/workspace/controller/WorkspaceRestController.java
@@ -6,11 +6,11 @@
import com.example.Veco.domain.workspace.dto.WorkspaceRequestDTO;
import com.example.Veco.domain.workspace.dto.WorkspaceResponseDTO;
import com.example.Veco.domain.workspace.dto.WorkspaceResponseDTO.JoinWorkspace;
-import com.example.Veco.domain.workspace.entity.WorkSpace;
import com.example.Veco.domain.workspace.error.WorkspaceSuccessCode;
import com.example.Veco.domain.workspace.service.WorkspaceCommandService;
import com.example.Veco.domain.workspace.service.WorkspaceQueryService;
import com.example.Veco.global.apiPayload.ApiResponse;
+import com.example.Veco.global.auth.user.AuthUser;
import com.example.Veco.global.auth.user.userdetails.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -33,14 +33,14 @@ public class WorkspaceRestController {
@PostMapping("/create-url")
@Operation(summary = "워크스페이스 이름에 맞는 url를 미리보기합니다.")
- public ApiResponse createWorkspaceUrl(@Valid @RequestBody WorkspaceRequestDTO.PreviewUrlRequestDto request) {
+ public ApiResponse createWorkspaceUrl(@Valid @RequestBody WorkspaceRequestDTO.WorkspaceRequestDto request) {
String previewUrl = workspaceQueryService.createPreviewUrl(request.getWorkspaceName());
return ApiResponse.onSuccess(WorkspaceConverter.toPreviewUrlResponseDto(previewUrl));
}
@PostMapping("")
@Operation(summary = "워크스페이스를 생성합니다.")
- public ApiResponse createWorkspace(@AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody WorkspaceRequestDTO.CreateWorkspaceRequestDto request) {
+ public ApiResponse createWorkspace(@AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody WorkspaceRequestDTO.WorkspaceRequestDto request) {
String socialUid = userDetails.getSocialUid();
Member member = memberQueryService.getMemberBySocialUid(socialUid);
@@ -62,4 +62,16 @@ public ApiResponse joinWorkspace(
workspaceCommandService.joinWorkspace(dto, user)
);
}
+
+ // 워크스페이스 연동 해제
+ @Operation(
+ summary = "워크스페이스 연동 해제 (테스트용) API By 김주헌",
+ description = "계정과 연동되어 있는 워크스페이스를 해제합니다. (워크스페이스ID null 처리)"
+ )
+ @PostMapping("/unlinked")
+ public ApiResponse