diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md new file mode 100644 index 00000000..9f83fb85 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue-template.md @@ -0,0 +1,20 @@ +--- +name: Issue Template +about: 이슈 템플릿입니다. +title: '' +labels: '' +assignees: '' + +--- + +**📌 상세 설명** + +[comment]: <> (이슈에 대한 설명을 적어주세요) + +**📝 체크리스트** + +[comment]: <> (해야 할 일들을 상세히 나눠 적어주시면 좋아요) + +- [ ] + +- [ ] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..971ff5e1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +## ✨ Related Issue +- close #이슈번호 +
+ +## 📝 기능 구현 명세 +- 이곳에는 postman 테스트 결과를 넣어주세요 + +## 🐥 추가적인 언급 사항 diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 00000000..c1a02871 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,40 @@ +name: CI + +on: + pull_request: + branches: [ "develop" ] + +jobs: + build: + runs-on: ubuntu-22.04 + + steps: + - name: checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: '17' + + - name: create application-secret.yml + run: | + # create application-secret.yml + cd ./src/main/resources + + # application-secret.yml 파일 생성 + touch ./application-secret.yml + + # GitHub-Actions 에서 설정한 값을 application-secret.yml 파일에 쓰기..git + echo "${{ secrets.CI_APPLICATION_SECRET }}" >> ./application-secret.yml + + # application.yml 파일 확인 + cat ./application-secret.yml + shell: bash + + - name: build + run: | + chmod +x gradlew + ./gradlew build -x test + shell: bash diff --git a/.github/workflows/DOCKER-CD.yml b/.github/workflows/DOCKER-CD.yml new file mode 100644 index 00000000..9678e1f5 --- /dev/null +++ b/.github/workflows/DOCKER-CD.yml @@ -0,0 +1,69 @@ +name: DOCKER-CD +on: + push: + branches: [ "develop" ] + +jobs: + ci: + runs-on: ubuntu-22.04 + env: + working-directory: . + + steps: + - name: 체크아웃 + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: '17' + + - name: application-secret.yml 생성 + run: | + cd ./src/main/resources + touch ./application-secret.yml + echo "${{ secrets.CD_APPLICATION_SECRET}}" > ./application-secret.yml + cat ./application-secret.yml + cat ./application-dev.yml + working-directory: ${{ env.working-directory }} + + - name: 빌드 + run: | + chmod +x gradlew + ./gradlew build -x test + working-directory: ${{ env.working-directory }} + shell: bash + + + - name: docker build 환경 설정 + uses: docker/setup-buildx-action@v2.9.1 + + - name: docker hub 로그인 + uses: docker/login-action@v2.2.0 + with: + username: ${{ secrets.DOCKER_LOGIN_USERNAME }} + password: ${{ secrets.DOCKER_LOGIN_ACCESSTOKEN }} + + - name: docker image 빌드 및 푸시 + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile-Dev + push: true + tags: ${{ secrets.DOCKER_LOGIN_USERNAME }}/dev + + cd: + needs: ci + runs-on: ubuntu-22.04 + + steps: + - name: docker 컨테이너 실행 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_IP }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_KEY }} + script: | + cd ~ + ./deploy.sh diff --git a/.github/workflows/PROD-CD.yml b/.github/workflows/PROD-CD.yml new file mode 100644 index 00000000..7db63e9c --- /dev/null +++ b/.github/workflows/PROD-CD.yml @@ -0,0 +1,68 @@ +name: PROD-CD +on: + push: + branches: [ "main" ] + +jobs: + ci: + runs-on: ubuntu-22.04 + env: + working-directory: . + + steps: + - name: 체크아웃 + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: '17' + + - name: application-secret.yml 생성 + run: | + cd ./src/main/resources + touch ./application-secret.yml + echo "${{ secrets.CD_APPLICATION_SECRET}}" > ./application-secret.yml + cat ./application-secret.yml + cat ./application-dev.yml + working-directory: ${{ env.working-directory }} + + - name: 빌드 + run: | + chmod +x gradlew + ./gradlew build -x test + working-directory: ${{ env.working-directory }} + shell: bash + + - name: docker build 환경 설정 + uses: docker/setup-buildx-action@v2.9.1 + + - name: docker hub 로그인 + uses: docker/login-action@v2.2.0 + with: + username: ${{ secrets.DOCKER_LOGIN_USERNAME_PROD }} + password: ${{ secrets.DOCKER_LOGIN_ACCESSTOKEN_PROD }} + + - name: docker image 빌드 및 푸시 + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile-Prod + push: true + tags: ${{ secrets.DOCKER_LOGIN_USERNAME_PROD }}/prod + + cd: + needs: ci + runs-on: ubuntu-22.04 + + steps: + - name: docker 컨테이너 실행 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_IP_PROD }} + username: ${{ secrets.SERVER_USER_PROD }} + key: ${{ secrets.SERVER_KEY_PROD }} + script: | + cd ~ + ./deploy.sh diff --git a/.github/workflows/PROD-CI.yml b/.github/workflows/PROD-CI.yml new file mode 100644 index 00000000..63a44225 --- /dev/null +++ b/.github/workflows/PROD-CI.yml @@ -0,0 +1,40 @@ +name: PROD-CI + +on: + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-22.04 + + steps: + - name: checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: '17' + + - name: create application-secret.yml + run: | + # create application-secret.yml + cd ./src/main/resources + + # application-secret.yml 파일 생성 + touch ./application-secret.yml + + # GitHub-Actions 에서 설정한 값을 application-secret.yml 파일에 쓰기..git + echo "${{ secrets.CI_APPLICATION_SECRET }}" >> ./application-secret.yml + + # application.yml 파일 확인 + cat ./application-secret.yml + shell: bash + + - name: build + run: | + chmod +x gradlew + ./gradlew build -x test + shell: bash diff --git a/.gitignore b/.gitignore index 1fc1e4d1..416d9087 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ out/ .vscode/ application-secret.yml +application-local.yml diff --git a/Dockerfile-Dev b/Dockerfile-Dev new file mode 100644 index 00000000..0fcbb90e --- /dev/null +++ b/Dockerfile-Dev @@ -0,0 +1,4 @@ +FROM amd64/amazoncorretto:17 +WORKDIR /app +COPY ./build/libs/Tiki-server-0.0.1-SNAPSHOT.jar /app/Tiki.jar +CMD ["java", "-Duser.timezone=Asia/Seoul" ,"-jar", "-Dspring.profiles.active=dev","Tiki.jar"] \ No newline at end of file diff --git a/Dockerfile-Prod b/Dockerfile-Prod new file mode 100644 index 00000000..72de4f07 --- /dev/null +++ b/Dockerfile-Prod @@ -0,0 +1,4 @@ +FROM amd64/amazoncorretto:17 +WORKDIR /app +COPY ./build/libs/Tiki-server-0.0.1-SNAPSHOT.jar /app/Tiki.jar +CMD ["java", "-Duser.timezone=Asia/Seoul" ,"-jar", "-Dspring.profiles.active=prod","Tiki.jar"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..70696fdf --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +### 🏢 Architecture + +![image](https://github.com/user-attachments/assets/16dd8458-9a0e-4020-86f8-28d63fee4315) + +### 📖 Directory +``` +📁 TIKI_SERVER +├── .github +├── .gradle +├── .idea +├── build +├── gradle +├── src.main +│ ├──java.com.tiki.server +│   ├── auth +│   ├── common +│   ├── document +│   ├── member +│   ├── controller +│   ├── dto +│   ├── entity +│   ├── message +│   ├── repository +│   ├── service +│   ├── memberteammanager +│   ├── team +│   ├── timeblock +``` + +### ✉️ Commit Messge Rules + +**서버** 들의 **Git Commit Message Rules** + +- 반영사항을 바로 확인할 수 있도록 작은 기능 하나라도 구현되면 커밋을 권장합니다. +- 기능 구현이 완벽하지 않을 땐, 각자 브랜치에 커밋을 해주세요. + +### 📌 Commit Convention + +**[태그] 제목의 형태** + +| 태그 이름 | 설명 | +| :-------: | :-----------------------------------------------: | +| FEAT | 새로운 기능을 추가할 경우 | +| FIX | 버그를 고친 경우 | +| CHORE | 짜잘한 수정 | +| DOCS | 문서 수정 | +| INIT | 초기 설정 | +| TEST | 테스트 코드, 리펙토링 테스트 코드 추가 | +| RENAME | 파일 혹은 폴더명을 수정하거나 옮기는 작업인 경우 | +| STYLE | 코드 포맷팅, 세미콜론 누락, 코드 변경이 없는 경우 | +| REFACTOR | 코드 리팩토링 | + +### **커밋 타입** + +- `[태그] 설명` 형식으로 커밋 메시지를 작성합니다. +- 태그는 영어를 쓰고 대문자로 작성합니다. + +예시 > + +``` + [FEAT] 검색 api 추가 +``` + +### **💻 Github mangement** + +**티키** 들의 WorkFlow : **Gitflow Workflow** + +- Develop, Feature, Hotfix 브랜치 + +- 개발(develop): 기능들의 통합 브랜치 + +- 기능 단위 개발(feature): 기능 단위 브랜치 + +- 버그 수정 및 갑작스런 수정(hotfix): 수정 사항 발생 시 브랜치 + +- 개발 브랜치 아래 기능별 브랜치를 만들어 작성합니다. + +### ✍🏻 Code Convention + +[에어비앤비 코드 컨벤션](https://github.com/airbnb/javascript) + +### 📍 Gitflow 규칙 + +- Develop에 직접적인 commit, push는 금지합니다. +- 커밋 메세지는 다른 사람들이 봐도 이해할 수 있게 써주세요. +- 작업 이전에 issue 작성 후 pullrequest 와 issue를 연동해 주세요. +- 풀리퀘스트를 통해 코드 리뷰를 전원이 코드리뷰를 진행합니다. +- 기능 개발 시 개발 브랜치에서 feature/기능 으로 브랜치를 파서 관리합니다. +- feature 자세한 기능 한 가지를 담당하며, 기능 개발이 완료되면 각자의 브랜치로 Pull Request를 보냅니다. +- 각자가 기간 동안 맡은 역할을 전부 수행하면, 각자 브랜치에서 develop브랜치로 Pull Request를 보냅니다. + **develop 브랜치로의 Pull Request는 상대방의 코드리뷰 후에 merge할 수 있습니다.** + +### ❗️ branch naming convention + +- develop +- feature/issue_number-도메인-http Method-api +- fix/issue_number-도메인-http Method-api +- release/version_number +- hotfix/issue_number - Short Description + +예시 > + +``` + feature/#3-user-post-api +``` + +### 📋 Code Review Convention + +- P1: 꼭 반영해주세요 (Request changes) +- P2: 적극적으로 고려해주세요 (Request changes) +- P3: 웬만하면 반영해 주세요 (Comment) +- P4: 반영해도 좋고 넘어가도 좋습니다 (Approve) +- P5: 그냥 사소한 의견입니다 (Approve) + +### 🚀 Test Code Convention + +1. given, when, then을 사용한다. +2. 테스트 메서드명은 다음과 같이 작성한다. -> 메서드명_테스트하고자하는상태_예상되는결과 (ex. giveCotton_CottonCountIs0_NotEnoughCotton) +3. 설마 이런 거까지 생각해야하나싶은 거까지 작성한다. (ex. 솜뭉치를 여러 개 줄 수 있다.) +4. 다수의 값을 다룰 때는 @ParameterizedTest를 활용한다. + +### 👩‍👧‍👧 Our Team + +| **🍀 [남궁찬](https://github.com/Chan531)** |**🍀 [신민규](https://github.com/paragon0107)** | + |:-----------------------------------:|:-----------------------------------:| +| Server Developer | Server Developer | +| 프로젝트 세팅
| 프로젝트 세팅
| diff --git a/build.gradle b/build.gradle index ad2653f2..fb35b3de 100644 --- a/build.gradle +++ b/build.gradle @@ -24,15 +24,49 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + //jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // slack logback + implementation 'com.github.maricn:logback-slack-appender:1.4.0' + + // s3 + implementation("software.amazon.awssdk:bom:2.21.0") + implementation("software.amazon.awssdk:s3:2.21.0") + + // s3 + implementation("software.amazon.awssdk:bom:2.21.0") + implementation("software.amazon.awssdk:s3:2.21.0") + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + + // mail + implementation 'org.springframework.boot:spring-boot-starter-mail' + + //Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'io.lettuce:lettuce-core:6.2.1.RELEASE' + + //thymeleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + //Validation + implementation 'commons-validator:commons-validator:1.7' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..da20a6d5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + redis: + container_name: redis + image: redis:alpine + hostname: redis + ports: + - "6379:6379" + networks: + - tiki-network + +networks: + tiki-network: + external: true \ No newline at end of file diff --git a/src/main/java/com/tiki/server/TikiServerApplication.java b/src/main/java/com/tiki/server/TikiServerApplication.java index 7dae9dea..eddec38e 100644 --- a/src/main/java/com/tiki/server/TikiServerApplication.java +++ b/src/main/java/com/tiki/server/TikiServerApplication.java @@ -2,12 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication public class TikiServerApplication { - public static void main(String[] args) { - SpringApplication.run(TikiServerApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(TikiServerApplication.class, args); + } } diff --git a/src/main/java/com/tiki/server/auth/config/EncoderConfig.java b/src/main/java/com/tiki/server/auth/config/EncoderConfig.java new file mode 100644 index 00000000..5a70b43d --- /dev/null +++ b/src/main/java/com/tiki/server/auth/config/EncoderConfig.java @@ -0,0 +1,15 @@ +package com.tiki.server.auth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class EncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/tiki/server/auth/config/SecurityConfig.java b/src/main/java/com/tiki/server/auth/config/SecurityConfig.java new file mode 100644 index 00000000..c858e05a --- /dev/null +++ b/src/main/java/com/tiki/server/auth/config/SecurityConfig.java @@ -0,0 +1,89 @@ +package com.tiki.server.auth.config; + +import com.tiki.server.auth.exception.handler.CustomAuthenticationEntryPointHandler; +import com.tiki.server.auth.filter.ExceptionHandlerFilter; +import com.tiki.server.auth.filter.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private static final String[] AUTH_WHITE_LIST = { + "/api/v1/auth/sign-in", + "/api/v1/auth/reissue", + "/api/v1/members/password", + "/api/v1/members", + "/api/v1/email/verification/**", + "/actuator/health" + }; + + private final CustomAuthenticationEntryPointHandler customAuthenticationEntryPointHandler; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final ExceptionHandlerFilter exceptionHandlerFilter; + + @Bean + @Profile("local") + public SecurityFilterChain filterChainLocal(HttpSecurity http) throws Exception { + permitSwaggerUri(http); + setHttp(http); + return http.build(); + } + + @Bean + @Profile("dev") + public SecurityFilterChain filterChainDev(HttpSecurity http) throws Exception { + permitSwaggerUri(http); + setHttp(http); + return http.build(); + } + + @Bean + @Profile("prod") + public SecurityFilterChain filterChainProd(HttpSecurity http) throws Exception { + setHttp(http); + return http.build(); + } + + private void setHttp(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagementConfigurer -> + sessionManagementConfigurer + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exceptionHandlingConfigurer -> + exceptionHandlingConfigurer + .authenticationEntryPoint(customAuthenticationEntryPointHandler)) + .authorizeHttpRequests(request -> + request + .requestMatchers(AUTH_WHITE_LIST).permitAll() + .anyRequest() + .authenticated()) + .addFilterBefore( + jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class + ) + .addFilterBefore( + exceptionHandlerFilter, JwtAuthenticationFilter.class + ); + } + + private void permitSwaggerUri(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests + .requestMatchers(new AntPathRequestMatcher("/v3/api-docs/**")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/swagger-ui/**")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/docs/**")).permitAll()); + } +} diff --git a/src/main/java/com/tiki/server/auth/controller/AuthController.java b/src/main/java/com/tiki/server/auth/controller/AuthController.java new file mode 100644 index 00000000..2175d28e --- /dev/null +++ b/src/main/java/com/tiki/server/auth/controller/AuthController.java @@ -0,0 +1,46 @@ +package com.tiki.server.auth.controller; + +import com.tiki.server.auth.controller.docs.AuthControllerDocs; +import com.tiki.server.auth.dto.request.SignInRequest; +import com.tiki.server.auth.dto.response.ReissueGetResponse; +import com.tiki.server.auth.dto.response.SignInGetResponse; +import com.tiki.server.common.dto.SuccessResponse; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.tiki.server.auth.service.AuthService; + +import lombok.RequiredArgsConstructor; + +import static com.tiki.server.auth.message.SuccessMessage.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/auth") +public class AuthController implements AuthControllerDocs { + + private final AuthService authService; + + @Override + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/sign-in") + public SuccessResponse signIn(@RequestBody final SignInRequest request) { + SignInGetResponse response = authService.signIn(request); + return SuccessResponse.success(SUCCESS_SIGN_IN.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.CREATED) + @GetMapping("/reissue") + public SuccessResponse reissue(final HttpServletRequest httpServletRequest) { + ReissueGetResponse response = authService.reissueToken(httpServletRequest); + return SuccessResponse.success(SUCCESS_REISSUE_ACCESS_TOKEN.getMessage(), response); + } +} diff --git a/src/main/java/com/tiki/server/auth/controller/docs/AuthControllerDocs.java b/src/main/java/com/tiki/server/auth/controller/docs/AuthControllerDocs.java new file mode 100644 index 00000000..a92693c1 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/controller/docs/AuthControllerDocs.java @@ -0,0 +1,68 @@ +package com.tiki.server.auth.controller.docs; + +import com.tiki.server.auth.dto.request.SignInRequest; +import com.tiki.server.auth.dto.response.SignInGetResponse; +import com.tiki.server.auth.dto.response.ReissueGetResponse; +import com.tiki.server.common.dto.ErrorResponse; +import com.tiki.server.common.dto.SuccessResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "auth", description = "인증 API") +public interface AuthControllerDocs { + + @Operation( + summary = "로그인", + description = "로그인을 진행한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "400", + description = "일치하지 않은 비밀번호", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "유효하지 않은 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse signIn(@RequestBody final SignInRequest request); + + @Operation( + summary = "엑세스 토큰 재발급", + description = "엑세스 토큰 재발급 메서드입니다.", + responses = { + @ApiResponse(responseCode = "201", description = "성공"), + @ApiResponse( + responseCode = "401", + description = "유효하지 않은 키, 인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = SuccessResponse.class))), + @ApiResponse( + responseCode = "404", + description = "유효하지 않은 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse reissue(final HttpServletRequest request); +} diff --git a/src/main/java/com/tiki/server/auth/dto/request/SignInRequest.java b/src/main/java/com/tiki/server/auth/dto/request/SignInRequest.java new file mode 100644 index 00000000..1a72920e --- /dev/null +++ b/src/main/java/com/tiki/server/auth/dto/request/SignInRequest.java @@ -0,0 +1,9 @@ +package com.tiki.server.auth.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record SignInRequest( + @NotNull String email, + @NotNull String password +) { +} diff --git a/src/main/java/com/tiki/server/auth/dto/response/ReissueGetResponse.java b/src/main/java/com/tiki/server/auth/dto/response/ReissueGetResponse.java new file mode 100644 index 00000000..8971daf2 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/dto/response/ReissueGetResponse.java @@ -0,0 +1,16 @@ +package com.tiki.server.auth.dto.response; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import static lombok.AccessLevel.PRIVATE; + +@Builder(access = PRIVATE) +public record ReissueGetResponse( + @NotNull String accessToken +) { + + public static ReissueGetResponse from(final String accessToken) { + return ReissueGetResponse.builder().accessToken(accessToken).build(); + } +} diff --git a/src/main/java/com/tiki/server/auth/dto/response/SignInGetResponse.java b/src/main/java/com/tiki/server/auth/dto/response/SignInGetResponse.java new file mode 100644 index 00000000..7c47539b --- /dev/null +++ b/src/main/java/com/tiki/server/auth/dto/response/SignInGetResponse.java @@ -0,0 +1,17 @@ +package com.tiki.server.auth.dto.response; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import static lombok.AccessLevel.PRIVATE; + +@Builder(access = PRIVATE) +public record SignInGetResponse( + @NotNull String accessToken, + @NotNull String refreshToken +) { + + public static SignInGetResponse from(final String accessToken, final String refreshToken) { + return SignInGetResponse.builder().accessToken(accessToken).refreshToken(refreshToken).build(); + } +} diff --git a/src/main/java/com/tiki/server/auth/exception/AuthException.java b/src/main/java/com/tiki/server/auth/exception/AuthException.java new file mode 100644 index 00000000..5e608465 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/exception/AuthException.java @@ -0,0 +1,16 @@ +package com.tiki.server.auth.exception; + +import com.tiki.server.auth.message.ErrorCode; + +import lombok.Getter; + +@Getter +public class AuthException extends RuntimeException { + + private final ErrorCode errorCode; + + public AuthException(final ErrorCode errorCode) { + super("[AuthException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/tiki/server/auth/exception/handler/CustomAuthenticationEntryPointHandler.java b/src/main/java/com/tiki/server/auth/exception/handler/CustomAuthenticationEntryPointHandler.java new file mode 100644 index 00000000..87e632ba --- /dev/null +++ b/src/main/java/com/tiki/server/auth/exception/handler/CustomAuthenticationEntryPointHandler.java @@ -0,0 +1,45 @@ +package com.tiki.server.auth.exception.handler; + +import static com.tiki.server.auth.message.ErrorCode.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tiki.server.common.dto.ErrorCodeResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.PrintWriter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPointHandler implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + final HttpServletRequest request, + final HttpServletResponse response, + final AuthenticationException authException + ) throws IOException { + log.info("[AuthenticationEntryPoint] " + authException.getMessage()); + setResponse(response); + } + + private void setResponse(final HttpServletResponse response) throws IOException { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + PrintWriter writer = response.getWriter(); + writer.write(objectMapper.writeValueAsString( + ErrorCodeResponse.of(UNAUTHENTICATED.getCode(), UNAUTHENTICATED.getMessage()))); + } +} diff --git a/src/main/java/com/tiki/server/auth/filter/ExceptionHandlerFilter.java b/src/main/java/com/tiki/server/auth/filter/ExceptionHandlerFilter.java new file mode 100644 index 00000000..4465e9aa --- /dev/null +++ b/src/main/java/com/tiki/server/auth/filter/ExceptionHandlerFilter.java @@ -0,0 +1,54 @@ +package com.tiki.server.auth.filter; + +import static com.tiki.server.auth.message.ErrorCode.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tiki.server.auth.exception.AuthException; +import com.tiki.server.auth.message.ErrorCode; +import com.tiki.server.common.dto.ErrorCodeResponse; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.io.PrintWriter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ExceptionHandlerFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal( + @NonNull final HttpServletRequest request, + @NonNull final HttpServletResponse response, + @NonNull final FilterChain filterChain + ) throws IOException { + try { + filterChain.doFilter(request, response); + } catch (AuthException e) { + log.info("[ExceptionHandlerFilter] - AuthException : " + e); + setResponse(response, e.getErrorCode()); + } catch (Exception e) { + log.info("[ExceptionHandlerFilter] - UncaughtException : " + e); + setResponse(response, UNCAUGHT_EXCEPTION); + } + } + + private void setResponse(final HttpServletResponse response, final ErrorCode errorCode) + throws IOException { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.setStatus(errorCode.getHttpStatus().value()); + PrintWriter writer = response.getWriter(); + writer.write(objectMapper.writeValueAsString(ErrorCodeResponse.of(errorCode.getCode(), errorCode.getMessage()))); + } +} diff --git a/src/main/java/com/tiki/server/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/tiki/server/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..59c797b6 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,48 @@ +package com.tiki.server.auth.filter; + +import com.tiki.server.auth.jwt.JwtProvider; +import com.tiki.server.auth.jwt.JwtValidator; +import com.tiki.server.auth.jwt.UserAuthentication; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +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; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final JwtValidator jwtValidator; + + @Override + protected void doFilterInternal( + @NonNull final HttpServletRequest request, + @NonNull final HttpServletResponse response, + @NonNull final FilterChain filterChain + ) throws IOException, ServletException { + String token = jwtProvider.getTokenFromRequest(request); + if (StringUtils.hasText(token)) { + jwtValidator.validateToken(token); + setAuthenticationContextHolder(jwtProvider.getUserFromJwt((token)), request); + } + filterChain.doFilter(request, response); + } + + private void setAuthenticationContextHolder(final long memberId, final HttpServletRequest request) { + UserAuthentication authentication = new UserAuthentication(memberId, null, null); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/main/java/com/tiki/server/auth/jwt/JwtGenerator.java b/src/main/java/com/tiki/server/auth/jwt/JwtGenerator.java new file mode 100644 index 00000000..6551c75d --- /dev/null +++ b/src/main/java/com/tiki/server/auth/jwt/JwtGenerator.java @@ -0,0 +1,60 @@ +package com.tiki.server.auth.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; + +import static io.jsonwebtoken.Header.JWT_TYPE; +import static io.jsonwebtoken.Header.TYPE; +import static io.jsonwebtoken.security.Keys.hmacShaKeyFor; +import static java.util.Base64.getEncoder; + +import java.util.Date; + +@RequiredArgsConstructor +@Component +public class JwtGenerator { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token-expire-time}") + private long ACCESS_TOKEN_EXPIRE_TIME; + + @Value("${jwt.refresh-token-expire-time}") + public long REFRESH_TOKEN_EXPIRE_TIME; + + public String generateToken(final Authentication authentication, final long expiration) { + return Jwts.builder() + .setHeaderParam(TYPE, JWT_TYPE) + .setClaims(generateClaims(authentication)) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey()) + .compact(); + } + + public String generateAccessToken(final Authentication authentication) { + return generateToken(authentication, ACCESS_TOKEN_EXPIRE_TIME); + } + + public String generateRefreshToken(final Authentication authentication) { + return generateToken(authentication, REFRESH_TOKEN_EXPIRE_TIME); + } + + private Claims generateClaims(final Authentication authentication) { + Claims claims = Jwts.claims(); + claims.put("memberId", authentication.getPrincipal()); + return claims; + } + + private SecretKey getSigningKey() { + String encodedKey = getEncoder().encodeToString(secretKey.getBytes()); + return hmacShaKeyFor(encodedKey.getBytes()); + } +} diff --git a/src/main/java/com/tiki/server/auth/jwt/JwtProvider.java b/src/main/java/com/tiki/server/auth/jwt/JwtProvider.java new file mode 100644 index 00000000..146bbc83 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/jwt/JwtProvider.java @@ -0,0 +1,49 @@ +package com.tiki.server.auth.jwt; + +import com.tiki.server.common.constants.Constants; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.crypto.SecretKey; + +import static io.jsonwebtoken.security.Keys.hmacShaKeyFor; +import static java.util.Base64.getEncoder; + +@RequiredArgsConstructor +@Component +public class JwtProvider { + + @Value("${jwt.secret}") + private String secretKey; + + public String getTokenFromRequest(final HttpServletRequest request) { + String accessToken = request.getHeader(Constants.AUTHORIZATION); + if (!StringUtils.hasText(accessToken) || !accessToken.startsWith(Constants.BEARER)) { + return null; + } + return accessToken.substring(Constants.BEARER.length()); + } + + public long getUserFromJwt(final String token) { + Claims claims = getBodyFromJwt(token); + return Long.parseLong(claims.get("memberId").toString()); + } + + public Claims getBodyFromJwt(final String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private SecretKey getSigningKey() { + String encodedKey = getEncoder().encodeToString(secretKey.getBytes()); + return hmacShaKeyFor(encodedKey.getBytes()); + } +} diff --git a/src/main/java/com/tiki/server/auth/jwt/JwtValidator.java b/src/main/java/com/tiki/server/auth/jwt/JwtValidator.java new file mode 100644 index 00000000..0f0ceeff --- /dev/null +++ b/src/main/java/com/tiki/server/auth/jwt/JwtValidator.java @@ -0,0 +1,33 @@ +package com.tiki.server.auth.jwt; + +import com.tiki.server.auth.exception.AuthException; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import static com.tiki.server.auth.message.ErrorCode.*; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JwtValidator { + + private final JwtProvider jwtProvider; + + public void validateToken(final String token) { + try { + jwtProvider.getBodyFromJwt(token); + } catch (ExpiredJwtException exception) { + log.info(exception.getMessage()); + throw new AuthException(EXPIRED_JWT_TOKEN); + } catch (JwtException exception) { + log.info(exception.getMessage()); + throw new AuthException(INVALID_JWT_TOKEN); + } catch (Exception exception) { + log.info("예상치 못한 에러: " + exception); + throw new AuthException(UNCAUGHT_EXCEPTION); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/auth/jwt/UserAuthentication.java b/src/main/java/com/tiki/server/auth/jwt/UserAuthentication.java new file mode 100644 index 00000000..5161d124 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/jwt/UserAuthentication.java @@ -0,0 +1,16 @@ +package com.tiki.server.auth.jwt; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class UserAuthentication extends UsernamePasswordAuthenticationToken { + public UserAuthentication( + final Object principal, + final Object credentials, + final Collection authorities + ) { + super(principal, credentials, authorities); + } +} diff --git a/src/main/java/com/tiki/server/auth/message/ErrorCode.java b/src/main/java/com/tiki/server/auth/message/ErrorCode.java new file mode 100644 index 00000000..02a99f11 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/message/ErrorCode.java @@ -0,0 +1,33 @@ +package com.tiki.server.auth.message; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import static org.springframework.http.HttpStatus.*; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + /* 400 BAD REQUEST : 잘못된 요청 */ + UNCAUGHT_EXCEPTION(BAD_REQUEST, 40001, "예상치 못한 오류가 발생했습니다."), + + /* 401 UNAUTHORIZED : 인증 없음 */ + UNAUTHENTICATED(UNAUTHORIZED, 40101, "인증 과정 중 오류가 발생했습니다"), + UNMATCHED_TOKEN(UNAUTHORIZED, 40102, "토큰이 일치하지 않습니다."), + INVALID_JWT_TOKEN(UNAUTHORIZED, 40103, "잘못된 토큰 형식입니다."), + EXPIRED_JWT_TOKEN(UNAUTHORIZED, 40104, "만료된 토큰입니다."), + EMPTY_JWT(UNAUTHORIZED, 40105, "빈 토큰입니다."), + + /* 403 FORBIDDEN : 권한 없음 */ + UNAUTHORIZED_USER(FORBIDDEN, 40301, "권한이 없는 사용자입니다."), + + /* 500 INTERNAL_SERVER_ERROR : 서버 내부 오류 발생 */ + UNCAUGHT_SERVER_EXCEPTION(INTERNAL_SERVER_ERROR, 500, "서버 내부에서 오류가 발생했습니다."); + + private final HttpStatus httpStatus; + private final int code; + private final String message; +} diff --git a/src/main/java/com/tiki/server/auth/message/SuccessMessage.java b/src/main/java/com/tiki/server/auth/message/SuccessMessage.java new file mode 100644 index 00000000..99e40192 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/message/SuccessMessage.java @@ -0,0 +1,15 @@ +package com.tiki.server.auth.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + SUCCESS_SIGN_UP("회원가입 성공"), + SUCCESS_SIGN_IN("로그인 성공"), + SUCCESS_REISSUE_ACCESS_TOKEN("엑세스 토큰 재발급 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/auth/service/AuthService.java b/src/main/java/com/tiki/server/auth/service/AuthService.java new file mode 100644 index 00000000..8356da3d --- /dev/null +++ b/src/main/java/com/tiki/server/auth/service/AuthService.java @@ -0,0 +1,94 @@ +package com.tiki.server.auth.service; + +import com.tiki.server.auth.dto.request.SignInRequest; +import com.tiki.server.auth.dto.response.SignInGetResponse; +import com.tiki.server.auth.dto.response.ReissueGetResponse; +import com.tiki.server.auth.exception.AuthException; +import com.tiki.server.auth.jwt.JwtProvider; +import com.tiki.server.auth.token.adapter.TokenFinder; +import com.tiki.server.auth.token.adapter.TokenSaver; +import com.tiki.server.auth.token.entity.Token; +import com.tiki.server.email.Email; +import com.tiki.server.member.adapter.MemberFinder; +import com.tiki.server.member.entity.Member; +import com.tiki.server.member.exception.MemberException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.tiki.server.auth.jwt.JwtGenerator; +import com.tiki.server.auth.jwt.UserAuthentication; + +import lombok.RequiredArgsConstructor; +import org.thymeleaf.util.StringUtils; + +import static com.tiki.server.auth.message.ErrorCode.EMPTY_JWT; +import static com.tiki.server.auth.message.ErrorCode.UNMATCHED_TOKEN; +import static com.tiki.server.member.message.ErrorCode.INVALID_MEMBER; +import static com.tiki.server.member.message.ErrorCode.UNMATCHED_PASSWORD; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final JwtGenerator jwtGenerator; + private final JwtProvider jwtProvider; + private final MemberFinder memberFinder; + private final TokenSaver tokenSaver; + private final TokenFinder tokenFinder; + private final PasswordEncoder passwordEncoder; + + public SignInGetResponse signIn(final SignInRequest request) { + Member member = checkMemberEmpty(request); + checkPasswordMatching(member, request.password()); + Authentication authentication = createAuthentication(member.getId()); + String accessToken = jwtGenerator.generateAccessToken(authentication); + String refreshToken = jwtGenerator.generateRefreshToken(authentication); + tokenSaver.save(Token.of(member.getId(), refreshToken)); + return SignInGetResponse.from(accessToken, refreshToken); + } + + public ReissueGetResponse reissueToken(final HttpServletRequest request) { + String refreshToken = jwtProvider.getTokenFromRequest(request); + checkTokenEmpty(refreshToken); + long memberId = jwtProvider.getUserFromJwt(refreshToken); + Token token = tokenFinder.findById(memberId); + checkRefreshToken(refreshToken, token); + Authentication authentication = createAuthentication(memberId); + String accessToken = jwtGenerator.generateAccessToken(authentication); + return ReissueGetResponse.from(accessToken); + } + + private Member checkMemberEmpty(final SignInRequest request) { + return memberFinder.findByEmail(Email.from(request.email())).orElseThrow(() -> new MemberException(INVALID_MEMBER)); + } + + private void checkTokenEmpty(final String token) { + if (StringUtils.isEmpty(token)) { + throw new AuthException(EMPTY_JWT); + } + } + + private void checkRefreshToken(final String getRefreshToken, final Token token) { + log.info("받은 토큰 : " + getRefreshToken); + log.info("저장 토큰 : " + token.refreshToken()); + if (!token.refreshToken().equals(getRefreshToken)) { + throw new AuthException(UNMATCHED_TOKEN); + } + } + + private void checkPasswordMatching(final Member member, final String password) { + if (!passwordEncoder.matches(password, member.getPassword())) { + throw new MemberException(UNMATCHED_PASSWORD); + } + } + + private Authentication createAuthentication(final long memberId) { + return new UserAuthentication(memberId, null, null); + } +} diff --git a/src/main/java/com/tiki/server/auth/token/adapter/TokenFinder.java b/src/main/java/com/tiki/server/auth/token/adapter/TokenFinder.java new file mode 100644 index 00000000..91ffca25 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/token/adapter/TokenFinder.java @@ -0,0 +1,22 @@ +package com.tiki.server.auth.token.adapter; + +import com.tiki.server.auth.exception.AuthException; +import com.tiki.server.auth.token.entity.Token; +import com.tiki.server.auth.token.repository.TokenRepository; +import com.tiki.server.common.support.RepositoryAdapter; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +import static com.tiki.server.auth.message.ErrorCode.UNAUTHORIZED_USER; + +@RepositoryAdapter +@RequiredArgsConstructor +public class TokenFinder { + + private final TokenRepository tokenRepository; + + public Token findById(final long id) { + return tokenRepository.findById(id).orElseThrow(() -> new AuthException(UNAUTHORIZED_USER)); + } +} diff --git a/src/main/java/com/tiki/server/auth/token/adapter/TokenSaver.java b/src/main/java/com/tiki/server/auth/token/adapter/TokenSaver.java new file mode 100644 index 00000000..c98273d6 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/token/adapter/TokenSaver.java @@ -0,0 +1,17 @@ +package com.tiki.server.auth.token.adapter; + +import com.tiki.server.auth.token.entity.Token; +import com.tiki.server.auth.token.repository.TokenRepository; +import com.tiki.server.common.support.RepositoryAdapter; +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class TokenSaver { + + private final TokenRepository tokenRepository; + + public void save(final Token token) { + tokenRepository.save(token); + } +} diff --git a/src/main/java/com/tiki/server/auth/token/constants/TokenConstant.java b/src/main/java/com/tiki/server/auth/token/constants/TokenConstant.java new file mode 100644 index 00000000..f3d6f2b5 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/token/constants/TokenConstant.java @@ -0,0 +1,9 @@ +package com.tiki.server.auth.token.constants; + +import org.springframework.beans.factory.annotation.Value; + +public class TokenConstant { + + @Value("${jwt.refresh-token-expire-time}") + public static int REFRESH_TOKEN_EXPIRED_TIME; +} diff --git a/src/main/java/com/tiki/server/auth/token/entity/Token.java b/src/main/java/com/tiki/server/auth/token/entity/Token.java new file mode 100644 index 00000000..0d00bd15 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/token/entity/Token.java @@ -0,0 +1,22 @@ +package com.tiki.server.auth.token.entity; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import org.springframework.data.redis.core.RedisHash; + +import static lombok.AccessLevel.PRIVATE; + +@Builder(access = PRIVATE) +@RedisHash(value = "refreshToken", timeToLive = 1209600000L) +public record Token( + @NotNull long id, + @NotNull String refreshToken +) { + public static Token of(final long id, final String refreshToken) { + return Token.builder() + .id(id) + .refreshToken(refreshToken) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/auth/token/repository/TokenRepository.java b/src/main/java/com/tiki/server/auth/token/repository/TokenRepository.java new file mode 100644 index 00000000..048413ff --- /dev/null +++ b/src/main/java/com/tiki/server/auth/token/repository/TokenRepository.java @@ -0,0 +1,13 @@ +package com.tiki.server.auth.token.repository; + +import com.tiki.server.auth.token.entity.Token; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface TokenRepository extends CrudRepository { + + Optional findById(final long id); +} diff --git a/src/main/java/com/tiki/server/auth/utils/CookieUtil.java b/src/main/java/com/tiki/server/auth/utils/CookieUtil.java new file mode 100644 index 00000000..d0637682 --- /dev/null +++ b/src/main/java/com/tiki/server/auth/utils/CookieUtil.java @@ -0,0 +1,22 @@ +package com.tiki.server.auth.utils; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseCookie; + +import static com.tiki.server.auth.token.constants.TokenConstant.REFRESH_TOKEN_EXPIRED_TIME; + +public class CookieUtil { + + public static void addRefreshToken(HttpServletResponse response, String value) { + + ResponseCookie cookie = ResponseCookie.from("refreshToken", value) + .path("/") + .secure(true) + .sameSite("None") + .httpOnly(true) + .maxAge(REFRESH_TOKEN_EXPIRED_TIME) + .build(); + response.setHeader("Set-Cookie", cookie.toString()); + } + +} diff --git a/src/main/java/com/tiki/server/common/config/CorsConfig.java b/src/main/java/com/tiki/server/common/config/CorsConfig.java new file mode 100644 index 00000000..4a544436 --- /dev/null +++ b/src/main/java/com/tiki/server/common/config/CorsConfig.java @@ -0,0 +1,38 @@ +package com.tiki.server.common.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +public class CorsConfig { + + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = setUrlBasedCorsConfigurationSource(); + return new CorsFilter(source); + } + + private UrlBasedCorsConfigurationSource setUrlBasedCorsConfigurationSource() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = setCorsConfiguration(); + source.registerCorsConfiguration("/**", config); + return source; + } + + private CorsConfiguration setCorsConfiguration() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOrigin(("https://ti-kii.com")); + config.addAllowedOrigin("http://localhost:5173"); + config.addAllowedOrigin("https://www.tiki-sopt.p-e.kr"); + config.addAllowedOrigin("https://tiki-client.vercel.app"); + config.addAllowedHeader("*"); + config.setAllowedMethods(List.of("HEAD", "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + return config; + } +} diff --git a/src/main/java/com/tiki/server/common/config/JpaAuditingConfig.java b/src/main/java/com/tiki/server/common/config/JpaAuditingConfig.java new file mode 100644 index 00000000..b3df4190 --- /dev/null +++ b/src/main/java/com/tiki/server/common/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.tiki.server.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/com/tiki/server/common/config/RedisConfig.java b/src/main/java/com/tiki/server/common/config/RedisConfig.java new file mode 100644 index 00000000..ccde4c63 --- /dev/null +++ b/src/main/java/com/tiki/server/common/config/RedisConfig.java @@ -0,0 +1,37 @@ +package com.tiki.server.common.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.util.Map; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + @Value("${REDIS.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate> redisEmailAuthenticationTemplate() { + RedisTemplate> redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/common/config/SwaggerConfig.java b/src/main/java/com/tiki/server/common/config/SwaggerConfig.java new file mode 100644 index 00000000..09d24168 --- /dev/null +++ b/src/main/java/com/tiki/server/common/config/SwaggerConfig.java @@ -0,0 +1,49 @@ +package com.tiki.server.common.config; + +import static io.swagger.v3.oas.models.security.SecurityScheme.Type.HTTP; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openApi() { + SecurityScheme securityScheme = new SecurityScheme(); + securityScheme.setType(HTTP); + securityScheme.setScheme("bearer"); + securityScheme.setBearerFormat("JWT"); + + Components components = new Components(); + components.addSecuritySchemes("BearerAuthentication", securityScheme); + + SecurityRequirement securityRequirement = new SecurityRequirement(); + securityRequirement.addList("BearerAuthentication"); + + Info info = new Info(); + info.setTitle("TIKI API Document"); + info.setDescription("티키 API 명세서"); + info.setVersion("1.0.0"); + + Server localServer = new Server(); + Server devServer = new Server(); + devServer.setUrl("https://www.tiki-sopt.p-e.kr"); + localServer.setUrl("http://localhost:8080"); + + return new OpenAPI() + .components(components) + .security(List.of(securityRequirement)) + .servers(List.of(localServer, devServer)) + .info(info); + } +} diff --git a/src/main/java/com/tiki/server/common/config/TimezoneConfig.java b/src/main/java/com/tiki/server/common/config/TimezoneConfig.java new file mode 100644 index 00000000..d0fd47f2 --- /dev/null +++ b/src/main/java/com/tiki/server/common/config/TimezoneConfig.java @@ -0,0 +1,16 @@ +package com.tiki.server.common.config; + +import java.util.TimeZone; + +import org.springframework.context.annotation.Configuration; + +import jakarta.annotation.PostConstruct; + +@Configuration +public class TimezoneConfig { + + @PostConstruct + public void init() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } +} diff --git a/src/main/java/com/tiki/server/common/constants/Constants.java b/src/main/java/com/tiki/server/common/constants/Constants.java new file mode 100644 index 00000000..252b9518 --- /dev/null +++ b/src/main/java/com/tiki/server/common/constants/Constants.java @@ -0,0 +1,8 @@ +package com.tiki.server.common.constants; + +public class Constants { + public static final String AUTHORIZATION = "Authorization"; + public static final String BEARER = "Bearer "; + public static final String WRONG_INPUT = "잘못된 JSON 형식입니다."; + public static final int INIT_NUM = 0; +} diff --git a/src/main/java/com/tiki/server/common/dto/BaseResponse.java b/src/main/java/com/tiki/server/common/dto/BaseResponse.java new file mode 100644 index 00000000..d75969a3 --- /dev/null +++ b/src/main/java/com/tiki/server/common/dto/BaseResponse.java @@ -0,0 +1,8 @@ +package com.tiki.server.common.dto; + +import jakarta.validation.constraints.NotNull; + +public interface BaseResponse { + @NotNull boolean success(); + @NotNull String message(); +} diff --git a/src/main/java/com/tiki/server/common/dto/ErrorCodeResponse.java b/src/main/java/com/tiki/server/common/dto/ErrorCodeResponse.java new file mode 100644 index 00000000..a9162ebe --- /dev/null +++ b/src/main/java/com/tiki/server/common/dto/ErrorCodeResponse.java @@ -0,0 +1,22 @@ +package com.tiki.server.common.dto; + +import static lombok.AccessLevel.PRIVATE; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record ErrorCodeResponse( + @NotNull boolean success, + @NotNull int code, + @NotNull String message +) implements BaseResponse { + + public static ErrorCodeResponse of(final int code, final String message) { + return ErrorCodeResponse.builder() + .success(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/common/dto/ErrorResponse.java b/src/main/java/com/tiki/server/common/dto/ErrorResponse.java new file mode 100644 index 00000000..147d8706 --- /dev/null +++ b/src/main/java/com/tiki/server/common/dto/ErrorResponse.java @@ -0,0 +1,20 @@ +package com.tiki.server.common.dto; + +import static lombok.AccessLevel.PRIVATE; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record ErrorResponse( + @NotNull boolean success, + @NotNull String message +) implements BaseResponse { + + public static ErrorResponse of(final String message) { + return ErrorResponse.builder() + .success(false) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/common/dto/SuccessResponse.java b/src/main/java/com/tiki/server/common/dto/SuccessResponse.java new file mode 100644 index 00000000..93a94d33 --- /dev/null +++ b/src/main/java/com/tiki/server/common/dto/SuccessResponse.java @@ -0,0 +1,25 @@ +package com.tiki.server.common.dto; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; +import static lombok.AccessLevel.PRIVATE; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record SuccessResponse( + @NotNull boolean success, + @NotNull String message, + @JsonInclude(value = NON_NULL) T data +) implements BaseResponse { + + public static SuccessResponse success(final String message, final T data) { + return SuccessResponse.builder().success(true).message(message).data(data).build(); + } + + public static SuccessResponse success(final String message) { + return SuccessResponse.builder().success(true).message(message).build(); + } +} diff --git a/src/main/java/com/tiki/server/common/entity/BaseTime.java b/src/main/java/com/tiki/server/common/entity/BaseTime.java new file mode 100644 index 00000000..080e7bb0 --- /dev/null +++ b/src/main/java/com/tiki/server/common/entity/BaseTime.java @@ -0,0 +1,23 @@ +package com.tiki.server.common.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseTime { + + @CreatedDate + protected LocalDateTime createdAt; + + @LastModifiedDate + protected LocalDateTime updatedAt; +} diff --git a/src/main/java/com/tiki/server/common/entity/Position.java b/src/main/java/com/tiki/server/common/entity/Position.java new file mode 100644 index 00000000..7db100b8 --- /dev/null +++ b/src/main/java/com/tiki/server/common/entity/Position.java @@ -0,0 +1,26 @@ +package com.tiki.server.common.entity; + +import static com.tiki.server.timeblock.message.ErrorCode.INVALID_TYPE; + +import com.tiki.server.timeblock.exception.TimeBlockException; + +import lombok.Getter; + +@Getter +public enum Position { + ADMIN(1), EXECUTIVE(2), MEMBER(3); + + private final int authorization; + + Position(final int authorization) { + this.authorization = authorization; + } + + public static Position getAccessiblePosition(final String type) { + return switch (type) { + case "executive" -> EXECUTIVE; + case "member" -> MEMBER; + default -> throw new TimeBlockException(INVALID_TYPE); + }; + } +} diff --git a/src/main/java/com/tiki/server/common/entity/SortOrder.java b/src/main/java/com/tiki/server/common/entity/SortOrder.java new file mode 100644 index 00000000..00b1f0f4 --- /dev/null +++ b/src/main/java/com/tiki/server/common/entity/SortOrder.java @@ -0,0 +1,5 @@ +package com.tiki.server.common.entity; + +public enum SortOrder { + ASC, DESC +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/common/entity/University.java b/src/main/java/com/tiki/server/common/entity/University.java new file mode 100644 index 00000000..d066f345 --- /dev/null +++ b/src/main/java/com/tiki/server/common/entity/University.java @@ -0,0 +1,8 @@ +package com.tiki.server.common.entity; + +import lombok.Getter; + +@Getter +public enum University { + 건국대학교, +} diff --git a/src/main/java/com/tiki/server/common/handler/ErrorHandler.java b/src/main/java/com/tiki/server/common/handler/ErrorHandler.java new file mode 100644 index 00000000..e0d0b82c --- /dev/null +++ b/src/main/java/com/tiki/server/common/handler/ErrorHandler.java @@ -0,0 +1,133 @@ +package com.tiki.server.common.handler; + +import com.tiki.server.auth.exception.AuthException; +import com.tiki.server.common.dto.ErrorCodeResponse; +import com.tiki.server.email.emailsender.exception.EmailSenderException; +import com.tiki.server.email.teaminvitation.exception.TeamInvitationException; +import com.tiki.server.email.verification.exception.EmailVerificationException; +import com.tiki.server.folder.exception.FolderException; +import com.tiki.server.note.exception.NoteException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.tiki.server.common.dto.BaseResponse; +import com.tiki.server.common.dto.ErrorResponse; +import com.tiki.server.document.exception.DocumentException; +import com.tiki.server.external.exception.ExternalException; +import com.tiki.server.member.exception.MemberException; +import com.tiki.server.memberteammanager.exception.MemberTeamManagerException; +import com.tiki.server.team.exception.TeamException; +import com.tiki.server.timeblock.exception.TimeBlockException; + +import lombok.extern.slf4j.Slf4j; +import lombok.val; + +import static com.tiki.server.auth.message.ErrorCode.UNCAUGHT_SERVER_EXCEPTION; +import static com.tiki.server.common.constants.Constants.WRONG_INPUT; + +@Slf4j +@RestControllerAdvice +public class ErrorHandler { + + @ExceptionHandler(MemberException.class) + public ResponseEntity memberException(MemberException exception) { + log.error(exception.getMessage()); + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage())); + } + + @ExceptionHandler(TeamException.class) + public ResponseEntity teamException(TeamException exception) { + log.error(exception.getMessage()); + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage())); + } + + @ExceptionHandler(MemberTeamManagerException.class) + public ResponseEntity memberTeamManagerException(MemberTeamManagerException exception) { + log.error(exception.getMessage()); + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage())); + } + + @ExceptionHandler(TimeBlockException.class) + public ResponseEntity timeBlockException(TimeBlockException exception) { + log.error(exception.getMessage()); + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage())); + } + + @ExceptionHandler(DocumentException.class) + public ResponseEntity documentException(DocumentException exception) { + log.error(exception.getMessage()); + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage())); + } + + @ExceptionHandler(NoteException.class) + public ResponseEntity noteException(NoteException exception) { + log.error(exception.getMessage()); + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage())); + } + + @ExceptionHandler(ExternalException.class) + public ResponseEntity externalException(ExternalException exception) { + log.error(exception.getMessage()); + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage())); + } + + @ExceptionHandler(EmailVerificationException.class) + public ResponseEntity mailVerificationException(EmailVerificationException exception) { + log.error(exception.getMessage()); + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage())); + } + + @ExceptionHandler(EmailSenderException.class) + public ResponseEntity mailSenderException(EmailSenderException exception) { + log.error(exception.getMessage()); + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage())); + } + + @ExceptionHandler(TeamInvitationException.class) + public ResponseEntity teamInvitationException(TeamInvitationException exception) { + log.error(exception.getMessage()); + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage())); + } + + @ExceptionHandler(FolderException.class) + public ResponseEntity folderException(FolderException exception) { + log.error(exception.getMessage()); + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage())); + } + + @ExceptionHandler(AuthException.class) + public ResponseEntity authException(AuthException exception) { + log.error(exception.getMessage()); + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body( + ErrorCodeResponse.of(errorCode.getCode(), errorCode.getMessage())); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity httpMessageNotReadableException(HttpMessageNotReadableException exception) { + log.error(exception.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( + ErrorResponse.of(WRONG_INPUT)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity exception(Exception exception) { + log.error(exception.getMessage()); + val errorCode = UNCAUGHT_SERVER_EXCEPTION; + return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage())); + } +} diff --git a/src/main/java/com/tiki/server/common/support/RepositoryAdapter.java b/src/main/java/com/tiki/server/common/support/RepositoryAdapter.java new file mode 100644 index 00000000..423dc88c --- /dev/null +++ b/src/main/java/com/tiki/server/common/support/RepositoryAdapter.java @@ -0,0 +1,14 @@ +package com.tiki.server.common.support; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.stereotype.Component; + +@Component +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface RepositoryAdapter { +} diff --git a/src/main/java/com/tiki/server/common/util/ContentDecoder.java b/src/main/java/com/tiki/server/common/util/ContentDecoder.java new file mode 100644 index 00000000..721a4962 --- /dev/null +++ b/src/main/java/com/tiki/server/common/util/ContentDecoder.java @@ -0,0 +1,21 @@ +package com.tiki.server.common.util; + +import java.util.Base64; +import java.util.List; + +public class ContentDecoder { + + public static List decodeNoteTemplate(final String encodedData) { + String[] parts = encodedData.split("\\|"); + String decodedActivity = parts[0].isBlank() ? "" : new String(Base64.getDecoder().decode(parts[0])); + String decodedPrepare = parts[1].isBlank() ? "" : new String(Base64.getDecoder().decode(parts[1])); + String decodedDisappointing = parts[2].isBlank() ? "" : new String(Base64.getDecoder().decode(parts[2])); + String decodedComplement = parts[3].isBlank() ? "" : new String(Base64.getDecoder().decode(parts[3])); + return List.of(decodedActivity, decodedPrepare, decodedDisappointing, decodedComplement); + } + + public static String decodeNoteFree(final String encodeDate) { + byte[] decodedBytes = Base64.getDecoder().decode(encodeDate); + return new String(decodedBytes); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/common/util/ContentEncoder.java b/src/main/java/com/tiki/server/common/util/ContentEncoder.java new file mode 100644 index 00000000..f24d7809 --- /dev/null +++ b/src/main/java/com/tiki/server/common/util/ContentEncoder.java @@ -0,0 +1,21 @@ +package com.tiki.server.common.util; + +import java.util.Base64; + +public class ContentEncoder { + + public static String encodeNoteTemplate(final String activity, final String prepare, final String disappointing, + final String complement) { + String encodedActivity = activity.isBlank() ? " " : Base64.getEncoder().encodeToString(activity.getBytes()); + String encodedPrepare = prepare.isBlank() ? " " : Base64.getEncoder().encodeToString(prepare.getBytes()); + String encodedDisappointing = + disappointing.isBlank() ? " " : Base64.getEncoder().encodeToString(disappointing.getBytes()); + String encodedComplement = + complement.isBlank() ? " " : Base64.getEncoder().encodeToString(complement.getBytes()); + return String.join("|", encodedActivity, encodedPrepare, encodedDisappointing, encodedComplement); + } + + public static String encodeNoteFree(final String contents) { + return Base64.getEncoder().encodeToString(contents.getBytes()); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/document/adapter/DeletedDocumentAdapter.java b/src/main/java/com/tiki/server/document/adapter/DeletedDocumentAdapter.java new file mode 100644 index 00000000..6b17e8a6 --- /dev/null +++ b/src/main/java/com/tiki/server/document/adapter/DeletedDocumentAdapter.java @@ -0,0 +1,47 @@ +package com.tiki.server.document.adapter; + +import static com.tiki.server.document.message.ErrorCode.INVALID_DOCUMENT; + +import java.util.List; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.document.entity.DeletedDocument; +import com.tiki.server.document.entity.Document; +import com.tiki.server.document.exception.DocumentException; +import com.tiki.server.document.repository.DeletedDocumentRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class DeletedDocumentAdapter { + + private final DeletedDocumentRepository deletedDocumentRepository; + + public List get(final long teamId) { + return deletedDocumentRepository.findAllByTeamId(teamId); + } + + public void save(final List documents) { + documents.forEach(document -> deletedDocumentRepository.save(create(document))); + } + + public List get(final List deletedDocumentIds, final long teamId) { + return deletedDocumentIds.stream() + .map(id -> find(id, teamId)) + .toList(); + } + + public void deleteAll(final List deletedDocuments) { + deletedDocumentRepository.deleteAll(deletedDocuments); + } + + private DeletedDocument create(final Document document) { + return DeletedDocument.of(document); + } + + private DeletedDocument find(final long id, final long teamId) { + return deletedDocumentRepository.findByIdAndTeamId(id, teamId) + .orElseThrow(() -> new DocumentException(INVALID_DOCUMENT)); + } +} diff --git a/src/main/java/com/tiki/server/document/adapter/DocumentDeleter.java b/src/main/java/com/tiki/server/document/adapter/DocumentDeleter.java new file mode 100644 index 00000000..9e7f81bd --- /dev/null +++ b/src/main/java/com/tiki/server/document/adapter/DocumentDeleter.java @@ -0,0 +1,20 @@ +package com.tiki.server.document.adapter; + +import java.util.List; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.document.entity.Document; +import com.tiki.server.document.repository.DocumentRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class DocumentDeleter { + + private final DocumentRepository documentRepository; + + public void deleteAll(final List documents) { + documentRepository.deleteAll(documents); + } +} diff --git a/src/main/java/com/tiki/server/document/adapter/DocumentFinder.java b/src/main/java/com/tiki/server/document/adapter/DocumentFinder.java new file mode 100644 index 00000000..b1813315 --- /dev/null +++ b/src/main/java/com/tiki/server/document/adapter/DocumentFinder.java @@ -0,0 +1,51 @@ +package com.tiki.server.document.adapter; + +import static com.tiki.server.document.message.ErrorCode.INVALID_DOCUMENT; + +import java.util.List; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.document.entity.Document; +import com.tiki.server.document.exception.DocumentException; +import com.tiki.server.document.repository.DocumentRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class DocumentFinder { + + private final DocumentRepository documentRepository; + + public List findAllByIdAndTeamId(final List documentIds, final long teamId) { + return documentIds.stream() + .map(id -> findByIdAndTeamId(id, teamId)) + .toList(); + } + + public Document findById(final long documentId) { + return documentRepository.findById(documentId) + .orElseThrow(() -> new DocumentException(INVALID_DOCUMENT)); + } + + public List findAllByTeamId(long teamId) { + return documentRepository.findAllByTeamId(teamId); + } + + public boolean existsById(final Long timeBlockId) { + return documentRepository.existsById(timeBlockId); + } + + public List findByTeamIdAndFolderId(final long teamId, final Long folderId) { + return documentRepository.findAllByTeamIdAndFolderIdOrderByCreatedAtDesc(teamId, folderId); + } + + public List findAllByFolderId(final long folderId) { + return documentRepository.findAllByFolderId(folderId); + } + + private Document findByIdAndTeamId(final long documentId, final long teamId) { + return documentRepository.findByIdAndTeamId(documentId, teamId) + .orElseThrow(() -> new DocumentException(INVALID_DOCUMENT)); + } +} diff --git a/src/main/java/com/tiki/server/document/adapter/DocumentSaver.java b/src/main/java/com/tiki/server/document/adapter/DocumentSaver.java new file mode 100644 index 00000000..d86f99b7 --- /dev/null +++ b/src/main/java/com/tiki/server/document/adapter/DocumentSaver.java @@ -0,0 +1,29 @@ +package com.tiki.server.document.adapter; + +import java.util.List; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.document.entity.DeletedDocument; +import com.tiki.server.document.entity.Document; +import com.tiki.server.document.repository.DocumentRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class DocumentSaver { + + private final DocumentRepository documentRepository; + + public Document save(final Document document) { + return documentRepository.save(document); + } + + public void restore(final List deletedDocuments) { + deletedDocuments.forEach(document -> documentRepository.save(create(document))); + } + + private Document create(final DeletedDocument deletedDocument) { + return Document.restore(deletedDocument); + } +} diff --git a/src/main/java/com/tiki/server/document/controller/DocumentController.java b/src/main/java/com/tiki/server/document/controller/DocumentController.java new file mode 100644 index 00000000..72ffc841 --- /dev/null +++ b/src/main/java/com/tiki/server/document/controller/DocumentController.java @@ -0,0 +1,125 @@ +package com.tiki.server.document.controller; + +import static com.tiki.server.document.message.SuccessMessage.SUCCESS_CREATE_DOCUMENTS; +import static com.tiki.server.document.message.SuccessMessage.SUCCESS_GET_DOCUMENTS; +import static com.tiki.server.document.message.SuccessMessage.SUCCESS_GET_TRASH; + +import java.security.Principal; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.document.controller.docs.DocumentControllerDocs; +import com.tiki.server.document.dto.request.DocumentsCreateRequest; +import com.tiki.server.document.dto.response.DeletedDocumentsGetResponse; +import com.tiki.server.document.dto.response.DocumentsCreateResponse; +import com.tiki.server.document.dto.response.DocumentsGetResponse; +import com.tiki.server.document.service.DocumentService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1") +public class DocumentController implements DocumentControllerDocs { + + private final DocumentService documentService; + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/documents/team/{teamId}/timeline") + public SuccessResponse getAllDocuments( + final Principal principal, + @PathVariable final long teamId, + @RequestParam final String type + ) { + long memberId = Long.parseLong(principal.getName()); + DocumentsGetResponse response = documentService.getAllDocuments(memberId, teamId, type); + return SuccessResponse.success(SUCCESS_GET_DOCUMENTS.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/teams/{teamId}/documents") + public SuccessResponse createDocuments( + final Principal principal, + @PathVariable final long teamId, + @RequestParam(required = false) final Long folderId, + @RequestBody final DocumentsCreateRequest request + ) { + long memberId = Long.parseLong(principal.getName()); + DocumentsCreateResponse response = documentService.createDocuments(memberId, teamId, folderId, request); + return SuccessResponse.success(SUCCESS_CREATE_DOCUMENTS.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/teams/{teamId}/documents") + public SuccessResponse getDocuments( + final Principal principal, + @PathVariable final long teamId, + @RequestParam(required = false) final Long folderId + ) { + long memberId = Long.parseLong(principal.getName()); + DocumentsGetResponse response = documentService.get(memberId, teamId, folderId); + return SuccessResponse.success(SUCCESS_GET_DOCUMENTS.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/teams/{teamId}/documents") + public void delete( + final Principal principal, + @PathVariable final long teamId, + @RequestParam("documentId") final List documentIds + ) { + long memberId = Long.parseLong(principal.getName()); + documentService.delete(memberId, teamId, documentIds); + } + + @Override + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/teams/{teamId}/trash") + public void deleteTrash( + final Principal principal, + @PathVariable final long teamId, + @RequestParam("documentId") final List deletedDocumentIds + ) { + long memberId = Long.parseLong(principal.getName()); + documentService.deleteTrash(memberId, teamId, deletedDocumentIds); + } + + @Override + @ResponseStatus(HttpStatus.NO_CONTENT) + @PostMapping("/teams/{teamId}/trash") + public void restore( + final Principal principal, + @PathVariable final long teamId, + @RequestParam("documentId") final List deletedDocumentIds + ) { + long memberId = Long.parseLong(principal.getName()); + documentService.restore(memberId, teamId, deletedDocumentIds); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/teams/{teamId}/trash") + public SuccessResponse getTrash( + final Principal principal, + @PathVariable final long teamId + ) { + long memberId = Long.parseLong(principal.getName()); + DeletedDocumentsGetResponse response = documentService.getTrash(memberId, teamId); + return SuccessResponse.success(SUCCESS_GET_TRASH.getMessage(), response); + } +} diff --git a/src/main/java/com/tiki/server/document/controller/docs/DocumentControllerDocs.java b/src/main/java/com/tiki/server/document/controller/docs/DocumentControllerDocs.java new file mode 100644 index 00000000..5ca95791 --- /dev/null +++ b/src/main/java/com/tiki/server/document/controller/docs/DocumentControllerDocs.java @@ -0,0 +1,244 @@ +package com.tiki.server.document.controller.docs; + +import java.security.Principal; +import java.util.List; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import com.tiki.server.common.dto.ErrorResponse; +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.document.dto.request.DocumentsCreateRequest; +import com.tiki.server.document.dto.response.DeletedDocumentsGetResponse; +import com.tiki.server.document.dto.response.DocumentsCreateResponse; +import com.tiki.server.document.dto.response.DocumentsGetResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "documents", description = "파일 API") +public interface DocumentControllerDocs { + + @Operation( + summary = "전체 문서 조회", + description = "전체 문서를 조회한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getAllDocuments( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "type", + description = "타임라인 타입", + in = ParameterIn.QUERY, + required = true, + example = "executive, member" + ) @RequestParam final String type + ); + + @Operation( + summary = "문서 생성", + description = "문서를 여러 개 생성한다.", + responses = { + @ApiResponse(responseCode = "201", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse createDocuments( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "folderId", + description = "생성할 파일이 속할 폴더 id", + in = ParameterIn.QUERY, + example = "1" + ) @RequestParam final Long folderId, + @RequestBody final DocumentsCreateRequest request + ); + + @Operation( + summary = "문서 조회", + description = "문서를 조회한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getDocuments( + @Parameter(hidden = true) Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "folderId", + description = "조회할 폴더 id (최상단은 비워두기)", + in = ParameterIn.QUERY, + example = "1" + ) @RequestParam final Long folderId + ); + + @Operation( + summary = "문서 삭제", + description = "문서를 여러 개 삭제한다.", + responses = { + @ApiResponse(responseCode = "204", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + void delete( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "documentId", + description = "삭제할 파일 id 리스트", + in = ParameterIn.QUERY, + required = true, + example = "[1, 2]" + ) @RequestParam("documentId") final List documentIds + ); + + @Operation( + summary = "휴지통 문서 삭제", + description = "휴지통 속 문서를 여러 개 삭제한다.", + responses = { + @ApiResponse(responseCode = "204", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + void deleteTrash( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "documentId", + description = "삭제할 파일 id 리스트", + in = ParameterIn.QUERY, + required = true, + example = "[1, 2]" + ) @RequestParam("documentId") final List deletedDocumentIds + ); + + @Operation( + summary = "휴지통 문서 복구", + description = "휴지통 속 문서를 여러 개 복구한다.", + responses = { + @ApiResponse(responseCode = "204", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + void restore( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "documentId", + description = "복구할 파일 id 리스트", + in = ParameterIn.QUERY, + required = true, + example = "[1, 2]" + ) @RequestParam("documentId") final List deletedDocumentIds + ); + + @Operation( + summary = "휴지통 조회", + description = "휴지통을 조회한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getTrash( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long teamId + ); +} diff --git a/src/main/java/com/tiki/server/document/dto/request/DocumentCreateRequest.java b/src/main/java/com/tiki/server/document/dto/request/DocumentCreateRequest.java new file mode 100644 index 00000000..53a7f24a --- /dev/null +++ b/src/main/java/com/tiki/server/document/dto/request/DocumentCreateRequest.java @@ -0,0 +1,16 @@ +package com.tiki.server.document.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record DocumentCreateRequest( + @Schema(description = "파일 이름", example = "tiki.jpg") + @NotNull String fileName, + @Schema(description = "파일 url", example = "https://.../tiki.jpg") + @NotNull String fileUrl, + @Schema(description = "파일 key", example = "....jpg") + @NotNull String fileKey, + @Schema(description = "파일 용량 (단위 : byte)", example = "123") + @NotNull long capacity +) { +} diff --git a/src/main/java/com/tiki/server/document/dto/request/DocumentsCreateRequest.java b/src/main/java/com/tiki/server/document/dto/request/DocumentsCreateRequest.java new file mode 100644 index 00000000..0d7957d1 --- /dev/null +++ b/src/main/java/com/tiki/server/document/dto/request/DocumentsCreateRequest.java @@ -0,0 +1,10 @@ +package com.tiki.server.document.dto.request; + +import java.util.List; + +import jakarta.validation.constraints.NotNull; + +public record DocumentsCreateRequest( + @NotNull List documents +) { +} diff --git a/src/main/java/com/tiki/server/document/dto/response/DeletedDocumentsGetResponse.java b/src/main/java/com/tiki/server/document/dto/response/DeletedDocumentsGetResponse.java new file mode 100644 index 00000000..0ae4d9c1 --- /dev/null +++ b/src/main/java/com/tiki/server/document/dto/response/DeletedDocumentsGetResponse.java @@ -0,0 +1,40 @@ +package com.tiki.server.document.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import java.util.List; + +import com.tiki.server.document.entity.DeletedDocument; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record DeletedDocumentsGetResponse( + @NotNull List deletedDocuments +) { + + public static DeletedDocumentsGetResponse from(final List deletedDocuments) { + return DeletedDocumentsGetResponse.builder() + .deletedDocuments(deletedDocuments.stream().map(DeletedDocumentGetResponse::from).toList()) + .build(); + } + + @Builder(access = PRIVATE) + private record DeletedDocumentGetResponse( + @NotNull long documentId, + @NotNull String name, + @NotNull String url, + @NotNull long capacity + ) { + + private static DeletedDocumentGetResponse from(final DeletedDocument deletedDocument) { + return DeletedDocumentGetResponse.builder() + .documentId(deletedDocument.getId()) + .name(deletedDocument.getFileName()) + .url(deletedDocument.getFileUrl()) + .capacity(deletedDocument.getCapacity()) + .build(); + } + } +} diff --git a/src/main/java/com/tiki/server/document/dto/response/DocumentsCreateResponse.java b/src/main/java/com/tiki/server/document/dto/response/DocumentsCreateResponse.java new file mode 100644 index 00000000..95464888 --- /dev/null +++ b/src/main/java/com/tiki/server/document/dto/response/DocumentsCreateResponse.java @@ -0,0 +1,32 @@ +package com.tiki.server.document.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import java.util.List; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record DocumentsCreateResponse( + @NotNull List response +) { + + public static DocumentsCreateResponse from(final List documentIds) { + return DocumentsCreateResponse.builder() + .response(documentIds.stream().map(DocumentCreateResponse::from).toList()) + .build(); + } + + @Builder(access = PRIVATE) + private record DocumentCreateResponse( + @NotNull long documentId + ) { + + public static DocumentCreateResponse from(final long documentId) { + return DocumentCreateResponse.builder() + .documentId(documentId) + .build(); + } + } +} diff --git a/src/main/java/com/tiki/server/document/dto/response/DocumentsGetResponse.java b/src/main/java/com/tiki/server/document/dto/response/DocumentsGetResponse.java new file mode 100644 index 00000000..3ba22bc3 --- /dev/null +++ b/src/main/java/com/tiki/server/document/dto/response/DocumentsGetResponse.java @@ -0,0 +1,43 @@ +package com.tiki.server.document.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import java.time.LocalDateTime; +import java.util.List; + +import com.tiki.server.document.entity.Document; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record DocumentsGetResponse( + @NotNull List documents +) { + + public static DocumentsGetResponse from(final List documents) { + return DocumentsGetResponse.builder() + .documents(documents.stream().map(DocumentInfoGetResponse::from).toList()) + .build(); + } + + @Builder(access = PRIVATE) + private record DocumentInfoGetResponse( + @NotNull long documentId, + @NotNull String name, + @NotNull String url, + @NotNull long capacity, + @NotNull LocalDateTime createdTime + ) { + + public static DocumentInfoGetResponse from(final Document document) { + return DocumentInfoGetResponse.builder() + .documentId(document.getId()) + .name(document.getFileName()) + .url(document.getFileUrl()) + .capacity(document.getCapacity()) + .createdTime(document.getCreatedAt()) + .build(); + } + } +} diff --git a/src/main/java/com/tiki/server/document/entity/DeletedDocument.java b/src/main/java/com/tiki/server/document/entity/DeletedDocument.java new file mode 100644 index 00000000..d50d5a38 --- /dev/null +++ b/src/main/java/com/tiki/server/document/entity/DeletedDocument.java @@ -0,0 +1,54 @@ +package com.tiki.server.document.entity; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import com.tiki.server.common.entity.BaseTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +@Builder(access = PRIVATE) +@AllArgsConstructor(access = PRIVATE) +public class DeletedDocument extends BaseTime { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "deleted_document_id") + private Long id; + + @Column(nullable = false) + private String fileName; + + @Column(nullable = false) + private String fileUrl; + + @Column(nullable = false) + private String fileKey; + + @Column(nullable = false) + private long teamId; + + @Column(nullable = false) + private long capacity; + + public static DeletedDocument of(final Document document) { + return DeletedDocument.builder() + .fileName(document.getFileName()) + .fileUrl(document.getFileUrl()) + .fileKey(document.getFileKey()) + .teamId(document.getTeamId()) + .capacity(document.getCapacity()) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/document/entity/Document.java b/src/main/java/com/tiki/server/document/entity/Document.java new file mode 100644 index 00000000..dd5389f8 --- /dev/null +++ b/src/main/java/com/tiki/server/document/entity/Document.java @@ -0,0 +1,69 @@ +package com.tiki.server.document.entity; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import com.tiki.server.common.entity.BaseTime; +import com.tiki.server.document.dto.request.DocumentCreateRequest; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder(access = PRIVATE) +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +public class Document extends BaseTime { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "document_id") + private Long id; + + @Column(nullable = false) + private String fileName; + + @Column(nullable = false) + private String fileUrl; + + @Column(nullable = false) + private String fileKey; + + @Column(nullable = false) + private long capacity; + + @Column(nullable = false) + private long teamId; + + private Long folderId; + + public static Document of(final DocumentCreateRequest request, final long teamId, final Long folderId) { + return Document.builder() + .fileName(request.fileName()) + .fileUrl(request.fileUrl()) + .capacity(request.capacity()) + .fileKey(request.fileKey()) + .teamId(teamId) + .folderId(folderId) + .build(); + } + + public static Document restore(final DeletedDocument deletedDocument) { + return Document.builder() + .fileName(deletedDocument.getFileName()) + .fileUrl(deletedDocument.getFileUrl()) + .capacity(deletedDocument.getCapacity()) + .fileKey(deletedDocument.getFileKey()) + .teamId(deletedDocument.getTeamId()) + .folderId(null) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/document/exception/DocumentException.java b/src/main/java/com/tiki/server/document/exception/DocumentException.java new file mode 100644 index 00000000..142050fe --- /dev/null +++ b/src/main/java/com/tiki/server/document/exception/DocumentException.java @@ -0,0 +1,16 @@ +package com.tiki.server.document.exception; + +import com.tiki.server.document.message.ErrorCode; + +import lombok.Getter; + +@Getter +public class DocumentException extends RuntimeException { + + private final ErrorCode errorCode; + + public DocumentException(final ErrorCode errorCode) { + super("[DocumentException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/tiki/server/document/message/ErrorCode.java b/src/main/java/com/tiki/server/document/message/ErrorCode.java new file mode 100644 index 00000000..3a310735 --- /dev/null +++ b/src/main/java/com/tiki/server/document/message/ErrorCode.java @@ -0,0 +1,31 @@ +package com.tiki.server.document.message; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + /* 400 BAD_REQUEST : 잘못된 요청 */ + INVALID_TYPE(BAD_REQUEST, "유효한 타입이 아닙니다."), + + /* 403 FORBIDDEN : 권한 없음 */ + INVALID_AUTHORIZATION(FORBIDDEN, "문서에 대한 권한이 없습니다."), + + /* 404 NOT_FOUND : 자원을 찾을 수 없음 */ + INVALID_DOCUMENT(NOT_FOUND, "유효하지 않은 문서입니다."), + + /* 409 CONFLICT : 중복된 자원 */ + DOCUMENT_NAME_DUPLICATE(CONFLICT, "중복된 파일 이름입니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/tiki/server/document/message/SuccessMessage.java b/src/main/java/com/tiki/server/document/message/SuccessMessage.java new file mode 100644 index 00000000..315fceee --- /dev/null +++ b/src/main/java/com/tiki/server/document/message/SuccessMessage.java @@ -0,0 +1,15 @@ +package com.tiki.server.document.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + SUCCESS_CREATE_DOCUMENTS("파일 생성 성공"), + SUCCESS_GET_DOCUMENTS("전체 문서 조회 성공"), + SUCCESS_GET_TRASH("휴지통 조회 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/document/repository/DeletedDocumentRepository.java b/src/main/java/com/tiki/server/document/repository/DeletedDocumentRepository.java new file mode 100644 index 00000000..52dff8bd --- /dev/null +++ b/src/main/java/com/tiki/server/document/repository/DeletedDocumentRepository.java @@ -0,0 +1,15 @@ +package com.tiki.server.document.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.tiki.server.document.entity.DeletedDocument; + +public interface DeletedDocumentRepository extends JpaRepository { + + Optional findByIdAndTeamId(final long id, final long teamId); + + List findAllByTeamId(final long teamId); +} diff --git a/src/main/java/com/tiki/server/document/repository/DocumentRepository.java b/src/main/java/com/tiki/server/document/repository/DocumentRepository.java new file mode 100644 index 00000000..5e1e6058 --- /dev/null +++ b/src/main/java/com/tiki/server/document/repository/DocumentRepository.java @@ -0,0 +1,18 @@ +package com.tiki.server.document.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.tiki.server.document.entity.Document; + +public interface DocumentRepository extends JpaRepository { + List findAllByFolderId(final long folderId); + + List findAllByTeamId(final long teamId); + + List findAllByTeamIdAndFolderIdOrderByCreatedAtDesc(final long teamId, final Long folderId); + + Optional findByIdAndTeamId(final long id, final long teamId); +} diff --git a/src/main/java/com/tiki/server/document/service/DocumentService.java b/src/main/java/com/tiki/server/document/service/DocumentService.java new file mode 100644 index 00000000..4f05c92b --- /dev/null +++ b/src/main/java/com/tiki/server/document/service/DocumentService.java @@ -0,0 +1,141 @@ +package com.tiki.server.document.service; + +import static com.tiki.server.document.message.ErrorCode.DOCUMENT_NAME_DUPLICATE; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.tiki.server.common.entity.Position; +import com.tiki.server.document.adapter.DeletedDocumentAdapter; +import com.tiki.server.document.adapter.DocumentDeleter; +import com.tiki.server.document.adapter.DocumentFinder; +import com.tiki.server.document.adapter.DocumentSaver; +import com.tiki.server.document.dto.request.DocumentCreateRequest; +import com.tiki.server.document.dto.request.DocumentsCreateRequest; +import com.tiki.server.document.dto.response.DeletedDocumentsGetResponse; +import com.tiki.server.document.dto.response.DocumentsCreateResponse; +import com.tiki.server.document.dto.response.DocumentsGetResponse; +import com.tiki.server.document.entity.DeletedDocument; +import com.tiki.server.document.entity.Document; +import com.tiki.server.document.exception.DocumentException; +import com.tiki.server.documenttimeblockmanager.adapter.DTBAdapter; +import com.tiki.server.external.util.AwsHandler; +import com.tiki.server.folder.adapter.FolderFinder; +import com.tiki.server.folder.entity.Folder; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder; +import com.tiki.server.memberteammanager.entity.MemberTeamManager; +import com.tiki.server.team.adapter.TeamFinder; +import com.tiki.server.team.entity.Team; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DocumentService { + + private final DocumentSaver documentSaver; + private final DocumentFinder documentFinder; + private final DocumentDeleter documentDeleter; + private final FolderFinder folderFinder; + private final MemberTeamManagerFinder memberTeamManagerFinder; + private final DeletedDocumentAdapter deletedDocumentAdapter; + private final TeamFinder teamFinder; + private final DTBAdapter dtbAdapter; + private final AwsHandler awsHandler; + + public DocumentsGetResponse getAllDocuments(final long memberId, final long teamId, final String type) { + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + Position accessiblePosition = Position.getAccessiblePosition(type); + memberTeamManager.checkMemberAccessible(accessiblePosition); + List documents = documentFinder.findAllByTeamId(teamId); + return DocumentsGetResponse.from(documents); + } + + @Transactional + public DocumentsCreateResponse createDocuments(final long memberId, final long teamId, + final Long folderId, final DocumentsCreateRequest request) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + validateFolder(folderId, teamId); + validateFileName(folderId, teamId, request); + List documentIds = saveDocuments(teamId, folderId, request); + return DocumentsCreateResponse.from(documentIds); + } + + public DocumentsGetResponse get(final long memberId, final long teamId, final Long folderId) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + List documents = documentFinder.findByTeamIdAndFolderId(teamId, folderId); + return DocumentsGetResponse.from(documents); + } + + @Transactional + public void delete(final long memberId, final long teamId, final List documentIds) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + List documents = documentFinder.findAllByIdAndTeamId(documentIds, teamId); + dtbAdapter.deleteAllByDocuments(documentIds); + deletedDocumentAdapter.save(documents); + documentDeleter.deleteAll(documents); + } + + @Transactional + public void deleteTrash(final long memberId, final long teamId, final List documentIds) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + List deletedDocuments = deletedDocumentAdapter.get(documentIds, teamId); + restoreTeamUsage(teamId, deletedDocuments); + deletedDocuments.forEach(deletedDocument -> awsHandler.deleteFile(deletedDocument.getFileKey())); + deletedDocumentAdapter.deleteAll(deletedDocuments); + } + + @Transactional + public void restore(final long memberId, final long teamId, final List documentIds) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + List deletedDocuments = deletedDocumentAdapter.get(documentIds, teamId); + documentSaver.restore(deletedDocuments); + deletedDocumentAdapter.deleteAll(deletedDocuments); + } + + public DeletedDocumentsGetResponse getTrash(final long memberId, final long teamId) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + List deletedDocuments = deletedDocumentAdapter.get(teamId); + return DeletedDocumentsGetResponse.from(deletedDocuments); + } + + private void validateFolder(final Long folderId, final long teamId) { + if (folderId == null) { + return; + } + Folder folder = folderFinder.findById(folderId); + folder.validateTeamId(teamId); + } + + private void validateFileName(final Long folderId, final long teamId, final DocumentsCreateRequest request) { + List documents = documentFinder.findByTeamIdAndFolderId(teamId, folderId); + documents.forEach(document -> checkFileNameIsDuplicated(document.getFileName(), request)); + } + + private void checkFileNameIsDuplicated(final String fileName, final DocumentsCreateRequest request) { + if (request.documents().stream().anyMatch(document -> document.fileName().equals(fileName))) { + throw new DocumentException(DOCUMENT_NAME_DUPLICATE); + } + } + + private List saveDocuments(final long teamId, final Long folderId, final DocumentsCreateRequest request) { + Team team = teamFinder.findById(teamId); + return request.documents().stream() + .map(document -> saveDocument(team, folderId, document).getId()) + .toList(); + } + + private Document saveDocument(final Team team, final Long folderId, final DocumentCreateRequest request) { + team.addUsage(request.capacity()); + Document document = Document.of(request, team.getId(), folderId); + return documentSaver.save(document); + } + + private void restoreTeamUsage(final long teamId, final List deletedDocuments) { + Team team = teamFinder.findById(teamId); + team.restoreUsage(deletedDocuments.stream().mapToLong(DeletedDocument::getCapacity).sum()); + } +} diff --git a/src/main/java/com/tiki/server/document/service/dto/response/DocumentTagGetServiceResponse.java b/src/main/java/com/tiki/server/document/service/dto/response/DocumentTagGetServiceResponse.java new file mode 100644 index 00000000..bbbe4332 --- /dev/null +++ b/src/main/java/com/tiki/server/document/service/dto/response/DocumentTagGetServiceResponse.java @@ -0,0 +1,21 @@ +package com.tiki.server.document.service.dto.response; + +import com.tiki.server.document.entity.Document; + +import jakarta.validation.constraints.NotNull; + +public record DocumentTagGetServiceResponse( + @NotNull long id, + @NotNull String fileName, + @NotNull String fileUrl, + @NotNull long capacity +) { + + public static DocumentTagGetServiceResponse from(final Document document) { + return new DocumentTagGetServiceResponse( + document.getId(), + document.getFileName(), + document.getFileUrl(), + document.getCapacity()); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/documenttimeblockmanager/adapter/DTBAdapter.java b/src/main/java/com/tiki/server/documenttimeblockmanager/adapter/DTBAdapter.java new file mode 100644 index 00000000..0866f7f7 --- /dev/null +++ b/src/main/java/com/tiki/server/documenttimeblockmanager/adapter/DTBAdapter.java @@ -0,0 +1,41 @@ +package com.tiki.server.documenttimeblockmanager.adapter; + +import java.util.List; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.documenttimeblockmanager.entity.DTBManager; +import com.tiki.server.documenttimeblockmanager.repository.DTBRepository; +import com.tiki.server.timeblock.entity.TimeBlock; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class DTBAdapter { + + private final DTBRepository dtbRepository; + + public void saveAll(final TimeBlock timeBlock, final List documentIds) { + documentIds.forEach(documentId -> dtbRepository.save(DTBManager.of(timeBlock, documentId))); + } + + public List getAllByTimeBlock(final TimeBlock timeBlock) { + return dtbRepository.findAllByTimeBlockId(timeBlock.getId()); + } + + public List getAllByIds(final List ids) { + return dtbRepository.findAllByIdIn(ids); + } + + public void deleteAll(final List dtbManagers) { + dtbRepository.deleteAll(dtbManagers); + } + + public void deleteAllByTimeBlock(final TimeBlock timeBlock) { + dtbRepository.deleteAllByTimeBlockId(timeBlock.getId()); + } + + public void deleteAllByDocuments(final List documentIds) { + dtbRepository.deleteAllByDocumentIdIn(documentIds); + } +} diff --git a/src/main/java/com/tiki/server/documenttimeblockmanager/entity/DTBManager.java b/src/main/java/com/tiki/server/documenttimeblockmanager/entity/DTBManager.java new file mode 100644 index 00000000..83881706 --- /dev/null +++ b/src/main/java/com/tiki/server/documenttimeblockmanager/entity/DTBManager.java @@ -0,0 +1,52 @@ +package com.tiki.server.documenttimeblockmanager.entity; + +import static com.tiki.server.timeblock.message.ErrorCode.INVALID_DOCUMENT_TAG; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import com.tiki.server.common.entity.BaseTime; +import com.tiki.server.timeblock.entity.TimeBlock; +import com.tiki.server.timeblock.exception.TimeBlockException; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder(access = PRIVATE) +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +@Table(name = "document_time_block_manager") +public class DTBManager extends BaseTime { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private long documentId; + + @Column(nullable = false) + private long timeBlockId; + + public static DTBManager of(final TimeBlock timeBlock, final long documentId) { + return DTBManager.builder() + .documentId(documentId) + .timeBlockId(timeBlock.getId()) + .build(); + } + + public void validateTimeBlock(final TimeBlock timeBlock) { + if (this.timeBlockId != timeBlock.getId()) { + throw new TimeBlockException(INVALID_DOCUMENT_TAG); + } + } +} diff --git a/src/main/java/com/tiki/server/documenttimeblockmanager/repository/DTBRepository.java b/src/main/java/com/tiki/server/documenttimeblockmanager/repository/DTBRepository.java new file mode 100644 index 00000000..c5b7ec87 --- /dev/null +++ b/src/main/java/com/tiki/server/documenttimeblockmanager/repository/DTBRepository.java @@ -0,0 +1,17 @@ +package com.tiki.server.documenttimeblockmanager.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.tiki.server.documenttimeblockmanager.entity.DTBManager; + +public interface DTBRepository extends JpaRepository { + List findAllByTimeBlockId(final long timeBlockId); + + List findAllByIdIn(final List ids); + + void deleteAllByTimeBlockId(final long timeBlockId); + + void deleteAllByDocumentIdIn(final List documentIds); +} diff --git a/src/main/java/com/tiki/server/drive/controller/DriveController.java b/src/main/java/com/tiki/server/drive/controller/DriveController.java new file mode 100644 index 00000000..f594dc6e --- /dev/null +++ b/src/main/java/com/tiki/server/drive/controller/DriveController.java @@ -0,0 +1,41 @@ +package com.tiki.server.drive.controller; + +import static com.tiki.server.common.dto.SuccessResponse.success; +import static com.tiki.server.drive.message.SuccessMessage.SUCCESS_GET_DRIVE; + +import java.security.Principal; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.drive.controller.docs.DriveControllerDocs; +import com.tiki.server.drive.dto.DriveGetResponse; +import com.tiki.server.drive.service.DriveService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1") +public class DriveController implements DriveControllerDocs { + + private final DriveService driveService; + + @ResponseStatus(HttpStatus.OK) + @GetMapping("/teams/{teamId}/drive") + public SuccessResponse getDrive( + final Principal principal, + @PathVariable final long teamId, + @RequestParam(required = false) final Long folderId + ) { + long memberId = Long.parseLong(principal.getName()); + DriveGetResponse response = driveService.getDrive(memberId, teamId, folderId); + return success(SUCCESS_GET_DRIVE.getMessage(), response); + } +} diff --git a/src/main/java/com/tiki/server/drive/controller/docs/DriveControllerDocs.java b/src/main/java/com/tiki/server/drive/controller/docs/DriveControllerDocs.java new file mode 100644 index 00000000..32cd82bd --- /dev/null +++ b/src/main/java/com/tiki/server/drive/controller/docs/DriveControllerDocs.java @@ -0,0 +1,53 @@ +package com.tiki.server.drive.controller.docs; + +import java.security.Principal; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import com.tiki.server.common.dto.ErrorResponse; +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.drive.dto.DriveGetResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "drive", description = "드라이브 API") +public interface DriveControllerDocs { + + @Operation( + summary = "드라이브 조회", + description = "드라이브 뷰를 조회한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getDrive( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + required = true, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "folderId", + description = "조회할 폴더 id (최상단은 비워두기)", + in = ParameterIn.QUERY, + example = "1" + ) @RequestParam final Long folderId + ); +} diff --git a/src/main/java/com/tiki/server/drive/dto/DriveGetResponse.java b/src/main/java/com/tiki/server/drive/dto/DriveGetResponse.java new file mode 100644 index 00000000..dc31f3c0 --- /dev/null +++ b/src/main/java/com/tiki/server/drive/dto/DriveGetResponse.java @@ -0,0 +1,68 @@ +package com.tiki.server.drive.dto; + +import static lombok.AccessLevel.PRIVATE; + +import java.time.LocalDateTime; +import java.util.List; + +import com.tiki.server.document.entity.Document; +import com.tiki.server.folder.entity.Folder; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record DriveGetResponse( + @NotNull List documents, + @NotNull List folders +) { + + public static DriveGetResponse of(final List documents, final List folders) { + return DriveGetResponse.builder() + .documents(documents.stream().map(DocumentGetResponse::from).toList()) + .folders(folders.stream().map(FolderGetResponse::from).toList()) + .build(); + } + + @Builder(access = PRIVATE) + private record DocumentGetResponse( + @NotNull long documentId, + @NotNull String name, + @NotNull String url, + @NotNull long capacity, + @NotNull LocalDateTime createdTime, + @NotNull String type + ) { + + public static DocumentGetResponse from(final Document document) { + return DocumentGetResponse.builder() + .documentId(document.getId()) + .name(document.getFileName()) + .url(document.getFileUrl()) + .capacity(document.getCapacity()) + .createdTime(document.getCreatedAt()) + .type("document") + .build(); + } + } + + @Builder(access = PRIVATE) + private record FolderGetResponse( + @NotNull long folderId, + @NotNull String name, + @NotNull LocalDateTime createdTime, + @NotNull String path, + @NotNull String type + ) { + + private static FolderGetResponse from(final Folder folder) { + return FolderGetResponse.builder() + .folderId(folder.getId()) + .name(folder.getName()) + .createdTime(folder.getCreatedAt()) + .path(folder.getPath()) + .type("folder") + .build(); + } + } +} diff --git a/src/main/java/com/tiki/server/drive/message/SuccessMessage.java b/src/main/java/com/tiki/server/drive/message/SuccessMessage.java new file mode 100644 index 00000000..1d5a50fe --- /dev/null +++ b/src/main/java/com/tiki/server/drive/message/SuccessMessage.java @@ -0,0 +1,13 @@ +package com.tiki.server.drive.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + SUCCESS_GET_DRIVE("드라이브 조회 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/drive/service/DriveService.java b/src/main/java/com/tiki/server/drive/service/DriveService.java new file mode 100644 index 00000000..c195375b --- /dev/null +++ b/src/main/java/com/tiki/server/drive/service/DriveService.java @@ -0,0 +1,52 @@ +package com.tiki.server.drive.service; + +import static com.tiki.server.folder.constant.Constant.ROOT_PATH; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.tiki.server.document.adapter.DocumentFinder; +import com.tiki.server.document.entity.Document; +import com.tiki.server.drive.dto.DriveGetResponse; +import com.tiki.server.folder.adapter.FolderFinder; +import com.tiki.server.folder.entity.Folder; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DriveService { + + private final MemberTeamManagerFinder memberTeamManagerFinder; + private final DocumentFinder documentFinder; + private final FolderFinder folderFinder; + + public DriveGetResponse getDrive(final long memberId, final long teamId, final Long folderId) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + List documents = documentFinder.findByTeamIdAndFolderId(teamId, folderId); + Folder folder = getFolder(teamId, folderId); + String path = getChildFolderPath(folder); + List folders = folderFinder.findByTeamIdAndPath(teamId, path); + return DriveGetResponse.of(documents, folders); + } + + private Folder getFolder(final long teamId, final Long folderId) { + if (folderId == null) { + return null; + } + Folder folder = folderFinder.findById(folderId); + folder.validateTeamId(teamId); + return folder; + } + + private String getChildFolderPath(final Folder folder) { + if (folder == null) { + return ROOT_PATH; + } + return folder.getChildPath(); + } +} diff --git a/src/main/java/com/tiki/server/email/Constants.java b/src/main/java/com/tiki/server/email/Constants.java new file mode 100644 index 00000000..f9c1c1b5 --- /dev/null +++ b/src/main/java/com/tiki/server/email/Constants.java @@ -0,0 +1,6 @@ +package com.tiki.server.email; + +public class Constants { + public static final String MAIL_FORMAT_EDU = ".edu"; + public static final String MAIL_FORMAT_AC_KR = ".ac.kr"; +} diff --git a/src/main/java/com/tiki/server/email/Email.java b/src/main/java/com/tiki/server/email/Email.java new file mode 100644 index 00000000..ba10c7f0 --- /dev/null +++ b/src/main/java/com/tiki/server/email/Email.java @@ -0,0 +1,42 @@ +package com.tiki.server.email; + +import com.tiki.server.member.exception.MemberException; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.*; +import org.apache.commons.validator.routines.EmailValidator; + +import static com.tiki.server.email.Constants.MAIL_FORMAT_AC_KR; +import static com.tiki.server.email.Constants.MAIL_FORMAT_EDU; +import static com.tiki.server.member.message.ErrorCode.INVALID_EMAIL; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Email { + + @Column(nullable = false) + private String email; + + public static Email from(final String email){ + checkMailFormat(email); + return new Email(email); + } + + private static void checkMailFormat(final String email) { + if (!EmailValidator.getInstance().isValid(email) || !(email.endsWith(MAIL_FORMAT_EDU) || + email.endsWith(MAIL_FORMAT_AC_KR))) { + throw new MemberException(INVALID_EMAIL); + } + } + + @Override + public boolean equals(Object target) { + if (this == target) return true; + if (target == null || getClass() != target.getClass()) return false; + Email targetEmail = (Email) target; + return email.equals(targetEmail.email); + } +} diff --git a/src/main/java/com/tiki/server/email/emailsender/config/EmailConfig.java b/src/main/java/com/tiki/server/email/emailsender/config/EmailConfig.java new file mode 100644 index 00000000..398620c0 --- /dev/null +++ b/src/main/java/com/tiki/server/email/emailsender/config/EmailConfig.java @@ -0,0 +1,45 @@ +package com.tiki.server.email.emailsender.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +import static com.tiki.server.email.emailsender.constants.Constants.TIKI_EMAIL; + +@Configuration +public class EmailConfig { + + @Value("${spring.mail.password}") + private String emailPassword; + + @Bean + public JavaMailSender setProperties() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost("smtp.gmail.com"); + mailSender.setPort(587); + mailSender.setUsername(TIKI_EMAIL); + mailSender.setPassword(emailPassword); + + Properties javaMailProperties = getProperties(); + + mailSender.setJavaMailProperties(javaMailProperties); + + return mailSender; + } + + private static Properties getProperties() { + Properties javaMailProperties = new Properties(); + javaMailProperties.put("mail.transport.protocol", "smtp"); + javaMailProperties.put("mail.smtp.auth", "true"); + javaMailProperties.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory"); + javaMailProperties.put("mail.smtp.starttls.enable", "true"); + javaMailProperties.put("mail.debug", "true"); + javaMailProperties.put("mail.smtp.ssl.trust", "smtp.naver.com"); + javaMailProperties.put("mail.smtp.ssl.protocols", "TLSv1.2"); + return javaMailProperties; + } +} diff --git a/src/main/java/com/tiki/server/email/emailsender/constants/Constants.java b/src/main/java/com/tiki/server/email/emailsender/constants/Constants.java new file mode 100644 index 00000000..a0b2a9a8 --- /dev/null +++ b/src/main/java/com/tiki/server/email/emailsender/constants/Constants.java @@ -0,0 +1,19 @@ +package com.tiki.server.email.emailsender.constants; + +public class Constants { + + /* 공통 상수 */ + public static final String TIKI_EMAIL = "hello.wer.tiki@gmail.com"; + public static final String IMG_PATH = "images/mail_logo.png"; + + /* 이메일 인증 관련 상수 */ + public static final String MAIL_VERIFICATION_CODE = "[Ti.Ki] 회원가입: 이메일 인증번호 안내"; + public static final String MAIL_PASSWORD_CHANGING_CODE = "[Ti.Ki] 비밀번호 재설정: 이메일 인증번호 안내"; + public static final String MAIL_INVITE_TEAM_MEMBER = "[Ti.Ki] 워크스페이스 초대 안내"; + public static final String VERIFICATION_TEMPLATE_NAME = "signup"; + public static final String CHANGE_PASSWORD_TEMPLATE_NAME = "changePassword"; + public static final String INVITE_TEAM_MEMBER_TEMPLATE_NAME = "invitation"; + public static final String PAGE_LOGO_IMAGE_VAR = "image"; + + /* 이메일 초대 관련 상수 */ +} diff --git a/src/main/java/com/tiki/server/email/emailsender/controller/EmailSenderController.java b/src/main/java/com/tiki/server/email/emailsender/controller/EmailSenderController.java new file mode 100644 index 00000000..325d3564 --- /dev/null +++ b/src/main/java/com/tiki/server/email/emailsender/controller/EmailSenderController.java @@ -0,0 +1,49 @@ +package com.tiki.server.email.emailsender.controller; + +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.email.emailsender.controller.docs.EmailSenderControllerDocs; +import com.tiki.server.email.emailsender.controller.dto.request.EmailRequest; +import com.tiki.server.email.emailsender.message.SuccessMessage; +import com.tiki.server.email.emailsender.service.EmailSenderService; +import com.tiki.server.email.emailsender.service.dto.EmailServiceRequest; +import com.tiki.server.email.emailsender.service.dto.TeamInvitationCreateServiceRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/email") +public class EmailSenderController implements EmailSenderControllerDocs { + + private final EmailSenderService emailSenderService; + + @ResponseStatus(HttpStatus.OK) + @PostMapping("/verification/signup") + public SuccessResponse sendSignUpMail(@RequestBody final EmailRequest mailRequest) { + emailSenderService.sendSignUp(EmailServiceRequest.from(mailRequest.email())); + return SuccessResponse.success(SuccessMessage.SUCCESS_SEND_EMAIL.getMessage()); + } + + @ResponseStatus(HttpStatus.OK) + @PostMapping("/verification/password") + public SuccessResponse sendChangingPasswordMail(@RequestBody final EmailRequest mailRequest) { + emailSenderService.sendPasswordChanging(EmailServiceRequest.from(mailRequest.email())); + return SuccessResponse.success(SuccessMessage.SUCCESS_SEND_EMAIL.getMessage()); + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/invitation/team/{teamId}") + public SuccessResponse sendInvitationMail( + final Principal principal, + @PathVariable final long teamId, + @RequestBody final EmailRequest targetEmailRequest + ) { + long memberId = Long.parseLong(principal.getName()); + emailSenderService.createTeamInvitation( + TeamInvitationCreateServiceRequest.of(targetEmailRequest.email(), teamId, memberId)); + return SuccessResponse.success(SuccessMessage.SUCCESS_SEND_EMAIL.getMessage()); + } +} diff --git a/src/main/java/com/tiki/server/email/emailsender/controller/docs/EmailSenderControllerDocs.java b/src/main/java/com/tiki/server/email/emailsender/controller/docs/EmailSenderControllerDocs.java new file mode 100644 index 00000000..a7d78fc6 --- /dev/null +++ b/src/main/java/com/tiki/server/email/emailsender/controller/docs/EmailSenderControllerDocs.java @@ -0,0 +1,111 @@ +package com.tiki.server.email.emailsender.controller.docs; + +import com.tiki.server.common.dto.ErrorResponse; +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.email.emailsender.controller.dto.request.EmailRequest; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.security.Principal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "EmailVerification", description = "메일 인증 API") +public interface EmailSenderControllerDocs { + + @Operation( + summary = "회원가입 메일 전송", + description = "회원 가입을 진행한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "400", + description = "이메일 형식 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "409", + description = "이미 가입된 아이디", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse sendSignUpMail(@RequestBody EmailRequest mailRequest); + + @Operation( + summary = "비밀번호 재설정 메일 전송", + description = "비밀번호 재설정을 위한 이메일을 보낸다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "400", + description = "이메일 형식 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "가입되지 않은 이메일", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse sendChangingPasswordMail(@RequestBody EmailRequest mailRequest); + + @Operation( + summary = "팀원 초대 메일 전송", + description = "팀원 초대를 위한 이메일을 전송한다.", + responses = { + @ApiResponse(responseCode = "201", description = "성공"), + @ApiResponse( + responseCode = "400", + description = "이메일 형식 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "400", + description = "이미 존재하는 팀원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하는 회원이 아님", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀이 존재하지 않음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "메일을 설정할 수 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + + } + ) + public SuccessResponse sendInvitationMail( + final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long teamId, + @RequestBody final EmailRequest emailRequest); +} diff --git a/src/main/java/com/tiki/server/email/emailsender/controller/dto/request/EmailRequest.java b/src/main/java/com/tiki/server/email/emailsender/controller/dto/request/EmailRequest.java new file mode 100644 index 00000000..81f34b95 --- /dev/null +++ b/src/main/java/com/tiki/server/email/emailsender/controller/dto/request/EmailRequest.java @@ -0,0 +1,8 @@ +package com.tiki.server.email.emailsender.controller.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record EmailRequest( + @NotNull String email +) { +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/email/emailsender/entity/MailSender.java b/src/main/java/com/tiki/server/email/emailsender/entity/MailSender.java new file mode 100644 index 00000000..4f61d02e --- /dev/null +++ b/src/main/java/com/tiki/server/email/emailsender/entity/MailSender.java @@ -0,0 +1,83 @@ +package com.tiki.server.email.emailsender.entity; + +import com.tiki.server.email.emailsender.exception.EmailSenderException; +import com.tiki.server.email.emailsender.message.ErrorCode; +import jakarta.mail.internet.MimeMessage; + +import java.util.HashMap; +import java.util.Map; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +import static com.tiki.server.email.emailsender.constants.Constants.*; + +@Component +@RequiredArgsConstructor +public class MailSender { + + private final SpringTemplateEngine templateEngine; + private final JavaMailSender javaMailSender; + + public void sendVerificationMail(final String email, final String code) { + Map map = new HashMap<>(); + map.put("code", code); + MimeMessage message = makeMessage(email, MAIL_VERIFICATION_CODE, VERIFICATION_TEMPLATE_NAME, map); + javaMailSender.send(message); + } + + public void sendPasswordChangingMail(final String email, final String code, final String name) { + Map map = new HashMap<>(); + map.put("code", code); + map.put("name", name); + MimeMessage message = makeMessage(email, MAIL_PASSWORD_CHANGING_CODE, CHANGE_PASSWORD_TEMPLATE_NAME, map); + javaMailSender.send(message); + } + + public void sendTeamInvitationMail( + final String email, + final String senderName, + final String teamName, + final long teamId, + final long invitationId + ) { + Map map = new HashMap<>(); + map.put("teamId", String.format("%d", teamId)); + map.put("invitationId", String.format("%d", invitationId)); + map.put("teamName", teamName); + map.put("senderName", senderName); + MimeMessage message = makeMessage(email, MAIL_INVITE_TEAM_MEMBER, INVITE_TEAM_MEMBER_TEMPLATE_NAME, map); + javaMailSender.send(message); + } + + private MimeMessage makeMessage( + String email, + String subject, + String templateName, + Map map + ) { + MimeMessage message = javaMailSender.createMimeMessage(); + try { + MimeMessageHelper helper = new MimeMessageHelper(message, true, "utf-8"); + helper.setFrom(TIKI_EMAIL); + helper.setTo(email); + helper.setSubject(subject); + helper.setText(setContext(map, templateName), true); + helper.addInline(PAGE_LOGO_IMAGE_VAR, new ClassPathResource(IMG_PATH)); + return message; + } catch (Exception e) { + throw new EmailSenderException(ErrorCode.MESSAGE_HELPER_ERROR); + } + } + + private String setContext(Map map, String templateName) { + Context context = new Context(); + map.forEach(context::setVariable); + return templateEngine.process(templateName, context); + } +} diff --git a/src/main/java/com/tiki/server/email/emailsender/exception/EmailSenderException.java b/src/main/java/com/tiki/server/email/emailsender/exception/EmailSenderException.java new file mode 100644 index 00000000..7b338751 --- /dev/null +++ b/src/main/java/com/tiki/server/email/emailsender/exception/EmailSenderException.java @@ -0,0 +1,17 @@ +package com.tiki.server.email.emailsender.exception; + + +import com.tiki.server.email.emailsender.message.ErrorCode; +import lombok.Getter; + +@Getter +public class EmailSenderException extends RuntimeException { + + private final ErrorCode errorCode; + + public EmailSenderException(ErrorCode errorCode) { + super("[EmailSenderException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } + +} diff --git a/src/main/java/com/tiki/server/email/emailsender/message/ErrorCode.java b/src/main/java/com/tiki/server/email/emailsender/message/ErrorCode.java new file mode 100644 index 00000000..4f2d6f3d --- /dev/null +++ b/src/main/java/com/tiki/server/email/emailsender/message/ErrorCode.java @@ -0,0 +1,17 @@ +package com.tiki.server.email.emailsender.message; + +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + /* 500 INTERNAL_SERVER_ERROR : 서버 내부 오류 발생 */ + MESSAGE_HELPER_ERROR(INTERNAL_SERVER_ERROR,"메세지를 설정할 수 없습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/tiki/server/email/emailsender/message/SuccessMessage.java b/src/main/java/com/tiki/server/email/emailsender/message/SuccessMessage.java new file mode 100644 index 00000000..b0d180a5 --- /dev/null +++ b/src/main/java/com/tiki/server/email/emailsender/message/SuccessMessage.java @@ -0,0 +1,13 @@ +package com.tiki.server.email.emailsender.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + SUCCESS_SEND_EMAIL("이메일 전송 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/email/emailsender/service/EmailSenderService.java b/src/main/java/com/tiki/server/email/emailsender/service/EmailSenderService.java new file mode 100644 index 00000000..c29133c0 --- /dev/null +++ b/src/main/java/com/tiki/server/email/emailsender/service/EmailSenderService.java @@ -0,0 +1,83 @@ +package com.tiki.server.email.emailsender.service; + +import static com.tiki.server.memberteammanager.message.ErrorCode.CONFLICT_TEAM_MEMBER; + +import com.tiki.server.common.entity.Position; +import com.tiki.server.email.emailsender.entity.MailSender; +import com.tiki.server.email.emailsender.service.dto.EmailServiceRequest; +import com.tiki.server.email.emailsender.service.dto.TeamInvitationCreateServiceRequest; +import com.tiki.server.email.teaminvitation.adapter.TeamInvitationFinder; +import com.tiki.server.email.teaminvitation.adapter.TeamInvitationSaver; +import com.tiki.server.email.teaminvitation.entity.TeamInvitation; +import com.tiki.server.email.verification.adapter.EmailVerificationSaver; +import com.tiki.server.email.verification.domain.EmailVerification; +import com.tiki.server.member.adapter.MemberFinder; +import com.tiki.server.member.entity.Member; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder; +import com.tiki.server.memberteammanager.entity.MemberTeamManager; +import com.tiki.server.memberteammanager.exception.MemberTeamManagerException; +import com.tiki.server.team.adapter.TeamFinder; +import com.tiki.server.team.entity.Team; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailSenderService { + + private final MemberFinder memberFinder; + private final TeamFinder teamFinder; + private final MailSender mailSender; + private final EmailVerificationSaver emailVerificationSaver; + private final MemberTeamManagerFinder memberTeamManagerFinder; + private final TeamInvitationSaver teamInvitationSaver; + private final TeamInvitationFinder teamInvitationFinder; + + public void sendSignUp(final EmailServiceRequest emailServiceRequest) { + memberFinder.checkPresent(emailServiceRequest.email()); + EmailVerification emailVerification = EmailVerification.of(emailServiceRequest.email()); + mailSender.sendVerificationMail( + emailVerification.getId(), + emailVerification.getVerificationCode().getCode() + ); + emailVerificationSaver.save(emailVerification); + } + + public void sendPasswordChanging(final EmailServiceRequest emailServiceRequest) { + Member member = memberFinder.checkEmpty(emailServiceRequest.email()); + EmailVerification emailVerification = EmailVerification.of(emailServiceRequest.email()); + mailSender.sendPasswordChangingMail( + emailVerification.getId(), + emailVerification.getVerificationCode().getCode(), + member.getName() + ); + emailVerificationSaver.save(emailVerification); + } + + @Transactional + public void createTeamInvitation(final TeamInvitationCreateServiceRequest request) { + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(request.senderId(), + request.teamId()); + memberTeamManager.checkMemberAccessible(Position.ADMIN); + Team team = teamFinder.findById(request.teamId()); + checkIsPresentTeamMember(request); + TeamInvitation teamInvitation = teamInvitationSaver.createTeamInvitation( + TeamInvitation.of(memberTeamManager.getName(), request.teamId(), request.targetEmail())); + mailSender.sendTeamInvitationMail( + request.targetEmail().getEmail(), + memberTeamManager.getName(), + team.getName(), + request.teamId(), + teamInvitation.getId() + ); + } + + private void checkIsPresentTeamMember(final TeamInvitationCreateServiceRequest request) { + if (memberTeamManagerFinder.existsByTeamIdAndMemberEmail(request.teamId(), request.targetEmail())) { + throw new MemberTeamManagerException(CONFLICT_TEAM_MEMBER); + } + } +} diff --git a/src/main/java/com/tiki/server/email/emailsender/service/dto/EmailServiceRequest.java b/src/main/java/com/tiki/server/email/emailsender/service/dto/EmailServiceRequest.java new file mode 100644 index 00000000..e6936b3f --- /dev/null +++ b/src/main/java/com/tiki/server/email/emailsender/service/dto/EmailServiceRequest.java @@ -0,0 +1,12 @@ +package com.tiki.server.email.emailsender.service.dto; + +import com.tiki.server.email.Email; +import jakarta.validation.constraints.NotNull; + +public record EmailServiceRequest( + @NotNull Email email +) { + public static EmailServiceRequest from(final String email) { + return new EmailServiceRequest(Email.from(email)); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/email/emailsender/service/dto/TeamInvitationCreateServiceRequest.java b/src/main/java/com/tiki/server/email/emailsender/service/dto/TeamInvitationCreateServiceRequest.java new file mode 100644 index 00000000..c7474879 --- /dev/null +++ b/src/main/java/com/tiki/server/email/emailsender/service/dto/TeamInvitationCreateServiceRequest.java @@ -0,0 +1,16 @@ +package com.tiki.server.email.emailsender.service.dto; + +import com.tiki.server.email.Email; +import jakarta.validation.constraints.NotNull; + +public record TeamInvitationCreateServiceRequest( + @NotNull Email targetEmail, + @NotNull long teamId, + @NotNull long senderId +) { + + public static TeamInvitationCreateServiceRequest of(final String targetEmail, final long teamId, + final long senderId) { + return new TeamInvitationCreateServiceRequest(Email.from(targetEmail), teamId, senderId); + } +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationDeleter.java b/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationDeleter.java new file mode 100644 index 00000000..a35f779c --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationDeleter.java @@ -0,0 +1,23 @@ +package com.tiki.server.email.teaminvitation.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.email.teaminvitation.entity.TeamInvitation; +import com.tiki.server.email.teaminvitation.repository.TeamInvitationRepository; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RepositoryAdapter +@RequiredArgsConstructor +public class TeamInvitationDeleter { + + private final TeamInvitationRepository teamInvitationRepository; + + public void deleteTeamInvitation(final TeamInvitation teamInvitation) { + teamInvitationRepository.delete(teamInvitation); + } + + public void deleteAll(final List expiredInvitation) { + teamInvitationRepository.deleteAll(expiredInvitation); + } +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationFinder.java b/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationFinder.java new file mode 100644 index 00000000..f3edb080 --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationFinder.java @@ -0,0 +1,32 @@ +package com.tiki.server.email.teaminvitation.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.email.teaminvitation.exception.TeamInvitationException; +import com.tiki.server.email.teaminvitation.entity.TeamInvitation; +import com.tiki.server.email.teaminvitation.repository.TeamInvitationRepository; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +import static com.tiki.server.email.teaminvitation.messages.ErrorCode.INVALID_TEAM_INVITATION; + +@RepositoryAdapter +@RequiredArgsConstructor +public class TeamInvitationFinder { + + private final TeamInvitationRepository teamInvitationRepository; + + public TeamInvitation findByInvitationId(final long invitationId) { + return teamInvitationRepository.findById(invitationId) + .orElseThrow(() -> new TeamInvitationException(INVALID_TEAM_INVITATION)); + } + + public List findByExpiredDate(final LocalDate expiredDate) { + return teamInvitationRepository.findByExpiredDateBefore(expiredDate); + } + + public List findAllByTeamId(final long teamId) { + return teamInvitationRepository.findAllByTeamId(teamId); + } +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationSaver.java b/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationSaver.java new file mode 100644 index 00000000..714cd13c --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationSaver.java @@ -0,0 +1,18 @@ +package com.tiki.server.email.teaminvitation.adapter; + + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.email.teaminvitation.entity.TeamInvitation; +import com.tiki.server.email.teaminvitation.repository.TeamInvitationRepository; +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class TeamInvitationSaver { + + private final TeamInvitationRepository teamInvitationRepository; + + public TeamInvitation createTeamInvitation(final TeamInvitation teamInvitation){ + return teamInvitationRepository.save(teamInvitation); + } +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/controller/TeamInvitationController.java b/src/main/java/com/tiki/server/email/teaminvitation/controller/TeamInvitationController.java new file mode 100644 index 00000000..0eba6296 --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/controller/TeamInvitationController.java @@ -0,0 +1,76 @@ +package com.tiki.server.email.teaminvitation.controller; + +import static com.tiki.server.email.teaminvitation.messages.SuccessMessage.*; + +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.email.teaminvitation.service.TeamInvitationService; +import com.tiki.server.email.teaminvitation.service.dto.TeamInvitationEmailsGetResponse; +import com.tiki.server.email.teaminvitation.service.dto.TeamInvitationInformGetResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/team-invitation") +public class TeamInvitationController { + + private final TeamInvitationService teamInvitationService; + + @ResponseStatus(HttpStatus.OK) + @GetMapping("/team/{teamId}") + public SuccessResponse getTeamInvitation( + final Principal principal, + @PathVariable final long teamId + ) { + long memberId = Long.parseLong(principal.getName()); + TeamInvitationEmailsGetResponse response = teamInvitationService.getInvitations(memberId, teamId); + return SuccessResponse.success(GET_TEAM_INVITATIONS.getMessage(), response); + } + + @ResponseStatus(HttpStatus.OK) + @DeleteMapping("/team/{teamId}") + public SuccessResponse deleteTeamInvitationFromAdmin( + final Principal principal, + @RequestParam final long invitationId, + @PathVariable final long teamId + ) { + long memberId = Long.parseLong(principal.getName()); + teamInvitationService.deleteTeamInvitationFromAdmin(memberId, teamId, invitationId); + return SuccessResponse.success(DELETE_TEAM_INVITATION_FROM_ADMIN.getMessage()); + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping + public SuccessResponse getInvitationInform( + @RequestParam final long invitationId + ) { + TeamInvitationInformGetResponse response = teamInvitationService.getInvitationInform(invitationId); + return SuccessResponse.success(GET_TEAM_INVITATION_INFORM.getMessage(), response); + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/team-member") + public SuccessResponse createTeamMemberFromInvitation( + Principal principal, + @RequestParam final long teamId, + @RequestParam final long teamInvitationId + ) { + long memberId = Long.parseLong(principal.getName()); + teamInvitationService.createTeamMemberFromInvitation(memberId, teamId, teamInvitationId); + return SuccessResponse.success(CREATE_TEAM_MEMBER_FROM_INVITATION.getMessage()); + } + + @ResponseStatus(HttpStatus.OK) + @DeleteMapping + public SuccessResponse deleteTeamInvitationFromUser( + Principal principal, + @RequestParam final long invitationId + ) { + long memberId = Long.parseLong(principal.getName()); + teamInvitationService.deleteTeamInvitation(memberId, invitationId); + return SuccessResponse.success(DELETE_TEAM_INVITATION_FROM_USER.getMessage()); + } +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/entity/TeamInvitation.java b/src/main/java/com/tiki/server/email/teaminvitation/entity/TeamInvitation.java new file mode 100644 index 00000000..68187b59 --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/entity/TeamInvitation.java @@ -0,0 +1,47 @@ +package com.tiki.server.email.teaminvitation.entity; + +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import com.tiki.server.common.entity.BaseTime; +import com.tiki.server.email.Email; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Entity +@Builder +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor(access = PRIVATE) +public class TeamInvitation extends BaseTime { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "invitation_id") + private Long id; + + private String sender; + + private long teamId; + + private Email email; + + private LocalDate expiredDate; + + public static TeamInvitation of(final String sender, final long teamId, final Email email) { + return TeamInvitation.builder().sender(sender).teamId(teamId).email(email).expiredDate(LocalDate.now()).build(); + } + + public String getEmailToString(){ + return email.getEmail(); + } +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/exception/TeamInvitationException.java b/src/main/java/com/tiki/server/email/teaminvitation/exception/TeamInvitationException.java new file mode 100644 index 00000000..25117f71 --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/exception/TeamInvitationException.java @@ -0,0 +1,15 @@ +package com.tiki.server.email.teaminvitation.exception; + +import com.tiki.server.email.teaminvitation.messages.ErrorCode; +import lombok.Getter; + +@Getter +public class TeamInvitationException extends RuntimeException { + + private final ErrorCode errorCode; + + public TeamInvitationException(ErrorCode errorCode) { + super("[TeamInvitationException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/messages/ErrorCode.java b/src/main/java/com/tiki/server/email/teaminvitation/messages/ErrorCode.java new file mode 100644 index 00000000..fbc641fc --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/messages/ErrorCode.java @@ -0,0 +1,20 @@ +package com.tiki.server.email.teaminvitation.messages; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + ALREADY_INVITED_MEMBER(BAD_REQUEST, "이미 존재하는 팀원입니다."), + NOT_MATCHED_MEMBER_INFORM(BAD_REQUEST, "일치하지 않은 초대정보 입니다."), + INVALID_TEAM_INVITATION(NOT_FOUND, "존재하지 않거나 만료된 초대정보입니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/messages/SuccessMessage.java b/src/main/java/com/tiki/server/email/teaminvitation/messages/SuccessMessage.java new file mode 100644 index 00000000..6e08b6f0 --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/messages/SuccessMessage.java @@ -0,0 +1,17 @@ +package com.tiki.server.email.teaminvitation.messages; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + CREATE_TEAM_MEMBER_FROM_INVITATION("팀 가입 성공"), + DELETE_TEAM_INVITATION_FROM_ADMIN("초대 취소 성공"), + DELETE_TEAM_INVITATION_FROM_USER("초대 거부 성공"), + GET_TEAM_INVITATIONS("팀 초대 목록 불러오기 성공"), + GET_TEAM_INVITATION_INFORM("초대정보를 불러오기 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/repository/TeamInvitationRepository.java b/src/main/java/com/tiki/server/email/teaminvitation/repository/TeamInvitationRepository.java new file mode 100644 index 00000000..e49ac1fb --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/repository/TeamInvitationRepository.java @@ -0,0 +1,14 @@ +package com.tiki.server.email.teaminvitation.repository; + +import com.tiki.server.email.teaminvitation.entity.TeamInvitation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface TeamInvitationRepository extends JpaRepository { + + List findByExpiredDateBefore(final LocalDate expiredDate); + + List findAllByTeamId(final long teamId); +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/scheduler/TeamInvitationScheduler.java b/src/main/java/com/tiki/server/email/teaminvitation/scheduler/TeamInvitationScheduler.java new file mode 100644 index 00000000..18bce060 --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/scheduler/TeamInvitationScheduler.java @@ -0,0 +1,26 @@ +package com.tiki.server.email.teaminvitation.scheduler; + +import com.tiki.server.email.teaminvitation.adapter.TeamInvitationDeleter; +import com.tiki.server.email.teaminvitation.adapter.TeamInvitationFinder; +import com.tiki.server.email.teaminvitation.entity.TeamInvitation; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +public class TeamInvitationScheduler { + + private final TeamInvitationDeleter teamInvitationDeleter; + private final TeamInvitationFinder teamInvitationFinder; + + @Scheduled(cron = "0 0 0 * * ?") + public void cleanUpExpiredInvites() { + LocalDate now = LocalDate.now(); + List expiredInvites = teamInvitationFinder.findByExpiredDate(now); + if (!expiredInvites.isEmpty()) { + teamInvitationDeleter.deleteAll(expiredInvites); + } + } +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/service/TeamInvitationService.java b/src/main/java/com/tiki/server/email/teaminvitation/service/TeamInvitationService.java new file mode 100644 index 00000000..1255b74f --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/service/TeamInvitationService.java @@ -0,0 +1,87 @@ +package com.tiki.server.email.teaminvitation.service; + +import com.tiki.server.common.entity.Position; +import com.tiki.server.email.teaminvitation.exception.TeamInvitationException; +import com.tiki.server.email.teaminvitation.adapter.TeamInvitationDeleter; +import com.tiki.server.email.teaminvitation.adapter.TeamInvitationFinder; +import com.tiki.server.email.teaminvitation.entity.TeamInvitation; +import com.tiki.server.email.teaminvitation.service.dto.TeamInvitationEmailsGetResponse; +import com.tiki.server.email.teaminvitation.service.dto.TeamInvitationInformGetResponse; +import com.tiki.server.member.adapter.MemberFinder; +import com.tiki.server.member.entity.Member; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerSaver; +import com.tiki.server.memberteammanager.entity.MemberTeamManager; +import com.tiki.server.team.adapter.TeamFinder; +import com.tiki.server.team.entity.Team; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import org.springframework.transaction.annotation.Transactional; + +import static com.tiki.server.email.teaminvitation.messages.ErrorCode.ALREADY_INVITED_MEMBER; +import static com.tiki.server.email.teaminvitation.messages.ErrorCode.NOT_MATCHED_MEMBER_INFORM; + +@Service +@RequiredArgsConstructor +public class TeamInvitationService { + + private final TeamInvitationDeleter teamInvitationDeleter; + private final TeamInvitationFinder teamInvitationFinder; + private final MemberTeamManagerFinder memberTeamManagerFinder; + private final MemberTeamManagerSaver memberTeamManagerSaver; + private final TeamFinder teamFinder; + private final MemberFinder memberFinder; + + public TeamInvitationInformGetResponse getInvitationInform(final long invitationId) { + TeamInvitation invitation = teamInvitationFinder.findByInvitationId(invitationId); + Team team = teamFinder.findById(invitation.getTeamId()); + return TeamInvitationInformGetResponse.of(invitation, team); + } + + @Transactional + public void createTeamMemberFromInvitation(final long memberId, final long teamId, final long invitationId) { + checkIsPresentTeamMember(memberId, teamId); + Member member = memberFinder.findById(memberId); + Team team = teamFinder.findById(teamId); + TeamInvitation invitation = teamInvitationFinder.findByInvitationId(invitationId); + checkMemberMatched(invitation, member); + memberTeamManagerSaver.save(MemberTeamManager.of(member, team, Position.EXECUTIVE)); + teamInvitationDeleter.deleteTeamInvitation(invitation); + } + + public void deleteTeamInvitationFromAdmin(final long memberId, final long teamId, final long invitationId) { + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + memberTeamManager.checkMemberAccessible(Position.ADMIN); + TeamInvitation teamInvitation = teamInvitationFinder.findByInvitationId(invitationId); + teamInvitationDeleter.deleteTeamInvitation(teamInvitation); + } + + public void deleteTeamInvitation(final long memberId, final long invitationId) { + TeamInvitation invitation = teamInvitationFinder.findByInvitationId(invitationId); + Member member = memberFinder.findById(memberId); + checkMemberMatched(invitation, member); + teamInvitationDeleter.deleteTeamInvitation(invitation); + } + + @Transactional(readOnly = true) + public TeamInvitationEmailsGetResponse getInvitations(final long memberId, final long teamId) { + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + memberTeamManager.checkMemberAccessible(Position.ADMIN); + List teamInvitations = teamInvitationFinder.findAllByTeamId(teamId); + return TeamInvitationEmailsGetResponse.from(teamInvitations); + } + + private void checkMemberMatched(TeamInvitation teamInvitation, Member member) { + if (!teamInvitation.getEmail().equals(member.getEmail())) { + throw new TeamInvitationException(NOT_MATCHED_MEMBER_INFORM); + } + } + + private void checkIsPresentTeamMember(long memberId, long teamId) { + if (memberTeamManagerFinder.checkIsPresent(memberId, teamId)) { + throw new TeamInvitationException(ALREADY_INVITED_MEMBER); + } + } +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationEmailGetResponse.java b/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationEmailGetResponse.java new file mode 100644 index 00000000..10a9eb45 --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationEmailGetResponse.java @@ -0,0 +1,13 @@ +package com.tiki.server.email.teaminvitation.service.dto; + +import com.tiki.server.email.Email; +import com.tiki.server.email.teaminvitation.entity.TeamInvitation; +import jakarta.validation.constraints.NotNull; + +public record TeamInvitationEmailGetResponse( + @NotNull String email +) { + public static TeamInvitationEmailGetResponse from(final TeamInvitation teamInvitation) { + return new TeamInvitationEmailGetResponse(teamInvitation.getEmailToString()); + } +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationEmailsGetResponse.java b/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationEmailsGetResponse.java new file mode 100644 index 00000000..7852449d --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationEmailsGetResponse.java @@ -0,0 +1,17 @@ +package com.tiki.server.email.teaminvitation.service.dto; + +import com.tiki.server.email.teaminvitation.entity.TeamInvitation; + +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record TeamInvitationEmailsGetResponse( + @NotNull List teamInvitationEmailGetResponses +) { + public static TeamInvitationEmailsGetResponse from(final List teamInvitations) { + return new TeamInvitationEmailsGetResponse( + teamInvitations.stream() + .map(TeamInvitationEmailGetResponse::from) + .toList()); + } +} diff --git a/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationInformGetResponse.java b/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationInformGetResponse.java new file mode 100644 index 00000000..efb38bc3 --- /dev/null +++ b/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationInformGetResponse.java @@ -0,0 +1,25 @@ +package com.tiki.server.email.teaminvitation.service.dto; + +import com.tiki.server.email.teaminvitation.entity.TeamInvitation; +import com.tiki.server.team.entity.Team; +import jakarta.validation.constraints.NotNull; + +public record TeamInvitationInformGetResponse( + @NotNull String sender, + @NotNull String teamName, + @NotNull String teamIconUrl, + @NotNull long teamId +) { + + public static TeamInvitationInformGetResponse of( + final TeamInvitation invitation, + final Team team + ) { + return new TeamInvitationInformGetResponse( + invitation.getSender(), + team.getName(), + team.getIconImageUrl(), + team.getId() + ); + } +} diff --git a/src/main/java/com/tiki/server/email/verification/adapter/EmailVerificationFinder.java b/src/main/java/com/tiki/server/email/verification/adapter/EmailVerificationFinder.java new file mode 100644 index 00000000..19443e30 --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/adapter/EmailVerificationFinder.java @@ -0,0 +1,20 @@ +package com.tiki.server.email.verification.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.email.verification.domain.EmailVerification; +import com.tiki.server.email.verification.exception.EmailVerificationException; +import com.tiki.server.email.verification.repository.EmailVerificationRepository; +import lombok.RequiredArgsConstructor; + +import static com.tiki.server.email.verification.message.ErrorCode.INVALID_REQUEST; + +@RepositoryAdapter +@RequiredArgsConstructor +public class EmailVerificationFinder { + + private final EmailVerificationRepository mailRepository; + + public EmailVerification findById(final String email) { + return mailRepository.findById(email).orElseThrow(() -> new EmailVerificationException(INVALID_REQUEST)); + } +} diff --git a/src/main/java/com/tiki/server/email/verification/adapter/EmailVerificationSaver.java b/src/main/java/com/tiki/server/email/verification/adapter/EmailVerificationSaver.java new file mode 100644 index 00000000..185297e1 --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/adapter/EmailVerificationSaver.java @@ -0,0 +1,17 @@ +package com.tiki.server.email.verification.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.email.verification.domain.EmailVerification; +import com.tiki.server.email.verification.repository.EmailVerificationRepository; +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class EmailVerificationSaver { + + private final EmailVerificationRepository mailRepository; + + public void save(final EmailVerification mail) { + mailRepository.save(mail); + } +} diff --git a/src/main/java/com/tiki/server/email/verification/constants/Constants.java b/src/main/java/com/tiki/server/email/verification/constants/Constants.java new file mode 100644 index 00000000..c22e3ca8 --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/constants/Constants.java @@ -0,0 +1,7 @@ +package com.tiki.server.email.verification.constants; + +public class Constants { + + public static final int CODE_LENGTH = 6; + public static final int CODE_NUM_MAX_VALUE_PER_WORD = 10; +} diff --git a/src/main/java/com/tiki/server/email/verification/controller/EmailVerificationController.java b/src/main/java/com/tiki/server/email/verification/controller/EmailVerificationController.java new file mode 100644 index 00000000..86712204 --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/controller/EmailVerificationController.java @@ -0,0 +1,29 @@ +package com.tiki.server.email.verification.controller; + +import static com.tiki.server.email.verification.message.SuccessMessage.SUCCESS_VALIDATION; + +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.email.verification.dto.request.CodeVerificationRequest; +import com.tiki.server.email.verification.service.EmailVerificationService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/email/verification") +public class EmailVerificationController { + + private final EmailVerificationService emailVerificationService; + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/checking") + public SuccessResponse checkCode(@RequestBody CodeVerificationRequest codeVerificationRequest) { + emailVerificationService.checkCode(codeVerificationRequest); + return SuccessResponse.success(SUCCESS_VALIDATION.getMessage()); + } +} diff --git a/src/main/java/com/tiki/server/email/verification/controller/docs/EmailSenderControllerDocs.java b/src/main/java/com/tiki/server/email/verification/controller/docs/EmailSenderControllerDocs.java new file mode 100644 index 00000000..a4b14b4e --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/controller/docs/EmailSenderControllerDocs.java @@ -0,0 +1,44 @@ +package com.tiki.server.email.verification.controller.docs; + +import com.tiki.server.common.dto.BaseResponse; +import com.tiki.server.common.dto.ErrorResponse; +import com.tiki.server.email.verification.dto.request.CodeVerificationRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "EmailVerification", description = "메일 인증 API") +public interface EmailSenderControllerDocs { + + @Operation( + summary = "메일 인증", + description = "인증번호 확인", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "400", + description = "이메일 형식 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "403", + description = "인증 값 불일치", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "인증 정보가 존재하지 않음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + ResponseEntity checkCode(@RequestBody CodeVerificationRequest verificationCodeRequest); +} diff --git a/src/main/java/com/tiki/server/email/verification/domain/EmailVerification.java b/src/main/java/com/tiki/server/email/verification/domain/EmailVerification.java new file mode 100644 index 00000000..b448b9d5 --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/domain/EmailVerification.java @@ -0,0 +1,36 @@ +package com.tiki.server.email.verification.domain; + +import com.tiki.server.email.Email; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.redis.core.RedisHash; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PRIVATE; + +@Getter +@AllArgsConstructor(access = PRIVATE) +@Builder +@RedisHash(value = "mailVerification", timeToLive = 180) +public class EmailVerification { + + @Id + @GeneratedValue(strategy = IDENTITY) + private String id; + + private VerificationCode verificationCode; + + public static EmailVerification of(Email email) { + return EmailVerification.builder() + .id(email.getEmail()) + .verificationCode(VerificationCode.from()) + .build(); + } + + public void verify(String code) { + this.verificationCode.verify(code); + } +} diff --git a/src/main/java/com/tiki/server/email/verification/domain/VerificationCode.java b/src/main/java/com/tiki/server/email/verification/domain/VerificationCode.java new file mode 100644 index 00000000..a1cb8891 --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/domain/VerificationCode.java @@ -0,0 +1,44 @@ +package com.tiki.server.email.verification.domain; + +import static com.tiki.server.common.constants.Constants.INIT_NUM; +import static com.tiki.server.email.verification.constants.Constants.CODE_LENGTH; +import static com.tiki.server.email.verification.constants.Constants.CODE_NUM_MAX_VALUE_PER_WORD; + +import com.tiki.server.email.verification.exception.EmailVerificationException; +import com.tiki.server.email.verification.message.ErrorCode; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.Random; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class VerificationCode { + + @Column(nullable = false) + private String code; + + public static VerificationCode from() { + return new VerificationCode(generateRandomValue()); + } + + protected void verify(String code) { + if (!this.code.equals(code)) { + throw new EmailVerificationException(ErrorCode.INVALID_MATCHED); + } + } + + private static String generateRandomValue() { + Random random = new Random(); + return IntStream.range(INIT_NUM, CODE_LENGTH) + .mapToObj(i -> String.valueOf(random.nextInt(CODE_NUM_MAX_VALUE_PER_WORD))).collect( + Collectors.joining()); + } +} diff --git a/src/main/java/com/tiki/server/email/verification/dto/request/CodeVerificationRequest.java b/src/main/java/com/tiki/server/email/verification/dto/request/CodeVerificationRequest.java new file mode 100644 index 00000000..a09b2050 --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/dto/request/CodeVerificationRequest.java @@ -0,0 +1,9 @@ +package com.tiki.server.email.verification.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record CodeVerificationRequest( + @NotNull String email, + @NotNull String code +) { +} diff --git a/src/main/java/com/tiki/server/email/verification/exception/EmailVerificationException.java b/src/main/java/com/tiki/server/email/verification/exception/EmailVerificationException.java new file mode 100644 index 00000000..b84c49a2 --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/exception/EmailVerificationException.java @@ -0,0 +1,15 @@ +package com.tiki.server.email.verification.exception; + +import com.tiki.server.email.verification.message.ErrorCode; +import lombok.Getter; + +@Getter +public class EmailVerificationException extends RuntimeException { + + private final ErrorCode errorCode; + + public EmailVerificationException(final ErrorCode errorCode) { + super("[EmailVerificationException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/tiki/server/email/verification/message/ErrorCode.java b/src/main/java/com/tiki/server/email/verification/message/ErrorCode.java new file mode 100644 index 00000000..7a720cfb --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/message/ErrorCode.java @@ -0,0 +1,21 @@ +package com.tiki.server.email.verification.message; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import static org.springframework.http.HttpStatus.*; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + /* 403 FORBIDDEN : 권한 없음 */ + INVALID_MATCHED(FORBIDDEN, "인증 정보가 일치하지 않습니다."), + + /* 404 NOT_FOUND : 자원을 찾을 수 없음 */ + INVALID_REQUEST(NOT_FOUND, "인증 정보가 존재하지 않습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/tiki/server/email/verification/message/SuccessMessage.java b/src/main/java/com/tiki/server/email/verification/message/SuccessMessage.java new file mode 100644 index 00000000..eddfa8d5 --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/message/SuccessMessage.java @@ -0,0 +1,13 @@ +package com.tiki.server.email.verification.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + SUCCESS_VALIDATION("인증 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/email/verification/repository/EmailVerificationRepository.java b/src/main/java/com/tiki/server/email/verification/repository/EmailVerificationRepository.java new file mode 100644 index 00000000..8d3cae9c --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/repository/EmailVerificationRepository.java @@ -0,0 +1,13 @@ +package com.tiki.server.email.verification.repository; + +import com.tiki.server.email.verification.domain.EmailVerification; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface EmailVerificationRepository extends CrudRepository { + + Optional findById(final String email); +} diff --git a/src/main/java/com/tiki/server/email/verification/service/EmailVerificationService.java b/src/main/java/com/tiki/server/email/verification/service/EmailVerificationService.java new file mode 100644 index 00000000..ed89bffa --- /dev/null +++ b/src/main/java/com/tiki/server/email/verification/service/EmailVerificationService.java @@ -0,0 +1,25 @@ +package com.tiki.server.email.verification.service; + +import com.tiki.server.email.verification.adapter.EmailVerificationFinder; +import com.tiki.server.email.verification.domain.EmailVerification; +import com.tiki.server.email.verification.dto.request.CodeVerificationRequest; +import com.tiki.server.email.Email; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EmailVerificationService { + + private final EmailVerificationFinder emailVerificationFinder; + + public void checkCode(CodeVerificationRequest codeVerificationRequest) { + Email email = Email.from(codeVerificationRequest.email()); + EmailVerification emailVerification = emailVerificationFinder.findById(email.getEmail()); + emailVerification.verify(codeVerificationRequest.code()); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/external/config/AWSConfig.java b/src/main/java/com/tiki/server/external/config/AWSConfig.java new file mode 100644 index 00000000..c68382da --- /dev/null +++ b/src/main/java/com/tiki/server/external/config/AWSConfig.java @@ -0,0 +1,55 @@ +package com.tiki.server.external.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.RequiredArgsConstructor; +import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +@RequiredArgsConstructor +public class AWSConfig { + + private static final String AWS_ACCESS_KEY_ID = "aws.accessKeyId"; + private static final String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey"; + + @Value("${aws-property.access-key}") + private String accessKey; + + @Value("${aws-property.secret-key}") + private String secretKey; + + @Value("${aws-property.aws-region}") + private String region; + + @Bean + public SystemPropertyCredentialsProvider systemPropertyCredentialsProvider() { + System.setProperty(AWS_ACCESS_KEY_ID, accessKey); + System.setProperty(AWS_SECRET_ACCESS_KEY, secretKey); + return SystemPropertyCredentialsProvider.create(); + } + + @Bean + public S3Client getS3Client() { + return S3Client.builder() + .region(getRegion()) + .credentialsProvider(systemPropertyCredentialsProvider()) + .build(); + } + + @Bean + public S3Presigner getS3PreSigner() { + return S3Presigner.builder() + .region(getRegion()) + .credentialsProvider(systemPropertyCredentialsProvider()) + .build(); + } + + private Region getRegion() { + return Region.of(region); + } +} diff --git a/src/main/java/com/tiki/server/external/constant/ExternalConstant.java b/src/main/java/com/tiki/server/external/constant/ExternalConstant.java new file mode 100644 index 00000000..b8a74733 --- /dev/null +++ b/src/main/java/com/tiki/server/external/constant/ExternalConstant.java @@ -0,0 +1,8 @@ +package com.tiki.server.external.constant; + +public class ExternalConstant { + + public static final Long PRE_SIGNED_URL_EXPIRE_MINUTE = 10L; + public static final String FILE_SAVE_PREFIX = "file/"; + public static final String FILE_DELIMITER = "."; +} diff --git a/src/main/java/com/tiki/server/external/controller/FileHandlerController.java b/src/main/java/com/tiki/server/external/controller/FileHandlerController.java new file mode 100644 index 00000000..78281252 --- /dev/null +++ b/src/main/java/com/tiki/server/external/controller/FileHandlerController.java @@ -0,0 +1,47 @@ +package com.tiki.server.external.controller; + +import static com.tiki.server.external.message.SuccessMessage.PRESIGNED_URL_GET_SUCCESS; +import static com.tiki.server.external.message.SuccessMessage.S3_FILE_DELETE_SUCCESS; + +import com.tiki.server.external.service.FileHandlerService; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.external.controller.docs.FileHandlerControllerDocs; +import com.tiki.server.external.dto.request.S3DeleteRequest; +import com.tiki.server.external.dto.response.PreSignedUrlResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/file") +public class FileHandlerController implements FileHandlerControllerDocs { + + private final FileHandlerService fileHandlerService; + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/upload") + public SuccessResponse getPreSignedUrl( + @RequestParam final String fileFormat) { + PreSignedUrlResponse response = fileHandlerService.getUploadPreSignedUrl(fileFormat); + return SuccessResponse.success(PRESIGNED_URL_GET_SUCCESS.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @PostMapping + public SuccessResponse deleteFile(@RequestBody final S3DeleteRequest request) { + fileHandlerService.deleteFile(request); + return SuccessResponse.success(S3_FILE_DELETE_SUCCESS.getMessage()); + } +} diff --git a/src/main/java/com/tiki/server/external/controller/docs/FileHandlerControllerDocs.java b/src/main/java/com/tiki/server/external/controller/docs/FileHandlerControllerDocs.java new file mode 100644 index 00000000..c6c1e387 --- /dev/null +++ b/src/main/java/com/tiki/server/external/controller/docs/FileHandlerControllerDocs.java @@ -0,0 +1,62 @@ +package com.tiki.server.external.controller.docs; + +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import com.tiki.server.common.dto.ErrorResponse; +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.external.dto.request.S3DeleteRequest; +import com.tiki.server.external.dto.response.PreSignedUrlResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "S3", description = "AWS S3 API") +public interface FileHandlerControllerDocs { + + @Operation( + summary = "Presigned Url 생성", + description = "s3로부터 Presigned Url을 생성한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "S3 PRESIGNED URL 불러오기 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getPreSignedUrl( + @Parameter( + name = "fileFormat", + description = "파일 형식", + in = ParameterIn.QUERY, + example = "hwp, pdf, ..." + ) @RequestParam final String fileFormat + ); + + @Operation( + summary = "s3 파일 삭제", + description = "s3의 파일 삭제한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "S3 버킷의 파일 삭제 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse deleteFile( + @RequestBody final S3DeleteRequest request + ); +} diff --git a/src/main/java/com/tiki/server/external/dto/request/PreSignedUrlRequest.java b/src/main/java/com/tiki/server/external/dto/request/PreSignedUrlRequest.java new file mode 100644 index 00000000..cde7684c --- /dev/null +++ b/src/main/java/com/tiki/server/external/dto/request/PreSignedUrlRequest.java @@ -0,0 +1,8 @@ +package com.tiki.server.external.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record PreSignedUrlRequest( + @NotNull String fileFormat +) { +} diff --git a/src/main/java/com/tiki/server/external/dto/request/S3DeleteRequest.java b/src/main/java/com/tiki/server/external/dto/request/S3DeleteRequest.java new file mode 100644 index 00000000..72fcf0bc --- /dev/null +++ b/src/main/java/com/tiki/server/external/dto/request/S3DeleteRequest.java @@ -0,0 +1,8 @@ +package com.tiki.server.external.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record S3DeleteRequest( + @NotNull String fileKey +) { +} diff --git a/src/main/java/com/tiki/server/external/dto/response/PreSignedUrlResponse.java b/src/main/java/com/tiki/server/external/dto/response/PreSignedUrlResponse.java new file mode 100644 index 00000000..8cc6c9ef --- /dev/null +++ b/src/main/java/com/tiki/server/external/dto/response/PreSignedUrlResponse.java @@ -0,0 +1,20 @@ +package com.tiki.server.external.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record PreSignedUrlResponse( + @NotNull String fileName, + @NotNull String url +) { + + public static PreSignedUrlResponse of(final String fileName, final String url) { + return PreSignedUrlResponse.builder() + .fileName(fileName) + .url(url) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/external/exception/ExternalException.java b/src/main/java/com/tiki/server/external/exception/ExternalException.java new file mode 100644 index 00000000..72fb7fcc --- /dev/null +++ b/src/main/java/com/tiki/server/external/exception/ExternalException.java @@ -0,0 +1,16 @@ +package com.tiki.server.external.exception; + +import com.tiki.server.external.message.ErrorCode; + +import lombok.Getter; + +@Getter +public class ExternalException extends RuntimeException { + + private final ErrorCode errorCode; + + public ExternalException(final ErrorCode errorCode) { + super("[ExternalException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/tiki/server/external/message/ErrorCode.java b/src/main/java/com/tiki/server/external/message/ErrorCode.java new file mode 100644 index 00000000..6dd0199f --- /dev/null +++ b/src/main/java/com/tiki/server/external/message/ErrorCode.java @@ -0,0 +1,20 @@ +package com.tiki.server.external.message; + +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + /* 500 INTERNAL_SERVER_ERROR : 서버 내부 오류 발생 */ + PRESIGNED_URL_GET_ERROR(INTERNAL_SERVER_ERROR, "S3 PRESIGNED URL 불러오기에 실패했습니다."), + FILE_DELETE_ERROR(INTERNAL_SERVER_ERROR, "S3 버킷의 파일 삭제에 실패했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/tiki/server/external/message/SuccessMessage.java b/src/main/java/com/tiki/server/external/message/SuccessMessage.java new file mode 100644 index 00000000..42828b46 --- /dev/null +++ b/src/main/java/com/tiki/server/external/message/SuccessMessage.java @@ -0,0 +1,14 @@ +package com.tiki.server.external.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + PRESIGNED_URL_GET_SUCCESS("S3 PRESIGNED URL 불러오기 성공"), + S3_FILE_DELETE_SUCCESS("S3 파일 삭제 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/external/service/FileHandlerService.java b/src/main/java/com/tiki/server/external/service/FileHandlerService.java new file mode 100644 index 00000000..ab227dbc --- /dev/null +++ b/src/main/java/com/tiki/server/external/service/FileHandlerService.java @@ -0,0 +1,22 @@ +package com.tiki.server.external.service; + +import com.tiki.server.external.dto.request.S3DeleteRequest; +import com.tiki.server.external.dto.response.PreSignedUrlResponse; +import com.tiki.server.external.util.AwsHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FileHandlerService { + + private final AwsHandler awsHandler; + + public PreSignedUrlResponse getUploadPreSignedUrl(final String fileFormat) { + return awsHandler.getUploadPreSignedUrl(fileFormat); + } + + public void deleteFile(final S3DeleteRequest request) { + awsHandler.deleteFile(request.fileKey()); + } +} diff --git a/src/main/java/com/tiki/server/external/util/AwsHandler.java b/src/main/java/com/tiki/server/external/util/AwsHandler.java new file mode 100644 index 00000000..8033fc30 --- /dev/null +++ b/src/main/java/com/tiki/server/external/util/AwsHandler.java @@ -0,0 +1,78 @@ +package com.tiki.server.external.util; + +import static com.tiki.server.external.constant.ExternalConstant.FILE_SAVE_PREFIX; +import static com.tiki.server.external.constant.ExternalConstant.PRE_SIGNED_URL_EXPIRE_MINUTE; +import static com.tiki.server.external.message.ErrorCode.*; +import static com.tiki.server.external.constant.ExternalConstant.FILE_DELIMITER; + +import java.time.Duration; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.tiki.server.external.config.AWSConfig; +import com.tiki.server.external.dto.response.PreSignedUrlResponse; +import com.tiki.server.external.exception.ExternalException; + +import lombok.RequiredArgsConstructor; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +@Component +@RequiredArgsConstructor +public class AwsHandler { + + private final AWSConfig awsConfig; + + @Value("${aws-property.bucket}") + private String bucket; + + public PreSignedUrlResponse getUploadPreSignedUrl(final String fileFormat) { + try { + String fileName = generateFileName(fileFormat); + String key = FILE_SAVE_PREFIX + fileName; + S3Presigner preSigner = awsConfig.getS3PreSigner(); + PutObjectRequest putObjectRequest = createPutObjectRequest(key); + PutObjectPresignRequest putObjectPresignRequest = createPutObjectPresignRequest(putObjectRequest); + String url = preSigner.presignPutObject(putObjectPresignRequest).url().toString(); + return PreSignedUrlResponse.of(fileName, url); + } catch (RuntimeException e) { + throw new ExternalException(PRESIGNED_URL_GET_ERROR); + } + } + + public void deleteFile(final String request) { + try { + S3Client s3Client = awsConfig.getS3Client(); + s3Client.deleteObject((DeleteObjectRequest.Builder builder) -> + builder.bucket(bucket) + .key(request) + .build() + ); + } catch (RuntimeException e) { + throw new ExternalException(FILE_DELETE_ERROR); + } + } + + private PutObjectRequest createPutObjectRequest(final String key) { + return PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + } + + private PutObjectPresignRequest createPutObjectPresignRequest(final PutObjectRequest putObjectRequest) { + return PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(PRE_SIGNED_URL_EXPIRE_MINUTE)) + .putObjectRequest(putObjectRequest) + .build(); + } + + private String generateFileName(final String fileFormat) { + return UUID.randomUUID() + FILE_DELIMITER + fileFormat; + } +} diff --git a/src/main/java/com/tiki/server/folder/adapter/FolderDeleter.java b/src/main/java/com/tiki/server/folder/adapter/FolderDeleter.java new file mode 100644 index 00000000..aecb7c68 --- /dev/null +++ b/src/main/java/com/tiki/server/folder/adapter/FolderDeleter.java @@ -0,0 +1,20 @@ +package com.tiki.server.folder.adapter; + +import java.util.List; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.folder.entity.Folder; +import com.tiki.server.folder.repository.FolderRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class FolderDeleter { + + private final FolderRepository folderRepository; + + public void deleteAll(final List folders) { + folderRepository.deleteAll(folders); + } +} diff --git a/src/main/java/com/tiki/server/folder/adapter/FolderFinder.java b/src/main/java/com/tiki/server/folder/adapter/FolderFinder.java new file mode 100644 index 00000000..5322af1c --- /dev/null +++ b/src/main/java/com/tiki/server/folder/adapter/FolderFinder.java @@ -0,0 +1,47 @@ +package com.tiki.server.folder.adapter; + +import static com.tiki.server.folder.constant.Constant.ROOT_PATH; +import static com.tiki.server.folder.message.ErrorCode.INVALID_FOLDER; + +import java.util.List; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.folder.entity.Folder; +import com.tiki.server.folder.exception.FolderException; +import com.tiki.server.folder.repository.FolderRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class FolderFinder { + + private final FolderRepository folderRepository; + + public Folder findById(final long id) { + return folderRepository.findById(id) + .orElseThrow(() -> new FolderException(INVALID_FOLDER)); + } + + public List findByTeamIdAndPath(final long teamId, final String path) { + if (path.equals(ROOT_PATH)) { + return folderRepository.findAllByTeamIdAndPathOrderByCreatedAtDesc(teamId, path); + } + return folderRepository.findAllByPathOrderByCreatedAtDesc(path); + } + + public List findAllById(final List folderIds, final long teamId) { + return folderIds.stream() + .map(id -> findByIdAndTeamId(id, teamId)) + .toList(); + } + + public List findAllStartWithPath(final String path) { + return folderRepository.findAllByPathStartsWith(path); + } + + private Folder findByIdAndTeamId(final long id, final long teamId) { + return folderRepository.findByIdAndTeamId(id, teamId) + .orElseThrow(() -> new FolderException(INVALID_FOLDER)); + } +} diff --git a/src/main/java/com/tiki/server/folder/adapter/FolderSaver.java b/src/main/java/com/tiki/server/folder/adapter/FolderSaver.java new file mode 100644 index 00000000..f2a6b28b --- /dev/null +++ b/src/main/java/com/tiki/server/folder/adapter/FolderSaver.java @@ -0,0 +1,18 @@ +package com.tiki.server.folder.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.folder.entity.Folder; +import com.tiki.server.folder.repository.FolderRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class FolderSaver { + + private final FolderRepository folderRepository; + + public Folder save(final Folder folder) { + return folderRepository.save(folder); + } +} diff --git a/src/main/java/com/tiki/server/folder/constant/Constant.java b/src/main/java/com/tiki/server/folder/constant/Constant.java new file mode 100644 index 00000000..e3f536e5 --- /dev/null +++ b/src/main/java/com/tiki/server/folder/constant/Constant.java @@ -0,0 +1,7 @@ +package com.tiki.server.folder.constant; + +public class Constant { + + public static final String ROOT_PATH = ""; + public static final String SEPARATOR = "/"; +} diff --git a/src/main/java/com/tiki/server/folder/controller/FolderController.java b/src/main/java/com/tiki/server/folder/controller/FolderController.java new file mode 100644 index 00000000..1de9f8f9 --- /dev/null +++ b/src/main/java/com/tiki/server/folder/controller/FolderController.java @@ -0,0 +1,91 @@ +package com.tiki.server.folder.controller; + +import static com.tiki.server.folder.message.SuccessMessage.SUCCESS_CREATE_FOLDER; +import static com.tiki.server.folder.message.SuccessMessage.SUCCESS_GET_FOLDERS; +import static com.tiki.server.folder.message.SuccessMessage.SUCCESS_UPDATE_FOLDER_NAME; + +import java.security.Principal; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.folder.controller.docs.FolderControllerDocs; +import com.tiki.server.folder.dto.request.FolderCreateRequest; +import com.tiki.server.folder.dto.request.FolderNameUpdateRequest; +import com.tiki.server.folder.dto.response.FolderCreateResponse; +import com.tiki.server.folder.dto.response.FoldersGetResponse; +import com.tiki.server.folder.service.FolderService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1") +public class FolderController implements FolderControllerDocs { + + private final FolderService folderService; + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/teams/{teamId}/folders") + public SuccessResponse getFolders( + final Principal principal, + @PathVariable final long teamId, + @RequestParam(required = false) final Long folderId + ) { + long memberId = Long.parseLong(principal.getName()); + FoldersGetResponse response = folderService.get(memberId, teamId, folderId); + return SuccessResponse.success(SUCCESS_GET_FOLDERS.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/teams/{teamId}/folders") + public SuccessResponse createFolder( + final Principal principal, + @PathVariable final long teamId, + @RequestParam(required = false) final Long folderId, + @RequestBody final FolderCreateRequest request + ) { + long memberId = Long.parseLong(principal.getName()); + FolderCreateResponse response = folderService.create(memberId, teamId, folderId, request); + return SuccessResponse.success(SUCCESS_CREATE_FOLDER.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @PatchMapping("/teams/{teamId}/folders/{folderId}") + public SuccessResponse updateFolderName( + final Principal principal, + @PathVariable final long teamId, + @PathVariable final long folderId, + @RequestBody final FolderNameUpdateRequest request + ) { + long memberId = Long.parseLong(principal.getName()); + folderService.updateFolderName(memberId, teamId, folderId, request); + return SuccessResponse.success(SUCCESS_UPDATE_FOLDER_NAME.getMessage()); + } + + @Override + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/teams/{teamId}/folders") + public void delete( + final Principal principal, + @PathVariable final long teamId, + @RequestParam("folderId") final List folderIds + ) { + long memberId = Long.parseLong(principal.getName()); + folderService.delete(memberId, teamId, folderIds); + } +} diff --git a/src/main/java/com/tiki/server/folder/controller/docs/FolderControllerDocs.java b/src/main/java/com/tiki/server/folder/controller/docs/FolderControllerDocs.java new file mode 100644 index 00000000..35441cee --- /dev/null +++ b/src/main/java/com/tiki/server/folder/controller/docs/FolderControllerDocs.java @@ -0,0 +1,154 @@ +package com.tiki.server.folder.controller.docs; + +import java.security.Principal; +import java.util.List; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import com.tiki.server.common.dto.ErrorResponse; +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.folder.dto.request.FolderCreateRequest; +import com.tiki.server.folder.dto.request.FolderNameUpdateRequest; +import com.tiki.server.folder.dto.response.FolderCreateResponse; +import com.tiki.server.folder.dto.response.FoldersGetResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "folders", description = "폴더 API") +public interface FolderControllerDocs { + + @Operation( + summary = "폴더 조회", + description = "폴더를 여러 개 조회한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getFolders( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + required = true, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "folderId", + description = "조회할 폴더 id (최상단은 비워두기)", + in = ParameterIn.QUERY, + example = "1" + ) @RequestParam final Long folderId + ); + + @Operation( + summary = "폴더 생성", + description = "폴더를 생성한다.", + responses = { + @ApiResponse(responseCode = "201", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse createFolder( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + required = true, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "folderId", + description = "생성할 폴더가 속할 폴더 id (최상단은 비워두기)", + in = ParameterIn.QUERY, + example = "1" + ) @RequestParam final Long folderId, + @RequestBody final FolderCreateRequest request + ); + + @Operation( + summary = "폴더 이름 수정", + description = "폴더 이름을 수정한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse updateFolderName( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + required = true, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "folderId", + description = "수정할 폴더 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long folderId, + @RequestBody final FolderNameUpdateRequest request + ); + + @Operation( + summary = "폴더 삭제", + description = "폴더를 여러 개 삭제한다.", + responses = { + @ApiResponse(responseCode = "204", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + void delete( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + required = true, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "folderId", + description = "삭제할 폴더 id 리스트", + in = ParameterIn.QUERY, + required = true, + example = "[1, 2]" + ) @RequestParam("folderId") final List folderIds + ); +} diff --git a/src/main/java/com/tiki/server/folder/dto/request/FolderCreateRequest.java b/src/main/java/com/tiki/server/folder/dto/request/FolderCreateRequest.java new file mode 100644 index 00000000..fcc85eb4 --- /dev/null +++ b/src/main/java/com/tiki/server/folder/dto/request/FolderCreateRequest.java @@ -0,0 +1,10 @@ +package com.tiki.server.folder.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record FolderCreateRequest( + @Schema(description = "폴더 이름", example = "폴더 1") + @NotNull String name +) { +} diff --git a/src/main/java/com/tiki/server/folder/dto/request/FolderNameUpdateRequest.java b/src/main/java/com/tiki/server/folder/dto/request/FolderNameUpdateRequest.java new file mode 100644 index 00000000..d4f2575e --- /dev/null +++ b/src/main/java/com/tiki/server/folder/dto/request/FolderNameUpdateRequest.java @@ -0,0 +1,10 @@ +package com.tiki.server.folder.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record FolderNameUpdateRequest( + @Schema(description = "수정할 폴더 이름", example = "수정할 폴더 1") + @NotNull String name +) { +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/folder/dto/response/FolderCreateResponse.java b/src/main/java/com/tiki/server/folder/dto/response/FolderCreateResponse.java new file mode 100644 index 00000000..ea6dcaee --- /dev/null +++ b/src/main/java/com/tiki/server/folder/dto/response/FolderCreateResponse.java @@ -0,0 +1,18 @@ +package com.tiki.server.folder.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record FolderCreateResponse( + @NotNull long folderId +) { + + public static FolderCreateResponse from(final long folderId) { + return FolderCreateResponse.builder() + .folderId(folderId) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/folder/dto/response/FoldersGetResponse.java b/src/main/java/com/tiki/server/folder/dto/response/FoldersGetResponse.java new file mode 100644 index 00000000..30211bcd --- /dev/null +++ b/src/main/java/com/tiki/server/folder/dto/response/FoldersGetResponse.java @@ -0,0 +1,41 @@ +package com.tiki.server.folder.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import java.time.LocalDateTime; +import java.util.List; + +import com.tiki.server.folder.entity.Folder; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record FoldersGetResponse( + @NotNull List folders +) { + + public static FoldersGetResponse from(final List folders) { + return FoldersGetResponse.builder() + .folders(folders.stream().map(FolderInfoGetResponse::from).toList()) + .build(); + } + + @Builder(access = PRIVATE) + private record FolderInfoGetResponse( + @NotNull long id, + @NotNull String name, + @NotNull LocalDateTime createdTime, + @NotNull String path + ) { + + private static FolderInfoGetResponse from(final Folder folder) { + return FolderInfoGetResponse.builder() + .id(folder.getId()) + .name(folder.getName()) + .createdTime(folder.getCreatedAt()) + .path(folder.getPath()) + .build(); + } + } +} diff --git a/src/main/java/com/tiki/server/folder/entity/Folder.java b/src/main/java/com/tiki/server/folder/entity/Folder.java new file mode 100644 index 00000000..68daded7 --- /dev/null +++ b/src/main/java/com/tiki/server/folder/entity/Folder.java @@ -0,0 +1,62 @@ +package com.tiki.server.folder.entity; + +import static com.tiki.server.document.message.ErrorCode.INVALID_AUTHORIZATION; +import static com.tiki.server.folder.constant.Constant.ROOT_PATH; +import static com.tiki.server.folder.constant.Constant.SEPARATOR; +import static jakarta.persistence.GenerationType.IDENTITY; + +import com.tiki.server.common.entity.BaseTime; +import com.tiki.server.document.exception.DocumentException; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class Folder extends BaseTime { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String path; + + @Column(nullable = false) + private long teamId; + + public Folder(final String name, final Folder parentFolder, final long teamId) { + this.name = name; + this.path = generatePath(parentFolder); + this.teamId = teamId; + } + + public void validateTeamId(final long teamId) { + if (this.teamId != teamId) { + throw new DocumentException(INVALID_AUTHORIZATION); + } + } + + public String getChildPath() { + return path + SEPARATOR + id; + } + + public void updateName(final String name) { + this.name = name; + } + + private String generatePath(final Folder parentFolder) { + if (parentFolder == null) { + return ROOT_PATH; + } + return parentFolder.getPath() + SEPARATOR + parentFolder.getId(); + } +} diff --git a/src/main/java/com/tiki/server/folder/exception/FolderException.java b/src/main/java/com/tiki/server/folder/exception/FolderException.java new file mode 100644 index 00000000..908a40e0 --- /dev/null +++ b/src/main/java/com/tiki/server/folder/exception/FolderException.java @@ -0,0 +1,16 @@ +package com.tiki.server.folder.exception; + +import com.tiki.server.folder.message.ErrorCode; + +import lombok.Getter; + +@Getter +public class FolderException extends RuntimeException { + + private final ErrorCode errorCode; + + public FolderException(final ErrorCode errorCode) { + super("[FolderException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/tiki/server/folder/message/ErrorCode.java b/src/main/java/com/tiki/server/folder/message/ErrorCode.java new file mode 100644 index 00000000..57bd9afe --- /dev/null +++ b/src/main/java/com/tiki/server/folder/message/ErrorCode.java @@ -0,0 +1,23 @@ +package com.tiki.server.folder.message; + +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + /* 404 NOT_FOUND : 자원을 찾을 수 없음 */ + INVALID_FOLDER(NOT_FOUND, "유효하지 않은 폴더입니다."), + + /* 409 CONFLICT : 중복된 자원 */ + FOLDER_NAME_DUPLICATE(CONFLICT, "중복된 폴더 이름입니다.");; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/tiki/server/folder/message/SuccessMessage.java b/src/main/java/com/tiki/server/folder/message/SuccessMessage.java new file mode 100644 index 00000000..5697f97c --- /dev/null +++ b/src/main/java/com/tiki/server/folder/message/SuccessMessage.java @@ -0,0 +1,15 @@ +package com.tiki.server.folder.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + SUCCESS_GET_FOLDERS("폴더 목록 조회 성공"), + SUCCESS_CREATE_FOLDER("폴더 생성 성공"), + SUCCESS_UPDATE_FOLDER_NAME("폴더 이름 수정 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/folder/repository/FolderRepository.java b/src/main/java/com/tiki/server/folder/repository/FolderRepository.java new file mode 100644 index 00000000..8b184542 --- /dev/null +++ b/src/main/java/com/tiki/server/folder/repository/FolderRepository.java @@ -0,0 +1,19 @@ +package com.tiki.server.folder.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.tiki.server.folder.entity.Folder; + +public interface FolderRepository extends JpaRepository { + + List findAllByTeamIdAndPathOrderByCreatedAtDesc(final long teamId, final String path); + + List findAllByPathOrderByCreatedAtDesc(final String path); + + Optional findByIdAndTeamId(final long id, final long teamId); + + List findAllByPathStartsWith(final String path); +} diff --git a/src/main/java/com/tiki/server/folder/service/FolderService.java b/src/main/java/com/tiki/server/folder/service/FolderService.java new file mode 100644 index 00000000..bdb1efb2 --- /dev/null +++ b/src/main/java/com/tiki/server/folder/service/FolderService.java @@ -0,0 +1,118 @@ +package com.tiki.server.folder.service; + +import static com.tiki.server.folder.constant.Constant.ROOT_PATH; +import static com.tiki.server.folder.message.ErrorCode.FOLDER_NAME_DUPLICATE; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.tiki.server.document.adapter.DeletedDocumentAdapter; +import com.tiki.server.document.adapter.DocumentDeleter; +import com.tiki.server.document.adapter.DocumentFinder; +import com.tiki.server.document.entity.Document; +import com.tiki.server.folder.adapter.FolderDeleter; +import com.tiki.server.folder.adapter.FolderFinder; +import com.tiki.server.folder.adapter.FolderSaver; +import com.tiki.server.folder.dto.request.FolderCreateRequest; +import com.tiki.server.folder.dto.request.FolderNameUpdateRequest; +import com.tiki.server.folder.dto.response.FolderCreateResponse; +import com.tiki.server.folder.dto.response.FoldersGetResponse; +import com.tiki.server.folder.entity.Folder; +import com.tiki.server.folder.exception.FolderException; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FolderService { + + private final FolderFinder folderFinder; + private final FolderSaver folderSaver; + private final MemberTeamManagerFinder memberTeamManagerFinder; + private final DocumentFinder documentFinder; + private final DocumentDeleter documentDeleter; + private final DeletedDocumentAdapter deletedDocumentAdapter; + private final FolderDeleter folderDeleter; + + public FoldersGetResponse get(final long memberId, final long teamId, + final Long folderId) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + Folder folder = getFolder(teamId, folderId); + String path = getChildFolderPath(folder); + List folders = folderFinder.findByTeamIdAndPath(teamId, path); + return FoldersGetResponse.from(folders); + } + + @Transactional + public FolderCreateResponse create(final long memberId, final long teamId, + final Long folderId, final FolderCreateRequest request) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + Folder parentFolder = getFolder(teamId, folderId); + String path = getChildFolderPath(parentFolder); + validateFolderName(teamId, path, request.name()); + Folder folder = folderSaver.save(new Folder(request.name(), parentFolder, teamId)); + return FolderCreateResponse.from(folder.getId()); + } + + @Transactional + public void updateFolderName(final long memberId, final long teamId, + final long folderId, final FolderNameUpdateRequest request) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + Folder folder = folderFinder.findById(folderId); + folder.validateTeamId(teamId); + validateFolderName(teamId, folder.getPath(), request.name()); + folder.updateName(request.name()); + } + + @Transactional + public void delete(final long memberId, final long teamId, final List folderIds) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + List folders = folderFinder.findAllById(folderIds, teamId); + deleteFolders(folders); + } + + private Folder getFolder(final long teamId, final Long folderId) { + if (folderId == null) { + return null; + } + Folder folder = folderFinder.findById(folderId); + folder.validateTeamId(teamId); + return folder; + } + + private String getChildFolderPath(final Folder folder) { + if (folder == null) { + return ROOT_PATH; + } + return folder.getChildPath(); + } + + private void validateFolderName(final long teamId, final String path, final String name) { + List folders = folderFinder.findByTeamIdAndPath(teamId, path); + if (folders.stream().anyMatch(folder -> folder.getName().equals(name))) { + throw new FolderException(FOLDER_NAME_DUPLICATE); + } + } + + private void deleteFolders(final List folders) { + folders.forEach(this::deleteChildFolders); + folders.forEach(this::deleteDocuments); + folderDeleter.deleteAll(folders); + } + + private void deleteChildFolders(final Folder folder) { + List childFolders = folderFinder.findAllStartWithPath(folder.getChildPath()); + childFolders.forEach(this::deleteDocuments); + folderDeleter.deleteAll(childFolders); + } + + private void deleteDocuments(final Folder folder) { + List documents = documentFinder.findAllByFolderId(folder.getId()); + deletedDocumentAdapter.save(documents); + documentDeleter.deleteAll(documents); + } +} diff --git a/src/main/java/com/tiki/server/member/Constants.java b/src/main/java/com/tiki/server/member/Constants.java new file mode 100644 index 00000000..38246c5f --- /dev/null +++ b/src/main/java/com/tiki/server/member/Constants.java @@ -0,0 +1,6 @@ +package com.tiki.server.member; + +public class Constants { + + public static final String PASSWORD_PATTERN = "^(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-={}\\[\\]:;\"'<>?,./]).{8,}$"; +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/member/adapter/MemberFinder.java b/src/main/java/com/tiki/server/member/adapter/MemberFinder.java new file mode 100644 index 00000000..4b06285f --- /dev/null +++ b/src/main/java/com/tiki/server/member/adapter/MemberFinder.java @@ -0,0 +1,39 @@ +package com.tiki.server.member.adapter; + +import static com.tiki.server.member.message.ErrorCode.CONFLICT_MEMBER; +import static com.tiki.server.member.message.ErrorCode.INVALID_MEMBER; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.email.Email; +import com.tiki.server.member.entity.Member; +import com.tiki.server.member.exception.MemberException; +import com.tiki.server.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +@RepositoryAdapter +@RequiredArgsConstructor +public class MemberFinder { + + private final MemberRepository memberRepository; + + public Optional findByEmail(final Email email) { + return memberRepository.findByEmail(email); + } + + public Member findById(final long memberId) { + return memberRepository.findById(memberId).orElseThrow(() -> new MemberException(INVALID_MEMBER)); + } + + public Member checkEmpty(final Email email) { + return memberRepository.findByEmail(email).orElseThrow(() -> new MemberException(INVALID_MEMBER)); + } + + public void checkPresent(final Email email) { + findByEmail(email).ifPresent((member) -> { + throw new MemberException(CONFLICT_MEMBER); + }); + } +} diff --git a/src/main/java/com/tiki/server/member/adapter/MemberSaver.java b/src/main/java/com/tiki/server/member/adapter/MemberSaver.java new file mode 100644 index 00000000..9b87ac47 --- /dev/null +++ b/src/main/java/com/tiki/server/member/adapter/MemberSaver.java @@ -0,0 +1,17 @@ +package com.tiki.server.member.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.member.entity.Member; +import com.tiki.server.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class MemberSaver { + + private final MemberRepository memberRepository; + + public void save(final Member member) { + memberRepository.save(member); + } +} diff --git a/src/main/java/com/tiki/server/member/controller/MemberController.java b/src/main/java/com/tiki/server/member/controller/MemberController.java new file mode 100644 index 00000000..8190560d --- /dev/null +++ b/src/main/java/com/tiki/server/member/controller/MemberController.java @@ -0,0 +1,57 @@ +package com.tiki.server.member.controller; + +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.member.controller.docs.MemberControllerDocs; +import com.tiki.server.member.dto.request.PasswordChangeRequest; +import com.tiki.server.member.dto.request.MemberProfileCreateRequest; +import com.tiki.server.member.dto.response.BelongTeamsGetResponse; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import com.tiki.server.member.service.MemberService; + +import lombok.RequiredArgsConstructor; + +import java.security.Principal; + +import static com.tiki.server.member.message.SuccessMessage.SUCCESS_CHANGING_PASSWORD; +import static com.tiki.server.member.message.SuccessMessage.SUCCESS_CREATE_MEMBER; +import static com.tiki.server.team.message.SuccessMessage.SUCCESS_GET_JOINED_TEAM; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/members") +public class MemberController implements MemberControllerDocs { + + private final MemberService memberService; + + @Override + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public SuccessResponse signUp(@RequestBody final MemberProfileCreateRequest request) { + memberService.signUp(request); + return SuccessResponse.success(SUCCESS_CREATE_MEMBER.getMessage()); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/teams") + public SuccessResponse getBelongTeam( + final Principal principal + ) { + long memberId = Long.parseLong(principal.getName()); + BelongTeamsGetResponse response = memberService.findBelongTeams(memberId); + return SuccessResponse.success(SUCCESS_GET_JOINED_TEAM.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @PatchMapping("/password") + public SuccessResponse changePassword( + @RequestBody final PasswordChangeRequest passwordChangeRequest + ) { + memberService.changePassword(passwordChangeRequest); + return SuccessResponse.success(SUCCESS_CHANGING_PASSWORD.getMessage()); + } +} diff --git a/src/main/java/com/tiki/server/member/controller/docs/MemberControllerDocs.java b/src/main/java/com/tiki/server/member/controller/docs/MemberControllerDocs.java new file mode 100644 index 00000000..ebd23b56 --- /dev/null +++ b/src/main/java/com/tiki/server/member/controller/docs/MemberControllerDocs.java @@ -0,0 +1,98 @@ +package com.tiki.server.member.controller.docs; + +import com.tiki.server.common.dto.ErrorResponse; +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.member.dto.request.PasswordChangeRequest; +import com.tiki.server.member.dto.request.MemberProfileCreateRequest; +import com.tiki.server.member.dto.response.BelongTeamsGetResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.springframework.web.bind.annotation.RequestBody; + +import java.security.Principal; + +@Tag(name = "members", description = "멤버 API") +public interface MemberControllerDocs { + + @Operation( + summary = "회원가입 API", + description = "회원가입을 위한 정보를 보낸다.", + responses = { + @ApiResponse(responseCode = "201", description = "성공"), + @ApiResponse( + responseCode = "400", + description = "이메일 형식 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "400", + description = "비밀번호 불일치", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "409", + description = "이미 가입된 아이디", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse signUp(@RequestBody final MemberProfileCreateRequest request); + + @Operation( + summary = "소속 팀 가져오기", + description = "왼쪽 사이드바의 소속된 팀 정보를 가져옵니다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "404", + description = "유효하지 않은 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getBelongTeam( + @Parameter(hidden = true) final Principal principal + ); + + @Operation( + summary = "비밀번호 변경", + description = "비밀번호를 변경합니다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "400", + description = "비밀번호가 일치하지 않습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "유효하지 않은 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse changePassword( + @RequestBody final PasswordChangeRequest passwordChangeRequest + ); +} diff --git a/src/main/java/com/tiki/server/member/dto/request/MemberProfileCreateRequest.java b/src/main/java/com/tiki/server/member/dto/request/MemberProfileCreateRequest.java new file mode 100644 index 00000000..09b91bf4 --- /dev/null +++ b/src/main/java/com/tiki/server/member/dto/request/MemberProfileCreateRequest.java @@ -0,0 +1,17 @@ +package com.tiki.server.member.dto.request; + +import java.time.LocalDate; + +import com.tiki.server.common.entity.University; + +import jakarta.validation.constraints.NotNull; + +public record MemberProfileCreateRequest( + @NotNull String name, + @NotNull LocalDate birth, + @NotNull University univ, + @NotNull String email, + @NotNull String password, + @NotNull String passwordChecker +) { +} diff --git a/src/main/java/com/tiki/server/member/dto/request/PasswordChangeRequest.java b/src/main/java/com/tiki/server/member/dto/request/PasswordChangeRequest.java new file mode 100644 index 00000000..ff85ddb8 --- /dev/null +++ b/src/main/java/com/tiki/server/member/dto/request/PasswordChangeRequest.java @@ -0,0 +1,10 @@ +package com.tiki.server.member.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record PasswordChangeRequest( + @NotNull String email, + @NotNull String password, + @NotNull String passwordChecker +) { +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/member/dto/response/BelongTeamsGetResponse.java b/src/main/java/com/tiki/server/member/dto/response/BelongTeamsGetResponse.java new file mode 100644 index 00000000..76c83efc --- /dev/null +++ b/src/main/java/com/tiki/server/member/dto/response/BelongTeamsGetResponse.java @@ -0,0 +1,37 @@ +package com.tiki.server.member.dto.response; + +import com.tiki.server.team.entity.Team; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import java.util.List; + +import static lombok.AccessLevel.PRIVATE; + +@Builder(access = PRIVATE) +public record BelongTeamsGetResponse( + @NotNull List belongTeamGetResponses +) { + + public static BelongTeamsGetResponse from(final List teams) { + return BelongTeamsGetResponse.builder() + .belongTeamGetResponses(teams.stream().map(BelongTeamGetResponse::from).toList()) + .build(); + } + + @Builder(access = PRIVATE) + public record BelongTeamGetResponse( + @NotNull long id, + @NotNull String name, + @NotNull String iconImageUrl + ) { + public static BelongTeamGetResponse from(final Team team) { + return BelongTeamGetResponse.builder() + .id(team.getId()) + .name(team.getName()) + .iconImageUrl(team.getIconImageUrl()) + .build(); + } + } +} diff --git a/src/main/java/com/tiki/server/member/dto/response/SignInResultGetResponse.java b/src/main/java/com/tiki/server/member/dto/response/SignInResultGetResponse.java new file mode 100644 index 00000000..f95ce5eb --- /dev/null +++ b/src/main/java/com/tiki/server/member/dto/response/SignInResultGetResponse.java @@ -0,0 +1,12 @@ +package com.tiki.server.member.dto.response; + +import jakarta.validation.constraints.NotNull; + +public record SignInResultGetResponse( + @NotNull String accessToken +) { + + public static SignInResultGetResponse from(final String accessToken) { + return new SignInResultGetResponse(accessToken); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/member/entity/Member.java b/src/main/java/com/tiki/server/member/entity/Member.java new file mode 100644 index 00000000..55623359 --- /dev/null +++ b/src/main/java/com/tiki/server/member/entity/Member.java @@ -0,0 +1,64 @@ +package com.tiki.server.member.entity; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.GenerationType.IDENTITY; + +import java.time.LocalDate; + +import com.tiki.server.common.entity.BaseTime; +import com.tiki.server.email.Email; +import com.tiki.server.common.entity.University; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Member extends BaseTime { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "member_id") + private Long id; + + private Email email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private LocalDate birth; + + @Column(nullable = false) + @Enumerated(value = STRING) + private University univ; + + public static Member of( + final String email, + final String password, + final String name, + final LocalDate birth, + final University univ) { + return Member.builder() + .email(Email.from(email)) + .password(password) + .name(name) + .birth(birth) + .univ(univ) + .build(); + } + + public void resetPassword(final String password) { + this.password = password; + } +} diff --git a/src/main/java/com/tiki/server/member/exception/MemberException.java b/src/main/java/com/tiki/server/member/exception/MemberException.java new file mode 100644 index 00000000..2df1ef2b --- /dev/null +++ b/src/main/java/com/tiki/server/member/exception/MemberException.java @@ -0,0 +1,16 @@ +package com.tiki.server.member.exception; + +import com.tiki.server.member.message.ErrorCode; + +import lombok.Getter; + +@Getter +public class MemberException extends RuntimeException { + + private final ErrorCode errorCode; + + public MemberException(final ErrorCode errorCode) { + super("[MemberException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/tiki/server/member/message/ErrorCode.java b/src/main/java/com/tiki/server/member/message/ErrorCode.java new file mode 100644 index 00000000..1d441fb3 --- /dev/null +++ b/src/main/java/com/tiki/server/member/message/ErrorCode.java @@ -0,0 +1,27 @@ +package com.tiki.server.member.message; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import static org.springframework.http.HttpStatus.*; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + /* 400 BAD REQUEST : 잘못된 요청 */ + UNMATCHED_PASSWORD(BAD_REQUEST, "비밀번호가 일치하지 않습니다."), + INVALID_PASSWORD(BAD_REQUEST, "잘못된 비밀번호 형식입니다."), + INVALID_EMAIL(BAD_REQUEST, "잘못된 이메일 형식입니다."), + + /* 404 NOT_FOUND : 자원을 찾을 수 없음 */ + INVALID_MEMBER(NOT_FOUND, "유효하지 않은 회원입니다."), + + /* 409 CONFLICT : 중복된 데이터 존재 */ + CONFLICT_MEMBER(CONFLICT, "존재하는 이메일입니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/tiki/server/member/message/SuccessMessage.java b/src/main/java/com/tiki/server/member/message/SuccessMessage.java new file mode 100644 index 00000000..a99abbe8 --- /dev/null +++ b/src/main/java/com/tiki/server/member/message/SuccessMessage.java @@ -0,0 +1,14 @@ +package com.tiki.server.member.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + SUCCESS_CREATE_MEMBER("회원가입 성공"), + SUCCESS_CHANGING_PASSWORD("비밀번호 변경 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/member/repository/MemberRepository.java b/src/main/java/com/tiki/server/member/repository/MemberRepository.java new file mode 100644 index 00000000..0788365b --- /dev/null +++ b/src/main/java/com/tiki/server/member/repository/MemberRepository.java @@ -0,0 +1,12 @@ +package com.tiki.server.member.repository; + +import com.tiki.server.email.Email; +import org.springframework.data.jpa.repository.JpaRepository; +import com.tiki.server.member.entity.Member; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + Optional findByEmail(final Email email); +} diff --git a/src/main/java/com/tiki/server/member/service/MemberService.java b/src/main/java/com/tiki/server/member/service/MemberService.java new file mode 100644 index 00000000..be4fdd4e --- /dev/null +++ b/src/main/java/com/tiki/server/member/service/MemberService.java @@ -0,0 +1,92 @@ +package com.tiki.server.member.service; + +import com.tiki.server.member.adapter.MemberFinder; +import com.tiki.server.member.adapter.MemberSaver; +import com.tiki.server.member.dto.request.PasswordChangeRequest; +import com.tiki.server.member.dto.request.MemberProfileCreateRequest; +import com.tiki.server.member.dto.response.BelongTeamsGetResponse; +import com.tiki.server.email.Email; +import com.tiki.server.member.entity.Member; +import com.tiki.server.member.exception.MemberException; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder; +import com.tiki.server.memberteammanager.entity.MemberTeamManager; +import com.tiki.server.team.adapter.TeamFinder; +import com.tiki.server.team.entity.Team; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.regex.Pattern; + +import static com.tiki.server.member.Constants.PASSWORD_PATTERN; +import static com.tiki.server.member.message.ErrorCode.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberSaver memberSaver; + private final MemberFinder memberFinder; + private final PasswordEncoder passwordEncoder; + private final TeamFinder teamFinder; + private final MemberTeamManagerFinder memberTeamManagerFinder; + + @Transactional + public void signUp(final MemberProfileCreateRequest request) { + memberFinder.checkPresent(Email.from(request.email())); + checkPasswordFormat(request.password()); + checkPasswordMatch(request.password(), request.passwordChecker()); + Member member = createMember(request); + saveMember(member); + } + + public BelongTeamsGetResponse findBelongTeams(final long memberId) { + List memberTeamManagers = memberTeamManagerFinder.findAllByMemberIdOrderByCreatedAt(memberId); + List teams = getTeams(memberTeamManagers); + return BelongTeamsGetResponse.from(teams); + } + + @Transactional + public void changePassword(final PasswordChangeRequest request) { + Member member = memberFinder.checkEmpty(Email.from(request.email())); + checkPasswordFormat(request.password()); + checkPasswordMatch(request.password(), request.passwordChecker()); + member.resetPassword(passwordEncoder.encode(request.password())); + } + + private Member createMember(final MemberProfileCreateRequest request) { + return Member.of( + request.email(), + passwordEncoder.encode(request.password()), + request.name(), + request.birth(), + request.univ()); + } + + private void saveMember(final Member member) { + memberSaver.save(member); + } + + private List getTeams(final List memberTeamManagers) { + return memberTeamManagers.stream() + .map(memberTeamManager -> teamFinder.findById(memberTeamManager.getTeamId())) + .toList(); + } + + private void checkPasswordFormat(final String password) { + if (!(password != null && !password.contains(" ") && Pattern.matches(PASSWORD_PATTERN, password))) { + throw new MemberException(INVALID_PASSWORD); + } + } + + private void checkPasswordMatch(final String password, final String passwordChecker) { + if (!password.equals(passwordChecker)) { + throw new MemberException(UNMATCHED_PASSWORD); + } + } +} diff --git a/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerDeleter.java b/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerDeleter.java new file mode 100644 index 00000000..c38e3c38 --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerDeleter.java @@ -0,0 +1,24 @@ +package com.tiki.server.memberteammanager.adapter; + +import java.util.List; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.memberteammanager.entity.MemberTeamManager; +import com.tiki.server.memberteammanager.repository.MemberTeamManagerRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class MemberTeamManagerDeleter { + + private final MemberTeamManagerRepository memberTeamManagerRepository; + + public void delete(final MemberTeamManager memberTeamManager) { + memberTeamManagerRepository.delete(memberTeamManager); + } + + public void deleteAll(final List memberTeamManagers) { + memberTeamManagerRepository.deleteAll(memberTeamManagers); + } +} diff --git a/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerFinder.java b/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerFinder.java new file mode 100644 index 00000000..668fb66b --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerFinder.java @@ -0,0 +1,46 @@ +package com.tiki.server.memberteammanager.adapter; + +import static com.tiki.server.memberteammanager.message.ErrorCode.INVALID_MEMBER_TEAM_MANAGER; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.email.Email; +import com.tiki.server.memberteammanager.entity.MemberTeamManager; +import com.tiki.server.memberteammanager.exception.MemberTeamManagerException; +import com.tiki.server.memberteammanager.repository.MemberTeamManagerRepository; + +import com.tiki.server.memberteammanager.repository.projection.TeamMemberInformGetProjection; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RepositoryAdapter +@RequiredArgsConstructor +public class MemberTeamManagerFinder { + + private final MemberTeamManagerRepository memberTeamManagerRepository; + + public MemberTeamManager findByMemberIdAndTeamId(final long memberId, final long teamId) { + return memberTeamManagerRepository.findByMemberIdAndTeamId(memberId, teamId) + .orElseThrow(() -> new MemberTeamManagerException(INVALID_MEMBER_TEAM_MANAGER)); + } + + public List findAllByMemberIdOrderByCreatedAt(final long memberId) { + return memberTeamManagerRepository.findAllByMemberIdOrderByCreatedAt(memberId); + } + + public List findAllByTeamId(final long teamId) { + return memberTeamManagerRepository.findAllByTeamId(teamId); + } + + public List findNameAndEmailByMemberIdAndTeamId(final long teamId) { + return memberTeamManagerRepository.findTeamMembersByTeamId(teamId); + } + + public boolean checkIsPresent(final long memberId, final long teamId) { + return memberTeamManagerRepository.findByMemberIdAndTeamId(memberId, teamId).isPresent(); + } + + public boolean existsByTeamIdAndMemberEmail(final long teamId, final Email email){ + return memberTeamManagerRepository.existsByTeamIdAndMemberEmail(teamId,email); + } +} diff --git a/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerSaver.java b/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerSaver.java new file mode 100644 index 00000000..eff8baca --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerSaver.java @@ -0,0 +1,18 @@ +package com.tiki.server.memberteammanager.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.memberteammanager.entity.MemberTeamManager; +import com.tiki.server.memberteammanager.repository.MemberTeamManagerRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class MemberTeamManagerSaver { + + private final MemberTeamManagerRepository memberTeamManagerRepository; + + public void save(final MemberTeamManager memberTeamManager) { + memberTeamManagerRepository.save(memberTeamManager); + } +} diff --git a/src/main/java/com/tiki/server/memberteammanager/controller/MemberTeamController.java b/src/main/java/com/tiki/server/memberteammanager/controller/MemberTeamController.java new file mode 100644 index 00000000..ae6a6bf9 --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/controller/MemberTeamController.java @@ -0,0 +1,84 @@ +package com.tiki.server.memberteammanager.controller; + +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.memberteammanager.controller.dto.request.UpdateTeamMemberNameRequest; +import com.tiki.server.memberteammanager.service.MemberTeamManagerService; +import com.tiki.server.memberteammanager.service.dto.response.MemberTeamInformGetResponse; +import com.tiki.server.memberteammanager.service.dto.response.TeamMembersGetResponse; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; + +import static com.tiki.server.memberteammanager.message.SuccessMessage.GET_TEAM_INFORM; +import static com.tiki.server.memberteammanager.message.SuccessMessage.GET_TEAM_MEMBERS; +import static com.tiki.server.memberteammanager.message.SuccessMessage.KICK_TEAM; +import static com.tiki.server.memberteammanager.message.SuccessMessage.LEAVE_TEAM; +import static com.tiki.server.memberteammanager.message.SuccessMessage.UPDATE_NAME; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/team-member") +public class MemberTeamController { + + private final MemberTeamManagerService memberTeamManagerService; + + @ResponseStatus(HttpStatus.OK) + @GetMapping("/teams/{teamId}/members") + public SuccessResponse getMembers( + final Principal principal, + @PathVariable final long teamId + ) { + long memberId = Long.parseLong(principal.getName()); + TeamMembersGetResponse response = memberTeamManagerService.getMembers(teamId, memberId); + return SuccessResponse.success(GET_TEAM_MEMBERS.getMessage(), response); + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping("/teams/{teamId}/members/position") + public SuccessResponse getMemberTeamInform( + final Principal principal, + @PathVariable final long teamId + ) { + long memberId = Long.parseLong(principal.getName()); + MemberTeamInformGetResponse response = memberTeamManagerService.getMemberTeamInform(memberId, teamId); + return SuccessResponse.success(GET_TEAM_INFORM.getMessage(), response); + } + + @ResponseStatus(HttpStatus.OK) + @PatchMapping("/teams/{teamId}/members/name") + public SuccessResponse updateTeamMemberName( + final Principal principal, + @PathVariable final long teamId, + @RequestBody final UpdateTeamMemberNameRequest request + ) { + long memberId = Long.parseLong(principal.getName()); + memberTeamManagerService.updateTeamMemberName(memberId, teamId, request.newName()); + return SuccessResponse.success(UPDATE_NAME.getMessage()); + } + + @ResponseStatus(HttpStatus.OK) + @DeleteMapping("/teams/{teamId}/members/{kickOutMemberId}") + public SuccessResponse kickOutMemberFromTeam( + final Principal principal, + @PathVariable final long teamId, + @PathVariable final long kickOutMemberId + ) { + long memberId = Long.parseLong(principal.getName()); + memberTeamManagerService.kickOutMemberFromTeam(memberId, teamId, kickOutMemberId); + return SuccessResponse.success(KICK_TEAM.getMessage()); + } + + @ResponseStatus(HttpStatus.OK) + @DeleteMapping("/teams/{teamId}/leave") + public SuccessResponse leaveTeam( + final Principal principal, + @PathVariable final long teamId + ) { + long memberId = Long.parseLong(principal.getName()); + memberTeamManagerService.leaveTeam(memberId, teamId); + return SuccessResponse.success(LEAVE_TEAM.getMessage()); + } +} diff --git a/src/main/java/com/tiki/server/memberteammanager/controller/dto/request/UpdateTeamMemberNameRequest.java b/src/main/java/com/tiki/server/memberteammanager/controller/dto/request/UpdateTeamMemberNameRequest.java new file mode 100644 index 00000000..9b6fc655 --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/controller/dto/request/UpdateTeamMemberNameRequest.java @@ -0,0 +1,8 @@ +package com.tiki.server.memberteammanager.controller.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record UpdateTeamMemberNameRequest( + @NotNull String newName +) { +} diff --git a/src/main/java/com/tiki/server/memberteammanager/entity/MemberTeamManager.java b/src/main/java/com/tiki/server/memberteammanager/entity/MemberTeamManager.java new file mode 100644 index 00000000..3cd56074 --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/entity/MemberTeamManager.java @@ -0,0 +1,76 @@ +package com.tiki.server.memberteammanager.entity; + +import static com.tiki.server.memberteammanager.message.ErrorCode.*; +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import com.tiki.server.common.entity.BaseTime; +import com.tiki.server.common.entity.Position; +import com.tiki.server.member.entity.Member; +import com.tiki.server.memberteammanager.exception.MemberTeamManagerException; +import com.tiki.server.team.entity.Team; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder(access = PRIVATE) +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +public class MemberTeamManager extends BaseTime { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "manager_id") + private Long id; + + @Column(nullable = false) + private long memberId; + + @Column(nullable = false) + private long teamId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + @Enumerated(value = STRING) + private Position position; + + public static MemberTeamManager of(final Member member, final Team team, final Position position) { + return MemberTeamManager.builder() + .memberId(member.getId()) + .teamId(team.getId()) + .name(member.getName()) + .position(position) + .build(); + } + + public void checkMemberAccessible(final Position accesiblePosition) { + if (this.position.getAuthorization() > accesiblePosition.getAuthorization()) { + throw new MemberTeamManagerException(INVALID_AUTHORIZATION); + } + } + + public void updateName(final String name){ + this.name = name; + } + + public void updatePositionToExecutive(){ + this.position = Position.EXECUTIVE; + } + + public void updatePositionToAdmin(){ + this.position = Position.ADMIN; + } +} diff --git a/src/main/java/com/tiki/server/memberteammanager/exception/MemberTeamManagerException.java b/src/main/java/com/tiki/server/memberteammanager/exception/MemberTeamManagerException.java new file mode 100644 index 00000000..02bc2939 --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/exception/MemberTeamManagerException.java @@ -0,0 +1,16 @@ +package com.tiki.server.memberteammanager.exception; + +import com.tiki.server.memberteammanager.message.ErrorCode; + +import lombok.Getter; + +@Getter +public class MemberTeamManagerException extends RuntimeException { + + private final ErrorCode errorCode; + + public MemberTeamManagerException(final ErrorCode errorCode) { + super("[MemberTeamManagerException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/tiki/server/memberteammanager/message/ErrorCode.java b/src/main/java/com/tiki/server/memberteammanager/message/ErrorCode.java new file mode 100644 index 00000000..0134d070 --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/message/ErrorCode.java @@ -0,0 +1,25 @@ +package com.tiki.server.memberteammanager.message; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + CONFLICT_TEAM_MEMBER(BAD_REQUEST,"이미 존재하는 팀원입니다"), + /* 403 FORBIDDEN : 권한 없음 */ + INVALID_AUTHORIZATION(FORBIDDEN, "권한이 없습니다."), + + /* 404 NOT_FOUND : 자원을 찾을 수 없음 */ + INVALID_MEMBER_TEAM_MANAGER(NOT_FOUND, "팀에 존재하지 않는 회원입니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/tiki/server/memberteammanager/message/SuccessMessage.java b/src/main/java/com/tiki/server/memberteammanager/message/SuccessMessage.java new file mode 100644 index 00000000..05038e62 --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/message/SuccessMessage.java @@ -0,0 +1,17 @@ +package com.tiki.server.memberteammanager.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + GET_TEAM_INFORM("팀 개인 정보 불러오기 성공"), + GET_TEAM_MEMBERS("팀원 정보 불러오기 성공"), + KICK_TEAM("팀 퇴출 성공"), + LEAVE_TEAM("팀 탈퇴 성공"), + UPDATE_NAME("팀 내 이름 변경 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/memberteammanager/repository/MemberTeamManagerRepository.java b/src/main/java/com/tiki/server/memberteammanager/repository/MemberTeamManagerRepository.java new file mode 100644 index 00000000..6b8e831a --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/repository/MemberTeamManagerRepository.java @@ -0,0 +1,32 @@ +package com.tiki.server.memberteammanager.repository; + +import com.tiki.server.email.Email; +import com.tiki.server.memberteammanager.repository.projection.TeamMemberInformGetProjection; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.tiki.server.memberteammanager.entity.MemberTeamManager; +import org.springframework.data.jpa.repository.Query; + +public interface MemberTeamManagerRepository extends JpaRepository { + + Optional findByMemberIdAndTeamId(final long memberId, final long teamId); + + List findAllByTeamId(final long teamId); + + List findAllByMemberIdOrderByCreatedAt(final long memberId); + + @Query("SELECT m.name AS memberName, m.email AS memberEmail, mtm.position AS memberPosition " + + "FROM MemberTeamManager mtm " + + "JOIN Member m ON mtm.memberId = m.id " + + "WHERE mtm.teamId = :teamId") + List findTeamMembersByTeamId(final long teamId); + + @Query("SELECT CASE WHEN COUNT(mtm) > 0 THEN true ELSE false END " + + "FROM MemberTeamManager mtm " + + "JOIN Member m ON mtm.memberId = m.id " + + "WHERE mtm.teamId = :teamId AND m.email = :email") + boolean existsByTeamIdAndMemberEmail(final long teamId, final Email email); +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/memberteammanager/repository/projection/TeamMemberInformGetProjection.java b/src/main/java/com/tiki/server/memberteammanager/repository/projection/TeamMemberInformGetProjection.java new file mode 100644 index 00000000..548813e5 --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/repository/projection/TeamMemberInformGetProjection.java @@ -0,0 +1,9 @@ +package com.tiki.server.memberteammanager.repository.projection; + +import com.tiki.server.common.entity.Position; + +public interface TeamMemberInformGetProjection { + String getMemberName(); + String getMemberEmail(); + Position getMemberPosition(); +} diff --git a/src/main/java/com/tiki/server/memberteammanager/service/MemberTeamManagerService.java b/src/main/java/com/tiki/server/memberteammanager/service/MemberTeamManagerService.java new file mode 100644 index 00000000..7f5cf1f2 --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/service/MemberTeamManagerService.java @@ -0,0 +1,83 @@ +package com.tiki.server.memberteammanager.service; + +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerDeleter; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerSaver; +import com.tiki.server.memberteammanager.entity.MemberTeamManager; +import com.tiki.server.memberteammanager.repository.projection.TeamMemberInformGetProjection; +import com.tiki.server.memberteammanager.service.dto.response.TeamMembersGetResponse; +import com.tiki.server.note.adapter.NoteFinder; +import com.tiki.server.note.entity.Note; +import com.tiki.server.team.exception.TeamException; +import com.tiki.server.memberteammanager.service.dto.response.MemberTeamInformGetResponse; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import static com.tiki.server.common.entity.Position.ADMIN; +import static com.tiki.server.team.message.ErrorCode.INVALID_AUTHORIZATION_DELETE; +import static com.tiki.server.team.message.ErrorCode.TOO_HIGH_AUTHORIZATION; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberTeamManagerService { + + private final NoteFinder noteFinder; + private final MemberTeamManagerFinder memberTeamManagerFinder; + private final MemberTeamManagerDeleter memberTeamManagerDeleter; + + @Transactional + public void kickOutMemberFromTeam(final long memberId, final long teamId, final long kickOutMemberId) { + MemberTeamManager accessMember = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + checkIsAdmin(accessMember); + MemberTeamManager kickOutMember = memberTeamManagerFinder.findByMemberIdAndTeamId(kickOutMemberId, teamId); + deleteNoteDependency(kickOutMemberId, teamId); + memberTeamManagerDeleter.delete(kickOutMember); + } + + @Transactional + public void leaveTeam(final long memberId, final long teamId) { + MemberTeamManager accessMember = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + checkIsNotAdmin(accessMember); + deleteNoteDependency(memberId, teamId); + memberTeamManagerDeleter.delete(accessMember); + } + + public MemberTeamInformGetResponse getMemberTeamInform(final long memberId, final long teamId) { + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + return MemberTeamInformGetResponse.of(memberTeamManager.getPosition(), memberTeamManager.getName()); + } + + @Transactional + public void updateTeamMemberName(final long memberId, final long teamId, final String name) { + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + memberTeamManager.updateName(name); + } + + public TeamMembersGetResponse getMembers(final long teamId, final long memberId) { + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + List teamMembers = memberTeamManagerFinder.findNameAndEmailByMemberIdAndTeamId(teamId); + return TeamMembersGetResponse.from(teamMembers); + } + + private void checkIsAdmin(MemberTeamManager memberTeamManager) { + if (!memberTeamManager.getPosition().equals(ADMIN)) { + throw new TeamException(INVALID_AUTHORIZATION_DELETE); + } + } + + private void checkIsNotAdmin(final MemberTeamManager memberTeamManager) { + if (memberTeamManager.getPosition().equals(ADMIN)) { + throw new TeamException(TOO_HIGH_AUTHORIZATION); + } + } + + private void deleteNoteDependency(final long memberId, final long teamId) { + List notes = noteFinder.findAllByMemberIdAndTeamId(memberId, teamId); + notes.forEach(Note::deleteMemberDependency); + } +} diff --git a/src/main/java/com/tiki/server/memberteammanager/service/dto/response/MemberTeamInformGetResponse.java b/src/main/java/com/tiki/server/memberteammanager/service/dto/response/MemberTeamInformGetResponse.java new file mode 100644 index 00000000..7c2ea78a --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/service/dto/response/MemberTeamInformGetResponse.java @@ -0,0 +1,13 @@ +package com.tiki.server.memberteammanager.service.dto.response; + +import com.tiki.server.common.entity.Position; +import jakarta.validation.constraints.NotNull; + +public record MemberTeamInformGetResponse( + @NotNull Position position, + @NotNull String name +) { + public static MemberTeamInformGetResponse of(final Position position, final String name) { + return new MemberTeamInformGetResponse(position, name); + } +} diff --git a/src/main/java/com/tiki/server/memberteammanager/service/dto/response/TeamMemberGetResponse.java b/src/main/java/com/tiki/server/memberteammanager/service/dto/response/TeamMemberGetResponse.java new file mode 100644 index 00000000..0912f7a6 --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/service/dto/response/TeamMemberGetResponse.java @@ -0,0 +1,15 @@ +package com.tiki.server.memberteammanager.service.dto.response; + +import com.tiki.server.common.entity.Position; +import com.tiki.server.memberteammanager.repository.projection.TeamMemberInformGetProjection; +import jakarta.validation.constraints.NotNull; + +public record TeamMemberGetResponse ( + @NotNull String name, + @NotNull Position position, + @NotNull String email +){ + public static TeamMemberGetResponse from(TeamMemberInformGetProjection projection){ + return new TeamMemberGetResponse(projection.getMemberName(),projection.getMemberPosition(), projection.getMemberEmail()); + } +} diff --git a/src/main/java/com/tiki/server/memberteammanager/service/dto/response/TeamMembersGetResponse.java b/src/main/java/com/tiki/server/memberteammanager/service/dto/response/TeamMembersGetResponse.java new file mode 100644 index 00000000..491153d5 --- /dev/null +++ b/src/main/java/com/tiki/server/memberteammanager/service/dto/response/TeamMembersGetResponse.java @@ -0,0 +1,17 @@ +package com.tiki.server.memberteammanager.service.dto.response; + +import com.tiki.server.memberteammanager.repository.projection.TeamMemberInformGetProjection; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record TeamMembersGetResponse( + @NotNull List teamMemberGetResponses +) { + public TeamMembersGetResponse(List teamMemberGetResponses){ + this.teamMemberGetResponses = teamMemberGetResponses; + } + + public static TeamMembersGetResponse from(List projections){ + return new TeamMembersGetResponse(projections.stream().map(TeamMemberGetResponse::from).toList()); + } +} diff --git a/src/main/java/com/tiki/server/note/adapter/NoteDeleter.java b/src/main/java/com/tiki/server/note/adapter/NoteDeleter.java new file mode 100644 index 00000000..1080645c --- /dev/null +++ b/src/main/java/com/tiki/server/note/adapter/NoteDeleter.java @@ -0,0 +1,18 @@ +package com.tiki.server.note.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.note.repository.NoteRepository; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RepositoryAdapter +@RequiredArgsConstructor +public class NoteDeleter { + + private final NoteRepository noteRepository; + + public void deleteNoteByIds(final List noteIds){ + noteIds.forEach(noteRepository::deleteById); + } +} diff --git a/src/main/java/com/tiki/server/note/adapter/NoteFinder.java b/src/main/java/com/tiki/server/note/adapter/NoteFinder.java new file mode 100644 index 00000000..fdcf4748 --- /dev/null +++ b/src/main/java/com/tiki/server/note/adapter/NoteFinder.java @@ -0,0 +1,45 @@ +package com.tiki.server.note.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.note.entity.Note; +import com.tiki.server.note.exception.NoteException; +import com.tiki.server.note.repository.NoteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.tiki.server.note.message.ErrorCode.INVALID_NOTE; + +@RepositoryAdapter +@RequiredArgsConstructor +public class NoteFinder { + + private final NoteRepository noteRepository; + + public List findByCreatedAtBeforeOrderByModifiedAtDesc( + final LocalDateTime createdAt, + final PageRequest pageRequest, + final long teamId + ) { + return noteRepository.findByTeamIdAndCreatedAtBeforeOrderByCreatedDesc(createdAt, pageRequest, teamId); + } + + public List findByCreatedAtAfterOrderByModifiedAtAsc( + final LocalDateTime createdAt, + final PageRequest pageRequest, + final long teamId + ) { + return noteRepository.findByTeamIdAndCreatedAtAfterOrderByCreatedAtAsc(createdAt, pageRequest, teamId); + } + + public List findAllByMemberIdAndTeamId(final long memberId, final long teamId) { + return noteRepository.findAllByMemberIdAndTeamId(memberId, teamId); + } + + public Note findById(final long noteId) { + return noteRepository.findById(noteId) + .orElseThrow(() -> new NoteException(INVALID_NOTE)); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/adapter/NoteSaver.java b/src/main/java/com/tiki/server/note/adapter/NoteSaver.java new file mode 100644 index 00000000..112db2ac --- /dev/null +++ b/src/main/java/com/tiki/server/note/adapter/NoteSaver.java @@ -0,0 +1,17 @@ +package com.tiki.server.note.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.note.entity.Note; +import com.tiki.server.note.repository.NoteRepository; +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class NoteSaver { + + private final NoteRepository noteRepository; + + public Note createNote(final Note note) { + return noteRepository.save(note); + } +} diff --git a/src/main/java/com/tiki/server/note/constants/NoteConstants.java b/src/main/java/com/tiki/server/note/constants/NoteConstants.java new file mode 100644 index 00000000..0b199f08 --- /dev/null +++ b/src/main/java/com/tiki/server/note/constants/NoteConstants.java @@ -0,0 +1,6 @@ +package com.tiki.server.note.constants; + +public class NoteConstants { + + public static final int PAGE_SIZE = 10; +} diff --git a/src/main/java/com/tiki/server/note/controller/NoteController.java b/src/main/java/com/tiki/server/note/controller/NoteController.java new file mode 100644 index 00000000..7d98fd5d --- /dev/null +++ b/src/main/java/com/tiki/server/note/controller/NoteController.java @@ -0,0 +1,137 @@ +package com.tiki.server.note.controller; + +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.common.entity.SortOrder; +import com.tiki.server.note.controller.docs.NoteControllerDocs; +import com.tiki.server.note.controller.dto.request.NoteFreeCreateRequest; +import com.tiki.server.note.controller.dto.request.NoteFreeUpdateRequest; +import com.tiki.server.note.controller.dto.request.NoteTemplateCreateRequest; +import com.tiki.server.note.controller.dto.request.NoteTemplateUpdateRequest; +import com.tiki.server.note.controller.dto.response.SuccessNoteDetailResponse; +import com.tiki.server.note.service.NoteService; +import com.tiki.server.note.service.dto.request.NoteFreeCreateServiceRequest; +import com.tiki.server.note.service.dto.request.NoteFreeUpdateServiceRequest; +import com.tiki.server.note.service.dto.request.NoteTemplateCreateServiceRequest; +import com.tiki.server.note.service.dto.request.NoteTemplateUpdateServiceRequest; +import com.tiki.server.note.service.dto.response.NoteCreateServiceResponse; +import com.tiki.server.note.service.dto.response.NoteDetailGetServiceResponse; +import com.tiki.server.note.service.dto.response.NoteListGetServiceResponse; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.List; + +import static com.tiki.server.note.message.SuccessMessage.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/notes") +public class NoteController implements NoteControllerDocs { + + private final NoteService noteService; + + @Override + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/free") + public SuccessResponse createNoteFree( + final Principal principal, + @RequestBody final NoteFreeCreateRequest request + ) { + long memberId = Long.parseLong(principal.getName()); + NoteCreateServiceResponse response = noteService.createNoteFree(NoteFreeCreateServiceRequest.of(request, memberId)); + return SuccessResponse.success(CREATE_NOTE.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/template") + public SuccessResponse createNoteTemplate( + final Principal principal, + @RequestBody final NoteTemplateCreateRequest request + ) { + long memberId = Long.parseLong(principal.getName()); + NoteCreateServiceResponse response = noteService.createNoteTemplate(NoteTemplateCreateServiceRequest.of(request, memberId)); + return SuccessResponse.success(CREATE_NOTE.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @PatchMapping("/free/{noteId}") + public SuccessResponse updateNoteFree( + final Principal principal, + @PathVariable final long noteId, + @RequestBody final NoteFreeUpdateRequest request + ) { + long memberId = Long.parseLong(principal.getName()); + noteService.updateNoteFree(NoteFreeUpdateServiceRequest.of(request, noteId, memberId)); + return SuccessResponse.success(UPDATE_NOTE.getMessage()); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @PatchMapping("/template/{noteId}") + public SuccessResponse updateNoteTemplate( + final Principal principal, + @PathVariable final long noteId, + @RequestBody final NoteTemplateUpdateRequest request + ) { + long memberId = Long.parseLong(principal.getName()); + noteService.updateNoteTemplate(NoteTemplateUpdateServiceRequest.of(request, noteId, memberId)); + return SuccessResponse.success(UPDATE_NOTE.getMessage()); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/{teamId}") + public SuccessResponse getNote( + final Principal principal, + @PathVariable long teamId, + @RequestParam(required = false) LocalDateTime createdAt, + @RequestParam(defaultValue = "DESC") SortOrder sortOrder + ) { + long memberId = Long.parseLong(principal.getName()); + if (createdAt == null) { + createdAt = (sortOrder == SortOrder.DESC) ? LocalDateTime.now() : LocalDateTime.of(1970, 1, 1, 0, 0); + } + NoteListGetServiceResponse response = noteService.getNote(teamId, memberId, createdAt, sortOrder); + return SuccessResponse.success(GET_NOTE.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/{teamId}/{noteId}") + public SuccessNoteDetailResponse getNoteDetail( + final Principal principal, + @PathVariable final long teamId, + @PathVariable final long noteId + ) { + long memberId = Long.parseLong(principal.getName()); + NoteDetailGetServiceResponse response = noteService.getNoteDetail(teamId, memberId, noteId); + return SuccessNoteDetailResponse.success(GET_NOTE_DETAIL.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/{teamId}") + public void deleteNotes( + final Principal principal, + @PathVariable final long teamId, + @RequestParam final List noteIds + ) { + long memberId = Long.parseLong(principal.getName()); + noteService.deleteNotes(noteIds, teamId, memberId); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/controller/docs/NoteControllerDocs.java b/src/main/java/com/tiki/server/note/controller/docs/NoteControllerDocs.java new file mode 100644 index 00000000..5cb6a54d --- /dev/null +++ b/src/main/java/com/tiki/server/note/controller/docs/NoteControllerDocs.java @@ -0,0 +1,332 @@ +package com.tiki.server.note.controller.docs; + +import com.tiki.server.common.dto.ErrorResponse; +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.common.entity.SortOrder; +import com.tiki.server.note.controller.dto.request.NoteFreeCreateRequest; +import com.tiki.server.note.controller.dto.request.NoteFreeUpdateRequest; +import com.tiki.server.note.controller.dto.request.NoteTemplateCreateRequest; +import com.tiki.server.note.controller.dto.request.NoteTemplateUpdateRequest; +import com.tiki.server.note.controller.dto.response.SuccessNoteDetailResponse; +import com.tiki.server.note.service.dto.response.NoteCreateServiceResponse; +import com.tiki.server.note.service.dto.response.NoteDetailGetServiceResponse; +import com.tiki.server.note.service.dto.response.NoteListGetServiceResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.List; + +@Tag(name = "notes", description = "노트 API") +public interface NoteControllerDocs { + + @Operation( + summary = "자유 형식 노트 생성", + description = "새로운 자유 형식 노트를 생성한다.", + responses = { + @ApiResponse(responseCode = "201", description = "노트 생성 성공"), + @ApiResponse( + responseCode = "400", + description = "요청 데이터 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음(토큰 에러)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + SuccessResponse createNoteFree( + @Parameter(hidden = true) final Principal principal, + @RequestBody final NoteFreeCreateRequest request + ); + + @Operation( + summary = "템플릿 노트 생성", + description = "새로운 템플릿 형식 노트를 생성한다.", + responses = { + @ApiResponse(responseCode = "201", description = "노트 생성 성공"), + @ApiResponse( + responseCode = "400", + description = "요청 데이터 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음(토큰 에러)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + SuccessResponse createNoteTemplate( + @Parameter(hidden = true) final Principal principal, + @RequestBody final NoteTemplateCreateRequest request + ); + + @Operation( + summary = "자유 형식 노트 수정", + description = "기존 자유 형식 노트를 수정한다.", + responses = { + @ApiResponse(responseCode = "200", description = "노트 수정 성공"), + @ApiResponse( + responseCode = "400", + description = "요청 데이터 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음(토큰 에러)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 노트", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + SuccessResponse updateNoteFree( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "noteId", + description = "노트 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long noteId, + @RequestBody final NoteFreeUpdateRequest request + ); + + @Operation( + summary = "템플릿 노트 수정", + description = "기존 템플릿 노트를 수정한다.", + responses = { + @ApiResponse(responseCode = "200", description = "노트 수정 성공"), + @ApiResponse( + responseCode = "400", + description = "요청 데이터 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음(토큰 에러)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 노트", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + SuccessResponse updateNoteTemplate( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "noteId", + description = "노트 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long noteId, + @RequestBody final NoteTemplateUpdateRequest request + ); + + @Operation( + summary = "노트 목록 조회", + description = "특정 팀의 노트 목록을 조회한다.", + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음(토큰 에러)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + SuccessResponse getNote( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "createdAt", + description = "생성시간", + in = ParameterIn.QUERY, + required = false, + example = "yyyy-MM-dd'T'HH:mm:ss.nnnnnnnnn" + ) @RequestParam(required = false) final LocalDateTime createdAt, + @Parameter( + name = "sortOrder", + description = "정렬 순서", + in = ParameterIn.QUERY, + required = false, + example = "ASC, DESC" + ) @RequestParam(defaultValue = "DESC") final SortOrder sortOrder + ); + + @Operation( + summary = "노트 상세 조회", + description = "특정 노트의 상세 정보를 조회한다.", + responses = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessNoteDetailResponse.class))), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음(토큰 에러)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "노트를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + SuccessNoteDetailResponse getNoteDetail( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "noteId", + description = "노트 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long noteId + ); + + @Operation( + summary = "노트 삭제", + description = "특정 팀의 노트를 삭제한다.", + responses = { + @ApiResponse(responseCode = "204", description = "삭제 성공"), + @ApiResponse( + responseCode = "400", + description = "요청 데이터 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음(토큰 에러)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + void deleteNotes( + @Parameter(hidden = true) Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "noteIds", + description = "노트 id 리스트", + in = ParameterIn.QUERY, + example = "[1,2,3,4,5]" + ) @RequestParam final List noteIds + ); +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/controller/dto/request/NoteFreeCreateRequest.java b/src/main/java/com/tiki/server/note/controller/dto/request/NoteFreeCreateRequest.java new file mode 100644 index 00000000..f40bac2a --- /dev/null +++ b/src/main/java/com/tiki/server/note/controller/dto/request/NoteFreeCreateRequest.java @@ -0,0 +1,18 @@ +package com.tiki.server.note.controller.dto.request; + +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.util.List; + +public record NoteFreeCreateRequest( + @NotNull String title, + @NotNull boolean complete, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull String contents, + @NotNull List timeBlockIds, + @NotNull List documentIds, + @NotNull long teamId +) { +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/controller/dto/request/NoteFreeUpdateRequest.java b/src/main/java/com/tiki/server/note/controller/dto/request/NoteFreeUpdateRequest.java new file mode 100644 index 00000000..90e412e6 --- /dev/null +++ b/src/main/java/com/tiki/server/note/controller/dto/request/NoteFreeUpdateRequest.java @@ -0,0 +1,19 @@ +package com.tiki.server.note.controller.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.NonNull; + +import java.time.LocalDate; +import java.util.List; + +public record NoteFreeUpdateRequest( + @NotNull String title, + @NotNull boolean complete, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull String contents, + @NotNull List timeBlockIds, + @NotNull List documentIds, + @NotNull long teamId +) { +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/controller/dto/request/NoteTemplateCreateRequest.java b/src/main/java/com/tiki/server/note/controller/dto/request/NoteTemplateCreateRequest.java new file mode 100644 index 00000000..b8b23c52 --- /dev/null +++ b/src/main/java/com/tiki/server/note/controller/dto/request/NoteTemplateCreateRequest.java @@ -0,0 +1,22 @@ +package com.tiki.server.note.controller.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.NonNull; + +import java.time.LocalDate; +import java.util.List; + +public record NoteTemplateCreateRequest( + @NotNull String title, + @NotNull boolean complete, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull String answerWhatActivity, + @NotNull String answerHowToPrepare, + @NotNull String answerWhatIsDisappointedThing, + @NotNull String answerHowToFix, + @NotNull List timeBlockIds, + @NotNull List documentIds, + @NotNull long teamId +) { +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/controller/dto/request/NoteTemplateUpdateRequest.java b/src/main/java/com/tiki/server/note/controller/dto/request/NoteTemplateUpdateRequest.java new file mode 100644 index 00000000..4c22c26b --- /dev/null +++ b/src/main/java/com/tiki/server/note/controller/dto/request/NoteTemplateUpdateRequest.java @@ -0,0 +1,22 @@ +package com.tiki.server.note.controller.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.NonNull; + +import java.time.LocalDate; +import java.util.List; + +public record NoteTemplateUpdateRequest( + @NotNull String title, + @NotNull boolean complete, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull String answerWhatActivity, + @NotNull String answerHowToPrepare, + @NotNull String answerWhatIsDisappointedThing, + @NotNull String answerHowToFix, + @NotNull List timeBlockIds, + @NotNull List documentIds, + @NotNull long teamId +) { +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/controller/dto/response/SuccessNoteDetailResponse.java b/src/main/java/com/tiki/server/note/controller/dto/response/SuccessNoteDetailResponse.java new file mode 100644 index 00000000..1902f6d9 --- /dev/null +++ b/src/main/java/com/tiki/server/note/controller/dto/response/SuccessNoteDetailResponse.java @@ -0,0 +1,35 @@ +package com.tiki.server.note.controller.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.tiki.server.common.dto.BaseResponse; +import com.tiki.server.note.service.dto.response.NoteFreeDetailGetServiceResponse; +import com.tiki.server.note.service.dto.response.NoteTemplateDetailGetServiceResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; +import static lombok.AccessLevel.PRIVATE; + +@Builder(access = PRIVATE) +public record SuccessNoteDetailResponse( + @NotNull boolean success, + @NotNull String message, + @Schema( + description = "응답 데이터", + oneOf = { + NoteFreeDetailGetServiceResponse.class, + NoteTemplateDetailGetServiceResponse.class + } + ) + @JsonInclude(value = NON_NULL) T data +) implements BaseResponse { + + public static SuccessNoteDetailResponse success(final String message, final T data) { + return SuccessNoteDetailResponse.builder().success(true).message(message).data(data).build(); + } + + public static SuccessNoteDetailResponse success(final String message) { + return SuccessNoteDetailResponse.builder().success(true).message(message).build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/entity/Note.java b/src/main/java/com/tiki/server/note/entity/Note.java new file mode 100644 index 00000000..dc1760a1 --- /dev/null +++ b/src/main/java/com/tiki/server/note/entity/Note.java @@ -0,0 +1,112 @@ +package com.tiki.server.note.entity; + +import com.tiki.server.common.entity.BaseTime; +import com.tiki.server.note.exception.NoteException; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +import static com.tiki.server.note.message.ErrorCode.UPDATE_ONLY_AUTHOR; +import static com.tiki.server.note.message.ErrorCode.UPDATE_ONLY_BELONGING_TEAM; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +public class Note extends BaseTime { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "note_id") + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private boolean complete; + + @Column(nullable = false) + private LocalDate startDate; + + @Column(nullable = false) + private LocalDate endDate; + + private Long memberId; + + @Column(nullable = false) + private long teamId; + + @Column(nullable = false) + private String contents; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private NoteType noteType; + + public static Note of( + final String title, + final boolean complete, + final LocalDate startDate, + final LocalDate endDate, + final String contents, + final long memberId, + final long teamId, + final NoteType noteType + ) { + return Note.builder() + .title(title) + .complete(complete) + .startDate(startDate) + .endDate(endDate) + .memberId(memberId) + .teamId(teamId) + .contents(contents) + .noteType(noteType) + .build(); + } + + public void updateValue( + final long clientId, + final long clientTeamId, + final String title, + final String contents, + final LocalDate startDate, + final LocalDate endDate, + final boolean complete, + final NoteType noteType + ) { + checkAuthor(clientId); + checkTeam(clientTeamId); + this.title = title; + this.contents = contents; + this.startDate = startDate; + this.endDate = endDate; + this.complete = complete; + this.noteType = noteType; + } + + public void deleteMemberDependency() { + this.memberId = null; + } + + private void checkAuthor(final long clientId) { + if (this.memberId != clientId) { + throw new NoteException(UPDATE_ONLY_AUTHOR); + } + } + + private void checkTeam(final long clientTeamId) { + if (this.teamId != clientTeamId) { + throw new NoteException(UPDATE_ONLY_BELONGING_TEAM); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/entity/NoteType.java b/src/main/java/com/tiki/server/note/entity/NoteType.java new file mode 100644 index 00000000..409ba86f --- /dev/null +++ b/src/main/java/com/tiki/server/note/entity/NoteType.java @@ -0,0 +1,6 @@ +package com.tiki.server.note.entity; + +public enum NoteType { + FREE, + TEMPLATE +} diff --git a/src/main/java/com/tiki/server/note/exception/NoteException.java b/src/main/java/com/tiki/server/note/exception/NoteException.java new file mode 100644 index 00000000..54a9524f --- /dev/null +++ b/src/main/java/com/tiki/server/note/exception/NoteException.java @@ -0,0 +1,15 @@ +package com.tiki.server.note.exception; + +import com.tiki.server.note.message.ErrorCode; +import lombok.Getter; + +@Getter +public class NoteException extends RuntimeException { + + private final ErrorCode errorCode; + + public NoteException(final ErrorCode errorCode) { + super("[NoteException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/message/ErrorCode.java b/src/main/java/com/tiki/server/note/message/ErrorCode.java new file mode 100644 index 00000000..c5f9e49b --- /dev/null +++ b/src/main/java/com/tiki/server/note/message/ErrorCode.java @@ -0,0 +1,25 @@ +package com.tiki.server.note.message; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + /* 400 BAD REQUEST : 잘못된 요청 */ + TITLE_IS_EMPTY(BAD_REQUEST, "제목은 필수 입력값 입니다."), + TITLE_LENGTH_OVER(BAD_REQUEST, "제목은 100자를 넘길 수 없습니다."), + UPDATE_ONLY_AUTHOR(BAD_REQUEST, "수정은 작성자만 가능합니다."), + UPDATE_ONLY_BELONGING_TEAM(BAD_REQUEST, "해당 팀에 소속된 파일이 아닙니다."), + + /* 404 NOT_FOUND : 자원을 찾을 수 없음 */ + INVALID_NOTE(NOT_FOUND, "유효하지 않은 노트입니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/tiki/server/note/message/SuccessMessage.java b/src/main/java/com/tiki/server/note/message/SuccessMessage.java new file mode 100644 index 00000000..0d9a6ff5 --- /dev/null +++ b/src/main/java/com/tiki/server/note/message/SuccessMessage.java @@ -0,0 +1,16 @@ +package com.tiki.server.note.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + GET_NOTE("노트 조회 성공"), + GET_NOTE_DETAIL("노트 상세 조회 성공"), + CREATE_NOTE("노트 생성 성공"), + UPDATE_NOTE("노트 수정 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/note/repository/NoteRepository.java b/src/main/java/com/tiki/server/note/repository/NoteRepository.java new file mode 100644 index 00000000..e8e6ab54 --- /dev/null +++ b/src/main/java/com/tiki/server/note/repository/NoteRepository.java @@ -0,0 +1,25 @@ +package com.tiki.server.note.repository; + +import com.tiki.server.note.entity.Note; + +import io.lettuce.core.dynamic.annotation.Param; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDateTime; +import java.util.List; + +public interface NoteRepository extends JpaRepository { + + @Query("SELECT n FROM Note n WHERE n.teamId = :teamId AND n.createdAt < :createdAt ORDER BY n.createdAt DESC") + List findByTeamIdAndCreatedAtBeforeOrderByCreatedDesc(@Param("createdAt") final LocalDateTime createdAt, + final Pageable pageable, final long teamId); + + @Query("SELECT n FROM Note n WHERE n.teamId = :teamId AND n.createdAt > :createdAt ORDER BY n.createdAt ASC") + List findByTeamIdAndCreatedAtAfterOrderByCreatedAtAsc(@Param("createdAt") final LocalDateTime createdAt, + final Pageable pageable, final long teamId); + + List findAllByMemberIdAndTeamId(final long memberId, final long TeamId); +} diff --git a/src/main/java/com/tiki/server/note/service/NoteService.java b/src/main/java/com/tiki/server/note/service/NoteService.java new file mode 100644 index 00000000..8b5c46ce --- /dev/null +++ b/src/main/java/com/tiki/server/note/service/NoteService.java @@ -0,0 +1,245 @@ +package com.tiki.server.note.service; + +import com.tiki.server.common.entity.SortOrder; +import com.tiki.server.common.util.ContentEncoder; +import com.tiki.server.document.adapter.DocumentFinder; +import com.tiki.server.document.entity.Document; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder; +import com.tiki.server.note.adapter.NoteDeleter; +import com.tiki.server.note.adapter.NoteFinder; +import com.tiki.server.note.adapter.NoteSaver; +import com.tiki.server.note.entity.Note; +import com.tiki.server.note.entity.NoteType; +import com.tiki.server.note.service.dto.request.*; +import com.tiki.server.note.service.dto.response.*; +import com.tiki.server.notedocumentmanager.adapter.NDDeleter; +import com.tiki.server.notedocumentmanager.adapter.NDFinder; +import com.tiki.server.notedocumentmanager.adapter.NDSaver; +import com.tiki.server.notedocumentmanager.entity.NDManager; +import com.tiki.server.notetimeblockmanager.adapter.NTBDeleter; +import com.tiki.server.notetimeblockmanager.adapter.NTBFinder; +import com.tiki.server.notetimeblockmanager.adapter.NTBSaver; +import com.tiki.server.notetimeblockmanager.entity.NTBManager; +import com.tiki.server.timeblock.adapter.TimeBlockFinder; +import com.tiki.server.timeblock.entity.TimeBlock; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static com.tiki.server.common.constants.Constants.INIT_NUM; +import static com.tiki.server.note.constants.NoteConstants.PAGE_SIZE; + +@Service +@RequiredArgsConstructor +public class NoteService { + + private final MemberTeamManagerFinder memberTeamManagerFinder; + private final NoteSaver noteSaver; + private final NoteFinder noteFinder; + private final NoteDeleter noteDeleter; + private final NTBFinder ntbFinder; + private final NTBSaver ntbSaver; + private final NTBDeleter ntbDeleter; + private final NDFinder ndFinder; + private final NDSaver ndSaver; + private final NDDeleter ndDeleter; + private final TimeBlockFinder timeBlockFinder; + private final DocumentFinder documentFinder; + + @Transactional + public NoteCreateServiceResponse createNoteFree(final NoteFreeCreateServiceRequest request) { + memberTeamManagerFinder.findByMemberIdAndTeamId(request.memberId(), request.teamId()); + String encryptedContents = ContentEncoder.encodeNoteFree(request.contents()); + Note note = createNote(NoteBase.of(request), encryptedContents, NoteType.FREE); + createNoteTimeBlockManagers(request.timeBlockIds(), note.getId()); + createNoteDocumentManagers(request.documentIds(), note.getId()); + return NoteCreateServiceResponse.from(note.getId()); + } + + @Transactional + public NoteCreateServiceResponse createNoteTemplate(final NoteTemplateCreateServiceRequest request) { + memberTeamManagerFinder.findByMemberIdAndTeamId(request.memberId(), request.teamId()); + String encryptedContents = ContentEncoder.encodeNoteTemplate( + request.answerWhatActivity(), + request.answerHowToPrepare(), + request.answerWhatIsDisappointedThing(), + request.answerHowToFix() + ); + Note note = createNote(NoteBase.of(request), encryptedContents, NoteType.TEMPLATE); + createNoteTimeBlockManagers(request.timeBlockIds(), note.getId()); + createNoteDocumentManagers(request.documentIds(), note.getId()); + return NoteCreateServiceResponse.from(note.getId()); + } + + @Transactional + public void updateNoteFree(final NoteFreeUpdateServiceRequest request) { + Note note = noteFinder.findById(request.noteId()); + memberTeamManagerFinder.findByMemberIdAndTeamId(request.memberId(), request.teamId()); + String encryptedContents = ContentEncoder.encodeNoteFree(request.contents()); + note.updateValue( + request.memberId(), + request.teamId(), + request.title(), + encryptedContents, + request.startDate(), + request.endDate(), + request.complete(), + NoteType.FREE + ); + updateNoteTimeBlockManager(request.timeBlockIds(), note.getId()); + updateNoteDocumentManager(request.documentIds(), note.getId()); + } + + @Transactional + public void updateNoteTemplate(final NoteTemplateUpdateServiceRequest request) { + Note note = noteFinder.findById(request.noteId()); + memberTeamManagerFinder.findByMemberIdAndTeamId(request.memberId(), request.teamId()); + String encryptedContents = ContentEncoder.encodeNoteTemplate( + request.answerWhatActivity(), + request.answerHowToPrepare(), + request.answerWhatIsDisappointedThing(), + request.answerHowToFix() + ); + note.updateValue( + request.memberId(), + request.teamId(), + request.title(), + encryptedContents, + request.startDate(), + request.endDate(), + request.complete(), + NoteType.TEMPLATE + ); + updateNoteTimeBlockManager(request.timeBlockIds(), request.noteId()); + updateNoteDocumentManager(request.documentIds(), request.noteId()); + } + + @Transactional + public void deleteNotes(final List noteIds, final long teamId, final long memberId) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + ndDeleter.deleteByNoteIds(noteIds); + ntbDeleter.noteTimeBlockManagerDeleteByIds(noteIds); + noteDeleter.deleteNoteByIds(noteIds); + } + + public NoteListGetServiceResponse getNote( + final long teamId, + final long memberId, + final LocalDateTime createdAt, + final SortOrder sortOrder + ) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + PageRequest pageable = PageRequest.of(INIT_NUM, PAGE_SIZE); + List noteList = getNotes(createdAt, sortOrder, pageable, teamId); + List noteGetResponses = noteList.stream() + .map(note -> NoteGetResponse.of(note, getMemberName(note.getMemberId(), teamId))) + .toList(); + return new NoteListGetServiceResponse(noteGetResponses); + } + + public NoteDetailGetServiceResponse getNoteDetail(final long teamId, final long memberId, final long noteId) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + Note note = noteFinder.findById(noteId); + List documentList = getDocumentListMappedByNote(noteId); + List timeBlockList = getTimeBlocksMappedByNote(noteId); + String memberName = getMemberName(note.getMemberId(), teamId); + return note.getNoteType() == NoteType.FREE + ? NoteFreeDetailGetServiceResponse.of(note, memberName, documentList, timeBlockList) + : NoteTemplateDetailGetServiceResponse.of(note, memberName, documentList, timeBlockList); + } + + private String getMemberName(final Long noteMemberId, final long teamId) { + return Optional.ofNullable(noteMemberId) + .map(id -> memberTeamManagerFinder.findByMemberIdAndTeamId(id, teamId).getName()) + .orElse("알 수 없음"); + } + + private void updateNoteDocumentManager(final List documentIds, final long noteId) { + List existingNoteDocumentIds = ndFinder.findAllByNoteId(noteId).stream() + .map(NDManager::getDocumentId) + .toList(); + List idsToAdd = documentIds.stream() + .filter(id -> !existingNoteDocumentIds.contains(id)) + .toList(); + List idsToRemove = existingNoteDocumentIds.stream() + .filter(id -> !documentIds.contains(id)) + .toList(); + createNoteDocumentManagers(idsToAdd, noteId); + ndDeleter.deleteByNoteIdAndDocumentId(noteId, idsToRemove); + } + + private void updateNoteTimeBlockManager(final List timeBlockIds, final long noteId) { + List existingNoteTimeBlockIds = ntbFinder.findAllByNoteId(noteId).stream() + .map(NTBManager::getTimeBlockId) + .toList(); + List idsToAdd = timeBlockIds.stream() + .filter(id -> !existingNoteTimeBlockIds.contains(id)) + .toList(); + List idsToRemove = existingNoteTimeBlockIds.stream() + .filter(id -> !timeBlockIds.contains(id)) + .toList(); + createNoteTimeBlockManagers(idsToAdd, noteId); + ntbDeleter.deleteByNoteIdAndTimeBlockId(noteId, idsToRemove); + } + + private List getNotes(final LocalDateTime createdAt, final SortOrder sortOrder, final PageRequest pageable, + final long teamId) { + if (sortOrder == SortOrder.DESC) { + return noteFinder.findByCreatedAtBeforeOrderByModifiedAtDesc(createdAt, pageable, teamId); + } + return noteFinder.findByCreatedAtAfterOrderByModifiedAtAsc(createdAt, pageable, teamId); + } + + private List getTimeBlocksMappedByNote(final long noteId) { + List timblockIdList = ntbFinder.findAllByNoteId(noteId).stream() + .map(NTBManager::getTimeBlockId) + .toList(); + return timblockIdList.stream() + .map(timeBlockFinder::findById) + .toList(); + } + + private List getDocumentListMappedByNote(final long noteId) { + List documentIdList = ndFinder.findAllByNoteId(noteId).stream() + .map(NDManager::getDocumentId) + .toList(); + return documentIdList.stream() + .map(documentFinder::findById) + .toList(); + } + + private Note createNote(final NoteBase request, final String encryptedContents, final NoteType noteType) { + return noteSaver.createNote( + Note.of( + request.title(), + request.complete(), + request.startDate(), + request.endDate(), + encryptedContents, + request.memberId(), + request.teamId(), + noteType + )); + } + + private void createNoteTimeBlockManagers(final List timeBlockIds, final long noteId) { + timeBlockIds.stream() + .filter(timeBlockFinder::existsById) + .map(timeBlockId -> NTBManager.of(noteId, timeBlockId)) + .forEach(ntbSaver::save); + } + + private void createNoteDocumentManagers(final List documentIds, final long noteId) { + documentIds.stream() + .filter(documentFinder::existsById) + .map(documentId -> NDManager.of(noteId, documentId)) + .forEach(ndSaver::save); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/service/dto/request/NoteBase.java b/src/main/java/com/tiki/server/note/service/dto/request/NoteBase.java new file mode 100644 index 00000000..40f9fe77 --- /dev/null +++ b/src/main/java/com/tiki/server/note/service/dto/request/NoteBase.java @@ -0,0 +1,44 @@ +package com.tiki.server.note.service.dto.request; + +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.util.List; + +public record NoteBase( + @NotNull String title, + @NotNull boolean complete, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull long memberId, + @NotNull long teamId, + @NotNull List timeBlockIds, + @NotNull List documentIds +) { + + public static NoteBase of(final NoteFreeCreateServiceRequest noteFreeCreateServiceRequest) { + return new NoteBase( + noteFreeCreateServiceRequest.title(), + noteFreeCreateServiceRequest.complete(), + noteFreeCreateServiceRequest.startDate(), + noteFreeCreateServiceRequest.endDate(), + noteFreeCreateServiceRequest.memberId(), + noteFreeCreateServiceRequest.teamId(), + noteFreeCreateServiceRequest.timeBlockIds(), + noteFreeCreateServiceRequest.documentIds() + ); + } + + public static NoteBase of(final NoteTemplateCreateServiceRequest noteTemplateCreateServiceRequest) { + return new NoteBase( + noteTemplateCreateServiceRequest.title(), + noteTemplateCreateServiceRequest.complete(), + noteTemplateCreateServiceRequest.startDate(), + noteTemplateCreateServiceRequest.endDate(), + noteTemplateCreateServiceRequest.memberId(), + noteTemplateCreateServiceRequest.teamId(), + noteTemplateCreateServiceRequest.timeBlockIds(), + noteTemplateCreateServiceRequest.documentIds() + ); + } +} diff --git a/src/main/java/com/tiki/server/note/service/dto/request/NoteFreeCreateServiceRequest.java b/src/main/java/com/tiki/server/note/service/dto/request/NoteFreeCreateServiceRequest.java new file mode 100644 index 00000000..a2eda438 --- /dev/null +++ b/src/main/java/com/tiki/server/note/service/dto/request/NoteFreeCreateServiceRequest.java @@ -0,0 +1,37 @@ +package com.tiki.server.note.service.dto.request; + +import com.tiki.server.note.controller.dto.request.NoteFreeCreateRequest; + +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.util.List; + +public record NoteFreeCreateServiceRequest( + @NotNull String title, + @NotNull boolean complete, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull String contents, + @NotNull long teamId, + @NotNull List timeBlockIds, + @NotNull List documentIds, + @NotNull long memberId +) { + public static NoteFreeCreateServiceRequest of( + final NoteFreeCreateRequest request, + final long memberId + ) { + return new NoteFreeCreateServiceRequest( + request.title(), + request.complete(), + request.startDate(), + request.endDate(), + request.contents(), + request.teamId(), + request.timeBlockIds(), + request.documentIds(), + memberId + ); + } +} diff --git a/src/main/java/com/tiki/server/note/service/dto/request/NoteFreeUpdateServiceRequest.java b/src/main/java/com/tiki/server/note/service/dto/request/NoteFreeUpdateServiceRequest.java new file mode 100644 index 00000000..5c2f544c --- /dev/null +++ b/src/main/java/com/tiki/server/note/service/dto/request/NoteFreeUpdateServiceRequest.java @@ -0,0 +1,41 @@ +package com.tiki.server.note.service.dto.request; + +import com.tiki.server.note.controller.dto.request.NoteFreeUpdateRequest; + +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.util.List; + +public record NoteFreeUpdateServiceRequest( + @NotNull long noteId, + @NotNull String title, + @NotNull boolean complete, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull String contents, + @NotNull long teamId, + @NotNull List timeBlockIds, + @NotNull List documentIds, + @NotNull long memberId +) { + + public static NoteFreeUpdateServiceRequest of( + final NoteFreeUpdateRequest request, + final long noteId, + final long memberId + ) { + return new NoteFreeUpdateServiceRequest( + noteId, + request.title(), + request.complete(), + request.startDate(), + request.endDate(), + request.contents(), + request.teamId(), + request.timeBlockIds(), + request.documentIds(), + memberId + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/service/dto/request/NoteTemplateCreateServiceRequest.java b/src/main/java/com/tiki/server/note/service/dto/request/NoteTemplateCreateServiceRequest.java new file mode 100644 index 00000000..f03c93ce --- /dev/null +++ b/src/main/java/com/tiki/server/note/service/dto/request/NoteTemplateCreateServiceRequest.java @@ -0,0 +1,43 @@ +package com.tiki.server.note.service.dto.request; + +import com.tiki.server.note.controller.dto.request.NoteTemplateCreateRequest; + +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.util.List; + +public record NoteTemplateCreateServiceRequest( + @NotNull String title, + @NotNull boolean complete, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull String answerWhatActivity, + @NotNull String answerHowToPrepare, + @NotNull String answerWhatIsDisappointedThing, + @NotNull String answerHowToFix, + @NotNull long teamId, + @NotNull List timeBlockIds, + @NotNull List documentIds, + @NotNull long memberId +) { + public static NoteTemplateCreateServiceRequest of( + final NoteTemplateCreateRequest request, + final long memberId + ) { + return new NoteTemplateCreateServiceRequest( + request.title(), + request.complete(), + request.startDate(), + request.endDate(), + request.answerWhatActivity(), + request.answerHowToPrepare(), + request.answerWhatIsDisappointedThing(), + request.answerHowToFix(), + request.teamId(), + request.timeBlockIds(), + request.documentIds(), + memberId + ); + } +} diff --git a/src/main/java/com/tiki/server/note/service/dto/request/NoteTemplateUpdateServiceRequest.java b/src/main/java/com/tiki/server/note/service/dto/request/NoteTemplateUpdateServiceRequest.java new file mode 100644 index 00000000..414f2c35 --- /dev/null +++ b/src/main/java/com/tiki/server/note/service/dto/request/NoteTemplateUpdateServiceRequest.java @@ -0,0 +1,47 @@ +package com.tiki.server.note.service.dto.request; + +import com.tiki.server.note.controller.dto.request.NoteTemplateUpdateRequest; + +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.util.List; + +public record NoteTemplateUpdateServiceRequest( + @NotNull long noteId, + @NotNull String title, + @NotNull boolean complete, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull String answerWhatActivity, + @NotNull String answerHowToPrepare, + @NotNull String answerWhatIsDisappointedThing, + @NotNull String answerHowToFix, + @NotNull long teamId, + @NotNull List timeBlockIds, + @NotNull List documentIds, + @NotNull long memberId +) { + + public static NoteTemplateUpdateServiceRequest of( + final NoteTemplateUpdateRequest request, + final long noteId, + final long memberId + ) { + return new NoteTemplateUpdateServiceRequest( + noteId, + request.title(), + request.complete(), + request.startDate(), + request.endDate(), + request.answerWhatActivity(), + request.answerHowToPrepare(), + request.answerWhatIsDisappointedThing(), + request.answerHowToFix(), + request.teamId(), + request.timeBlockIds(), + request.documentIds(), + memberId + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/service/dto/response/NoteCreateServiceResponse.java b/src/main/java/com/tiki/server/note/service/dto/response/NoteCreateServiceResponse.java new file mode 100644 index 00000000..5167e271 --- /dev/null +++ b/src/main/java/com/tiki/server/note/service/dto/response/NoteCreateServiceResponse.java @@ -0,0 +1,11 @@ +package com.tiki.server.note.service.dto.response; + +import jakarta.validation.constraints.NotNull; + +public record NoteCreateServiceResponse( + @NotNull long noteId +) { + public static NoteCreateServiceResponse from(final long noteId) { + return new NoteCreateServiceResponse(noteId); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/service/dto/response/NoteDetailGetServiceResponse.java b/src/main/java/com/tiki/server/note/service/dto/response/NoteDetailGetServiceResponse.java new file mode 100644 index 00000000..1758caea --- /dev/null +++ b/src/main/java/com/tiki/server/note/service/dto/response/NoteDetailGetServiceResponse.java @@ -0,0 +1,11 @@ +package com.tiki.server.note.service.dto.response; + + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema( + description = "NoteDetail 응답 객체", + oneOf = {NoteFreeDetailGetServiceResponse.class, NoteTemplateDetailGetServiceResponse.class} +) +public interface NoteDetailGetServiceResponse { +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/service/dto/response/NoteFreeDetailGetServiceResponse.java b/src/main/java/com/tiki/server/note/service/dto/response/NoteFreeDetailGetServiceResponse.java new file mode 100644 index 00000000..2266832f --- /dev/null +++ b/src/main/java/com/tiki/server/note/service/dto/response/NoteFreeDetailGetServiceResponse.java @@ -0,0 +1,48 @@ +package com.tiki.server.note.service.dto.response; + +import com.tiki.server.common.util.ContentDecoder; +import com.tiki.server.document.entity.Document; +import com.tiki.server.document.service.dto.response.DocumentTagGetServiceResponse; +import com.tiki.server.note.entity.Note; +import com.tiki.server.note.entity.NoteType; +import com.tiki.server.timeblock.entity.TimeBlock; +import com.tiki.server.timeblock.service.dto.response.TimeBlockTagServiceResponse; + +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.util.List; + +public record NoteFreeDetailGetServiceResponse( + @NotNull long noteId, + @NotNull NoteType noteType, + @NotNull String title, + @NotNull String author, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull boolean complete, + @NotNull String contents, + @NotNull List documentList, + @NotNull List timeBlockList +) implements NoteDetailGetServiceResponse { + + public static NoteFreeDetailGetServiceResponse of( + final Note note, + final String author, + final List documentList, + final List timeBlockList + ) { + return new NoteFreeDetailGetServiceResponse( + note.getId(), + NoteType.FREE, + note.getTitle(), + author, + note.getStartDate(), + note.getEndDate(), + note.isComplete(), + ContentDecoder.decodeNoteFree(note.getContents()), + documentList.stream().map(DocumentTagGetServiceResponse::from).toList(), + timeBlockList.stream().map(TimeBlockTagServiceResponse::from).toList() + ); + } +} diff --git a/src/main/java/com/tiki/server/note/service/dto/response/NoteGetResponse.java b/src/main/java/com/tiki/server/note/service/dto/response/NoteGetResponse.java new file mode 100644 index 00000000..25b59495 --- /dev/null +++ b/src/main/java/com/tiki/server/note/service/dto/response/NoteGetResponse.java @@ -0,0 +1,31 @@ +package com.tiki.server.note.service.dto.response; + +import com.tiki.server.note.entity.Note; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import jakarta.validation.constraints.NotNull; + +public record NoteGetResponse( + @NotNull long noteId, + @NotNull String title, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull String author, + @NotNull boolean complete, + @NotNull LocalDateTime lastUpdatedAt +) { + + public static NoteGetResponse of(final Note note, final String author) { + return new NoteGetResponse( + note.getId(), + note.getTitle(), + note.getStartDate(), + note.getEndDate(), + author, + note.isComplete(), + note.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/tiki/server/note/service/dto/response/NoteListGetServiceResponse.java b/src/main/java/com/tiki/server/note/service/dto/response/NoteListGetServiceResponse.java new file mode 100644 index 00000000..e0881cee --- /dev/null +++ b/src/main/java/com/tiki/server/note/service/dto/response/NoteListGetServiceResponse.java @@ -0,0 +1,14 @@ +package com.tiki.server.note.service.dto.response; + +import java.util.List; + +import jakarta.validation.constraints.NotNull; + +public record NoteListGetServiceResponse( + @NotNull List noteGetResponseList +) { + + public static NoteListGetServiceResponse of(final List noteGetResponseList) { + return new NoteListGetServiceResponse(noteGetResponseList); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/note/service/dto/response/NoteTemplateDetailGetServiceResponse.java b/src/main/java/com/tiki/server/note/service/dto/response/NoteTemplateDetailGetServiceResponse.java new file mode 100644 index 00000000..7f252024 --- /dev/null +++ b/src/main/java/com/tiki/server/note/service/dto/response/NoteTemplateDetailGetServiceResponse.java @@ -0,0 +1,55 @@ +package com.tiki.server.note.service.dto.response; + +import com.tiki.server.common.util.ContentDecoder; +import com.tiki.server.document.entity.Document; +import com.tiki.server.document.service.dto.response.DocumentTagGetServiceResponse; +import com.tiki.server.note.entity.Note; +import com.tiki.server.note.entity.NoteType; +import com.tiki.server.timeblock.entity.TimeBlock; +import com.tiki.server.timeblock.service.dto.response.TimeBlockTagServiceResponse; + +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.util.List; + +public record NoteTemplateDetailGetServiceResponse( + @NotNull long noteId, + @NotNull NoteType noteType, + @NotNull String title, + @NotNull String author, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull boolean complete, + @NotNull String answerWhatActivity, + @NotNull String answerHowToPrepare, + @NotNull String answerWhatIsDisappointedThing, + @NotNull String answerHowToFix, + @NotNull List documentList, + @NotNull List timeBlockList +) implements NoteDetailGetServiceResponse { + + public static NoteTemplateDetailGetServiceResponse of( + final Note note, + final String author, + final List documentList, + final List timeBlockList + ) { + List contents = ContentDecoder.decodeNoteTemplate(note.getContents()); + return new NoteTemplateDetailGetServiceResponse( + note.getId(), + NoteType.TEMPLATE, + note.getTitle(), + author, + note.getStartDate(), + note.getEndDate(), + note.isComplete(), + contents.get(0), + contents.get(1), + contents.get(2), + contents.get(3), + documentList.stream().map(DocumentTagGetServiceResponse::from).toList(), + timeBlockList.stream().map(TimeBlockTagServiceResponse::from).toList() + ); + } +} diff --git a/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDDeleter.java b/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDDeleter.java new file mode 100644 index 00000000..ac63ea35 --- /dev/null +++ b/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDDeleter.java @@ -0,0 +1,24 @@ +package com.tiki.server.notedocumentmanager.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.notedocumentmanager.repository.NDRepository; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RepositoryAdapter +@RequiredArgsConstructor +public class NDDeleter { + + private final NDRepository ndRepository; + + public void deleteByNoteIds(final List noteIds) { + noteIds.forEach(ndRepository::deleteAllByNoteId); + } + + public void deleteByNoteIdAndDocumentId(final long noteId, final List documentIds) { + documentIds.forEach(documentId -> + ndRepository.deleteByNoteIdAndDocumentId(noteId, documentId) + ); + } +} diff --git a/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDFinder.java b/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDFinder.java new file mode 100644 index 00000000..52c2350f --- /dev/null +++ b/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDFinder.java @@ -0,0 +1,19 @@ +package com.tiki.server.notedocumentmanager.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.notedocumentmanager.entity.NDManager; +import com.tiki.server.notedocumentmanager.repository.NDRepository; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RepositoryAdapter +@RequiredArgsConstructor +public class NDFinder { + + private final NDRepository ndRepository; + + public List findAllByNoteId(final long noteId){ + return ndRepository.findAllByNoteId(noteId); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDSaver.java b/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDSaver.java new file mode 100644 index 00000000..86c47d88 --- /dev/null +++ b/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDSaver.java @@ -0,0 +1,17 @@ +package com.tiki.server.notedocumentmanager.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.notedocumentmanager.entity.NDManager; +import com.tiki.server.notedocumentmanager.repository.NDRepository; +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class NDSaver { + + private final NDRepository ndRepository; + + public NDManager save(final NDManager ndManager) { + return ndRepository.save(ndManager); + } +} diff --git a/src/main/java/com/tiki/server/notedocumentmanager/entity/NDManager.java b/src/main/java/com/tiki/server/notedocumentmanager/entity/NDManager.java new file mode 100644 index 00000000..7125feea --- /dev/null +++ b/src/main/java/com/tiki/server/notedocumentmanager/entity/NDManager.java @@ -0,0 +1,39 @@ +package com.tiki.server.notedocumentmanager.entity; + +import com.tiki.server.common.entity.BaseTime; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Getter +@Builder(access = PRIVATE) +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +@Table(name = "note_document_manager") +public class NDManager extends BaseTime { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "manager_id") + private Long id; + + @Column(nullable = false) + private long noteId; + + @Column(nullable = false) + private long documentId; + + public static NDManager of(final long noteId, final long documentId) { + return NDManager.builder() + .noteId(noteId) + .documentId(documentId) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/notedocumentmanager/repository/NDRepository.java b/src/main/java/com/tiki/server/notedocumentmanager/repository/NDRepository.java new file mode 100644 index 00000000..f924cc1f --- /dev/null +++ b/src/main/java/com/tiki/server/notedocumentmanager/repository/NDRepository.java @@ -0,0 +1,15 @@ +package com.tiki.server.notedocumentmanager.repository; + +import com.tiki.server.notedocumentmanager.entity.NDManager; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface NDRepository extends JpaRepository { + + void deleteAllByNoteId(final long noteId); + + void deleteByNoteIdAndDocumentId(final long noteId, final long documentId); + + List findAllByNoteId(final long noteId); +} diff --git a/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBDeleter.java b/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBDeleter.java new file mode 100644 index 00000000..13269f49 --- /dev/null +++ b/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBDeleter.java @@ -0,0 +1,24 @@ +package com.tiki.server.notetimeblockmanager.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.notetimeblockmanager.repository.NTBRepository; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RepositoryAdapter +@RequiredArgsConstructor +public class NTBDeleter { + + private final NTBRepository ntbRepository; + + public void noteTimeBlockManagerDeleteByIds(final List noteIds) { + noteIds.forEach(ntbRepository::deleteAllByNoteId); + } + + public void deleteByNoteIdAndTimeBlockId(final long noteId, final List timeBlockIds) { + timeBlockIds.forEach(timeBlockId -> + ntbRepository.deleteByNoteIdAndTimeBlockId(noteId, timeBlockId) + ); + } +} diff --git a/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBFinder.java b/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBFinder.java new file mode 100644 index 00000000..76f9d469 --- /dev/null +++ b/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBFinder.java @@ -0,0 +1,23 @@ +package com.tiki.server.notetimeblockmanager.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.notetimeblockmanager.entity.NTBManager; +import com.tiki.server.notetimeblockmanager.repository.NTBRepository; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RepositoryAdapter +@RequiredArgsConstructor +public class NTBFinder { + + private final NTBRepository ntbRepository; + + public List findAllByNoteId(final long noteId) { + return ntbRepository.findAllByNoteId(noteId); + } + + public List findAllByTimeBlockId(final long timeBlockId) { + return ntbRepository.findAllByTimeBlockId(timeBlockId); + } +} diff --git a/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBSaver.java b/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBSaver.java new file mode 100644 index 00000000..5dd673bc --- /dev/null +++ b/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBSaver.java @@ -0,0 +1,17 @@ +package com.tiki.server.notetimeblockmanager.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.notetimeblockmanager.entity.NTBManager; +import com.tiki.server.notetimeblockmanager.repository.NTBRepository; +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class NTBSaver { + + private final NTBRepository ntbRepository; + + public NTBManager save(final NTBManager ntbManager) { + return ntbRepository.save(ntbManager); + } +} diff --git a/src/main/java/com/tiki/server/notetimeblockmanager/entity/NTBManager.java b/src/main/java/com/tiki/server/notetimeblockmanager/entity/NTBManager.java new file mode 100644 index 00000000..0cf5d45f --- /dev/null +++ b/src/main/java/com/tiki/server/notetimeblockmanager/entity/NTBManager.java @@ -0,0 +1,39 @@ +package com.tiki.server.notetimeblockmanager.entity; + +import com.tiki.server.common.entity.BaseTime; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Getter +@Builder(access = PRIVATE) +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +@Table(name = "note_time_block_manager") +public class NTBManager extends BaseTime { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "manager_id") + private Long id; + + @Column(nullable = false) + private long noteId; + + @Column(nullable = false) + private long timeBlockId; + + public static NTBManager of(final long noteId, final long timeBlockId) { + return NTBManager.builder() + .noteId(noteId) + .timeBlockId(timeBlockId) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/notetimeblockmanager/repository/NTBRepository.java b/src/main/java/com/tiki/server/notetimeblockmanager/repository/NTBRepository.java new file mode 100644 index 00000000..b6a70e8e --- /dev/null +++ b/src/main/java/com/tiki/server/notetimeblockmanager/repository/NTBRepository.java @@ -0,0 +1,17 @@ +package com.tiki.server.notetimeblockmanager.repository; + +import com.tiki.server.notetimeblockmanager.entity.NTBManager; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface NTBRepository extends JpaRepository { + + void deleteAllByNoteId(final long noteId); + + void deleteByNoteIdAndTimeBlockId(final long noteId, final long timeBlockId); + + List findAllByNoteId(final long noteId); + + List findAllByTimeBlockId(final long timeBlockId); +} diff --git a/src/main/java/com/tiki/server/team/adapter/TeamDeleter.java b/src/main/java/com/tiki/server/team/adapter/TeamDeleter.java new file mode 100644 index 00000000..33e71601 --- /dev/null +++ b/src/main/java/com/tiki/server/team/adapter/TeamDeleter.java @@ -0,0 +1,18 @@ +package com.tiki.server.team.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.team.entity.Team; +import com.tiki.server.team.repository.TeamRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class TeamDeleter { + + private final TeamRepository teamRepository; + + public void deleteById(final long teamId) { + teamRepository.deleteById(teamId); + } +} diff --git a/src/main/java/com/tiki/server/team/adapter/TeamFinder.java b/src/main/java/com/tiki/server/team/adapter/TeamFinder.java new file mode 100644 index 00000000..3c86d12d --- /dev/null +++ b/src/main/java/com/tiki/server/team/adapter/TeamFinder.java @@ -0,0 +1,29 @@ +package com.tiki.server.team.adapter; + +import static com.tiki.server.team.message.ErrorCode.INVALID_TEAM; + +import com.tiki.server.common.entity.University; +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.team.entity.Team; +import com.tiki.server.team.exception.TeamException; +import com.tiki.server.team.repository.TeamRepository; + +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RepositoryAdapter +@RequiredArgsConstructor +public class TeamFinder { + + private final TeamRepository teamRepository; + + public Team findById(final long teamId) { + return teamRepository.findById(teamId) + .orElseThrow(() -> new TeamException(INVALID_TEAM)); + } + + public List findAllByUniv(final University univ) { + return teamRepository.findAllByUniv(univ); + } +} diff --git a/src/main/java/com/tiki/server/team/adapter/TeamSaver.java b/src/main/java/com/tiki/server/team/adapter/TeamSaver.java new file mode 100644 index 00000000..aa7f4cd7 --- /dev/null +++ b/src/main/java/com/tiki/server/team/adapter/TeamSaver.java @@ -0,0 +1,18 @@ +package com.tiki.server.team.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.team.entity.Team; +import com.tiki.server.team.repository.TeamRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class TeamSaver { + + private final TeamRepository teamRepository; + + public Team save(final Team team) { + return teamRepository.save(team); + } +} diff --git a/src/main/java/com/tiki/server/team/controller/TeamController.java b/src/main/java/com/tiki/server/team/controller/TeamController.java new file mode 100644 index 00000000..b95932ed --- /dev/null +++ b/src/main/java/com/tiki/server/team/controller/TeamController.java @@ -0,0 +1,122 @@ +package com.tiki.server.team.controller; + +import static com.tiki.server.team.message.SuccessMessage.SUCCESS_ALTER_AUTHORITY; +import static com.tiki.server.team.message.SuccessMessage.SUCCESS_CREATE_TEAM; +import static com.tiki.server.team.message.SuccessMessage.SUCCESS_GET_CAPACITY_INFO; +import static com.tiki.server.team.message.SuccessMessage.SUCCESS_GET_CATEGORIES; +import static com.tiki.server.team.message.SuccessMessage.SUCCESS_GET_TEAMS; +import static com.tiki.server.team.message.SuccessMessage.SUCCESS_GET_TEAM_INFORM; +import static com.tiki.server.team.message.SuccessMessage.SUCCESS_UPDATE_TEAM_NAME; + +import java.security.Principal; + +import com.tiki.server.team.dto.request.TeamInformUpdateRequest; +import com.tiki.server.team.dto.request.TeamInformUpdateServiceRequest; +import com.tiki.server.team.dto.response.UsageGetResponse; +import com.tiki.server.team.dto.response.CategoriesGetResponse; +import com.tiki.server.team.dto.response.TeamsGetResponse; + +import org.springframework.http.HttpStatus; +import com.tiki.server.team.service.dto.response.TeamInformGetResponse; +import org.springframework.web.bind.annotation.*; + +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.team.controller.docs.TeamControllerDocs; +import com.tiki.server.team.dto.request.TeamCreateRequest; +import com.tiki.server.team.dto.response.TeamCreateResponse; +import com.tiki.server.team.service.TeamService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/teams") +public class TeamController implements TeamControllerDocs { + + private final TeamService teamService; + + @Override + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public SuccessResponse createTeam( + final Principal principal, + @RequestBody final TeamCreateRequest request + ) { + long memberId = Long.parseLong(principal.getName()); + TeamCreateResponse response = teamService.createTeam(memberId, request); + return SuccessResponse.success(SUCCESS_CREATE_TEAM.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping + public SuccessResponse getAllTeams(final Principal principal) { + long memberId = Long.parseLong(principal.getName()); + TeamsGetResponse response = teamService.getAllTeams(memberId); + return SuccessResponse.success(SUCCESS_GET_TEAMS.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/category") + public SuccessResponse getCategories() { + CategoriesGetResponse response = teamService.getCategories(); + return SuccessResponse.success(SUCCESS_GET_CATEGORIES.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/{teamId}") + public void deleteTeam( + final Principal principal, + @PathVariable final long teamId + ) { + long memberId = Long.parseLong(principal.getName()); + teamService.deleteTeam(memberId, teamId); + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping("/{teamId}/inform") + public SuccessResponse getTeamName( + @PathVariable final long teamId + ) { + TeamInformGetResponse response = teamService.getTeamInform(teamId); + return SuccessResponse.success(SUCCESS_GET_TEAM_INFORM.getMessage(), response); + } + + @ResponseStatus(HttpStatus.OK) + @PatchMapping("/{teamId}/inform") + public SuccessResponse updateTeamInform( + final Principal principal, + @PathVariable final long teamId, + @RequestBody final TeamInformUpdateRequest request + ) { + long memberId = Long.parseLong(principal.getName()); + teamService.updateTeamInform(TeamInformUpdateServiceRequest.from(request,memberId,teamId)); + return SuccessResponse.success(SUCCESS_UPDATE_TEAM_NAME.getMessage()); + } + + @ResponseStatus(HttpStatus.OK) + @PatchMapping("/{teamId}/member/{targetId}/admin") + public SuccessResponse alterAdmin( + final Principal principal, + @PathVariable final long teamId, + @PathVariable final long targetId + ) { + long memberId = Long.parseLong(principal.getName()); + teamService.alterAdmin(memberId, teamId, targetId); + return SuccessResponse.success(SUCCESS_ALTER_AUTHORITY.getMessage()); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/{teamId}/capacity") + public SuccessResponse getCapacityInfo( + final Principal principal, + @PathVariable final long teamId + ) { + long memberId = Long.parseLong(principal.getName()); + UsageGetResponse response = teamService.getCapacityInfo(memberId, teamId); + return SuccessResponse.success(SUCCESS_GET_CAPACITY_INFO.getMessage(), response); + } +} diff --git a/src/main/java/com/tiki/server/team/controller/docs/TeamControllerDocs.java b/src/main/java/com/tiki/server/team/controller/docs/TeamControllerDocs.java new file mode 100644 index 00000000..f94a8e21 --- /dev/null +++ b/src/main/java/com/tiki/server/team/controller/docs/TeamControllerDocs.java @@ -0,0 +1,144 @@ +package com.tiki.server.team.controller.docs; + +import java.security.Principal; + +import com.tiki.server.team.dto.response.UsageGetResponse; +import com.tiki.server.team.dto.response.CategoriesGetResponse; +import com.tiki.server.team.dto.response.TeamsGetResponse; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import com.tiki.server.common.dto.ErrorResponse; +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.team.dto.request.TeamCreateRequest; +import com.tiki.server.team.dto.response.TeamCreateResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "teams", description = "팀 API") +public interface TeamControllerDocs { + + @Operation( + summary = "팀 생성", + description = "팀을 생성한다.", + responses = { + @ApiResponse(responseCode = "201", description = "성공"), + @ApiResponse( + responseCode = "404", + description = "유효하지 않은 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse createTeam( + @Parameter(hidden = true) final Principal principal, + @RequestBody final TeamCreateRequest request + ); + + @Operation( + summary = "전체 팀 조회", + description = "가입한 대학의 전체 팀을 조회한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "404", + description = "유효하지 않은 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getAllTeams( + @Parameter(hidden = true) final Principal principal + ); + + @Operation( + summary = "카테고리 조회", + description = "카테고리 리스트를 조회한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getCategories(); + + @Operation( + summary = "팀 삭제", + description = "팀을 삭제한다.", + responses = { + @ApiResponse(responseCode = "204", description = "성공"), + @ApiResponse( + responseCode = "404", + description = "유효하지 않은 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + void deleteTeam( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + required = true, + example = "1" + ) + @PathVariable final long teamId + ); + + @Operation( + summary = "팀 용량 정보 조회", + description = "팀 용량 정보를 조회한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getCapacityInfo( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + required = true, + example = "1" + ) + @PathVariable final long teamId + ); +} diff --git a/src/main/java/com/tiki/server/team/dto/request/TeamCreateRequest.java b/src/main/java/com/tiki/server/team/dto/request/TeamCreateRequest.java new file mode 100644 index 00000000..3fa77a7f --- /dev/null +++ b/src/main/java/com/tiki/server/team/dto/request/TeamCreateRequest.java @@ -0,0 +1,13 @@ +package com.tiki.server.team.dto.request; + +import com.tiki.server.team.entity.Category; + +import jakarta.validation.constraints.NotNull; +import lombok.NonNull; + +public record TeamCreateRequest( + @NotNull String name, + @NotNull Category category, + @NotNull String iconImageUrl +) { +} diff --git a/src/main/java/com/tiki/server/team/dto/request/TeamInformUpdateRequest.java b/src/main/java/com/tiki/server/team/dto/request/TeamInformUpdateRequest.java new file mode 100644 index 00000000..61e5bc31 --- /dev/null +++ b/src/main/java/com/tiki/server/team/dto/request/TeamInformUpdateRequest.java @@ -0,0 +1,9 @@ +package com.tiki.server.team.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record TeamInformUpdateRequest( + @NotNull String teamName, + @NotNull String teamUrl +) { +} diff --git a/src/main/java/com/tiki/server/team/dto/request/TeamInformUpdateServiceRequest.java b/src/main/java/com/tiki/server/team/dto/request/TeamInformUpdateServiceRequest.java new file mode 100644 index 00000000..dea104ca --- /dev/null +++ b/src/main/java/com/tiki/server/team/dto/request/TeamInformUpdateServiceRequest.java @@ -0,0 +1,19 @@ +package com.tiki.server.team.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record TeamInformUpdateServiceRequest( + @NotNull long memberId, + @NotNull long teamId, + @NotNull String teamName, + @NotNull String teamIconUrl +) { + public static TeamInformUpdateServiceRequest from(final TeamInformUpdateRequest request, final long memberId, final long teamId) { + return new TeamInformUpdateServiceRequest( + memberId, + teamId, + request.teamName(), + request.teamUrl() + ); + } +} diff --git a/src/main/java/com/tiki/server/team/dto/response/CategoriesGetResponse.java b/src/main/java/com/tiki/server/team/dto/response/CategoriesGetResponse.java new file mode 100644 index 00000000..3301aa4d --- /dev/null +++ b/src/main/java/com/tiki/server/team/dto/response/CategoriesGetResponse.java @@ -0,0 +1,23 @@ +package com.tiki.server.team.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import java.util.Arrays; +import java.util.List; + +import com.tiki.server.team.entity.Category; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record CategoriesGetResponse( + @NotNull List categories +) { + + public static CategoriesGetResponse from(final Category[] categories) { + return CategoriesGetResponse.builder() + .categories(Arrays.stream(categories).toList()) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/team/dto/response/TeamCreateResponse.java b/src/main/java/com/tiki/server/team/dto/response/TeamCreateResponse.java new file mode 100644 index 00000000..ccce0e43 --- /dev/null +++ b/src/main/java/com/tiki/server/team/dto/response/TeamCreateResponse.java @@ -0,0 +1,20 @@ +package com.tiki.server.team.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import com.tiki.server.team.entity.Team; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record TeamCreateResponse( + @NotNull long teamId +) { + + public static TeamCreateResponse from(final Team team) { + return TeamCreateResponse.builder() + .teamId(team.getId()) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/team/dto/response/TeamGetResponse.java b/src/main/java/com/tiki/server/team/dto/response/TeamGetResponse.java new file mode 100644 index 00000000..4d6b37f3 --- /dev/null +++ b/src/main/java/com/tiki/server/team/dto/response/TeamGetResponse.java @@ -0,0 +1,32 @@ +package com.tiki.server.team.dto.response; + +import com.tiki.server.common.entity.University; +import com.tiki.server.team.entity.Category; +import com.tiki.server.team.entity.Team; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import static lombok.AccessLevel.PRIVATE; + +@Builder(access = PRIVATE) +public record TeamGetResponse( + @NotNull long teamId, + @NotNull String name, + @NotNull Category category, + @NotNull University univ, + @NotNull String overview, + @NotNull String imageUrl +) { + public static TeamGetResponse from(final Team team) { + return TeamGetResponse.builder() + .teamId(team.getId()) + .name(team.getName()) + .overview(team.getOverview()) + .category(team.getCategory()) + .univ(team.getUniv()) + .imageUrl(team.getImageUrl()) + .build(); + } + +} diff --git a/src/main/java/com/tiki/server/team/dto/response/TeamsGetResponse.java b/src/main/java/com/tiki/server/team/dto/response/TeamsGetResponse.java new file mode 100644 index 00000000..b685083b --- /dev/null +++ b/src/main/java/com/tiki/server/team/dto/response/TeamsGetResponse.java @@ -0,0 +1,21 @@ +package com.tiki.server.team.dto.response; + +import com.tiki.server.team.entity.Team; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import java.util.List; + +import static lombok.AccessLevel.PRIVATE; + +@Builder(access = PRIVATE) +public record TeamsGetResponse( + @NotNull List teams +) { + public static TeamsGetResponse from(final List teams) { + return TeamsGetResponse.builder() + .teams(teams.stream().map(TeamGetResponse::from).toList()) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/team/dto/response/UsageGetResponse.java b/src/main/java/com/tiki/server/team/dto/response/UsageGetResponse.java new file mode 100644 index 00000000..c182033f --- /dev/null +++ b/src/main/java/com/tiki/server/team/dto/response/UsageGetResponse.java @@ -0,0 +1,20 @@ +package com.tiki.server.team.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record UsageGetResponse( + @NotNull long capacity, + @NotNull long usage +) { + + public static UsageGetResponse of(final long capacity, final long usage) { + return UsageGetResponse.builder() + .capacity(capacity) + .usage(usage) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/team/entity/Category.java b/src/main/java/com/tiki/server/team/entity/Category.java new file mode 100644 index 00000000..36163952 --- /dev/null +++ b/src/main/java/com/tiki/server/team/entity/Category.java @@ -0,0 +1,8 @@ +package com.tiki.server.team.entity; + +import lombok.Getter; + +@Getter +public enum Category { + 전체, 학술연구, 문화예술, 스포츠레저, 사회활동, 취미활동, 창업비즈니스, 과학기술, 종교, 국제교류, 네트워킹, +} diff --git a/src/main/java/com/tiki/server/team/entity/Subscribe.java b/src/main/java/com/tiki/server/team/entity/Subscribe.java new file mode 100644 index 00000000..9d380dba --- /dev/null +++ b/src/main/java/com/tiki/server/team/entity/Subscribe.java @@ -0,0 +1,22 @@ +package com.tiki.server.team.entity; + +import lombok.Getter; + +@Getter +public enum Subscribe { + BASIC(6000, 30L * 1073741824, 30, false), + ADVANCED(13000, 150L * 1073741824, 120, true), + PREMIUM(25000, 500L * 1073741824, 999999, true); + + private final int price; + private final long capacity; + private final int memberLimit; + private final boolean bannerDiscount; + + Subscribe(final int price, final long capacity, final int memberLimit, final boolean bannerDiscount) { + this.price = price; + this.capacity = capacity; + this.memberLimit = memberLimit; + this.bannerDiscount = bannerDiscount; + } +} diff --git a/src/main/java/com/tiki/server/team/entity/Team.java b/src/main/java/com/tiki/server/team/entity/Team.java new file mode 100644 index 00000000..89733e5a --- /dev/null +++ b/src/main/java/com/tiki/server/team/entity/Team.java @@ -0,0 +1,131 @@ +package com.tiki.server.team.entity; + +import static com.tiki.server.common.constants.Constants.INIT_NUM; +import static com.tiki.server.team.entity.Subscribe.BASIC; +import static com.tiki.server.team.message.ErrorCode.EXCEED_TEAM_CAPACITY; +import static com.tiki.server.team.message.ErrorCode.TOO_SHORT_PERIOD; +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import com.tiki.server.common.entity.BaseTime; +import com.tiki.server.common.entity.University; +import com.tiki.server.team.dto.request.TeamCreateRequest; + +import com.tiki.server.team.exception.TeamException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder(access = PRIVATE) +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +public class Team extends BaseTime { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "team_id") + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String overview; + + @Column(nullable = false) + @Enumerated(value = STRING) + private Category category; + + @Column(nullable = false) + @Enumerated(value = STRING) + private University univ; + + @Column(nullable = false) + @Enumerated(value = STRING) + private Subscribe subscribe; + + @Column(nullable = false) + private long usage; + + private String imageUrl; + + private String iconImageUrl; + + private LocalDate namingUpdatedAt; + + public static Team of(final TeamCreateRequest request, final University univ) { + return Team.builder() + .name(request.name()) + .overview("") + .category(request.category()) + .univ(univ) + .subscribe(BASIC) + .usage(INIT_NUM) + .iconImageUrl(request.iconImageUrl()) + .namingUpdatedAt(LocalDate.now()) + .build(); + } + + public void updateInform(final String name, final String iconImageUrl) { + if (!name.equals(this.name)) { + updateTeamName(name); + } + if(!iconImageUrl.equals(this.iconImageUrl)){ + updateIconImageUrl(iconImageUrl); + } + } + + private void updateTeamName(final String name) { + if (!canChangeName()) { + throw new TeamException(TOO_SHORT_PERIOD); + } + this.name = name; + this.namingUpdatedAt = LocalDate.now(); + } + + public void updateIconImageUrl(final String url) { + this.iconImageUrl = url; + } + + public boolean isDefaultImage() { + return this.iconImageUrl.isBlank(); + } + + public boolean isSameIconUrl(final String iconImageUrl) { + return this.iconImageUrl.equals(iconImageUrl); + } + + public void addUsage(final long capacity) { + if (usage + capacity > subscribe.getCapacity()) { + throw new TeamException(EXCEED_TEAM_CAPACITY); + } + usage += capacity; + } + + public void restoreUsage(final long capacity) { + usage -= capacity; + } + + public long getCapacity() { + return subscribe.getCapacity(); + } + + private boolean canChangeName() { + long daysBetween = ChronoUnit.DAYS.between(namingUpdatedAt, LocalDate.now()); + return daysBetween >= 30; + } +} diff --git a/src/main/java/com/tiki/server/team/exception/TeamException.java b/src/main/java/com/tiki/server/team/exception/TeamException.java new file mode 100644 index 00000000..6e80cafa --- /dev/null +++ b/src/main/java/com/tiki/server/team/exception/TeamException.java @@ -0,0 +1,16 @@ +package com.tiki.server.team.exception; + +import com.tiki.server.team.message.ErrorCode; + +import lombok.Getter; + +@Getter +public class TeamException extends RuntimeException { + + private final ErrorCode errorCode; + + public TeamException(final ErrorCode errorCode) { + super("[TeamException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/tiki/server/team/message/ErrorCode.java b/src/main/java/com/tiki/server/team/message/ErrorCode.java new file mode 100644 index 00000000..3aac099a --- /dev/null +++ b/src/main/java/com/tiki/server/team/message/ErrorCode.java @@ -0,0 +1,27 @@ +package com.tiki.server.team.message; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import static org.springframework.http.HttpStatus.*; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + /* 400 BAD_REQUEST : 잘못된 요청 */ + TOO_HIGH_AUTHORIZATION(BAD_REQUEST, "어드민은 진행할 수 없습니다."), + TOO_SHORT_PERIOD(BAD_REQUEST, "30일이 지나야 이름을 변경할 수 있습니다."), + EXCEED_TEAM_CAPACITY(BAD_REQUEST, "팀 사용 가능 용량을 초과하였습니다."), + + /* 403 FORBIDDEN : 권한 없음 */ + INVALID_AUTHORIZATION_DELETE(FORBIDDEN, "권한이 없습니다."), + + /* 404 NOT_FOUND : 자원을 찾을 수 없음 */ + INVALID_TEAM(NOT_FOUND, "유효하지 않은 단체입니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/tiki/server/team/message/SuccessMessage.java b/src/main/java/com/tiki/server/team/message/SuccessMessage.java new file mode 100644 index 00000000..e4ba24dc --- /dev/null +++ b/src/main/java/com/tiki/server/team/message/SuccessMessage.java @@ -0,0 +1,21 @@ +package com.tiki.server.team.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + SUCCESS_CREATE_TEAM("팀 생성 성공"), + SUCCESS_UPDATE_TEAM_NAME("팀 정보 변경 성공"), + SUCCESS_UPDATE_TEAM_ICON("팀 아이콘 변경 성공"), + SUCCESS_ALTER_AUTHORITY("어드민 권한 위임 성공"), + SUCCESS_GET_TEAMS("전체 팀 불러오기 성공"), + SUCCESS_GET_CATEGORIES("카테고리 리스트 불러오기 성공"), + SUCCESS_GET_TEAM_INFORM("팀 설정 정보 불러오기 성공"), + SUCCESS_GET_JOINED_TEAM("소속 팀 불러오기 성공"), + SUCCESS_GET_CAPACITY_INFO("팀 용량 정보 조회 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/team/repository/TeamRepository.java b/src/main/java/com/tiki/server/team/repository/TeamRepository.java new file mode 100644 index 00000000..eecf0292 --- /dev/null +++ b/src/main/java/com/tiki/server/team/repository/TeamRepository.java @@ -0,0 +1,12 @@ +package com.tiki.server.team.repository; + +import com.tiki.server.common.entity.University; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.tiki.server.team.entity.Team; +import java.util.List; + +public interface TeamRepository extends JpaRepository { + + List findAllByUniv(final University university); +} diff --git a/src/main/java/com/tiki/server/team/service/TeamService.java b/src/main/java/com/tiki/server/team/service/TeamService.java new file mode 100644 index 00000000..46c8fc05 --- /dev/null +++ b/src/main/java/com/tiki/server/team/service/TeamService.java @@ -0,0 +1,134 @@ +package com.tiki.server.team.service; + +import static com.tiki.server.common.entity.Position.ADMIN; + +import java.util.List; + +import com.tiki.server.document.adapter.DocumentDeleter; +import com.tiki.server.document.adapter.DocumentFinder; +import com.tiki.server.document.entity.Document; +import com.tiki.server.external.util.AwsHandler; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerDeleter; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder; +import com.tiki.server.team.adapter.TeamDeleter; +import com.tiki.server.team.adapter.TeamFinder; +import com.tiki.server.team.dto.request.TeamInformUpdateServiceRequest; +import com.tiki.server.team.dto.response.CategoriesGetResponse; +import com.tiki.server.team.dto.response.TeamsGetResponse; + +import com.tiki.server.team.dto.response.UsageGetResponse; +import com.tiki.server.team.service.dto.response.TeamInformGetResponse; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.tiki.server.common.entity.Position; +import com.tiki.server.common.entity.University; +import com.tiki.server.member.adapter.MemberFinder; +import com.tiki.server.member.entity.Member; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerSaver; +import com.tiki.server.memberteammanager.entity.MemberTeamManager; +import com.tiki.server.team.adapter.TeamSaver; +import com.tiki.server.team.dto.request.TeamCreateRequest; +import com.tiki.server.team.dto.response.TeamCreateResponse; +import com.tiki.server.team.entity.Category; +import com.tiki.server.team.entity.Team; +import com.tiki.server.timeblock.adapter.TimeBlockDeleter; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TeamService { + + private final TeamSaver teamSaver; + private final TeamFinder teamFinder; + private final TeamDeleter teamDeleter; + private final MemberFinder memberFinder; + private final DocumentFinder documentFinder; + private final DocumentDeleter documentDeleter; + private final TimeBlockDeleter timeBlockDeleter; + private final MemberTeamManagerFinder memberTeamManagerFinder; + private final MemberTeamManagerDeleter memberTeamManagerDeleter; + private final MemberTeamManagerSaver memberTeamManagerSaver; + private final AwsHandler awsHandler; + + @Transactional + public TeamCreateResponse createTeam(final long memberId, final TeamCreateRequest request) { + Member member = memberFinder.findById(memberId); + Team team = teamSaver.save(createTeam(request, member.getUniv())); + memberTeamManagerSaver.save(createMemberTeamManager(member, team, ADMIN)); + return TeamCreateResponse.from(team); + } + + public TeamsGetResponse getAllTeams(final long memberId) { + Member member = memberFinder.findById(memberId); + University univ = member.getUniv(); + List team = teamFinder.findAllByUniv(univ); + return TeamsGetResponse.from(team); + } + + public CategoriesGetResponse getCategories() { + Category[] categories = Category.values(); + return CategoriesGetResponse.from(categories); + } + + @Transactional + public void deleteTeam(final long memberId, final long teamId) { + checkIsAdmin(memberId, teamId); + List memberTeamManagers = memberTeamManagerFinder.findAllByTeamId(teamId); + memberTeamManagerDeleter.deleteAll(memberTeamManagers); + List documents = documentFinder.findAllByTeamId(teamId); + documentDeleter.deleteAll(documents); + timeBlockDeleter.deleteAllByTeamId(teamId); + teamDeleter.deleteById(teamId); + } + + public TeamInformGetResponse getTeamInform(final long teamId) { + return TeamInformGetResponse.from(teamFinder.findById(teamId)); + } + + private Team createTeam(final TeamCreateRequest request, final University univ) { + return Team.of(request, univ); + } + + @Transactional + public void updateTeamInform(final TeamInformUpdateServiceRequest request) { + checkIsAdmin(request.memberId(), request.teamId()); + Team team = teamFinder.findById(request.teamId()); + team.updateInform(request.teamName(), request.teamIconUrl()); + updateIconUrlS3(team, request.teamIconUrl()); + } + + @Transactional + public void alterAdmin(final long memberId, final long teamId, final long targetId) { + MemberTeamManager oldAdmin = checkIsAdmin(memberId, teamId); + MemberTeamManager newAdmin = memberTeamManagerFinder.findByMemberIdAndTeamId(targetId, teamId); + oldAdmin.updatePositionToExecutive(); + newAdmin.updatePositionToAdmin(); + } + + public UsageGetResponse getCapacityInfo(final long memberId, final long teamId) { + memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + Team team = teamFinder.findById(teamId); + long capacity = team.getCapacity(); + long usage = team.getUsage(); + return UsageGetResponse.of(capacity, usage); + } + + private MemberTeamManager createMemberTeamManager(final Member member, final Team team, final Position position) { + return MemberTeamManager.of(member, team, position); + } + + private void updateIconUrlS3(final Team team, final String iconUrl) { + if (!team.isDefaultImage() && !team.isSameIconUrl(iconUrl)) { + awsHandler.deleteFile(team.getIconImageUrl()); + } + } + + private MemberTeamManager checkIsAdmin(final long memberId, final long teamId) { + MemberTeamManager accessMember = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + accessMember.checkMemberAccessible(ADMIN); + return accessMember; + } +} diff --git a/src/main/java/com/tiki/server/team/service/dto/response/TeamInformGetResponse.java b/src/main/java/com/tiki/server/team/service/dto/response/TeamInformGetResponse.java new file mode 100644 index 00000000..14811941 --- /dev/null +++ b/src/main/java/com/tiki/server/team/service/dto/response/TeamInformGetResponse.java @@ -0,0 +1,19 @@ +package com.tiki.server.team.service.dto.response; + +import com.tiki.server.common.entity.University; +import com.tiki.server.team.entity.Team; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record TeamInformGetResponse( + @NotNull String teamName, + @NotNull University university, + @NotNull String teamIconUrl, + @NotNull LocalDate namingUpdatedAt + ) { + + public static TeamInformGetResponse from(final Team team) { + return new TeamInformGetResponse(team.getName(),team.getUniv(), team.getIconImageUrl(),team.getNamingUpdatedAt()); + } +} diff --git a/src/main/java/com/tiki/server/timeblock/adapter/TimeBlockDeleter.java b/src/main/java/com/tiki/server/timeblock/adapter/TimeBlockDeleter.java new file mode 100644 index 00000000..8bb0a736 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/adapter/TimeBlockDeleter.java @@ -0,0 +1,26 @@ +package com.tiki.server.timeblock.adapter; + +import java.util.HashSet; +import java.util.List; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.team.entity.Team; +import com.tiki.server.timeblock.entity.TimeBlock; +import com.tiki.server.timeblock.repository.TimeBlockRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class TimeBlockDeleter { + + private final TimeBlockRepository timeBlockRepository; + + public void deleteById(final long id) { + timeBlockRepository.deleteById(id); + } + + public void deleteAllByTeamId(long teamId) { + timeBlockRepository.deleteAllByTeamId(teamId); + } +} diff --git a/src/main/java/com/tiki/server/timeblock/adapter/TimeBlockFinder.java b/src/main/java/com/tiki/server/timeblock/adapter/TimeBlockFinder.java new file mode 100644 index 00000000..aa8ce138 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/adapter/TimeBlockFinder.java @@ -0,0 +1,46 @@ +package com.tiki.server.timeblock.adapter; + +import static com.tiki.server.timeblock.message.ErrorCode.*; + +import java.util.List; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.timeblock.entity.TimeBlock; +import com.tiki.server.timeblock.exception.TimeBlockException; +import com.tiki.server.timeblock.repository.TimeBlockRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class TimeBlockFinder { + + private final TimeBlockRepository timeBlockRepository; + + public TimeBlock findById(final long id) { + return timeBlockRepository.findById(id) + .orElseThrow(() -> new TimeBlockException(INVALID_TIME_BLOCK)); + } + + public TimeBlock findByIdAndTeamId(final long id, final long teamId) { + return timeBlockRepository.findByIdAndTeamId(id, teamId) + .orElseThrow(() -> new TimeBlockException(INVALID_TIME_BLOCK)); + } + + public List findAllByTeamId(final long teamId){ + return timeBlockRepository.findAllByTeamId(teamId); + } + + public List findByTeamAndAccessiblePositionAndDate( + final long teamId, + final String accessiblePosition, + final String date + ) { + return timeBlockRepository.findByTeamAndAccessiblePositionAndDate(teamId, accessiblePosition, date).stream() + .toList(); + } + + public boolean existsById(final Long timeBlockId) { + return timeBlockRepository.existsById(timeBlockId); + } +} diff --git a/src/main/java/com/tiki/server/timeblock/adapter/TimeBlockSaver.java b/src/main/java/com/tiki/server/timeblock/adapter/TimeBlockSaver.java new file mode 100644 index 00000000..c5818de4 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/adapter/TimeBlockSaver.java @@ -0,0 +1,18 @@ +package com.tiki.server.timeblock.adapter; + +import com.tiki.server.common.support.RepositoryAdapter; +import com.tiki.server.timeblock.entity.TimeBlock; +import com.tiki.server.timeblock.repository.TimeBlockRepository; + +import lombok.RequiredArgsConstructor; + +@RepositoryAdapter +@RequiredArgsConstructor +public class TimeBlockSaver { + + private final TimeBlockRepository timeBlockRepository; + + public TimeBlock save(final TimeBlock timeBlock) { + return timeBlockRepository.save(timeBlock); + } +} diff --git a/src/main/java/com/tiki/server/timeblock/controller/TimeBlockController.java b/src/main/java/com/tiki/server/timeblock/controller/TimeBlockController.java new file mode 100644 index 00000000..61cfafb1 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/controller/TimeBlockController.java @@ -0,0 +1,149 @@ +package com.tiki.server.timeblock.controller; + +import static com.tiki.server.timeblock.message.SuccessMessage.SUCCESS_CREATE_DOCUMENT_TAG; +import static com.tiki.server.timeblock.message.SuccessMessage.SUCCESS_CREATE_TIME_BLOCK; +import static com.tiki.server.timeblock.message.SuccessMessage.SUCCESS_GET_ALL_TIME_BLOCK; +import static com.tiki.server.timeblock.message.SuccessMessage.SUCCESS_GET_TIMELINE; +import static com.tiki.server.timeblock.message.SuccessMessage.SUCCESS_GET_TIME_BLOCK_DETAIL; +import static com.tiki.server.timeblock.message.SuccessMessage.SUCCESS_UPDATE_TIME_BLOCK; + +import com.tiki.server.timeblock.dto.request.TimeBlockUpdateRequest; +import com.tiki.server.timeblock.service.dto.response.AllTimeBlockServiceResponse; +import java.security.Principal; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.timeblock.controller.docs.TimeBlockControllerDocs; +import com.tiki.server.timeblock.dto.request.TimeBlockCreateRequest; +import com.tiki.server.timeblock.dto.response.TimeBlockCreateResponse; +import com.tiki.server.timeblock.dto.response.TimeBlockDetailGetResponse; +import com.tiki.server.timeblock.dto.response.TimelineGetResponse; +import com.tiki.server.timeblock.service.TimeBlockService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1") +public class TimeBlockController implements TimeBlockControllerDocs { + + private final TimeBlockService timeBlockService; + + @Override + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/teams/{teamId}/time-block") + public SuccessResponse createTimeBlock( + final Principal principal, + @PathVariable final long teamId, + @RequestParam final String type, + @RequestBody final TimeBlockCreateRequest request + ) { + long memberId = Long.parseLong(principal.getName()); + TimeBlockCreateResponse response = timeBlockService.createTimeBlock(memberId, teamId, type, request); + return SuccessResponse.success(SUCCESS_CREATE_TIME_BLOCK.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/teams/{teamId}/timeline") + public SuccessResponse getTimeline( + final Principal principal, + @PathVariable final long teamId, + @RequestParam final String type, + @RequestParam final String date + ) { + long memberId = Long.parseLong(principal.getName()); + TimelineGetResponse response = timeBlockService.getTimeline(memberId, teamId, type, date); + return SuccessResponse.success(SUCCESS_GET_TIMELINE.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/teams/{teamId}/time-block/all") + public SuccessResponse getAllTimeBlock( + final Principal principal, + @PathVariable final long teamId + ) { + long memberId = Long.parseLong(principal.getName()); + AllTimeBlockServiceResponse response = timeBlockService.getAllTimeBlock(memberId, teamId); + return SuccessResponse.success(SUCCESS_GET_ALL_TIME_BLOCK.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @GetMapping("/teams/{teamId}/time-block/{timeBlockId}") + public SuccessResponse getTimeBlockDetail( + final Principal principal, + @PathVariable final long teamId, + @PathVariable final long timeBlockId + ) { + long memberId = Long.parseLong(principal.getName()); + TimeBlockDetailGetResponse response = timeBlockService.getTimeBlockDetail(memberId, teamId, timeBlockId); + return SuccessResponse.success(SUCCESS_GET_TIME_BLOCK_DETAIL.getMessage(), response); + } + + @Override + @ResponseStatus(HttpStatus.OK) + @PatchMapping("/teams/{teamId}/time-block/{timeBlockId}") + public SuccessResponse updateTimeBlock( + final Principal principal, + @PathVariable final long teamId, + @PathVariable final long timeBlockId, + @RequestBody final TimeBlockUpdateRequest request + ) { + long memberId = Long.parseLong(principal.getName()); + timeBlockService.updateTimeBlock(memberId, teamId, timeBlockId, request); + return SuccessResponse.success(SUCCESS_UPDATE_TIME_BLOCK.getMessage()); + } + + @Override + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/teams/{teamId}/time-block/{timeBlockId}") + public void deleteTimeBlock( + final Principal principal, + @PathVariable final long teamId, + @PathVariable final long timeBlockId + ) { + long memberId = Long.parseLong(principal.getName()); + timeBlockService.deleteTimeBlock(memberId, teamId, timeBlockId); + } + + @Override + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/teams/{teamId}/time-block/{timeBlockId}") + public SuccessResponse createDocumentTag( + final Principal principal, + @PathVariable final long teamId, + @PathVariable final long timeBlockId, + @RequestParam("documentId") final List documentIds + ) { + long memberId = Long.parseLong(principal.getName()); + timeBlockService.createDocumentTag(memberId, teamId, timeBlockId, documentIds); + return SuccessResponse.success(SUCCESS_CREATE_DOCUMENT_TAG.getMessage()); + } + + @Override + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/teams/{teamId}/time-block/{timeBlockId}/tags") + public void deleteDocumentTag( + final Principal principal, + @PathVariable final long teamId, + @PathVariable final long timeBlockId, + @RequestParam("tagId") final List tagIds + ) { + long memberId = Long.parseLong(principal.getName()); + timeBlockService.deleteDocumentTag(memberId, teamId, timeBlockId, tagIds); + } +} diff --git a/src/main/java/com/tiki/server/timeblock/controller/docs/TimeBlockControllerDocs.java b/src/main/java/com/tiki/server/timeblock/controller/docs/TimeBlockControllerDocs.java new file mode 100644 index 00000000..66547316 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/controller/docs/TimeBlockControllerDocs.java @@ -0,0 +1,356 @@ +package com.tiki.server.timeblock.controller.docs; + +import com.tiki.server.timeblock.dto.request.TimeBlockUpdateRequest; +import com.tiki.server.timeblock.service.dto.response.AllTimeBlockServiceResponse; +import java.security.Principal; +import java.util.List; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import com.tiki.server.common.dto.ErrorResponse; +import com.tiki.server.common.dto.SuccessResponse; +import com.tiki.server.timeblock.dto.request.TimeBlockCreateRequest; +import com.tiki.server.timeblock.dto.response.TimeBlockCreateResponse; +import com.tiki.server.timeblock.dto.response.TimeBlockDetailGetResponse; +import com.tiki.server.timeblock.dto.response.TimelineGetResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "time-blocks", description = "타임 블록 API") +public interface TimeBlockControllerDocs { + + @Operation( + summary = "타임 블록 생성", + description = "타임 블록을 생성한다.", + responses = { + @ApiResponse(responseCode = "201", description = "성공"), + @ApiResponse( + responseCode = "400", + description = "타입 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원, 유효하지 않은 팀", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse createTimeBlock( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) + @PathVariable final long teamId, + @Parameter( + name = "type", + description = "타임라인 타입", + in = ParameterIn.QUERY, + required = true, + example = "executive, member" + ) @RequestParam final String type, + @RequestBody final TimeBlockCreateRequest request + ); + + @Operation( + summary = "타임라인 조회", + description = "타임라인을 조회한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "400", + description = "타입 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원, 유효하지 않은 팀", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getTimeline( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) + @PathVariable final long teamId, + @Parameter( + name = "type", + description = "타임라인 타입", + in = ParameterIn.QUERY, + required = true, + example = "executive, member" + ) @RequestParam final String type, + @Parameter( + name = "date", + description = "조회할 타임라인의 년도와 월 정보", + in = ParameterIn.QUERY, + required = true, + example = "2024-07" + ) @RequestParam final String date + ); + + @Operation( + summary = "타임 블록 전체 조회", + description = "타임 블록을 전체 조회한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getAllTimeBlock( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) + @PathVariable final long teamId + ); + + @Operation( + summary = "타임 블록 상세 조회", + description = "타임 블록을 상세 조회한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원, 유효하지 않은 타임 블록", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse getTimeBlockDetail( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) + @PathVariable final long teamId, + @Parameter( + name = "timeBlockId", + description = "타임 블록 id", + in = ParameterIn.PATH, + example = "1" + ) + @PathVariable final long timeBlockId + ); + + @Operation( + summary = "타임 블록 정보 수정", + description = "타임 블록 정보를 수정한다.", + responses = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원, 유효하지 않은 타임 블록", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse updateTimeBlock( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) + @PathVariable final long teamId, + @Parameter( + name = "timeBlockId", + description = "타임 블록 id", + in = ParameterIn.PATH, + example = "1" + ) + @PathVariable final long timeBlockId, + @RequestBody final TimeBlockUpdateRequest request + + ); + + @Operation( + summary = "타임 블록 삭제", + description = "타임 블록을 삭제한다.", + responses = { + @ApiResponse(responseCode = "204", description = "성공"), + @ApiResponse( + responseCode = "403", + description = "접근 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "팀에 존재하지 않는 회원, 유효하지 않은 타임 블록", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + void deleteTimeBlock( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) + @PathVariable final long teamId, + @Parameter( + name = "timeBlockId", + description = "타임 블록 id", + in = ParameterIn.PATH, + example = "1" + ) + @PathVariable final long timeBlockId + ); + + @Operation( + summary = "타임 블록 파일 태그 추가", + description = "타임 블록에 파일 태그를 추가한다.", + responses = { + @ApiResponse(responseCode = "201", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + SuccessResponse createDocumentTag( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long teamId, + @Parameter( + name = "timeBlockId", + description = "타임 블록 id", + in = ParameterIn.PATH, + example = "1" + ) @PathVariable final long timeBlockId, + @Parameter( + name = "documentId", + description = "추가할 파일 id 리스트", + in = ParameterIn.QUERY, + required = true, + example = "[1, 2]" + ) @RequestParam("documentId") final List documentIds + ); + + @Operation( + summary = "타임 블록 파일 태그 삭제", + description = "타임 블록의 파일 태그를 삭제한다.", + responses = { + @ApiResponse(responseCode = "204", description = "성공"), + @ApiResponse( + responseCode = "4xx", + description = "클라이언트(요청) 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)))} + ) + void deleteDocumentTag( + @Parameter(hidden = true) final Principal principal, + @Parameter( + name = "teamId", + description = "팀 id", + in = ParameterIn.PATH, + example = "1" + ) + @PathVariable final long teamId, + @Parameter( + name = "timeBlockId", + description = "타임 블록 id", + in = ParameterIn.PATH, + example = "1" + ) + @PathVariable final long timeBlockId, + @Parameter( + name = "tagId", + description = "삭제할 파일 태그 id 리스트", + in = ParameterIn.QUERY, + required = true, + example = "[1, 2]" + ) @RequestParam("tagId") final List tagIds + ); +} diff --git a/src/main/java/com/tiki/server/timeblock/dto/request/TimeBlockCreateRequest.java b/src/main/java/com/tiki/server/timeblock/dto/request/TimeBlockCreateRequest.java new file mode 100644 index 00000000..64e2b545 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/dto/request/TimeBlockCreateRequest.java @@ -0,0 +1,18 @@ +package com.tiki.server.timeblock.dto.request; + +import java.time.LocalDate; +import java.util.List; + +import com.tiki.server.timeblock.entity.BlockType; + +import jakarta.validation.constraints.NotNull; + +public record TimeBlockCreateRequest( + @NotNull String name, + @NotNull String color, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull BlockType blockType, + @NotNull List documentIds +) { +} diff --git a/src/main/java/com/tiki/server/timeblock/dto/request/TimeBlockUpdateRequest.java b/src/main/java/com/tiki/server/timeblock/dto/request/TimeBlockUpdateRequest.java new file mode 100644 index 00000000..761ca742 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/dto/request/TimeBlockUpdateRequest.java @@ -0,0 +1,12 @@ +package com.tiki.server.timeblock.dto.request; + +import java.time.LocalDate; + +import jakarta.validation.constraints.NotNull; + +public record TimeBlockUpdateRequest( + @NotNull String name, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate +) { +} diff --git a/src/main/java/com/tiki/server/timeblock/dto/response/TimeBlockCreateResponse.java b/src/main/java/com/tiki/server/timeblock/dto/response/TimeBlockCreateResponse.java new file mode 100644 index 00000000..aaedc772 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/dto/response/TimeBlockCreateResponse.java @@ -0,0 +1,18 @@ +package com.tiki.server.timeblock.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record TimeBlockCreateResponse( + @NotNull long timeBlockId +) { + + public static TimeBlockCreateResponse of(final long timeBlockId) { + return TimeBlockCreateResponse.builder() + .timeBlockId(timeBlockId) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/timeblock/dto/response/TimeBlockDetailGetResponse.java b/src/main/java/com/tiki/server/timeblock/dto/response/TimeBlockDetailGetResponse.java new file mode 100644 index 00000000..2303ed39 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/dto/response/TimeBlockDetailGetResponse.java @@ -0,0 +1,59 @@ +package com.tiki.server.timeblock.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import java.util.List; + +import com.tiki.server.note.entity.Note; +import com.tiki.server.timeblock.service.dto.DocumentTagInfo; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record TimeBlockDetailGetResponse( + @NotNull List documents, + @NotNull List notes +) { + + public static TimeBlockDetailGetResponse from(final List documents, final List notes) { + return TimeBlockDetailGetResponse.builder() + .documents(documents.stream().map(DocumentDetailGetResponse::from).toList()) + .notes(notes.stream().map(NoteNameGetResponse::from).toList()) + .build(); + } + + @Builder(access = PRIVATE) + private record DocumentDetailGetResponse( + @NotNull long documentId, + @NotNull String fileName, + @NotNull String fileUrl, + @NotNull long capacity, + @NotNull long tagId + ) { + + private static DocumentDetailGetResponse from(final DocumentTagInfo document) { + return DocumentDetailGetResponse.builder() + .documentId(document.documentId()) + .fileName(document.fileName()) + .fileUrl(document.fileUrl()) + .capacity(document.capacity()) + .tagId(document.tagId()) + .build(); + } + } + + @Builder(access = PRIVATE) + private record NoteNameGetResponse( + @NotNull long noteId, + @NotNull String noteName + ) { + + private static NoteNameGetResponse from(final Note note) { + return NoteNameGetResponse.builder() + .noteId(note.getId()) + .noteName(note.getTitle()) + .build(); + } + } +} diff --git a/src/main/java/com/tiki/server/timeblock/dto/response/TimelineGetResponse.java b/src/main/java/com/tiki/server/timeblock/dto/response/TimelineGetResponse.java new file mode 100644 index 00000000..1f56c3b6 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/dto/response/TimelineGetResponse.java @@ -0,0 +1,47 @@ +package com.tiki.server.timeblock.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import java.time.LocalDate; +import java.util.List; + +import com.tiki.server.timeblock.entity.BlockType; +import com.tiki.server.timeblock.entity.TimeBlock; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.NonNull; + +@Builder(access = PRIVATE) +public record TimelineGetResponse( + @NotNull List timeBlocks +) { + + public static TimelineGetResponse from(final List timeBlocks) { + return TimelineGetResponse.builder() + .timeBlocks(timeBlocks.stream().map(TimeBlockGetResponse::from).toList()) + .build(); + } + + @Builder(access = PRIVATE) + public record TimeBlockGetResponse( + @NotNull long timeBlockId, + @NotNull String name, + @NotNull String color, + @NotNull LocalDate startDate, + @NotNull LocalDate endDate, + @NotNull BlockType blockType + ) { + + public static TimeBlockGetResponse from(final TimeBlock timeBlock) { + return TimeBlockGetResponse.builder() + .timeBlockId(timeBlock.getId()) + .name(timeBlock.getName()) + .color(timeBlock.getColor()) + .startDate(timeBlock.getStartDate()) + .endDate(timeBlock.getEndDate()) + .blockType(timeBlock.getType()) + .build(); + } + } +} diff --git a/src/main/java/com/tiki/server/timeblock/entity/BlockType.java b/src/main/java/com/tiki/server/timeblock/entity/BlockType.java new file mode 100644 index 00000000..44be82f5 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/entity/BlockType.java @@ -0,0 +1,5 @@ +package com.tiki.server.timeblock.entity; + +public enum BlockType { + MEETING, RECRUITING, STUDY, EVENT, NOTICE, ETC +} diff --git a/src/main/java/com/tiki/server/timeblock/entity/TimeBlock.java b/src/main/java/com/tiki/server/timeblock/entity/TimeBlock.java new file mode 100644 index 00000000..cd54d010 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/entity/TimeBlock.java @@ -0,0 +1,79 @@ +package com.tiki.server.timeblock.entity; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDate; + +import com.tiki.server.common.entity.BaseTime; +import com.tiki.server.common.entity.Position; +import com.tiki.server.team.entity.Team; +import com.tiki.server.timeblock.dto.request.TimeBlockCreateRequest; +import com.tiki.server.timeblock.dto.request.TimeBlockUpdateRequest; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder(access = PRIVATE) +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +public class TimeBlock extends BaseTime { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "block_id") + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String color; + + @Column(nullable = false) + @Enumerated(value = STRING) + private Position accessiblePosition; + + @Column(nullable = false) + private LocalDate startDate; + + @Column(nullable = false) + private LocalDate endDate; + + @Column(nullable = false) + @Enumerated(value = STRING) + private BlockType type; + + @Column(nullable = false) + private long teamId; + + public static TimeBlock of(final Team team, final Position accessiblePosition, + final TimeBlockCreateRequest request) { + return TimeBlock.builder() + .name(request.name()) + .color(request.color()) + .accessiblePosition(accessiblePosition) + .startDate(request.startDate()) + .endDate(request.endDate()) + .teamId(team.getId()) + .type(request.blockType()) + .build(); + } + + public void updateNameAndDate(final TimeBlockUpdateRequest request) { + this.name = request.name(); + this.startDate = request.startDate(); + this.endDate = request.endDate(); + } +} diff --git a/src/main/java/com/tiki/server/timeblock/exception/TimeBlockException.java b/src/main/java/com/tiki/server/timeblock/exception/TimeBlockException.java new file mode 100644 index 00000000..fb410474 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/exception/TimeBlockException.java @@ -0,0 +1,16 @@ +package com.tiki.server.timeblock.exception; + +import com.tiki.server.timeblock.message.ErrorCode; + +import lombok.Getter; + +@Getter +public class TimeBlockException extends RuntimeException { + + private final ErrorCode errorCode; + + public TimeBlockException(final ErrorCode errorCode) { + super("[TimeBlockException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/tiki/server/timeblock/message/ErrorCode.java b/src/main/java/com/tiki/server/timeblock/message/ErrorCode.java new file mode 100644 index 00000000..dfb22164 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/message/ErrorCode.java @@ -0,0 +1,24 @@ +package com.tiki.server.timeblock.message; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + /* 400 BAD_REQUEST : 잘못된 요청 */ + INVALID_TYPE(BAD_REQUEST, "유효한 타입이 아닙니다."), + INVALID_DOCUMENT_TAG(BAD_REQUEST, "유효한 파일이 아닙니다"), + + /* 404 NOT_FOUND : 자원을 찾을 수 없음 */ + INVALID_TIME_BLOCK(NOT_FOUND, "유효하지 않은 타임 블록입니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/tiki/server/timeblock/message/SuccessMessage.java b/src/main/java/com/tiki/server/timeblock/message/SuccessMessage.java new file mode 100644 index 00000000..2eadc1b1 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/message/SuccessMessage.java @@ -0,0 +1,18 @@ +package com.tiki.server.timeblock.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessMessage { + + SUCCESS_CREATE_TIME_BLOCK("타임 블록 생성 성공"), + SUCCESS_GET_TIMELINE("타임라인 조회 성공"), + SUCCESS_GET_ALL_TIME_BLOCK("전체 타임블록 조회 성공"), + SUCCESS_GET_TIME_BLOCK_DETAIL("타임 블록 상세 정보 조회 성공"), + SUCCESS_UPDATE_TIME_BLOCK("타임 블록 정보 수정 성공"), + SUCCESS_CREATE_DOCUMENT_TAG("파일 태그 성공"); + + private final String message; +} diff --git a/src/main/java/com/tiki/server/timeblock/repository/TimeBlockRepository.java b/src/main/java/com/tiki/server/timeblock/repository/TimeBlockRepository.java new file mode 100644 index 00000000..d4dd6461 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/repository/TimeBlockRepository.java @@ -0,0 +1,24 @@ +package com.tiki.server.timeblock.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import com.tiki.server.timeblock.entity.TimeBlock; + +public interface TimeBlockRepository extends JpaRepository { + + Optional findByIdAndTeamId(final long id, final long teamId); + + void deleteAllByTeamId(final long teamId); + + @Query(value = "select * from time_block " + + "where team_id = :teamId and accessible_position = :position and " + + "(to_char(start_date, 'YYYY-MM') <= :date and to_char(end_date, 'YYYY-MM') >= :date) " + + "order by start_date asc", nativeQuery = true) + List findByTeamAndAccessiblePositionAndDate(final long teamId, final String position, final String date); + + List findAllByTeamId(final long teamId); +} diff --git a/src/main/java/com/tiki/server/timeblock/service/TimeBlockService.java b/src/main/java/com/tiki/server/timeblock/service/TimeBlockService.java new file mode 100644 index 00000000..a5d1082a --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/service/TimeBlockService.java @@ -0,0 +1,187 @@ +package com.tiki.server.timeblock.service; + +import com.tiki.server.timeblock.dto.request.TimeBlockUpdateRequest; +import com.tiki.server.timeblock.service.dto.response.AllTimeBlockServiceResponse; +import java.util.List; + +import com.tiki.server.document.entity.Document; +import com.tiki.server.documenttimeblockmanager.adapter.DTBAdapter; +import com.tiki.server.documenttimeblockmanager.entity.DTBManager; +import com.tiki.server.note.adapter.NoteFinder; +import com.tiki.server.note.entity.Note; +import com.tiki.server.notetimeblockmanager.adapter.NTBFinder; +import com.tiki.server.notetimeblockmanager.entity.NTBManager; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.tiki.server.common.entity.Position; +import com.tiki.server.document.adapter.DocumentFinder; +import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder; +import com.tiki.server.memberteammanager.entity.MemberTeamManager; +import com.tiki.server.team.adapter.TeamFinder; +import com.tiki.server.team.entity.Team; +import com.tiki.server.timeblock.adapter.TimeBlockDeleter; +import com.tiki.server.timeblock.adapter.TimeBlockFinder; +import com.tiki.server.timeblock.adapter.TimeBlockSaver; +import com.tiki.server.timeblock.dto.request.TimeBlockCreateRequest; +import com.tiki.server.timeblock.dto.response.TimeBlockCreateResponse; +import com.tiki.server.timeblock.dto.response.TimeBlockDetailGetResponse; +import com.tiki.server.timeblock.dto.response.TimelineGetResponse; +import com.tiki.server.timeblock.entity.TimeBlock; +import com.tiki.server.timeblock.service.dto.DocumentTagInfo; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TimeBlockService { + + private final TeamFinder teamFinder; + private final MemberTeamManagerFinder memberTeamManagerFinder; + private final TimeBlockSaver timeBlockSaver; + private final TimeBlockFinder timeBlockFinder; + private final TimeBlockDeleter timeBlockDeleter; + private final DocumentFinder documentFinder; + private final DTBAdapter dtbAdapter; + private final NTBFinder ntbFinder; + private final NoteFinder noteFinder; + + @Transactional + public TimeBlockCreateResponse createTimeBlock( + final long memberId, + final long teamId, + final String type, + final TimeBlockCreateRequest request + ) { + Team team = teamFinder.findById(teamId); + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + Position accessiblePosition = Position.getAccessiblePosition(type); + memberTeamManager.checkMemberAccessible(accessiblePosition); + validateDocuments(team, request.documentIds()); + TimeBlock timeBlock = saveTimeBlock(team, accessiblePosition, request); + dtbAdapter.saveAll(timeBlock, request.documentIds()); + return TimeBlockCreateResponse.of(timeBlock.getId()); + } + + public TimelineGetResponse getTimeline( + final long memberId, + final long teamId, + final String type, + final String date + ) { + Team team = teamFinder.findById(teamId); + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + Position accessiblePosition = Position.getAccessiblePosition(type); + memberTeamManager.checkMemberAccessible(accessiblePosition); + List timeBlocks = timeBlockFinder.findByTeamAndAccessiblePositionAndDate( + team.getId(), accessiblePosition.name(), date); + return TimelineGetResponse.from(timeBlocks); + } + + public AllTimeBlockServiceResponse getAllTimeBlock( + final long memberId, + final long teamId + ) { + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + List timeBlocks = timeBlockFinder.findAllByTeamId(teamId); + return AllTimeBlockServiceResponse.from(timeBlocks); + } + + public TimeBlockDetailGetResponse getTimeBlockDetail( + final long memberId, + final long teamId, + final long timeBlockId + ) { + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + TimeBlock timeBlock = timeBlockFinder.findByIdAndTeamId(timeBlockId, teamId); + memberTeamManager.checkMemberAccessible(timeBlock.getAccessiblePosition()); + List documents = getDocumentsInfo(timeBlock); + List notes = getNotes(timeBlock.getId()); + return TimeBlockDetailGetResponse.from(documents, notes); + } + + @Transactional + public void updateTimeBlock( + final long memberId, + final long teamId, + final long timeBlockId, + final TimeBlockUpdateRequest request + ) { + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + TimeBlock timeBlock = timeBlockFinder.findByIdAndTeamId(timeBlockId, teamId); + memberTeamManager.checkMemberAccessible(timeBlock.getAccessiblePosition()); + timeBlock.updateNameAndDate(request); + } + + @Transactional + public void deleteTimeBlock(final long memberId, final long teamId, final long timeBlockId) { + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + TimeBlock timeBlock = timeBlockFinder.findByIdAndTeamId(timeBlockId, teamId); + memberTeamManager.checkMemberAccessible(timeBlock.getAccessiblePosition()); + dtbAdapter.deleteAllByTimeBlock(timeBlock); + timeBlockDeleter.deleteById(timeBlock.getId()); + } + + @Transactional + public void createDocumentTag( + final long memberId, + final long teamId, + final long timeBlockId, + final List documentIds + ) { + Team team = teamFinder.findById(teamId); + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + TimeBlock timeBlock = timeBlockFinder.findByIdAndTeamId(timeBlockId, teamId); + memberTeamManager.checkMemberAccessible(timeBlock.getAccessiblePosition()); + validateDocuments(team, documentIds); + dtbAdapter.saveAll(timeBlock, documentIds); + } + + @Transactional + public void deleteDocumentTag( + final long memberId, + final long teamId, + final long timeBlockId, + final List tagIds + ) { + MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId); + TimeBlock timeBlock = timeBlockFinder.findByIdAndTeamId(timeBlockId, teamId); + memberTeamManager.checkMemberAccessible(timeBlock.getAccessiblePosition()); + List dtbManagers = dtbAdapter.getAllByIds(tagIds); + dtbManagers.forEach(dtbManager -> dtbManager.validateTimeBlock(timeBlock)); + dtbAdapter.deleteAll(dtbManagers); + } + + private void validateDocuments(final Team team, final List documentIds) { + documentFinder.findAllByIdAndTeamId(documentIds, team.getId()); + } + + private TimeBlock saveTimeBlock( + final Team team, + final Position accessiblePosition, + final TimeBlockCreateRequest request + ) { + return timeBlockSaver.save(TimeBlock.of(team, accessiblePosition, request)); + } + + private List getDocumentsInfo(final TimeBlock timeBlock) { + List dtbManagers = dtbAdapter.getAllByTimeBlock(timeBlock); + return dtbManagers.stream() + .map(this::getDocumentTagInfo) + .toList(); + } + + private DocumentTagInfo getDocumentTagInfo(final DTBManager dtbManager) { + Document document = documentFinder.findById(dtbManager.getDocumentId()); + return DocumentTagInfo.of(document, dtbManager); + } + + private List getNotes(final long timeBlockId) { + List noteTimeBlockManagers = ntbFinder.findAllByTimeBlockId(timeBlockId); + return noteTimeBlockManagers.stream() + .map(noteTimeBlockManager -> noteFinder.findById(noteTimeBlockManager.getNoteId())) + .toList(); + } +} diff --git a/src/main/java/com/tiki/server/timeblock/service/dto/DocumentTagInfo.java b/src/main/java/com/tiki/server/timeblock/service/dto/DocumentTagInfo.java new file mode 100644 index 00000000..c4274edb --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/service/dto/DocumentTagInfo.java @@ -0,0 +1,29 @@ +package com.tiki.server.timeblock.service.dto; + +import static lombok.AccessLevel.PRIVATE; + +import com.tiki.server.document.entity.Document; +import com.tiki.server.documenttimeblockmanager.entity.DTBManager; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder(access = PRIVATE) +public record DocumentTagInfo( + @NotNull long documentId, + @NotNull String fileName, + @NotNull String fileUrl, + @NotNull long capacity, + @NotNull long tagId +) { + + public static DocumentTagInfo of(final Document document, final DTBManager dtbManager) { + return DocumentTagInfo.builder() + .documentId(document.getId()) + .fileName(document.getFileName()) + .fileUrl(document.getFileUrl()) + .capacity(document.getCapacity()) + .tagId(dtbManager.getId()) + .build(); + } +} diff --git a/src/main/java/com/tiki/server/timeblock/service/dto/response/AllTimeBlockServiceResponse.java b/src/main/java/com/tiki/server/timeblock/service/dto/response/AllTimeBlockServiceResponse.java new file mode 100644 index 00000000..f367ee0e --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/service/dto/response/AllTimeBlockServiceResponse.java @@ -0,0 +1,13 @@ +package com.tiki.server.timeblock.service.dto.response; + +import com.tiki.server.timeblock.entity.TimeBlock; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record AllTimeBlockServiceResponse( + @NotNull List tImeBlockTaggingResponses +) { + public static AllTimeBlockServiceResponse from(final List timeBlocks) { + return new AllTimeBlockServiceResponse(timeBlocks.stream().map(TImeBlockTaggingResponse::from).toList()); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/timeblock/service/dto/response/TImeBlockTaggingResponse.java b/src/main/java/com/tiki/server/timeblock/service/dto/response/TImeBlockTaggingResponse.java new file mode 100644 index 00000000..d3441190 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/service/dto/response/TImeBlockTaggingResponse.java @@ -0,0 +1,24 @@ +package com.tiki.server.timeblock.service.dto.response; + +import com.tiki.server.timeblock.entity.BlockType; +import com.tiki.server.timeblock.entity.TimeBlock; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +public record TImeBlockTaggingResponse( + @NotNull long timeBlockId, + @NotNull String name, + @NotNull BlockType type, + @NotNull String color, + @NotNull LocalDate startDate +) { + public static TImeBlockTaggingResponse from(final TimeBlock timeBlock) { + return new TImeBlockTaggingResponse( + timeBlock.getId(), + timeBlock.getName(), + timeBlock.getType(), + timeBlock.getColor(), + timeBlock.getStartDate() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/tiki/server/timeblock/service/dto/response/TimeBlockTagServiceResponse.java b/src/main/java/com/tiki/server/timeblock/service/dto/response/TimeBlockTagServiceResponse.java new file mode 100644 index 00000000..8ccfaf97 --- /dev/null +++ b/src/main/java/com/tiki/server/timeblock/service/dto/response/TimeBlockTagServiceResponse.java @@ -0,0 +1,27 @@ +package com.tiki.server.timeblock.service.dto.response; + +import com.tiki.server.timeblock.entity.BlockType; +import com.tiki.server.timeblock.entity.TimeBlock; + +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record TimeBlockTagServiceResponse( + @NotNull long id, + @NotNull String name, + @NotNull String color, + @NotNull BlockType blockType, + @NotNull LocalDate startDate +) { + + public static TimeBlockTagServiceResponse from(final TimeBlock timeBlock) { + return new TimeBlockTagServiceResponse( + timeBlock.getId(), + timeBlock.getName(), + timeBlock.getColor(), + timeBlock.getType(), + timeBlock.getStartDate() + ); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e69de29b..73524f90 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,60 @@ +spring: + config: + import: application-secret.yml + activate: + on-profile: dev + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${DATABASE.ENDPOINT_URL.dev}:5432/postgres?currentSchema=${DATABASE.NAME.dev} + username: ${DATABASE.USERNAME.dev} + password: ${DATABASE.PASSWORD.dev} + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + format_sql: true + default_batch_fetch_size: 1000 + auto_quote_keyword: true + data: + redis: + host: ${REDIS.host} + port: 6379 + task: + scheduling: + pool: + size: 1 + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL.username} + password: ${MAIL.password} + + properties: + mail: + smtp: + auth: true + starttls: + enable: true + +logging: + level: + org.hibernate.SQL: debug + slack: + webhook_url: ${SLACK.WEBHOOK_URL.dev} + config: classpath:logback-spring.xml + +jwt: + secret: + ${JWT.SECRET} + access-token-expire-time: + ${JWT.EXPIRE_ACCESS} + refresh-token-expire-time: + ${JWT.EXPIRE_REFRESH} + +aws-property: + access-key: ${AWS_PROPERTY.ACCESS_KEY.dev} + secret-key: ${AWS_PROPERTY.SECRET_KEY.dev} + bucket: ${AWS_PROPERTY.BUCKET.dev} + aws-region: ap-northeast-2 + s3-url: ${AWS_PROPERTY.S3_URL.dev} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..589e2b89 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,60 @@ +spring: + config: + import: application-secret.yml + activate: + on-profile: prod + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${DATABASE.ENDPOINT_URL.prod}:5432/postgres?currentSchema=${DATABASE.NAME.prod} + username: ${DATABASE.USERNAME.prod} + password: ${DATABASE.PASSWORD.prod} + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + format_sql: true + default_batch_fetch_size: 1000 + auto_quote_keyword: true + data: + redis: + host: ${REDIS.host} + port: 6379 + task: + scheduling: + pool: + size: 1 + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL.username} + password: ${MAIL.password} + + properties: + mail: + smtp: + auth: true + starttls: + enable: true + +logging: + level: + org.hibernate.SQL: debug + slack: + webhook_url: ${SLACK.WEBHOOK_URL.prod} + config: classpath:logback-spring.xml + +jwt: + secret: + ${JWT.SECRET} + access-token-expire-time: + ${JWT.EXPIRE_ACCESS} + refresh-token-expire-time: + ${JWT.EXPIRE_REFRESH} + +aws-property: + access-key: ${AWS_PROPERTY.ACCESS_KEY.prod} + secret-key: ${AWS_PROPERTY.SECRET_KEY.prod} + bucket: ${AWS_PROPERTY.BUCKET.prod} + aws-region: ap-northeast-2 + s3-url: ${AWS_PROPERTY.S3_URL.prod} diff --git a/src/main/resources/images/mail_logo.png b/src/main/resources/images/mail_logo.png new file mode 100644 index 00000000..e2c9d843 Binary files /dev/null and b/src/main/resources/images/mail_logo.png differ diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..222c64e3 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,30 @@ + + + + + ${SLACK_WEBHOOK_URI} + + %d{yyyy-MM-dd HH:mm:ss.SSS} %msg %n + + incoming-webhook + true + + + + + %d %-5level %logger{35} - %msg%n + + + + + + + ERROR + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/changePassword.html b/src/main/resources/templates/changePassword.html new file mode 100644 index 00000000..bdb5c655 --- /dev/null +++ b/src/main/resources/templates/changePassword.html @@ -0,0 +1,72 @@ + + + + + + + Tiki 인증코드 + + +
+ tiki_logo +

+ 이메일 인증 안내 +

+ + 님, +
+

+ 비밀번호 재설정을 위한 인증코드입니다.
+ 인증코드는 이메일 발송 시점으로부터 3분동안 유효합니다. +

+
+

+ 인증코드를 확인해주세요. +

+

+ 123456 +

+
+
+

+ TIKI는 국내 대학생과 동아리를 위한 플랫폼입니다.
+ 동아리 탐색과 문서 관리 및 아카이빙을 지원합니다. +

+
+
+ + diff --git a/src/main/resources/templates/invitation.html b/src/main/resources/templates/invitation.html new file mode 100644 index 00000000..12bc6cfa --- /dev/null +++ b/src/main/resources/templates/invitation.html @@ -0,0 +1,84 @@ + + + + + + + Tiki 워크스페이스 초대 + + + + +
+ tiki_logo +

+ 워크스페이스 초대 +

+
+ + 님이  + +  워크스페이스에 초대했습니다. +
+

+ 초대받은 이메일 주소로 로그인해 초대를 수락하고,
+ 티키에서 더 편리해진 동아리 관리를 경험해보세요. +

+ +
+

+ TIKI는 국내 대학생과 동아리를 위한 플랫폼입니다.
+ 동아리 탐색과 문서 관리 및 아카이빙을 지원합니다. +

+
+
+ + diff --git a/src/main/resources/templates/signup.html b/src/main/resources/templates/signup.html new file mode 100644 index 00000000..54327db1 --- /dev/null +++ b/src/main/resources/templates/signup.html @@ -0,0 +1,71 @@ + + + + + + + Tiki 인증코드 + + +
+ tiki_logo +

+ 이메일 인증 안내 +

+
+

+ 회원가입을 위한 인증코드입니다.
+ 인증코드는 이메일 발송 시점으로부터 3분동안 유효합니다. +

+
+

+ 인증코드를 확인해주세요. +

+

+ 123456 +

+
+
+

+ TIKI는 국내 대학생과 동아리를 위한 플랫폼입니다.
+ 동아리 탐색과 문서 관리 및 아카이빙을 지원합니다. +

+
+
+ +