diff --git a/.env b/.env new file mode 100644 index 0000000..db4d196 --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +DB_NAME=${{ secrets.DB_NAME }} +DB_USERNAME=${{ secrets.DB_PASSWORD }} +DB_PASSWORD=${{ secrets.DB_USERNAME }} + + +BUCKET_ACCESSKEY=${{ secrets.BUCKET_ACCESSORY }} +BUCKET_SECRETKEY=${{ secrets.BUCKET_SECRETARY }} +BUCKET_NAME=${{ secrets.BUCKET_NAME }} \ No newline at end of file diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml new file mode 100644 index 0000000..a46b7bc --- /dev/null +++ b/.github/workflows/gradle-publish.yml @@ -0,0 +1,58 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a package using Gradle and then publish it to GitHub packages when a release is created +# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#Publishing-using-gradle + +name: Gradle Package + +on: [ push ] + + # https://github.com/marketplace/actions/build-and-push-docker-images +jobs: + docker-build-and-push: + runs-on: ubuntu-latest + steps: + - + name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - + name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - + name: Build and push + uses: docker/build-push-action@v6 + with: + file: /build/libs/* + push: true + tags: ${{ vars.DOCKERHUB_USERNAME }}/${{ vars.DOCKER_IMAGE_TAG_NAME }}:latest + # Ec2 에 배포 + deploy-to-ec2: + needs: docker-build-and-push + runs-on: ubuntu-latest + # https://github.com/marketplace/actions/ssh-remote-commands + steps: + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_KEY }} + script: | + CONTAINER_ID=$(sudo docker ps -q --filter "publish=8080-8080") + + if [ ! -z "$CONTAINER_ID" ]; then + sudo docker stop $CONTAINER_ID + sudo docker rm $CONTAINER_ID + fi + + sudo docker pull ${{ vars.DOCKERHUB_USERNAME }}/${{ vars.DOCKER_IMAGE_TAG_NAME }}:latest + sudo docker run -d -p 8080:8080 \ + ${{ vars.DOCKERHUB_USERNAME }}/${{ vars.DOCKER_IMAGE_TAG_NAME }}:latest diff --git a/Docker-compose.yml b/Docker-compose.yml new file mode 100644 index 0000000..279e68d --- /dev/null +++ b/Docker-compose.yml @@ -0,0 +1,38 @@ +version: '3' +services: + app: + image: trello:latest + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + BUCKET_ACCESSKEY: ${BUCKET_ACCESSKEY} + BUCKET_SECRETKEY: ${BUCKET_SECRETKEY} + BUCKET_NAME: ${BUCKET_NAME} + SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/trello?serverTimezone=Asia/Seoul&useSSL=false&allowPublicKeyRetrieval=true + SPRING_DATASOURCE_USERNAME: root + SPRING_DATASOURCE_PASSWORD: 1111 + depends_on: + - db + + db: + image: mysql:8.0 + container_name: my-mysql + environment: + MYSQL_ROOT_PASSWORD: 1111 + MYSQL_DATABASE: trello + MYSQL_USER: demo + MYSQL_PASSWORD: demo + ports: + - "3307:3306" + volumes: + - db_data:/var/lib/mysql + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --default-authentication-plugin=mysql_native_password + +volumes: + db_data: \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1ef194 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM eclipse-temurin:17-jdk-alpine + +ARG JAR_FILE=build/libs/*.jar + +COPY ${JAR_FILE} app.jar + +ENTRYPOINT ["java", "-jar", "/app.jar"] + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..894f49a --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Trello TeamProject +--- +## πŸ¦Ήβ€ 12μ‘° 3FT1 νŒ€μ›μ†Œκ°œ +||||| +|:-:|:-:|:-:|:-:| +|κ°•μ€€ν˜|κ³ κ°•ν˜|μ²œμ€€λ―Ό|μž₯μš°νƒœ| +|kangjunhyuk1
[@kangjunhyuk1](https://github.com/kangjunhyuk1)|Newbiekk
[@Newbiekk-kkh](https://github.com/Newbiekk-kkh)|2unmini
[@2unmini](https://github.com/2unmini)|jangutae
[@jangutae](https://github.com/jangutae)| +--- +## πŸ› οΈ Tools : Java Java ![Amazon S3](https://img.shields.io/badge/Amazon%20S3-FF9900?style=for-the-badge&logo=amazons3&logoColor=white) ![Git](https://img.shields.io/badge/git-%23F05033.svg?style=for-the-badge&logo=git&logoColor=white) ![GitHub](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white) ![Slack](https://img.shields.io/badge/Slack-4A154B?style=for-the-badge&logo=slack&logoColor=white) +- MySql 8.0 +- Java 17 +- SpringBoot 3.4.1 +--- +## πŸ‘¨β€πŸ’» Period : 2024/12/23 ~ 2024/12/30 +--- +## πŸ‘¨β€πŸ’» ERD +![image](https://github.com/user-attachments/assets/aba1e1ad-adc4-47e4-95e6-b416657efee3) + + +--- +## πŸ‘¨β€πŸ’» APIλͺ…μ„Έμ„œ + +![ν”ŒλŸ¬μŠ€](https://github.com/user-attachments/assets/1833f1c6-2298-4244-bc2a-dbb4ecb02ff2) + +--- +## πŸ‘¨β€πŸ’» About Project + +- νšŒμ›κ°€μž…/둜그인 + - Spring security, JWT λ₯Ό μ‚¬μš©ν•΄ 인증 κ΅¬ν˜„ + - μ•„μ΄λ””λŠ” 이메일 ν˜•μ‹ + - νƒˆν‡΄ν•œ 이메일 μž¬μ‚¬μš© λΆˆκ°€λŠ₯ (νšŒμ› μ‚­μ œλ₯Ό STATUS ENUM 으둜 관리) + - λΉ„λ°€λ²ˆν˜ΈλŠ” λŒ€μ†Œλ¬Έμž 포함 영문 + 숫자 + 특수문자 μ΅œμ†Œ 1κΈ€μžμ”© 포함 + - μ—­ν• (ADMIN, USER) 에 따라 κΆŒν•œ λΆ€μ—¬ + +- μ›Œν¬μŠ€νŽ˜μ΄μŠ€ + - 제λͺ©, μ„€λͺ…, μŠ¬λž™URL 둜 ꡬ성 + - ADMIN 역할인 νšŒμ›λ§Œ 생성 κ°€λŠ₯ + - μˆ˜μ •, μ‚­μ œ λ“± κ΄€λ¦¬λŠ” WORKSPACE 멀버역할을 κ°€μ§„ λ©€λ²„λ§Œ κ°€λŠ₯ + +- μ›Œν¬μŠ€νŽ˜μ΄μŠ€ 멀버 + - 멀버 μ—­ν• (WORKSPACE, BOARD, READ_ONLY)둜 ꡬ성 + - ADMIN 역할을 κ°€μ§„ νšŒμ›λ§Œ WORKSPACE μ—­ν•  λΆ€μ—¬ κ°€λŠ₯ + - μ›Œν¬μŠ€νŽ˜μ΄μŠ€ 멀버 μ΄ˆλŒ€λŠ” WORKSPACE μ—­ν• λ§Œ κ°€λŠ₯ + +- λ³΄λ“œ + - READ_ONLY 역할을 μ œμ™Έν•œ μ›Œν¬μŠ€νŽ˜μ΄μŠ€μ˜ λ©€λ²„λ§Œ 생성, μˆ˜μ •, μ‚­μ œ κ°€λŠ₯ + - λ³΄λ“œλŠ” 제λͺ©, 배경색, 이미지 μ„€μ • κ°€λŠ₯ + - 배경으둜 μ“Έ 이미지 μ—…λ‘œλ“œ κΈ°λŠ₯ κ΅¬ν˜„ (jpg, png, jpeg, gif μ΄μ™Έμ˜ ν™•μž₯μžλŠ” μ—…λ‘œλ“œ λΆˆκ°€λŠ₯) + - 단일 μ‘°νšŒμ‹œ, λ³΄λ“œμ— μ†ν•œ 리슀트, μΉ΄λ“œ 전체 쑰회 κ°€λŠ₯ + +- 리슀트 + - READ_ONLY 역할을 μ œμ™Έν•œ μ›Œν¬μŠ€νŽ˜μ΄μŠ€μ˜ λ©€λ²„λ§Œ 생성, μˆ˜μ •, μ‚­μ œ κ°€λŠ₯ + - λ¦¬μŠ€νŠΈλŠ” 제λͺ©, μˆœμ„œ μ„€μ • κ°€λŠ₯ + +- μΉ΄λ“œ + - READ_ONLY 역할을 μ œμ™Έν•œ μ›Œν¬μŠ€νŽ˜μ΄μŠ€μ˜ λ©€λ²„λ§Œ 생성, μˆ˜μ •, μ‚­μ œ κ°€λŠ₯ + - μΉ΄λ“œλŠ” 제λͺ©, μ„€λͺ…, 마감일, λ‹΄λ‹Ήμž 멀버 등을 μΆ”κ°€ κ°€λŠ₯ + - μΉ΄λ“œμ˜ 제λͺ©, λ‚΄μš©, 마감일, λ‹΄λ‹Ήμž 이름 등을 κΈ°μ€€μœΌλ‘œ νŽ˜μ΄μ§•ν•˜μ—¬ 검색 (쿼리DSL ν™œμš©) + - μ²¨λΆ€νŒŒμΌ κΈ°λŠ₯ κ΅¬ν˜„ (μ²¨λΆ€νŒŒμΌ μ—…λ‘œλ“œ, μ‚­μ œ, 쑰회) / μ •ν•΄μ§„ ν™•μž₯자(jpg, png, jpeg, gif, pdf, csv) μ΄μ™ΈλŠ” μ—…λ‘œλ“œ λΆˆκ°€λŠ₯ + +- λŒ“κΈ€ + - μΉ΄λ“œ 내에 λŒ“κΈ€ μž‘μ„± κΈ°λŠ₯ ( ν…μŠ€νŠΈμ™€ 이λͺ¨μ§€ μž‘μ„± ) + - READ_ONLY 역할을 μ œμ™Έν•œ μ›Œν¬μŠ€νŽ˜μ΄μŠ€μ˜ λ©€λ²„λ§Œ 생성 κ°€λŠ₯ + - λŒ“κΈ€μ€ μž‘μ„±μžλ§Œ μˆ˜μ •, μ‚­μ œ κ°€λŠ₯ + +- μ•Œλ¦Ό + - 멀버 μΆ”κ°€ / μΉ΄λ“œ λ³€κ²½ / λŒ“κΈ€ μž‘μ„± μ‹œ μ›Œν¬μŠ€νŽ˜μ΄μŠ€ μƒμ„±μ‹œ λ“±λ‘ν–ˆλ˜ μŠ¬λž™Url둜 μ‹€μ‹œκ°„ μ•Œλ¦Ό 제곡 +--- + +## 😭 μ•„μ‰¬μš΄μ  +- 제좜 당일 μƒˆλ²½ 4μ‹œκΉŒμ§€ νŒ€μ›λ“€ λͺ¨λ‘κ°€ λͺ¨μ—¬μ„œ CI/CD 배포λ₯Ό ν•˜λ €ν–ˆμœΌλ‚˜, μ§€μ†λ˜λŠ” 였λ₯˜μ— ν¬κΈ°ν•œ 점이 μ•„μ‰½μŠ΅λ‹ˆλ‹€. λ‹€μŒ μ΅œμ’… ν”„λ‘œμ νŠΈμ—μ„  κΌ­ λˆ„λ½λ˜λŠ” κΈ°λŠ₯ 없이 κ΅¬ν˜„ν•΄λ³΄κ³  μ‹ΆμŠ΅λ‹ˆλ‹€. +- Spring security 둜 인증은 κ΅¬ν˜„ν•˜μ˜€μœΌλ‚˜, μΈκ°€λŠ” κ΅¬ν˜„ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. μ΄λ˜ν•œ 좔가적인 ν•™μŠ΅ 후에 κ΅¬ν˜„ν•΄λ³΄κ³  μ‹ΆμŠ΅λ‹ˆλ‹€. diff --git a/build.gradle b/build.gradle index 638f295..36d6442 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' @@ -35,11 +36,22 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // JWT + compileOnly 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + // QueryDSL implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta" annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + //Slack + implementation 'com.slack.api:slack-api-client:1.27.2' + + // Apache Commons IO 라이브러리 + implementation 'commons-io:commons-io:2.11.0' } tasks.named('test') { diff --git a/src/main/java/com/example/trello/board/Board.java b/src/main/java/com/example/trello/board/Board.java index 3813008..d2ed11c 100644 --- a/src/main/java/com/example/trello/board/Board.java +++ b/src/main/java/com/example/trello/board/Board.java @@ -3,6 +3,7 @@ import com.example.trello.cardlist.CardList; import com.example.trello.workspace.Workspace; import jakarta.persistence.*; +import lombok.Builder; import lombok.Getter; import java.util.List; @@ -15,11 +16,12 @@ public class Board { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "title") + @Column(name = "title", nullable = false) private String title; @Column(name = "color") - private String color; + @Enumerated(EnumType.STRING) + private BoardColor color; @Column(name = "image") private String image; @@ -30,4 +32,20 @@ public class Board { @OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true) private List cardLists; + public Board() { + } + + @Builder + public Board(String title, BoardColor color, String image, Workspace workspace) { + this.title = title; + this.color = color; + this.image = image; + this.workspace = workspace; + } + + public void updateBoard(String title, BoardColor color, String image) { + this.title = title; + this.color = color; + this.image = image; + } } diff --git a/src/main/java/com/example/trello/board/BoardColor.java b/src/main/java/com/example/trello/board/BoardColor.java new file mode 100644 index 0000000..5a6dbd5 --- /dev/null +++ b/src/main/java/com/example/trello/board/BoardColor.java @@ -0,0 +1,12 @@ +package com.example.trello.board; + +public enum BoardColor { + BLACK, + BLUE, + RED, + WHITE, + ORANGE, + PURPLE, + YELLOW, + GREEN +} diff --git a/src/main/java/com/example/trello/board/BoardController.java b/src/main/java/com/example/trello/board/BoardController.java new file mode 100644 index 0000000..2fd88dd --- /dev/null +++ b/src/main/java/com/example/trello/board/BoardController.java @@ -0,0 +1,52 @@ +package com.example.trello.board; + +import com.example.trello.board.dto.*; +import com.example.trello.config.auth.UserDetailsImpl; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/boards") +public class BoardController { + private final BoardService boardService; + + @PostMapping + public ResponseEntity createBoard( + @Valid @ModelAttribute BoardRequestDto dto, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + BoardResponseDto boardResponseDto = boardService.createBoard(dto, userDetails.getUser().getId()); + return new ResponseEntity<>(boardResponseDto, HttpStatus.CREATED); + } + + @GetMapping + public ResponseEntity> viewAllBoard(@Valid @RequestBody viewAllBoardRequestDto dto, @AuthenticationPrincipal UserDetailsImpl userDetails) { + List boardResponseDtoList = boardService.viewAllBoard(dto, userDetails.getUser().getId()); + return new ResponseEntity<>(boardResponseDtoList, HttpStatus.OK); + } + + @GetMapping("/{boardId}") + public ResponseEntity viewBoard(@PathVariable Long boardId, @AuthenticationPrincipal UserDetailsImpl userDetails) { + BoardDetailResponseDto boardDetailResponseDto = boardService.viewBoard(boardId, userDetails.getUser().getId()); + return new ResponseEntity<>(boardDetailResponseDto, HttpStatus.OK); + } + + @PatchMapping("/{boardId}") + public ResponseEntity updateBoard(@PathVariable Long boardId, @Valid @ModelAttribute UpdateBoardRequestDto dto, @AuthenticationPrincipal UserDetailsImpl userDetails) { + BoardResponseDto updatedBoardResponseDto = boardService.updateBoard(boardId, dto, userDetails.getUser().getId()); + return new ResponseEntity<>(updatedBoardResponseDto, HttpStatus.OK); + } + + @DeleteMapping("/{boardId}") + public ResponseEntity deleteBoard(@PathVariable Long boardId, @AuthenticationPrincipal UserDetailsImpl userDetails) { + boardService.deleteBoard(boardId, userDetails.getUser().getId()); + return new ResponseEntity<>("λ³΄λ“œκ°€ μ •μƒμ μœΌλ‘œ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.OK); + } +} diff --git a/src/main/java/com/example/trello/board/BoardRepository.java b/src/main/java/com/example/trello/board/BoardRepository.java index cf06247..2d8012d 100644 --- a/src/main/java/com/example/trello/board/BoardRepository.java +++ b/src/main/java/com/example/trello/board/BoardRepository.java @@ -1,12 +1,17 @@ package com.example.trello.board; +import com.example.trello.common.exception.BoardErrorCode; +import com.example.trello.common.exception.BoardException; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface BoardRepository extends JpaRepository { + List findAllByWorkspaceId(Long workspaceId); default Board findByIdOrElseThrow(Long id){ - return findById(id).orElseThrow(()->new RuntimeException()); + return findById(id).orElseThrow(() -> new BoardException(BoardErrorCode.CAN_NOT_FIND_BOARD_WITH_BOARD_ID)); } } diff --git a/src/main/java/com/example/trello/board/BoardService.java b/src/main/java/com/example/trello/board/BoardService.java new file mode 100644 index 0000000..fb89bd7 --- /dev/null +++ b/src/main/java/com/example/trello/board/BoardService.java @@ -0,0 +1,153 @@ +package com.example.trello.board; + +import com.amazonaws.SdkClientException; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.example.trello.board.dto.*; +import com.example.trello.card.Card; +import com.example.trello.card.cardrepository.CardRepository; +import com.example.trello.card.dto.GetCardResponseDto; +import com.example.trello.cardlist.CardList; +import com.example.trello.cardlist.CardListRepository; +import com.example.trello.cardlist.dto.GetCardListResponseDto; +import com.example.trello.common.exception.*; +import com.example.trello.util.FileUploadUtil; +import com.example.trello.workspace.WorkSpaceRepository; +import com.example.trello.workspace.Workspace; +import com.example.trello.workspace_member.WorkspaceMemberRepository; +import com.example.trello.workspace_member.WorkspaceMemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BoardService { + private final BoardRepository boardRepository; + private final WorkspaceMemberService workspaceMemberService; + private final WorkspaceMemberRepository workspaceMemberRepository; + private final CardListRepository cardListRepository; + private final CardRepository cardRepository; + private final AmazonS3Client amazonS3Client; + private final WorkSpaceRepository workSpaceRepository; + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Transactional + public BoardResponseDto createBoard(BoardRequestDto dto, Long loginUserId) { + workspaceMemberService.checkReadRole(loginUserId, dto.getWorkspaceId()); + + String imageUrl = null; + if (dto.getFile() != null && !dto.getFile().isEmpty()) { + isValidBoardImage(dto.getFile()); + imageUrl = uploadFileToS3(dto.getFile()); + } + + Workspace workspace = workSpaceRepository.findByIdOrElseThrow(dto.getWorkspaceId()); + + Board board = Board.builder() + .title(dto.getTitle()) + .color(dto.getColor()) + .image(imageUrl) + .workspace(workspace) + .build(); + + boardRepository.save(board); + return BoardResponseDto.toDto(board); + } + + @Transactional(readOnly = true) + public List viewAllBoard(viewAllBoardRequestDto dto, Long loginUserId) { + if (!workspaceMemberRepository.existsByUserIdAndWorkspaceId(loginUserId, dto.getWorkspaceId())) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.IS_NOT_WORKSPACEMEMBER); + } + + List findBoardList = boardRepository.findAllByWorkspaceId(dto.getWorkspaceId()); + + return findBoardList + .stream() + .map(BoardResponseDto::toDto) + .toList(); + } + + @Transactional(readOnly = true) + public BoardDetailResponseDto viewBoard(Long boardId, Long loginUserId) { + Board findBoard = boardRepository.findByIdOrElseThrow(boardId); + + if (!workspaceMemberRepository.existsByUserIdAndWorkspaceId(loginUserId, findBoard.getWorkspace().getId())) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.IS_NOT_WORKSPACEMEMBER); + } + + List findCardLists = cardListRepository.findByBoard(findBoard); + List getCardListResponseDtoList = new ArrayList<>(); + + for (CardList cardList : findCardLists) { + List findCards = cardRepository.findByCardList(cardList); + List getCardResponseDtoList = findCards.stream().map(GetCardResponseDto::toDto).toList(); + GetCardListResponseDto getCardListResponseDto = GetCardListResponseDto.toDto(cardList, getCardResponseDtoList); + getCardListResponseDtoList.add(getCardListResponseDto); + } + + return BoardDetailResponseDto.toDto(findBoard, getCardListResponseDtoList); + } + + @Transactional + public BoardResponseDto updateBoard(Long boardId, UpdateBoardRequestDto dto, Long loginUserId) { + Board findBoard = boardRepository.findByIdOrElseThrow(boardId); + workspaceMemberService.checkReadRole(loginUserId, findBoard.getWorkspace().getId()); + deleteFile(findBoard.getImage()); + + String imageUrl = null; + if (dto.getFile() != null && !dto.getFile().isEmpty()) { + isValidBoardImage(dto.getFile()); + imageUrl = uploadFileToS3(dto.getFile()); + } + + findBoard.updateBoard(dto.getTitle(), dto.getColor(), imageUrl); + + return BoardResponseDto.toDto(findBoard); + } + + @Transactional + public void deleteBoard(Long boardId, Long loginUserId) { + Board findBoard = boardRepository.findByIdOrElseThrow(boardId); + workspaceMemberService.checkReadRole(loginUserId, findBoard.getWorkspace().getId()); + deleteFile(findBoard.getImage()); + + boardRepository.delete(findBoard); + } + + private String uploadFileToS3(MultipartFile file) { + try { + String fileName = file.getOriginalFilename(); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType(file.getContentType()); + metadata.setContentLength(file.getSize()); + amazonS3Client.putObject(bucket, fileName, file.getInputStream(), metadata); + return fileName; + } catch (IOException e) { + throw new BoardException(BoardErrorCode.FAILED_IMAGE_UPLOAD); + } + } + + public void deleteFile(String image) { + try { + amazonS3Client.deleteObject(bucket, image); + } catch (SdkClientException e) { + throw new BoardException(BoardErrorCode.FAILED_IMAGE_DELETE); + } + } + + public void isValidBoardImage(MultipartFile file) { + String fileName = file.getOriginalFilename(); + if (!FileUploadUtil.isAllowedExtension(fileName)) { + throw new BoardException(BoardErrorCode.IS_NOT_ALLOWED_FILE_EXTENSION); + } + } +} diff --git a/src/main/java/com/example/trello/board/dto/BoardDetailResponseDto.java b/src/main/java/com/example/trello/board/dto/BoardDetailResponseDto.java new file mode 100644 index 0000000..507a347 --- /dev/null +++ b/src/main/java/com/example/trello/board/dto/BoardDetailResponseDto.java @@ -0,0 +1,30 @@ +package com.example.trello.board.dto; + +import com.example.trello.board.Board; +import com.example.trello.board.BoardColor; +import com.example.trello.cardlist.CardList; +import com.example.trello.cardlist.dto.GetCardListResponseDto; +import lombok.Getter; + +import java.util.List; + +@Getter +public class BoardDetailResponseDto { + private Long id; + private String title; + private BoardColor color; + private String image; + private List cardList; + + public BoardDetailResponseDto(Long id, String title, BoardColor color, String image, List cardList) { + this.id = id; + this.title = title; + this.color = color; + this.image = image; + this.cardList = cardList; + } + + public static BoardDetailResponseDto toDto(Board board, List cardList) { + return new BoardDetailResponseDto(board.getId(), board.getTitle(), board.getColor(), board.getImage(), cardList); + } +} diff --git a/src/main/java/com/example/trello/board/dto/BoardRequestDto.java b/src/main/java/com/example/trello/board/dto/BoardRequestDto.java new file mode 100644 index 0000000..d7f0283 --- /dev/null +++ b/src/main/java/com/example/trello/board/dto/BoardRequestDto.java @@ -0,0 +1,29 @@ +package com.example.trello.board.dto; + +import com.example.trello.board.BoardColor; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Getter +@Setter +public class BoardRequestDto { + @NotBlank(message = "title 은 Null 일 수 μ—†μŠ΅λ‹ˆλ‹€.") + @Size(min = 1, max = 50, message = "title ν¬κΈ°λŠ” 1μ—μ„œ 50μ‚¬μ΄μ—¬μ•Όν•©λ‹ˆλ‹€.") + private String title; + + private BoardColor color; + @NotNull + private Long workspaceId; + private MultipartFile file; + + public BoardRequestDto(String title, BoardColor color, Long workspaceId, MultipartFile file) { + this.title = title; + this.color = color; + this.workspaceId = workspaceId; + this.file = file; + } +} diff --git a/src/main/java/com/example/trello/board/dto/BoardResponseDto.java b/src/main/java/com/example/trello/board/dto/BoardResponseDto.java new file mode 100644 index 0000000..32858ad --- /dev/null +++ b/src/main/java/com/example/trello/board/dto/BoardResponseDto.java @@ -0,0 +1,24 @@ +package com.example.trello.board.dto; + +import com.example.trello.board.Board; +import com.example.trello.board.BoardColor; +import lombok.Getter; + +@Getter +public class BoardResponseDto { + private Long id; + private String title; + private BoardColor color; + private String image; + + public BoardResponseDto(Long id, String title, BoardColor color, String image) { + this.id = id; + this.title = title; + this.color = color; + this.image = image; + } + + public static BoardResponseDto toDto(Board board) { + return new BoardResponseDto(board.getId(), board.getTitle(), board.getColor(), board.getImage()); + } +} diff --git a/src/main/java/com/example/trello/board/dto/UpdateBoardRequestDto.java b/src/main/java/com/example/trello/board/dto/UpdateBoardRequestDto.java new file mode 100644 index 0000000..433ffa9 --- /dev/null +++ b/src/main/java/com/example/trello/board/dto/UpdateBoardRequestDto.java @@ -0,0 +1,24 @@ +package com.example.trello.board.dto; + +import com.example.trello.board.BoardColor; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import org.springframework.web.multipart.MultipartFile; + +@Getter +public class UpdateBoardRequestDto { + @NotBlank(message = "title 은 Null 일 수 μ—†μŠ΅λ‹ˆλ‹€.") + @Size(min = 1, max = 50, message = "title ν¬κΈ°λŠ” 1μ—μ„œ 50μ‚¬μ΄μ—¬μ•Όν•©λ‹ˆλ‹€.") + private String title; + + private BoardColor color; + + private MultipartFile file; + + public UpdateBoardRequestDto(String title, BoardColor color, MultipartFile file) { + this.title = title; + this.color = color; + this.file = file; + } +} diff --git a/src/main/java/com/example/trello/board/dto/viewAllBoardRequestDto.java b/src/main/java/com/example/trello/board/dto/viewAllBoardRequestDto.java new file mode 100644 index 0000000..729cb3b --- /dev/null +++ b/src/main/java/com/example/trello/board/dto/viewAllBoardRequestDto.java @@ -0,0 +1,14 @@ +package com.example.trello.board.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class viewAllBoardRequestDto { + @NotNull + private Long workspaceId; + + public viewAllBoardRequestDto(Long workspaceId) { + this.workspaceId = workspaceId; + } +} diff --git a/src/main/java/com/example/trello/card/Card.java b/src/main/java/com/example/trello/card/Card.java index 0a9acde..58710af 100644 --- a/src/main/java/com/example/trello/card/Card.java +++ b/src/main/java/com/example/trello/card/Card.java @@ -1,15 +1,25 @@ package com.example.trello.card; +import com.example.trello.card.requestDto.UpdateCardRequestDto; +import com.example.trello.card.responsedto.CardResponseDto; +import com.example.trello.card.responsedto.UpdateCardResponseDto; import com.example.trello.cardlist.CardList; import com.example.trello.comment.Comment; +import com.example.trello.workspace_member.WorkspaceMember; import jakarta.persistence.*; -import lombok.Getter; +import jakarta.validation.constraints.Pattern; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @Entity @Getter +@DynamicInsert +@AllArgsConstructor +@NoArgsConstructor public class Card { @Id @@ -22,23 +32,55 @@ public class Card { @Column(name = "description") private String description; - @Column(name = "order") - private Integer order; - - @Column(name = "image") - private String image; + @Column(name = "fileName") + private String fileName; @Column(name = "start_at") - private LocalDateTime startAt; + private LocalDate startAt; @Column(name = "end_at") - private LocalDateTime endAt; + private LocalDate endAt; @ManyToOne(fetch = FetchType.LAZY) private CardList cardList; + @ManyToOne(fetch = FetchType.LAZY) + private WorkspaceMember workspaceMember; + @OneToMany(mappedBy = "card",cascade = CascadeType.ALL,orphanRemoval = true) private List comments; + + + @Builder + public Card(String title, String description, WorkspaceMember workspaceMember, LocalDate startAt, LocalDate endAt,CardList cardList) { + this.title = title; + this.description = description; + this.workspaceMember = workspaceMember; + this.startAt = startAt; + this.endAt = endAt; + this.cardList = cardList; + } + + + public void updateCard(UpdateCardResponseDto responseDto) { + this.cardList = responseDto.getCardList(); + this.title = responseDto.getTitle(); + this.description = responseDto.getDescription(); + this.workspaceMember = responseDto.getWorkspaceMember(); + this.startAt = responseDto.getStartAt(); + this.endAt = responseDto.getEndAt(); + } + + public void uploadFile(String fileName) { + this.fileName = fileName; + } + + public void deleteFile () { + this.fileName = null; + } + + + } diff --git a/src/main/java/com/example/trello/card/CardController.java b/src/main/java/com/example/trello/card/CardController.java new file mode 100644 index 0000000..a6d8931 --- /dev/null +++ b/src/main/java/com/example/trello/card/CardController.java @@ -0,0 +1,95 @@ +package com.example.trello.card; + +import com.example.trello.card.requestDto.FileNameRequestDto; +import com.example.trello.card.responsedto.CardPageResponseDto; +import com.example.trello.card.requestDto.CardRequestDto; +import com.example.trello.card.responsedto.CardResponseDto; +import com.example.trello.card.requestDto.UpdateCardRequestDto; +import com.example.trello.config.auth.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + + +import java.time.LocalDate; + + +@RequiredArgsConstructor +@RestController +@RequestMapping("/cards") +public class CardController { + + private final CardService cardService; + + + /** + * μΉ΄λ“œ CRUD + */ + + @PostMapping + public ResponseEntity createdCard(@RequestBody CardRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) { + CardResponseDto responseDto = cardService.createdCardService(requestDto, userDetails); + return new ResponseEntity<>(responseDto, HttpStatus.CREATED); + } + + @PatchMapping("/{cardId}") + public ResponseEntity updateCard(@PathVariable Long cardId, @RequestBody UpdateCardRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) { + CardResponseDto responseDto = cardService.updateCardService(cardId, requestDto, userDetails); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + + @DeleteMapping("/{cardId}") + public ResponseEntity deleteCard(@PathVariable Long cardId, @AuthenticationPrincipal UserDetailsImpl userDetails) { + cardService.deleteCardService(cardId, userDetails); + return new ResponseEntity<>("μ‚­μ œ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€", HttpStatus.OK); + } + + @GetMapping("/{cardId}") + public ResponseEntity findCard(@PathVariable Long cardId) { + CardResponseDto responseDto = cardService.findCardById(cardId); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + + @GetMapping + public ResponseEntity getCards(@RequestParam(defaultValue = "0") int page, + @RequestParam(required = false) Long cardListId, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startAt, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endAt, + @RequestParam(required = false) Long boardId) { + CardPageResponseDto cards = cardService.searchCards(page, cardListId, startAt, endAt, boardId); + return new ResponseEntity<>(cards, HttpStatus.OK); + } + + + /** + * 파일 μ—…λ‘œλ“œ + */ + + @PostMapping(value = "/{cardId}/attachment", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadAttachments(@PathVariable Long cardId, + @RequestPart("file") MultipartFile file, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + String fileUrl = cardService .uploadFile(cardId, file, userDetails); + return new ResponseEntity<>(fileUrl + "μ—…λ‘œλ“œ μ™„λ£Œ", HttpStatus.OK); + } + + @DeleteMapping("/{cardId}/attachment") + public ResponseEntity deleteFile(@PathVariable Long cardId, + @RequestBody FileNameRequestDto requestDto, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + cardService.deleteFile(cardId, requestDto.getFileName(), userDetails); + return new ResponseEntity<>("μ‚­μ œ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€", HttpStatus.OK); + } + + @GetMapping("/{cardId}/attachment") + public ResponseEntity getFileUrl(@PathVariable Long cardId, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + return new ResponseEntity<>(cardService.getFile(cardId, userDetails), HttpStatus.OK); + + } +} diff --git a/src/main/java/com/example/trello/card/CardRepository.java b/src/main/java/com/example/trello/card/CardRepository.java deleted file mode 100644 index 1e6c533..0000000 --- a/src/main/java/com/example/trello/card/CardRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.trello.card; - -import com.example.trello.board.Board; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface CardRepository extends JpaRepository { - - default Card findByIdOrElseThrow(Long id){ - return findById(id).orElseThrow(()->new RuntimeException()); - } -} diff --git a/src/main/java/com/example/trello/card/CardService.java b/src/main/java/com/example/trello/card/CardService.java new file mode 100644 index 0000000..1dca14d --- /dev/null +++ b/src/main/java/com/example/trello/card/CardService.java @@ -0,0 +1,260 @@ +package com.example.trello.card; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.example.trello.card.cardrepository.CardRepository; +import com.example.trello.card.responsedto.CardPageResponseDto; +import com.example.trello.card.requestDto.CardRequestDto; +import com.example.trello.card.responsedto.CardResponseDto; +import com.example.trello.card.requestDto.UpdateCardRequestDto; +import com.example.trello.card.responsedto.UpdateCardResponseDto; +import com.example.trello.cardlist.CardList; +import com.example.trello.cardlist.CardListRepository; +import com.example.trello.common.exception.CardErrorCode; +import com.example.trello.common.exception.CardException; +import com.example.trello.common.exception.WorkspaceMemberErrorCode; +import com.example.trello.common.exception.WorkspaceMemberException; +import com.example.trello.config.auth.UserDetailsImpl; +import com.example.trello.notification.NotificationService; +import com.example.trello.workspace.WorkSpaceRepository; +import com.example.trello.workspace_member.WorkspaceMember; +import com.example.trello.workspace_member.WorkspaceMemberRepository; +import com.example.trello.workspace_member.WorkspaceMemberService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + + +import java.io.IOException; +import java.time.LocalDate; + + +import static com.example.trello.notification.NotificationType.UPDATE_CARD; +import static com.example.trello.util.FileUploadUtil.isAllowedExtension; + +@Service +@RequiredArgsConstructor +public class CardService { + + private final CardRepository cardRepository; + private final CardListRepository cardListRepository; + private final WorkspaceMemberRepository workspaceMemberRepository; + private final AmazonS3Client amazonS3Client; + private final WorkspaceMemberService workspaceMemberService; + private final NotificationService notificationService; + private final WorkSpaceRepository workSpaceRepository; + + @Value("${cloud.aws.s3.bucket}") + private String bucketName; + + /** + * μΉ΄λ“œ CRUD + */ + + // μΉ΄λ“œ 생성 + @Transactional + public CardResponseDto createdCardService(CardRequestDto requestDto, UserDetailsImpl userDetails) { + + CardList cardList = cardListRepository.findByIdOrElseThrow(requestDto.getCardListId()); + + Long workspaceId = cardList.getBoard().getWorkspace().getId(); + + WorkspaceMember workspaceMember = workspaceMemberRepository.findByUserIdAndWorkspaceIdOrElseThrow(userDetails.getUser().getId(), workspaceId); + + if (!workspaceMemberRepository.existsByUserIdAndWorkspaceId(workspaceMember.getId(), workspaceId)) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.IS_NOT_WORKSPACEMEMBER); + } + + Long workSpaceId = cardList.getBoard().getWorkspace().getId(); + + workspaceMemberService.checkReadRole(userDetails.getUser().getId(), workSpaceId); + + Card card = Card.builder() + .title(requestDto.getTitle()) + .description(requestDto.getDescription()) + .workspaceMember(workspaceMember) + .startAt(requestDto.getStartAt()) + .endAt(requestDto.getEndAt()) + .cardList(cardList) + .build(); + cardRepository.save(card); + + return CardResponseDto.toDto(card); + } + + //μΉ΄λ“œ μ—…λ°μ΄νŠΈ + @Transactional + public CardResponseDto updateCardService(Long cardId, UpdateCardRequestDto requestDto, UserDetailsImpl userDetails) { + Card card = cardRepository.findByIdOrElseThrow(cardId); + + WorkspaceMember changeWorkspaceMember = workspaceMemberRepository.findByIdOrElseThrow(requestDto.getWorkspaceMemberId()); + + CardList cardList = cardRepository.findByIdOrElseThrow(requestDto.getCardListId()).getCardList(); + + UpdateCardResponseDto responseDto = new UpdateCardResponseDto(cardList, requestDto.getTitle(), requestDto.getDescription(), changeWorkspaceMember, requestDto.getStartAt(), requestDto.getEndAt()); + + WorkspaceMember workspaceMember = findWorkSpaceMember(userDetails, cardId); + + if (!workspaceMemberRepository.existsByUserIdAndWorkspaceId(workspaceMember.getId(), card.getCardList().getBoard().getWorkspace().getId())) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.IS_NOT_WORKSPACEMEMBER); + } + + Long workSpaceId = card.getCardList().getBoard().getWorkspace().getId(); + workspaceMemberService.checkReadRole(userDetails.getUser().getId(), workSpaceId); + + card.updateCard(responseDto); + + cardRepository.save(card); + + + + notificationService.sendSlack(UPDATE_CARD, card.getCardList().getBoard().getWorkspace()); + + return CardResponseDto.toDto(card); + } + + // μΉ΄λ“œ μ‚­μ œ + @Transactional + public void deleteCardService(Long cardId, UserDetailsImpl userDetails) { + Card card = cardRepository.findByIdOrElseThrow(cardId); + + WorkspaceMember workspaceMember = findWorkSpaceMember(userDetails, cardId); + + if (!workspaceMemberRepository.existsByUserIdAndWorkspaceId(workspaceMember.getId(), card.getCardList().getBoard().getWorkspace().getId())) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.IS_NOT_WORKSPACEMEMBER); + } + + Long workSpaceId = card.getCardList().getBoard().getWorkspace().getId(); + + workspaceMemberService.checkReadRole(userDetails.getUser().getId(), workSpaceId); + + cardRepository.delete(card); + } + + // μΉ΄λ“œ 단건 쑰회 + @Transactional + public CardResponseDto findCardById(Long cardId) { + Card card = cardRepository.findByIdOrElseThrow(cardId); + return CardResponseDto.toDto(card); + } + + // μΉ΄λ“œ 닀건 쑰회(쑰건 O) + @Transactional + public CardPageResponseDto searchCards(int page , Long cardListId, LocalDate startAt, LocalDate endAt, Long boardId) { + + PageRequest pageRequest = PageRequest.of(page,10, Sort.by(Sort.Direction.DESC, "id")); + + CardPageResponseDto cards = cardRepository.searchCard(pageRequest, cardListId, startAt, endAt, boardId); + + return cards; + } + + + + /** + * 파일 μ—…λ‘œλ“œ + */ + + + // 파일 μ—…λ‘œλ“œ + @Transactional + public String uploadFile(Long cardId, MultipartFile file, UserDetailsImpl userDetails) { + + Card card = cardRepository.findByIdOrElseThrow(cardId); + + Long workSpaceId = card.getCardList().getBoard().getWorkspace().getId(); + + if (!workspaceMemberRepository.existsByUserIdAndWorkspaceId(userDetails.getUser().getId(), workSpaceId)) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.IS_NOT_WORKSPACEMEMBER); + } + + workspaceMemberService.checkReadRole(userDetails.getUser().getId(), workSpaceId); + + // 파일 ν˜•μ‹ μ˜ˆμ™Έμ²˜λ¦¬ + if (!isAllowedExtension(file.getOriginalFilename())) { + throw new CardException(CardErrorCode.FORMAT_NOT_SUPPORTED); + } + + + // 파일 크기 μ˜ˆμ™Έμ²˜λ¦¬ + long maxSize = 5 * 1024 * 1024; + + if (file.getSize() > maxSize) { + throw new CardException(CardErrorCode.FILE_SIZE_EXCEEDED); + } + + String fileName = file.getOriginalFilename(); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + try { + amazonS3Client.putObject(bucketName, fileName, file.getInputStream(), metadata); + } catch (IOException e) { + throw new RuntimeException(); + } + + card.uploadFile(file.getOriginalFilename()); + return amazonS3Client.getUrl(bucketName, fileName).toString(); + } + + // 파일 μ‚­μ œ + @Transactional + public void deleteFile(Long cardId, String fileName, UserDetailsImpl userDetails) { + Card card = cardRepository.findByIdOrElseThrow(cardId); + + Long workSpaceId = card.getCardList().getBoard().getWorkspace().getId(); + + if (!workspaceMemberRepository.existsByUserIdAndWorkspaceId(userDetails.getUser().getId(), workSpaceId)) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.IS_NOT_WORKSPACEMEMBER); + } + + workspaceMemberService.checkReadRole(userDetails.getUser().getId(), workSpaceId); + + if (!card.getFileName().equals(fileName)) { + throw new RuntimeException(); + } + + card.deleteFile(); + amazonS3Client.deleteObject(bucketName, fileName); + } + + @Transactional + public String getFile(Long cardId, UserDetailsImpl userDetails) { + + Card card = cardRepository.findByIdOrElseThrow(cardId); + + Long workSpaceId = card.getCardList().getBoard().getWorkspace().getId(); + + if (!workspaceMemberRepository.existsByUserIdAndWorkspaceId(userDetails.getUser().getId(), workSpaceId)) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.IS_NOT_WORKSPACEMEMBER); + } + + + try { + return amazonS3Client.getUrl(bucketName, card.getFileName()).toString(); + } catch (Exception e) { + throw new RuntimeException("File not found: " + card.getFileName(), e); + } + } + + + /** + * νŽΈμ˜μ„± λ©”μ†Œλ“œ + */ + + // WorkspaceMember μ°Ύμ•„μ£ΌλŠ” λ©”μ†Œλ“œ + @Transactional + public WorkspaceMember findWorkSpaceMember(UserDetailsImpl userDetails, Long cardId) { + Card card = cardRepository.findByIdOrElseThrow(cardId); + + Long workspaceId = card.getCardList().getBoard().getWorkspace().getId(); + + return workspaceMemberRepository.findByUserIdAndWorkspaceIdOrElseThrow(userDetails.getUser().getId(), workspaceId); + } + +} diff --git a/src/main/java/com/example/trello/card/cardrepository/CardRepository.java b/src/main/java/com/example/trello/card/cardrepository/CardRepository.java new file mode 100644 index 0000000..55c0181 --- /dev/null +++ b/src/main/java/com/example/trello/card/cardrepository/CardRepository.java @@ -0,0 +1,20 @@ +package com.example.trello.card.cardrepository; + +import com.example.trello.card.Card; +import com.example.trello.cardlist.CardList; +import com.example.trello.common.exception.CardErrorCode; +import com.example.trello.common.exception.CardException; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CardRepository extends JpaRepository, CardRepositoryCustom { + + List findByCardList(CardList cardList); + + default Card findByIdOrElseThrow(Long id){ + return findById(id).orElseThrow(()->new CardException(CardErrorCode.CARD_NOT_FOUND)); + } +} diff --git a/src/main/java/com/example/trello/card/cardrepository/CardRepositoryCustom.java b/src/main/java/com/example/trello/card/cardrepository/CardRepositoryCustom.java new file mode 100644 index 0000000..6d4bbc9 --- /dev/null +++ b/src/main/java/com/example/trello/card/cardrepository/CardRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.example.trello.card.cardrepository; + +import com.example.trello.card.responsedto.CardPageResponseDto; +import org.springframework.data.domain.PageRequest; + +import java.time.LocalDate; + +public interface CardRepositoryCustom { + CardPageResponseDto searchCard(PageRequest pageRequest, Long cardListId, LocalDate startAt, LocalDate endAt, Long boardId); +} diff --git a/src/main/java/com/example/trello/card/cardrepository/CardRepositoryCustomImpl.java b/src/main/java/com/example/trello/card/cardrepository/CardRepositoryCustomImpl.java new file mode 100644 index 0000000..bbac456 --- /dev/null +++ b/src/main/java/com/example/trello/card/cardrepository/CardRepositoryCustomImpl.java @@ -0,0 +1,65 @@ +package com.example.trello.card.cardrepository; + +import com.example.trello.card.Card; +import com.example.trello.card.QCard; +import com.example.trello.card.responsedto.CardPageResponseDto; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class CardRepositoryCustomImpl implements CardRepositoryCustom{ + + private final JPAQueryFactory queryFactory; + + @Override + public CardPageResponseDto searchCard(PageRequest pageRequest, Long cardListId, LocalDate startAt, LocalDate endAt, Long boardId) { + QCard card = QCard.card; + + List cardList = queryFactory + .selectFrom(card) + .where( + eqCardListId(cardListId), + eqStartAt(startAt), + eqEndAt(endAt), + eqBoard(boardId)) + .offset(pageRequest.getOffset()) + .limit(pageRequest.getPageSize()) + .fetch(); + + Long count = queryFactory + .select(card.count()) + .from(card) + .where( + eqCardListId(cardListId), + eqStartAt(startAt), + eqEndAt(endAt), + eqBoard(boardId)) + .fetchOne(); + return new CardPageResponseDto(cardList,count); + + } + + private BooleanExpression eqCardListId(Long cardListId) { + return cardListId != null ? QCard.card.cardList.id.eq(cardListId) : null; + } + + private BooleanExpression eqStartAt(LocalDate startAt) { + return startAt != null ? QCard.card.startAt.eq(startAt) : null; + } + + private BooleanExpression eqEndAt(LocalDate endAt) { + return endAt != null ? QCard.card.endAt.eq(endAt) : null; + } + + private BooleanExpression eqBoard(Long boardId) { + return boardId != null ? QCard.card.cardList.board.id.eq(boardId) : null; + } + +} diff --git a/src/main/java/com/example/trello/card/dto/GetCardResponseDto.java b/src/main/java/com/example/trello/card/dto/GetCardResponseDto.java new file mode 100644 index 0000000..0ccdd49 --- /dev/null +++ b/src/main/java/com/example/trello/card/dto/GetCardResponseDto.java @@ -0,0 +1,31 @@ +package com.example.trello.card.dto; + +import com.example.trello.card.Card; +import com.example.trello.cardlist.CardList; +import com.example.trello.cardlist.dto.GetCardListResponseDto; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +public class GetCardResponseDto { + private Long id; + private String title; + private String description; + private LocalDate startAt; + private LocalDate endAt; + + public GetCardResponseDto(Long id, String title, String description, LocalDate startAt, LocalDate endAt) { + this.id = id; + this.title = title; + this.description = description; + this.startAt = startAt; + this.endAt = endAt; + } + + public static GetCardResponseDto toDto(Card card) { + return new GetCardResponseDto(card.getId(), card.getTitle(), card.getDescription(), card.getStartAt(), card.getEndAt()); + } +} diff --git a/src/main/java/com/example/trello/card/requestDto/CardRequestDto.java b/src/main/java/com/example/trello/card/requestDto/CardRequestDto.java new file mode 100644 index 0000000..fe16bf9 --- /dev/null +++ b/src/main/java/com/example/trello/card/requestDto/CardRequestDto.java @@ -0,0 +1,36 @@ +package com.example.trello.card.requestDto; + +import com.example.trello.card.Card; +import com.example.trello.workspace.Workspace; +import com.example.trello.workspace_member.WorkspaceMember; +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.persistence.Column; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +public class CardRequestDto { + + private Long cardListId; + + private String title; + + private String description; + + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate startAt; + + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate endAt; + + public CardRequestDto(Long cardListId, String title, String description, LocalDate startAt, LocalDate endAt) { + this.cardListId = cardListId; + this.title = title; + this.description = description; + this.startAt = startAt; + this.endAt = endAt; + } + +} diff --git a/src/main/java/com/example/trello/card/requestDto/FileNameRequestDto.java b/src/main/java/com/example/trello/card/requestDto/FileNameRequestDto.java new file mode 100644 index 0000000..f0a01a3 --- /dev/null +++ b/src/main/java/com/example/trello/card/requestDto/FileNameRequestDto.java @@ -0,0 +1,13 @@ +package com.example.trello.card.requestDto; + +import lombok.Getter; + +@Getter +public class FileNameRequestDto { + + private String fileName; + + public FileNameRequestDto(String fileName) { + this.fileName = fileName; + } +} diff --git a/src/main/java/com/example/trello/card/requestDto/UpdateCardRequestDto.java b/src/main/java/com/example/trello/card/requestDto/UpdateCardRequestDto.java new file mode 100644 index 0000000..745910f --- /dev/null +++ b/src/main/java/com/example/trello/card/requestDto/UpdateCardRequestDto.java @@ -0,0 +1,34 @@ +package com.example.trello.card.requestDto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +public class UpdateCardRequestDto { + + private Long CardListId; + + private String title; + + private String description; + + private Long workspaceMemberId; + + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate startAt; + + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate endAt; + + public UpdateCardRequestDto(String title, Long cardListId, String description, Long workspaceMemberId, LocalDate startAt, LocalDate endAt) { + this.title = title; + this.CardListId = cardListId; + this.description = description; + this.workspaceMemberId = workspaceMemberId; + this.startAt = startAt; + this.endAt = endAt; + } +} diff --git a/src/main/java/com/example/trello/card/responsedto/CardPageResponseDto.java b/src/main/java/com/example/trello/card/responsedto/CardPageResponseDto.java new file mode 100644 index 0000000..d96f737 --- /dev/null +++ b/src/main/java/com/example/trello/card/responsedto/CardPageResponseDto.java @@ -0,0 +1,20 @@ +package com.example.trello.card.responsedto; + +import com.example.trello.card.Card; +import lombok.Getter; + +import java.util.List; + +@Getter +public class CardPageResponseDto { + + private List cardList; + private Long count; + + public CardPageResponseDto(List cardList, Long count) { + this.cardList = cardList.stream() + .map(CardResponseDto::toDto) + .toList(); + this.count = count; + } +} diff --git a/src/main/java/com/example/trello/card/responsedto/CardResponseDto.java b/src/main/java/com/example/trello/card/responsedto/CardResponseDto.java new file mode 100644 index 0000000..41a4b05 --- /dev/null +++ b/src/main/java/com/example/trello/card/responsedto/CardResponseDto.java @@ -0,0 +1,53 @@ +package com.example.trello.card.responsedto; + +import com.example.trello.card.Card; +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.persistence.Column; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + + +@Getter +public class CardResponseDto { + + + private Long cardListId; + + private Long cardId; + + private String title; + + private String description; + + private Long workspaceMemberId; + + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate startAt; + + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate endAt; + + + public CardResponseDto(Long cardListId, Long cardId, String title, String description, Long workspaceMemberId, LocalDate startAt, LocalDate endAt) { + this.cardListId = cardListId; + this.cardId = cardId; + this.title = title; + this.description = description; + this.workspaceMemberId = workspaceMemberId; + this.startAt = startAt; + this.endAt =endAt; + } + + + public static CardResponseDto toDto(Card card) { + return new CardResponseDto(card.getCardList().getId(), + card.getId(), + card.getTitle(), + card.getDescription(), + card.getWorkspaceMember().getId(), + card.getStartAt(), + card.getEndAt()); + } +} diff --git a/src/main/java/com/example/trello/card/responsedto/UpdateCardResponseDto.java b/src/main/java/com/example/trello/card/responsedto/UpdateCardResponseDto.java new file mode 100644 index 0000000..003149c --- /dev/null +++ b/src/main/java/com/example/trello/card/responsedto/UpdateCardResponseDto.java @@ -0,0 +1,35 @@ +package com.example.trello.card.responsedto; + +import com.example.trello.cardlist.CardList; +import com.example.trello.workspace_member.WorkspaceMember; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +public class UpdateCardResponseDto { + + private CardList cardList; + + private String title; + + private String description; + + private WorkspaceMember workspaceMember; + + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate startAt; + + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate endAt; + + public UpdateCardResponseDto(CardList cardListId, String title, String description, WorkspaceMember workspaceMember, LocalDate startAt, LocalDate endAt) { + this.cardList = cardListId; + this.title = title; + this.description = description; + this.workspaceMember = workspaceMember; + this.startAt = startAt; + this.endAt = endAt; + } +} diff --git a/src/main/java/com/example/trello/cardlist/CardList.java b/src/main/java/com/example/trello/cardlist/CardList.java index 452bcad..e71b8ac 100644 --- a/src/main/java/com/example/trello/cardlist/CardList.java +++ b/src/main/java/com/example/trello/cardlist/CardList.java @@ -2,13 +2,18 @@ import com.example.trello.board.Board; import com.example.trello.card.Card; +import com.example.trello.cardlist.dto.CardListResponseDto; +import com.example.trello.cardlist.dto.UpdateCardListRequestDto; import jakarta.persistence.*; -import lombok.Getter; +import lombok.*; import java.util.List; @Getter @Entity +@Builder +@AllArgsConstructor +@NoArgsConstructor public class CardList { @Id @@ -17,13 +22,38 @@ public class CardList { @Column(name = "title") private String title; - - @Column(name = "order") - private Integer order; + @Setter + @Column(name = "sequence") + private Integer sequence; @ManyToOne(fetch = FetchType.LAZY) private Board board; @OneToMany(mappedBy = "cardList", cascade = CascadeType.ALL, orphanRemoval = true) private List cards; + + public static CardListResponseDto toDto(CardList cardList) { + return CardListResponseDto.builder() + .id(cardList.getId()) + .sequence(cardList.sequence) + .build(); + } + + public void update(UpdateCardListRequestDto requestDto, Integer sequence) { + this.title = requestDto.getTitle(); + this.sequence = sequence; + + } + + public void downSequence() { + this.sequence--; + } + + public void upSequence() { + this.sequence++; + } + + public void updateSequence(Integer sequence) { + this.sequence = sequence; + } } diff --git a/src/main/java/com/example/trello/cardlist/CardListController.java b/src/main/java/com/example/trello/cardlist/CardListController.java new file mode 100644 index 0000000..b8758b6 --- /dev/null +++ b/src/main/java/com/example/trello/cardlist/CardListController.java @@ -0,0 +1,53 @@ +package com.example.trello.cardlist; + +import com.example.trello.cardlist.dto.CardListResponseDto; +import com.example.trello.cardlist.dto.CreateCardListRequestDto; +import com.example.trello.cardlist.dto.UpdateCardListRequestDto; +import com.example.trello.config.auth.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/lists") +public class CardListController { + + private final CardListService cardListService; + + @PostMapping + public ResponseEntity createCardList(@RequestBody CreateCardListRequestDto requestDto,@AuthenticationPrincipal UserDetailsImpl userDetails) { + CardListResponseDto cardListResponseDto = cardListService.create(requestDto,userDetails.getUser()); + return new ResponseEntity<>(cardListResponseDto, HttpStatus.CREATED); + } + + @GetMapping("/{id}") + public ResponseEntity findCardList(@PathVariable Long id) { + CardListResponseDto cardListResponseDto = cardListService.findCardList(id); + return new ResponseEntity<>(cardListResponseDto, HttpStatus.OK); + } + + @PatchMapping("/{id}") + public ResponseEntity updateCardList(@PathVariable Long id, @RequestBody UpdateCardListRequestDto requestDto,@AuthenticationPrincipal UserDetailsImpl userDetails) { + CardListResponseDto cardListResponseDto = cardListService.moveSequence(id, requestDto,userDetails.getUser()); + return new ResponseEntity<>(cardListResponseDto, HttpStatus.OK); + + } + + @PatchMapping("/{id}/exchange") + public ResponseEntity swapCardList(@PathVariable Long id, @RequestBody UpdateCardListRequestDto requestDto,@AuthenticationPrincipal UserDetailsImpl userDetails) { + CardListResponseDto cardListResponseDto = cardListService.swapSequence(id, requestDto,userDetails.getUser()); + return new ResponseEntity<>(cardListResponseDto, HttpStatus.OK); + + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCardList(@PathVariable Long id,@AuthenticationPrincipal UserDetailsImpl userDetails) { + cardListService.delete(id,userDetails.getUser()); + return new ResponseEntity<>(HttpStatus.OK); + } + + +} diff --git a/src/main/java/com/example/trello/cardlist/CardListRepository.java b/src/main/java/com/example/trello/cardlist/CardListRepository.java index 79df0c9..f3be8cc 100644 --- a/src/main/java/com/example/trello/cardlist/CardListRepository.java +++ b/src/main/java/com/example/trello/cardlist/CardListRepository.java @@ -1,13 +1,35 @@ package com.example.trello.cardlist; +import com.example.trello.board.Board; +import com.example.trello.common.exception.CardListException; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + +import static com.example.trello.common.exception.CardListErrorCode.CARD_LIST_NOT_FOUND; + @Repository public interface CardListRepository extends JpaRepository { + List findByBoard(Board board); + + default CardList findByIdOrElseThrow(Long id) { + return findById(id).orElseThrow(() -> new CardListException(CARD_LIST_NOT_FOUND)); + } - default CardList findByIdOrElseThrow(Long id){ - return findById(id).orElseThrow(()->new RuntimeException()); + @Query(value = "select max(cardlist.sequence)from CardList cardlist where cardlist.board.id = :board_id") + Optional findByMax(@Param("board_id") Long board_id); + + Optional findBySequenceAndBoardId(Integer sequence, Long boardId); + + default CardList findBySequenceAndBoardIdOrElseThrow(Integer sequence, Long boardId) { + return findBySequenceAndBoardId(sequence, boardId).orElseThrow(() -> new CardListException(CARD_LIST_NOT_FOUND)); } + + List findAllByBoardId(Long boardId); + } diff --git a/src/main/java/com/example/trello/cardlist/CardListService.java b/src/main/java/com/example/trello/cardlist/CardListService.java new file mode 100644 index 0000000..7157b0e --- /dev/null +++ b/src/main/java/com/example/trello/cardlist/CardListService.java @@ -0,0 +1,107 @@ +package com.example.trello.cardlist; + +import com.example.trello.board.Board; +import com.example.trello.board.BoardRepository; +import com.example.trello.cardlist.dto.CardListResponseDto; +import com.example.trello.cardlist.dto.CreateCardListRequestDto; +import com.example.trello.cardlist.dto.UpdateCardListRequestDto; +import com.example.trello.common.exception.CardListException; +import com.example.trello.user.User; +import com.example.trello.workspace_member.WorkspaceMemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.example.trello.common.exception.CardListErrorCode.INVALID_SEQUENCE; + +@Service +@RequiredArgsConstructor +public class CardListService { + + private final CardListRepository cardListRepository; + private final BoardRepository boardRepository; + private final WorkspaceMemberService workspaceMemberService; + + private Integer maxSequence; // λ³΄λ“œμ—μ„œ κ°€μž₯ 큰 μˆœμ„œ + + @Transactional + public CardListResponseDto create(CreateCardListRequestDto requestDto, User user) { + Board board = boardRepository.findByIdOrElseThrow(requestDto.getBoardId()); + workspaceMemberService.checkReadRole(user.getId(), board.getWorkspace().getId()); + maxSequence = cardListRepository.findByMax(board.getId()).orElse(0); + CardList cardList = requestDto.toEntity(requestDto, board, ++maxSequence); + CardList savedCardList = cardListRepository.save(cardList); + + return CardList.toDto(savedCardList); + } + + + public CardListResponseDto findCardList(Long id) { + CardList cardList = cardListRepository.findByIdOrElseThrow(id); + return CardList.toDto(cardList); + } + + @Transactional + public CardListResponseDto moveSequence(Long id, UpdateCardListRequestDto requestDto, User user) { + CardList cardList = cardListRepository.findByIdOrElseThrow(id); + Board board = cardList.getBoard(); + workspaceMemberService.checkReadRole(user.getId(), board.getWorkspace().getId()); + Integer currentSequence = cardList.getSequence(); + List cardLists = cardListRepository.findAllByBoardId(cardList.getBoard().getId()); + + if (requestDto.getSequence() > cardLists.size()) { + throw new CardListException(INVALID_SEQUENCE); + } + + if (currentSequence != requestDto.getSequence()) { + if (currentSequence < requestDto.getSequence()) { + cardLists.stream() + .filter(e -> e.getSequence() > currentSequence) + .filter(e -> e.getSequence() <= requestDto.getSequence()) + .forEach(e -> e.downSequence()); + } else { + cardLists.stream() + .filter(e -> e.getSequence() < currentSequence) + .filter(e -> e.getSequence() >= requestDto.getSequence()) + .forEach(e -> e.upSequence()); + } + cardList.updateSequence(requestDto.getSequence()); + } + + return CardList.toDto(cardList); + } + + @Transactional + public CardListResponseDto swapSequence(Long id, UpdateCardListRequestDto requestDto, User user) { + CardList cardList = cardListRepository.findByIdOrElseThrow(id); + Board board = cardList.getBoard(); + workspaceMemberService.checkReadRole(user.getId(), board.getWorkspace().getId()); + CardList exchangeCardList = cardListRepository.findBySequenceAndBoardIdOrElseThrow(requestDto.getSequence(), cardList.getBoard().getId()); + Integer temp = cardList.getSequence(); + cardList.update(requestDto, exchangeCardList.getSequence()); + exchangeCardList.updateSequence(temp); + + return CardList.toDto(cardList); + } + + + @Transactional + public void delete(Long id, User user) { + + CardList cardList = cardListRepository.findByIdOrElseThrow(id); + Board board = cardList.getBoard(); + workspaceMemberService.checkReadRole(user.getId(), board.getWorkspace().getId()); + Integer deleteSequence = cardList.getSequence(); + cardListRepository.delete(cardList); + List cardLists = cardListRepository.findAllByBoardId(board.getId()); + for (CardList list : cardLists) { + if (list.getSequence() > deleteSequence) { + list.downSequence(); + } + } + } + + +} diff --git a/src/main/java/com/example/trello/cardlist/dto/CardListResponseDto.java b/src/main/java/com/example/trello/cardlist/dto/CardListResponseDto.java new file mode 100644 index 0000000..b9d0ae5 --- /dev/null +++ b/src/main/java/com/example/trello/cardlist/dto/CardListResponseDto.java @@ -0,0 +1,13 @@ +package com.example.trello.cardlist.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class CardListResponseDto { + + private Long id; + + private Integer sequence; +} diff --git a/src/main/java/com/example/trello/cardlist/dto/CreateCardListRequestDto.java b/src/main/java/com/example/trello/cardlist/dto/CreateCardListRequestDto.java new file mode 100644 index 0000000..e42e304 --- /dev/null +++ b/src/main/java/com/example/trello/cardlist/dto/CreateCardListRequestDto.java @@ -0,0 +1,24 @@ +package com.example.trello.cardlist.dto; + +import com.example.trello.board.Board; +import com.example.trello.cardlist.CardList; +import lombok.Getter; + +@Getter +public class CreateCardListRequestDto { + + private String title; + + private Integer sequence; + + private Long boardId; + + public CardList toEntity(CreateCardListRequestDto requestDto, Board board ,Integer sequence) { + return CardList.builder() + .title(requestDto.title) + .sequence(sequence) + .board(board) + .build(); + } + +} diff --git a/src/main/java/com/example/trello/cardlist/dto/GetCardListResponseDto.java b/src/main/java/com/example/trello/cardlist/dto/GetCardListResponseDto.java new file mode 100644 index 0000000..372072f --- /dev/null +++ b/src/main/java/com/example/trello/cardlist/dto/GetCardListResponseDto.java @@ -0,0 +1,27 @@ +package com.example.trello.cardlist.dto; + +import com.example.trello.card.Card; +import com.example.trello.card.dto.GetCardResponseDto; +import com.example.trello.cardlist.CardList; +import lombok.Getter; + +import java.util.List; + +@Getter +public class GetCardListResponseDto { + private Long id; + private String title; + private Integer sequence; + private List card; + + public GetCardListResponseDto(Long id, String title, Integer sequence, List card) { + this.id = id; + this.title = title; + this.sequence = sequence; + this.card = card; + } + + public static GetCardListResponseDto toDto(CardList cardList, List card) { + return new GetCardListResponseDto(cardList.getId(), cardList.getTitle(), cardList.getSequence(), card); + } +} diff --git a/src/main/java/com/example/trello/cardlist/dto/UpdateCardListRequestDto.java b/src/main/java/com/example/trello/cardlist/dto/UpdateCardListRequestDto.java new file mode 100644 index 0000000..19c95ae --- /dev/null +++ b/src/main/java/com/example/trello/cardlist/dto/UpdateCardListRequestDto.java @@ -0,0 +1,13 @@ +package com.example.trello.cardlist.dto; + +import lombok.Getter; + +@Getter +public class UpdateCardListRequestDto { + + private String title; + + private Integer sequence; + + +} diff --git a/src/main/java/com/example/trello/comment/Comment.java b/src/main/java/com/example/trello/comment/Comment.java index 2c00cb2..b7b3258 100644 --- a/src/main/java/com/example/trello/comment/Comment.java +++ b/src/main/java/com/example/trello/comment/Comment.java @@ -2,11 +2,15 @@ import com.example.trello.card.Card; import com.example.trello.user.User; +import com.example.trello.workspace_member.WorkspaceMember; import jakarta.persistence.*; -import lombok.Getter; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; @Entity @Getter +@AllArgsConstructor +@NoArgsConstructor public class Comment { @Id @@ -22,4 +26,19 @@ public class Comment { @ManyToOne(fetch = FetchType.LAZY) private User user; + @ManyToOne(fetch = FetchType.LAZY) + private WorkspaceMember workspaceMember; + + @Builder + public Comment(String content, Card card, User user, WorkspaceMember workspaceMember) { + this.content = content; + this.card = card; + this.user = user; + this.workspaceMember = workspaceMember; + } + + public void updateComment(String content) { + this.content = content; + } + } diff --git a/src/main/java/com/example/trello/comment/CommentController.java b/src/main/java/com/example/trello/comment/CommentController.java new file mode 100644 index 0000000..06d663c --- /dev/null +++ b/src/main/java/com/example/trello/comment/CommentController.java @@ -0,0 +1,39 @@ +package com.example.trello.comment; + +import com.example.trello.comment.dto.request.CommentRequestDto; +import com.example.trello.comment.dto.request.UpdateCommentRequestDto; +import com.example.trello.comment.dto.response.CommentResponseDto; +import com.example.trello.config.auth.UserDetailsImpl; +import jakarta.servlet.http.HttpServletRequest; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/comments") +public class CommentController { + + private final CommentService commentService; + + @PostMapping + public ResponseEntity createComment(@RequestBody CommentRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) { + CommentResponseDto responseDto = commentService.createComment(requestDto, userDetails); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + + @PatchMapping("/{commentId}") + public ResponseEntity updateComment(@PathVariable Long commentId, @RequestBody UpdateCommentRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) { + CommentResponseDto responseDto = commentService.updateComment(commentId, requestDto, userDetails); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment(@PathVariable Long commentId, UserDetailsImpl userDetails) { + commentService.deleteComment(commentId, userDetails); + return new ResponseEntity<>("μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€" , HttpStatus.OK); + } +} diff --git a/src/main/java/com/example/trello/comment/CommentRepository.java b/src/main/java/com/example/trello/comment/CommentRepository.java index 1d9f114..797dda4 100644 --- a/src/main/java/com/example/trello/comment/CommentRepository.java +++ b/src/main/java/com/example/trello/comment/CommentRepository.java @@ -1,6 +1,8 @@ package com.example.trello.comment; import com.example.trello.board.Board; +import com.example.trello.common.exception.CommentErrorCode; +import com.example.trello.common.exception.CommentException; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -8,6 +10,6 @@ public interface CommentRepository extends JpaRepository { default Comment findByIdOrElseThrow(Long id){ - return findById(id).orElseThrow(()->new RuntimeException()); + return findById(id).orElseThrow(()->new CommentException(CommentErrorCode.COMMENT_NOT_FOUND)); } } diff --git a/src/main/java/com/example/trello/comment/CommentService.java b/src/main/java/com/example/trello/comment/CommentService.java new file mode 100644 index 0000000..d1c048f --- /dev/null +++ b/src/main/java/com/example/trello/comment/CommentService.java @@ -0,0 +1,105 @@ +package com.example.trello.comment; + +import com.example.trello.card.Card; +import com.example.trello.card.cardrepository.CardRepository; +import com.example.trello.comment.dto.request.CommentRequestDto; +import com.example.trello.comment.dto.request.UpdateCommentRequestDto; +import com.example.trello.comment.dto.response.CommentResponseDto; +import com.example.trello.common.exception.CommentErrorCode; +import com.example.trello.common.exception.CommentException; +import com.example.trello.common.exception.WorkspaceMemberErrorCode; +import com.example.trello.common.exception.WorkspaceMemberException; +import com.example.trello.config.auth.UserDetailsImpl; +import com.example.trello.notification.NotificationService; +import com.example.trello.workspace.WorkSpaceRepository; +import com.example.trello.workspace.Workspace; +import com.example.trello.workspace_member.WorkspaceMember; +import com.example.trello.workspace_member.WorkspaceMemberRepository; +import com.example.trello.workspace_member.WorkspaceMemberService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import static com.example.trello.notification.NotificationType.CREATE_COMMENT; + +@Service +@RequiredArgsConstructor +public class CommentService { + private final CardRepository cardRepository; + private final CommentRepository commentRepository; + private final WorkspaceMemberRepository workspaceMemberRepository; + private final WorkspaceMemberService workspaceMemberService; + private final NotificationService notificationService; + private final WorkSpaceRepository workSpaceRepository; + + @Transactional + public CommentResponseDto createComment(CommentRequestDto requestDto, UserDetailsImpl userDetails) { + + Card card = cardRepository.findByIdOrElseThrow(requestDto.getCardId()); + + Long workSpaceId = card.getCardList().getBoard().getWorkspace().getId(); + + WorkspaceMember workspaceMember = workspaceMemberRepository.findByUserIdAndWorkspaceIdOrElseThrow(userDetails.getUser().getId(), workSpaceId); + + workspaceMemberService.checkReadRole(userDetails.getUser().getId(), workSpaceId); + + Comment comment = Comment.builder() + .content(requestDto.getContent()) + .card(card) + .user(userDetails.getUser()) + .workspaceMember(workspaceMember) + .build(); + + commentRepository.save(comment); + + Workspace workSpace = workSpaceRepository.findByIdOrElseThrow(workSpaceId); + + notificationService.sendSlack(CREATE_COMMENT, workSpace); + return CommentResponseDto.toDto(comment); + } + + @Transactional + public CommentResponseDto updateComment(Long commentId, UpdateCommentRequestDto requestDto, UserDetailsImpl userDetails) { + + Comment comment = commentRepository.findByIdOrElseThrow(commentId); + + Long workSpaceId = comment.getCard().getCardList().getBoard().getWorkspace().getId(); + + if (!workspaceMemberRepository.existsByUserIdAndWorkspaceId(userDetails.getUser().getId(), workSpaceId)) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.IS_NOT_WORKSPACEMEMBER); + } + + workspaceMemberService.checkReadRole(userDetails.getUser().getId(), workSpaceId); + + if (!userDetails.getUser().getId().equals(comment.getWorkspaceMember().getUser().getId())) { + throw new CommentException(CommentErrorCode.CANNOT_BE_MODIFIED); + } + + comment.updateComment(requestDto.getContent()); + + commentRepository.save(comment); + + return CommentResponseDto.toDto(comment); + + } + + @Transactional + public void deleteComment(Long commentId, UserDetailsImpl userDetails) { + + Comment comment = commentRepository.findByIdOrElseThrow(commentId); + + Long workSpaceId = comment.getCard().getCardList().getBoard().getWorkspace().getId(); + + if (!workspaceMemberRepository.existsByUserIdAndWorkspaceId(userDetails.getUser().getId(), workSpaceId)) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.IS_NOT_WORKSPACEMEMBER); + } + + workspaceMemberService.checkReadRole(userDetails.getUser().getId(), workSpaceId); + + if (!userDetails.getUser().getId().equals(comment.getWorkspaceMember().getUser().getId())) { + throw new CommentException(CommentErrorCode.CANNOT_BE_MODIFIED); + } + + commentRepository.delete(comment); + } +} diff --git a/src/main/java/com/example/trello/comment/dto/request/CommentRequestDto.java b/src/main/java/com/example/trello/comment/dto/request/CommentRequestDto.java new file mode 100644 index 0000000..60a5150 --- /dev/null +++ b/src/main/java/com/example/trello/comment/dto/request/CommentRequestDto.java @@ -0,0 +1,16 @@ +package com.example.trello.comment.dto.request; + +import lombok.Getter; + +@Getter +public class CommentRequestDto { + + private String content; + + private Long cardId; + + public CommentRequestDto(String content, Long cardId) { + this.content = content; + this.cardId = cardId; + } +} diff --git a/src/main/java/com/example/trello/comment/dto/request/UpdateCommentRequestDto.java b/src/main/java/com/example/trello/comment/dto/request/UpdateCommentRequestDto.java new file mode 100644 index 0000000..214fff6 --- /dev/null +++ b/src/main/java/com/example/trello/comment/dto/request/UpdateCommentRequestDto.java @@ -0,0 +1,13 @@ +package com.example.trello.comment.dto.request; + +import lombok.Getter; + +@Getter +public class UpdateCommentRequestDto { + + private String content; + + public UpdateCommentRequestDto (String content) { + this.content = content; + } +} diff --git a/src/main/java/com/example/trello/comment/dto/response/CommentResponseDto.java b/src/main/java/com/example/trello/comment/dto/response/CommentResponseDto.java new file mode 100644 index 0000000..d0a92b1 --- /dev/null +++ b/src/main/java/com/example/trello/comment/dto/response/CommentResponseDto.java @@ -0,0 +1,33 @@ +package com.example.trello.comment.dto.response; + +import com.example.trello.comment.Comment; +import lombok.Getter; + +import java.util.List; + +@Getter +public class CommentResponseDto { + + private Long commentId; + + private String content; + + private Long cardId; + + private Long userId; + + public CommentResponseDto(Long commentId, String content, Long cardId, Long userId) { + this.commentId = commentId; + this.content = content; + this.cardId = cardId; + this.userId = userId; + } + + public static CommentResponseDto toDto(Comment comment) { + return new CommentResponseDto( + comment.getId(), + comment.getContent(), + comment.getCard().getId(), + comment.getUser().getId()); + } +} diff --git a/src/main/java/com/example/trello/common/exception/BoardErrorCode.java b/src/main/java/com/example/trello/common/exception/BoardErrorCode.java new file mode 100644 index 0000000..dd50ada --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/BoardErrorCode.java @@ -0,0 +1,19 @@ +package com.example.trello.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +import static org.springframework.http.HttpStatus.*; + +@RequiredArgsConstructor +@Getter +public enum BoardErrorCode { + CAN_NOT_FIND_BOARD_WITH_BOARD_ID("λ³΄λ“œλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", BAD_REQUEST), + IS_NOT_ALLOWED_FILE_EXTENSION("이미지 파일(ν™•μž₯자)만 μ—…λ‘œλ“œκ°€ κ°€λŠ₯ν•©λ‹ˆλ‹€.", BAD_REQUEST), + FAILED_IMAGE_UPLOAD("이미지 μ—…λ‘œλ“œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", BAD_REQUEST), + FAILED_IMAGE_DELETE("이미지 μ‚­μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", BAD_REQUEST); + + private final String message; + private final HttpStatus httpStatus; +} diff --git a/src/main/java/com/example/trello/common/exception/BoardException.java b/src/main/java/com/example/trello/common/exception/BoardException.java new file mode 100644 index 0000000..df05a60 --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/BoardException.java @@ -0,0 +1,11 @@ +package com.example.trello.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import okhttp3.internal.http2.ErrorCode; + +@AllArgsConstructor +@Getter +public class BoardException extends RuntimeException { + private BoardErrorCode boardErrorCode; +} diff --git a/src/main/java/com/example/trello/common/exception/CardErrorCode.java b/src/main/java/com/example/trello/common/exception/CardErrorCode.java new file mode 100644 index 0000000..397b9d3 --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/CardErrorCode.java @@ -0,0 +1,17 @@ +package com.example.trello.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +@Getter +public enum CardErrorCode { + + CARD_NOT_FOUND("μΉ΄λ“œλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + FORMAT_NOT_SUPPORTED("μ§€μ›ν•˜μ§€ μ•ŠλŠ” ν˜•μ‹μž…λ‹ˆλ‹€", HttpStatus.BAD_REQUEST), + FILE_SIZE_EXCEEDED("파일의 크기가 5MBλ₯Ό λ„˜μ—ˆμŠ΅λ‹ˆλ‹€ 파일의 크기λ₯Ό μ€„μ—¬μ£Όμ„Έμš”", HttpStatus.BAD_REQUEST); + + private final String message; + private final HttpStatus httpStatus; +} diff --git a/src/main/java/com/example/trello/common/exception/CardException.java b/src/main/java/com/example/trello/common/exception/CardException.java new file mode 100644 index 0000000..ad3c672 --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/CardException.java @@ -0,0 +1,10 @@ +package com.example.trello.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class CardException extends RuntimeException { + private CardErrorCode cardErrorCode; +} diff --git a/src/main/java/com/example/trello/common/exception/CardListErrorCode.java b/src/main/java/com/example/trello/common/exception/CardListErrorCode.java new file mode 100644 index 0000000..087822a --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/CardListErrorCode.java @@ -0,0 +1,15 @@ +package com.example.trello.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +@Getter +public enum CardListErrorCode { + CARD_LIST_NOT_FOUND("리슀트λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + INVALID_SEQUENCE("μ§€μ •ν•œ μˆœμ„œλ‘œ λ³€κ²½ν• μˆ˜ μ—†μŠ΅λ‹ˆλ‹€", HttpStatus.BAD_REQUEST); + + private final String message; + private final HttpStatus httpStatus; +} diff --git a/src/main/java/com/example/trello/common/exception/CardListException.java b/src/main/java/com/example/trello/common/exception/CardListException.java new file mode 100644 index 0000000..ff97bab --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/CardListException.java @@ -0,0 +1,10 @@ +package com.example.trello.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class CardListException extends RuntimeException { + private CardListErrorCode cardListErrorCode; +} diff --git a/src/main/java/com/example/trello/common/exception/CommentErrorCode.java b/src/main/java/com/example/trello/common/exception/CommentErrorCode.java new file mode 100644 index 0000000..f6668fa --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/CommentErrorCode.java @@ -0,0 +1,16 @@ +package com.example.trello.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +@Getter +public enum CommentErrorCode { + + CANNOT_BE_MODIFIED("λŒ“κΈ€ μˆ˜μ •μ€ μž‘μ„±μžλ§Œ κ°€λŠ₯ν•©λ‹ˆλ‹€", HttpStatus.FORBIDDEN), + COMMENT_NOT_FOUND("λŒ“κΈ€μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€", HttpStatus.NOT_FOUND); + + private final String message; + private final HttpStatus httpStatus; +} diff --git a/src/main/java/com/example/trello/common/exception/CommentException.java b/src/main/java/com/example/trello/common/exception/CommentException.java new file mode 100644 index 0000000..61cd551 --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/CommentException.java @@ -0,0 +1,10 @@ +package com.example.trello.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class CommentException extends RuntimeException { + private CommentErrorCode commentErrorCode; +} diff --git a/src/main/java/com/example/trello/common/exception/ErrorResponse.java b/src/main/java/com/example/trello/common/exception/ErrorResponse.java new file mode 100644 index 0000000..6007082 --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/ErrorResponse.java @@ -0,0 +1,12 @@ +package com.example.trello.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +@Getter +@AllArgsConstructor +@Builder +public class ErrorResponse { + private String errorCode; + private String message; +} diff --git a/src/main/java/com/example/trello/common/exception/GlobalExceptionHandler.java b/src/main/java/com/example/trello/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..4e12725 --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,60 @@ +package com.example.trello.common.exception; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CardListException.class) + public ResponseEntity handleCardListException(CardListException e) { + ErrorResponse message = ErrorResponse.builder() + .errorCode(e.getCardListErrorCode().name()) + .message(e.getCardListErrorCode().getMessage()) + .build(); + return new ResponseEntity<>(message, e.getCardListErrorCode().getHttpStatus()); + + } + + @ExceptionHandler(WorkspaceException.class) + public ResponseEntity handleCardListException(WorkspaceException e) { + ErrorResponse message = ErrorResponse.builder() + .errorCode(e.getWorkspaceErrorCode().name()) + .message(e.getWorkspaceErrorCode().getMessage()) + .build(); + return new ResponseEntity<>(message, e.getWorkspaceErrorCode().getHttpStatus()); + + } + + @ExceptionHandler(WorkspaceMemberException.class) + public ResponseEntity handleCardListException(WorkspaceMemberException e) { + ErrorResponse message = ErrorResponse.builder() + .errorCode(e.getWorkspaceMemberErrorCode().name()) + .message(e.getWorkspaceMemberErrorCode().getMessage()) + .build(); + return new ResponseEntity<>(message, e.getWorkspaceMemberErrorCode().getHttpStatus()); + + } + + @ExceptionHandler(BoardException.class) + public ResponseEntity handleCardListException(BoardException e) { + ErrorResponse message = ErrorResponse.builder() + .errorCode(e.getBoardErrorCode().name()) + .message(e.getBoardErrorCode().getMessage()) + .build(); + return new ResponseEntity<>(message, e.getBoardErrorCode().getHttpStatus()); + + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidException(MethodArgumentNotValidException e) { + ErrorResponse message = ErrorResponse.builder() + .errorCode(e.getStatusCode().toString()) + .message(e.getBindingResult().getFieldError().getDefaultMessage()) + .build(); + return new ResponseEntity<>(message, e.getStatusCode()); + + } +} diff --git a/src/main/java/com/example/trello/common/exception/UserErrorCode.java b/src/main/java/com/example/trello/common/exception/UserErrorCode.java new file mode 100644 index 0000000..0bc6430 --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/UserErrorCode.java @@ -0,0 +1,23 @@ +package com.example.trello.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +@Getter +public enum UserErrorCode { + + NOT_FOUND_ID("μš”μ²­ν•˜μ‹  아이디값을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + NOT_FOUND_EMAIL("μš”μ²­ν•˜μ‹  이메일값을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + + DUPLICATED_EMAIL("μ€‘λ³΅λœ μ΄λ©”μΌμž…λ‹ˆλ‹€. λ‹€μ‹œ μž…λ ₯ν•΄ μ£Όμ„Έμš”", HttpStatus.NOT_FOUND), + PASSWORD_INCORRECT("λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", HttpStatus.BAD_REQUEST), + + ALREADY_DELETED("이미 νƒˆν‡΄λœ κ³„μ •μž…λ‹ˆλ‹€.", HttpStatus.BAD_REQUEST), + REQUIRED_LOGIN("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€.", HttpStatus.UNAUTHORIZED); + + + private final String message; + private final HttpStatus httpStatus; +} diff --git a/src/main/java/com/example/trello/common/exception/UserException.java b/src/main/java/com/example/trello/common/exception/UserException.java new file mode 100644 index 0000000..5d2ee81 --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/UserException.java @@ -0,0 +1,11 @@ +package com.example.trello.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import okhttp3.internal.http2.ErrorCode; + +@RequiredArgsConstructor +@Getter +public class UserException extends RuntimeException { + private final UserErrorCode errorCode; +} diff --git a/src/main/java/com/example/trello/common/exception/WorkspaceErrorCode.java b/src/main/java/com/example/trello/common/exception/WorkspaceErrorCode.java new file mode 100644 index 0000000..ac67bbe --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/WorkspaceErrorCode.java @@ -0,0 +1,20 @@ +package com.example.trello.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.FORBIDDEN; + +@RequiredArgsConstructor +@Getter +public enum WorkspaceErrorCode { + ONLY_ADMIN_CAN_CREATE_WORKSPACE("ADMIN μ—­ν• μ˜ USER만 μ›Œν¬μŠ€νŽ˜μ΄μŠ€λ₯Ό 생성할 수 μžˆμŠ΅λ‹ˆλ‹€.", FORBIDDEN), + ONLY_ADMIN_CAN_UPDATE_MEMBER_ROLE_TO_WORKSPACE("ADMIN μ—­ν• μ˜ USER만 λ‹€λ₯Έ MEMBER의 역할을 μ›Œν¬μŠ€νŽ˜μ΄μŠ€λ‘œ μˆ˜μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€.", FORBIDDEN), + ONLY_WORKSPACE_ROLE_CAN_HANDLE_WORKSPACE("WORKSPACE μ—­ν• μ˜ MEMBER만 μ›Œν¬μŠ€νŽ˜μ΄μŠ€λ₯Ό 관리할 수 μžˆμŠ΅λ‹ˆλ‹€.", FORBIDDEN), + CAN_NOT_FIND_WORKSPACE_WITH_WORKSPACE_ID("WORKSPACE λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", BAD_REQUEST); + + private final String message; + private final HttpStatus httpStatus; +} diff --git a/src/main/java/com/example/trello/common/exception/WorkspaceException.java b/src/main/java/com/example/trello/common/exception/WorkspaceException.java new file mode 100644 index 0000000..7147606 --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/WorkspaceException.java @@ -0,0 +1,10 @@ +package com.example.trello.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class WorkspaceException extends RuntimeException { + private WorkspaceErrorCode workspaceErrorCode; +} diff --git a/src/main/java/com/example/trello/common/exception/WorkspaceMemberErrorCode.java b/src/main/java/com/example/trello/common/exception/WorkspaceMemberErrorCode.java new file mode 100644 index 0000000..14dc821 --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/WorkspaceMemberErrorCode.java @@ -0,0 +1,22 @@ +package com.example.trello.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.FORBIDDEN; + +@RequiredArgsConstructor +@Getter +public enum WorkspaceMemberErrorCode { + IS_NOT_WORKSPACEMEMBER("WORKSPACE의 MEMBERκ°€ μ•„λ‹™λ‹ˆλ‹€.", FORBIDDEN), + ONLY_WORKSPACE_ROLE_CAN_INVITE("WORKSPACE μ—­ν• μ˜ MEMBER만 μ΄ˆλŒ€κ°€ κ°€λŠ₯ν•©λ‹ˆλ‹€.", FORBIDDEN), + ONLY_WORKSPACE_ROLE_CAN_UPDATE_MEMBER_ROLE("WORKSPACE μ—­ν• μ˜ MEMBER만 λ‹€λ₯Έ μœ μ €μ˜ κΆŒν•œμ„ (BOARD, READ_ONLY)둜 변경이 κ°€λŠ₯ν•©λ‹ˆλ‹€.", FORBIDDEN), + ALREADY_MEMBER("이미 μ›Œν¬μŠ€νŽ˜μ΄μŠ€μ˜ λ©€λ²„μž…λ‹ˆλ‹€.", BAD_REQUEST), + CAN_NOT_FIND_WORKSPACEMEMBER_WITH_WORKSPACEMEMBER_ID("WORKSPACEMEMBER λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", BAD_REQUEST), + CAN_NOT_READ_ROLE("κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€",FORBIDDEN); + + private final String message; + private final HttpStatus httpStatus; +} diff --git a/src/main/java/com/example/trello/common/exception/WorkspaceMemberException.java b/src/main/java/com/example/trello/common/exception/WorkspaceMemberException.java new file mode 100644 index 0000000..e931231 --- /dev/null +++ b/src/main/java/com/example/trello/common/exception/WorkspaceMemberException.java @@ -0,0 +1,10 @@ +package com.example.trello.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class WorkspaceMemberException extends RuntimeException { + private WorkspaceMemberErrorCode workspaceMemberErrorCode; +} diff --git a/src/main/java/com/example/trello/config/QueryDslConfig.java b/src/main/java/com/example/trello/config/QueryDslConfig.java index a1cc8f7..727471f 100644 --- a/src/main/java/com/example/trello/config/QueryDslConfig.java +++ b/src/main/java/com/example/trello/config/QueryDslConfig.java @@ -1,4 +1,18 @@ package com.example.trello.config; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration public class QueryDslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } } diff --git a/src/main/java/com/example/trello/config/S3config.java b/src/main/java/com/example/trello/config/S3config.java new file mode 100644 index 0000000..bef6f62 --- /dev/null +++ b/src/main/java/com/example/trello/config/S3config.java @@ -0,0 +1,29 @@ +package com.example.trello.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3config { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3Client() { + BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client) AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } +} + diff --git a/src/main/java/com/example/trello/config/SecurityConfig.java b/src/main/java/com/example/trello/config/SecurityConfig.java index 070d57d..abb3bda 100644 --- a/src/main/java/com/example/trello/config/SecurityConfig.java +++ b/src/main/java/com/example/trello/config/SecurityConfig.java @@ -1,4 +1,62 @@ package com.example.trello.config; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@Configuration +@RequiredArgsConstructor +@Slf4j(topic = "Security::SecurityConfig") public class SecurityConfig { + + private final UserDetailsService userDetailsService; + +// private static final String[] WHITE_LIST = {"/users/login", "/users/sign-up"}; + + + @Bean + BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * AuthenticationManager(인증 κ΄€λ¦¬μž). + * + * @param config {@link AuthenticationConfiguration} + * @return 섀정이 μΆ”κ°€λœ AuthenticationManager + */ + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) + throws Exception { + log.info("AuthenticationManager에 μœ„μž„."); + return config.getAuthenticationManager(); + } + + /** + * AuthenticationProvider(인증 κ³΅κΈ‰μž). + * + * @return {@link AuthenticationProvider} + */ + @Bean + AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + log.info("AuthenticationProvider μ„€μ •. κ΅¬ν˜„μ²΄: {}", authProvider.getClass().getSimpleName()); + + log.info("UserDetailsService에 μ‚¬μš©μž 관리 μœ„μž„. κ΅¬ν˜„μ²΄: {}", + this.userDetailsService.getClass().getSimpleName()); + authProvider.setUserDetailsService(this.userDetailsService); + + log.info("PasswordEncoder에 μ•”ν˜Έ 검증 μœ„μž„. κ΅¬ν˜„μ²΄: {}", + this.passwordEncoder().getClass().getSimpleName()); + authProvider.setPasswordEncoder(passwordEncoder()); + + return authProvider; + } } diff --git a/src/main/java/com/example/trello/config/WebConfig.java b/src/main/java/com/example/trello/config/WebConfig.java new file mode 100644 index 0000000..147007c --- /dev/null +++ b/src/main/java/com/example/trello/config/WebConfig.java @@ -0,0 +1,99 @@ +package com.example.trello.config; + +import com.example.trello.config.auth.UserDetailsServiceImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; + +@Configuration +@EnableWebSecurity // SecurityFilterChain 빈 섀정을 μœ„ν•΄ ν•„μš”. +@RequiredArgsConstructor +public class WebConfig { + + + private final UserDetailsServiceImpl userDetailsServiceImpl; + + + private final AuthenticationProvider authenticationProvider; + /** + * AuthenticationEntryPoint. + */ + private final AuthenticationEntryPoint authEntryPoint; + /** + * AccessDeniedHandler. + */ + private final AccessDeniedHandler accessDeniedHandler; + /** + * ν™”μ΄νŠΈ 리슀트. + */ + private static final String[] WHITE_LIST = {"/users/login","/users/sign-up"}; + + /** + * security ν•„ν„°. + * + * @param http {@link HttpSecurity} + * @return {@link SecurityFilterChain} ν•„ν„° 체인 + */ + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.cors(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> + auth.requestMatchers(WHITE_LIST).permitAll() +// .requestMatchers("/admins/**").hasRole("ADMIN") +// .requestMatchers("/users/**").hasRole("user") + .anyRequest().permitAll() + ) + // Spring Security μ˜ˆμ™Έμ— λŒ€ν•œ 처리λ₯Ό ν•Έλ“€λŸ¬μ— μœ„μž„. + .exceptionHandling(handler -> handler + .authenticationEntryPoint(authEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + // JWT 기반 ν…ŒμŠ€νŠΈλ₯Ό μœ„ν•΄ SecurityContextλ₯Ό κ°€μ Έμ˜¬ λ•Œ HttpSession을 μ‚¬μš©ν•˜μ§€ μ•Šλ„λ‘ μ„€μ •. + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authenticationProvider(authenticationProvider); + + return http.build(); + } + + /** + * μ‚¬μš©μž κΆŒν•œμ˜ 계측을 μ„€μ •. + * + * @return {@link RoleHierarchy} + */ + @Bean + public RoleHierarchy roleHierarchy() { + return RoleHierarchyImpl.fromHierarchy( + // "ROLE_ADMIN > ROLE_STAFF\nROLE_ADMIN > ROLE_USER" + """ + ROLE_ADMIN > ROLE_STAFF + ROLE_ADMIN > ROLE_USER + """); + } + + /** + * h2-console 접속은 Spring Securityλ₯Ό κ±°μΉ˜μ§€ μ•Šλ„λ‘ μ„€μ •. + * + * @return {@link WebSecurityCustomizer} + * @see spring-securityμ—μ„œ-h2-console-μ‚¬μš©ν•˜κΈ° + */ + @Bean + @ConditionalOnProperty(name = "spring.h2.console.enabled", havingValue = "true") + public WebSecurityCustomizer configureH2ConsoleEnable() { + return web -> web.ignoring().requestMatchers(PathRequest.toH2Console()); + } +} diff --git a/src/main/java/com/example/trello/config/WebConfigImpl.java b/src/main/java/com/example/trello/config/WebConfigImpl.java new file mode 100644 index 0000000..6183dcf --- /dev/null +++ b/src/main/java/com/example/trello/config/WebConfigImpl.java @@ -0,0 +1,40 @@ +package com.example.trello.config; + +import com.example.trello.config.interceptor.BoardInterceptor; +import com.example.trello.config.interceptor.ReadOnlyInterceptor; +import com.example.trello.config.interceptor.WorkSpaceInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfigImpl implements WebMvcConfigurer { + + private static final String[] AUTH_REQUIRED_PATH_PATTERNS = {"/users/login", "/users/sign-up", "/workspaces"}; + private static final String[] WORKSPACE_ROLE_REQUIRED_PATH_PATTERNS = {""}; + private static final String[] BOARD_ROLE_REQUIRED_PATH_PATTERNS = {""}; + private static final String[] READ_ONLY_ROLE_REQUIRED_PATH_PATTERNS = {""}; + + private final WorkSpaceInterceptor workSpaceInterceptor; + private final BoardInterceptor boardInterceptor; + private final ReadOnlyInterceptor readOnlyInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(workSpaceInterceptor) + .addPathPatterns(WORKSPACE_ROLE_REQUIRED_PATH_PATTERNS) + .excludePathPatterns(AUTH_REQUIRED_PATH_PATTERNS) + .order(Ordered.HIGHEST_PRECEDENCE); + registry.addInterceptor(boardInterceptor) + .addPathPatterns(BOARD_ROLE_REQUIRED_PATH_PATTERNS) + .excludePathPatterns(AUTH_REQUIRED_PATH_PATTERNS) + .order(Ordered.HIGHEST_PRECEDENCE + 1); + registry.addInterceptor(readOnlyInterceptor) + .addPathPatterns(READ_ONLY_ROLE_REQUIRED_PATH_PATTERNS) + .excludePathPatterns(AUTH_REQUIRED_PATH_PATTERNS) + .order(Ordered.HIGHEST_PRECEDENCE + 2); + } +} diff --git a/src/main/java/com/example/trello/config/auth/DelegateAccessDeniedHandler.java b/src/main/java/com/example/trello/config/auth/DelegateAccessDeniedHandler.java new file mode 100644 index 0000000..61e0a3e --- /dev/null +++ b/src/main/java/com/example/trello/config/auth/DelegateAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package com.example.trello.config.auth; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import java.io.IOException; + +@Component +public class DelegateAccessDeniedHandler implements AccessDeniedHandler { + + + private final HandlerExceptionResolver resolver; + + public DelegateAccessDeniedHandler( + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { + this.resolver = resolver; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) + throws IOException, ServletException { + resolver.resolveException(request, response, null, accessDeniedException); + + } +} diff --git a/src/main/java/com/example/trello/config/auth/DelegatedAuthenticationEntryPoint.java b/src/main/java/com/example/trello/config/auth/DelegatedAuthenticationEntryPoint.java new file mode 100644 index 0000000..9e3808a --- /dev/null +++ b/src/main/java/com/example/trello/config/auth/DelegatedAuthenticationEntryPoint.java @@ -0,0 +1,29 @@ +package com.example.trello.config.auth; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import java.io.IOException; + +@Component +public class DelegatedAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final HandlerExceptionResolver resolver; + + public DelegatedAuthenticationEntryPoint( + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { + this.resolver = resolver; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) + throws IOException, ServletException { + resolver.resolveException(request, response, null, authException); + } +} diff --git a/src/main/java/com/example/trello/config/auth/UserDetailsImpl.java b/src/main/java/com/example/trello/config/auth/UserDetailsImpl.java new file mode 100644 index 0000000..d9c6b2f --- /dev/null +++ b/src/main/java/com/example/trello/config/auth/UserDetailsImpl.java @@ -0,0 +1,55 @@ +package com.example.trello.config.auth; + +import com.example.trello.user.User; +import com.example.trello.user.enums.Role; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; + +@Getter +@RequiredArgsConstructor +@Slf4j(topic = "Security::UserDetailsImpl") +public class UserDetailsImpl implements UserDetails { + + private final User user; + + + @Override + public Collection getAuthorities() { + Role role = this.user.getRole(); + log.info("μ‚¬μš©μž κΆŒν•œ: {}", role.getAuthorities()); + return new ArrayList<>(role.getAuthorities()); + } + + @Override + public String getPassword() { + return this.user.getPassword(); + } + + @Override + public String getUsername() { + return this.user.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; } + + @Override + public boolean isCredentialsNonExpired() { + return true; } + + @Override + public boolean isEnabled() { + return true; } +} diff --git a/src/main/java/com/example/trello/config/auth/UserDetailsServiceImpl.java b/src/main/java/com/example/trello/config/auth/UserDetailsServiceImpl.java new file mode 100644 index 0000000..d0d1f2b --- /dev/null +++ b/src/main/java/com/example/trello/config/auth/UserDetailsServiceImpl.java @@ -0,0 +1,26 @@ +package com.example.trello.config.auth; + +import com.example.trello.user.User; +import com.example.trello.user.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j(topic = "Security::UserDetailsServiceImpl") +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = this.userRepository.findByEmailOrElseThrow(username); + + log.info("μ°ΎλŠ” μ‚¬μš©μž : {}", username); + return new UserDetailsImpl(user); + } +} diff --git a/src/main/java/com/example/trello/config/filter/JwtAuthFilter.java b/src/main/java/com/example/trello/config/filter/JwtAuthFilter.java new file mode 100644 index 0000000..3645f5c --- /dev/null +++ b/src/main/java/com/example/trello/config/filter/JwtAuthFilter.java @@ -0,0 +1,109 @@ +package com.example.trello.config.filter; + +import com.example.trello.util.AuthenticationScheme; +import com.example.trello.util.JwtProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +@Slf4j(topic = "Security::JwtAuthFilter") +public class JwtAuthFilter extends OncePerRequestFilter { + + /** + * JWT 토큰 제곡자. + */ + private final JwtProvider jwtProvider; + + /** + * UserDetailsService. + */ + private final UserDetailsService userDetailsService; + + /** + * {@inheritDoc} + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + log.info("URI: {}", request.getRequestURI()); + this.authenticate(request); + filterChain.doFilter(request, response); + } + + /** + * requestλ₯Ό μ΄μš©ν•΄ 인증을 μ²˜λ¦¬ν•œλ‹€. + * + * @param request {@link HttpServletRequest} + */ + private void authenticate(HttpServletRequest request) { + log.info("인증 처리."); + + // 토큰 검증. + String token = this.getTokenFromRequest(request); + if (!jwtProvider.validToken(token)) { + return; + } + + // ν† ν°μœΌλ‘œλΆ€ν…¨ username을 μΆ”μΆœ. + String username = this.jwtProvider.getUsername(token); + + // username에 ν•΄λ‹Ήλ˜λŠ” μ‚¬μš©μžλ₯Ό μ°ΎλŠ”λ‹€. + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + // SecurityContext에 인증 객체 μ €μž₯. + this.setAuthentication(request, userDetails); + } + + /** + * request의 Authorization ν—€λ”μ—μ„œ 토큰 값을 μΆ”μΆœ. + * + * @param request {@link HttpServletRequest} + * @return 토큰 κ°’ (μ°Ύμ§€ λͺ»ν•œ 경우 {@code null}) + */ + private String getTokenFromRequest(HttpServletRequest request) { + final String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION); + final String headerPrefix = AuthenticationScheme.generateType(AuthenticationScheme.BEARER); + + boolean tokenFound = + StringUtils.hasText(bearerToken) && bearerToken.startsWith(headerPrefix); + if (tokenFound) { + return bearerToken.substring(headerPrefix.length()); + } + + return null; + } + + /** + * {@code SecurityContext}에 인증 객체λ₯Ό μ €μž₯ν•œλ‹€. + * + * @param request {@link HttpServletRequest} + * @param userDetails μ°Ύμ•„μ˜¨ μ‚¬μš©μž 정보 + */ + private void setAuthentication(HttpServletRequest request, UserDetails userDetails) { + log.info("SecurityContext에 Authentication μ €μž₯."); + + // μ°Ύμ•„μ˜¨ μ‚¬μš©μž μ •λ³΄λ‘œ 인증 객체λ₯Ό 생성. + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, userDetails.getPassword(), userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + // SecurityContext에 인증 객체 μ €μž₯. + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/trello/config/interceptor/BoardInterceptor.java b/src/main/java/com/example/trello/config/interceptor/BoardInterceptor.java new file mode 100644 index 0000000..575493c --- /dev/null +++ b/src/main/java/com/example/trello/config/interceptor/BoardInterceptor.java @@ -0,0 +1 @@ +package com.example.trello.config.interceptor; import com.example.trello.config.auth.UserDetailsImpl; import com.example.trello.config.auth.UserDetailsServiceImpl; import com.example.trello.user.User; import com.example.trello.user.UserRepository; import com.example.trello.workspace.WorkSpaceRepository; import com.example.trello.workspace.Workspace; import com.example.trello.workspace_member.WorkspaceMember; import com.example.trello.workspace_member.WorkspaceMemberRepository; import com.example.trello.workspace_member.WorkspaceMemberRole; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @RequiredArgsConstructor @Component public class BoardInterceptor implements HandlerInterceptor { private final WorkspaceMemberRepository workspaceMemberRepository; private final WorkSpaceRepository workSpaceRepository; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals("POST")) { return false; } UserDetailsImpl userDetails = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); User user = userDetails.getUser(); Workspace workspace = workSpaceRepository.findByIdOrElseThrow(user.getId()); WorkspaceMember workspaceMember = workspaceMemberRepository.findByUserIdAndWorkspaceIdOrElseThrow(user.getId(), workspace.getId()); if (workspaceMember.getRole() != WorkspaceMemberRole.WORKSPACE) { throw new RuntimeException(); } return true; } } \ No newline at end of file diff --git a/src/main/java/com/example/trello/config/interceptor/ReadOnlyInterceptor.java b/src/main/java/com/example/trello/config/interceptor/ReadOnlyInterceptor.java new file mode 100644 index 0000000..01c030e --- /dev/null +++ b/src/main/java/com/example/trello/config/interceptor/ReadOnlyInterceptor.java @@ -0,0 +1 @@ +package com.example.trello.config.interceptor; import com.example.trello.config.auth.UserDetailsImpl; import com.example.trello.config.auth.UserDetailsServiceImpl; import com.example.trello.user.User; import com.example.trello.user.UserRepository; import com.example.trello.workspace.WorkSpaceRepository; import com.example.trello.workspace.Workspace; import com.example.trello.workspace_member.WorkspaceMember; import com.example.trello.workspace_member.WorkspaceMemberRepository; import com.example.trello.workspace_member.WorkspaceMemberRole; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @RequiredArgsConstructor @Component public class ReadOnlyInterceptor implements HandlerInterceptor { private final WorkspaceMemberRepository workspaceMemberRepository; private final WorkSpaceRepository workSpaceRepository; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { UserDetailsImpl userDetails = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); User user = userDetails.getUser(); Workspace workspace = workSpaceRepository.findByIdOrElseThrow(user.getId()); WorkspaceMember workspaceMember = workspaceMemberRepository.findByUserIdAndWorkspaceIdOrElseThrow(user.getId(), workspace.getId()); if (workspaceMember.getRole() != WorkspaceMemberRole.WORKSPACE) { throw new RuntimeException(); } return true; } } \ No newline at end of file diff --git a/src/main/java/com/example/trello/config/interceptor/WorkSpaceInterceptor.java b/src/main/java/com/example/trello/config/interceptor/WorkSpaceInterceptor.java new file mode 100644 index 0000000..574e71e --- /dev/null +++ b/src/main/java/com/example/trello/config/interceptor/WorkSpaceInterceptor.java @@ -0,0 +1 @@ +package com.example.trello.config.interceptor; import com.example.trello.config.auth.UserDetailsImpl; import com.example.trello.user.User; import com.example.trello.user.UserRepository; import com.example.trello.workspace.WorkSpaceRepository; import com.example.trello.workspace.Workspace; import com.example.trello.workspace_member.WorkspaceMember; import com.example.trello.workspace_member.WorkspaceMemberRepository; import com.example.trello.workspace_member.WorkspaceMemberRole; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @RequiredArgsConstructor @Component @Slf4j public class WorkSpaceInterceptor implements HandlerInterceptor { private final WorkSpaceRepository workSpaceRepository; private final WorkspaceMemberRepository workspaceMemberRepository; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals("POST")) { return false; } UserDetailsImpl userDetails = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); User user = userDetails.getUser(); Workspace workspace = workSpaceRepository.findByIdOrElseThrow(user.getId()); WorkspaceMember workspaceMember = workspaceMemberRepository.findByUserIdAndWorkspaceIdOrElseThrow(user.getId(), workspace.getId()); if (workspaceMember.getRole() != WorkspaceMemberRole.WORKSPACE) { throw new RuntimeException(); } log.info("request.getMethod(): " + request.getMethod()); return true; } } \ No newline at end of file diff --git a/src/main/java/com/example/trello/notification/Notification.java b/src/main/java/com/example/trello/notification/Notification.java index c23583b..ffaa2c3 100644 --- a/src/main/java/com/example/trello/notification/Notification.java +++ b/src/main/java/com/example/trello/notification/Notification.java @@ -2,14 +2,19 @@ import com.example.trello.workspace.Workspace; import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.Fetch; import java.time.LocalDateTime; @Entity @Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor public class Notification { @Id @@ -26,4 +31,8 @@ public class Notification { @ManyToOne(fetch = FetchType.LAZY) private Workspace workspace; + @Enumerated(EnumType.STRING) + private NotificationType notificationType; + + } diff --git a/src/main/java/com/example/trello/notification/NotificationService.java b/src/main/java/com/example/trello/notification/NotificationService.java new file mode 100644 index 0000000..d25eeb7 --- /dev/null +++ b/src/main/java/com/example/trello/notification/NotificationService.java @@ -0,0 +1,42 @@ +package com.example.trello.notification; + +import com.example.trello.workspace.Workspace; +import com.slack.api.Slack; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationRepository notificationRepository; + + @Transactional + public void sendSlack(NotificationType notificationType ,Workspace workspace) { + Slack slack =Slack.getInstance(); + String message = NotificationType.createMessage(notificationType); + try { + slack.send(workspace.getSlackUrl(), message); + Notification notification = Notification.builder() + .content(notificationType.getMessage()) + .notificationType(notificationType) + .workspace(workspace) + .build(); + notificationRepository.save(notification); + } catch (IOException e) { + throw new RuntimeException(e); + } + + } + + + + + + +} diff --git a/src/main/java/com/example/trello/notification/NotificationType.java b/src/main/java/com/example/trello/notification/NotificationType.java new file mode 100644 index 0000000..ac486e2 --- /dev/null +++ b/src/main/java/com/example/trello/notification/NotificationType.java @@ -0,0 +1,19 @@ +package com.example.trello.notification; + +import lombok.Getter; + +@Getter +public enum NotificationType { + + ADD_MEMBER("맴버가 μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€"),UPDATE_CARD("μΉ΄λ“œκ°€ λ³€κ²½λ˜μ—ˆμŠ΅λ‹ˆλ‹€"),CREATE_COMMENT("μƒˆλ‘œμš΄ λŒ“κΈ€μ΄ μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€"); + + private String message; + + NotificationType(String message) { + this.message = message; + } + + public static String createMessage(NotificationType notificationType) { + return "{\"text\" : " + " \""+notificationType.getMessage()+"\" }"; + } +} diff --git a/src/main/java/com/example/trello/user/User.java b/src/main/java/com/example/trello/user/User.java index b64461c..4a80489 100644 --- a/src/main/java/com/example/trello/user/User.java +++ b/src/main/java/com/example/trello/user/User.java @@ -1,24 +1,59 @@ package com.example.trello.user; +import com.example.trello.user.enums.AccountStatus; +import com.example.trello.user.enums.Role; +import com.example.trello.workspace.Workspace; +import com.example.trello.workspace_member.WorkspaceMember; import jakarta.persistence.*; -import lombok.Getter; +import lombok.*; + +import java.util.List; @Getter @Entity +@Table(name = "users") +@NoArgsConstructor +@AllArgsConstructor public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "email") + @Column(name = "email", unique = true, nullable = false) private String email; - @Column(name = "password") + @Column(name = "password", nullable = false) private String password; - @Column(name = "nickname") + @Column(name = "nickname", nullable = false) private String nickname; - + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Role role; + + @Column + @Enumerated(EnumType.STRING) + private AccountStatus status; + + @OneToMany(mappedBy = "user") + private List workspaceMembers; + + @Builder + public User(String email, String password, String nickname, Role role, AccountStatus status) { + this.email = email; + this.password = password; + this.nickname = nickname; + this.role = role; + this.status = status; + } + + public void deletedAccount(AccountStatus status) { + this.status = status; + } + + public boolean isDeletedAccount(User user) { + return user.getStatus().equals(AccountStatus.DELETED); + } } diff --git a/src/main/java/com/example/trello/user/UserRepository.java b/src/main/java/com/example/trello/user/UserRepository.java index b82e59a..fbbfc33 100644 --- a/src/main/java/com/example/trello/user/UserRepository.java +++ b/src/main/java/com/example/trello/user/UserRepository.java @@ -1,13 +1,24 @@ package com.example.trello.user; -import com.example.trello.comment.Comment; +import com.example.trello.common.exception.UserErrorCode; +import com.example.trello.common.exception.UserException; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface UserRepository extends JpaRepository { + default User findByIdOrElseThrow(Long userId){ + return findById(userId).orElseThrow(() -> new UserException(UserErrorCode.NOT_FOUND_ID)); + } + + Optional findByEmail(String email); - default User findByIdOrElseThrow(Long id){ - return findById(id).orElseThrow(()->new RuntimeException()); + boolean existsByEmail(String email); + + default User findByEmailOrElseThrow(String email) { + return findByEmail(email).orElseThrow(()-> new UserException(UserErrorCode.NOT_FOUND_EMAIL)); } } + diff --git a/src/main/java/com/example/trello/user/UserService.java b/src/main/java/com/example/trello/user/UserService.java new file mode 100644 index 0000000..352871b --- /dev/null +++ b/src/main/java/com/example/trello/user/UserService.java @@ -0,0 +1,91 @@ +package com.example.trello.user; + +import com.example.trello.common.exception.UserErrorCode; +import com.example.trello.common.exception.UserException; +import com.example.trello.user.dto.JwtAuthResponse; +import com.example.trello.user.dto.LoginRequestDto; +import com.example.trello.user.dto.SignupRequestDto; +import com.example.trello.user.enums.AccountStatus; +import com.example.trello.util.AuthenticationScheme; +import com.example.trello.util.JwtProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UserService { + + private final UserRepository userRepository; + private final AuthenticationManager authenticationManager; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + private final JwtProvider jwtProvider; + + public void signupUser(SignupRequestDto requestDto) { + boolean isUser = userRepository.existsByEmail(requestDto.getEmail()); + + if (isUser) { + throw new RuntimeException(); + } + + String encodedPassword = bCryptPasswordEncoder.encode(requestDto.getPassword()); + + User user = User.builder(). + email(requestDto.getEmail()) + .password(encodedPassword) + .nickname(requestDto.getNickname()) + .role(requestDto.getRole()) + .status(AccountStatus.ACTIVE).build(); + + userRepository.save(user); + } + + + public JwtAuthResponse login(LoginRequestDto requestDto) { + User findUser = userRepository.findByEmailOrElseThrow(requestDto.getEmail()); + + if (findUser.isDeletedAccount(findUser)) { + throw new UserException(UserErrorCode.ALREADY_DELETED); + } + + this.validatePassword(requestDto.getPassword(), findUser.getPassword()); + + Authentication authentication = this.authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + requestDto.getEmail(), + requestDto.getPassword()) + ); + log.info("SecurituContext에 Authentication μ €μž₯"); + SecurityContextHolder.getContext().setAuthentication(authentication); + + String accessToken = jwtProvider.generateToken(authentication); + log.info("토큰 생성: {}", accessToken); + + return new JwtAuthResponse(AuthenticationScheme.BEARER.getName(), accessToken); + } + + @Transactional + public void leave(Long userId) { + User findUser = userRepository.findByIdOrElseThrow(userId); + findUser.deletedAccount(AccountStatus.DELETED); + } + + private void validatePassword(String rawPassword, String encodedPassword) + throws RuntimeException { + boolean notValid = !bCryptPasswordEncoder.matches(rawPassword, encodedPassword); + if (notValid) { + throw new UserException(UserErrorCode.PASSWORD_INCORRECT); + } + } +} diff --git a/src/main/java/com/example/trello/user/controller/UserController.java b/src/main/java/com/example/trello/user/controller/UserController.java new file mode 100644 index 0000000..3fc4129 --- /dev/null +++ b/src/main/java/com/example/trello/user/controller/UserController.java @@ -0,0 +1,80 @@ +package com.example.trello.user.controller; + +import com.example.trello.common.exception.UserErrorCode; +import com.example.trello.common.exception.UserException; +import com.example.trello.config.auth.UserDetailsImpl; +import com.example.trello.user.User; +import com.example.trello.user.UserService; +import com.example.trello.user.dto.JwtAuthResponse; +import com.example.trello.user.dto.LoginRequestDto; +import com.example.trello.user.dto.SignupRequestDto; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.jaas.SecurityContextLoginModule; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @PostMapping("/sign-up") + public ResponseEntity signupUser(@Valid @RequestBody SignupRequestDto requestDto) { + userService.signupUser(requestDto); + + return ResponseEntity.status(HttpStatus.CREATED).body("νšŒμ›κ°€μž…μ΄ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + @PostMapping("/login") + public ResponseEntity login( + @Valid @RequestBody LoginRequestDto requestDto + ) { + JwtAuthResponse authResponse = this.userService.login(requestDto); + + return ResponseEntity.ok().body(authResponse); + } + + @PostMapping("/logout") + public ResponseEntity logout( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws RuntimeException { + if (authentication != null && authentication.isAuthenticated()) { + new SecurityContextLogoutHandler().logout(request, response, null); + + return ResponseEntity.ok().body("λ‘œκ·Έμ•„μ›ƒ λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + throw new UserException(UserErrorCode.REQUIRED_LOGIN); + } + + @DeleteMapping("/{userId}") + public ResponseEntity leave( + @PathVariable Long userId, + HttpServletRequest httpServletRequest, + HttpServletResponse httpServletResponse, + Authentication authentication + ) throws RuntimeException { + userService.leave(userId); + + if (authentication != null && authentication.isAuthenticated()) { + new SecurityContextLogoutHandler().logout(httpServletRequest, httpServletResponse, null); + + return ResponseEntity.ok().body("νšŒμ› νƒˆν‡΄κ°€ μ •μƒμ μœΌλ‘œ μ²˜λ¦¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + + } + throw new UserException(UserErrorCode.REQUIRED_LOGIN); + } + +} diff --git a/src/main/java/com/example/trello/user/dto/JwtAuthResponse.java b/src/main/java/com/example/trello/user/dto/JwtAuthResponse.java new file mode 100644 index 0000000..cc2af04 --- /dev/null +++ b/src/main/java/com/example/trello/user/dto/JwtAuthResponse.java @@ -0,0 +1,28 @@ +package com.example.trello.user.dto; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PACKAGE) +public class JwtAuthResponse { + + /** + * access token 인증 방식. + */ + private String tokenAuthScheme; + + /** + * access token. + */ + private String accessToken; + + /** + * μƒμ„±μž. + */ + public JwtAuthResponse(String tokenAuthScheme, String accessToken) { + this.tokenAuthScheme = tokenAuthScheme; + this.accessToken = accessToken; + } +} diff --git a/src/main/java/com/example/trello/user/dto/LoginRequestDto.java b/src/main/java/com/example/trello/user/dto/LoginRequestDto.java new file mode 100644 index 0000000..d89de7e --- /dev/null +++ b/src/main/java/com/example/trello/user/dto/LoginRequestDto.java @@ -0,0 +1,24 @@ +package com.example.trello.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class LoginRequestDto { + + @NotBlank(message = "이메일은 ν•„μˆ˜κ°’ μž…λ‹ˆλ‹€.") + @Pattern(regexp = "^[A-Za-z0-9.!@#$+]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}$", message = "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μž…λ ₯ν•΄μ£Όμ„Έμš”") + private final String email; + + @NotBlank(message = "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜κ°’ μž…λ‹ˆλ‹€.") + @Pattern(regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$._+]).{8,16}$" , message = "λΉ„λ°€λ²ˆν˜ΈλŠ” λŒ€λ¬Έμž + μ†Œλ¬Έμž + 숫자 + 특수문자λ₯Ό μ΅œμ†Œ 1κΈ€μžμ”© μž…λ ₯ν•΄μ£Όμ„Έμš”") + private final String password; + + @Builder + public LoginRequestDto(String email, String password) { + this.email = email; + this.password = password; + } +} diff --git a/src/main/java/com/example/trello/user/dto/SignupRequestDto.java b/src/main/java/com/example/trello/user/dto/SignupRequestDto.java new file mode 100644 index 0000000..3bd6ebb --- /dev/null +++ b/src/main/java/com/example/trello/user/dto/SignupRequestDto.java @@ -0,0 +1,35 @@ +package com.example.trello.user.dto; + +import com.example.trello.user.enums.Role; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class SignupRequestDto { + + @NotBlank(message = "이메일은 ν•„μˆ˜κ°’ μž…λ‹ˆλ‹€.") + @Pattern(regexp = "^[A-Za-z0-9.!@#$+]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}$", message = "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μž…λ ₯ν•΄μ£Όμ„Έμš”") + private final String email; + + @NotBlank(message = "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜κ°’ μž…λ‹ˆλ‹€.") + @Pattern(regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$._+]).{8,16}$" , message = "λΉ„λ°€λ²ˆν˜ΈλŠ” λŒ€λ¬Έμž + μ†Œλ¬Έμž + 숫자 + 특수문자λ₯Ό μ΅œμ†Œ 1κΈ€μžμ”© μž…λ €ν•΄μ£Όμ„Έμš”") + private final String password; + + @NotBlank(message = "λ‹‰λ„€μž„μ€ ν•„μˆ˜κ°’ μž…λ‹ˆλ‹€.") + private final String nickname; + + @NotNull(message = "μœ μ € 역할은 ν•„μˆ˜κ°’ μž…λ‹ˆλ‹€.") + private final Role role; + + + @Builder + public SignupRequestDto(String email, String password, String nickname, Role role) { + this.email = email; + this.password = password; + this.nickname = nickname; + this.role = role; + } +} diff --git a/src/main/java/com/example/trello/user/enums/AccountStatus.java b/src/main/java/com/example/trello/user/enums/AccountStatus.java new file mode 100644 index 0000000..cb96adc --- /dev/null +++ b/src/main/java/com/example/trello/user/enums/AccountStatus.java @@ -0,0 +1,5 @@ +package com.example.trello.user.enums; + +public enum AccountStatus { + ACTIVE, DELETED +} diff --git a/src/main/java/com/example/trello/user/enums/Role.java b/src/main/java/com/example/trello/user/enums/Role.java new file mode 100644 index 0000000..90d3461 --- /dev/null +++ b/src/main/java/com/example/trello/user/enums/Role.java @@ -0,0 +1,33 @@ +package com.example.trello.user.enums; + +import lombok.Getter; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; + +@Getter +public enum Role { + USER("USER"), + ADMIN("ADMIN") + + ; + private final String name; + + Role(String name) { + this.name = name; + } + + public static Role of(String roleName) throws IllegalArgumentException { + for (Role role : values()) { + if (role.getName().equals(roleName.toUpperCase())) { + return role; + } + } + + throw new RuntimeException(); + } + + public List getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + this.name)); + } + } diff --git a/src/main/java/com/example/trello/util/AuthenticationScheme.java b/src/main/java/com/example/trello/util/AuthenticationScheme.java new file mode 100644 index 0000000..844dd91 --- /dev/null +++ b/src/main/java/com/example/trello/util/AuthenticationScheme.java @@ -0,0 +1,22 @@ +package com.example.trello.util; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthenticationScheme { + BEARER("Bearer"); + + private final String name; + + /** + * Authorization ν—€λ”μ˜ κ°’μœΌλ‘œ μ‚¬μš©λ  prefixλ₯Ό 생성. + * + * @param authenticationScheme {@link AuthenticationScheme} + * @return μƒμ„±λœ prefix + */ + public static String generateType(AuthenticationScheme authenticationScheme) { + return authenticationScheme.getName() + " "; + } +} diff --git a/src/main/java/com/example/trello/util/FileUploadUtil.java b/src/main/java/com/example/trello/util/FileUploadUtil.java new file mode 100644 index 0000000..2493c98 --- /dev/null +++ b/src/main/java/com/example/trello/util/FileUploadUtil.java @@ -0,0 +1,28 @@ +package com.example.trello.util; + +import org.apache.commons.io.FilenameUtils; + +public class FileUploadUtil { + private static final String[] ALLOWED_BOARD_IMAGE = {"jpg", "png", "gif", "jpeg"}; + private static final String[] ALLOWED_ATTACHMENT_EXTENSIONS = {"jpg", "png", "jpeg", "gif", "pdf", "csv"}; + + public static boolean isAllowedExtension(String fileName) { + String ext = FilenameUtils.getExtension(fileName).toLowerCase(); + for (String allowedExt : ALLOWED_BOARD_IMAGE) { + if (allowedExt.equals(ext)) { + return true; + } + } + return false; + } + + public static boolean isAllowedAttachmentExtension(String fileName) { + String ext = FilenameUtils.getExtension(fileName).toLowerCase(); + for (String allowedExt : ALLOWED_ATTACHMENT_EXTENSIONS) { + if (allowedExt.equals(ext)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/example/trello/util/JwtProvider.java b/src/main/java/com/example/trello/util/JwtProvider.java new file mode 100644 index 0000000..d7ddac5 --- /dev/null +++ b/src/main/java/com/example/trello/util/JwtProvider.java @@ -0,0 +1,169 @@ +package com.example.trello.util; + +import com.example.trello.user.User; +import com.example.trello.user.UserRepository; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.persistence.EntityNotFoundException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.function.Function; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtProvider { + + /** + * JWT μ‹œν¬λ¦Ώ ν‚€. + */ + @Value("${jwt.secret}") + private String secret; + + /** + * 토큰 λ§Œλ£Œμ‹œκ°„(λ°€λ¦¬μ΄ˆ). + */ + @Getter + @Value("${jwt.expiry-millis}") + private Long expiryMillis; + + /** + * Member repository. + */ + private final UserRepository userRepository; + + /** + *

토큰 생성 ν›„ 리턴.

+ * μž…λ ₯받은 {@link Authentication}μ—μ„œ μΆ”μΆœν•œ {@code username}으둜 {@link #generateTokenBy(String)} μ΄μš©ν•œλ‹€. + * + * @param authentication 인증 μ™„λ£Œλœ ν›„ μ„ΈλΆ€ 정보 + * @return μƒμ„±λœ 토큰 + * @throws EntityNotFoundException μž…λ ₯받은 이메일에 ν•΄λ‹Ήν•˜λŠ” μ‚¬μš©μžλ₯Ό μ°Ύμ§€ λͺ»ν–ˆμ„ 경우 + */ + public String generateToken(Authentication authentication) throws EntityNotFoundException { + String username = authentication.getName(); + return this.generateTokenBy(username); + } + + /** + * μž…λ ₯받은 ν† ν°μ—μ„œ {@link Authentication}의 {@code username}을 리턴. + * + * @param token 토큰 + * @return username + */ + public String getUsername(String token) { + Claims claims = this.getClaims(token); + return claims.getSubject(); + } + + /** + * 토큰이 μœ νš¨ν•œμ§€ 확인. + * + * @param token 토큰 + * @return 유효 μ—¬λΆ€. + *
    + *
  • {@code true} - μœ νš¨ν•¨.
  • + *
  • {@code false} - μœ νš¨ν•˜μ§€ μ•ŠμŒ.
  • + *
+ */ + public boolean validToken(String token) throws JwtException { + try { + return !this.tokenExpired(token); + } catch (MalformedJwtException e) { + log.error("Invalid JWT token: {}", e.getMessage()); + } catch (ExpiredJwtException e) { + log.error("JWT token is expired: {}", e.getMessage()); + } catch (UnsupportedJwtException e) { + log.error("JWT token is unsupported: {}", e.getMessage()); + } + + return false; + } + + /** + *

이메일 μ£Όμ†Œλ₯Ό μ΄μš©ν•΄ 토큰을 μƒμ„±ν•œ ν›„ 리턴.

+ *

토큰 μƒμ„±μ—λŠ” HS256 μ•Œκ³ λ¦¬μ¦˜μ„ 이용.

+ * + * @param email 이메일 + * @return μƒμ„±λœ 토큰 + * @throws EntityNotFoundException μž…λ ₯받은 이메일에 ν•΄λ‹Ήν•˜λŠ” μ‚¬μš©μžλ₯Ό μ°Ύμ§€ λͺ»ν–ˆμ„ 경우 + */ + private String generateTokenBy(String email) throws EntityNotFoundException { + User user = this.userRepository.findByEmailOrElseThrow(email); + Date currentDate = new Date(); + Date expireDate = new Date(currentDate.getTime() + this.expiryMillis); + + return Jwts.builder() + .subject(email) + .issuedAt(currentDate) + .expiration(expireDate) + .claim("role", user.getRole()) + .signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)), Jwts.SIG.HS256) + .compact(); + } + + /** + * JWT의 claim 뢀뢄을 μΆ”μΆœ. + * + * @param token 토큰 + * @return {@link Claims} + * @see JSON μ›Ή 토큰 + */ + private Claims getClaims(String token) { + if (!StringUtils.hasText(token)) { + throw new MalformedJwtException("토큰이 λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€."); + } + + return Jwts.parser() + .verifyWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + /** + * μž…λ ₯받은 ν† ν°μ˜ 만료 μ—¬λΆ€. + * + * @param token 토큰 + * @return 만료 μ—¬λΆ€ + *
    + *
  • {@code true} - 만료됨.
  • + *
  • {@code false} - λ§Œλ£Œλ˜μ§€ μ•ŠμŒ.
  • + *
+ */ + private boolean tokenExpired(String token) { + final Date expiration = this.getExpirationDateFromToken(token); + return expiration.before(new Date()); + } + + /** + * μž…λ ₯ 받은 ν† ν°μ˜ λ§Œλ£ŒμΌμ„ 리턴. + * + * @param token 토큰 + * @return 만료일 + */ + private Date getExpirationDateFromToken(String token) { + return this.resolveClaims(token, Claims::getExpiration); + } + + /** + * 토큰에 μž…λ ₯ 받은 λ‘œμ§μ„ μ μš©ν•˜κ³  κ·Έ κ²°κ³Όλ₯Ό 리턴. + * + * @param token 토큰 + * @param claimsResolver 토큰에 μ μš©ν•  둜직. + * @param {@code claimsResolver}의 리턴 νƒ€μž…. + * @return {@code T} + */ + private T resolveClaims(String token, Function claimsResolver) { + final Claims claims = this.getClaims(token); + return claimsResolver.apply(claims); + } +} diff --git a/src/main/java/com/example/trello/workspace/WorkSpaceRepository.java b/src/main/java/com/example/trello/workspace/WorkSpaceRepository.java index f8382b1..4741a60 100644 --- a/src/main/java/com/example/trello/workspace/WorkSpaceRepository.java +++ b/src/main/java/com/example/trello/workspace/WorkSpaceRepository.java @@ -1,5 +1,9 @@ package com.example.trello.workspace; +import com.example.trello.common.exception.WorkspaceErrorCode; +import com.example.trello.common.exception.WorkspaceException; +import com.example.trello.common.exception.WorkspaceMemberErrorCode; +import com.example.trello.common.exception.WorkspaceMemberException; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -7,7 +11,7 @@ public interface WorkSpaceRepository extends JpaRepository { default Workspace findByIdOrElseThrow(Long id){ - return findById(id).orElseThrow(()->new RuntimeException()); + return findById(id).orElseThrow(() -> new WorkspaceException(WorkspaceErrorCode.CAN_NOT_FIND_WORKSPACE_WITH_WORKSPACE_ID)); } } diff --git a/src/main/java/com/example/trello/workspace/Workspace.java b/src/main/java/com/example/trello/workspace/Workspace.java index cf983e9..257d8e7 100644 --- a/src/main/java/com/example/trello/workspace/Workspace.java +++ b/src/main/java/com/example/trello/workspace/Workspace.java @@ -2,8 +2,11 @@ import com.example.trello.board.Board; import com.example.trello.user.User; +import com.example.trello.workspace_member.WorkspaceMember; import jakarta.persistence.*; +import lombok.Builder; import lombok.Getter; +import org.hibernate.annotations.DynamicUpdate; import java.util.List; @@ -14,15 +17,38 @@ public class Workspace { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "title") + @Column(name = "title", nullable = false) private String title; - @Column(name = "description") + @Column(name = "description", nullable = false) private String description; + @Column(name = "slack_url") + private String slackUrl; + @OneToMany(mappedBy = "workspace", cascade = CascadeType.ALL, orphanRemoval = true) private List boards; + @OneToMany(mappedBy = "workspace", cascade = CascadeType.ALL, orphanRemoval = true) + private List workspaceMembers; + @ManyToOne(fetch = FetchType.LAZY) private User user; + + public Workspace() { + } + + @Builder + public Workspace(String title, String description, String slackUrl, User user) { + this.title = title; + this.description = description; + this.slackUrl = slackUrl; + this.user = user; + } + + public void updateWorkspace(String title, String description, String slackUrl) { + this.title = title; + this.description = description; + this.slackUrl = slackUrl; + } } diff --git a/src/main/java/com/example/trello/workspace/WorkspaceController.java b/src/main/java/com/example/trello/workspace/WorkspaceController.java new file mode 100644 index 0000000..6af578f --- /dev/null +++ b/src/main/java/com/example/trello/workspace/WorkspaceController.java @@ -0,0 +1,52 @@ +package com.example.trello.workspace; + +import com.example.trello.config.auth.UserDetailsImpl; +import com.example.trello.workspace.dto.UpdateWorkspaceRequestDto; +import com.example.trello.workspace.dto.WorkspaceRequestDto; +import com.example.trello.workspace.dto.WorkspaceResponseDto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/workspaces") +public class WorkspaceController { + private final WorkspaceService workspaceService; + + @PostMapping + public ResponseEntity createWorkspace(@Valid @RequestBody WorkspaceRequestDto dto, @AuthenticationPrincipal UserDetailsImpl userDetails) { + WorkspaceResponseDto createdWorkspaceResponseDto = workspaceService.createWorkspace(dto, userDetails.getUser().getId()); + + return new ResponseEntity<>(createdWorkspaceResponseDto, HttpStatus.CREATED); + } + + @GetMapping + public ResponseEntity> viewAllWorkspace(@AuthenticationPrincipal UserDetailsImpl userDetails) { + List workspaceResponseDtoList = workspaceService.viewAllWorkspace(userDetails.getUser().getId()); + return new ResponseEntity<>(workspaceResponseDtoList, HttpStatus.OK); + } + + @GetMapping("/{workspaceId}") + public ResponseEntity viewWorkspace(@PathVariable Long workspaceId, @AuthenticationPrincipal UserDetailsImpl userDetails) { + WorkspaceResponseDto findWorkspaceResponseDto = workspaceService.viewWorkspace(workspaceId, userDetails.getUser().getId()); + return new ResponseEntity<>(findWorkspaceResponseDto, HttpStatus.OK); + } + + @PatchMapping("/{workspaceId}") + public ResponseEntity updateWorkspace(@PathVariable Long workspaceId, @Valid @RequestBody UpdateWorkspaceRequestDto dto, @AuthenticationPrincipal UserDetailsImpl userDetails) { + WorkspaceResponseDto updatedWorkspaceResponseDto = workspaceService.updateWorkspace(workspaceId, dto, userDetails.getUser().getId()); + return new ResponseEntity<>(updatedWorkspaceResponseDto, HttpStatus.OK); + } + + @DeleteMapping("/{workspaceId}") + public ResponseEntity deleteWorkspace(@PathVariable Long workspaceId, @AuthenticationPrincipal UserDetailsImpl userDetails) { + workspaceService.deleteWorkspace(workspaceId, userDetails.getUser().getId()); + return new ResponseEntity<>("μ›Œν¬μŠ€νŽ˜μ΄μŠ€κ°€ μ •μƒμ μœΌλ‘œ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.OK); + } +} diff --git a/src/main/java/com/example/trello/workspace/WorkspaceService.java b/src/main/java/com/example/trello/workspace/WorkspaceService.java new file mode 100644 index 0000000..d4fa716 --- /dev/null +++ b/src/main/java/com/example/trello/workspace/WorkspaceService.java @@ -0,0 +1,110 @@ +package com.example.trello.workspace; + +import com.example.trello.common.exception.WorkspaceErrorCode; +import com.example.trello.common.exception.WorkspaceException; +import com.example.trello.user.User; +import com.example.trello.user.UserRepository; +import com.example.trello.workspace.dto.UpdateWorkspaceRequestDto; +import com.example.trello.workspace.dto.WorkspaceRequestDto; +import com.example.trello.workspace.dto.WorkspaceResponseDto; + + +import com.example.trello.workspace_member.WorkspaceMember; +import com.example.trello.workspace_member.WorkspaceMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +import java.util.ArrayList; +import java.util.List; + +import static com.example.trello.user.enums.Role.ADMIN; +import static com.example.trello.workspace_member.WorkspaceMemberRole.WORKSPACE; + +@Service +@RequiredArgsConstructor +public class WorkspaceService { + private final UserRepository userRepository; + private final WorkSpaceRepository workSpaceRepository; + private final WorkspaceMemberRepository workspaceMemberRepository; + + @Transactional + public WorkspaceResponseDto createWorkspace(WorkspaceRequestDto dto, Long loginUserId) { + User loginUser = userRepository.findByIdOrElseThrow(loginUserId); + + if (loginUser.getRole() != ADMIN) { + throw new WorkspaceException(WorkspaceErrorCode.ONLY_ADMIN_CAN_CREATE_WORKSPACE); + } + + Workspace workspace = Workspace.builder() + .title(dto.getTitle()) + .description(dto.getDescription()) + .slackUrl(dto.getSlackUrl()) + .user(loginUser) + .build(); + + WorkspaceMember workspaceMember = WorkspaceMember.builder() + .user(loginUser) + .workspace(workspace) + .role(WORKSPACE) + .build(); + + workSpaceRepository.save(workspace); + workspaceMemberRepository.save(workspaceMember); + + return WorkspaceResponseDto.toDto(workspace); + } + + @Transactional(readOnly = true) + public List viewAllWorkspace(Long loginUserId) { + List WorkspaceMemberListByUser = workspaceMemberRepository.findByUserId(loginUserId); + + List workspaceList = new ArrayList<>(); + for (WorkspaceMember workspaceMember : WorkspaceMemberListByUser) { + workspaceList.add(workspaceMember.getWorkspace()); + } + + return workspaceList + .stream() + .map(WorkspaceResponseDto::toDto) + .toList(); + } + + @Transactional(readOnly = true) + public WorkspaceResponseDto viewWorkspace(Long workspaceId, Long loginUserId) { + WorkspaceMember findWorkspaceMember = workspaceMemberRepository.findByUserIdAndWorkspaceIdOrElseThrow(loginUserId, workspaceId); + + Workspace workspace = findWorkspaceMember.getWorkspace(); + + return WorkspaceResponseDto.toDto(workspace); + } + + @Transactional + public WorkspaceResponseDto updateWorkspace(Long workspaceId, UpdateWorkspaceRequestDto dto, Long loginUserId) { + WorkspaceMember findWorkspaceMember = workspaceMemberRepository.findByUserIdAndWorkspaceIdOrElseThrow(loginUserId, workspaceId); + + if (findWorkspaceMember.getRole() != WORKSPACE) { + throw new WorkspaceException(WorkspaceErrorCode.ONLY_WORKSPACE_ROLE_CAN_HANDLE_WORKSPACE); + } + + Workspace workspace = findWorkspaceMember.getWorkspace(); + + workspace.updateWorkspace(dto.getTitle(), dto.getDescription(), dto.getSlackUrl()); + + return WorkspaceResponseDto.toDto(workspace); + } + + @Transactional + public void deleteWorkspace(Long workspaceId, Long loginUserId) { + WorkspaceMember findWorkspaceMember = workspaceMemberRepository.findByUserIdAndWorkspaceIdOrElseThrow(loginUserId, workspaceId); + + if (findWorkspaceMember.getRole() != WORKSPACE) { + throw new WorkspaceException(WorkspaceErrorCode.ONLY_WORKSPACE_ROLE_CAN_HANDLE_WORKSPACE); + } + + Workspace workspace = findWorkspaceMember.getWorkspace(); + + workSpaceRepository.delete(workspace); + } +} diff --git a/src/main/java/com/example/trello/workspace/dto/UpdateWorkspaceRequestDto.java b/src/main/java/com/example/trello/workspace/dto/UpdateWorkspaceRequestDto.java new file mode 100644 index 0000000..8352c97 --- /dev/null +++ b/src/main/java/com/example/trello/workspace/dto/UpdateWorkspaceRequestDto.java @@ -0,0 +1,24 @@ +package com.example.trello.workspace.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class UpdateWorkspaceRequestDto { + @NotBlank(message = "title 은 Null 일 수 μ—†μŠ΅λ‹ˆλ‹€.") + @Size(min = 1, max = 50, message = "title ν¬κΈ°λŠ” 1μ—μ„œ 50μ‚¬μ΄μ—¬μ•Όν•©λ‹ˆλ‹€.") + private String title; + + @NotBlank(message = "description 은 Null 일 수 μ—†μŠ΅λ‹ˆλ‹€.") + @Size(min = 1, max = 255, message = "title ν¬κΈ°λŠ” 1μ—μ„œ 255μ‚¬μ΄μ—¬μ•Όν•©λ‹ˆλ‹€.") + private String description; + + private String slackUrl; + + public UpdateWorkspaceRequestDto(String title, String description, String slackUrl) { + this.title = title; + this.description = description; + this.slackUrl = slackUrl; + } +} diff --git a/src/main/java/com/example/trello/workspace/dto/WorkspaceRequestDto.java b/src/main/java/com/example/trello/workspace/dto/WorkspaceRequestDto.java new file mode 100644 index 0000000..d6f89c8 --- /dev/null +++ b/src/main/java/com/example/trello/workspace/dto/WorkspaceRequestDto.java @@ -0,0 +1,24 @@ +package com.example.trello.workspace.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class WorkspaceRequestDto { + @NotBlank(message = "title 은 Null 일 수 μ—†μŠ΅λ‹ˆλ‹€.") + @Size(min = 1, max = 50, message = "title ν¬κΈ°λŠ” 1μ—μ„œ 50μ‚¬μ΄μ—¬μ•Όν•©λ‹ˆλ‹€.") + private String title; + + @NotBlank(message = "description 은 Null 일 수 μ—†μŠ΅λ‹ˆλ‹€.") + @Size(min = 1, max = 255, message = "title ν¬κΈ°λŠ” 1μ—μ„œ 255μ‚¬μ΄μ—¬μ•Όν•©λ‹ˆλ‹€.") + private String description; + + private String slackUrl; + + public WorkspaceRequestDto(String title, String description, String slackUrl) { + this.title = title; + this.description = description; + this.slackUrl = slackUrl; + } +} diff --git a/src/main/java/com/example/trello/workspace/dto/WorkspaceResponseDto.java b/src/main/java/com/example/trello/workspace/dto/WorkspaceResponseDto.java new file mode 100644 index 0000000..dbeef98 --- /dev/null +++ b/src/main/java/com/example/trello/workspace/dto/WorkspaceResponseDto.java @@ -0,0 +1,23 @@ +package com.example.trello.workspace.dto; + +import com.example.trello.workspace.Workspace; +import lombok.Getter; + +@Getter +public class WorkspaceResponseDto { + private Long workspaceId; + private String title; + private String description; + private String slackUrl; + + public WorkspaceResponseDto(Long workspaceId, String title, String description, String slackUrl) { + this.workspaceId = workspaceId; + this.title = title; + this.description = description; + this.slackUrl = slackUrl; + } + + public static WorkspaceResponseDto toDto(Workspace workspace) { + return new WorkspaceResponseDto(workspace.getId(), workspace.getTitle(), workspace.getDescription(), workspace.getSlackUrl()); + } +} diff --git a/src/main/java/com/example/trello/workspace_member/WorkspaceMember.java b/src/main/java/com/example/trello/workspace_member/WorkspaceMember.java new file mode 100644 index 0000000..a72d016 --- /dev/null +++ b/src/main/java/com/example/trello/workspace_member/WorkspaceMember.java @@ -0,0 +1,40 @@ +package com.example.trello.workspace_member; + +import com.example.trello.user.User; +import com.example.trello.workspace.Workspace; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; + +@Entity +@Getter +public class WorkspaceMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + private WorkspaceMemberRole role; + + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + private Workspace workspace; + + public WorkspaceMember() { + } + + @Builder + public WorkspaceMember(WorkspaceMemberRole role, User user, Workspace workspace) { + this.role = role; + this.user = user; + this.workspace = workspace; + } + + public void updateRole(WorkspaceMemberRole role) { + this.role = role; + } +} diff --git a/src/main/java/com/example/trello/workspace_member/WorkspaceMemberController.java b/src/main/java/com/example/trello/workspace_member/WorkspaceMemberController.java new file mode 100644 index 0000000..a8583e6 --- /dev/null +++ b/src/main/java/com/example/trello/workspace_member/WorkspaceMemberController.java @@ -0,0 +1,32 @@ +package com.example.trello.workspace_member; + +import com.example.trello.config.auth.UserDetailsImpl; +import com.example.trello.workspace_member.dto.UpdateWorkspaceMemberRoleDto; +import com.example.trello.workspace_member.dto.WorkspaceMemberRequestDto; +import com.example.trello.workspace_member.dto.WorkspaceMemberResponseDto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/workspaces/{workspaceId}") +public class WorkspaceMemberController { + + private final WorkspaceMemberService workspaceMemberService; + + @PostMapping("/member-invite") + public ResponseEntity inviteWorkspaceUser(@PathVariable Long workspaceId, @Valid @RequestBody WorkspaceMemberRequestDto dto, @AuthenticationPrincipal UserDetailsImpl userDetails) { + WorkspaceMemberResponseDto workspaceMemberResponseDto = workspaceMemberService.inviteWorkspaceMember(workspaceId, dto, userDetails.getUser().getId()); + return new ResponseEntity<>(workspaceMemberResponseDto, HttpStatus.CREATED); + } + + @PatchMapping("/member-role") + public ResponseEntity updateWorkspaceUserRole(@PathVariable Long workspaceId, @Valid @RequestBody UpdateWorkspaceMemberRoleDto dto, @AuthenticationPrincipal UserDetailsImpl userDetails) { + workspaceMemberService.updateWorkspaceMemberRole(workspaceId, dto, userDetails.getUser().getId()); + return new ResponseEntity<>("κΆŒν•œμ΄ " + dto.getRole() + "둜 λ³€κ²½λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.OK); + } +} diff --git a/src/main/java/com/example/trello/workspace_member/WorkspaceMemberRepository.java b/src/main/java/com/example/trello/workspace_member/WorkspaceMemberRepository.java new file mode 100644 index 0000000..85229a1 --- /dev/null +++ b/src/main/java/com/example/trello/workspace_member/WorkspaceMemberRepository.java @@ -0,0 +1,32 @@ +package com.example.trello.workspace_member; + +import com.example.trello.common.exception.WorkspaceMemberErrorCode; +import com.example.trello.common.exception.WorkspaceMemberException; +import com.example.trello.user.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +import static org.springframework.http.HttpStatus.FORBIDDEN; + +@Repository +public interface WorkspaceMemberRepository extends JpaRepository { + List findByUserId(Long userId); + Optional findByUserIdAndWorkspaceId(Long userId, Long workspaceId); + Optional findByIdAndWorkspaceId(Long id, Long workspaceId); + Boolean existsByUserIdAndWorkspaceId(Long userId, Long workspaceId); + + default WorkspaceMember findByIdOrElseThrow(Long id){ + return findById(id).orElseThrow(() -> new WorkspaceMemberException(WorkspaceMemberErrorCode.CAN_NOT_FIND_WORKSPACEMEMBER_WITH_WORKSPACEMEMBER_ID)); + } + + default WorkspaceMember findByIdAndWorkspaceIdOrElseThrow(Long id, Long workspaceId){ + return findByIdAndWorkspaceId(id, workspaceId).orElseThrow(() -> new WorkspaceMemberException(WorkspaceMemberErrorCode.IS_NOT_WORKSPACEMEMBER)); + } + + default WorkspaceMember findByUserIdAndWorkspaceIdOrElseThrow(Long userId, Long workspaceId) { + return findByUserIdAndWorkspaceId(userId, workspaceId).orElseThrow(() -> new WorkspaceMemberException(WorkspaceMemberErrorCode.IS_NOT_WORKSPACEMEMBER)); + } +} diff --git a/src/main/java/com/example/trello/workspace_member/WorkspaceMemberRole.java b/src/main/java/com/example/trello/workspace_member/WorkspaceMemberRole.java new file mode 100644 index 0000000..f9722cc --- /dev/null +++ b/src/main/java/com/example/trello/workspace_member/WorkspaceMemberRole.java @@ -0,0 +1,7 @@ +package com.example.trello.workspace_member; + +public enum WorkspaceMemberRole { + WORKSPACE, + BOARD, + READ_ONLY +} diff --git a/src/main/java/com/example/trello/workspace_member/WorkspaceMemberService.java b/src/main/java/com/example/trello/workspace_member/WorkspaceMemberService.java new file mode 100644 index 0000000..e664846 --- /dev/null +++ b/src/main/java/com/example/trello/workspace_member/WorkspaceMemberService.java @@ -0,0 +1,87 @@ +package com.example.trello.workspace_member; + +import com.example.trello.common.exception.WorkspaceErrorCode; +import com.example.trello.common.exception.WorkspaceException; +import com.example.trello.common.exception.WorkspaceMemberErrorCode; +import com.example.trello.common.exception.WorkspaceMemberException; +import com.example.trello.notification.NotificationService; +import com.example.trello.user.User; +import com.example.trello.user.UserRepository; +import com.example.trello.workspace.WorkSpaceRepository; +import com.example.trello.workspace.Workspace; +import com.example.trello.workspace_member.dto.UpdateWorkspaceMemberRoleDto; +import com.example.trello.workspace_member.dto.WorkspaceMemberRequestDto; +import com.example.trello.workspace_member.dto.WorkspaceMemberResponseDto; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import static com.example.trello.common.exception.WorkspaceMemberErrorCode.CAN_NOT_READ_ROLE; +import static com.example.trello.notification.NotificationType.ADD_MEMBER; +import static com.example.trello.user.enums.Role.ADMIN; +import static com.example.trello.workspace_member.WorkspaceMemberRole.READ_ONLY; +import static com.example.trello.workspace_member.WorkspaceMemberRole.WORKSPACE; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WorkspaceMemberService { + + private final WorkspaceMemberRepository workspaceMemberRepository; + private final WorkSpaceRepository workSpaceRepository; + private final UserRepository userRepository; + private final NotificationService notificationService; + + @Transactional + public WorkspaceMemberResponseDto inviteWorkspaceMember(Long workspaceId, WorkspaceMemberRequestDto dto, Long loginUserId) { + WorkspaceMember findWorkspaceMember = workspaceMemberRepository.findByUserIdAndWorkspaceIdOrElseThrow(loginUserId, workspaceId); + + if (findWorkspaceMember.getRole() != WORKSPACE) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.ONLY_WORKSPACE_ROLE_CAN_INVITE); + } + + User findUser = userRepository.findByEmailOrElseThrow(dto.getEmail()); + Workspace workspace = findWorkspaceMember.getWorkspace(); + + if (workspaceMemberRepository.existsByUserIdAndWorkspaceId(findUser.getId(), workspace.getId())) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.ALREADY_MEMBER); + } + + WorkspaceMember workspaceMember = WorkspaceMember.builder() + .user(findUser) + .workspace(workspace) + .role(READ_ONLY) + .build(); + + workspaceMemberRepository.save(workspaceMember); + notificationService.sendSlack(ADD_MEMBER, workspace); + + return WorkspaceMemberResponseDto.toDto(workspaceMember); + } + + @Transactional + public void updateWorkspaceMemberRole(Long workspaceId, UpdateWorkspaceMemberRoleDto dto, Long loginUserId) { + WorkspaceMember findWorkspaceMember = workspaceMemberRepository.findByUserIdAndWorkspaceIdOrElseThrow(loginUserId, workspaceId); + + if (dto.getRole() == WORKSPACE && findWorkspaceMember.getUser().getRole() != ADMIN) { + throw new WorkspaceException(WorkspaceErrorCode.ONLY_ADMIN_CAN_UPDATE_MEMBER_ROLE_TO_WORKSPACE); + } + + if (findWorkspaceMember.getRole() != WORKSPACE) { + throw new WorkspaceMemberException(WorkspaceMemberErrorCode.ONLY_WORKSPACE_ROLE_CAN_UPDATE_MEMBER_ROLE); + } + + WorkspaceMember roleUpdatedWorkspaceMember = workspaceMemberRepository.findByIdAndWorkspaceIdOrElseThrow(dto.getWorkspaceMemberId(), workspaceId); + + roleUpdatedWorkspaceMember.updateRole(dto.getRole()); + log.info("{}의 역할이 {}둜 λ³€κ²½λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", roleUpdatedWorkspaceMember.getUser().getNickname(), dto.getRole()); + } + + public void checkReadRole(Long userId, Long workspaceId) { + WorkspaceMember workspaceMember = workspaceMemberRepository.findByUserIdAndWorkspaceIdOrElseThrow(userId, workspaceId); + if(workspaceMember.getRole()==READ_ONLY) { + throw new WorkspaceMemberException(CAN_NOT_READ_ROLE); + } + } +} diff --git a/src/main/java/com/example/trello/workspace_member/dto/UpdateWorkspaceMemberRoleDto.java b/src/main/java/com/example/trello/workspace_member/dto/UpdateWorkspaceMemberRoleDto.java new file mode 100644 index 0000000..17cba52 --- /dev/null +++ b/src/main/java/com/example/trello/workspace_member/dto/UpdateWorkspaceMemberRoleDto.java @@ -0,0 +1,20 @@ +package com.example.trello.workspace_member.dto; + +import com.example.trello.workspace_member.WorkspaceMemberRole; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class UpdateWorkspaceMemberRoleDto { + @NotNull + private Long workspaceMemberId; + + @NotNull + private WorkspaceMemberRole role; + + public UpdateWorkspaceMemberRoleDto(Long workspaceMemberId, WorkspaceMemberRole role) { + this.workspaceMemberId = workspaceMemberId; + this.role = role; + } +} diff --git a/src/main/java/com/example/trello/workspace_member/dto/WorkspaceMemberRequestDto.java b/src/main/java/com/example/trello/workspace_member/dto/WorkspaceMemberRequestDto.java new file mode 100644 index 0000000..34d3951 --- /dev/null +++ b/src/main/java/com/example/trello/workspace_member/dto/WorkspaceMemberRequestDto.java @@ -0,0 +1,16 @@ +package com.example.trello.workspace_member.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; + +@Getter +public class WorkspaceMemberRequestDto { + @NotBlank + @Pattern(regexp = "^[A-Za-z0-9.!@#$+]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}$", message = "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μž…λ ₯ν•΄μ£Όμ„Έμš”") + private String email; + + public WorkspaceMemberRequestDto(String email) { + this.email = email; + } +} diff --git a/src/main/java/com/example/trello/workspace_member/dto/WorkspaceMemberResponseDto.java b/src/main/java/com/example/trello/workspace_member/dto/WorkspaceMemberResponseDto.java new file mode 100644 index 0000000..5419efd --- /dev/null +++ b/src/main/java/com/example/trello/workspace_member/dto/WorkspaceMemberResponseDto.java @@ -0,0 +1,22 @@ +package com.example.trello.workspace_member.dto; + +import com.example.trello.workspace_member.WorkspaceMember; +import com.example.trello.workspace_member.WorkspaceMemberRole; +import lombok.Getter; + +@Getter +public class WorkspaceMemberResponseDto { + private Long userId; + private Long workspaceId; + private WorkspaceMemberRole role; + + public WorkspaceMemberResponseDto(Long userId, Long workspaceId, WorkspaceMemberRole role) { + this.userId = userId; + this.workspaceId = workspaceId; + this.role = role; + } + + public static WorkspaceMemberResponseDto toDto(WorkspaceMember workspaceMember) { + return new WorkspaceMemberResponseDto(workspaceMember.getUser().getId(), workspaceMember.getWorkspace().getId(), workspaceMember.getRole()); + } +} diff --git a/src/main/java/com/example/trello/workspace_user/WorkspaceUser.java b/src/main/java/com/example/trello/workspace_user/WorkspaceUser.java deleted file mode 100644 index 4aa39ef..0000000 --- a/src/main/java/com/example/trello/workspace_user/WorkspaceUser.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.trello.workspace_user; - -import com.example.trello.user.User; -import jakarta.persistence.*; -import lombok.Getter; - -@Entity -@Getter -public class WorkspaceUser { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - private User user; - - @ManyToOne(fetch = FetchType.LAZY) - private WorkspaceUser workspaceUser; - - -} diff --git a/src/main/java/com/example/trello/workspace_user/WorkspaceUserRepository.java b/src/main/java/com/example/trello/workspace_user/WorkspaceUserRepository.java deleted file mode 100644 index 89adcd2..0000000 --- a/src/main/java/com/example/trello/workspace_user/WorkspaceUserRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.trello.workspace_user; - -import com.example.trello.workspace.Workspace; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface WorkspaceUserRepository extends JpaRepository { - - default WorkspaceUser findByIdOrElseThrow(Long id){ - return findById(id).orElseThrow(()->new RuntimeException()); - } -} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index af41afb..697e26a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,10 +4,16 @@ spring.datasource.url=jdbc:mysql://localhost:3306/${DB_NAME} spring.datasource.username=${DB_USERNAME} spring.datasource.password=${DB_PASSWORD} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -spring.jpa.hibernate.ddl-auto=none +spring.jpa.hibernate.ddl-auto=create spring.jpa.properties.hibernate.show_sql=true spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.use_sql_comments=true - spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect + +spring.servlet.multipart.enabled=true +spring.servlet.multipart.max-file-size=5MB +spring.servlet.multipart.max-request-size=5MB + +jwt.secret="036c4fe3ec667532545b9e8fa7e2a98a22f439dff102623c097715060e2da68c" +jwt.expiry-millis=3600000 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..0e44c6f --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,9 @@ +cloud: + aws: + s3: + bucket: ${BUCKET_NAME} + stack.auto: false + region.static: ap-northeast-2 + credentials: + accessKey: ${BUCKET_ACCESSKEY} + secretKey: ${BUCKET_SECRETKEY} \ No newline at end of file diff --git a/src/test/java/com/example/trello/TrelloApplicationTests.java b/src/test/java/com/example/trello/TrelloApplicationTests.java deleted file mode 100644 index 6281f09..0000000 --- a/src/test/java/com/example/trello/TrelloApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.trello; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class TrelloApplicationTests { - - @Test - void contextLoads() { - } - -}