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 연동 +--- +## 🛜 서버 현황 +스크린샷 2025-08-09 04 46 30 + +- 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 unlinkWorkspace( + @AuthenticationPrincipal AuthUser user + ){ + return ApiResponse.onSuccess(workspaceCommandService.unlinkWorkspace(user)); + } } diff --git a/src/main/java/com/example/Veco/domain/workspace/converter/WorkspaceConverter.java b/src/main/java/com/example/Veco/domain/workspace/converter/WorkspaceConverter.java index 8917a8b3..303f87b0 100644 --- a/src/main/java/com/example/Veco/domain/workspace/converter/WorkspaceConverter.java +++ b/src/main/java/com/example/Veco/domain/workspace/converter/WorkspaceConverter.java @@ -23,6 +23,7 @@ public static WorkspaceResponseDTO.WorkspaceResponseDto toWorkspaceResponse(Work .workspaceImageUrl(workspace.getProfileUrl()) .workspaceUrl(workspace.getWorkspaceUrl()) .defaultTeamId(workspace.getTeams().get(0).getId()) + .invitePassword(workspace.getInvitePassword()) .build(); } @@ -33,6 +34,7 @@ public static WorkspaceResponseDTO.WorkspaceTeamDto toWorkspaceTeamDto(Team team return WorkspaceResponseDTO.WorkspaceTeamDto.builder() .teamId(team.getId()) .teamName(team.getName()) + .teamImageUrl(team.getProfileUrl()) .memberCount(memberCount) .createdAt(team.getCreatedAt()) .build(); @@ -90,12 +92,18 @@ public static WorkspaceResponseDTO.JoinWorkspace toJoinWorkspace( .build(); } + /** + * 워크스페이스 URL 미리보기 응답 DTO 변환 + */ public static WorkspaceResponseDTO.PreviewUrlResponseDto toPreviewUrlResponseDto(String previewUrl) { return WorkspaceResponseDTO.PreviewUrlResponseDto.builder() .workspaceUrl(previewUrl) .build(); } + /** + * 워크스페이스 초대 정보 응답 DTO 변환 + */ public static WorkspaceResponseDTO.InviteInfoResponseDto toInviteInfoResponseDto(WorkSpace workspace, Member member) { return WorkspaceResponseDTO.InviteInfoResponseDto.builder() .name(member.getName()) @@ -103,4 +111,31 @@ public static WorkspaceResponseDTO.InviteInfoResponseDto toInviteInfoResponseDto .invitePassword(workspace.getInvitePassword()) .build(); } + + /** + * 워크스페이스 내 멤버 + 소속 팀 리스트 응답 DTO 변환 + */ + public static WorkspaceResponseDTO.WorkspaceMemberWithTeamsDto + toWorkspaceMemberWithTeamsDto( + Member member, + List teams, + LocalDateTime joinedAt + ) { + List teamDtos = teams.stream() + .map(team -> WorkspaceResponseDTO.WorkspaceMemberWithTeamsDto.TeamInfoDto.builder() + .teamId(team.getId()) + .teamName(team.getName()) + .teamImageUrl(team.getProfileUrl()) + .build()) + .toList(); + + return WorkspaceResponseDTO.WorkspaceMemberWithTeamsDto.builder() + .memberId(member.getId()) + .email(member.getEmail()) + .name(member.getName()) + .profileImageUrl(member.getProfile().getProfileImageUrl()) + .teams(teamDtos) + .joinedAt(joinedAt) + .build(); + } } diff --git a/src/main/java/com/example/Veco/domain/workspace/dto/TeamMemberCountDto.java b/src/main/java/com/example/Veco/domain/workspace/dto/TeamMemberCountDto.java index 1f71ce04..dd5b9135 100644 --- a/src/main/java/com/example/Veco/domain/workspace/dto/TeamMemberCountDto.java +++ b/src/main/java/com/example/Veco/domain/workspace/dto/TeamMemberCountDto.java @@ -4,6 +4,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; +/** + * WorkspaceMemberWithTeamsDto 내 TeamInfoDto에 추가됨 + */ @Getter @Builder @NoArgsConstructor diff --git a/src/main/java/com/example/Veco/domain/workspace/dto/WorkspaceRequestDTO.java b/src/main/java/com/example/Veco/domain/workspace/dto/WorkspaceRequestDTO.java index cff003f8..86e2fc24 100644 --- a/src/main/java/com/example/Veco/domain/workspace/dto/WorkspaceRequestDTO.java +++ b/src/main/java/com/example/Veco/domain/workspace/dto/WorkspaceRequestDTO.java @@ -2,7 +2,6 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; @@ -31,38 +30,33 @@ public static class CreateTeamRequestDto { } /** - * + * 팀 순서 변경 요청 DTO + * - 프론트에서 변경된 팀 순서를 리스트로 전달 */ @Builder @Getter @NoArgsConstructor @AllArgsConstructor public static class TeamOrderRequestDto { - //@NotEmpty("") private List teamIdList; } - // 워크스페이스 참여 + /** + * 워크스페이스 참여 요청 DTO + */ public record JoinWorkspace( String token, String password ){} + /** + * 워크스페이스 URL 미리보기 & 워크스페이스 생성 요청 DTO + */ @Builder @Getter @NoArgsConstructor @AllArgsConstructor - public static class PreviewUrlRequestDto { - @NotBlank(message = "워크스페이스 이름을 입력해주세요.") - @Size(min = 4, max = 10, message = "워크스페이스 이름은 최소 4자, 최대 10자입니다.") - private String workspaceName; - } - - @Builder - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class CreateWorkspaceRequestDto { + public static class WorkspaceRequestDto { @NotBlank(message = "워크스페이스 이름을 입력해주세요.") @Size(min = 4, max = 10, message = "워크스페이스 이름은 최소 4자, 최대 10자입니다.") private String workspaceName; diff --git a/src/main/java/com/example/Veco/domain/workspace/dto/WorkspaceResponseDTO.java b/src/main/java/com/example/Veco/domain/workspace/dto/WorkspaceResponseDTO.java index d5f655fc..84c5b8e6 100644 --- a/src/main/java/com/example/Veco/domain/workspace/dto/WorkspaceResponseDTO.java +++ b/src/main/java/com/example/Veco/domain/workspace/dto/WorkspaceResponseDTO.java @@ -23,6 +23,7 @@ public static class WorkspaceResponseDto { private String workspaceName; // name -> workspaceName private String workspaceImageUrl; // profileUrl -> workspaceProfileImageUrl private String workspaceUrl; + private String invitePassword; private Long defaultTeamId; } @@ -82,13 +83,18 @@ public static class MemberDto { } } - // 워크스페이스 참여 + /** + * 워크스페이스 참여 DTO + */ @Builder public record JoinWorkspace( Long workspaceId, LocalDateTime joinedAt ){} + /** + * 워크스페이스 내 멤버 및 속한 팀 정보 DTO + */ @Getter @Builder public static class WorkspaceMemberWithTeamsDto { @@ -99,6 +105,9 @@ public static class WorkspaceMemberWithTeamsDto { private List teams; private LocalDateTime joinedAt; + /** + * 멤버가 속한 팀 정보 DTO + */ @Getter @Builder public static class TeamInfoDto { @@ -108,12 +117,18 @@ public static class TeamInfoDto { } } + /** + * 워크스페이스 URL 미리보기 응답 DTO + */ @Getter @Builder public static class PreviewUrlResponseDto { private String workspaceUrl; } + /** + * 워크스페이스 생성 응답 DTO + */ @Getter @Builder @NoArgsConstructor @@ -128,6 +143,9 @@ public static class CreateWorkspaceResponseDto { private Long defaultTeamId; } + /** + * 워크스페이스 초대 정보 응답 DTO + */ @Getter @Builder @NoArgsConstructor diff --git a/src/main/java/com/example/Veco/domain/workspace/entity/WorkSpace.java b/src/main/java/com/example/Veco/domain/workspace/entity/WorkSpace.java index 98bc6b6b..5ede8531 100644 --- a/src/main/java/com/example/Veco/domain/workspace/entity/WorkSpace.java +++ b/src/main/java/com/example/Veco/domain/workspace/entity/WorkSpace.java @@ -28,8 +28,9 @@ public class WorkSpace extends BaseEntity { @Column(name = "profile_url") private String profileUrl; + @Builder.Default @Column(name = "workspace_url") - private String workspaceUrl; + private String workspaceUrl = "https://s3.ap-northeast-2.amazonaws.com/s3.veco/default/defalut-workspace.png"; @Column(name = "invite_password") private String invitePassword; diff --git a/src/main/java/com/example/Veco/domain/workspace/error/SettingHandler.java b/src/main/java/com/example/Veco/domain/workspace/error/SettingHandler.java new file mode 100644 index 00000000..805e93fa --- /dev/null +++ b/src/main/java/com/example/Veco/domain/workspace/error/SettingHandler.java @@ -0,0 +1,10 @@ +package com.example.Veco.domain.workspace.error; + +import com.example.Veco.global.apiPayload.code.BaseErrorStatus; +import com.example.Veco.global.apiPayload.exception.VecoException; + +public class SettingHandler extends VecoException { + public SettingHandler(BaseErrorStatus errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/com/example/Veco/domain/workspace/error/SettingSuccessCode.java b/src/main/java/com/example/Veco/domain/workspace/error/SettingSuccessCode.java new file mode 100644 index 00000000..da5a2ac3 --- /dev/null +++ b/src/main/java/com/example/Veco/domain/workspace/error/SettingSuccessCode.java @@ -0,0 +1,28 @@ +package com.example.Veco.domain.workspace.error; + +import com.example.Veco.global.apiPayload.code.BaseSuccessStatus; +import com.example.Veco.global.apiPayload.dto.SuccessReasonDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum SettingSuccessCode implements BaseSuccessStatus { + DELETE(HttpStatus.OK, + "SETTING200_1", + "성공적으로 삭제했습니다.") + ; + + private final HttpStatus status; + private final String code; + private final String message; + + @Override + public SuccessReasonDTO getReasonHttpStatus() { + return SuccessReasonDTO.builder() + .isSuccess(true) + .httpStatus(status) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/example/Veco/domain/workspace/error/WorkspaceErrorStatus.java b/src/main/java/com/example/Veco/domain/workspace/error/WorkspaceErrorStatus.java index f5db0246..844b6ad5 100644 --- a/src/main/java/com/example/Veco/domain/workspace/error/WorkspaceErrorStatus.java +++ b/src/main/java/com/example/Veco/domain/workspace/error/WorkspaceErrorStatus.java @@ -12,7 +12,8 @@ public enum WorkspaceErrorStatus implements BaseErrorStatus { _WORKSPACE_NOT_FOUND(HttpStatus.NOT_FOUND, "WORKSPACE4001", "해당 워크스페이스가 없습니다."), _WORKSPACE_DUPLICATED(HttpStatus.BAD_REQUEST, "WORKSPACE4002", "워크스페이스는 한 사람당 하나만 존재합니다."), _INVALIDED_PASSWORD(HttpStatus.BAD_REQUEST, "WORKSPACE4003", "입력 정보를 다시 확인하세요."), - _WORKSPACE_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "WORKSPACE4003", "워크스페이스 저장에 실패했습니다.") + _WORKSPACE_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "WORKSPACE4003", "워크스페이스 저장에 실패했습니다."), + _DUPLICATE_WORKSPACE_NAME(HttpStatus.BAD_REQUEST, "WORKSPACE4004", "중복된 워크스페이스 이름입니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/example/Veco/domain/workspace/repository/WorkspaceQueryDslRepositoryImpl.java b/src/main/java/com/example/Veco/domain/workspace/repository/WorkspaceQueryDslRepositoryImpl.java index b0bbc36c..1b7d3841 100644 --- a/src/main/java/com/example/Veco/domain/workspace/repository/WorkspaceQueryDslRepositoryImpl.java +++ b/src/main/java/com/example/Veco/domain/workspace/repository/WorkspaceQueryDslRepositoryImpl.java @@ -7,6 +7,7 @@ import com.example.Veco.domain.member.error.MemberHandler; import com.example.Veco.domain.team.entity.QTeam; import com.example.Veco.domain.team.entity.Team; +import com.example.Veco.domain.workspace.converter.WorkspaceConverter; import com.example.Veco.domain.workspace.dto.WorkspaceResponseDTO; import com.example.Veco.domain.workspace.entity.WorkSpace; import com.example.Veco.domain.workspace.error.WorkspaceErrorStatus; @@ -47,42 +48,31 @@ public List findWorkspaceMembe .where(m.workSpace.eq(workspace)) .fetch(); + // 2. 멤버 ID 기준으로 그룹핑 + Map> grouped = tuples.stream() + .collect(Collectors.groupingBy(tuple -> tuple.get(m))); + // 워크스페이스 내에 멤버가 없을 경우 예외 처리 - if (tuples.isEmpty()) { + if (grouped.isEmpty()) { throw new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND); } - // 2. 멤버 ID 기준으로 그룹핑 - Map> grouped = tuples.stream() - .collect(Collectors.groupingBy(tuple -> tuple.get(m).getId())); - // 3. 최종 DTO 변환 - return grouped.values().stream().map(tupleList -> { - Member member = tupleList.get(0).get(m); + return grouped.entrySet().stream().map(entry -> { + Member member = entry.getKey(); + + List tupleList = entry.getValue(); - List teamDtos = tupleList.stream() - .map(tu -> { - Team team = tu.get(t); - return WorkspaceResponseDTO.WorkspaceMemberWithTeamsDto.TeamInfoDto.builder() - .teamId(team.getId()) - .teamName(team.getName()) - .teamImageUrl(team.getProfileUrl()) - .build(); - }).toList(); + List teams = tupleList.stream() + .map(tu -> tu.get(t)) + .toList(); LocalDateTime joinedAt = tupleList.stream() .map(tu -> tu.get(mt.createdAt)) .min(LocalDateTime::compareTo) .orElse(null); - return WorkspaceResponseDTO.WorkspaceMemberWithTeamsDto.builder() - .memberId(member.getId()) - .email(member.getEmail()) - .name(member.getName()) - .profileImageUrl(member.getProfile().getProfileImageUrl()) // 필요시 profile.getUrl()로 수정 - .teams(teamDtos) - .joinedAt(joinedAt) - .build(); + return WorkspaceConverter.toWorkspaceMemberWithTeamsDto(member, teams, joinedAt); }).toList(); } } diff --git a/src/main/java/com/example/Veco/domain/workspace/repository/WorkspaceRepository.java b/src/main/java/com/example/Veco/domain/workspace/repository/WorkspaceRepository.java index 8690819e..7e1b17f5 100644 --- a/src/main/java/com/example/Veco/domain/workspace/repository/WorkspaceRepository.java +++ b/src/main/java/com/example/Veco/domain/workspace/repository/WorkspaceRepository.java @@ -1,6 +1,8 @@ package com.example.Veco.domain.workspace.repository; import com.example.Veco.domain.workspace.entity.WorkSpace; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -12,4 +14,7 @@ public interface WorkspaceRepository extends JpaRepository { boolean existsBySlug(String slug); Optional findByInviteToken(String inviteToken); + + + boolean existsByName(String name); } diff --git a/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceCommandService.java b/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceCommandService.java index 47257af7..140a01a4 100644 --- a/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceCommandService.java +++ b/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceCommandService.java @@ -4,6 +4,7 @@ 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.global.auth.user.AuthUser; import com.example.Veco.global.auth.user.userdetails.CustomUserDetails; import org.springframework.stereotype.Service; @@ -18,5 +19,7 @@ public interface WorkspaceCommandService { WorkspaceResponseDTO.JoinWorkspace joinWorkspace(WorkspaceRequestDTO.JoinWorkspace dto, CustomUserDetails user); - WorkspaceResponseDTO.CreateWorkspaceResponseDto createWorkspace(Member member, WorkspaceRequestDTO.CreateWorkspaceRequestDto request); + WorkspaceResponseDTO.CreateWorkspaceResponseDto createWorkspace(Member member, WorkspaceRequestDTO.WorkspaceRequestDto request); + + String unlinkWorkspace(AuthUser user); } diff --git a/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceCommandServiceImpl.java b/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceCommandServiceImpl.java index e7c6a2b4..a8f7ba6d 100644 --- a/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceCommandServiceImpl.java +++ b/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceCommandServiceImpl.java @@ -1,5 +1,7 @@ package com.example.Veco.domain.workspace.service; +import com.example.Veco.domain.assignee.entity.Assignee; +import com.example.Veco.domain.assignee.repository.AssigneeRepository; import com.example.Veco.domain.mapping.converter.MemberTeamConverter; import com.example.Veco.domain.mapping.entity.MemberTeam; import com.example.Veco.domain.mapping.repository.MemberTeamRepository; @@ -21,7 +23,9 @@ import com.example.Veco.domain.workspace.util.InvitePasswordGenerator; import com.example.Veco.domain.workspace.util.InviteTokenGenerator; import com.example.Veco.domain.workspace.util.SlugGenerator; +import com.example.Veco.global.auth.user.AuthUser; import com.example.Veco.global.auth.user.userdetails.CustomUserDetails; +import com.example.Veco.global.enums.Role; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,46 +42,65 @@ public class WorkspaceCommandServiceImpl implements WorkspaceCommandService { private final MemberRepository memberRepository; private final MemberTeamRepository memberTeamRepository; private final WorkspaceRepository workspaceRepository; + private final AssigneeRepository assigneeRepository; private final SlugGenerator slugGenerator; private final InviteTokenGenerator inviteTokenGenerator; private final InvitePasswordGenerator invitePasswordGenerator; /** - * 팀 생성 및 멤버 할당 로직 - * 1. 팀 저장 - * 2. 멤버 ID로 멤버 조회 - * 3. MemberTeam 엔티티로 연결 - * 4. 연결 저장 후 DTO 반환 + * 팀 생성 및 멤버 할당 로직 ~ */ + @Transactional @Override public WorkspaceResponseDTO.CreateTeamResponseDto createTeam(WorkSpace workspace, WorkspaceRequestDTO.CreateTeamRequestDto request) { - // 1. 팀 저장 + // 1. 팀 이름 중복 검사 + if (teamRepository.existsByNameAndWorkSpace(request.getTeamName(), workspace)) { + throw new TeamException(TeamErrorCode._DUPLICATE_TEAM_NAME); + } + int maxOrder = teamRepository.findMaxOrderByWorkSpace(workspace).orElse(-1); + // 2. 팀 저장 Team team = teamRepository.save(Team.builder() .name(request.getTeamName()) .workSpace(workspace) .goalNumber(1L) + .order(maxOrder + 1) // 새로 만든 팀은 자동으로 맨 뒤 배치 .build()); - // 2. 요청으로 전달된 멤버 ID 리스트로 멤버 조회 + // 3. 요청으로 전달된 멤버 ID 리스트로 멤버 조회 List members = memberRepository.findAllById(request.getMemberId()); + List memberIds = request.getMemberId(); - // 3. 에러 처리 : 멤버가 아예 없을 경우 예외 발생 + // 4-1. 에러 처리 : 멤버가 아예 없을 경우 예외 발생 if (members.isEmpty()) { throw new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND); } - // 4. 멤버와 팀을 매핑하여 MemberTeam 리스트 생성 + // 4-2 에러 처리 : 존재하지 않는 멤버 아이디가 포함되어 있을 경우 + if (members.size() != memberIds.size()) { + throw new MemberHandler(MemberErrorStatus._INVALID_MEMBER_INCLUDE); + } + + // 4-3. 에러 처리 : 워크스페이스에 속하지 않은 멤버가 있는지 체크 + boolean hasInvalidMember = members.stream() + .anyMatch(member -> !workspace.equals(member.getWorkSpace())); + + if (hasInvalidMember) { + throw new MemberHandler(MemberErrorStatus._MEMBER_NOT_IN_WORKSPACE); + } + + // 5. 멤버와 팀을 매핑하여 MemberTeam 리스트 생성 List memberTeams = members.stream() .map(member -> MemberTeam.builder() .member(member) + .role(Role.USER) .team(team) .build()) .toList(); - // 5. 멤버~팀 관계 저장 + // 6. 멤버~팀 관계 저장 memberTeamRepository.saveAll(memberTeams); - // 6. 응답 DTO 생성 후 반환 + // 7. 응답 DTO 생성 후 반환 return WorkspaceConverter.toCreateTeamResponseDto(team, members); } @@ -90,6 +113,12 @@ public void updateTeamOrder(WorkSpace workspace, List teamIdList) { throw new TeamException(TeamErrorCode._TEAM_COUNT_MISMATCH); } + Long defaultTeamId = teamRepository.findMinTeamIdByWorkSpace(workspace); + + if (!teamIdList.get(0).equals(defaultTeamId)) { + throw new TeamException(TeamErrorCode._DEFAULT_TEAM_MUST_BE_FIRST); + } + for (int i = 0; i < teamIdList.size(); i++) { Long teamId = teamIdList.get(i); Team team = teamRepository.findById(teamId) @@ -105,7 +134,11 @@ public void updateTeamOrder(WorkSpace workspace, List teamIdList) { } @Override - public WorkspaceResponseDTO.CreateWorkspaceResponseDto createWorkspace(Member member, WorkspaceRequestDTO.CreateWorkspaceRequestDto request) { + public WorkspaceResponseDTO.CreateWorkspaceResponseDto createWorkspace(Member member, WorkspaceRequestDTO.WorkspaceRequestDto request) { + // 1. 워크스페이스 이름 중복 검사 + if (workspaceRepository.existsByName(request.getWorkspaceName())) { + throw new WorkspaceHandler(WorkspaceErrorStatus._DUPLICATE_WORKSPACE_NAME); + } // 2. 이미 워크스페이스가 있으면 예외 if (member.getWorkSpace() != null) { throw new WorkspaceHandler(WorkspaceErrorStatus._WORKSPACE_DUPLICATED); @@ -126,6 +159,7 @@ public WorkspaceResponseDTO.CreateWorkspaceResponseDto createWorkspace(Member me // 평문대신 BCrypto 사용해서 암호화 한뒤 저장해야 함 .invitePassword(invitePassword) .inviteUrl(inviteUrl) + .profileUrl("https://s3.ap-northeast-2.amazonaws.com/s3.veco/default/defalut-workspace.png") .workspaceUrl(workspaceUrl) .members(new ArrayList<>()) // 초기화 .teams(new ArrayList<>()) // 초기화 @@ -140,6 +174,7 @@ public WorkspaceResponseDTO.CreateWorkspaceResponseDto createWorkspace(Member me .name(workSpace.getName()) // 워크스페이스 이름과 같은 디폴트 팀 .workSpace(workSpace) .goalNumber(1L) + .profileUrl("https://s3.ap-northeast-2.amazonaws.com/s3.veco/default/defalut-workspace.png") // 기본 팀은 워크스페이스 이미지 사용 .build(); workSpace.getTeams().add(defaultTeam); @@ -169,6 +204,37 @@ public WorkspaceResponseDTO.CreateWorkspaceResponseDto createWorkspace(Member me .build(); } + // 워크스페이스 연동 해제 + @Override + @Transactional + public String unlinkWorkspace( + AuthUser user + ) { + // 유저 정보 조회 + Member member = memberRepository.findBySocialUid(user.getSocialUid()) + .orElseThrow(() -> new MemberHandler(MemberErrorStatus._MEMBER_NOT_FOUND)); + + // 유저 - 팀 조회 + List result = memberTeamRepository.findAllByMemberIdAndTeamIn( + member.getId(), + member.getWorkSpace().getTeams() + ); + List memberTeamIds = new ArrayList<>(); + result.forEach(memberTeam -> memberTeamIds.add(memberTeam.getId())); + + // 담당자 정보 null 처리 + List assignees = assigneeRepository.findAllByMemberTeamIdIn(memberTeamIds); + assignees.forEach(assignee -> assignee.updateMemberTeam(null)); + + // 유저 - 팀 삭제 + memberTeamRepository.deleteAll(result); + + // 유저 - 워크스페이스 null 처리 + member.updateWorkspace(null); + + return member.getName(); + } + // 워크스페이스 참여 @Override public WorkspaceResponseDTO.JoinWorkspace joinWorkspace( diff --git a/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceQueryService.java b/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceQueryService.java index 7e45ed8e..20c8a658 100644 --- a/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceQueryService.java +++ b/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceQueryService.java @@ -21,4 +21,6 @@ public interface WorkspaceQueryService { List getWorkspaceMembers(Member loginMember); String createPreviewUrl(String workspaceName); + + WorkSpace getWorkspaceBySocialUid(String socialUid); } diff --git a/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceQueryServiceImpl.java b/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceQueryServiceImpl.java index ebca8cab..be3d6be4 100644 --- a/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceQueryServiceImpl.java +++ b/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceQueryServiceImpl.java @@ -4,6 +4,7 @@ import com.example.Veco.domain.mapping.repository.MemberTeamRepository; import com.example.Veco.domain.member.entity.Member; import com.example.Veco.domain.member.repository.MemberRepository; +import com.example.Veco.domain.member.service.MemberQueryService; import com.example.Veco.domain.team.entity.Team; import com.example.Veco.domain.team.repository.TeamRepository; import com.example.Veco.domain.workspace.converter.WorkspaceConverter; @@ -38,6 +39,7 @@ public class WorkspaceQueryServiceImpl implements WorkspaceQueryService { private final MemberRepository memberRepository; private final WorkspaceQueryDslRepository workspaceQueryDslRepository; private final SlugGenerator slugGenerator; + private final MemberQueryService memberQueryService; /** * 로그인한 멤버가 속한 워크스페이스 조회 @@ -49,6 +51,12 @@ public WorkSpace getWorkSpaceByMember(Member member) { .orElseThrow(() -> new WorkspaceHandler(WorkspaceErrorStatus._WORKSPACE_NOT_FOUND)); } + @Override + public WorkSpace getWorkspaceBySocialUid(String socialUid) { + Member member = memberQueryService.getMemberBySocialUid(socialUid); + return getWorkSpaceByMember(member); + } + /** * 특정 워크스페이스에 속한 팀 리스트를 페이징하여 조회 * - 각 팀별 멤버 수 계산 포함 @@ -84,8 +92,7 @@ public WorkspaceResponseDTO.WorkspaceTeamListDto getTeamListByWorkSpace(Pageable @Override public List getWorkspaceMembers(Member loginMember) { - WorkSpace workspace = Optional.ofNullable(loginMember.getWorkSpace()) - .orElseThrow(() -> new WorkspaceHandler(WorkspaceErrorStatus._WORKSPACE_NOT_FOUND)); + WorkSpace workspace = getWorkSpaceByMember(loginMember); return workspaceQueryDslRepository.findWorkspaceMembersWithTeams(workspace); } diff --git a/src/main/java/com/example/Veco/global/apiPayload/code/ErrorStatus.java b/src/main/java/com/example/Veco/global/apiPayload/code/ErrorStatus.java index e37760a0..3c2640da 100644 --- a/src/main/java/com/example/Veco/global/apiPayload/code/ErrorStatus.java +++ b/src/main/java/com/example/Veco/global/apiPayload/code/ErrorStatus.java @@ -20,11 +20,12 @@ public enum ErrorStatus implements BaseErrorStatus{ VALID_FAILED(HttpStatus.BAD_REQUEST, "VALID400_1", "잘못된 파라미터입니다."), + BODY_TYPE_BAD_REQUEST(HttpStatus.BAD_REQUEST,"COMMON400_1", "요청한 Body 타입이 잘못되었습니다.") ; - private HttpStatus httpStatus; - private String code; - private String message; + private final HttpStatus httpStatus; + private final String code; + private final String message; public ErrorReasonDTO getReasonHttpStatus() { return ErrorReasonDTO.builder() diff --git a/src/main/java/com/example/Veco/global/apiPayload/exception/ExceptionAdvice.java b/src/main/java/com/example/Veco/global/apiPayload/exception/ExceptionAdvice.java index f541943c..1e938bcf 100644 --- a/src/main/java/com/example/Veco/global/apiPayload/exception/ExceptionAdvice.java +++ b/src/main/java/com/example/Veco/global/apiPayload/exception/ExceptionAdvice.java @@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingPathVariableException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -74,6 +75,24 @@ public ResponseEntity validation( return ResponseEntity.status(constraintErrorCode.getReasonHttpStatus().getHttpStatus()).body(errorResponse); } + // 요청 직렬화 실패 + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity> handleHttpMessageNotReadableException( + HttpMessageNotReadableException ex + ){ + log.error("[ 요청 직렬화 실패 ]: {}", ex.getMessage()); + + // 직렬화 실패시, 응답 통일 + BaseErrorStatus code = ErrorStatus.BODY_TYPE_BAD_REQUEST; + ApiResponse errorResponse = ApiResponse.onFailure( + code.getReasonHttpStatus().getCode(), + code.getReasonHttpStatus().getMessage(), + null + ); + + return ResponseEntity.status(code.getReasonHttpStatus().getHttpStatus()).body(errorResponse); + } + // 쿼리 파라미터 검증 @ExceptionHandler(HandlerMethodValidationException.class) protected ResponseEntity>> handleHandlerMethodValidationException( diff --git a/src/main/java/com/example/Veco/global/auth/oauth2/exception/code/OAuth2ErrorCode.java b/src/main/java/com/example/Veco/global/auth/oauth2/exception/code/OAuth2ErrorCode.java index 7344a51b..c762718b 100644 --- a/src/main/java/com/example/Veco/global/auth/oauth2/exception/code/OAuth2ErrorCode.java +++ b/src/main/java/com/example/Veco/global/auth/oauth2/exception/code/OAuth2ErrorCode.java @@ -13,6 +13,11 @@ public enum OAuth2ErrorCode implements BaseErrorStatus { HttpStatus.BAD_REQUEST, "OAUTH2401_0", "state 값이 유효하지 않습니다." + ), + _SOCIAL_UNLINK_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, + "MEMBER500_1", + "소셜 계정 연동 해제에 실패했습니다." ); private final HttpStatus httpStatus; diff --git a/src/main/java/com/example/Veco/global/auth/oauth2/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/Veco/global/auth/oauth2/handler/OAuth2SuccessHandler.java index 428717e0..0d645649 100644 --- a/src/main/java/com/example/Veco/global/auth/oauth2/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/example/Veco/global/auth/oauth2/handler/OAuth2SuccessHandler.java @@ -4,13 +4,9 @@ import com.example.Veco.domain.member.service.MemberCommandService; import com.example.Veco.global.auth.jwt.util.JwtUtil; import com.example.Veco.global.auth.oauth2.CustomOAuth2User; -import com.example.Veco.global.auth.oauth2.exception.OAuth2Exeception; -import com.example.Veco.global.auth.oauth2.exception.code.OAuth2ErrorCode; import com.example.Veco.global.auth.user.userdetails.CustomUserDetails; -import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -38,9 +34,11 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler private List allowedOrigins; @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { - - String flow = getFlow(request); + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); @@ -53,61 +51,32 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.setHeader("Access-Control-Allow-Credentials", "true"); } + // 유저 정보 가져오기 & 인증 객체 생성 CustomOAuth2User userDetails = (CustomOAuth2User) authentication.getPrincipal(); Member member = userDetails.getMember(); CustomUserDetails customUserDetails = new CustomUserDetails(member); String redirectURL; + // Refresh Token 발급 & 쿠키 설정 String refreshToken = jwtUtil.createRefreshToken(customUserDetails); ResponseCookie refreshTokenCookie = jwtUtil.createRefreshTokenCookie(refreshToken); response.addHeader("Set-Cookie", refreshTokenCookie.toString()); + // 인증 객체 저장 UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authToken); + // DB에 Refresh Token 업데이트 member.updateRefreshToken(refreshToken); memberCommandService.saveMember(member); - // 최초 로그인 - // TODO: 테스트용 주석 처리, 이후 해제 필요 - if (userDetails.getMember().getWorkSpace() == null) { - // 워크스페이스 생성 - if (flow.equals("create")) { - redirectURL = UriComponentsBuilder.fromUriString("https://web.vecoservice.shop/onboarding/workspace") - .build() - .encode(StandardCharsets.UTF_8) - .toUriString(); - // 워크스페이스 참여 - } else if (flow.equals("join")) { - redirectURL = UriComponentsBuilder.fromUriString("https://web.vecoservice.shop/onboarding/input-pw") + + // 로딩 화면으로 리다이렉트 + redirectURL = UriComponentsBuilder.fromUriString("http://localhost:5173/onboarding/loading") .build() .encode(StandardCharsets.UTF_8) .toUriString(); - } else { - throw new OAuth2Exeception(OAuth2ErrorCode.OAUTH2_INVALID_STATE); - } - // 기존 회원 - } else { - redirectURL = UriComponentsBuilder.fromUriString("https://web.vecoservice.shop/workspace") - .build() - .encode(StandardCharsets.UTF_8) - .toUriString(); - } - -// redirectURL = UriComponentsBuilder.fromUriString("https://web.vecoservice.shop/onboarding/workspace") -// .build() -// .encode(StandardCharsets.UTF_8) -// .toUriString(); getRedirectStrategy().sendRedirect(request, response, redirectURL); } - - public String getFlow(HttpServletRequest request) { - - HttpSession session = request.getSession(); - String flow = (String) session.getAttribute("flow"); - session.removeAttribute("flow"); - - return flow != null ? flow : "create"; - } } \ No newline at end of file diff --git a/src/main/java/com/example/Veco/global/auth/oauth2/service/OAuth2UserService.java b/src/main/java/com/example/Veco/global/auth/oauth2/service/OAuth2UserService.java index e32a4e23..088c5606 100644 --- a/src/main/java/com/example/Veco/global/auth/oauth2/service/OAuth2UserService.java +++ b/src/main/java/com/example/Veco/global/auth/oauth2/service/OAuth2UserService.java @@ -7,17 +7,30 @@ import com.example.Veco.domain.profile.repository.ProfileRepository; import com.example.Veco.global.auth.oauth2.CustomOAuth2User; import com.example.Veco.global.auth.oauth2.dto.OAuth2Attribute; +import com.example.Veco.global.auth.oauth2.exception.OAuth2Exeception; +import com.example.Veco.global.auth.oauth2.exception.code.OAuth2ErrorCode; import com.example.Veco.global.auth.oauth2.userinfo.OAuth2UserInfo; import com.example.Veco.global.auth.oauth2.util.OAuth2Util; +import com.example.Veco.global.auth.user.userdetails.CustomUserDetails; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; import java.util.Collections; import java.util.Map; @@ -30,7 +43,10 @@ public class OAuth2UserService extends DefaultOAuth2UserService { private final MemberRepository memberRepository; private final ProfileRepository profileRepository; + private final OAuth2AuthorizedClientService clientService; + @Value("${spring.security.oauth2.client.provider.kakao.admin-key}") + private String adminKey; @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { @@ -82,4 +98,49 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic return new CustomOAuth2User(Collections.singleton(new SimpleGrantedAuthority("user")), attributes, oAuth2Attribute.getNameAttributeKey(), member); } + + public void unlinkGoogleAccess(CustomUserDetails customUserDetails) { + + OAuth2AuthorizedClient authorizedClient = clientService.loadAuthorizedClient( + "google", + customUserDetails.getUsername() + ); + + if (authorizedClient != null) { + String accessToken = authorizedClient.getAccessToken().getTokenValue(); + + try { + String revokeUrl = "https://oauth2.googleapis.com/revoke?token=" + accessToken; + + WebClient.create() + .post() + .uri(revokeUrl) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .bodyToMono(String.class) + .block(); + + } catch (Exception e) { + log.error("구글 연동 해제 실패", e); + throw new OAuth2Exeception(OAuth2ErrorCode._SOCIAL_UNLINK_FAILED); + } + } + } + + public void unlinkKakaoAccount(Long kakaoId) { + try { + String response = WebClient.create("https://kapi.kakao.com") + .post() + .uri("/v1/user/unlink") + .header(HttpHeaders.AUTHORIZATION, "KakaoAK " + adminKey) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .bodyValue("target_id_type=user_id&target_id=" + kakaoId) + .retrieve() + .bodyToMono(String.class) + .block(); + } catch (Exception e) { + log.error("카카오 연동 해제 실패", e); + throw new OAuth2Exeception(OAuth2ErrorCode._SOCIAL_UNLINK_FAILED); + } + } } diff --git a/src/main/java/com/example/Veco/global/auth/oauth2/userinfo/KakaoOAuth2UserInfo.java b/src/main/java/com/example/Veco/global/auth/oauth2/userinfo/KakaoOAuth2UserInfo.java new file mode 100644 index 00000000..526ab93d --- /dev/null +++ b/src/main/java/com/example/Veco/global/auth/oauth2/userinfo/KakaoOAuth2UserInfo.java @@ -0,0 +1,34 @@ +package com.example.Veco.global.auth.oauth2.userinfo; + +import java.util.Map; + +public class KakaoOAuth2UserInfo extends OAuth2UserInfo { + + public static Map account; + public static Map profile; + + public KakaoOAuth2UserInfo(Map attributes) { + super(attributes); + account = (Map) attributes.get("kakao_account"); + profile = (Map) account.get("profile"); + } + + @Override + public String getSocialId() { + return String.valueOf(attributes.get("id")); + } + + @Override + public String getEmail() { + return (String) account.get("email"); + } + + @Override + public String getName() { + return (String) profile.get("nickname"); + } + @Override + public String getPicture() { + return (String) profile.get("thumbnail_image_url"); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/Veco/global/auth/oauth2/util/OAuth2Util.java b/src/main/java/com/example/Veco/global/auth/oauth2/util/OAuth2Util.java index d21fbb27..d126b7ce 100644 --- a/src/main/java/com/example/Veco/global/auth/oauth2/util/OAuth2Util.java +++ b/src/main/java/com/example/Veco/global/auth/oauth2/util/OAuth2Util.java @@ -2,6 +2,7 @@ import com.example.Veco.domain.member.enums.Provider; import com.example.Veco.global.auth.oauth2.userinfo.GoogleOAuth2UserInfo; +import com.example.Veco.global.auth.oauth2.userinfo.KakaoOAuth2UserInfo; import com.example.Veco.global.auth.oauth2.userinfo.OAuth2UserInfo; import java.util.Map; @@ -15,8 +16,8 @@ public static Provider getProvider(String registrationId) { if ("GOOGLE".equals(registrationId)) { return Provider.GOOGLE; -// } else if ("KAKAO".equals(registrationId)) { -// return Provider.KAKAO; + } else if ("KAKAO".equals(registrationId)) { + return Provider.KAKAO; } return null; } @@ -25,8 +26,8 @@ public static OAuth2UserInfo getOAuth2UserInfo(Provider provider, Map cors.configurationSource(corsConfigurationSource)) .csrf(AbstractHttpConfigurer::disable) - .headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable())) + .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/test/login", "/api/token/reissue", "/login-test.html", "/v3/api-docs/**", "/swagger-ui/**", + .requestMatchers("/api/test/**", "/api/teams/*/externals/**", "/api/teams/*/externals", "/api/test/login", "/api/token/reissue", "/login-test.html", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/css/**", "/images/**", "/healthcheck", - "/js/**", "/h2-console/**", "/profile","/workspace/create-url", "/github/**","/api/github/**").permitAll() + "/js/**", "/h2-console/**", "/profile","/workspace/create-url","/slack/callback", "/github/**","/api/github/**").permitAll() .anyRequest().authenticated() ) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) - .logout(logout -> logout.logoutSuccessUrl("/")) + .logout(logout -> logout.logoutSuccessUrl("http://localhost:5173/onboarding")) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .oauth2Login(oauth2 -> oauth2 @@ -57,12 +58,13 @@ protected SecurityFilterChain configure(HttpSecurity http) throws Exception { .authorizationRequestRepository(new HttpSessionOAuth2AuthorizationRequestRepository()) .authorizationRequestResolver(customAuthorizationRequestResolver) ) + .loginPage("http://localhost:5173/onboarding") .successHandler(oAuth2SuccessHandler) .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) .failureHandler((request, response, exception) -> { // 로그인 실패 시 처리 로직 log.error("OAuth2 로그인 실패: {}", exception.getMessage()); - response.sendRedirect("/login?error"); + response.sendRedirect("http://localhost:5173/onboarding"); }) ); return http.build(); diff --git a/src/main/java/com/example/Veco/test/TestDataController.java b/src/main/java/com/example/Veco/test/TestDataController.java new file mode 100644 index 00000000..23a388e0 --- /dev/null +++ b/src/main/java/com/example/Veco/test/TestDataController.java @@ -0,0 +1,425 @@ +package com.example.Veco.test; + +import com.example.Veco.domain.external.entity.External; +import com.example.Veco.domain.external.repository.ExternalRepository; +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.repository.AssigmentRepository; +import com.example.Veco.domain.member.entity.Member; +import com.example.Veco.domain.member.enums.MemberRole; +import com.example.Veco.domain.member.enums.Provider; +import com.example.Veco.domain.member.repository.MemberRepository; +import com.example.Veco.domain.team.entity.Team; +import com.example.Veco.domain.team.repository.TeamRepository; +import com.example.Veco.global.enums.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@RestController +@RequestMapping("/api/test") +public class TestDataController { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TeamRepository teamRepository; + + @Autowired + private GoalRepository goalRepository; + + @Autowired + private ExternalRepository externalRepository; + + @Autowired + private AssigmentRepository assignmentRepository; + + @PostMapping("/insert-test-data") + public String insertTestData() { + try { + // 1. Member 테스트 데이터 + List members = new ArrayList<>(); + members.add(Member.builder() + .name("김철수") + .nickname("chulsoo") + .email("chulsoo@test.com") + .provider(Provider.GOOGLE) + .role(MemberRole.USER) + .build()); + members.add(Member.builder() + .name("이영희") + .nickname("younghee") + .email("younghee@test.com") + .provider(Provider.GOOGLE) + .role(MemberRole.USER) + .build()); + members.add(Member.builder() + .name("박민수") + .nickname("minsu") + .email("minsu@test.com") + .provider(Provider.GOOGLE) + .role(MemberRole.USER) + .build()); + members.add(Member.builder() + .name("최지혜") + .nickname("jihye") + .email("jihye@test.com") + .provider(Provider.GOOGLE) + .role(MemberRole.USER) + .build()); + memberRepository.saveAll(members); + + // 2. Team 테스트 데이터 + Team team = Team.builder() + .name("테스트팀") + .build(); + teamRepository.save(team); + + // 3. Goal 테스트 데이터 + List goals = new ArrayList<>(); + goals.add(Goal.builder() + .name("프론트엔드 개발") + .title("웹 프론트엔드 완성하기") + .content("리액트 기반 프론트엔드 개발") + .state(State.IN_PROGRESS) + .priority(Priority.HIGH) + .deadlineStart(LocalDate.of(2024, 1, 1)) + .deadlineEnd(LocalDate.of(2024, 3, 31)) + .team(team) + .build()); + goals.add(Goal.builder() + .name("백엔드 API") + .title("REST API 개발") + .content("스프링부트 백엔드 API 구현") + .state(State.TODO) + .priority(Priority.URGENT) + .deadlineStart(LocalDate.of(2024, 2, 1)) + .deadlineEnd(LocalDate.of(2024, 4, 30)) + .team(team) + .build()); + goals.add(Goal.builder() + .name("데이터베이스 설계") + .title("DB 스키마 설계") + .content("데이터베이스 스키마 및 관계 설정") + .state(State.FINISH) + .priority(Priority.NORMAL) + .deadlineStart(LocalDate.of(2023, 12, 1)) + .deadlineEnd(LocalDate.of(2024, 1, 31)) + .team(team) + .build()); + goals.add(Goal.builder() + .name("DevOps 구축") + .title("CI/CD 파이프라인") + .content("자동화 배포 시스템 구축") + .state(State.NONE) + .priority(Priority.LOW) + .team(team) + .build()); + goalRepository.saveAll(goals); + + // 4. External 테스트 데이터 + List externals = new ArrayList<>(); + + externals.add(External.builder() + .githubDataId(1001L) + .name("EXT-001") + .title("로그인 페이지 개발") + .description("OAuth 로그인 페이지 구현") + .state(State.NONE) + .member(members.get(0)) + .priority(Priority.URGENT) + .startDate(LocalDate.of(2024, 1, 15)) + .endDate(LocalDate.of(2024, 1, 30)) + .type(ExtServiceType.GITHUB) + .team(team) + .goal(goals.get(0)) + .build()); + + externals.add(External.builder() + .githubDataId(1002L) + .name("EXT-002") + .title("회원가입 API") + .description("사용자 등록 REST API 개발") + .state(State.TODO) + .member(members.get(0)) + .priority(Priority.HIGH) + .startDate(LocalDate.of(2024, 1, 20)) + .endDate(LocalDate.of(2024, 2, 10)) + .type(ExtServiceType.GITHUB) + .team(team) + .goal(goals.get(1)) + .build()); + + externals.add(External.builder() + .githubDataId(1003L) + .name("EXT-003") + .title("데이터베이스 스키마") + .description("User 테이블 스키마 설계") + .state(State.IN_PROGRESS) + .member(members.get(1)) + .priority(Priority.HIGH) + .startDate(LocalDate.of(2024, 1, 10)) + .endDate(LocalDate.of(2024, 1, 25)) + .type(ExtServiceType.SLACK) + .team(team) + .goal(goals.get(2)) + .build()); + + externals.add(External.builder() + .githubDataId(1004L) + .name("EXT-004") + .title("비밀번호 암호화") + .description("BCrypt 암호화 적용") + .state(State.FINISH) + .member(members.get(1)) + .priority(Priority.NORMAL) + .startDate(LocalDate.of(2024, 1, 5)) + .endDate(LocalDate.of(2024, 1, 20)) + .type(ExtServiceType.GITHUB) + .team(team) + .goal(goals.get(1)) + .build()); + + externals.add(External.builder() + .githubDataId(1005L) + .name("EXT-005") + .title("JWT 토큰 관리") + .description("JWT 인증 시스템 구현") + .state(State.REVIEW) + .member(members.get(2)) + .priority(Priority.URGENT) + .startDate(LocalDate.of(2024, 1, 25)) + .endDate(LocalDate.of(2024, 2, 15)) + .type(ExtServiceType.NOTION) + .team(team) + .goal(goals.get(1)) + .build()); + + externals.add(External.builder() + .githubDataId(1006L) + .name("EXT-006") + .title("메인 대시보드 UI") + .description("메인 화면 컴포넌트 개발") + .state(State.TODO) + .member(members.get(0)) + .priority(Priority.NONE) + .type(ExtServiceType.GITHUB) + .team(team) + .goal(goals.get(0)) + .build()); + + externals.add(External.builder() + .githubDataId(1007L) + .name("EXT-007") + .title("보안 취약점 점검") + .description("OWASP 보안 점검") + .state(State.IN_PROGRESS) + .member(members.get(2)) + .priority(Priority.URGENT) + .startDate(LocalDate.of(2024, 2, 1)) + .endDate(LocalDate.of(2024, 2, 10)) + .type(ExtServiceType.SLACK) + .team(team) + .build()); + + externals.add(External.builder() + .githubDataId(1008L) + .name("EXT-008") + .title("성능 최적화") + .description("DB 쿼리 최적화") + .state(State.NONE) + .member(members.get(3)) + .priority(Priority.LOW) + .type(ExtServiceType.SLACK) + .team(team) + .goal(goals.get(2)) + .build()); + + externals.add(External.builder() + .githubDataId(1009L) + .name("EXT-009") + .title("단위 테스트 작성") + .description("Service 레이어 테스트") + .state(State.TODO) + .member(members.get(1)) + .priority(Priority.NORMAL) + .startDate(LocalDate.of(2024, 2, 5)) + .endDate(LocalDate.of(2024, 2, 20)) + .type(ExtServiceType.GITHUB) + .team(team) + .goal(goals.get(1)) + .build()); + + externals.add(External.builder() + .githubDataId(1010L) + .name("EXT-010") + .title("문서화 작업") + .description("API 문서 Swagger 적용") + .state(State.REVIEW) + .member(members.get(3)) + .priority(Priority.HIGH) + .startDate(LocalDate.of(2024, 1, 30)) + .endDate(LocalDate.of(2024, 2, 15)) + .type(ExtServiceType.NOTION) + .team(team) + .build()); + + externals.add(External.builder() + .githubDataId(1015L) + .name("EXT-015") + .title("임시 버그 수정") + .description("긴급 버그 핫픽스") + .state(State.TODO) // 'URGENT' 상태는 존재하지 않으므로 TODO로 변경 + .member(members.get(0)) + .priority(Priority.URGENT) + .startDate(LocalDate.of(2024, 2, 8)) + .endDate(LocalDate.of(2024, 2, 8)) + .type(ExtServiceType.GITHUB) + .team(team) + .build()); + + externals.add(External.builder() + .githubDataId(1016L) + .name("EXT-016") + .title("서버 모니터링") + .description("서버 상태 모니터링 설정") + .state(State.IN_PROGRESS) + .member(members.get(2)) + .priority(Priority.HIGH) + .startDate(LocalDate.of(2024, 2, 1)) + .endDate(LocalDate.of(2024, 2, 29)) + .type(ExtServiceType.SLACK) + .team(team) + .build()); + + externalRepository.saveAll(externals); + + // 5. Assignment 테스트 데이터 + List assignments = new ArrayList<>(); + + // EXT-001: 김철수 + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("김철수") + .assignee(members.get(0)) + .external(externals.get(0)) + .build()); + + // EXT-002: 김철수 + 이영희 (복수 담당자) + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("김철수") + .assignee(members.get(0)) + .external(externals.get(1)) + .build()); + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("이영희") + .assignee(members.get(1)) + .external(externals.get(1)) + .build()); + + // EXT-003: 이영희 + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("이영희") + .assignee(members.get(1)) + .external(externals.get(2)) + .build()); + + // EXT-004: 이영희 + 박민수 (복수 담당자) + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("이영희") + .assignee(members.get(1)) + .external(externals.get(3)) + .build()); + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("박민수") + .assignee(members.get(2)) + .external(externals.get(3)) + .build()); + + // EXT-005: 박민수 + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("박민수") + .assignee(members.get(2)) + .external(externals.get(4)) + .build()); + + // EXT-006: 담당자 없음 + + // EXT-007: 박민수 + 최지혜 (복수 담당자) + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("박민수") + .assignee(members.get(2)) + .external(externals.get(6)) + .build()); + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("최지혜") + .assignee(members.get(3)) + .external(externals.get(6)) + .build()); + + // EXT-008: 최지혜 + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("최지혜") + .assignee(members.get(3)) + .external(externals.get(7)) + .build()); + + // EXT-015: 김철수 + 이영희 + 박민수 (3명 담당자) + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("김철수") + .assignee(members.get(0)) + .external(externals.get(10)) + .build()); + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("이영희") + .assignee(members.get(1)) + .external(externals.get(10)) + .build()); + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("박민수") + .assignee(members.get(2)) + .external(externals.get(10)) + .build()); + + // EXT-016: 박민수 + assignments.add(Assignment.builder() + .category(Category.EXTERNAL) + .assigneeName("박민수") + .assignee(members.get(2)) + .external(externals.get(11)) + .build()); + + assignmentRepository.saveAll(assignments); + + return "테스트 데이터가 성공적으로 삽입되었습니다!\\n" + + "- Members: " + members.size() + "\\n" + + "- Team ID: " + team.getId() + "\\n" + + "- Goals: " + goals.size() + "\\n" + + "- Externals: " + externals.size() + "\\n" + + "- Assignments: " + assignments.size(); + + } catch (Exception e) { + return "테스트 데이터 삽입 중 오류 발생: " + e.getMessage(); + } + } +} \ No newline at end of file diff --git a/src/main/resources/static/login-test.html b/src/main/resources/static/login-test.html index 8151ace2..48ae1486 100644 --- a/src/main/resources/static/login-test.html +++ b/src/main/resources/static/login-test.html @@ -7,24 +7,44 @@

🔐 Google 로그인 테스트

- - - - - - +
+ + +
+ +
+ + +
+ +
+ + + + +