diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..81553563 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +root = true + +[*] +# [encoding-utf8] +charset = utf-8 + +# [newline-lf] +end_of_line = lf + +# [newline-eof] +insert_final_newline = true + +[*.bat] +end_of_line = crlf + +[*.java] +# [indentation-tab] +indent_style = tab + +# [4-spaces-tab] +indent_size = 4 +tab_width = 4 + +# [no-trailing-spaces] +trim_trailing_whitespace = true + +[line-length-120] +max_line_length = 120 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..8af972cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git "a/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" new file mode 100644 index 00000000..8675f0ac --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" @@ -0,0 +1,12 @@ +--- +name: "♻️ refactor" +about: 리팩토링 이슈 템플릿 +titles: "♻️ " +labels: "♻️ refactor" +assignees: '' + +--- + +## 📌 Description + +- \ No newline at end of file diff --git "a/.github/ISSUE_TEMPLATE/\342\232\231\357\270\217-chore.md" "b/.github/ISSUE_TEMPLATE/\342\232\231\357\270\217-chore.md" new file mode 100644 index 00000000..9df46a80 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\232\231\357\270\217-chore.md" @@ -0,0 +1,12 @@ +--- +name: "⚙️ chore" +about: CI/CD 및 설정 이슈 템플릿 +titles: "⚙️ " +labels: "⚙️ chore" +assignees: '' + +--- + +## 📌 Description + +- \ No newline at end of file diff --git "a/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" "b/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" new file mode 100644 index 00000000..2a7f8a62 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" @@ -0,0 +1,12 @@ +--- +name: "✨ feature" +about: 기능 추가 이슈 템플릿 +titles: "✨ " +labels: "✨ feature" +assignees: '' + +--- + +## 📌 Description + +- \ No newline at end of file diff --git "a/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" "b/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" new file mode 100644 index 00000000..37f21488 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" @@ -0,0 +1,12 @@ +--- +name: "🐛 fix" +about: 버그 및 에러 이슈 템플릿 +titles: "🐛 " +labels: "🐛 bug/error" +assignees: '' + +--- + +## 📌 Description + +- diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..7b46d00f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +## 🌱 관련 이슈 + +- close #Issue_number + +--- +## 📌 작업 내용 및 특이사항 + +- + +--- +## 📚 참고사항 + +- diff --git a/.github/workflows/develop_build_deploy.yml b/.github/workflows/develop_build_deploy.yml new file mode 100644 index 00000000..b8f026a5 --- /dev/null +++ b/.github/workflows/develop_build_deploy.yml @@ -0,0 +1,88 @@ +name: Build and Deploy to Develop + +on: + push: + branches: + - develop + +env: + DOCKERHUB_USERNAME: ht3064 + DOCKERHUB_IMAGE_NAME: devfit-server + +jobs: + build-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Start Redis container for test + run: docker compose -f ./docker-compose-test.yml up -d + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Build with Gradle + run: ./gradlew clean build -x test + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + + - name: Extract metadata for Docker + id: metadata + uses: docker/metadata-action@v4 + with: + images: ${{ env.DOCKERHUB_USERNAME }}/${{ env.DOCKERHUB_IMAGE_NAME }} + tags: | + type=sha,prefix= + + - name: Build and Push Docker image + uses: docker/build-push-action@v4.1.1 + with: + context: . + push: true + tags: ${{ steps.metadata.outputs.tags }} + + - name: Copy docker-compose.yml to EC2 + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + source: docker-compose.yml + target: /home/ubuntu/ + + - name: Copy default.conf to EC2 + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + source: nginx/default.conf + target: /home/ubuntu/ + + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + env: + IMAGE_FULL_URL: ${{ steps.metadata.outputs.tags }} + DOCKERHUB_IMAGE_NAME: ${{ env.DOCKERHUB_IMAGE_NAME }} + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + envs: IMAGE_FULL_URL, DOCKERHUB_IMAGE_NAME + script: | + echo "${{ secrets.DOCKERHUB_ACCESS_TOKEN }}" | docker login -u "${{ env.DOCKERHUB_USERNAME }}" --password-stdin + docker compose up -d + docker exec nginx nginx -s reload + docker image prune -a -f diff --git a/.github/workflows/develop_pull_request.yml b/.github/workflows/develop_pull_request.yml new file mode 100644 index 00000000..770868d7 --- /dev/null +++ b/.github/workflows/develop_pull_request.yml @@ -0,0 +1,37 @@ +name: Check Style And Test + +on: + pull_request: + branches: + - develop + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Start Redis container for test + run: docker compose -f ./docker-compose-test.yml up -d + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Test + run: | + ./gradlew test diff --git a/.github/workflows/production_build_deploy.yml b/.github/workflows/production_build_deploy.yml new file mode 100644 index 00000000..2c084105 --- /dev/null +++ b/.github/workflows/production_build_deploy.yml @@ -0,0 +1,94 @@ +name: Build and Deploy to Production + +on: + push: + tags: + - v*.*.* + +env: + DOCKERHUB_USERNAME: ht3064 + DOCKERHUB_IMAGE_NAME: devfit-server + +jobs: + build-deploy: + runs-on: ubuntu-latest + environment: production + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Start Redis container for test + run: docker compose -f ./docker-compose-test.yml up -d + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Build with Gradle + uses: gradle/actions/setup-gradle@v3 + with: + arguments: build + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + + - name: Extract metadata for Docker + id: metadata + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKERHUB_USERNAME }}/${{ env.DOCKERHUB_IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + flavor: | + latest=false + + - name: Build and Push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.metadata.outputs.tags }} + + - name: Copy docker-compose.yml to EC2 + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + source: docker-compose.yml + target: /home/ubuntu/ + + - name: Copy default.conf to EC2 + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + source: nginx/default.conf + target: /home/ubuntu/ + + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + env: + IMAGE_FULL_URL: ${{ steps.metadata.outputs.tags }} + DOCKERHUB_IMAGE_NAME: ${{ env.DOCKERHUB_IMAGE_NAME }} + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + envs: IMAGE_FULL_URL, DOCKERHUB_IMAGE_NAME + script: | + echo "${{ secrets.DOCKERHUB_ACCESS_TOKEN }}" | docker login -u "${{ env.DOCKERHUB_USERNAME }}" --password-stdin + docker compose up -d + docker exec nginx nginx -s reload + docker image prune -a -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e30b31bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Custom ### +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..04331186 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17 +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..ed4a8b46 --- /dev/null +++ b/build.gradle @@ -0,0 +1,119 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.1' + id 'io.spring.dependency-management' version '1.1.7' + id 'com.diffplug.spotless' version '6.20.0' +} + +group = 'com.amcamp' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + //Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0' + + // Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-test' + + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // MySQL + implementation 'mysql:mysql-connector-java:8.0.33' + + // Spring Cloud + implementation platform("org.springframework.cloud:spring-cloud-dependencies:2024.0.0") + + // Spring Cloud Open Feign + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Oauth2 Jose + implementation 'org.springframework.security:spring-security-oauth2-jose' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // S3 + implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4' +} + +tasks.named('test') { + useJUnitPlatform() +} + +clean { + delete file('src/main/generated') +} + +jar.enabled = true + +spotless { + java { + // Google Java 포맷 적용 + /* + googleJavaFormat() : 탭은 2개의 공백 + googleJavaFormat().aosp() : 탭은 4개의 공백 + [참고] https://github.com/google/google-java-format/issues/525 + */ + googleJavaFormat().aosp() + // import 순서 정렬 + importOrder() + // 사용하지 않는 import 제거 + removeUnusedImports() + // 각 라인 끝에 있는 공백을 제거 + trimTrailingWhitespace() + // 파일 끝에 새로운 라인 추가 + endWithNewline() + } +} + +tasks.register('updateGitHooks', Copy) { + from './scripts/pre-commit' + into './.git/hooks' +} + +tasks.register('makeGitHooksExecutable', Exec) { + commandLine 'chmod', '+x', './.git/hooks/pre-commit' + dependsOn updateGitHooks +} + +compileJava.dependsOn makeGitHooksExecutable diff --git a/docker-compose-test.yml b/docker-compose-test.yml new file mode 100644 index 00000000..668cecb7 --- /dev/null +++ b/docker-compose-test.yml @@ -0,0 +1,7 @@ +version: "3.8" + +services: + redis: + image: "redis:alpine" + ports: + - "6379:6379" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..03a596cd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + backend: + image: ${IMAGE_FULL_URL} + container_name: ${DOCKERHUB_IMAGE_NAME} + restart: always + networks: + - devfit-net + environment: + - TZ=Asia/Seoul + env_file: + - .env + redis: + image: "redis:alpine" + container_name: redis + networks: + - devfit-net + environment: + - TZ=Asia/Seoul + nginx: + image: "nginx:alpine" + container_name: nginx + ports: + - "80:80" + networks: + - devfit-net + environment: + - TZ=Asia/Seoul + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf + +networks: + devfit-net: + external: true + name: devfit-net diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e2847c82 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..f5feea6d --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..9d21a218 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 00000000..ecbdad96 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name devfit.site; + + if ($http_x_forwarded_proto != 'https') { + return 301 https://$host$request_uri; + } + + location / { + proxy_pass http://devfit-server:8080; + proxy_redirect off; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header HOST $http_host; + proxy_set_header X-NginX-Proxy true; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100644 index 00000000..ac68c3ce --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,12 @@ +#!/bin/sh + +stagedFiles=$(git diff --staged --name-only) + +echo "Running SpotlessApply. Formatting Code..." +./gradlew spotlessApply + +for file in $stagedFiles; do + if test -f "$file"; then + git add $file + fi +done diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..2230a9d7 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'amcamp' diff --git a/src/main/java/com/amcamp/DevFitApplication.java b/src/main/java/com/amcamp/DevFitApplication.java new file mode 100644 index 00000000..ec2d282a --- /dev/null +++ b/src/main/java/com/amcamp/DevFitApplication.java @@ -0,0 +1,12 @@ +package com.amcamp; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DevFitApplication { + + public static void main(String[] args) { + SpringApplication.run(DevFitApplication.class, args); + } +} diff --git a/src/main/java/com/amcamp/domain/auth/api/AuthController.java b/src/main/java/com/amcamp/domain/auth/api/AuthController.java new file mode 100644 index 00000000..3d0f071c --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/api/AuthController.java @@ -0,0 +1,36 @@ +package com.amcamp.domain.auth.api; + +import com.amcamp.domain.auth.application.AuthService; +import com.amcamp.domain.auth.domain.OauthProvider; +import com.amcamp.domain.auth.dto.request.AuthCodeRequest; +import com.amcamp.domain.auth.dto.response.SocialLoginResponse; +import com.amcamp.global.util.CookieUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "1-1. 인증 API", description = "인증 관련 API입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + private final CookieUtil cookieUtil; + + @Operation(summary = "회원가입 및 로그인", description = "회원가입 및 로그인을 진행합니다.") + @PostMapping("/social-login") + public ResponseEntity memberSocialLogin( + @RequestParam(name = "oauthProvider") OauthProvider provider, + @RequestBody AuthCodeRequest request) { + SocialLoginResponse response = authService.socialLoginMember(request, provider); + + String refreshToken = response.refreshToken(); + HttpHeaders headers = cookieUtil.generateRefreshTokenCookie(refreshToken); + + return ResponseEntity.ok().headers(headers).body(response); + } +} diff --git a/src/main/java/com/amcamp/domain/auth/application/AuthService.java b/src/main/java/com/amcamp/domain/auth/application/AuthService.java new file mode 100644 index 00000000..5b8b83a8 --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/application/AuthService.java @@ -0,0 +1,80 @@ +package com.amcamp.domain.auth.application; + +import com.amcamp.domain.auth.domain.OauthProvider; +import com.amcamp.domain.auth.dto.request.AuthCodeRequest; +import com.amcamp.domain.auth.dto.response.SocialLoginResponse; +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.MemberStatus; +import com.amcamp.domain.member.domain.OauthInfo; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthService { + + private final KakaoService kakaoService; + private final GoogleService googleService; + private final JwtTokenService jwtTokenService; + private final IdTokenVerifier idTokenVerifier; + private final MemberRepository memberRepository; + + public SocialLoginResponse socialLoginMember(AuthCodeRequest request, OauthProvider provider) { + String idToken = getIdToken(request.code(), provider); + + OidcUser oidcUser = idTokenVerifier.getOidcUser(idToken, provider); + + Optional optionalMember = findByOidcUser(oidcUser); + Member member = optionalMember.orElseGet(() -> saveMember(oidcUser, provider)); + + if (member.getStatus() == MemberStatus.DELETED) { + member.reEnroll(); + } + + return getLoginResponse(member); + } + + private SocialLoginResponse getLoginResponse(Member member) { + String accessToken = jwtTokenService.createAccessToken(member.getId(), member.getRole()); + String refreshToken = jwtTokenService.createRefreshToken(member.getId()); + return SocialLoginResponse.of(accessToken, refreshToken); + } + + private String getIdToken(String code, OauthProvider provider) { + return switch (provider) { + case GOOGLE -> googleService.getIdToken(code); + case KAKAO -> kakaoService.getIdToken(code); + }; + } + + private Optional findByOidcUser(OidcUser oidcUser) { + OauthInfo oauthInfo = extractOauthInfo(oidcUser); + return memberRepository.findByOauthInfo(oauthInfo); + } + + private Member saveMember(OidcUser oidcUser, OauthProvider provider) { + OauthInfo oauthInfo = extractOauthInfo(oidcUser); + String nickname = getDisplayName(oidcUser, provider); + + Member member = Member.createMember(nickname, oidcUser.getPicture(), oauthInfo); + return memberRepository.save(member); + } + + private OauthInfo extractOauthInfo(OidcUser oidcUser) { + return OauthInfo.createOauthInfo(oidcUser.getSubject(), oidcUser.getIssuer().toString()); + } + + private String getDisplayName(OidcUser oidcUser, OauthProvider provider) { + return switch (provider) { + case GOOGLE -> (String) oidcUser.getClaims().get("name"); + case KAKAO -> (String) oidcUser.getClaims().get("nickname"); + }; + } +} diff --git a/src/main/java/com/amcamp/domain/auth/application/GoogleService.java b/src/main/java/com/amcamp/domain/auth/application/GoogleService.java new file mode 100644 index 00000000..11dafcc8 --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/application/GoogleService.java @@ -0,0 +1,27 @@ +package com.amcamp.domain.auth.application; + +import com.amcamp.domain.auth.dto.response.IdTokenResponse; +import com.amcamp.infra.config.feign.GoogleOauthClient; +import com.amcamp.infra.config.oauth.GoogleProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class GoogleService { + + private final GoogleOauthClient googleOauthClient; + private final GoogleProperties googleProperties; + + public String getIdToken(String code) { + IdTokenResponse response = + googleOauthClient.getIdToken( + googleProperties.grantType(), + googleProperties.clientId(), + googleProperties.redirectUri(), + code, + googleProperties.clientSecret()); + + return response.id_token(); + } +} diff --git a/src/main/java/com/amcamp/domain/auth/application/IdTokenVerifier.java b/src/main/java/com/amcamp/domain/auth/application/IdTokenVerifier.java new file mode 100644 index 00000000..a882724c --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/application/IdTokenVerifier.java @@ -0,0 +1,78 @@ +package com.amcamp.domain.auth.application; + +import com.amcamp.domain.auth.domain.OauthProvider; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.AuthErrorCode; +import com.amcamp.infra.config.oauth.GoogleProperties; +import com.amcamp.infra.config.oauth.KakaoProperties; +import java.time.Instant; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class IdTokenVerifier { + + private final KakaoProperties kakaoProperties; + private final GoogleProperties googleProperties; + + private final Map decoders = + Map.of( + OauthProvider.GOOGLE, buildDecoder(OauthProvider.GOOGLE.getJwkSetUrl()), + OauthProvider.KAKAO, buildDecoder(OauthProvider.KAKAO.getJwkSetUrl())); + + private JwtDecoder buildDecoder(String jwkSetUrl) { + return NimbusJwtDecoder.withJwkSetUri(jwkSetUrl).build(); + } + + public OidcUser getOidcUser(String idToken, OauthProvider provider) { + Jwt jwt = getJwt(idToken, provider); + OidcIdToken oidcIdToken = getOidcIdToken(jwt); + + validateAudience(oidcIdToken, provider.getClientId(googleProperties, kakaoProperties)); + validateIssuer(oidcIdToken, provider.getIssuer()); + validateExpiresAt(oidcIdToken); + + return new DefaultOidcUser(null, oidcIdToken); + } + + private Jwt getJwt(String idToken, OauthProvider provider) { + return decoders.get(provider).decode(idToken); + } + + private OidcIdToken getOidcIdToken(Jwt jwt) { + return new OidcIdToken( + jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaims()); + } + + private void validateAudience(OidcIdToken oidcIdToken, String clientId) { + String idTokenAudience = oidcIdToken.getAudience().get(0); + + if (idTokenAudience == null || !idTokenAudience.equals(clientId)) { + throw new CommonException(AuthErrorCode.ID_TOKEN_VERIFICATION_FAILED); + } + } + + private void validateIssuer(OidcIdToken oidcIdToken, String issuer) { + String idTokenIssuer = oidcIdToken.getIssuer().toString(); + + if (idTokenIssuer == null || !idTokenIssuer.equals(issuer)) { + throw new CommonException(AuthErrorCode.ID_TOKEN_VERIFICATION_FAILED); + } + } + + private void validateExpiresAt(OidcIdToken oidcIdToken) { + Instant expiresAt = oidcIdToken.getExpiresAt(); + + if (expiresAt == null || expiresAt.isBefore(Instant.now())) { + throw new CommonException(AuthErrorCode.ID_TOKEN_VERIFICATION_FAILED); + } + } +} diff --git a/src/main/java/com/amcamp/domain/auth/application/JwtTokenService.java b/src/main/java/com/amcamp/domain/auth/application/JwtTokenService.java new file mode 100644 index 00000000..8c555164 --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/application/JwtTokenService.java @@ -0,0 +1,106 @@ +package com.amcamp.domain.auth.application; + +import static com.amcamp.global.common.constants.SecurityConstants.TOKEN_ROLE_NAME; + +import com.amcamp.domain.auth.dao.RefreshTokenRepository; +import com.amcamp.domain.auth.domain.RefreshToken; +import com.amcamp.domain.auth.dto.AccessTokenDto; +import com.amcamp.domain.auth.dto.RefreshTokenDto; +import com.amcamp.domain.member.domain.MemberRole; +import com.amcamp.global.util.JwtUtil; +import io.jsonwebtoken.ExpiredJwtException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class JwtTokenService { + + private final JwtUtil jwtUtil; + private final RefreshTokenRepository refreshTokenRepository; + + public AccessTokenDto createAccessTokenDto(Long memberId, MemberRole memberRole) { + return jwtUtil.generateAccessTokenDto(memberId, memberRole); + } + + public String createAccessToken(Long memberId, MemberRole memberRole) { + return jwtUtil.generateAccessToken(memberId, memberRole); + } + + public RefreshTokenDto createRefreshTokenDto(Long memberId) { + RefreshTokenDto refreshTokenDto = jwtUtil.generateRefreshTokenDto(memberId); + RefreshToken refreshToken = + RefreshToken.builder() + .memberId(memberId) + .token(refreshTokenDto.refreshTokenValue()) + .ttl(refreshTokenDto.ttl()) + .build(); + refreshTokenRepository.save(refreshToken); + + return refreshTokenDto; + } + + public String createRefreshToken(Long memberId) { + String token = jwtUtil.generateRefreshToken(memberId); + RefreshToken refreshToken = + RefreshToken.builder() + .memberId(memberId) + .token(token) + .ttl(jwtUtil.getRefreshTokenExpirationTime()) + .build(); + refreshTokenRepository.save(refreshToken); + + return token; + } + + public AccessTokenDto retrieveAccessToken(String accessTokenValue) { + try { + return jwtUtil.parseAccessToken(accessTokenValue); + } catch (Exception e) { + return null; + } + } + + public RefreshTokenDto retrieveRefreshToken(String refreshTokenValue) { + RefreshTokenDto refreshTokenDto = parseRefreshToken(refreshTokenValue); + + if (refreshTokenDto == null) { + return null; + } + + Optional refreshToken = getRefreshToken(refreshTokenDto.memberId()); + + if (refreshToken.isPresent() + && refreshTokenDto.refreshTokenValue().equals(refreshToken.get().getToken())) { + return refreshTokenDto; + } + + return null; + } + + public AccessTokenDto reissueAccessTokenIfExpired(String accessTokenValue) { + try { + jwtUtil.parseAccessToken(accessTokenValue); + return null; + } catch (ExpiredJwtException e) { + Long memberId = Long.parseLong(e.getClaims().getSubject()); + MemberRole memberRole = + MemberRole.valueOf(e.getClaims().get(TOKEN_ROLE_NAME, String.class)); + + return createAccessTokenDto(memberId, memberRole); + } + } + + private RefreshTokenDto parseRefreshToken(String refreshTokenValue) { + try { + return jwtUtil.parseRefreshToken(refreshTokenValue); + } catch (Exception e) { + return null; + } + } + + private Optional getRefreshToken(Long memberId) { + return refreshTokenRepository.findById(memberId); + } +} diff --git a/src/main/java/com/amcamp/domain/auth/application/KakaoService.java b/src/main/java/com/amcamp/domain/auth/application/KakaoService.java new file mode 100644 index 00000000..479a0f14 --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/application/KakaoService.java @@ -0,0 +1,27 @@ +package com.amcamp.domain.auth.application; + +import com.amcamp.domain.auth.dto.response.IdTokenResponse; +import com.amcamp.infra.config.feign.KakaoOauthClient; +import com.amcamp.infra.config.oauth.KakaoProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class KakaoService { + + private final KakaoOauthClient kakaoOauthClient; + private final KakaoProperties kakaoProperties; + + public String getIdToken(String code) { + IdTokenResponse response = + kakaoOauthClient.getIdToken( + kakaoProperties.grantType(), + kakaoProperties.clientId(), + kakaoProperties.redirectUri(), + code, + kakaoProperties.clientSecret()); + + return response.id_token(); + } +} diff --git a/src/main/java/com/amcamp/domain/auth/dao/RefreshTokenRepository.java b/src/main/java/com/amcamp/domain/auth/dao/RefreshTokenRepository.java new file mode 100644 index 00000000..196f4e29 --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/dao/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package com.amcamp.domain.auth.dao; + +import com.amcamp.domain.auth.domain.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository {} diff --git a/src/main/java/com/amcamp/domain/auth/domain/OauthProvider.java b/src/main/java/com/amcamp/domain/auth/domain/OauthProvider.java new file mode 100644 index 00000000..46038712 --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/domain/OauthProvider.java @@ -0,0 +1,26 @@ +package com.amcamp.domain.auth.domain; + +import static com.amcamp.global.common.constants.SecurityConstants.*; + +import com.amcamp.infra.config.oauth.GoogleProperties; +import com.amcamp.infra.config.oauth.KakaoProperties; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum OauthProvider { + KAKAO(KAKAO_JWK_SET_URL, KAKAO_ISSUER), + GOOGLE(GOOGLE_JWK_SET_URL, GOOGLE_ISSUER), + ; + + private final String jwkSetUrl; + private final String issuer; + + public String getClientId(GoogleProperties googleProperties, KakaoProperties kakaoProperties) { + return switch (this) { + case GOOGLE -> googleProperties.clientId(); + case KAKAO -> kakaoProperties.clientId(); + }; + } +} diff --git a/src/main/java/com/amcamp/domain/auth/domain/RefreshToken.java b/src/main/java/com/amcamp/domain/auth/domain/RefreshToken.java new file mode 100644 index 00000000..0ca1f5b8 --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/domain/RefreshToken.java @@ -0,0 +1,25 @@ +package com.amcamp.domain.auth.domain; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash(value = "refreshToken") +public class RefreshToken { + + @Id private Long memberId; + + private String token; + + @TimeToLive private long ttl; + + @Builder + private RefreshToken(Long memberId, String token, long ttl) { + this.memberId = memberId; + this.token = token; + this.ttl = ttl; + } +} diff --git a/src/main/java/com/amcamp/domain/auth/dto/AccessTokenDto.java b/src/main/java/com/amcamp/domain/auth/dto/AccessTokenDto.java new file mode 100644 index 00000000..164fb613 --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/dto/AccessTokenDto.java @@ -0,0 +1,9 @@ +package com.amcamp.domain.auth.dto; + +import com.amcamp.domain.member.domain.MemberRole; + +public record AccessTokenDto(Long memberId, MemberRole role, String accessTokenValue) { + public static AccessTokenDto of(Long memberId, MemberRole role, String accessTokenValue) { + return new AccessTokenDto(memberId, role, accessTokenValue); + } +} diff --git a/src/main/java/com/amcamp/domain/auth/dto/RefreshTokenDto.java b/src/main/java/com/amcamp/domain/auth/dto/RefreshTokenDto.java new file mode 100644 index 00000000..a2e6c98d --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/dto/RefreshTokenDto.java @@ -0,0 +1,7 @@ +package com.amcamp.domain.auth.dto; + +public record RefreshTokenDto(Long memberId, String refreshTokenValue, Long ttl) { + public static RefreshTokenDto of(Long memberId, String refreshTokenValue, Long ttl) { + return new RefreshTokenDto(memberId, refreshTokenValue, ttl); + } +} diff --git a/src/main/java/com/amcamp/domain/auth/dto/request/AuthCodeRequest.java b/src/main/java/com/amcamp/domain/auth/dto/request/AuthCodeRequest.java new file mode 100644 index 00000000..1b7636b6 --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/dto/request/AuthCodeRequest.java @@ -0,0 +1,8 @@ +package com.amcamp.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record AuthCodeRequest( + @NotBlank(message = "인증코드는 필수입니다") @Schema(description = "구글, 카카오 로그인을 통한 인증코드") + String code) {} diff --git a/src/main/java/com/amcamp/domain/auth/dto/response/IdTokenResponse.java b/src/main/java/com/amcamp/domain/auth/dto/response/IdTokenResponse.java new file mode 100644 index 00000000..a18edc22 --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/dto/response/IdTokenResponse.java @@ -0,0 +1,3 @@ +package com.amcamp.domain.auth.dto.response; + +public record IdTokenResponse(String id_token) {} diff --git a/src/main/java/com/amcamp/domain/auth/dto/response/SocialLoginResponse.java b/src/main/java/com/amcamp/domain/auth/dto/response/SocialLoginResponse.java new file mode 100644 index 00000000..a5d90b96 --- /dev/null +++ b/src/main/java/com/amcamp/domain/auth/dto/response/SocialLoginResponse.java @@ -0,0 +1,11 @@ +package com.amcamp.domain.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; + +public record SocialLoginResponse( + @Schema(description = "엑세스 토큰") String accessToken, @JsonIgnore String refreshToken) { + public static SocialLoginResponse of(String accessToken, String refreshToken) { + return new SocialLoginResponse(accessToken, refreshToken); + } +} diff --git a/src/main/java/com/amcamp/domain/common/model/BaseTimeEntity.java b/src/main/java/com/amcamp/domain/common/model/BaseTimeEntity.java new file mode 100644 index 00000000..a59b9749 --- /dev/null +++ b/src/main/java/com/amcamp/domain/common/model/BaseTimeEntity.java @@ -0,0 +1,22 @@ +package com.amcamp.domain.common.model; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdDt; + + @LastModifiedDate private LocalDateTime updatedDt; +} diff --git a/src/main/java/com/amcamp/domain/contribution/api/ContributionController.java b/src/main/java/com/amcamp/domain/contribution/api/ContributionController.java new file mode 100644 index 00000000..2f2290d1 --- /dev/null +++ b/src/main/java/com/amcamp/domain/contribution/api/ContributionController.java @@ -0,0 +1,31 @@ +package com.amcamp.domain.contribution.api; + +import com.amcamp.domain.contribution.application.ContributionService; +import com.amcamp.domain.contribution.dto.response.BasicContributionInfoResponse; +import com.amcamp.domain.contribution.dto.response.ContributionInfoResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "8. 기여도 API", description = "기여도 관련 API 입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/contributions") +public class ContributionController { + private final ContributionService contributionService; + + @Operation(summary = "개별 회원 기여도 조회", description = "스프린트별 개별 회원의 기여도를 조회합니다.") + @GetMapping("/{projectId}/me") + public BasicContributionInfoResponse contributionByMember( + @PathVariable Long projectId, @RequestParam Long sprintId) { + return contributionService.getContributionByMember(projectId, sprintId); + } + + @Operation(summary = "프로젝트 내 회원 기여도 조회", description = "프로젝트 페이지에서 스프린트별 기여도를 조회합니다.") + @GetMapping("/{sprintId}") + public List contributionBySprint(@PathVariable Long sprintId) { + return contributionService.getContributionBySprint(sprintId); + } +} diff --git a/src/main/java/com/amcamp/domain/contribution/application/ContributionService.java b/src/main/java/com/amcamp/domain/contribution/application/ContributionService.java new file mode 100644 index 00000000..62d464f4 --- /dev/null +++ b/src/main/java/com/amcamp/domain/contribution/application/ContributionService.java @@ -0,0 +1,113 @@ +package com.amcamp.domain.contribution.application; + +import com.amcamp.domain.contribution.dao.ContributionRepository; +import com.amcamp.domain.contribution.domain.Contribution; +import com.amcamp.domain.contribution.dto.response.BasicContributionInfoResponse; +import com.amcamp.domain.contribution.dto.response.ContributionInfoResponse; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.project.dao.ProjectParticipantRepository; +import com.amcamp.domain.project.dao.ProjectRepository; +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.sprint.dao.SprintRepository; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.ContributionErrorCode; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import com.amcamp.global.exception.errorcode.SprintErrorCode; +import com.amcamp.global.exception.errorcode.TeamErrorCode; +import com.amcamp.global.util.MemberUtil; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +@RequiredArgsConstructor +public class ContributionService { + private final MemberUtil memberUtil; + private final SprintRepository sprintRepository; + private final ContributionRepository contributionRepository; + private final ProjectRepository projectRepository; + private final TeamParticipantRepository teamParticipantRepository; + private final ProjectParticipantRepository projectParticipantRepository; + + public BasicContributionInfoResponse getContributionByMember(Long projectId, Long sprintId) { + Member member = memberUtil.getCurrentMember(); + Project project = + projectRepository + .findById(projectId) + .orElseThrow(() -> new CommonException(ProjectErrorCode.PROJECT_NOT_FOUND)); + + ProjectParticipant currentParticipant = + validateProjectParticipant(project, project.getTeam(), member); + Sprint sprint = validateSprint(sprintId); + validateProjectSprintMismatch(project, sprint); + + Contribution contribution = validateContribution(sprint, currentParticipant); + return BasicContributionInfoResponse.of(contribution, contribution.getScore()); + } + + public List getContributionBySprint(Long sprintId) { + Member member = memberUtil.getCurrentMember(); + Sprint sprint = validateSprint(sprintId); + Project project = sprint.getProject(); + validateTeamParticipant(project.getTeam(), member); + + List contributionList = + contributionRepository.findBySprintOrderByScoreDesc(sprint); + + List result = new ArrayList<>(); + for (Contribution contribution : contributionList) { + result.add(ContributionInfoResponse.of(contribution, contribution.getScore())); + } + + return result; + } + + private void validateProjectSprintMismatch(Project project, Sprint sprint) { + if (!project.equals(sprint.getProject())) { + throw new CommonException(ProjectErrorCode.PROJECT_SPRINT_MISMATCH); + } + } + + private void validateTeamParticipant(Team team, Member member) { + TeamParticipant teamParticipant = + teamParticipantRepository + .findByMemberAndTeam(member, team) + .orElseThrow( + () -> new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + } + + private ProjectParticipant validateProjectParticipant( + Project project, Team team, Member currentMember) { + TeamParticipant teamParticipant = + teamParticipantRepository + .findByMemberAndTeam(currentMember, team) + .orElseThrow( + () -> new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + + return projectParticipantRepository + .findByProjectAndTeamParticipant(project, teamParticipant) + .orElseThrow( + () -> new CommonException(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED)); + } + + private Sprint validateSprint(Long sprintId) { + return sprintRepository + .findById(sprintId) + .orElseThrow(() -> new CommonException(SprintErrorCode.SPRINT_NOT_FOUND)); + } + + private Contribution validateContribution(Sprint sprint, ProjectParticipant participant) { + return contributionRepository + .findBySprintAndParticipant(sprint, participant) + .orElseThrow( + () -> new CommonException(ContributionErrorCode.CONTRIBUTION_NOT_FOUND)); + } +} diff --git a/src/main/java/com/amcamp/domain/contribution/dao/ContributionRepository.java b/src/main/java/com/amcamp/domain/contribution/dao/ContributionRepository.java new file mode 100644 index 00000000..4e078e30 --- /dev/null +++ b/src/main/java/com/amcamp/domain/contribution/dao/ContributionRepository.java @@ -0,0 +1,15 @@ +package com.amcamp.domain.contribution.dao; + +import com.amcamp.domain.contribution.domain.Contribution; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.sprint.domain.Sprint; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ContributionRepository extends JpaRepository { + Optional findBySprintAndParticipant( + Sprint sprint, ProjectParticipant participant); + + List findBySprintOrderByScoreDesc(Sprint sprint); +} diff --git a/src/main/java/com/amcamp/domain/contribution/domain/Contribution.java b/src/main/java/com/amcamp/domain/contribution/domain/Contribution.java new file mode 100644 index 00000000..e9e26977 --- /dev/null +++ b/src/main/java/com/amcamp/domain/contribution/domain/Contribution.java @@ -0,0 +1,48 @@ +package com.amcamp.domain.contribution.domain; + +import com.amcamp.domain.common.model.BaseTimeEntity; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.sprint.domain.Sprint; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Contribution extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "contribution_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sprint_id") + private Sprint sprint; + + // 기여한 멤버 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "participant_id") + private ProjectParticipant participant; + + // 기여도 점수/비율 + private Double score; + + @Builder(access = AccessLevel.PRIVATE) + Contribution(Sprint sprint, ProjectParticipant participant, Double score) { + this.sprint = sprint; + this.participant = participant; + this.score = score; + } + + public static Contribution createContribution( + Sprint sprint, ProjectParticipant participant, Double score) { + return Contribution.builder().sprint(sprint).participant(participant).score(score).build(); + } + + public void updateScore(Double score) { + this.score = score; + } +} diff --git a/src/main/java/com/amcamp/domain/contribution/dto/response/BasicContributionInfoResponse.java b/src/main/java/com/amcamp/domain/contribution/dto/response/BasicContributionInfoResponse.java new file mode 100644 index 00000000..2ed474b8 --- /dev/null +++ b/src/main/java/com/amcamp/domain/contribution/dto/response/BasicContributionInfoResponse.java @@ -0,0 +1,19 @@ +package com.amcamp.domain.contribution.dto.response; + +import com.amcamp.domain.contribution.domain.Contribution; +import io.swagger.v3.oas.annotations.media.Schema; + +public record BasicContributionInfoResponse( + @Schema(description = "기여도 아이디", example = "1") Long contributionId, + @Schema(description = "스프린트 아이디", example = "1") Long sprintId, + @Schema(description = "프로젝트 참여자 아이디", example = "1") Long projectParticipantId, + @Schema(description = "기여도 점수", example = "68.1") int score) { + + public static BasicContributionInfoResponse of(Contribution contribution, Double score) { + return new BasicContributionInfoResponse( + contribution.getId(), + contribution.getSprint().getId(), + contribution.getParticipant().getId(), + score.intValue()); + } +} diff --git a/src/main/java/com/amcamp/domain/contribution/dto/response/ContributionInfoResponse.java b/src/main/java/com/amcamp/domain/contribution/dto/response/ContributionInfoResponse.java new file mode 100644 index 00000000..9871bec5 --- /dev/null +++ b/src/main/java/com/amcamp/domain/contribution/dto/response/ContributionInfoResponse.java @@ -0,0 +1,22 @@ +package com.amcamp.domain.contribution.dto.response; + +import com.amcamp.domain.contribution.domain.Contribution; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ContributionInfoResponse( + @Schema(description = "기여도 아이디", example = "1") Long contributionId, + @Schema(description = "스프린트 아이디", example = "1") Long sprintId, + @Schema(description = "프로젝트참여자 아이디", example = "1") Long projectParticipantId, + @Schema(description = "멤버 닉네임", example = "최현태") String nickname, + @Schema(description = "멤버 프로필 url", example = "Presigned URL") String profileImageUrl, + @Schema(description = "기여도 점수", example = "68") int score) { + public static ContributionInfoResponse of(Contribution contribution, Double score) { + return new ContributionInfoResponse( + contribution.getId(), + contribution.getSprint().getId(), + contribution.getParticipant().getId(), + contribution.getParticipant().getTeamParticipant().getMember().getNickname(), + contribution.getParticipant().getTeamParticipant().getMember().getProfileImageUrl(), + score.intValue()); + } +} diff --git a/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java b/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java new file mode 100644 index 00000000..5d26e42f --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java @@ -0,0 +1,70 @@ +package com.amcamp.domain.feedback.api; + +import com.amcamp.domain.feedback.application.FeedbackService; +import com.amcamp.domain.feedback.dto.request.FeedbackSendRequest; +import com.amcamp.domain.feedback.dto.request.OriginalFeedbackRequest; +import com.amcamp.domain.feedback.dto.response.FeedbackInfoResponse; +import com.amcamp.domain.feedback.dto.response.FeedbackRefineResponse; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "7. 피드백 API", description = "피드백 관련 API입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/feedbacks") +public class FeedbackController { + + private final FeedbackService feedbackService; + + @Operation(summary = "스프린트별 동료평가 여부 확인", description = "스프린트별/팀원별 동료평가 여부를 확인합니다. ") + @GetMapping("/{sprintId}/participants") + public Slice feedbackStatusFind( + @PathVariable Long sprintId, + @Parameter(description = "프로젝트 아이디") @RequestParam Long projectId, + @Parameter(description = "이전 페이지의 마지막 프로젝트 참가자 ID (첫 페이지는 비워두세요)") + @RequestParam(required = false) + Long lastProjectParticipantId, + @Parameter(description = "페이지당 프로젝트 참여자 수", example = "5") @RequestParam(value = "size") + int pageSize) { + + return feedbackService.findFeedbackStatusBySprint( + projectId, sprintId, lastProjectParticipantId, pageSize); + } + + @Operation( + summary = "OpenAI 기반 피드백 메시지 개선", + description = "사용자가 입력한 피드백을 AI가 분석하여 부드럽고 명확하게 개선합니다.") + @PostMapping("/refinement") + public FeedbackRefineResponse feedbackRefine( + @Valid @RequestBody OriginalFeedbackRequest request) { + return feedbackService.refineFeedback(request); + } + + @Operation(summary = "개선된 피드백 메시지 전송", description = "사용자가 AI를 통해 개선한 피드백을 특정 팀원에게 전송합니다.") + @PostMapping("/sent") + public ResponseEntity feedbackSend(@Valid @RequestBody FeedbackSendRequest request) { + feedbackService.sendFeedback(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "피드백 메시지 조회", description = "특정 프로젝트 참여자가 스프린트에서 받은 피드백을 조회합니다.") + @GetMapping("/{projectId}") + public Slice participantFindSprintFeedbacks( + @PathVariable Long projectId, + @Parameter(description = "피드백을 조회할 스프린트 ID") @RequestParam Long sprintId, + @Parameter(description = "이전 페이지의 마지막 피드백 ID (첫 페이지는 비워두세요)") + @RequestParam(required = false) + Long lastFeedbackId, + @Parameter(description = "페이지당 피드백 수", example = "1") @RequestParam(value = "size") + int pageSize) { + return feedbackService.findSprintFeedbacksByParticipant( + projectId, sprintId, lastFeedbackId, pageSize); + } +} diff --git a/src/main/java/com/amcamp/domain/feedback/application/ChatGptService.java b/src/main/java/com/amcamp/domain/feedback/application/ChatGptService.java new file mode 100644 index 00000000..1d54399e --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/application/ChatGptService.java @@ -0,0 +1,26 @@ +package com.amcamp.domain.feedback.application; + +import static com.amcamp.global.common.constants.SecurityConstants.TOKEN_PREFIX; + +import com.amcamp.domain.feedback.dto.request.ChatRequest; +import com.amcamp.domain.feedback.dto.response.ChatResponse; +import com.amcamp.infra.config.feign.OpenAiClient; +import com.amcamp.infra.config.openai.OpenAiProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ChatGptService { + + private final OpenAiProperties openAiProperties; + private final OpenAiClient openAiClient; + + public String getAiFeedback(String userMessage) { + String apiKey = TOKEN_PREFIX + openAiProperties.apiKey(); + ChatRequest request = ChatRequest.of(openAiProperties.model(), userMessage); + + ChatResponse response = openAiClient.getAiFeedback(apiKey, request); + return response.choices()[0].message().content(); + } +} diff --git a/src/main/java/com/amcamp/domain/feedback/application/FeedbackService.java b/src/main/java/com/amcamp/domain/feedback/application/FeedbackService.java new file mode 100644 index 00000000..b8e42edc --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/application/FeedbackService.java @@ -0,0 +1,183 @@ +package com.amcamp.domain.feedback.application; + +import com.amcamp.domain.feedback.dao.FeedbackRepository; +import com.amcamp.domain.feedback.domain.Feedback; +import com.amcamp.domain.feedback.dto.request.FeedbackSendRequest; +import com.amcamp.domain.feedback.dto.request.OriginalFeedbackRequest; +import com.amcamp.domain.feedback.dto.response.FeedbackInfoResponse; +import com.amcamp.domain.feedback.dto.response.FeedbackRefineResponse; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.project.dao.ProjectParticipantRepository; +import com.amcamp.domain.project.dao.ProjectRepository; +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.domain.ProjectParticipantStatus; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; +import com.amcamp.domain.sprint.dao.SprintRepository; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.FeedbackErrorCode; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import com.amcamp.global.exception.errorcode.SprintErrorCode; +import com.amcamp.global.exception.errorcode.TeamErrorCode; +import com.amcamp.global.util.MemberUtil; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +@RequiredArgsConstructor +public class FeedbackService { + + private final ChatGptService chatGptService; + private final MemberUtil memberUtil; + private final FeedbackRepository feedbackRepository; + private final TeamParticipantRepository teamParticipantRepository; + private final ProjectParticipantRepository projectParticipantRepository; + private final ProjectRepository projectRepository; + private final SprintRepository sprintRepository; + + public FeedbackRefineResponse refineFeedback(OriginalFeedbackRequest request) { + String chatResponse = chatGptService.getAiFeedback(request.originalMessage()); + return new FeedbackRefineResponse(chatResponse); + } + + public void sendFeedback(FeedbackSendRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + final Sprint sprint = findBySprintId(request.sprintId()); + + validateSprintDueDate(sprint); + + // 피드백을 보낼 대상 + final ProjectParticipant sender = findSender(currentMember, sprint.getProject()); + + // 피드백을 받을 대상 + final ProjectParticipant receiver = findReceiver(request.receiverId()); + validateUnknownUser(receiver); + + validateSenderIsNotReceiver(sender, receiver); + validateDuplicateFeedback(sender, receiver, sprint); + validateSameProject(sender, receiver); + + feedbackRepository.save( + Feedback.createFeedback(sender, receiver, sprint, request.message())); + } + + @Transactional(readOnly = true) + public Slice findSprintFeedbacksByParticipant( + Long projectId, Long sprintId, Long lastFeedbackId, int pageSize) { + final Member currentMember = memberUtil.getCurrentMember(); + final Project project = findByProjectId(projectId); + final Sprint sprint = findBySprintId(sprintId); + + ProjectParticipant projectParticipant = validateProjectParticipant(currentMember, project); + validateProjectSprintMismatch(project, sprint); + + return feedbackRepository.findSprintFeedbacksByParticipant( + projectParticipant.getId(), sprintId, lastFeedbackId, pageSize); + } + + @Transactional(readOnly = true) + public Slice findFeedbackStatusBySprint( + Long projectId, Long sprintId, Long lastProjectParticipantId, int pageSize) { + final Member currentMember = memberUtil.getCurrentMember(); + final Project project = findByProjectId(projectId); + final Sprint sprint = findBySprintId(sprintId); + + ProjectParticipant projectParticipant = validateProjectParticipant(currentMember, project); + validateProjectSprintMismatch(project, sprint); + + return feedbackRepository.findSprintFeedbackStatusByParticipant( + projectParticipant, sprintId, lastProjectParticipantId, pageSize); + } + + private Project findByProjectId(Long projectId) { + return projectRepository + .findById(projectId) + .orElseThrow(() -> new CommonException(ProjectErrorCode.PROJECT_NOT_FOUND)); + } + + private Sprint findBySprintId(Long sprintId) { + return sprintRepository + .findById(sprintId) + .orElseThrow(() -> new CommonException(SprintErrorCode.SPRINT_NOT_FOUND)); + } + + private ProjectParticipant findSender(Member currentMember, Project project) { + final TeamParticipant teamParticipant = + teamParticipantRepository + .findByMemberAndTeam(currentMember, project.getTeam()) + .orElseThrow( + () -> new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + + return projectParticipantRepository + .findByProjectAndTeamParticipant(project, teamParticipant) + .orElseThrow( + () -> new CommonException(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED)); + } + + private ProjectParticipant findReceiver(Long receiverId) { + return projectParticipantRepository + .findById(receiverId) + .orElseThrow(() -> new CommonException(FeedbackErrorCode.RECEIVER_NOT_FOUND)); + } + + private void validateSenderIsNotReceiver( + ProjectParticipant sender, ProjectParticipant receiver) { + if (sender.getId().equals(receiver.getId())) { + throw new CommonException(FeedbackErrorCode.CANNOT_SEND_FEEDBACK_TO_SELF); + } + } + + private void validateSameProject(ProjectParticipant sender, ProjectParticipant receiver) { + if (!sender.getProject().equals(receiver.getProject())) { + throw new CommonException(FeedbackErrorCode.INVALID_PROJECT_PARTICIPANT); + } + } + + private void validateDuplicateFeedback( + ProjectParticipant sender, ProjectParticipant receiver, Sprint sprint) { + boolean feedbackExists = + feedbackRepository.existsBySenderAndReceiverAndSprint(sender, receiver, sprint); + + if (feedbackExists) { + throw new CommonException(FeedbackErrorCode.FEEDBACK_ALREADY_SENT); + } + } + + private void validateSprintDueDate(Sprint sprint) { + if (!sprint.getDueDt().isEqual(LocalDate.now())) { + throw new CommonException(FeedbackErrorCode.FEEDBACK_DUE_DATE_ONLY); + } + } + + private void validateProjectSprintMismatch(Project project, Sprint sprint) { + if (!project.equals(sprint.getProject())) { + throw new CommonException(ProjectErrorCode.PROJECT_SPRINT_MISMATCH); + } + } + + private ProjectParticipant validateProjectParticipant(Member member, Project project) { + TeamParticipant teamParticipant = + teamParticipantRepository + .findByMemberAndTeam(member, project.getTeam()) + .orElseThrow( + () -> new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + + return projectParticipantRepository + .findByProjectAndTeamParticipant(project, teamParticipant) + .orElseThrow( + () -> new CommonException(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED)); + } + + private void validateUnknownUser(ProjectParticipant participant) { + if (participant.getStatus() == ProjectParticipantStatus.INACTIVE) { + throw new CommonException(FeedbackErrorCode.PARTICIPANT_IS_UNKNOWN); + } + } +} diff --git a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepository.java b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepository.java new file mode 100644 index 00000000..f5568dc4 --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepository.java @@ -0,0 +1,12 @@ +package com.amcamp.domain.feedback.dao; + +import com.amcamp.domain.feedback.domain.Feedback; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.sprint.domain.Sprint; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FeedbackRepository + extends JpaRepository, FeedbackRepositoryCustom { + boolean existsBySenderAndReceiverAndSprint( + ProjectParticipant sender, ProjectParticipant receiver, Sprint sprint); +} diff --git a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryCustom.java b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryCustom.java new file mode 100644 index 00000000..bf9d5af8 --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryCustom.java @@ -0,0 +1,17 @@ +package com.amcamp.domain.feedback.dao; + +import com.amcamp.domain.feedback.dto.response.FeedbackInfoResponse; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; +import org.springframework.data.domain.Slice; + +public interface FeedbackRepositoryCustom { + Slice findSprintFeedbacksByParticipant( + Long projectParticipantId, Long sprintId, Long lastFeedbackId, int pageSize); + + Slice findSprintFeedbackStatusByParticipant( + ProjectParticipant projectParticipant, + Long sprintId, + Long lastProjectParticipantId, + int pageSize); +} diff --git a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java new file mode 100644 index 00000000..83b56849 --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java @@ -0,0 +1,130 @@ +package com.amcamp.domain.feedback.dao; + +import static com.amcamp.domain.feedback.domain.QFeedback.feedback; +import static com.amcamp.domain.member.domain.QMember.member; +import static com.amcamp.domain.project.domain.QProjectParticipant.projectParticipant; +import static com.amcamp.domain.team.domain.QTeamParticipant.teamParticipant; + +import com.amcamp.domain.feedback.dto.response.FeedbackInfoResponse; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.FeedbackErrorCode; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class FeedbackRepositoryImpl implements FeedbackRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Slice findSprintFeedbacksByParticipant( + Long projectParticipantId, Long sprintId, Long lastFeedbackId, int pageSize) { + List results = + jpaQueryFactory + .select( + Projections.constructor( + FeedbackInfoResponse.class, + feedback.id, + feedback.message, + feedback.createdDt)) + .from(feedback) + .where( + feedback.sprint.id.eq(sprintId), + feedback.receiver.id.eq(projectParticipantId), + lastFeedbackId(lastFeedbackId)) + .orderBy(feedback.createdDt.desc()) + .limit(pageSize + 1) + .fetch(); + + if (results.isEmpty()) { + throw new CommonException(FeedbackErrorCode.FEEDBACK_NOT_EXISTS); + } + + return checkLastPage(pageSize, results); + } + + @Override + public Slice findSprintFeedbackStatusByParticipant( + ProjectParticipant sender, Long sprintId, Long lastProjectParticipantId, int pageSize) { + + // 1. sender가 이 sprint에서 피드백한 receiver ID 목록 먼저 뽑기 + List feedbackGivenReceiverIds = + jpaQueryFactory + .select(feedback.receiver.id) + .from(feedback) + .where(feedback.sender.eq(sender), feedback.sprint.id.eq(sprintId)) + .fetch(); + + // 2. 프로젝트 참여자 목록 뽑고, 해당 ID가 feedbackGivenReceiverIds 안에 있는지 체크해서 status 판단 + List results = + jpaQueryFactory + .select( + Projections.constructor( + ProjectParticipantFeedbackInfoResponse.class, + projectParticipant.id, + member.nickname, + member.profileImageUrl, + projectParticipant.projectRole, + projectParticipant.status, + new CaseBuilder() + .when( + projectParticipant.id.in( + feedbackGivenReceiverIds)) + .then("COMPLETED") + .otherwise("PENDING"))) + .from(projectParticipant) + .leftJoin(projectParticipant.teamParticipant, teamParticipant) + .leftJoin(teamParticipant.member, member) + .where( + projectParticipant.project.eq(sender.getProject()), + lastProjectParticipantId(lastProjectParticipantId)) + .orderBy(projectParticipant.id.asc()) + .limit(pageSize + 1) + .fetch(); + + if (results.isEmpty()) { + throw new CommonException(ProjectErrorCode.PROJECT_PARTICIPANT_NOT_EXISTS); + } + + return checkLastPage(pageSize, results); + } + + private BooleanExpression lastFeedbackId(Long feedbackId) { + if (feedbackId == null) { + return null; + } + + return feedback.id.lt(feedbackId); + } + + private BooleanExpression lastProjectParticipantId(Long projectParticipantId) { + if (projectParticipantId == null) { + return null; + } + + return projectParticipant.id.gt(projectParticipantId); + } + + private Slice checkLastPage(int pageSize, List results) { + boolean hasNext = false; + + if (results.size() > pageSize) { + hasNext = true; + results.remove(pageSize); + } + + return new SliceImpl<>(results, PageRequest.of(0, pageSize), hasNext); + } +} diff --git a/src/main/java/com/amcamp/domain/feedback/domain/Feedback.java b/src/main/java/com/amcamp/domain/feedback/domain/Feedback.java new file mode 100644 index 00000000..e76072ae --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/domain/Feedback.java @@ -0,0 +1,55 @@ +package com.amcamp.domain.feedback.domain; + +import com.amcamp.domain.common.model.BaseTimeEntity; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.sprint.domain.Sprint; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Feedback extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "feedback_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id") + private ProjectParticipant sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id") + private ProjectParticipant receiver; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sprint_id") + private Sprint sprint; + + @Column(length = 600) + private String message; + + @Builder(access = AccessLevel.PRIVATE) + private Feedback( + ProjectParticipant sender, ProjectParticipant receiver, Sprint sprint, String message) { + this.sender = sender; + this.receiver = receiver; + this.sprint = sprint; + this.message = message; + } + + public static Feedback createFeedback( + ProjectParticipant sender, ProjectParticipant receiver, Sprint sprint, String message) { + return Feedback.builder() + .sender(sender) + .receiver(receiver) + .sprint(sprint) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/amcamp/domain/feedback/dto/Choice.java b/src/main/java/com/amcamp/domain/feedback/dto/Choice.java new file mode 100644 index 00000000..e49ae57c --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/dto/Choice.java @@ -0,0 +1,3 @@ +package com.amcamp.domain.feedback.dto; + +public record Choice(Message message) {} diff --git a/src/main/java/com/amcamp/domain/feedback/dto/Message.java b/src/main/java/com/amcamp/domain/feedback/dto/Message.java new file mode 100644 index 00000000..2525dcea --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/dto/Message.java @@ -0,0 +1,3 @@ +package com.amcamp.domain.feedback.dto; + +public record Message(String role, String content) {} diff --git a/src/main/java/com/amcamp/domain/feedback/dto/request/ChatRequest.java b/src/main/java/com/amcamp/domain/feedback/dto/request/ChatRequest.java new file mode 100644 index 00000000..8899b0b9 --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/dto/request/ChatRequest.java @@ -0,0 +1,29 @@ +package com.amcamp.domain.feedback.dto.request; + +import com.amcamp.domain.feedback.dto.Message; + +public record ChatRequest(String model, Message[] messages) { + public static ChatRequest of(String model, String userMessage) { + return new ChatRequest( + model, + new Message[] { + new Message( + "system", + """ + 당신은 팀 프로젝트의 동료 평가를 위한 피드백 메시지를 순화하는 역할을 맡고 있습니다. + 사용자가 제공한 피드백은 그대로 전달하기엔 다소 직설적이거나 부정적인 뉘앙스를 가질 수 있습니다. 이 메시지를 보다 부드럽고 건설적인 표현으로 다듬어 주세요. + 출력은 사용자가 최종적으로 보낼 메시지입니다. 별도의 설명이나 추가 문장은 포함하지 마세요. + + - 피드백의 핵심 의도는 유지해주세요. + - 상대방이 기분 나쁘지 않게 개선점을 전달해주세요. + - 너무 딱딱하거나 로봇처럼 느껴지지 않도록 자연스럽게 표현해주세요. + - 이미 긍정적인 메시지라면, 조금 더 따뜻하게 다듬어주시면 됩니다. + + 예시: + "항상 고마워요" → "항상 도움을 아끼지 않아줘서 고마워요. 덕분에 이번 스프린트가 더 원활했던 것 같아요." + "이번엔 역할이 부족했던 것 같아요." → "이번엔 조금 어려움이 있었던 것 같아요. 다음엔 더 잘 맞춰갈 수 있으면 좋겠습니다." + """), + new Message("user", userMessage) + }); + } +} diff --git a/src/main/java/com/amcamp/domain/feedback/dto/request/FeedbackSendRequest.java b/src/main/java/com/amcamp/domain/feedback/dto/request/FeedbackSendRequest.java new file mode 100644 index 00000000..5a6b8099 --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/dto/request/FeedbackSendRequest.java @@ -0,0 +1,17 @@ +package com.amcamp.domain.feedback.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record FeedbackSendRequest( + @Schema(description = "스프린트 ID", example = "1") @NotNull(message = "스프린트 ID는 필수입니다.") + Long sprintId, + @Schema(description = "피드백을 받을 대상의 프로젝트 참여자 ID", example = "2") + @NotNull(message = "receiverId는 필수입니다.") + Long receiverId, + @Schema(description = "보낼 피드백 메시지", example = "이번 프로젝트에서 협업 방식이 좋았습니다!") + @NotBlank(message = "피드백 메시지는 비워둘 수 없습니다.") + @Size(max = 600, message = "피드백 메시지는 최대 600자까지 전송할 수 있습니다.") + String message) {} diff --git a/src/main/java/com/amcamp/domain/feedback/dto/request/OriginalFeedbackRequest.java b/src/main/java/com/amcamp/domain/feedback/dto/request/OriginalFeedbackRequest.java new file mode 100644 index 00000000..518716fa --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/dto/request/OriginalFeedbackRequest.java @@ -0,0 +1,11 @@ +package com.amcamp.domain.feedback.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record OriginalFeedbackRequest( + @NotBlank(message = "피드백 메시지는 비워둘 수 없습니다.") + @Schema(description = "사용자가 입력한 원본 피드백 메시지", example = "이 프로젝트 진행 방식이 별로였습니다.") + @Size(max = 300, message = "피드백 메시지는 최대 300자까지 입력할 수 있습니다.") + String originalMessage) {} diff --git a/src/main/java/com/amcamp/domain/feedback/dto/response/ChatResponse.java b/src/main/java/com/amcamp/domain/feedback/dto/response/ChatResponse.java new file mode 100644 index 00000000..01ac93f0 --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/dto/response/ChatResponse.java @@ -0,0 +1,5 @@ +package com.amcamp.domain.feedback.dto.response; + +import com.amcamp.domain.feedback.dto.Choice; + +public record ChatResponse(Choice[] choices) {} diff --git a/src/main/java/com/amcamp/domain/feedback/dto/response/FeedbackInfoResponse.java b/src/main/java/com/amcamp/domain/feedback/dto/response/FeedbackInfoResponse.java new file mode 100644 index 00000000..0f747b87 --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/dto/response/FeedbackInfoResponse.java @@ -0,0 +1,14 @@ +package com.amcamp.domain.feedback.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public record FeedbackInfoResponse( + @Schema(description = "피드백 ID", example = "1") Long feedbackId, + @Schema(description = "받은 피드백 메시지", example = "이번 스프린트에서 아주 잘해주셨습니다.") String message, + @Schema(description = "피드백을 받은 날짜", example = "2026-01-01") LocalDate receivedDt) { + public FeedbackInfoResponse(Long feedbackId, String message, LocalDateTime createdDt) { + this(feedbackId, message, createdDt.toLocalDate()); + } +} diff --git a/src/main/java/com/amcamp/domain/feedback/dto/response/FeedbackRefineResponse.java b/src/main/java/com/amcamp/domain/feedback/dto/response/FeedbackRefineResponse.java new file mode 100644 index 00000000..8c0540a9 --- /dev/null +++ b/src/main/java/com/amcamp/domain/feedback/dto/response/FeedbackRefineResponse.java @@ -0,0 +1,7 @@ +package com.amcamp.domain.feedback.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record FeedbackRefineResponse( + @Schema(description = "AI가 개선한 피드백 메시지", example = "이번 프로젝트에서 협업이 조금 더 원활했으면 좋았을 것 같습니다.") + String refinedMessage) {} diff --git a/src/main/java/com/amcamp/domain/image/api/ImageController.java b/src/main/java/com/amcamp/domain/image/api/ImageController.java new file mode 100644 index 00000000..64594879 --- /dev/null +++ b/src/main/java/com/amcamp/domain/image/api/ImageController.java @@ -0,0 +1,41 @@ +package com.amcamp.domain.image.api; + +import com.amcamp.domain.image.application.ImageService; +import com.amcamp.domain.image.dto.request.MemberImageUploadCompleteRequest; +import com.amcamp.domain.image.dto.request.MemberImageUploadRequest; +import com.amcamp.domain.image.dto.response.PresignedUrlResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "2. 이미지 API", description = "이미지 관련 API입니다.") +@RestController +@RequiredArgsConstructor +public class ImageController { + + private final ImageService imageService; + + @Operation( + summary = "회원 프로필 이미지 Presigned URL 생성", + description = "회원 프로필 이미지 Presigned URL을 생성합니다.") + @PostMapping("/members/me/image/upload-url") + public PresignedUrlResponse memberImagePresignedUrlCreate( + @Valid @RequestBody MemberImageUploadRequest request) { + return imageService.createMemberImagePresignedUrl(request); + } + + @Operation( + summary = "회원 프로필 이미지 업로드 완료 처리", + description = "회원 프로필 이미지의 업로드가 완료되었을 때 호출하시면 됩니다.") + @PostMapping("/members/me/image/upload-complete") + public ResponseEntity memberImageUploadComplete( + @Valid @RequestBody MemberImageUploadCompleteRequest request) { + imageService.uploadCompleteMemberImage(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/amcamp/domain/image/application/ImageService.java b/src/main/java/com/amcamp/domain/image/application/ImageService.java new file mode 100644 index 00000000..80590a7e --- /dev/null +++ b/src/main/java/com/amcamp/domain/image/application/ImageService.java @@ -0,0 +1,121 @@ +package com.amcamp.domain.image.application; + +import com.amazonaws.HttpMethod; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.Headers; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import com.amcamp.domain.image.dao.ImageRepository; +import com.amcamp.domain.image.domain.Image; +import com.amcamp.domain.image.domain.ImageFileExtension; +import com.amcamp.domain.image.dto.request.MemberImageUploadCompleteRequest; +import com.amcamp.domain.image.dto.request.MemberImageUploadRequest; +import com.amcamp.domain.image.dto.response.PresignedUrlResponse; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.global.common.constants.UrlConstants; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.ImageErrorCode; +import com.amcamp.global.util.MemberUtil; +import com.amcamp.infra.config.s3.S3Properties; +import java.util.Date; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +@RequiredArgsConstructor +public class ImageService { + + private final MemberUtil memberUtil; + private final S3Properties s3Properties; + private final ImageRepository imageRepository; + private final AmazonS3 amazonS3; + + public PresignedUrlResponse createMemberImagePresignedUrl(MemberImageUploadRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + + String imageKey = generateUUID(); + String imageName = + createImageFileName(currentMember.getId(), imageKey, request.imageFileExtension()); + GeneratePresignedUrlRequest generatePresignedUrlRequest = + generatePresignedUrlRequest( + s3Properties.bucket(), + imageName, + request.imageFileExtension().getExtension()); + + String presignedUrl = amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString(); + + imageRepository.save( + Image.createImage(currentMember.getId(), imageKey, request.imageFileExtension())); + + return new PresignedUrlResponse(presignedUrl); + } + + public void uploadCompleteMemberImage(MemberImageUploadCompleteRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + + String imageUrl = null; + if (request.imageFileExtension() != null) { + Image image = findImage(currentMember.getId(), request.imageFileExtension()); + imageUrl = + createReadImageUrl( + currentMember.getId(), + image.getImageKey(), + image.getImageFileExtension()); + } + + currentMember.updateProfileImageUrl(imageUrl); + } + + private GeneratePresignedUrlRequest generatePresignedUrlRequest( + String bucket, String imageName, String imageFileExtension) { + GeneratePresignedUrlRequest generatePresignedUrlRequest = + new GeneratePresignedUrlRequest(bucket, imageName, HttpMethod.PUT) + .withKey(imageName) + .withContentType("image/" + imageFileExtension) + .withExpiration(getPresignedUrlExpiration()); + + generatePresignedUrlRequest.addRequestParameter( + Headers.S3_CANNED_ACL, CannedAccessControlList.PublicRead.toString()); + + return generatePresignedUrlRequest; + } + + private String generateUUID() { + return UUID.randomUUID().toString(); + } + + private String createImageFileName( + Long memberId, String imageKey, ImageFileExtension imageFileExtension) { + return memberId + "/" + imageKey + "." + imageFileExtension.getExtension(); + } + + private Date getPresignedUrlExpiration() { + Date expiration = new Date(); + long expTime = expiration.getTime(); + expTime += TimeUnit.MINUTES.toMillis(3); + expiration.setTime(expTime); + + return expiration; + } + + private Image findImage(Long memberId, ImageFileExtension imageFileExtension) { + return imageRepository + .findLatestByMemberIdAndExtension(memberId, imageFileExtension) + .orElseThrow(() -> new CommonException(ImageErrorCode.IMAGE_NOT_FOUND)); + } + + private String createReadImageUrl( + Long memberId, String imageKey, ImageFileExtension imageFileExtension) { + return UrlConstants.IMAGE_URL + + "/" + + createImageFileName(memberId, imageKey, imageFileExtension); + } + + public void deleteAllImage() { + imageRepository.deleteAll(); + } +} diff --git a/src/main/java/com/amcamp/domain/image/dao/ImageRepository.java b/src/main/java/com/amcamp/domain/image/dao/ImageRepository.java new file mode 100644 index 00000000..2354c08e --- /dev/null +++ b/src/main/java/com/amcamp/domain/image/dao/ImageRepository.java @@ -0,0 +1,15 @@ +package com.amcamp.domain.image.dao; + +import com.amcamp.domain.image.domain.Image; +import com.amcamp.domain.image.domain.ImageFileExtension; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface ImageRepository extends JpaRepository { + @Query( + "select i from Image i where i.memberId = :memberId and i.imageFileExtension = :imageFileExtension " + + "order by i.id desc limit 1") + Optional findLatestByMemberIdAndExtension( + Long memberId, ImageFileExtension imageFileExtension); +} diff --git a/src/main/java/com/amcamp/domain/image/domain/Image.java b/src/main/java/com/amcamp/domain/image/domain/Image.java new file mode 100644 index 00000000..09940a36 --- /dev/null +++ b/src/main/java/com/amcamp/domain/image/domain/Image.java @@ -0,0 +1,42 @@ +package com.amcamp.domain.image.domain; + +import com.amcamp.domain.common.model.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Image extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_id") + private Long id; + + private Long memberId; + + private String imageKey; + + @Enumerated(EnumType.STRING) + private ImageFileExtension imageFileExtension; + + @Builder(access = AccessLevel.PRIVATE) + private Image(Long memberId, String imageKey, ImageFileExtension imageFileExtension) { + this.memberId = memberId; + this.imageKey = imageKey; + this.imageFileExtension = imageFileExtension; + } + + public static Image createImage( + Long memberId, String imageKey, ImageFileExtension imageFileExtension) { + return Image.builder() + .memberId(memberId) + .imageKey(imageKey) + .imageFileExtension(imageFileExtension) + .build(); + } +} diff --git a/src/main/java/com/amcamp/domain/image/domain/ImageFileExtension.java b/src/main/java/com/amcamp/domain/image/domain/ImageFileExtension.java new file mode 100644 index 00000000..084b549a --- /dev/null +++ b/src/main/java/com/amcamp/domain/image/domain/ImageFileExtension.java @@ -0,0 +1,15 @@ +package com.amcamp.domain.image.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ImageFileExtension { + PNG("png"), + JPG("jpg"), + JPEG("jpeg"), + ; + + private final String extension; +} diff --git a/src/main/java/com/amcamp/domain/image/dto/request/MemberImageUploadCompleteRequest.java b/src/main/java/com/amcamp/domain/image/dto/request/MemberImageUploadCompleteRequest.java new file mode 100644 index 00000000..52f34e62 --- /dev/null +++ b/src/main/java/com/amcamp/domain/image/dto/request/MemberImageUploadCompleteRequest.java @@ -0,0 +1,10 @@ +package com.amcamp.domain.image.dto.request; + +import com.amcamp.domain.image.domain.ImageFileExtension; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record MemberImageUploadCompleteRequest( + @NotNull(message = "이미지 파일 확장자는 비워둘 수 없습니다.") + @Schema(description = "이미지 파일 확장자", defaultValue = "JPEG") + ImageFileExtension imageFileExtension) {} diff --git a/src/main/java/com/amcamp/domain/image/dto/request/MemberImageUploadRequest.java b/src/main/java/com/amcamp/domain/image/dto/request/MemberImageUploadRequest.java new file mode 100644 index 00000000..5a666f8f --- /dev/null +++ b/src/main/java/com/amcamp/domain/image/dto/request/MemberImageUploadRequest.java @@ -0,0 +1,10 @@ +package com.amcamp.domain.image.dto.request; + +import com.amcamp.domain.image.domain.ImageFileExtension; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record MemberImageUploadRequest( + @NotNull(message = "이미지 파일 확장자는 비워둘 수 없습니다.") + @Schema(description = "이미지 파일 확장자", defaultValue = "JPEG") + ImageFileExtension imageFileExtension) {} diff --git a/src/main/java/com/amcamp/domain/image/dto/response/PresignedUrlResponse.java b/src/main/java/com/amcamp/domain/image/dto/response/PresignedUrlResponse.java new file mode 100644 index 00000000..75ce7e1e --- /dev/null +++ b/src/main/java/com/amcamp/domain/image/dto/response/PresignedUrlResponse.java @@ -0,0 +1,5 @@ +package com.amcamp.domain.image.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PresignedUrlResponse(@Schema(description = "Presigned URL") String presignedUrl) {} diff --git a/src/main/java/com/amcamp/domain/meeting/api/MeetingController.java b/src/main/java/com/amcamp/domain/meeting/api/MeetingController.java new file mode 100644 index 00000000..616fbc97 --- /dev/null +++ b/src/main/java/com/amcamp/domain/meeting/api/MeetingController.java @@ -0,0 +1,69 @@ +package com.amcamp.domain.meeting.api; + +import com.amcamp.domain.meeting.application.MeetingService; +import com.amcamp.domain.meeting.dto.request.MeetingCreateRequest; +import com.amcamp.domain.meeting.dto.request.MeetingDtUpdateRequest; +import com.amcamp.domain.meeting.dto.request.MeetingTitleUpdateRequest; +import com.amcamp.domain.meeting.dto.response.MeetingInfoResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "9. 팀 미팅 API", description = "팀 미팅 관련 API입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/meetings") +public class MeetingController { + + private final MeetingService meetingService; + + @Operation(summary = "미팅 생성", description = "새로운 미팅을 생성합니다.") + @PostMapping("/create") + public ResponseEntity meetingCreate(@Valid @RequestBody MeetingCreateRequest request) { + meetingService.createMeeting(request); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @Operation(summary = "미팅 일시 업데이트", description = "미팅 일시를 업데이트합니다.") + @PostMapping("/{meetingId}/date") + public ResponseEntity meetingDateUpdate( + @PathVariable Long meetingId, @Valid @RequestBody MeetingDtUpdateRequest request) { + meetingService.updateMeetingDt(meetingId, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "미팅 타이틀 업데이트", description = "미팅 타이틀을 업데이트합니다.") + @PostMapping("/{meetingId}/title") + public ResponseEntity meetingTitleUpdate( + @PathVariable Long meetingId, @Valid @RequestBody MeetingTitleUpdateRequest request) { + meetingService.updateMeetingTitle(meetingId, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "미팅 취소", description = "미팅을 삭제합니다.") + @DeleteMapping("/{meetingId}") + public ResponseEntity meetingCancel(@PathVariable Long meetingId) { + meetingService.deleteMeeting(meetingId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "미팅 조회", description = "미팅 개별 정보를 조회합니다.") + @GetMapping("/{meetingId}") + public MeetingInfoResponse meetingGet(@PathVariable Long meetingId) { + return meetingService.getMeeting(meetingId); + } + + @Operation(summary = "미팅 목록 조회", description = "스프린트 별 미팅 목록을 조회합니다.") + @GetMapping("/{sprintId}/list") + public Slice meetingListGet( + @PathVariable Long sprintId, + @RequestParam(required = false) Long lastMeetingId, + @RequestParam(defaultValue = "10") int pageSize) { + return meetingService.getMeetingList(sprintId, lastMeetingId, pageSize); + } +} diff --git a/src/main/java/com/amcamp/domain/meeting/application/MeetingService.java b/src/main/java/com/amcamp/domain/meeting/application/MeetingService.java new file mode 100644 index 00000000..0b7b8e90 --- /dev/null +++ b/src/main/java/com/amcamp/domain/meeting/application/MeetingService.java @@ -0,0 +1,165 @@ +package com.amcamp.domain.meeting.application; + +import com.amcamp.domain.meeting.dao.MeetingRepository; +import com.amcamp.domain.meeting.domain.Meeting; +import com.amcamp.domain.meeting.domain.MeetingStatus; +import com.amcamp.domain.meeting.dto.request.MeetingCreateRequest; +import com.amcamp.domain.meeting.dto.request.MeetingDtUpdateRequest; +import com.amcamp.domain.meeting.dto.request.MeetingTitleUpdateRequest; +import com.amcamp.domain.meeting.dto.response.MeetingInfoResponse; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.project.dao.ProjectParticipantRepository; +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.sprint.dao.SprintRepository; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.MeetingErrorCode; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import com.amcamp.global.exception.errorcode.SprintErrorCode; +import com.amcamp.global.exception.errorcode.TeamErrorCode; +import com.amcamp.global.util.MemberUtil; +import jakarta.annotation.Nullable; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class MeetingService { + + private final TeamParticipantRepository teamParticipantRepository; + private final ProjectParticipantRepository projectParticipantRepository; + private final MeetingRepository meetingRepository; + private final SprintRepository sprintRepository; + private final MemberUtil memberUtil; + + // 미팅 생성 + public void createMeeting(MeetingCreateRequest request) { + Member member = memberUtil.getCurrentMember(); + Sprint sprint = getValidSprint(member, request.sprintId()); + validateMeetingTime(null, sprint, request.meetingStart(), request.meetingEnd()); + meetingRepository.save( + Meeting.createMeeting( + request.title(), request.meetingStart(), request.meetingEnd(), sprint)); + } + + @Transactional(readOnly = true) + public MeetingInfoResponse getMeeting(Long meetingId) { + Member member = memberUtil.getCurrentMember(); + Meeting meeting = getMeetingById(meetingId); + validateProjectParticipant(member, meeting.getSprint().getProject()); + return MeetingInfoResponse.from(meeting); + } + + @Transactional(readOnly = true) + public Slice getMeetingList( + Long sprintId, Long lastMeetingId, int pageSize) { + Member member = memberUtil.getCurrentMember(); + Sprint sprint = getValidSprint(member, sprintId); + validateProjectParticipant(member, sprint.getProject()); + return meetingRepository.findAllBySprintIdWithPagination(sprintId, lastMeetingId, pageSize); + } + + // 업데이트 + public void updateMeetingTitle(Long meetingId, MeetingTitleUpdateRequest request) { + Member member = memberUtil.getCurrentMember(); + Meeting meeting = getMeetingById(meetingId); + validateProjectParticipant(member, meeting.getSprint().getProject()); + + meeting.updateTitle(request.title()); + } + + public void updateMeetingDt(Long meetingId, MeetingDtUpdateRequest request) { + Member member = memberUtil.getCurrentMember(); + Meeting meeting = getMeetingById(meetingId); + validateProjectParticipant(member, meeting.getSprint().getProject()); + LocalDateTime meetingStart = + request.meetingStart() != null ? request.meetingStart() : meeting.getMeetingStart(); + LocalDateTime meetingEnd = + request.meetingEnd() != null ? request.meetingEnd() : meeting.getMeetingEnd(); + validateMeetingTime(meeting.getId(), meeting.getSprint(), meetingStart, meetingEnd); + meeting.updateMeetingDt(meetingStart, meetingEnd); + } + + // 삭제 + public void deleteMeeting(Long meetingId) { + Member member = memberUtil.getCurrentMember(); + Meeting meeting = getMeetingById(meetingId); + validateProjectParticipant(member, meeting.getSprint().getProject()); + meetingRepository.delete(meeting); + } + + // util + private void validateMeetingTime( + @Nullable Long meetingId, + Sprint sprint, + LocalDateTime meetingStart, + LocalDateTime meetingEnd) { + + if (!meetingStart.isBefore(meetingEnd) || meetingStart.isEqual(meetingEnd)) { + throw new CommonException(MeetingErrorCode.INVALID_MEETING_TIME_RANGE); + } + // 8:00~00:00 + if (meetingStart.getHour() < 8 + || (meetingEnd.getHour() == 0 && meetingEnd.getMinute() > 0)) { + throw new CommonException(MeetingErrorCode.INVALID_MEETING_TIME_RANGE); + } + // 스프린트 범위 확인 + if (meetingStart.toLocalDate().isBefore(sprint.getStartDt()) + || meetingEnd.toLocalDate().isAfter(sprint.getDueDt())) { + throw new CommonException(MeetingErrorCode.MEETING_DATE_OUT_OF_SPRINT); + } // 기존 일정과의 중복 확인 + if (meetingRepository + .findOverlappingMeeting(meetingId, sprint, meetingStart, meetingEnd) + .isPresent()) { + throw new CommonException(MeetingErrorCode.MEETING_ALREADY_EXISTS); + } + } + + private TeamParticipant getValidTeamParticipant(Member member, Team team) { + return teamParticipantRepository + .findByMemberAndTeam(member, team) + .orElseThrow(() -> new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + } + + private ProjectParticipant validateProjectParticipant(Member member, Project project) { + TeamParticipant teamParticipant = getValidTeamParticipant(member, project.getTeam()); + return projectParticipantRepository + .findByProjectAndTeamParticipant(project, teamParticipant) + .orElseThrow( + () -> new CommonException(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED)); + } + + private Sprint getValidSprint(Member member, Long sprintId) { + Sprint sprint = + sprintRepository + .findById(sprintId) + .orElseThrow(() -> new CommonException(SprintErrorCode.SPRINT_NOT_FOUND)); + validateProjectParticipant(member, sprint.getProject()); + return sprint; + } + + private Meeting getMeetingById(Long meetingId) { + return meetingRepository + .findById(meetingId) + .orElseThrow(() -> new CommonException(MeetingErrorCode.MEETING_NOT_FOUND)); + } + + public void updateExpiredMeetings() { + // 상태:OPEN, meetingEnd 현재 시간보다 이전인 미팅 + List expiredMeetings = + meetingRepository.findByStatusAndMeetingEndBefore( + MeetingStatus.OPEN, LocalDateTime.now()); + + // CLOSE로 변경 + expiredMeetings.forEach(meeting -> meeting.updateStatus(MeetingStatus.CLOSE)); + } +} diff --git a/src/main/java/com/amcamp/domain/meeting/dao/MeetingRepository.java b/src/main/java/com/amcamp/domain/meeting/dao/MeetingRepository.java new file mode 100644 index 00000000..a418b9a3 --- /dev/null +++ b/src/main/java/com/amcamp/domain/meeting/dao/MeetingRepository.java @@ -0,0 +1,28 @@ +package com.amcamp.domain.meeting.dao; + +import com.amcamp.domain.meeting.domain.Meeting; +import com.amcamp.domain.meeting.domain.MeetingStatus; +import com.amcamp.domain.sprint.domain.Sprint; +import feign.Param; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface MeetingRepository extends JpaRepository, MeetingRepositoryCustom { + @Query( + "select m from Meeting m where m.sprint = :sprint " + + " and ((:meetingId is NULL) or m.id <> :meetingId)" + + "and ((:meetingStart between m.meetingStart and m.meetingEnd) " + + "or (:meetingEnd between m.meetingStart and m.meetingEnd) " + + "or (m.meetingStart between :meetingStart and :meetingEnd) " + + "or (m.meetingEnd between :meetingStart and :meetingEnd))") + Optional findOverlappingMeeting( + @Param("meetingId") Long meetingId, + @Param("sprint") Sprint sprint, + @Param("meetingStart") LocalDateTime meetingStart, + @Param("meetingEnd") LocalDateTime meetingEnd); + + List findByStatusAndMeetingEndBefore(MeetingStatus status, LocalDateTime dateTime); +} diff --git a/src/main/java/com/amcamp/domain/meeting/dao/MeetingRepositoryCustom.java b/src/main/java/com/amcamp/domain/meeting/dao/MeetingRepositoryCustom.java new file mode 100644 index 00000000..38030259 --- /dev/null +++ b/src/main/java/com/amcamp/domain/meeting/dao/MeetingRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.amcamp.domain.meeting.dao; + +import com.amcamp.domain.meeting.dto.response.MeetingInfoResponse; +import org.springframework.data.domain.Slice; + +public interface MeetingRepositoryCustom { + Slice findAllBySprintIdWithPagination( + Long sprintId, Long lastMeetingId, int pageSize); +} diff --git a/src/main/java/com/amcamp/domain/meeting/dao/MeetingRepositoryImpl.java b/src/main/java/com/amcamp/domain/meeting/dao/MeetingRepositoryImpl.java new file mode 100644 index 00000000..4e0153c2 --- /dev/null +++ b/src/main/java/com/amcamp/domain/meeting/dao/MeetingRepositoryImpl.java @@ -0,0 +1,59 @@ +package com.amcamp.domain.meeting.dao; + +import static com.amcamp.domain.meeting.domain.QMeeting.meeting; +import static com.amcamp.domain.sprint.domain.QSprint.sprint; + +import com.amcamp.domain.meeting.dto.response.MeetingInfoResponse; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +@RequiredArgsConstructor +public class MeetingRepositoryImpl implements MeetingRepositoryCustom { + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Slice findAllBySprintIdWithPagination( + Long sprintId, Long lastMeetingId, int pageSize) { + + List responses = + jpaQueryFactory + .select( + Projections.constructor( + MeetingInfoResponse.class, + meeting.id, + meeting.title, + meeting.meetingStart, + meeting.meetingEnd)) + .from(meeting) + .leftJoin(sprint) + .on(meeting.sprint.eq(sprint)) + .where(meeting.sprint.id.eq(sprintId), lastMeetingCondition(lastMeetingId)) + .orderBy(meeting.id.desc()) + .limit(pageSize + 1) + .fetch(); + + return checkLastPage(pageSize, responses); + } + + private BooleanExpression lastMeetingCondition(Long meetingId) { + return (meetingId == null) ? null : meeting.id.lt(meetingId); + } + + private Slice checkLastPage( + int pageSize, List results) { + boolean hasNext = false; + + if (results.size() > pageSize) { + hasNext = true; + results.remove(pageSize); + } + + return new SliceImpl<>(results, PageRequest.of(0, pageSize), hasNext); + } +} diff --git a/src/main/java/com/amcamp/domain/meeting/domain/Meeting.java b/src/main/java/com/amcamp/domain/meeting/domain/Meeting.java new file mode 100644 index 00000000..1796935f --- /dev/null +++ b/src/main/java/com/amcamp/domain/meeting/domain/Meeting.java @@ -0,0 +1,85 @@ +package com.amcamp.domain.meeting.domain; + +import com.amcamp.domain.common.model.BaseTimeEntity; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.MeetingErrorCode; +import jakarta.persistence.*; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Meeting extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "meeting_id") + private Long id; + + @Column(name = "meeting_title") + private String title; + + @Column(name = "meeting_start") + private LocalDateTime meetingStart; + + @Column(name = "meeting_end") + private LocalDateTime meetingEnd; + + @Column(name = "meeting_status") + private MeetingStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sprint_id") + private Sprint sprint; + + @Builder(access = AccessLevel.PRIVATE) + private Meeting( + String title, + LocalDateTime meetingStart, + LocalDateTime meetingEnd, + Sprint sprint, + MeetingStatus status) { + this.title = title; + this.meetingStart = meetingStart; + this.meetingEnd = meetingEnd; + this.sprint = sprint; + this.status = status; + } + + public static Meeting createMeeting( + String title, LocalDateTime meetingStart, LocalDateTime meetingEnd, Sprint sprint) { + + if (meetingStart.isAfter(meetingEnd) || meetingStart.isEqual(meetingEnd)) { + throw new CommonException(MeetingErrorCode.INVALID_MEETING_TIME_RANGE); + } + return Meeting.builder() + .title(title) + .meetingStart(meetingStart) + .meetingEnd(meetingEnd) + .sprint(sprint) + .status(MeetingStatus.OPEN) + .build(); + } + + public void updateStatus(MeetingStatus status) { + this.status = status; + } + + public void updateTitle(String title) { + this.title = title; + } + + public void updateMeetingDt(LocalDateTime meetingStart, LocalDateTime meetingEnd) { + if (meetingStart != null) { + this.meetingStart = meetingStart; + } + if (meetingEnd != null) { + this.meetingEnd = meetingEnd; + } + } +} diff --git a/src/main/java/com/amcamp/domain/meeting/domain/MeetingStatus.java b/src/main/java/com/amcamp/domain/meeting/domain/MeetingStatus.java new file mode 100644 index 00000000..d68bddbb --- /dev/null +++ b/src/main/java/com/amcamp/domain/meeting/domain/MeetingStatus.java @@ -0,0 +1,13 @@ +package com.amcamp.domain.meeting.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum MeetingStatus { + OPEN("MEETING_OPEN"), + CLOSE("MEETING_CLOSE"); + + private final String status; +} diff --git a/src/main/java/com/amcamp/domain/meeting/dto/request/MeetingCreateRequest.java b/src/main/java/com/amcamp/domain/meeting/dto/request/MeetingCreateRequest.java new file mode 100644 index 00000000..f216619f --- /dev/null +++ b/src/main/java/com/amcamp/domain/meeting/dto/request/MeetingCreateRequest.java @@ -0,0 +1,18 @@ +package com.amcamp.domain.meeting.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +public record MeetingCreateRequest( + @Schema(description = "스프린트 아이디", example = "1") @NotNull Long sprintId, + @Schema(description = "미팅 타이틀", example = "중간 점검 회의") + @Size(max = 15, message = "미팅 타이틀은 최대 15자까지 입력 가능합니다.") + @NotBlank + String title, + @Schema(description = "미팅 시작 날짜/시간", example = "2026-03-01T15:17:00") @NotNull + LocalDateTime meetingStart, + @Schema(description = "미팅 종료 날짜/시간", example = "2026-03-01T15:18:00") @NotNull + LocalDateTime meetingEnd) {} diff --git a/src/main/java/com/amcamp/domain/meeting/dto/request/MeetingDtUpdateRequest.java b/src/main/java/com/amcamp/domain/meeting/dto/request/MeetingDtUpdateRequest.java new file mode 100644 index 00000000..896f4f69 --- /dev/null +++ b/src/main/java/com/amcamp/domain/meeting/dto/request/MeetingDtUpdateRequest.java @@ -0,0 +1,11 @@ +package com.amcamp.domain.meeting.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import java.time.LocalDateTime; + +public record MeetingDtUpdateRequest( + @Schema(description = "수정된 미팅 시작날짜, 시간", example = "회의 일시") @Nullable + LocalDateTime meetingStart, + @Schema(description = "수정된 미팅 종료날짜, 시간", example = "회의 일시") @Nullable + LocalDateTime meetingEnd) {} diff --git a/src/main/java/com/amcamp/domain/meeting/dto/request/MeetingTitleUpdateRequest.java b/src/main/java/com/amcamp/domain/meeting/dto/request/MeetingTitleUpdateRequest.java new file mode 100644 index 00000000..b6e41a22 --- /dev/null +++ b/src/main/java/com/amcamp/domain/meeting/dto/request/MeetingTitleUpdateRequest.java @@ -0,0 +1,11 @@ +package com.amcamp.domain.meeting.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record MeetingTitleUpdateRequest( + @Schema(description = "수정된 미팅 타이틀", example = "중간 점검 회의") + @Size(max = 15, message = "미팅 타이틀은 최대 15자까지 입력 가능합니다.") + @NotBlank + String title) {} diff --git a/src/main/java/com/amcamp/domain/meeting/dto/response/MeetingInfoResponse.java b/src/main/java/com/amcamp/domain/meeting/dto/response/MeetingInfoResponse.java new file mode 100644 index 00000000..a25a36e5 --- /dev/null +++ b/src/main/java/com/amcamp/domain/meeting/dto/response/MeetingInfoResponse.java @@ -0,0 +1,20 @@ +package com.amcamp.domain.meeting.dto.response; + +import com.amcamp.domain.meeting.domain.Meeting; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; + +public record MeetingInfoResponse( + @Schema(description = "미팅 아이디", example = "1") @NotNull Long meetingId, + @Schema(description = "미팅 타이틀", example = "미팅 타이틀") @NotNull String meetingTitle, + @Schema(description = "미팅 시작날짜, 시간", example = "회의 일시") @NotNull LocalDateTime meetingStart, + @Schema(description = "미팅 종료날짜, 시간", example = "회의 일시") @NotNull LocalDateTime meetingEnd) { + public static MeetingInfoResponse from(Meeting meeting) { + return new MeetingInfoResponse( + meeting.getId(), + meeting.getTitle(), + meeting.getMeetingStart(), + meeting.getMeetingEnd()); + } +} diff --git a/src/main/java/com/amcamp/domain/member/api/MemberController.java b/src/main/java/com/amcamp/domain/member/api/MemberController.java new file mode 100644 index 00000000..a9934fa3 --- /dev/null +++ b/src/main/java/com/amcamp/domain/member/api/MemberController.java @@ -0,0 +1,64 @@ +package com.amcamp.domain.member.api; + +import com.amcamp.domain.member.application.MemberService; +import com.amcamp.domain.member.dto.request.NicknameUpdateRequest; +import com.amcamp.domain.member.dto.response.BasicMemberResponse; +import com.amcamp.domain.member.dto.response.MemberInfoResponse; +import com.amcamp.global.util.CookieUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "1-2. 회원 API", description = "회원 관련 API입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/members") +public class MemberController { + + private final CookieUtil cookieUtil; + private final MemberService memberService; + + @Operation(summary = "로그아웃", description = "로그아웃을 진행합니다.") + @PostMapping("/logout") + public ResponseEntity memberLogout() { + memberService.logoutMember(); + return ResponseEntity.ok().headers(cookieUtil.deleteRefreshTokenCookie()).build(); + } + + @Operation(summary = "회원 탈퇴", description = "회원 탈퇴를 진행합니다.") + @DeleteMapping("/withdrawal") + public ResponseEntity memberWithdrawal() { + memberService.withdrawalMember(); + return ResponseEntity.ok().headers(cookieUtil.deleteRefreshTokenCookie()).build(); + } + + @Operation(summary = "회원 닉네임 변경", description = "회원 닉네임을 변경합니다.") + @PostMapping("/me/nickname") + public ResponseEntity memberNicknameUpdate( + @Valid @RequestBody NicknameUpdateRequest request) { + memberService.updateMemberNickname(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "회원 정보 조회", description = "로그인한 회원 정보를 조회합니다.") + @GetMapping("/me") + public MemberInfoResponse memberInfo() { + return memberService.getMemberInfo(); + } + + @Operation(summary = "팀에 속한 회원 목록 조회", description = "멤버 페이지에서 팀장을 제외한 회원을 모두 조회합니다.") + @GetMapping("/{teamId}/list") + public Slice memberFindAll( + @PathVariable Long teamId, + @Parameter(description = "이전 페이지의 마지막 멤버 ID (첫 페이지는 비워두세요)") + @RequestParam(required = false) + Long lastMemberId, + @RequestParam(value = "size", defaultValue = "3") int pageSize) { + return memberService.findAllMembers(teamId, lastMemberId, pageSize); + } +} diff --git a/src/main/java/com/amcamp/domain/member/application/MemberService.java b/src/main/java/com/amcamp/domain/member/application/MemberService.java new file mode 100644 index 00000000..dfee700e --- /dev/null +++ b/src/main/java/com/amcamp/domain/member/application/MemberService.java @@ -0,0 +1,59 @@ +package com.amcamp.domain.member.application; + +import com.amcamp.domain.auth.dao.RefreshTokenRepository; +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.dto.request.NicknameUpdateRequest; +import com.amcamp.domain.member.dto.response.BasicMemberResponse; +import com.amcamp.domain.member.dto.response.MemberInfoResponse; +import com.amcamp.domain.team.domain.TeamParticipantRole; +import com.amcamp.global.util.MemberUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberUtil memberUtil; + private final RefreshTokenRepository refreshTokenRepository; + private final MemberRepository memberRepository; + + public void logoutMember() { + Member currentMember = memberUtil.getCurrentMember(); + refreshTokenRepository + .findById(currentMember.getId()) + .ifPresent(refreshTokenRepository::delete); + } + + public void withdrawalMember() { + Member currentMember = memberUtil.getCurrentMember(); + + refreshTokenRepository + .findById(currentMember.getId()) + .ifPresent(refreshTokenRepository::delete); + + currentMember.withdrawal(); + } + + public void updateMemberNickname(NicknameUpdateRequest request) { + Member currentMember = memberUtil.getCurrentMember(); + + currentMember.updateNickname(request.nickname()); + } + + @Transactional(readOnly = true) + public MemberInfoResponse getMemberInfo() { + Member currentMember = memberUtil.getCurrentMember(); + return MemberInfoResponse.from(currentMember); + } + + @Transactional(readOnly = true) + public Slice findAllMembers(Long teamId, Long lastMemberId, int pageSize) { + return memberRepository.findMemberByTeamExceptAdmin( + teamId, lastMemberId, pageSize, TeamParticipantRole.ADMIN); + } +} diff --git a/src/main/java/com/amcamp/domain/member/dao/MemberRepository.java b/src/main/java/com/amcamp/domain/member/dao/MemberRepository.java new file mode 100644 index 00000000..ff72929c --- /dev/null +++ b/src/main/java/com/amcamp/domain/member/dao/MemberRepository.java @@ -0,0 +1,10 @@ +package com.amcamp.domain.member.dao; + +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.OauthInfo; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository, MemberRepositoryCustom { + Optional findByOauthInfo(OauthInfo oauthInfo); +} diff --git a/src/main/java/com/amcamp/domain/member/dao/MemberRepositoryCustom.java b/src/main/java/com/amcamp/domain/member/dao/MemberRepositoryCustom.java new file mode 100644 index 00000000..661a016c --- /dev/null +++ b/src/main/java/com/amcamp/domain/member/dao/MemberRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.amcamp.domain.member.dao; + +import com.amcamp.domain.member.dto.response.BasicMemberResponse; +import com.amcamp.domain.team.domain.TeamParticipantRole; +import org.springframework.data.domain.Slice; + +public interface MemberRepositoryCustom { + Slice findMemberByTeamExceptAdmin( + Long teamId, Long lastMemberId, int pageSize, TeamParticipantRole role); +} diff --git a/src/main/java/com/amcamp/domain/member/dao/MemberRepositoryImpl.java b/src/main/java/com/amcamp/domain/member/dao/MemberRepositoryImpl.java new file mode 100644 index 00000000..fc8167b0 --- /dev/null +++ b/src/main/java/com/amcamp/domain/member/dao/MemberRepositoryImpl.java @@ -0,0 +1,69 @@ +package com.amcamp.domain.member.dao; + +import static com.amcamp.domain.member.domain.QMember.member; +import static com.amcamp.domain.team.domain.QTeam.team; +import static com.amcamp.domain.team.domain.QTeamParticipant.teamParticipant; + +import com.amcamp.domain.member.dto.response.BasicMemberResponse; +import com.amcamp.domain.team.domain.TeamParticipantRole; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MemberRepositoryImpl implements MemberRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Slice findMemberByTeamExceptAdmin( + Long teamId, Long lastMemberId, int pageSize, TeamParticipantRole role) { + List results = + jpaQueryFactory + .select( + Projections.constructor( + BasicMemberResponse.class, + member.id, + member.nickname, + member.profileImageUrl)) + .from(teamParticipant) + .leftJoin(teamParticipant.member, member) + .on(member.id.eq(teamParticipant.member.id)) + .where( + team.id.eq(teamId), + teamParticipant.role.ne(role), + lastMemberId(lastMemberId)) + .orderBy(teamParticipant.createdDt.desc()) + .limit(pageSize + 1) + .fetch(); + + return checkLastPage(pageSize, results); + } + + private BooleanExpression lastMemberId(Long memberId) { + if (memberId == null) { + return null; + } + + return member.id.lt(memberId); + } + + private Slice checkLastPage( + int pageSize, List results) { + boolean hasNext = false; + + if (results.size() > pageSize) { + hasNext = true; + results.remove(pageSize); + } + + return new SliceImpl<>(results, PageRequest.of(0, pageSize), hasNext); + } +} diff --git a/src/main/java/com/amcamp/domain/member/domain/Member.java b/src/main/java/com/amcamp/domain/member/domain/Member.java new file mode 100644 index 00000000..3b4dc14a --- /dev/null +++ b/src/main/java/com/amcamp/domain/member/domain/Member.java @@ -0,0 +1,78 @@ +package com.amcamp.domain.member.domain; + +import com.amcamp.domain.common.model.BaseTimeEntity; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.MemberErrorCode; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + private String nickname; + + private String profileImageUrl; + + @Embedded private OauthInfo oauthInfo; + + @Enumerated(EnumType.STRING) + private MemberStatus status; + + @Enumerated(EnumType.STRING) + private MemberRole role; + + @Builder(access = AccessLevel.PRIVATE) + private Member( + String nickname, + String profileImageUrl, + OauthInfo oauthInfo, + MemberStatus status, + MemberRole role) { + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.oauthInfo = oauthInfo; + this.status = status; + this.role = role; + } + + public static Member createMember( + String nickname, String profileImageUrl, OauthInfo oauthInfo) { + return Member.builder() + .nickname(nickname) + .profileImageUrl(profileImageUrl) + .oauthInfo(oauthInfo) + .status(MemberStatus.NORMAL) + .role(MemberRole.USER) + .build(); + } + + public void withdrawal() { + if (this.status == MemberStatus.DELETED) { + throw new CommonException(MemberErrorCode.MEMBER_ALREADY_DELETED); + } + + this.status = MemberStatus.DELETED; + } + + public void reEnroll() { + this.status = MemberStatus.NORMAL; + } + + public void updateProfileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + } + + public void updateNickname(String nickname) { + this.nickname = nickname; + } +} diff --git a/src/main/java/com/amcamp/domain/member/domain/MemberRole.java b/src/main/java/com/amcamp/domain/member/domain/MemberRole.java new file mode 100644 index 00000000..a079ca71 --- /dev/null +++ b/src/main/java/com/amcamp/domain/member/domain/MemberRole.java @@ -0,0 +1,14 @@ +package com.amcamp.domain.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberRole { + ADMIN("ROLE_ADMIN"), + USER("ROLE_USER"), + ; + + private final String role; +} diff --git a/src/main/java/com/amcamp/domain/member/domain/MemberStatus.java b/src/main/java/com/amcamp/domain/member/domain/MemberStatus.java new file mode 100644 index 00000000..d5cdf124 --- /dev/null +++ b/src/main/java/com/amcamp/domain/member/domain/MemberStatus.java @@ -0,0 +1,15 @@ +package com.amcamp.domain.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberStatus { + NORMAL("NORMAL"), + DELETED("DELETED"), + FORBIDDEN("FORBIDDEN"), + ; + + private final String status; +} diff --git a/src/main/java/com/amcamp/domain/member/domain/OauthInfo.java b/src/main/java/com/amcamp/domain/member/domain/OauthInfo.java new file mode 100644 index 00000000..cad3e5ed --- /dev/null +++ b/src/main/java/com/amcamp/domain/member/domain/OauthInfo.java @@ -0,0 +1,26 @@ +package com.amcamp.domain.member.domain; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OauthInfo { + + private String oauthId; + private String oauthProvider; + + @Builder(access = AccessLevel.PRIVATE) + private OauthInfo(String oauthId, String oauthProvider) { + this.oauthId = oauthId; + this.oauthProvider = oauthProvider; + } + + public static OauthInfo createOauthInfo(String oauthId, String oauthProvider) { + return OauthInfo.builder().oauthId(oauthId).oauthProvider(oauthProvider).build(); + } +} diff --git a/src/main/java/com/amcamp/domain/member/dto/request/NicknameUpdateRequest.java b/src/main/java/com/amcamp/domain/member/dto/request/NicknameUpdateRequest.java new file mode 100644 index 00000000..e84ec142 --- /dev/null +++ b/src/main/java/com/amcamp/domain/member/dto/request/NicknameUpdateRequest.java @@ -0,0 +1,11 @@ +package com.amcamp.domain.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record NicknameUpdateRequest( + @NotBlank(message = "닉네임은 비워둘 수 없습니다.") + @Size(max = 10, message = "닉네임은 최대 10자까지 입력 가능합니다.") + @Schema(description = "닉네임", example = "최현태") + String nickname) {} diff --git a/src/main/java/com/amcamp/domain/member/dto/response/BasicMemberResponse.java b/src/main/java/com/amcamp/domain/member/dto/response/BasicMemberResponse.java new file mode 100644 index 00000000..bfbafe6a --- /dev/null +++ b/src/main/java/com/amcamp/domain/member/dto/response/BasicMemberResponse.java @@ -0,0 +1,15 @@ +package com.amcamp.domain.member.dto.response; + +import com.amcamp.domain.member.domain.Member; +import io.swagger.v3.oas.annotations.media.Schema; + +public record BasicMemberResponse( + @Schema(description = "멤버 아이디", example = "1") Long memberId, + @Schema(description = "멤버 닉네임", example = "최현태") String nickname, + @Schema(description = "멤버 프로필 url", example = "Presigned URL") String profileImageUrl) { + + public static BasicMemberResponse from(Member member) { + return new BasicMemberResponse( + member.getId(), member.getNickname(), member.getProfileImageUrl()); + } +} diff --git a/src/main/java/com/amcamp/domain/member/dto/response/MemberInfoResponse.java b/src/main/java/com/amcamp/domain/member/dto/response/MemberInfoResponse.java new file mode 100644 index 00000000..111e4469 --- /dev/null +++ b/src/main/java/com/amcamp/domain/member/dto/response/MemberInfoResponse.java @@ -0,0 +1,22 @@ +package com.amcamp.domain.member.dto.response; + +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.MemberRole; +import com.amcamp.domain.member.domain.MemberStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +public record MemberInfoResponse( + @Schema(description = "멤버 아이디", example = "1") Long memberId, + @Schema(description = "멤버 닉네임", example = "최현태") String nickname, + @Schema(description = "멤버 프로필 url", example = "Presigned URL") String profileImageUrl, + @Schema(description = "멤버 상태", example = "NORMAL") MemberStatus status, + @Schema(description = "멤버 역할", example = "ROLE_USER") MemberRole role) { + public static MemberInfoResponse from(Member member) { + return new MemberInfoResponse( + member.getId(), + member.getNickname(), + member.getProfileImageUrl(), + member.getStatus(), + member.getRole()); + } +} diff --git a/src/main/java/com/amcamp/domain/project/api/ProjectController.java b/src/main/java/com/amcamp/domain/project/api/ProjectController.java new file mode 100644 index 00000000..b39979ee --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/api/ProjectController.java @@ -0,0 +1,138 @@ +package com.amcamp.domain.project.api; + +import com.amcamp.domain.project.application.ProjectService; +import com.amcamp.domain.project.dto.request.*; +import com.amcamp.domain.project.dto.response.ProjectInfoResponse; +import com.amcamp.domain.project.dto.response.ProjectListInfoResponse; +import com.amcamp.domain.project.dto.response.ProjectParticipantInfoResponse; +import com.amcamp.domain.project.dto.response.ProjectRegisterDetailResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "4. 프로젝트 API", description = "프로젝트 관련 API 입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/projects") +public class ProjectController { + + private final ProjectService projectService; + + @Operation(summary = "프로젝트 생성", description = "새로운 프로젝트를 생성합니다.") + @PostMapping("/create") + public ResponseEntity projectCreate( + @Valid @RequestBody ProjectCreateRequest projectCreateRequest) { + projectService.createProject(projectCreateRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @Operation( + summary = "전체 프로젝트 목록 조회", + description = "팀 ID를 통해 사용자가 참여 중인 프로젝트와 참여 중이지 않은 프로젝트를 나누어 조회합니다.") + @GetMapping("/{teamId}/list") + public Slice projectListInfo( + @PathVariable Long teamId, + @RequestParam(required = false) Long lastProjectId, + @RequestParam(defaultValue = "10") int pageSize) { + return projectService.getProjectListInfo(teamId, lastProjectId, pageSize); + } + + @Operation(summary = "프로젝트 조회", description = "프로젝트 ID를 통해 프로젝트 정보를 조회합니다.") + @GetMapping("/{projectId}") + public ProjectInfoResponse projectInfo(@PathVariable Long projectId) { + return projectService.getProjectInfo(projectId); + } + + @Operation(summary = "프로젝트 정보 수정", description = "프로젝트 제목, 설명, 마감일자를 수정합니다.") + @PatchMapping("/{projectId}") + public ProjectInfoResponse projectUpdate( + @PathVariable Long projectId, @Valid @RequestBody ProjectUpdateRequest request) { + return projectService.updateProject(projectId, request); + } + + @Operation(summary = "프로젝트 삭제", description = "프로젝트를 삭제합니다.") + @DeleteMapping("/{projectId}") + public ResponseEntity projectDelete(@PathVariable Long projectId) { + projectService.deleteProject(projectId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "프로젝트 가입 신청", description = "프로젝트 가입 신청 요청을 보냅니다.") + @PostMapping("/{projectId}/registration") + public ResponseEntity projectRegister(@PathVariable Long projectId) { + projectService.requestToProjectRegistration(projectId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "프로젝트 가입 신청 목록 조회", description = "현재 프로젝트에 신청된 가입 요청 목록을 조회합니다.") + @GetMapping("/{projectId}/registration/list") + public Slice projectRegistrationListGet( + @PathVariable Long projectId, + @RequestParam(required = false) Long lastRegistrationId, + @RequestParam(defaultValue = "10") int pageSize) { + return projectService.getProjectRegistrationList(projectId, lastRegistrationId, pageSize); + } + + @Operation(summary = "프로젝트 가입 신청 승인", description = "프로젝트 가입 신청을 승인합니다.") + @PutMapping("/{projectId}/registration/approve") + public ResponseEntity projectRegistrationApprove( + @PathVariable Long projectId, @RequestParam Long projectRegisterId) { + projectService.approveProjectRegistration(projectId, projectRegisterId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "프로젝트 가입 신청 거부", description = "프로젝트 가입 신청을 거부합니다.") + @PutMapping("/{projectId}/registration/reject") + public ResponseEntity projectRegistrationReject( + @PathVariable Long projectId, @RequestParam Long projectRegisterId) { + projectService.rejectProjectRegistration(projectId, projectRegisterId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "프로젝트 가입 신청 취소", description = "프로젝트 가입 신청을 취소합니다.") + @DeleteMapping("/{projectId}/registration/cancel") + public ResponseEntity projectRegistrationDelete(@PathVariable Long projectId) { + projectService.deleteProjectRegistration(projectId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "프로젝트 내 개인 참가자 정보 조회", description = "로그인한 사용자의 해당 프로젝트 참가자 정보를 조회합니다.") + @GetMapping("/{projectId}/me") + public ProjectParticipantInfoResponse projectParticipantGet(@PathVariable Long projectId) { + return projectService.getProjectParticipant(projectId); + } + + @Operation(summary = "프로젝트 참가자 목록 조회", description = "현재 프로젝트에 참여하고 있는 참가자 전체 목록을 조회합니다. ") + @GetMapping("/{projectId}/participants") + public Slice projectParticipantListGet( + @PathVariable Long projectId, + @Parameter(description = "이전 페이지의 마지막 프로젝트 참가자 ID (첫 페이지는 비워두세요)") + @RequestParam(required = false) + Long lastProjectParticipantId, + @Parameter(description = "페이지당 프로젝트 참여자 수", example = "1") @RequestParam(value = "size") + int pageSize) { + return projectService.getProjectParticipantList( + projectId, lastProjectParticipantId, pageSize); + } + + @Operation(summary = "프로젝트 나가기", description = "프로젝트에 참여중인 프로젝트 참여자 정보를 삭제합니다.") + @DeleteMapping("/{projectId}/leave") + public ResponseEntity projectParticipantDelete(@PathVariable Long projectId) { + projectService.deleteProjectParticipant(projectId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "admin 권한 양도", description = "프로젝트 Admin 권한을 양도합니다.") + @PostMapping("/{projectId}/admin/change") + public ResponseEntity projectAdminChange( + @PathVariable Long projectId, @Valid @RequestBody ProjectAdminChangeRequest request) { + projectService.changeProjectAdmin(projectId, request.newAdminId()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/amcamp/domain/project/application/ProjectService.java b/src/main/java/com/amcamp/domain/project/application/ProjectService.java new file mode 100644 index 00000000..60d93a39 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/application/ProjectService.java @@ -0,0 +1,299 @@ +package com.amcamp.domain.project.application; + +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.project.dao.*; +import com.amcamp.domain.project.domain.*; +import com.amcamp.domain.project.domain.ProjectRegistration; +import com.amcamp.domain.project.dto.request.*; +import com.amcamp.domain.project.dto.response.ProjectInfoResponse; +import com.amcamp.domain.project.dto.response.ProjectListInfoResponse; +import com.amcamp.domain.project.dto.response.ProjectParticipantInfoResponse; +import com.amcamp.domain.project.dto.response.ProjectRegisterDetailResponse; +import com.amcamp.domain.project.dto.response.ProjectRegistrationInfoResponse; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.dao.TeamRepository; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import com.amcamp.global.exception.errorcode.TeamErrorCode; +import com.amcamp.global.util.MemberUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ProjectService { + + private final ProjectRepository projectRepository; + private final TeamRepository teamRepository; + private final MemberUtil memberUtil; + private final TeamParticipantRepository teamParticipantRepository; + private final ProjectParticipantRepository projectParticipantRepository; + private final MemberRepository memberRepository; + private final ProjectRegistrationRepository projectRegistrationRepository; + + public void createProject(ProjectCreateRequest request) { + Member member = memberUtil.getCurrentMember(); + Team team = getTeam(request.teamId()); + TeamParticipant teamParticipant = getValidTeamParticipant(member, team); + Project project = + projectRepository.save( + Project.createProject( + team, + normalizeProjectTitle(request.projectTitle()), + request.projectDescription(), + request.dueDt())); + + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipant, project, ProjectParticipantRole.ADMIN)); + } + + @Transactional(readOnly = true) + public ProjectInfoResponse getProjectInfo(Long projectId) { + Member member = memberUtil.getCurrentMember(); + Project project = getProjectById(projectId); + getValidTeamParticipant(member, project.getTeam()); + return ProjectInfoResponse.from(project); + } + + @Transactional(readOnly = true) + public Slice getProjectListInfo( + Long teamId, Long lastProjectId, int pageSize) { + + Member member = memberUtil.getCurrentMember(); + Team team = getTeam(teamId); + TeamParticipant teamParticipant = getValidTeamParticipant(member, team); + return projectRepository.findAllByTeamIdWithPagination( + teamId, lastProjectId, pageSize, teamParticipant); + } + + public ProjectInfoResponse updateProject(Long projectId, ProjectUpdateRequest request) { + final Member member = memberUtil.getCurrentMember(); + final Project project = getProjectById(projectId); + + getValidProjectParticipant(member, project); + project.updateProject(request.title(), request.description(), request.dueDt()); + + return ProjectInfoResponse.from(project); + } + + public void deleteProject(Long projectId) { + Member member = memberUtil.getCurrentMember(); + Project project = getProjectById(projectId); + validateProjectAdmin(member, project); + projectParticipantRepository.deleteAllByProject(project); + projectRepository.delete(project); + } + + public void deleteProjectParticipant(Long projectId) { + Member member = memberUtil.getCurrentMember(); + Project project = getProjectById(projectId); + ProjectParticipant participant = getValidProjectParticipant(member, project); + if (isProjectAdmin(member, project)) { + boolean hasOtherParticipants = + projectParticipantRepository.existsByProjectAndProjectRoleNot( + project, ProjectParticipantRole.ADMIN); + // 다른 팀원 있을 떄 -> 예외 발생 + if (hasOtherParticipants) { + throw new CommonException(ProjectErrorCode.PROJECT_ADMIN_CANNOT_LEAVE); + } + projectParticipantRepository.deleteAllByProject(project); + projectRepository.delete(project); + } else { + participant.changeStatus(ProjectParticipantStatus.INACTIVE); + } + } + + public void changeProjectAdmin(Long projectId, Long newAdminId) { + Member currentMember = memberUtil.getCurrentMember(); // 현재 사용자 (기존 Admin) + Project project = getProjectById(projectId); + ProjectParticipant currentAdmin = validateProjectAdmin(currentMember, project); + ProjectParticipant newAdmin = getProjectParticipantById(newAdminId); // 양도할 프로젝트멤버 + + currentAdmin.changeRole(ProjectParticipantRole.MEMBER); + newAdmin.changeRole(ProjectParticipantRole.ADMIN); + } + + // project Registration + public void requestToProjectRegistration(Long projectId) { + Member member = memberUtil.getCurrentMember(); + Project project = getProjectById(projectId); + if (projectParticipantRepository.findAllByProject(project).size() >= 15) { + throw new CommonException(ProjectErrorCode.PROJECT_PARTICIPANT_LIMIT_EXCEED); + } + + TeamParticipant requester = validateProjectRegistrationAlreadyExists(member, project); + + projectRegistrationRepository.save(ProjectRegistration.createRequest(project, requester)); + } + + @Transactional(readOnly = true) + public ProjectRegistrationInfoResponse getProjectRegistration( + Long projectId, Long registrationId) { + Member member = memberUtil.getCurrentMember(); + Project project = getProjectById(projectId); + validateProjectAdmin(member, project); + return ProjectRegistrationInfoResponse.from(getProjectRegistrationById(registrationId)); + } + + @Transactional(readOnly = true) + public Slice getProjectRegistrationList( + Long projectId, Long lastRegistrationId, int pageSize) { + Member member = memberUtil.getCurrentMember(); + Project project = getProjectById(projectId); + validateProjectAdmin(member, project); + return projectRegistrationRepository.findAllByProjectIdWithPagination( + projectId, lastRegistrationId, pageSize); + } + + public void approveProjectRegistration(Long projectId, Long registrationId) { + Member member = memberUtil.getCurrentMember(); + Project project = getProjectById(projectId); + validateProjectAdmin(member, project); + ProjectRegistration registration = getProjectRegistrationById(registrationId); + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + registration.getRequester(), project, ProjectParticipantRole.MEMBER)); + registration.updateStatus(ProjectRegistrationStatus.APPROVED); + } + + public void rejectProjectRegistration(Long projectId, Long registrationId) { + Member member = memberUtil.getCurrentMember(); + Project project = getProjectById(projectId); + validateProjectAdmin(member, project); + getProjectRegistrationById(registrationId).updateStatus(ProjectRegistrationStatus.REJECTED); + } + + public void deleteProjectRegistration(Long projectId) { + Member member = memberUtil.getCurrentMember(); + Project project = getProjectById(projectId); + TeamParticipant teamParticipant = getValidTeamParticipant(member, project.getTeam()); + ProjectRegistration registration = + getProjectRegistrationByProjectAndRequester(project, teamParticipant); + + if (teamParticipant.equals(registration.getRequester())) { + projectRegistrationRepository.delete(registration); + } else { + throw new CommonException(ProjectErrorCode.UNAUTHORIZED_ACCESS); + } + } + + // project participant info + + @Transactional(readOnly = true) + public ProjectParticipantInfoResponse getProjectParticipant(Long projectId) { + final Member member = memberUtil.getCurrentMember(); + final Project project = getProjectById(projectId); + + ProjectParticipant participant = getValidProjectParticipant(member, project); + + return ProjectParticipantInfoResponse.from(participant); + } + + @Transactional(readOnly = true) + public Slice getProjectParticipantList( + Long projectId, Long lastProjectParticipantId, int pageSize) { + final Member member = memberUtil.getCurrentMember(); + final Project project = getProjectById(projectId); + + getValidTeamParticipant(member, project.getTeam()); + + return projectRepository.findAllProjectParticipantByProject( + projectId, lastProjectParticipantId, pageSize); + } + + // project utils + + private ProjectParticipant getProjectParticipantById(Long projectMemberId) { + return projectParticipantRepository + .findById(projectMemberId) + .orElseThrow( + () -> new CommonException(ProjectErrorCode.PROJECT_PARTICIPANT_NOT_EXISTS)); + } + + private String normalizeProjectTitle(String name) { + return name.trim().replaceAll("[^0-9a-zA-Z가-힣 ]", "_"); + } + + private Project getProjectById(Long projectId) { + return projectRepository + .findById(projectId) + .orElseThrow(() -> new CommonException(ProjectErrorCode.PROJECT_NOT_FOUND)); + } + + private TeamParticipant getValidTeamParticipant(Member member, Team team) { + return teamParticipantRepository + .findByMemberAndTeam(member, team) + .orElseThrow(() -> new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + } + + private ProjectParticipant getValidProjectParticipant(Member member, Project project) { + TeamParticipant teamParticipant = getValidTeamParticipant(member, project.getTeam()); + return projectParticipantRepository + .findByProjectAndTeamParticipant(project, teamParticipant) + .orElseThrow( + () -> new CommonException(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED)); + } + + private Team getTeam(Long teamId) { + return teamRepository + .findById(teamId) + .orElseThrow(() -> new CommonException(TeamErrorCode.TEAM_NOT_FOUND)); + } + + private TeamParticipant validateProjectRegistrationAlreadyExists( + Member member, Project project) { + TeamParticipant participant = getValidTeamParticipant(member, project.getTeam()); + if (projectParticipantRepository + .findByProjectAndTeamParticipant(project, participant) + .filter( + r -> + r.getStatus() + .equals( + ProjectParticipantStatus + .ACTIVE)) // inactive 인 경우 신청 가능하도록 + .isPresent()) { + throw new CommonException(ProjectErrorCode.PROJECT_PARTICIPANT_ALREADY_EXISTS); + } + if (projectRegistrationRepository + .findByProjectAndRequester(project, participant) + .isPresent()) { + throw new CommonException(ProjectErrorCode.PROJECT_REGISTRATION_ALREADY_EXISTS); + } + return participant; + } + + private ProjectRegistration getProjectRegistrationById(Long registrationId) { + return projectRegistrationRepository + .findById(registrationId) + .orElseThrow( + () -> new CommonException(ProjectErrorCode.PROJECT_REGISTRATION_NOT_FOUND)); + } + + private ProjectRegistration getProjectRegistrationByProjectAndRequester( + Project project, TeamParticipant requester) { + return projectRegistrationRepository + .findByProjectAndRequester(project, requester) + .orElseThrow( + () -> new CommonException(ProjectErrorCode.PROJECT_REGISTRATION_NOT_FOUND)); + } + + private ProjectParticipant validateProjectAdmin(Member member, Project project) { + ProjectParticipant participant = getValidProjectParticipant(member, project); + if (!participant.getProjectRole().equals(ProjectParticipantRole.ADMIN)) { + throw new CommonException(ProjectErrorCode.UNAUTHORIZED_ACCESS); + } + return participant; + } + + private boolean isProjectAdmin(Member member, Project project) { + ProjectParticipant participant = getValidProjectParticipant(member, project); + return participant.getProjectRole().equals(ProjectParticipantRole.ADMIN); + } +} diff --git a/src/main/java/com/amcamp/domain/project/dao/ProjectParticipantRepository.java b/src/main/java/com/amcamp/domain/project/dao/ProjectParticipantRepository.java new file mode 100644 index 00000000..5cc158cf --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dao/ProjectParticipantRepository.java @@ -0,0 +1,20 @@ +package com.amcamp.domain.project.dao; + +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.domain.ProjectParticipantRole; +import com.amcamp.domain.team.domain.TeamParticipant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProjectParticipantRepository extends JpaRepository { + Optional findByProjectAndTeamParticipant( + Project project, TeamParticipant teamParticipant); + + List findAllByProject(Project project); + + boolean existsByProjectAndProjectRoleNot(Project project, ProjectParticipantRole role); + + void deleteAllByProject(Project project); +} diff --git a/src/main/java/com/amcamp/domain/project/dao/ProjectRegistrationRepository.java b/src/main/java/com/amcamp/domain/project/dao/ProjectRegistrationRepository.java new file mode 100644 index 00000000..1fe6a7ea --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dao/ProjectRegistrationRepository.java @@ -0,0 +1,18 @@ +package com.amcamp.domain.project.dao; + +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.project.domain.ProjectRegistration; +import com.amcamp.domain.team.domain.TeamParticipant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProjectRegistrationRepository + extends JpaRepository, ProjectRegistrationRepositoryCustom { + List findAllByProject(Project project); + + Optional findByRequester(TeamParticipant teamParticipant); + + Optional findByProjectAndRequester( + Project project, TeamParticipant participant); +} diff --git a/src/main/java/com/amcamp/domain/project/dao/ProjectRegistrationRepositoryCustom.java b/src/main/java/com/amcamp/domain/project/dao/ProjectRegistrationRepositoryCustom.java new file mode 100644 index 00000000..c44cec00 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dao/ProjectRegistrationRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.amcamp.domain.project.dao; + +import com.amcamp.domain.project.dto.response.ProjectRegisterDetailResponse; +import org.springframework.data.domain.Slice; + +public interface ProjectRegistrationRepositoryCustom { + Slice findAllByProjectIdWithPagination( + Long projectId, Long lastRegistrationId, int pageSize); +} diff --git a/src/main/java/com/amcamp/domain/project/dao/ProjectRegistrationRepositoryImpl.java b/src/main/java/com/amcamp/domain/project/dao/ProjectRegistrationRepositoryImpl.java new file mode 100644 index 00000000..86cce572 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dao/ProjectRegistrationRepositoryImpl.java @@ -0,0 +1,66 @@ +package com.amcamp.domain.project.dao; + +import static com.amcamp.domain.member.domain.QMember.member; +import static com.amcamp.domain.project.domain.QProjectRegistration.projectRegistration; + +import com.amcamp.domain.project.dto.response.ProjectRegisterDetailResponse; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +@RequiredArgsConstructor +public class ProjectRegistrationRepositoryImpl implements ProjectRegistrationRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Slice findAllByProjectIdWithPagination( + Long projectId, Long lastRegistrationId, int pageSize) { + + List responses = + jpaQueryFactory + .select( + Projections.constructor( + ProjectRegisterDetailResponse.class, + projectRegistration.project.id, + projectRegistration.id, + projectRegistration.requester.id, + member.nickname, + member.profileImageUrl, + projectRegistration.requestStatus)) + .from(projectRegistration) + .leftJoin(member) + .on(projectRegistration.requester.member.eq(member)) + .where( + projectRegistration.project.id.eq(projectId), + lastProjectRegistrationCondition(lastRegistrationId)) + .orderBy(projectRegistration.id.desc()) + .limit(pageSize + 1) + .fetch(); + + return checkLastPage(pageSize, responses); + } + + private BooleanExpression lastProjectRegistrationCondition(Long projectRegistrationId) { + return (projectRegistrationId == null) + ? null + : projectRegistration.id.lt(projectRegistrationId); + } + + private Slice checkLastPage( + int pageSize, List results) { + boolean hasNext = false; + + if (results.size() > pageSize) { + hasNext = true; + results.remove(pageSize); + } + + return new SliceImpl<>(results, PageRequest.of(0, pageSize), hasNext); + } +} diff --git a/src/main/java/com/amcamp/domain/project/dao/ProjectRepository.java b/src/main/java/com/amcamp/domain/project/dao/ProjectRepository.java new file mode 100644 index 00000000..f343f28e --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dao/ProjectRepository.java @@ -0,0 +1,6 @@ +package com.amcamp.domain.project.dao; + +import com.amcamp.domain.project.domain.Project; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProjectRepository extends JpaRepository, ProjectRepositoryCustom {} diff --git a/src/main/java/com/amcamp/domain/project/dao/ProjectRepositoryCustom.java b/src/main/java/com/amcamp/domain/project/dao/ProjectRepositoryCustom.java new file mode 100644 index 00000000..07c22775 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dao/ProjectRepositoryCustom.java @@ -0,0 +1,15 @@ +package com.amcamp.domain.project.dao; + +import com.amcamp.domain.project.dto.response.ProjectListInfoResponse; +import com.amcamp.domain.project.dto.response.ProjectParticipantInfoResponse; +import com.amcamp.domain.team.domain.TeamParticipant; +import org.springframework.data.domain.Slice; + +public interface ProjectRepositoryCustom { + + Slice findAllByTeamIdWithPagination( + Long teamId, Long lastProjectId, int pageSize, TeamParticipant teamParticipant); + + Slice findAllProjectParticipantByProject( + Long projectId, Long lastProjectParticipantId, int pageSize); +} diff --git a/src/main/java/com/amcamp/domain/project/dao/ProjectRepositoryImpl.java b/src/main/java/com/amcamp/domain/project/dao/ProjectRepositoryImpl.java new file mode 100644 index 00000000..3a6c8ae3 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dao/ProjectRepositoryImpl.java @@ -0,0 +1,139 @@ +package com.amcamp.domain.project.dao; + +import static com.amcamp.domain.project.domain.QProject.project; +import static com.amcamp.domain.project.domain.QProjectParticipant.projectParticipant; +import static com.amcamp.domain.project.domain.QProjectRegistration.projectRegistration; + +import com.amcamp.domain.project.domain.ProjectParticipantRole; +import com.amcamp.domain.project.domain.ProjectParticipantStatus; +import com.amcamp.domain.project.dto.response.ProjectInfoResponse; +import com.amcamp.domain.project.dto.response.ProjectListInfoResponse; +import com.amcamp.domain.project.dto.response.ProjectParticipantInfoResponse; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.core.types.dsl.StringExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +@RequiredArgsConstructor +public class ProjectRepositoryImpl implements ProjectRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Slice findAllByTeamIdWithPagination( + Long teamId, Long lastProjectId, int pageSize, TeamParticipant teamParticipant) { + + NumberExpression activeParticipation = + new CaseBuilder() + .when(projectParticipant.status.eq(ProjectParticipantStatus.ACTIVE)) + .then(1) + .otherwise(0) + .coalesce(0); + + NumberExpression adminCase = + new CaseBuilder() + .when(projectParticipant.projectRole.eq(ProjectParticipantRole.ADMIN)) + .then(1) + .otherwise(0); + + StringExpression joinStatusExpr = + projectRegistration.requestStatus.stringValue().min().coalesce("NONE"); + + List responses = + jpaQueryFactory + .select( + Projections.constructor( + ProjectListInfoResponse.class, + Projections.constructor( + ProjectInfoResponse.class, + project.id, + project.title, + project.team.name, + project.description, + project.startDt, + project.dueDt), + activeParticipation.sum().gt(0), + adminCase.sum().gt(0), + joinStatusExpr)) + .from(project) + .leftJoin(projectParticipant) + .on( + projectParticipant + .project + .eq(project) + .and( + projectParticipant.teamParticipant.eq( + teamParticipant))) + .leftJoin(projectRegistration) + .on( + projectRegistration + .project + .eq(project) + .and(projectRegistration.requester.eq(teamParticipant))) + .where(project.team.id.eq(teamId), lastProjectCondition(lastProjectId)) + .groupBy(project.id) + .orderBy(project.id.desc()) + .limit(pageSize + 1) + .fetch(); + + return checkLastPage(pageSize, responses); + } + + @Override + public Slice findAllProjectParticipantByProject( + Long projectId, Long lastProjectParticipantId, int pageSize) { + List results = + jpaQueryFactory + .select( + Projections.constructor( + ProjectParticipantInfoResponse.class, + projectParticipant.id, + projectParticipant.teamParticipant.member.nickname, + projectParticipant.teamParticipant.member.profileImageUrl, + projectParticipant.projectRole, + projectParticipant.status)) + .from(projectParticipant) + .where( + projectParticipant.project.id.eq(projectId), + lastProjectParticipantId(lastProjectParticipantId)) + .limit(pageSize + 1) + .fetch(); + + if (results.isEmpty()) { + throw new CommonException(ProjectErrorCode.PROJECT_PARTICIPANT_NOT_EXISTS); + } + + return checkLastPage(pageSize, results); + } + + private BooleanExpression lastProjectCondition(Long projectId) { + return (projectId == null) ? null : project.id.lt(projectId); + } + + private BooleanExpression lastProjectParticipantId(Long projectParticipantId) { + return (projectParticipantId == null) + ? null + : projectParticipant.id.gt(projectParticipantId); + } + + private Slice checkLastPage(int pageSize, List results) { + boolean hasNext = false; + + if (results.size() > pageSize) { + hasNext = true; + results.remove(pageSize); + } + + return new SliceImpl<>(results, PageRequest.of(0, pageSize), hasNext); + } +} diff --git a/src/main/java/com/amcamp/domain/project/domain/Project.java b/src/main/java/com/amcamp/domain/project/domain/Project.java new file mode 100644 index 00000000..71f5bc3d --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/domain/Project.java @@ -0,0 +1,82 @@ +package com.amcamp.domain.project.domain; + +import com.amcamp.domain.common.model.BaseTimeEntity; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import jakarta.persistence.*; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Project extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "project_id") + private Long id; + + // 팀 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id") + private Team team; + + // 프로젝트 이름 + @Column(name = "project_title") + private String title; + + // 설명 + @Lob private String description; + + private LocalDate startDt; + + private LocalDate dueDt; + + @OneToMany(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true) + private List sprints = new ArrayList<>(); + + // 캘린더 + @Builder(access = AccessLevel.PRIVATE) + private Project( + Team team, String title, String description, LocalDate startDt, LocalDate dueDt) { + this.team = team; + this.title = title; + this.description = description; + this.startDt = startDt; + this.dueDt = dueDt; + } + + public static Project createProject( + Team team, String title, String description, LocalDate dueDt) { + validateDueDt(LocalDate.now(), dueDt); + return Project.builder() + .team(team) + .title(title) + .description(description) + .startDt(LocalDate.now()) + .dueDt(dueDt) + .build(); + } + + public void updateProject(String title, String description, LocalDate dueDt) { + if (title != null) this.title = title; + if (description != null) this.description = description; + if (dueDt != null) { + validateDueDt(this.startDt, dueDt); + this.dueDt = dueDt; + } + } + + private static void validateDueDt(LocalDate startDt, LocalDate dueDt) { + if (dueDt.isBefore(startDt)) { + throw new CommonException(ProjectErrorCode.PROJECT_DUE_DATE_BEFORE_START); + } + } +} diff --git a/src/main/java/com/amcamp/domain/project/domain/ProjectParticipant.java b/src/main/java/com/amcamp/domain/project/domain/ProjectParticipant.java new file mode 100644 index 00000000..4e140af7 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/domain/ProjectParticipant.java @@ -0,0 +1,64 @@ +package com.amcamp.domain.project.domain; + +import com.amcamp.domain.common.model.BaseTimeEntity; +import com.amcamp.domain.team.domain.TeamParticipant; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class ProjectParticipant extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "project_participant_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_participant_id") + private TeamParticipant teamParticipant; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id") + private Project project; + + // 프로젝트 내 권한 + @Enumerated(EnumType.STRING) + private ProjectParticipantRole projectRole; + + @Enumerated(EnumType.STRING) + private ProjectParticipantStatus status; + + @Builder(access = AccessLevel.PRIVATE) + private ProjectParticipant( + TeamParticipant teamParticipant, + Project project, + ProjectParticipantRole projectRole, + ProjectParticipantStatus status) { + this.teamParticipant = teamParticipant; + this.project = project; + this.projectRole = projectRole; + this.status = status; + } + + public static ProjectParticipant createProjectParticipant( + TeamParticipant teamParticipant, Project project, ProjectParticipantRole projectRole) { + return ProjectParticipant.builder() + .teamParticipant(teamParticipant) + .project(project) + .projectRole(projectRole) + .status(ProjectParticipantStatus.ACTIVE) + .build(); + } + + public void changeRole(ProjectParticipantRole projectRole) { + this.projectRole = projectRole; + } + + public void changeStatus(ProjectParticipantStatus status) { + this.status = status; + } +} diff --git a/src/main/java/com/amcamp/domain/project/domain/ProjectParticipantRole.java b/src/main/java/com/amcamp/domain/project/domain/ProjectParticipantRole.java new file mode 100644 index 00000000..1008a6e3 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/domain/ProjectParticipantRole.java @@ -0,0 +1,13 @@ +package com.amcamp.domain.project.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ProjectParticipantRole { + ADMIN("PROJECT_ADMIN"), + MEMBER("PROJECT_MEMBER"); + + private final String projectRole; +} diff --git a/src/main/java/com/amcamp/domain/project/domain/ProjectParticipantStatus.java b/src/main/java/com/amcamp/domain/project/domain/ProjectParticipantStatus.java new file mode 100644 index 00000000..77b9719c --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/domain/ProjectParticipantStatus.java @@ -0,0 +1,14 @@ +package com.amcamp.domain.project.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ProjectParticipantStatus { + ACTIVE("ACTIVE"), + INACTIVE("INACTIVE"), + ; + + private final String status; +} diff --git a/src/main/java/com/amcamp/domain/project/domain/ProjectRegistration.java b/src/main/java/com/amcamp/domain/project/domain/ProjectRegistration.java new file mode 100644 index 00000000..b5fa0fb1 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/domain/ProjectRegistration.java @@ -0,0 +1,50 @@ +package com.amcamp.domain.project.domain; + +import com.amcamp.domain.common.model.BaseTimeEntity; +import com.amcamp.domain.team.domain.TeamParticipant; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class ProjectRegistration extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "project_registration_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id") + private Project project; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_participant_id", unique = true) + private TeamParticipant requester; + + @Enumerated(EnumType.STRING) + private ProjectRegistrationStatus requestStatus; + + @Builder + public ProjectRegistration( + Project project, TeamParticipant requester, ProjectRegistrationStatus requestStatus) { + this.project = project; + this.requester = requester; + this.requestStatus = requestStatus; + } + + public static ProjectRegistration createRequest(Project project, TeamParticipant requester) { + return ProjectRegistration.builder() + .project(project) + .requester(requester) + .requestStatus(ProjectRegistrationStatus.PENDING) + .build(); + } + + public void updateStatus(ProjectRegistrationStatus requestStatus) { + this.requestStatus = requestStatus; + } +} diff --git a/src/main/java/com/amcamp/domain/project/domain/ProjectRegistrationStatus.java b/src/main/java/com/amcamp/domain/project/domain/ProjectRegistrationStatus.java new file mode 100644 index 00000000..ea7f6582 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/domain/ProjectRegistrationStatus.java @@ -0,0 +1,15 @@ +package com.amcamp.domain.project.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum ProjectRegistrationStatus { + NONE("REQUEST_NONE"), + PENDING("REQUEST_PENDING"), + APPROVED("REQUEST_APPROVED"), + REJECTED("REQUEST_REJECTED"); + + private final String status; +} diff --git a/src/main/java/com/amcamp/domain/project/dto/request/ProjectAdminChangeRequest.java b/src/main/java/com/amcamp/domain/project/dto/request/ProjectAdminChangeRequest.java new file mode 100644 index 00000000..759a4066 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dto/request/ProjectAdminChangeRequest.java @@ -0,0 +1,7 @@ +package com.amcamp.domain.project.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record ProjectAdminChangeRequest( + @Schema(description = "새 Admin ID", example = "1") @NotNull Long newAdminId) {} diff --git a/src/main/java/com/amcamp/domain/project/dto/request/ProjectCreateRequest.java b/src/main/java/com/amcamp/domain/project/dto/request/ProjectCreateRequest.java new file mode 100644 index 00000000..148403f8 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dto/request/ProjectCreateRequest.java @@ -0,0 +1,22 @@ +package com.amcamp.domain.project.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record ProjectCreateRequest( + @Schema(description = "팀 ID", example = "1") @NotNull Long teamId, + @Schema(description = "프로젝트 제목", example = "Devfit") + @Size(max = 15, message = "프로젝트 제목은 최대 15자까지 입력 가능합니다.") + @NotBlank + String projectTitle, + @Schema(description = "프로젝트 마감 날짜", example = "2027-01-01") + @JsonFormat(shape = JsonFormat.Shape.STRING) + @NotNull + LocalDate dueDt, + @Schema(description = "프로젝트 설명", example = "LG CNS AM Inspire Camp 사이드 프로젝트") + @Size(max = 100, message = "프로젝트 설명은 최대 100자까지 입력 가능합니다.") + String projectDescription) {} diff --git a/src/main/java/com/amcamp/domain/project/dto/request/ProjectUpdateRequest.java b/src/main/java/com/amcamp/domain/project/dto/request/ProjectUpdateRequest.java new file mode 100644 index 00000000..f2097b4a --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dto/request/ProjectUpdateRequest.java @@ -0,0 +1,14 @@ +package com.amcamp.domain.project.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record ProjectUpdateRequest( + @Schema(description = "수정된 프로젝트 제목", example = "new project title") + @Size(max = 15, message = "프로젝트 제목은 최대 15자까지 입력 가능합니다.") + String title, + @Schema(description = "수정된 프로젝트 설명", example = "new project description") + @Size(max = 100, message = "프로젝트 설명은 최대 100자까지 입력 가능합니다.") + String description, + @Schema(description = "수정된 프로젝트 마감일자", example = "2026-04-01") LocalDate dueDt) {} diff --git a/src/main/java/com/amcamp/domain/project/dto/response/ProjectInfoResponse.java b/src/main/java/com/amcamp/domain/project/dto/response/ProjectInfoResponse.java new file mode 100644 index 00000000..e3fbb0fd --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dto/response/ProjectInfoResponse.java @@ -0,0 +1,25 @@ +package com.amcamp.domain.project.dto.response; + +import com.amcamp.domain.project.domain.Project; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +public record ProjectInfoResponse( + @Schema(description = "프로젝트 아이디", example = "1") Long projectId, + @Schema(description = "프로젝트 제목", example = "Devfit") String projectTitle, + @Schema(description = "프로젝트가 속한 팀 이름", example = "1") String teamName, + @Schema(description = "프로젝트 설명", example = "LG CNS AM Inspire Camp 사이드 프로젝트") + String projectDescription, + @Schema(description = "프로젝트 시작 날짜", example = "2024-01-01") LocalDate startDt, + @Schema(description = "프로젝트 마감 날짜", example = "2025-01-01") LocalDate dueDt) { + + public static ProjectInfoResponse from(Project project) { + return new ProjectInfoResponse( + project.getId(), + project.getTitle(), + project.getTeam().getName(), + project.getDescription(), + project.getStartDt(), + project.getDueDt()); + } +} diff --git a/src/main/java/com/amcamp/domain/project/dto/response/ProjectListInfoResponse.java b/src/main/java/com/amcamp/domain/project/dto/response/ProjectListInfoResponse.java new file mode 100644 index 00000000..89a39162 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dto/response/ProjectListInfoResponse.java @@ -0,0 +1,26 @@ +package com.amcamp.domain.project.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ProjectListInfoResponse( + @Schema(description = "사용자가 참여중인 프로젝트 목록") ProjectInfoResponse projectInfo, + @Schema(description = "프로젝트 참여 여부", example = "false") boolean isParticipant, + @Schema(description = "프로젝트 팀장 여부", example = "false") boolean isAdmin, + @Schema( + description = "프로젝트 가입신청 상태", + example = "REQUEST_PENDING", + allowableValues = { + "NONE", + "REQUEST_PENDING", + "REQUEST_APPROVED", + "REQUEST_REJECTED" + }) + String joinStatus) { + public static ProjectListInfoResponse from( + ProjectInfoResponse projectInfo, + boolean isParticipant, + boolean isAdmin, + String joinStatus) { + return new ProjectListInfoResponse(projectInfo, isParticipant, isAdmin, joinStatus); + } +} diff --git a/src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantFeedbackInfoResponse.java b/src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantFeedbackInfoResponse.java new file mode 100644 index 00000000..b52b25bf --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantFeedbackInfoResponse.java @@ -0,0 +1,13 @@ +package com.amcamp.domain.project.dto.response; + +import com.amcamp.domain.project.domain.ProjectParticipantRole; +import com.amcamp.domain.project.domain.ProjectParticipantStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ProjectParticipantFeedbackInfoResponse( + @Schema(description = "프로젝트 참여자 아이디", example = "1") Long projectParticipantId, + @Schema(description = "프로젝트 참여자 이름", example = "정선우") String nickname, + @Schema(description = "프로젝트 참여자 이미지 url", example = "PreSigned URL") String profileImageUrl, + @Schema(description = "프로젝트 참여자 권한", example = "PROJECT_ADMIN") ProjectParticipantRole role, + @Schema(description = "프로젝트 참여자 참여 상태", example = "ACTIVE") ProjectParticipantStatus status, + @Schema(description = "동료평가 완료 여부", example = "COMPLETED") String feedbackStatus) {} diff --git a/src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantInfoResponse.java b/src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantInfoResponse.java new file mode 100644 index 00000000..ff7bb113 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantInfoResponse.java @@ -0,0 +1,23 @@ +package com.amcamp.domain.project.dto.response; + +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.domain.ProjectParticipantRole; +import com.amcamp.domain.project.domain.ProjectParticipantStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ProjectParticipantInfoResponse( + @Schema(description = "프로젝트 참여자 아이디", example = "1") Long projectParticipantId, + @Schema(description = "프로젝트 참여자 이름", example = "정선우") String nickname, + @Schema(description = "프로젝트 참여자 이미지 url", example = "PreSigned URL") String profileImageUrl, + @Schema(description = "프로젝트 참여자 권한", example = "PROJECT_ADMIN") ProjectParticipantRole role, + @Schema(description = "프로젝트 참여자 참여 상태", example = "ACTIVE") + ProjectParticipantStatus status) { + public static ProjectParticipantInfoResponse from(ProjectParticipant participant) { + return new ProjectParticipantInfoResponse( + participant.getId(), + participant.getTeamParticipant().getMember().getNickname(), + participant.getTeamParticipant().getMember().getProfileImageUrl(), + participant.getProjectRole(), + participant.getStatus()); + } +} diff --git a/src/main/java/com/amcamp/domain/project/dto/response/ProjectRegisterDetailResponse.java b/src/main/java/com/amcamp/domain/project/dto/response/ProjectRegisterDetailResponse.java new file mode 100644 index 00000000..084be2a2 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dto/response/ProjectRegisterDetailResponse.java @@ -0,0 +1,26 @@ +package com.amcamp.domain.project.dto.response; + +import com.amcamp.domain.project.domain.ProjectRegistration; +import com.amcamp.domain.project.domain.ProjectRegistrationStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ProjectRegisterDetailResponse( + @Schema(description = "프로젝트 아이디", example = "1") Long projectId, + @Schema(description = "프로젝트 가입 요청 아이디", example = "1") Long registrationId, + @Schema(description = "프로젝트 가입 요청자 아이디", example = "1") Long requesterId, + @Schema(description = "프로젝트 가입 요청자 회원 닉네임", example = "최현태") String requesterNickname, + @Schema(description = "프로젝트 가입 요청자 회원 프로필 이미지", example = "presigend image ") + String requesterImageUrl, + @Schema(description = "프로젝트 가입 요청 진행 상태", example = "REQUEST_PENDING") + ProjectRegistrationStatus projectRegistrationStatus) { + + public static ProjectRegisterDetailResponse from(ProjectRegistration projectRegistration) { + return new ProjectRegisterDetailResponse( + projectRegistration.getProject().getId(), + projectRegistration.getId(), + projectRegistration.getRequester().getId(), + projectRegistration.getRequester().getMember().getNickname(), + projectRegistration.getRequester().getMember().getProfileImageUrl(), + projectRegistration.getRequestStatus()); + } +} diff --git a/src/main/java/com/amcamp/domain/project/dto/response/ProjectRegistrationInfoResponse.java b/src/main/java/com/amcamp/domain/project/dto/response/ProjectRegistrationInfoResponse.java new file mode 100644 index 00000000..ed5843d2 --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dto/response/ProjectRegistrationInfoResponse.java @@ -0,0 +1,21 @@ +package com.amcamp.domain.project.dto.response; + +import com.amcamp.domain.project.domain.ProjectRegistration; +import com.amcamp.domain.project.domain.ProjectRegistrationStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ProjectRegistrationInfoResponse( + @Schema(description = "프로젝트 아이디", example = "1") Long projectId, + @Schema(description = "프로젝트 가입 요청 아이디", example = "1") Long registrationId, + @Schema(description = "프로젝트 가입 요청자 아이디", example = "1") Long requesterId, + @Schema(description = "프로젝트 가입 요청 진행 상태", example = "REQUEST_PENDING") + ProjectRegistrationStatus projectRegistrationStatus) { + + public static ProjectRegistrationInfoResponse from(ProjectRegistration projectRegistration) { + return new ProjectRegistrationInfoResponse( + projectRegistration.getProject().getId(), + projectRegistration.getId(), + projectRegistration.getRequester().getId(), + projectRegistration.getRequestStatus()); + } +} diff --git a/src/main/java/com/amcamp/domain/sprint/api/SprintController.java b/src/main/java/com/amcamp/domain/sprint/api/SprintController.java new file mode 100644 index 00000000..d79b8f1d --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/api/SprintController.java @@ -0,0 +1,88 @@ +package com.amcamp.domain.sprint.api; + +import com.amcamp.domain.sprint.application.SprintService; +import com.amcamp.domain.sprint.dao.SprintPagingDirection; +import com.amcamp.domain.sprint.dto.request.SprintCreateRequest; +import com.amcamp.domain.sprint.dto.request.SprintUpdateRequest; +import com.amcamp.domain.sprint.dto.response.SprintDetailResponse; +import com.amcamp.domain.sprint.dto.response.SprintIdResponse; +import com.amcamp.domain.sprint.dto.response.SprintInfoResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "5. 스프린트 API", description = "스프린트 관련 API입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/sprints") +public class SprintController { + + private final SprintService sprintService; + + @Operation(summary = "스프린트 생성", description = "스프린트를 생성합니다.") + @PostMapping("/create") + public ResponseEntity sprintCreate( + @Valid @RequestBody SprintCreateRequest request) { + SprintInfoResponse response = sprintService.createSprint(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Operation(summary = "스프린트 정보 수정", description = "스프린트의 목표, 마감일자를 수정합니다.") + @PatchMapping("/{sprintId}") + public SprintInfoResponse sprintUpdate( + @PathVariable Long sprintId, @Valid @RequestBody SprintUpdateRequest request) { + return sprintService.updateSprint(sprintId, request); + } + + @Operation(summary = "스프린트 삭제", description = "스프린트를 삭제합니다.") + @DeleteMapping("/{sprintId}") + public ResponseEntity sprintDelete(@PathVariable Long sprintId) { + sprintService.deleteSprint(sprintId); + return ResponseEntity.status(HttpStatus.OK).build(); + } + + @Operation(summary = "스프린트 상세 조회", description = "스프린트 기본 정보를 조회합니다.") + @GetMapping("/{sprintId}") + public SprintInfoResponse sprintFind(@PathVariable Long sprintId) { + return sprintService.findSprint(sprintId); + } + + @Operation(summary = "프로젝트별 스프린트 목록 조회", description = "특정 프로젝트의 스프린트 목록을 조회합니다.") + @GetMapping("/{projectId}/project") + public Slice sprintFindAll( + @PathVariable Long projectId, + @Parameter(description = "기준이 되는 스프린트 ID입니다. 첫 요청 시에는 비워두세요.") + @RequestParam(required = false) + Long baseSprintId, + @Parameter(description = "페이징 방향입니다.(NEXT 또는 PREV) 첫 요청 시에는 비워두세요.") + @RequestParam(required = false) + SprintPagingDirection direction) { + return sprintService.findAllSprint(projectId, baseSprintId, direction); + } + + @Operation(summary = "프로젝트별 스프린트 아이디 목록 조회", description = "특정 프로젝트의 스프린트 아이디 목록을 조회합니다.") + @GetMapping("/{projectId}/list") + public List sprintIdAllByMember(@PathVariable Long projectId) { + return sprintService.findAllSprintId(projectId); + } + + @Operation(summary = "회원별 프로젝트 내 스프린트 목록 조회", description = "마이페이지에서 특정 프로젝트의 스프린트 목록을 조회합니다.") + @GetMapping("/{projectId}/me") + public Slice sprintFindAllByMember( + @PathVariable Long projectId, + @Parameter(description = "기준이 되는 스프린트 ID입니다. 첫 요청 시에는 비워두세요.") + @RequestParam(required = false) + Long baseSprintId, + @Parameter(description = "페이징 방향입니다.(NEXT 또는 PREV) 첫 요청 시에는 비워두세요.") + @RequestParam(required = false) + SprintPagingDirection direction) { + return sprintService.findAllSprintByMember(projectId, baseSprintId, direction); + } +} diff --git a/src/main/java/com/amcamp/domain/sprint/application/SprintService.java b/src/main/java/com/amcamp/domain/sprint/application/SprintService.java new file mode 100644 index 00000000..3d7e5ec5 --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/application/SprintService.java @@ -0,0 +1,217 @@ +package com.amcamp.domain.sprint.application; + +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.project.dao.ProjectParticipantRepository; +import com.amcamp.domain.project.dao.ProjectRepository; +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.domain.ProjectParticipantRole; +import com.amcamp.domain.sprint.dao.SprintPagingDirection; +import com.amcamp.domain.sprint.dao.SprintRepository; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.domain.sprint.dto.request.SprintCreateRequest; +import com.amcamp.domain.sprint.dto.request.SprintUpdateRequest; +import com.amcamp.domain.sprint.dto.response.SprintDetailResponse; +import com.amcamp.domain.sprint.dto.response.SprintIdResponse; +import com.amcamp.domain.sprint.dto.response.SprintInfoResponse; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import com.amcamp.global.exception.errorcode.SprintErrorCode; +import com.amcamp.global.exception.errorcode.TeamErrorCode; +import com.amcamp.global.util.MemberUtil; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +@RequiredArgsConstructor +public class SprintService { + + private final MemberUtil memberUtil; + private final SprintRepository sprintRepository; + private final ProjectRepository projectRepository; + private final TeamParticipantRepository teamParticipantRepository; + private final ProjectParticipantRepository projectParticipantRepository; + + public SprintInfoResponse createSprint(SprintCreateRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + final Project project = findByProjectId(request.projectId()); + + validateProjectParticipant(project, project.getTeam(), currentMember); + + validatePreviousSprintEnded(project); + validateSprintDueDate(request.dueDt(), project.getDueDt()); + + long count = sprintRepository.countByProject(project); + String autoTitle = String.valueOf(count + 1); + + Sprint sprint = + sprintRepository.save( + Sprint.createSprint(project, autoTitle, request.goal(), request.dueDt())); + + return SprintInfoResponse.from(sprint); + } + + public SprintInfoResponse updateSprint(Long sprintId, SprintUpdateRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + final Sprint sprint = findBySprintId(sprintId); + + validateProjectParticipant( + sprint.getProject(), sprint.getProject().getTeam(), currentMember); + + if (request.dueDt() != null) { + validateSprintDueDate(request.dueDt(), sprint.getProject().getDueDt()); + validateDueDtIfNextSprintExists(sprint.getProject(), request.dueDt(), sprintId); + } + + sprint.updateSprint(request.goal(), request.dueDt()); + + return SprintInfoResponse.from(sprint); + } + + public void deleteSprint(Long sprintId) { + final Member currentMember = memberUtil.getCurrentMember(); + final Sprint sprint = findBySprintId(sprintId); + + ProjectParticipant projectParticipant = + validateProjectParticipant( + sprint.getProject(), sprint.getProject().getTeam(), currentMember); + + validateAdminProjectParticipant(projectParticipant); + + sprintRepository.deleteById(sprintId); + + List sprintList = + sprintRepository.findAllByProjectOrderByCreatedAt(sprint.getProject()); + for (int i = 0; i < sprintList.size(); i++) { + sprintList.get(i).updateSprintTitle(String.valueOf(i + 1)); + } + } + + @Transactional(readOnly = true) + public SprintInfoResponse findSprint(Long sprintId) { + final Member currentMember = memberUtil.getCurrentMember(); + final Sprint sprint = findBySprintId(sprintId); + final Project project = findByProjectId(sprint.getProject().getId()); + + teamParticipantRepository + .findByMemberAndTeam(currentMember, project.getTeam()) + .orElseThrow(() -> new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + + return SprintInfoResponse.from(sprint); + } + + @Transactional(readOnly = true) + public Slice findAllSprint( + Long projectId, Long baseSprintId, SprintPagingDirection direction) { + final Member currentMember = memberUtil.getCurrentMember(); + final Project project = findByProjectId(projectId); + + teamParticipantRepository + .findByMemberAndTeam(currentMember, project.getTeam()) + .orElseThrow(() -> new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + + validatePagingRequest(baseSprintId, direction); + + return sprintRepository.findAllSprintByProjectId(projectId, baseSprintId, direction); + } + + @Transactional(readOnly = true) + public List findAllSprintId(Long projectId) { + final Member currentMember = memberUtil.getCurrentMember(); + final Project project = findByProjectId(projectId); + ProjectParticipant participant = + validateProjectParticipant(project, project.getTeam(), currentMember); + + return sprintRepository.findAllSprintIdByProjectId(projectId); + } + + @Transactional(readOnly = true) + public Slice findAllSprintByMember( + Long projectId, Long baseSprintId, SprintPagingDirection direction) { + final Member currentMember = memberUtil.getCurrentMember(); + final Project project = findByProjectId(projectId); + + ProjectParticipant participant = + validateProjectParticipant(project, project.getTeam(), currentMember); + + validatePagingRequest(baseSprintId, direction); + + return sprintRepository.findAllSprintByProjectIdAndAssignee( + projectId, baseSprintId, direction, participant); + } + + private Sprint findBySprintId(Long sprintId) { + return sprintRepository + .findById(sprintId) + .orElseThrow(() -> new CommonException(SprintErrorCode.SPRINT_NOT_FOUND)); + } + + private Project findByProjectId(Long projectId) { + return projectRepository + .findById(projectId) + .orElseThrow(() -> new CommonException(ProjectErrorCode.PROJECT_NOT_FOUND)); + } + + private ProjectParticipant validateProjectParticipant( + Project project, Team team, Member currentMember) { + TeamParticipant teamParticipant = + teamParticipantRepository + .findByMemberAndTeam(currentMember, team) + .orElseThrow( + () -> new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + + return projectParticipantRepository + .findByProjectAndTeamParticipant(project, teamParticipant) + .orElseThrow( + () -> new CommonException(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED)); + } + + private void validateAdminProjectParticipant(ProjectParticipant projectParticipant) { + if (!projectParticipant.getProjectRole().equals(ProjectParticipantRole.ADMIN)) { + throw new CommonException(SprintErrorCode.SPRINT_DELETE_FORBIDDEN); + } + } + + private void validateSprintDueDate(LocalDate sprintDueDt, LocalDate projectDueDt) { + if (sprintDueDt.isAfter(projectDueDt)) { + throw new CommonException(SprintErrorCode.SPRINT_DUE_DATE_EXCEEDS_PROJECT_END); + } + } + + private void validatePreviousSprintEnded(Project project) { + sprintRepository + .findTopByProjectOrderByCreatedDtDesc(project) + .filter(sprint -> !sprint.getDueDt().isBefore(LocalDate.now())) + .ifPresent( + sprint -> { + throw new CommonException(SprintErrorCode.PREVIOUS_SPRINT_NOT_ENDED); + }); + } + + private void validateDueDtIfNextSprintExists(Project project, LocalDate dueDt, Long sprintId) { + Optional nextSprint = + sprintRepository.findNextSprintAfterDueDate(project.getId(), dueDt, sprintId); + + if (nextSprint.isPresent()) { + if (!dueDt.isBefore(nextSprint.get().getStartDt())) { + throw new CommonException(SprintErrorCode.SPRINT_DUE_DATE_CONFLICT_WITH_NEXT); + } + } + } + + private void validatePagingRequest(Long baseSprintId, SprintPagingDirection direction) { + boolean onlyOnePresent = (baseSprintId == null) != (direction == null); + if (onlyOnePresent) { + throw new CommonException(SprintErrorCode.INVALID_PAGING_REQUEST); + } + } +} diff --git a/src/main/java/com/amcamp/domain/sprint/dao/SprintPagingDirection.java b/src/main/java/com/amcamp/domain/sprint/dao/SprintPagingDirection.java new file mode 100644 index 00000000..2951fa47 --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/dao/SprintPagingDirection.java @@ -0,0 +1,6 @@ +package com.amcamp.domain.sprint.dao; + +public enum SprintPagingDirection { + NEXT, + PREV +} diff --git a/src/main/java/com/amcamp/domain/sprint/dao/SprintRepository.java b/src/main/java/com/amcamp/domain/sprint/dao/SprintRepository.java new file mode 100644 index 00000000..255dac96 --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/dao/SprintRepository.java @@ -0,0 +1,31 @@ +package com.amcamp.domain.sprint.dao; + +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.sprint.domain.Sprint; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SprintRepository extends JpaRepository, SprintRepositoryCustom { + @Query("SELECT COUNT(s) FROM Sprint s WHERE s.project = :project") + long countByProject(@Param("project") Project project); + + @Query("SELECT s FROM Sprint s WHERE s.project = :project ORDER BY s.id ASC") + List findAllByProjectOrderByCreatedAt(@Param("project") Project project); + + Optional findTopByProjectOrderByCreatedDtDesc(Project project); + + @Query( + "SELECT s FROM Sprint s " + + "WHERE s.project.id = :projectId " + + "AND s.dueDt > :currentDueDate " + + "AND s.id != :currentSprintId " + + "ORDER BY s.dueDt ASC") + Optional findNextSprintAfterDueDate( + @Param("projectId") Long projectId, + @Param("currentDueDate") LocalDate currentDueDate, + @Param("currentSprintId") Long currentSprintId); +} diff --git a/src/main/java/com/amcamp/domain/sprint/dao/SprintRepositoryCustom.java b/src/main/java/com/amcamp/domain/sprint/dao/SprintRepositoryCustom.java new file mode 100644 index 00000000..7b859afd --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/dao/SprintRepositoryCustom.java @@ -0,0 +1,20 @@ +package com.amcamp.domain.sprint.dao; + +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.sprint.dto.response.SprintDetailResponse; +import com.amcamp.domain.sprint.dto.response.SprintIdResponse; +import java.util.List; +import org.springframework.data.domain.Slice; + +public interface SprintRepositoryCustom { + Slice findAllSprintByProjectId( + Long projectId, Long baseSprintId, SprintPagingDirection direction); + + Slice findAllSprintByProjectIdAndAssignee( + Long projectId, + Long baseSprintId, + SprintPagingDirection direction, + ProjectParticipant participant); + + List findAllSprintIdByProjectId(Long projectId); +} diff --git a/src/main/java/com/amcamp/domain/sprint/dao/SprintRepositoryImpl.java b/src/main/java/com/amcamp/domain/sprint/dao/SprintRepositoryImpl.java new file mode 100644 index 00000000..6bce6b39 --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/dao/SprintRepositoryImpl.java @@ -0,0 +1,176 @@ +package com.amcamp.domain.sprint.dao; + +import static com.amcamp.domain.sprint.domain.QSprint.sprint; +import static com.amcamp.domain.task.domain.QTask.task; + +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.sprint.dto.response.SprintDetailResponse; +import com.amcamp.domain.sprint.dto.response.SprintIdResponse; +import com.amcamp.domain.task.dto.response.TaskBasicInfoResponse; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.SprintErrorCode; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class SprintRepositoryImpl implements SprintRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Slice findAllSprintByProjectId( + Long projectId, Long baseSprintId, SprintPagingDirection direction) { + List sprintList = fetchSprintList(projectId, baseSprintId, direction); + List taskList = fetchTaskList(sprintList.get(0).id()); + + List results = convertToSprintDetails(sprintList, taskList); + + return checkLastPage(results); + } + + @Override + public Slice findAllSprintByProjectIdAndAssignee( + Long projectId, + Long baseSprintId, + SprintPagingDirection direction, + ProjectParticipant participant) { + List sprintList = fetchSprintList(projectId, baseSprintId, direction); + List taskList = + fetchTaskListByAssignee(sprintList.get(0).id(), participant); + + List results = convertToSprintDetails(sprintList, taskList); + + return checkLastPage(results); + } + + @Override + public List findAllSprintIdByProjectId(Long projectId) { + List results = + jpaQueryFactory + .select( + Projections.constructor( + SprintIdResponse.class, sprint.id, sprint.title)) + .from(sprint) + .where(sprint.project.id.eq(projectId)) + .orderBy(sprint.id.asc()) + .fetch(); + + return results; + } + + private BooleanExpression buildPagingCondition( + Long baseSprintId, SprintPagingDirection direction) { + if (baseSprintId == null) { + return null; + } + return direction == SprintPagingDirection.NEXT + ? sprint.id.gt(baseSprintId) + : sprint.id.lt(baseSprintId); + } + + private OrderSpecifier getSprintPagingOrder(SprintPagingDirection direction) { + return (direction == null || direction == SprintPagingDirection.PREV) + ? sprint.id.desc() + : sprint.id.asc(); + } + + private Slice checkLastPage(List results) { + boolean hasNext = false; + + if (results.size() > 1) { + hasNext = true; + results.remove(1); + } + + return new SliceImpl<>(results, PageRequest.of(0, 1), hasNext); + } + + private List convertToSprintDetails( + List sprintList, List taskList) { + return sprintList.stream() + .map( + sprint -> + new SprintDetailResponse( + sprint.id(), + sprint.title(), + sprint.goal(), + sprint.startDt(), + sprint.dueDt(), + sprint.progress(), + taskList)) + .collect(Collectors.toList()); + } + + private List fetchSprintList( + Long projectId, Long baseSprintId, SprintPagingDirection direction) { + List results = + jpaQueryFactory + .select( + Projections.constructor( + SprintDetailResponse.class, + sprint.id, + sprint.title, + sprint.goal, + sprint.startDt, + sprint.dueDt, + sprint.progress.intValue(), + Expressions.constant(Collections.emptyList()))) + .from(sprint) + .where( + buildPagingCondition(baseSprintId, direction), + sprint.project.id.eq(projectId)) + .orderBy(getSprintPagingOrder(direction)) + .limit(2) + .fetch(); + + if (results.isEmpty()) { + if (direction == null) { + throw new CommonException(SprintErrorCode.SPRINT_NOT_EXISTS); + } + + switch (direction) { + case NEXT -> throw new CommonException(SprintErrorCode.NEXT_SPRINT_NOT_EXISTS); + case PREV -> throw new CommonException(SprintErrorCode.PREV_SPRINT_NOT_EXISTS); + } + } + + return results; + } + + private List fetchTaskList(Long sprintId) { + return fetchTaskListWithCondition(sprintId, null); + } + + private List fetchTaskListByAssignee( + Long sprintId, ProjectParticipant participant) { + return fetchTaskListWithCondition(sprintId, task.assignee.eq(participant)); + } + + private List fetchTaskListWithCondition( + Long sprintId, BooleanExpression condition) { + return jpaQueryFactory + .select( + Projections.constructor( + TaskBasicInfoResponse.class, + task.id, + task.description, + task.taskStatus, + task.sosStatus)) + .from(task) + .where(task.sprint.id.eq(sprintId), condition) + .orderBy(task.id.asc()) + .fetch(); + } +} diff --git a/src/main/java/com/amcamp/domain/sprint/domain/Sprint.java b/src/main/java/com/amcamp/domain/sprint/domain/Sprint.java new file mode 100644 index 00000000..58525a84 --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/domain/Sprint.java @@ -0,0 +1,108 @@ +package com.amcamp.domain.sprint.domain; + +import com.amcamp.domain.common.model.BaseTimeEntity; +import com.amcamp.domain.contribution.domain.Contribution; +import com.amcamp.domain.feedback.domain.Feedback; +import com.amcamp.domain.meeting.domain.Meeting; +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.task.domain.Task; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.SprintErrorCode; +import jakarta.persistence.*; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Sprint extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "sprint_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id") + private Project project; + + private String title; + + @Lob private String goal; + + private LocalDate startDt; + + private LocalDate dueDt; + + @OneToMany(mappedBy = "sprint", cascade = CascadeType.ALL, orphanRemoval = true) + private List feedbacks = new ArrayList<>(); + + // Task + @OneToMany(mappedBy = "sprint", cascade = CascadeType.ALL, orphanRemoval = true) + private List tasks = new ArrayList<>(); + + // Meeting + @OneToMany(mappedBy = "sprint", cascade = CascadeType.ALL, orphanRemoval = true) + private List meetings = new ArrayList<>(); + + // 기여도 + @OneToMany(mappedBy = "sprint", cascade = CascadeType.ALL, orphanRemoval = true) + private List contribution = new ArrayList<>(); + + // 진척도 + private Double progress; + + @Builder(access = AccessLevel.PRIVATE) + private Sprint( + Project project, + String title, + String goal, + LocalDate startDt, + LocalDate dueDt, + Double progress) { + this.project = project; + this.title = title; + this.goal = goal; + this.startDt = startDt; + this.dueDt = dueDt; + this.progress = progress; + } + + public static Sprint createSprint(Project project, String title, String goal, LocalDate dueDt) { + validateDueDt(LocalDate.now(), dueDt); + return Sprint.builder() + .project(project) + .title(title) + .goal(goal) + .progress(0.0) + .startDt(LocalDate.now()) + .dueDt(dueDt) + .build(); + } + + public void updateSprint(String goal, LocalDate dueDt) { + if (goal != null) this.goal = goal; + if (dueDt != null) { + validateDueDt(this.startDt, dueDt); + this.dueDt = dueDt; + } + } + + public void updateSprintTitle(String title) { + this.title = title; + } + + public void updateProgress(Double progress) { + this.progress = progress; + } + + private static void validateDueDt(LocalDate startDt, LocalDate dueDt) { + if (dueDt.isBefore(startDt)) { + throw new CommonException(SprintErrorCode.SPRINT_DUE_DATE_BEFORE_START); + } + } +} diff --git a/src/main/java/com/amcamp/domain/sprint/dto/request/SprintCreateRequest.java b/src/main/java/com/amcamp/domain/sprint/dto/request/SprintCreateRequest.java new file mode 100644 index 00000000..dd184bcb --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/dto/request/SprintCreateRequest.java @@ -0,0 +1,27 @@ +package com.amcamp.domain.sprint.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record SprintCreateRequest( + @NotNull(message = "프로젝트 ID는 비워둘 수 없습니다.") @Schema(description = "프로젝트 ID", example = "1") + Long projectId, + @NotBlank(message = "스프린트 중간 목표는 비워둘 수 없습니다.") + @Size(max = 100, message = "스프린트 중간 목표는 최대 100자까지 입력 가능합니다.") + @Schema(description = "중간 목표", example = "MVP 개발") + String goal, + @NotNull(message = "스프린트 마감 날짜는 비워둘 수 없습니다.") + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd", + timezone = "Asia/Seoul") + @Schema(description = "스프린트 마감 날짜", defaultValue = "2026-03-01") + LocalDate dueDt) { + public static SprintCreateRequest of(Long projectId, String goal, LocalDate dueDt) { + return new SprintCreateRequest(projectId, goal, dueDt); + } +} diff --git a/src/main/java/com/amcamp/domain/sprint/dto/request/SprintUpdateRequest.java b/src/main/java/com/amcamp/domain/sprint/dto/request/SprintUpdateRequest.java new file mode 100644 index 00000000..5fdd2365 --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/dto/request/SprintUpdateRequest.java @@ -0,0 +1,14 @@ +package com.amcamp.domain.sprint.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +public record SprintUpdateRequest( + @Schema(description = "중간 목표", example = "MVP 개발") String goal, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd", + timezone = "Asia/Seoul") + @Schema(description = "스프린트 마감 날짜", defaultValue = "2026-03-01") + LocalDate dueDt) {} diff --git a/src/main/java/com/amcamp/domain/sprint/dto/response/SprintDetailResponse.java b/src/main/java/com/amcamp/domain/sprint/dto/response/SprintDetailResponse.java new file mode 100644 index 00000000..2bfce5fc --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/dto/response/SprintDetailResponse.java @@ -0,0 +1,16 @@ +package com.amcamp.domain.sprint.dto.response; + +import com.amcamp.domain.task.dto.response.TaskBasicInfoResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.List; + +public record SprintDetailResponse( + @Schema(description = "스프린트 ID", example = "1") Long id, + @Schema(description = "스프린트 제목", example = "1차 스프린트") String title, + @Schema(description = "중간 목표", example = "MVP 개발") String goal, + @Schema(description = "스프린트 시작 날짜", defaultValue = "2026-02-01") LocalDate startDt, + @Schema(description = "스프린트 마감 날짜", defaultValue = "2026-03-01") LocalDate dueDt, + @Schema(description = "스프린트 진척도", defaultValue = "0") Integer progress, + @Schema(description = "태스크 목록", defaultValue = "{}") + List taskList) {} diff --git a/src/main/java/com/amcamp/domain/sprint/dto/response/SprintIdResponse.java b/src/main/java/com/amcamp/domain/sprint/dto/response/SprintIdResponse.java new file mode 100644 index 00000000..c8c96910 --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/dto/response/SprintIdResponse.java @@ -0,0 +1,7 @@ +package com.amcamp.domain.sprint.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record SprintIdResponse( + @Schema(description = "스프린트 ID", example = "1") Long sprintId, + @Schema(description = "스프린트 제목", example = "1차 스프린트") String title) {} diff --git a/src/main/java/com/amcamp/domain/sprint/dto/response/SprintInfoResponse.java b/src/main/java/com/amcamp/domain/sprint/dto/response/SprintInfoResponse.java new file mode 100644 index 00000000..2a5a665c --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/dto/response/SprintInfoResponse.java @@ -0,0 +1,23 @@ +package com.amcamp.domain.sprint.dto.response; + +import com.amcamp.domain.sprint.domain.Sprint; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +public record SprintInfoResponse( + @Schema(description = "스프린트 ID", example = "1") Long id, + @Schema(description = "스프린트 제목", example = "1차 스프린트") String title, + @Schema(description = "중간 목표", example = "MVP 개발") String goal, + @Schema(description = "스프린트 시작 날짜", defaultValue = "2026-02-01") LocalDate startDt, + @Schema(description = "스프린트 마감 날짜", defaultValue = "2026-03-01") LocalDate dueDt, + @Schema(description = "스프린트 진척도", defaultValue = "0") Integer progress) { + public static SprintInfoResponse from(Sprint sprint) { + return new SprintInfoResponse( + sprint.getId(), + sprint.getTitle(), + sprint.getGoal(), + sprint.getStartDt(), + sprint.getDueDt(), + sprint.getProgress().intValue()); + } +} diff --git a/src/main/java/com/amcamp/domain/sprint/dto/response/SprintProgressResponse.java b/src/main/java/com/amcamp/domain/sprint/dto/response/SprintProgressResponse.java new file mode 100644 index 00000000..7c9bd508 --- /dev/null +++ b/src/main/java/com/amcamp/domain/sprint/dto/response/SprintProgressResponse.java @@ -0,0 +1,11 @@ +package com.amcamp.domain.sprint.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record SprintProgressResponse( + @Schema(description = "스프린트 아이디", example = "1L") Long sprintId, + @Schema(description = "스프린트 진척도", example = "50.0") Double progress) { + public static SprintProgressResponse from(Long sprintId, Double progress) { + return new SprintProgressResponse(sprintId, progress); + } +} diff --git a/src/main/java/com/amcamp/domain/task/api/TaskController.java b/src/main/java/com/amcamp/domain/task/api/TaskController.java new file mode 100644 index 00000000..ec0c546d --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/api/TaskController.java @@ -0,0 +1,87 @@ +package com.amcamp.domain.task.api; + +import com.amcamp.domain.task.application.TaskService; +import com.amcamp.domain.task.dto.request.TaskBasicInfoUpdateRequest; +import com.amcamp.domain.task.dto.request.TaskCreateRequest; +import com.amcamp.domain.task.dto.response.TaskBasicInfoResponse; +import com.amcamp.domain.task.dto.response.TaskInfoResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "6. 태스크 API", description = "태스크 관련 API입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/tasks") +public class TaskController { + private final TaskService taskService; + + @Operation(summary = "태스크 생성", description = "태스크를 생성합니다.") + @PostMapping("/create") + public TaskInfoResponse taskCreate(@Valid @RequestBody TaskCreateRequest request) { + return taskService.createTask(request); + } + + @Operation(summary = "태스크 기본 정보 수정", description = "태스크 기본 정보를 수정합니다.") + @PatchMapping("/{taskId}/basic-info") + public TaskInfoResponse taskUpdateBasic( + @PathVariable Long taskId, @Valid @RequestBody TaskBasicInfoUpdateRequest request) { + return taskService.updateTaskBasicInfo(taskId, request); + } + + @Operation(summary = "태스크 완료", description = "태스크 완료에 따른 일정 및 진행 상태를 수정합니다.") + @PatchMapping("/{taskId}") + public TaskInfoResponse taskUpdateToDo(@PathVariable Long taskId) { + return taskService.updateTaskStatus(taskId); + } + + @Operation(summary = "태스크 SOS", description = "태스크 sos 상태를 수정합니다.") + @PatchMapping("/{taskId}/sos") + public TaskInfoResponse taskUpdateSOS(@PathVariable Long taskId) { + return taskService.updateTaskSOS(taskId); + } + + @Operation(summary = "태스크 담당자 할당", description = "태스크 담당 상태를 수정합니다.") + @PostMapping("/{taskId}") + public TaskInfoResponse taskAssign(@PathVariable Long taskId) { + return taskService.assignTask(taskId); + } + + @Operation(summary = "태스크 삭제", description = "태스크를 삭제합니다.") + @DeleteMapping("/{taskId}") + public ResponseEntity taskDelete(@PathVariable Long taskId) { + taskService.deleteTask(taskId); + return ResponseEntity.status(HttpStatus.OK).build(); + } + + @Operation(summary = "태스크 목록 조회", description = "태스크를 스프린트 아이디값에 따라 불러 옵니다.") + @GetMapping("/{sprintId}/sprint") + public Slice taskList( + @PathVariable Long sprintId, + @Parameter(description = "이전 페이지의 마지막 태스크 ID (첫 페이지는 비워두세요)") + @RequestParam(required = false) + Long lastTaskId, + @RequestParam(value = "size", defaultValue = "3") int size) { + return taskService.getTasksBySprint(sprintId, lastTaskId, size); + } + + @Deprecated + @Operation( + summary = "마이 페이지 내 스프린트별 태스크 조회", + description = "멤버에 할당된 태스크를 스프린트 아이디값에 따라 불러 옵니다.") + @GetMapping("/{sprintId}/me") + public Slice taskListByMember( + @PathVariable Long sprintId, + @Parameter(description = "이전 페이지의 마지막 태스크 ID (첫 페이지는 비워두세요)") + @RequestParam(required = false) + Long lastTaskId, + @RequestParam(value = "size", defaultValue = "3") int size) { + return taskService.getTasksByMember(sprintId, lastTaskId, size); + } +} diff --git a/src/main/java/com/amcamp/domain/task/application/TaskService.java b/src/main/java/com/amcamp/domain/task/application/TaskService.java new file mode 100644 index 00000000..23b55a83 --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/application/TaskService.java @@ -0,0 +1,280 @@ +package com.amcamp.domain.task.application; + +import com.amcamp.domain.contribution.dao.ContributionRepository; +import com.amcamp.domain.contribution.domain.Contribution; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.project.dao.ProjectParticipantRepository; +import com.amcamp.domain.project.dao.ProjectRepository; +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.domain.ProjectParticipantRole; +import com.amcamp.domain.sprint.dao.SprintRepository; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.domain.task.dao.TaskRepository; +import com.amcamp.domain.task.domain.*; +import com.amcamp.domain.task.dto.request.TaskBasicInfoUpdateRequest; +import com.amcamp.domain.task.dto.request.TaskCreateRequest; +import com.amcamp.domain.task.dto.response.TaskBasicInfoResponse; +import com.amcamp.domain.task.dto.response.TaskInfoResponse; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.*; +import com.amcamp.global.util.MemberUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +@RequiredArgsConstructor +public class TaskService { + private final MemberUtil memberUtil; + private final TaskRepository taskRepository; + private final SprintRepository sprintRepository; + private final ProjectParticipantRepository projectParticipantRepository; + private final TeamParticipantRepository teamParticipantRepository; + private final ProjectRepository projectRepository; + private final ContributionRepository contributionRepository; + + public TaskInfoResponse createTask(TaskCreateRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + final Sprint sprint = findBySprintId(request.sprintId()); + final Project project = sprint.getProject(); + validateProjectParticipant(project, project.getTeam(), currentMember); + Task task = + taskRepository.save( + Task.createTask(sprint, request.description(), request.taskDifficulty())); + + return findProjectParticipantMember(task) != null + ? TaskInfoResponse.from(task, findProjectParticipantMember(task)) + : TaskInfoResponse.from(task); + } + + public TaskInfoResponse updateTaskBasicInfo(Long taskId, TaskBasicInfoUpdateRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + final Task task = findByTaskId(taskId); + final Sprint sprint = findBySprintId(task.getSprint().getId()); + final Project project = sprint.getProject(); + + ProjectParticipant participant = + validateProjectParticipant(project, project.getTeam(), currentMember); + validateTaskModify(participant, task); + task.updateTaskBasicInfo(request); + + return findProjectParticipantMember(task) != null + ? TaskInfoResponse.from(task, findProjectParticipantMember(task)) + : TaskInfoResponse.from(task); + } + + public TaskInfoResponse updateTaskStatus(Long taskId) { + final Member currentMember = memberUtil.getCurrentMember(); + final Task task = findByTaskId(taskId); + final Sprint sprint = findBySprintId(task.getSprint().getId()); + final Project project = sprint.getProject(); + + ProjectParticipant participant = + validateProjectParticipant(project, project.getTeam(), currentMember); + validateTaskStatusModify(participant, task); + + task.updateTaskStatus(); + Contribution contribution = validateContribution(sprint, participant); + sprint.updateProgress(getSprintProgress(sprint)); + contribution.updateScore(getScore(sprint, participant)); + + return findProjectParticipantMember(task) != null + ? TaskInfoResponse.from(task, findProjectParticipantMember(task)) + : TaskInfoResponse.from(task); + } + + public TaskInfoResponse updateTaskSOS(Long taskId) { + final Member currentMember = memberUtil.getCurrentMember(); + final Task task = findByTaskId(taskId); + final Sprint sprint = findBySprintId(task.getSprint().getId()); + final Project project = sprint.getProject(); + + ProjectParticipant participant = + validateProjectParticipant(project, project.getTeam(), currentMember); + validateTaskNotAssignedForSos(task); + validateTaskModify(participant, task); + task.updateTaskSOS(); + + return findProjectParticipantMember(task) != null + ? TaskInfoResponse.from(task, findProjectParticipantMember(task)) + : TaskInfoResponse.from(task); + } + + public TaskInfoResponse assignTask(Long taskId) { + final Member currentMember = memberUtil.getCurrentMember(); + final Task task = findByTaskId(taskId); + final Sprint sprint = findBySprintId(task.getSprint().getId()); + final Project project = sprint.getProject(); + + ProjectParticipant projectParticipant = + validateProjectParticipant(project, project.getTeam(), currentMember); + + if (task.getAssignedStatus() != AssignedStatus.NOT_ASSIGNED + && task.getAssignee() != null + && task.getSosStatus() != SOSStatus.SOS) { + throw new CommonException(TaskErrorCode.TASK_ALREADY_ASSIGNED); + } + + if (task.getAssignedStatus() != AssignedStatus.NOT_ASSIGNED + && task.getAssignee() == projectParticipant + && task.getSosStatus() == SOSStatus.SOS) { + throw new CommonException(TaskErrorCode.TASK_ASSIGN_FORBIDDEN); + } + + task.assignTask(projectParticipant); + return findProjectParticipantMember(task) != null + ? TaskInfoResponse.from(task, findProjectParticipantMember(task)) + : TaskInfoResponse.from(task); + } + + public void deleteTask(Long taskId) { + final Member currentMember = memberUtil.getCurrentMember(); + final Task task = findByTaskId(taskId); + final Sprint sprint = findBySprintId(task.getSprint().getId()); + final Project project = sprint.getProject(); + + ProjectParticipant participant = + validateProjectParticipant(project, project.getTeam(), currentMember); + validateTaskModify(participant, task); + taskRepository.delete(task); + } + + @Transactional(readOnly = true) + public Slice getTasksBySprint(Long sprintId, Long lastTaskId, int size) { + final Member currentMember = memberUtil.getCurrentMember(); + final Sprint sprint = findBySprintId(sprintId); + final Project project = sprint.getProject(); + validateTeamParticipant(project.getTeam(), currentMember); + return taskRepository.findBySprint(sprintId, lastTaskId, size); + } + + @Transactional(readOnly = true) + public Slice getTasksByMember(Long sprintId, Long lastTaskId, int size) { + final Member currentMember = memberUtil.getCurrentMember(); + final Sprint sprint = findBySprintId(sprintId); + final Project project = sprint.getProject(); + ProjectParticipant projectParticipant = + validateProjectParticipant(project, project.getTeam(), currentMember); + return taskRepository.findBySprintAndAssignee( + sprintId, projectParticipant, lastTaskId, size); + } + + private Double getScore(Sprint sprint, ProjectParticipant participant) { + int highTask = taskRepository.countBySprintAndTaskDifficulty(sprint, TaskDifficulty.HIGH); + int midTask = taskRepository.countBySprintAndTaskDifficulty(sprint, TaskDifficulty.MID); + int lowTask = taskRepository.countBySprintAndTaskDifficulty(sprint, TaskDifficulty.LOW); + + int highTaskCompleted = + taskRepository.countBySprintAndAssigneeAndTaskDifficulty( + sprint, participant, TaskDifficulty.HIGH); + int midTaskCompleted = + taskRepository.countBySprintAndAssigneeAndTaskDifficulty( + sprint, participant, TaskDifficulty.MID); + int lowTaskCompleted = + taskRepository.countBySprintAndAssigneeAndTaskDifficulty( + sprint, participant, TaskDifficulty.LOW); + + int maxScore = 20 * highTask + 10 * midTask + lowTask * 5; + if (maxScore == 0) { + throw new CommonException(SprintErrorCode.TASK_NOT_CREATED_YET); + } + + double total = + (20 * highTaskCompleted + 10 * midTaskCompleted + 5 * lowTaskCompleted) * 100; + return total / maxScore; + } + + private Contribution validateContribution(Sprint sprint, ProjectParticipant participant) { + return contributionRepository + .findBySprintAndParticipant(sprint, participant) + .orElseGet( + () -> + contributionRepository.save( + Contribution.createContribution(sprint, participant, 0.0))); + } + + private Double getSprintProgress(Sprint sprint) { + int totalTasks = taskRepository.countBySprint(sprint); + double completedTasks = + taskRepository.countBySprintAndTaskStatus(sprint, TaskStatus.COMPLETED); + Double progress = completedTasks * 100 / totalTasks; + return progress; + } + + private void validateTaskNotAssignedForSos(Task task) { + if (task.getAssignedStatus() == AssignedStatus.NOT_ASSIGNED) { + throw new CommonException(TaskErrorCode.TASK_NOT_ASSIGNED); + } + } + + private void validateTeamParticipant(Team team, Member currentMember) { + TeamParticipant teamParticipant = + teamParticipantRepository + .findByMemberAndTeam(currentMember, team) + .orElseThrow( + () -> new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + } + + private void validateTaskModify(ProjectParticipant participant, Task task) { + validateTaskModifyAccess(participant, task); + if (task.getTaskStatus() == TaskStatus.COMPLETED) { + throw new CommonException(TaskErrorCode.TASK_MODIFY_FORBIDDEN); + } + } + + private void validateTaskStatusModify(ProjectParticipant participant, Task task) { + validateTaskModifyAccess(participant, task); + if (task.getSosStatus() == SOSStatus.SOS) { + throw new CommonException(TaskErrorCode.TASK_COMPLETE_FORBIDDEN); + } + } + + private void validateTaskModifyAccess(ProjectParticipant participant, Task task) { + if (task.getAssignedStatus() != AssignedStatus.NOT_ASSIGNED || task.getAssignee() != null) { + if (!participant.getProjectRole().equals(ProjectParticipantRole.ADMIN) + && !participant.equals(task.getAssignee())) { + throw new CommonException(TaskErrorCode.TASK_MODIFY_FORBIDDEN); + } + } + } + + private ProjectParticipant validateProjectParticipant( + Project project, Team team, Member currentMember) { + TeamParticipant teamParticipant = + teamParticipantRepository + .findByMemberAndTeam(currentMember, team) + .orElseThrow( + () -> new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + + return projectParticipantRepository + .findByProjectAndTeamParticipant(project, teamParticipant) + .orElseThrow( + () -> new CommonException(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED)); + } + + private Sprint findBySprintId(Long sprintId) { + return sprintRepository + .findById(sprintId) + .orElseThrow(() -> new CommonException(SprintErrorCode.SPRINT_NOT_FOUND)); + } + + private Task findByTaskId(Long taskId) { + return taskRepository + .findById(taskId) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + } + + private Member findProjectParticipantMember(Task task) { + Member member = null; + if (task.getAssignee() != null && task.getAssignedStatus() != AssignedStatus.NOT_ASSIGNED) { + member = task.getAssignee().getTeamParticipant().getMember(); + } + return member; + } +} diff --git a/src/main/java/com/amcamp/domain/task/dao/TaskRepository.java b/src/main/java/com/amcamp/domain/task/dao/TaskRepository.java new file mode 100644 index 00000000..7509757c --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/dao/TaskRepository.java @@ -0,0 +1,22 @@ +package com.amcamp.domain.task.dao; + +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.domain.task.domain.Task; +import com.amcamp.domain.task.domain.TaskDifficulty; +import com.amcamp.domain.task.domain.TaskStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface TaskRepository extends JpaRepository, TaskRepositoryCustom { + + int countBySprintAndTaskDifficulty(Sprint sprint, TaskDifficulty taskDifficulty); + + int countBySprintAndAssigneeAndTaskDifficulty( + Sprint sprint, ProjectParticipant assignee, TaskDifficulty taskDifficulty); + + int countBySprint(Sprint sprint); + + @Query("SELECT COUNT(t) FROM Task t WHERE t.sprint = :sprint AND t.taskStatus = :taskStatus") + int countBySprintAndTaskStatus(Sprint sprint, TaskStatus taskStatus); +} diff --git a/src/main/java/com/amcamp/domain/task/dao/TaskRepositoryCustom.java b/src/main/java/com/amcamp/domain/task/dao/TaskRepositoryCustom.java new file mode 100644 index 00000000..ea13ce82 --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/dao/TaskRepositoryCustom.java @@ -0,0 +1,13 @@ +package com.amcamp.domain.task.dao; + +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.task.dto.response.TaskBasicInfoResponse; +import com.amcamp.domain.task.dto.response.TaskInfoResponse; +import org.springframework.data.domain.Slice; + +public interface TaskRepositoryCustom { + Slice findBySprint(Long sprintId, Long lastTaskId, int pageSize); + + Slice findBySprintAndAssignee( + Long sprintId, ProjectParticipant assignee, Long lastTaskId, int pageSize); +} diff --git a/src/main/java/com/amcamp/domain/task/dao/TaskRepositoryImpl.java b/src/main/java/com/amcamp/domain/task/dao/TaskRepositoryImpl.java new file mode 100644 index 00000000..564f97e8 --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/dao/TaskRepositoryImpl.java @@ -0,0 +1,122 @@ +package com.amcamp.domain.task.dao; + +import static com.amcamp.domain.member.domain.QMember.member; +import static com.amcamp.domain.project.domain.QProjectParticipant.projectParticipant; +import static com.amcamp.domain.task.domain.QTask.task; +import static com.amcamp.domain.team.domain.QTeamParticipant.teamParticipant; + +import com.amcamp.domain.member.domain.QMember; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.task.domain.AssignedStatus; +import com.amcamp.domain.task.domain.QTask; +import com.amcamp.domain.task.dto.response.TaskBasicInfoResponse; +import com.amcamp.domain.task.dto.response.TaskInfoResponse; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class TaskRepositoryImpl implements TaskRepositoryCustom { + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Slice findBySprint(Long sprintId, Long lastTaskId, int size) { + List results = + jpaQueryFactory + .select( + Projections.constructor( + TaskInfoResponse.class, + task.id, + task.description, + task.taskDifficulty, + task.dueDt, + task.taskStatus, + task.assignedStatus, + task.sosStatus, + getAssigneeId(task), + getAssigneeNickname(member), + getAssigneeProfileImageUrl(member))) + .from(task) + .leftJoin(task.assignee, projectParticipant) + .leftJoin(projectParticipant.teamParticipant, teamParticipant) + .leftJoin(teamParticipant.member, member) + .where(task.sprint.id.eq(sprintId), lastTaskId(lastTaskId)) + .orderBy(task.createdDt.asc()) + .limit(size + 1) + .fetch(); + + return checkLastPage(size, results); + } + + @Override + public Slice findBySprintAndAssignee( + Long sprintId, ProjectParticipant assignee, Long lastTaskId, int size) { + List results = + jpaQueryFactory + .select( + Projections.constructor( + TaskBasicInfoResponse.class, + task.id, + task.description, + task.taskStatus, + task.sosStatus)) + .from(task) + .where( + task.sprint.id.eq(sprintId), + lastTaskId(lastTaskId), + task.assignee.eq(assignee)) + .orderBy(task.createdDt.asc()) + .limit(size + 1) + .fetch(); + + return checkLastPage(size, results); + } + + public Expression getAssigneeId(QTask task) { + return new CaseBuilder() + .when(task.assignedStatus.eq(AssignedStatus.ASSIGNED)) + .then(task.assignee.id) + .otherwise(Expressions.nullExpression()); + } + + public Expression getAssigneeNickname(QMember member) { + return new CaseBuilder() + .when(member.isNotNull()) + .then(member.nickname) + .otherwise(Expressions.nullExpression()); + } + + private Expression getAssigneeProfileImageUrl(QMember member) { + return new CaseBuilder() + .when(member.isNotNull()) + .then(member.profileImageUrl) + .otherwise(Expressions.nullExpression()); + } + + private BooleanExpression lastTaskId(Long taskId) { + if (taskId == null) { + return null; + } + return task.id.gt(taskId); + } + + private Slice checkLastPage(int pageSize, List results) { + boolean hasNext = false; + + if (results.size() > pageSize) { + hasNext = true; + results.remove(pageSize); + } + return new SliceImpl<>(results, PageRequest.of(0, pageSize), hasNext); + } +} diff --git a/src/main/java/com/amcamp/domain/task/domain/AssignedStatus.java b/src/main/java/com/amcamp/domain/task/domain/AssignedStatus.java new file mode 100644 index 00000000..c7ba165a --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/domain/AssignedStatus.java @@ -0,0 +1,13 @@ +package com.amcamp.domain.task.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum AssignedStatus { + ASSIGNED("TASK_STATUS_ASSIGNED"), + NOT_ASSIGNED("TASK_STATUS_NOT_ASSIGNED"); + + private final String taskStatus; +} diff --git a/src/main/java/com/amcamp/domain/task/domain/SOSStatus.java b/src/main/java/com/amcamp/domain/task/domain/SOSStatus.java new file mode 100644 index 00000000..ac51114d --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/domain/SOSStatus.java @@ -0,0 +1,13 @@ +package com.amcamp.domain.task.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum SOSStatus { + SOS("TASK_SOS"), + NOT_SOS("TASK_NOT_SOS"); + + private final String sosStatus; +} diff --git a/src/main/java/com/amcamp/domain/task/domain/Task.java b/src/main/java/com/amcamp/domain/task/domain/Task.java new file mode 100644 index 00000000..157d050a --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/domain/Task.java @@ -0,0 +1,112 @@ +package com.amcamp.domain.task.domain; + +import com.amcamp.domain.common.model.BaseTimeEntity; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.domain.task.dto.request.TaskBasicInfoUpdateRequest; +import jakarta.persistence.*; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Task extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "task_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sprint_id") + private Sprint sprint; + + @Lob private String description; + + @Enumerated(value = EnumType.STRING) + private TaskStatus taskStatus; + + private LocalDate dueDt; + + @Enumerated(EnumType.STRING) + private TaskDifficulty taskDifficulty; + + @Enumerated(EnumType.STRING) + private AssignedStatus assignedStatus; + + @Enumerated(EnumType.STRING) + private SOSStatus sosStatus; + + // 태스크 수행 멤버 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assignee_id") + private ProjectParticipant assignee; + + @Builder(access = AccessLevel.PRIVATE) + private Task( + Sprint sprint, + String description, + TaskDifficulty taskDifficulty, + LocalDate dueDt, + TaskStatus taskStatus, + AssignedStatus assignedStatus, + ProjectParticipant assignee, + SOSStatus sosStatus) { + this.sprint = sprint; + this.description = description; + this.dueDt = dueDt; + this.taskStatus = taskStatus; + this.taskDifficulty = taskDifficulty; + this.assignedStatus = assignedStatus; + this.assignee = assignee; + this.sosStatus = sosStatus; + } + + public static Task createTask( + Sprint sprint, String description, TaskDifficulty taskDifficulty) { + return Task.builder() + .sprint(sprint) + .description(description) + .taskDifficulty(taskDifficulty) + .assignedStatus(AssignedStatus.NOT_ASSIGNED) + .dueDt(null) + .taskStatus(TaskStatus.NOT_STARTED) + .assignee(null) + .sosStatus(SOSStatus.NOT_SOS) + .build(); + } + + public void updateTaskBasicInfo(TaskBasicInfoUpdateRequest request) { + if (request.description() != null) { + this.description = request.description(); + } + if (request.taskDifficulty() != null) { + this.taskDifficulty = request.taskDifficulty(); + } + } + + public void updateTaskStatus() { + if (this.taskStatus != TaskStatus.COMPLETED) this.taskStatus = TaskStatus.COMPLETED; + else { + this.taskStatus = TaskStatus.ON_GOING; + } + } + + public void assignTask(ProjectParticipant projectParticipant) { + this.assignedStatus = AssignedStatus.ASSIGNED; + this.assignee = projectParticipant; + this.taskStatus = TaskStatus.ON_GOING; + this.sosStatus = SOSStatus.NOT_SOS; + } + + public void updateTaskSOS() { + if (this.sosStatus != SOSStatus.SOS) { + this.sosStatus = SOSStatus.SOS; + } else { + this.sosStatus = SOSStatus.NOT_SOS; + } + } +} diff --git a/src/main/java/com/amcamp/domain/task/domain/TaskDifficulty.java b/src/main/java/com/amcamp/domain/task/domain/TaskDifficulty.java new file mode 100644 index 00000000..be65a0e8 --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/domain/TaskDifficulty.java @@ -0,0 +1,14 @@ +package com.amcamp.domain.task.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum TaskDifficulty { + HIGH("DIFFICULTY_HIGH"), + MID("DIFFICULTY_MID"), + LOW("DIFFICULTY_LOW"); + + private final String taskDifficulty; +} diff --git a/src/main/java/com/amcamp/domain/task/domain/TaskStatus.java b/src/main/java/com/amcamp/domain/task/domain/TaskStatus.java new file mode 100644 index 00000000..a10dce57 --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/domain/TaskStatus.java @@ -0,0 +1,14 @@ +package com.amcamp.domain.task.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum TaskStatus { + NOT_STARTED("STATUS_NOT_STARTED"), + ON_GOING("STATUS_ON_GOING"), + COMPLETED("STATUS_COMPLETED"); + + private final String status; +} diff --git a/src/main/java/com/amcamp/domain/task/dto/request/TaskBasicInfoUpdateRequest.java b/src/main/java/com/amcamp/domain/task/dto/request/TaskBasicInfoUpdateRequest.java new file mode 100644 index 00000000..5c47e625 --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/dto/request/TaskBasicInfoUpdateRequest.java @@ -0,0 +1,11 @@ +package com.amcamp.domain.task.dto.request; + +import com.amcamp.domain.task.domain.TaskDifficulty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +public record TaskBasicInfoUpdateRequest( + @Schema(description = "태스크 내용", example = "피그마 화면 설계 1차 수정") + @Size(max = 15, message = "태스크 내용은 최대 15자까지 입력 가능합니다.") + String description, + @Schema(description = "태스크 난이도", example = "HIGH") TaskDifficulty taskDifficulty) {} diff --git a/src/main/java/com/amcamp/domain/task/dto/request/TaskCreateRequest.java b/src/main/java/com/amcamp/domain/task/dto/request/TaskCreateRequest.java new file mode 100644 index 00000000..7f28cdab --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/dto/request/TaskCreateRequest.java @@ -0,0 +1,17 @@ +package com.amcamp.domain.task.dto.request; + +import com.amcamp.domain.task.domain.TaskDifficulty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record TaskCreateRequest( + @NotNull(message = "스프린트 ID는 필수값입니다.") @Schema(description = "스프린트 ID", example = "1") + Long sprintId, + @NotBlank(message = "태스크 내용은 필수값입니다.") + @Schema(description = "태스크 내용", example = "피그마 화면 설계 수정") + @Size(max = 15, message = "태스크 내용은 최대 15자까지 입력 가능합니다.") + String description, + @NotNull(message = "태스크 난이도는 필수값입니다.") @Schema(description = "태스크 난이도", example = "MID") + TaskDifficulty taskDifficulty) {} diff --git a/src/main/java/com/amcamp/domain/task/dto/response/TaskBasicInfoResponse.java b/src/main/java/com/amcamp/domain/task/dto/response/TaskBasicInfoResponse.java new file mode 100644 index 00000000..1690b471 --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/dto/response/TaskBasicInfoResponse.java @@ -0,0 +1,11 @@ +package com.amcamp.domain.task.dto.response; + +import com.amcamp.domain.task.domain.SOSStatus; +import com.amcamp.domain.task.domain.TaskStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +public record TaskBasicInfoResponse( + @Schema(description = "태스크 아이디", example = "1") Long taskId, + @Schema(description = "태스크 내용", example = "피그마 화면 설계 수정") String description, + @Schema(description = "태스크 진행 현황", example = "ON_GOING") TaskStatus taskStatus, + @Schema(description = "태스크 SOS 상태", example = "SOS") SOSStatus sosStatus) {} diff --git a/src/main/java/com/amcamp/domain/task/dto/response/TaskInfoResponse.java b/src/main/java/com/amcamp/domain/task/dto/response/TaskInfoResponse.java new file mode 100644 index 00000000..625137ee --- /dev/null +++ b/src/main/java/com/amcamp/domain/task/dto/response/TaskInfoResponse.java @@ -0,0 +1,48 @@ +package com.amcamp.domain.task.dto.response; + +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.task.domain.*; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +public record TaskInfoResponse( + @Schema(description = "태스크 아이디", example = "1") Long taskId, + @Schema(description = "태스크 내용", example = "피그마 화면 설계 수정") String description, + @Schema(description = "태스크 난이도", example = "MID") TaskDifficulty taskDifficulty, + @Schema(description = "태스크 완료일자", example = "2024-01-02") LocalDate dueDt, + @Schema(description = "태스크 진행 현황", example = "ON_GOING") TaskStatus taskStatus, + @Schema(description = "태스크 담당 상태", example = "ASSIGNED") AssignedStatus assignedStatus, + @Schema(description = "태스크 SOS 상태", example = "SOS") SOSStatus sosStatus, + @Schema(description = "태스크 담당자 아이디", example = "1") Long projectParticipantId, + @Schema(description = "태스크 담당자 닉네임", example = "최현태") String nickname, + @Schema(description = "태스크 담당자 프로필 url", example = "Presigned URL") + String profileImageUrl) { + + public static TaskInfoResponse from(Task task, Member member) { + return new TaskInfoResponse( + task.getId(), + task.getDescription(), + task.getTaskDifficulty(), + task.getDueDt(), + task.getTaskStatus(), + task.getAssignedStatus(), + task.getSosStatus(), + member.getId(), + member.getNickname(), + member.getProfileImageUrl()); + } + + public static TaskInfoResponse from(Task task) { + return new TaskInfoResponse( + task.getId(), + task.getDescription(), + task.getTaskDifficulty(), + task.getDueDt(), + task.getTaskStatus(), + task.getAssignedStatus(), + task.getSosStatus(), + null, + null, + null); + } +} diff --git a/src/main/java/com/amcamp/domain/team/api/TeamController.java b/src/main/java/com/amcamp/domain/team/api/TeamController.java new file mode 100644 index 00000000..6f0a1e61 --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/api/TeamController.java @@ -0,0 +1,91 @@ +package com.amcamp.domain.team.api; + +import com.amcamp.domain.team.application.TeamService; +import com.amcamp.domain.team.dto.request.TeamCreateRequest; +import com.amcamp.domain.team.dto.request.TeamInviteCodeRequest; +import com.amcamp.domain.team.dto.request.TeamUpdateRequest; +import com.amcamp.domain.team.dto.response.TeamAdminResponse; +import com.amcamp.domain.team.dto.response.TeamCheckResponse; +import com.amcamp.domain.team.dto.response.TeamInfoResponse; +import com.amcamp.domain.team.dto.response.TeamInviteCodeResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "3. 팀 API", description = "팀 관련 API입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/teams") +public class TeamController { + private final TeamService teamService; + + @Operation(summary = "팀 생성", description = "팀을 생성합니다.") + @PostMapping("/create") + public TeamInviteCodeResponse teamCreate( + @Valid @RequestBody TeamCreateRequest teamCreateRequest) { + return teamService.createTeam(teamCreateRequest); + } + + @Operation(summary = "코드 확인", description = "팀 가입을 위한 초대 코드를 확인합니다.") + @GetMapping("/{teamId}/invite-code") + public TeamInviteCodeResponse teamInvite(@PathVariable Long teamId) { + return teamService.getInviteCode(teamId); + } + + @Operation(summary = "팀 참가 전 팀 확인", description = "초대 코드를 입력하여 참여하려고 하는 팀 정보를 확인합니다.") + @PostMapping("/check") + public TeamCheckResponse teamCheck( + @Valid @RequestBody TeamInviteCodeRequest teamInviteCodeRequest) { + return teamService.getTeamByCode(teamInviteCodeRequest); + } + + @Operation(summary = "팀 참가", description = "팀 정보를 확인 후 팀에 참가합니다.") + @PostMapping("/join") + public ResponseEntity teamJoin( + @Valid @RequestBody TeamInviteCodeRequest teamInviteCodeRequest) { + teamService.joinTeam(teamInviteCodeRequest); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "팀 수정", description = "팀 이름과 설명, 이모지 등 팀 기본정보를 수정합니다.") + @PatchMapping("/{teamId}") + public TeamInfoResponse teamEdit( + @PathVariable Long teamId, @Valid @RequestBody TeamUpdateRequest teamUpdateRequest) { + return teamService.editTeam(teamId, teamUpdateRequest); + } + + @Operation(summary = "팀 삭제", description = "팀을 삭제합니다.") + @DeleteMapping("/{teamId}") + public ResponseEntity teamDelete(@PathVariable Long teamId) { + teamService.deleteTeam(teamId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "팀 정보", description = "팀 이름과 설명 등 기본 정보를 반환합니다.") + @GetMapping("/{teamId}") + public TeamInfoResponse teamInfo(@PathVariable Long teamId) { + return teamService.getTeamInfo(teamId); + } + + @Operation(summary = "팀장 조회", description = "팀 페이지에서 팀장을 조회합니다.") + @GetMapping("/{teamId}/admin") + public TeamAdminResponse teamFindAdmin(@PathVariable Long teamId) { + return teamService.findTeamAdmin(teamId); + } + + @Operation(summary = "팀 목록 조회", description = "회원이 참여한 팀 목록을 조회합니다.") + @GetMapping("/list") + public Slice teamFindAll( + @Parameter(description = "이전 페이지의 마지막 팀 ID (첫 페이지는 비워두세요)") + @RequestParam(required = false) + Long lastTeamId, + @Parameter(description = "페이지당 팀 수", example = "1") @RequestParam(value = "size") + int pageSize) { + return teamService.findAllTeam(lastTeamId, pageSize); + } +} diff --git a/src/main/java/com/amcamp/domain/team/application/TeamService.java b/src/main/java/com/amcamp/domain/team/application/TeamService.java new file mode 100644 index 00000000..c9bdf2dc --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/application/TeamService.java @@ -0,0 +1,190 @@ +package com.amcamp.domain.team.application; + +import static com.amcamp.global.common.constants.RedisConstants.*; + +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.dao.TeamRepository; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.domain.team.domain.TeamParticipantRole; +import com.amcamp.domain.team.dto.request.TeamCreateRequest; +import com.amcamp.domain.team.dto.request.TeamInviteCodeRequest; +import com.amcamp.domain.team.dto.request.TeamUpdateRequest; +import com.amcamp.domain.team.dto.response.TeamAdminResponse; +import com.amcamp.domain.team.dto.response.TeamCheckResponse; +import com.amcamp.domain.team.dto.response.TeamInfoResponse; +import com.amcamp.domain.team.dto.response.TeamInviteCodeResponse; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.TeamErrorCode; +import com.amcamp.global.util.MemberUtil; +import com.amcamp.global.util.RandomUtil; +import com.amcamp.global.util.RedisUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +@RequiredArgsConstructor +@Slf4j +public class TeamService { + private final MemberUtil memberUtil; + private final TeamRepository teamRepository; + private final TeamParticipantRepository teamParticipantRepository; + private final RedisUtil redisUtil; + private final RandomUtil randomUtil; + + public TeamInviteCodeResponse createTeam(TeamCreateRequest teamCreateRequest) { + Member member = memberUtil.getCurrentMember(); + Team team = + Team.createTeam( + normalizeTeamName(teamCreateRequest.teamName()), + teamCreateRequest.teamDescription()); + teamRepository.save(team); + + TeamParticipant teamParticipant = + TeamParticipant.createParticipant(member, team, TeamParticipantRole.ADMIN); + teamParticipantRepository.save(teamParticipant); + + String code = randomUtil.generateCode(team.getId()); + + return new TeamInviteCodeResponse(code); + } + + public TeamInviteCodeResponse getInviteCode(Long teamId) { + Member member = memberUtil.getCurrentMember(); + Team team = validateTeam(teamId); + validateAdminParticipant(member, team); + + String code = randomUtil.generateCode(team.getId()); + + return new TeamInviteCodeResponse(code); + } + + public TeamCheckResponse getTeamByCode(TeamInviteCodeRequest teamInviteCodeRequest) { + Team team = searchTeamByCode(teamInviteCodeRequest.inviteCode()); + return new TeamCheckResponse(team.getId(), team.getName()); + } + + public void joinTeam(TeamInviteCodeRequest teamInviteCodeRequest) { + Member member = memberUtil.getCurrentMember(); + Team team = searchTeamByCode(teamInviteCodeRequest.inviteCode()); + validateTeamJoin(member, team); + + TeamParticipant teamParticipant = + TeamParticipant.createParticipant(member, team, TeamParticipantRole.USER); + teamParticipantRepository.save(teamParticipant); + } + + public TeamInfoResponse editTeam(Long teamId, TeamUpdateRequest teamUpdateRequest) { + Member member = memberUtil.getCurrentMember(); + Team team = validateTeam(teamId); + validateAdminParticipant(member, team); + + TeamUpdateRequest normalizedTeamUpdateRequest = + new TeamUpdateRequest( + normalizeTeamName(teamUpdateRequest.teamName()), + teamUpdateRequest.teamDescription(), + teamUpdateRequest.teamEmoji()); + + team.updateTeam(normalizedTeamUpdateRequest); + + return TeamInfoResponse.from(team); + } + + public void deleteTeam(Long teamId) { + Member member = memberUtil.getCurrentMember(); + Team team = validateTeam(teamId); + validateAdminParticipant(member, team); + + teamParticipantRepository.deleteByTeam(team); + + redisUtil + .getData(TEAM_ID_PREFIX.formatted(teamId)) + .ifPresent( + inviteCode -> { + redisUtil.deleteData(INVITE_CODE_PREFIX.formatted(inviteCode)); + redisUtil.deleteData(TEAM_ID_PREFIX.formatted(teamId)); + }); + + teamRepository.delete(team); + } + + @Transactional(readOnly = true) + public TeamInfoResponse getTeamInfo(Long teamId) { + Member member = memberUtil.getCurrentMember(); + Team team = validateTeam(teamId); + validateParticipant(member, team); + + return TeamInfoResponse.from(team); + } + + private Team searchTeamByCode(String inviteCode) { + Long teamId = + redisUtil + .getData(INVITE_CODE_PREFIX.formatted(inviteCode)) + .map(Long::valueOf) + .orElseThrow(() -> new CommonException(TeamErrorCode.INVALID_INVITE_CODE)); + + return teamRepository + .findById(teamId) + .orElseThrow(() -> new CommonException(TeamErrorCode.TEAM_NOT_FOUND)); + } + + private String normalizeTeamName(String name) { + if (name == null || name.trim().isEmpty()) { + return name; + } + return name.trim().replaceAll("[^0-9a-zA-Z가-힣 ]", "_"); + } + + private Team validateTeam(Long teamId) { + Team team = + teamRepository + .findById(teamId) + .orElseThrow(() -> new CommonException(TeamErrorCode.TEAM_NOT_FOUND)); + return team; + } + + private void validateTeamJoin(Member member, Team team) { + if (teamParticipantRepository.findByMemberAndTeam(member, team).isPresent()) { + throw new CommonException(TeamErrorCode.MEMBER_ALREADY_JOINED); + } + } + + private void validateParticipant(Member member, Team team) { + if (!teamParticipantRepository.findByMemberAndTeam(member, team).isPresent()) { + throw new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED); + } + } + + private void validateAdminParticipant(Member member, Team team) { + TeamParticipant teamParticipant = + teamParticipantRepository + .findByMemberAndTeam(member, team) + .orElseThrow( + () -> new CommonException(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + + if (teamParticipant.getRole() != TeamParticipantRole.ADMIN) { + throw new CommonException(TeamErrorCode.UNAUTHORIZED_ACCESS); + } + } + + @Transactional(readOnly = true) + public Slice findAllTeam(Long lastTeamId, int pageSize) { + Member currentMember = memberUtil.getCurrentMember(); + return teamRepository.findAllTeamByMemberId(currentMember.getId(), lastTeamId, pageSize); + } + + @Transactional(readOnly = true) + public TeamAdminResponse findTeamAdmin(Long teamId) { + Member member = memberUtil.getCurrentMember(); + Team team = validateTeam(teamId); + validateParticipant(member, team); + return TeamAdminResponse.from( + teamParticipantRepository.findAdmin(teamId, TeamParticipantRole.ADMIN)); + } +} diff --git a/src/main/java/com/amcamp/domain/team/dao/TeamParticipantRepository.java b/src/main/java/com/amcamp/domain/team/dao/TeamParticipantRepository.java new file mode 100644 index 00000000..cf4567c6 --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/dao/TeamParticipantRepository.java @@ -0,0 +1,35 @@ +package com.amcamp.domain.team.dao; + +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.domain.team.domain.TeamParticipantRole; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface TeamParticipantRepository extends JpaRepository { + Optional findByMemberAndTeam(Member member, Team team); + + void deleteByTeam(Team team); + + Optional findByTeamId(Long teamId); + + // @Query( + // "SELECT new com.amcamp.domain.team.dto.response.TeamAdminResponse (m.id, + // m.nickname, m.profileImageUrl) " + // + "FROM Member m " + // + "JOIN TeamParticipant tp ON m.id = tp.member.id " + // + "WHERE tp.team.id = :teamId " + // + "AND tp.role = :role ") + // TeamAdminResponse findAdmin( + // @Param("teamId") Long teamId, @Param("role") TeamParticipantRole role); + + @Query( + "SELECT m FROM Member m " + + "JOIN TeamParticipant tp ON m.id = tp.member.id " + + "WHERE tp.team.id = :teamId " + + "AND tp.role = :role") + Member findAdmin(@Param("teamId") Long teamId, @Param("role") TeamParticipantRole role); +} diff --git a/src/main/java/com/amcamp/domain/team/dao/TeamRepository.java b/src/main/java/com/amcamp/domain/team/dao/TeamRepository.java new file mode 100644 index 00000000..e3d34fcf --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/dao/TeamRepository.java @@ -0,0 +1,6 @@ +package com.amcamp.domain.team.dao; + +import com.amcamp.domain.team.domain.Team; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TeamRepository extends JpaRepository, TeamRepositoryCustom {} diff --git a/src/main/java/com/amcamp/domain/team/dao/TeamRepositoryCustom.java b/src/main/java/com/amcamp/domain/team/dao/TeamRepositoryCustom.java new file mode 100644 index 00000000..ae43fc26 --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/dao/TeamRepositoryCustom.java @@ -0,0 +1,8 @@ +package com.amcamp.domain.team.dao; + +import com.amcamp.domain.team.dto.response.TeamInfoResponse; +import org.springframework.data.domain.Slice; + +public interface TeamRepositoryCustom { + Slice findAllTeamByMemberId(Long memberId, Long lastTeamId, int pageSize); +} diff --git a/src/main/java/com/amcamp/domain/team/dao/TeamRepositoryImpl.java b/src/main/java/com/amcamp/domain/team/dao/TeamRepositoryImpl.java new file mode 100644 index 00000000..2aff504f --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/dao/TeamRepositoryImpl.java @@ -0,0 +1,70 @@ +package com.amcamp.domain.team.dao; + +import static com.amcamp.domain.team.domain.QTeam.team; +import static com.amcamp.domain.team.domain.QTeamParticipant.teamParticipant; + +import com.amcamp.domain.team.dto.response.TeamInfoResponse; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.TeamErrorCode; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class TeamRepositoryImpl implements TeamRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Slice findAllTeamByMemberId( + Long memberId, Long lastTeamId, int pageSize) { + List results = + jpaQueryFactory + .select( + Projections.constructor( + TeamInfoResponse.class, + team.id, + team.name, + team.description, + team.emoji)) + .from(teamParticipant) + .leftJoin(teamParticipant.team, team) + .on(team.id.eq(teamParticipant.team.id)) + .where(lastTeamId(lastTeamId), teamParticipant.member.id.eq(memberId)) + .orderBy(teamParticipant.createdDt.desc()) + .limit(pageSize + 1) + .fetch(); + + if (results.isEmpty()) { + throw new CommonException(TeamErrorCode.TEAM_NOT_EXISTS); + } + + return checkLastPage(pageSize, results); + } + + private BooleanExpression lastTeamId(Long teamId) { + if (teamId == null) { + return null; + } + + return team.id.lt(teamId); + } + + private Slice checkLastPage(int pageSize, List results) { + boolean hasNext = false; + + if (results.size() > pageSize) { + hasNext = true; + results.remove(pageSize); + } + + return new SliceImpl<>(results, PageRequest.of(0, pageSize), hasNext); + } +} diff --git a/src/main/java/com/amcamp/domain/team/domain/Team.java b/src/main/java/com/amcamp/domain/team/domain/Team.java new file mode 100644 index 00000000..d8a30177 --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/domain/Team.java @@ -0,0 +1,47 @@ +package com.amcamp.domain.team.domain; + +import com.amcamp.domain.common.model.BaseTimeEntity; +import com.amcamp.domain.team.dto.request.TeamUpdateRequest; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Team extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "team_id") + private Long id; + + private String name; + private String description; + private String emoji; + + @Builder(access = AccessLevel.PRIVATE) + private Team(String name, String description, String emoji) { + this.name = name; + this.description = description; + this.emoji = emoji; + } + + public static Team createTeam(String name, String description) { + return Team.builder().name(name).description(description).emoji("🍇").build(); + } + + public void updateTeam(TeamUpdateRequest teamUpdateRequest) { + this.name = + (teamUpdateRequest.teamName() != null) ? teamUpdateRequest.teamName() : this.name; + this.description = + (teamUpdateRequest.teamDescription() != null) + ? teamUpdateRequest.teamDescription() + : this.description; + this.emoji = + (teamUpdateRequest.teamEmoji() != null) + ? teamUpdateRequest.teamEmoji() + : this.emoji; + } +} diff --git a/src/main/java/com/amcamp/domain/team/domain/TeamParticipant.java b/src/main/java/com/amcamp/domain/team/domain/TeamParticipant.java new file mode 100644 index 00000000..7313bc9c --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/domain/TeamParticipant.java @@ -0,0 +1,42 @@ +package com.amcamp.domain.team.domain; + +import com.amcamp.domain.common.model.BaseTimeEntity; +import com.amcamp.domain.member.domain.Member; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TeamParticipant extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "team_participant_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id") + private Team team; + + @Enumerated(EnumType.STRING) + private TeamParticipantRole role; + + @Builder(access = AccessLevel.PRIVATE) + private TeamParticipant(Member member, Team team, TeamParticipantRole role) { + this.member = member; + this.team = team; + this.role = role; + } + + public static TeamParticipant createParticipant( + Member member, Team team, TeamParticipantRole role) { + return TeamParticipant.builder().member(member).team(team).role(role).build(); + } +} diff --git a/src/main/java/com/amcamp/domain/team/domain/TeamParticipantRole.java b/src/main/java/com/amcamp/domain/team/domain/TeamParticipantRole.java new file mode 100644 index 00000000..d788e578 --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/domain/TeamParticipantRole.java @@ -0,0 +1,14 @@ +package com.amcamp.domain.team.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum TeamParticipantRole { + ADMIN("TEAM_ADMIN"), + USER("TEAM_USER"), + ; + + private final String role; +} diff --git a/src/main/java/com/amcamp/domain/team/dto/request/TeamCreateRequest.java b/src/main/java/com/amcamp/domain/team/dto/request/TeamCreateRequest.java new file mode 100644 index 00000000..64f5bab6 --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/dto/request/TeamCreateRequest.java @@ -0,0 +1,18 @@ +package com.amcamp.domain.team.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record TeamCreateRequest( + @NotBlank(message = "팀 이름은 필수 사항입니다.") + @Size(max = 15, message = "팀 이름은 최대 15자까지 입력 가능합니다.") + @Schema(description = "팀 이름", example = "Side Effect") + String teamName, + @Size(max = 100, message = "팀 설명은 최대 100자까지 입력 가능합니다.") + @Schema(description = "팀 설명", example = "Lg cns am camp 1기 개발 스터디") + String teamDescription) { + public static TeamCreateRequest of(String teamName, String teamDescription) { + return new TeamCreateRequest(teamName, teamDescription); + } +} diff --git a/src/main/java/com/amcamp/domain/team/dto/request/TeamInviteCodeRequest.java b/src/main/java/com/amcamp/domain/team/dto/request/TeamInviteCodeRequest.java new file mode 100644 index 00000000..50171cb8 --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/dto/request/TeamInviteCodeRequest.java @@ -0,0 +1,7 @@ +package com.amcamp.domain.team.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record TeamInviteCodeRequest( + @NotBlank @Schema(description = "팀 코드", example = "FFbKeJvJ") String inviteCode) {} diff --git a/src/main/java/com/amcamp/domain/team/dto/request/TeamUpdateRequest.java b/src/main/java/com/amcamp/domain/team/dto/request/TeamUpdateRequest.java new file mode 100644 index 00000000..1757d05d --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/dto/request/TeamUpdateRequest.java @@ -0,0 +1,13 @@ +package com.amcamp.domain.team.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +public record TeamUpdateRequest( + @Size(max = 15, message = "팀 이름은 최대 15자까지 입력 가능합니다.") + @Schema(description = "팀 이름", example = "Side Effect") + String teamName, + @Size(max = 100, message = "팀 설명은 최대 100자까지 입력 가능합니다.") + @Schema(description = "팀 설명", example = "Lg cns am camp 1기 개발 스터디") + String teamDescription, + @Schema(description = "팀 이모지", example = "🍇") String teamEmoji) {} diff --git a/src/main/java/com/amcamp/domain/team/dto/response/TeamAdminResponse.java b/src/main/java/com/amcamp/domain/team/dto/response/TeamAdminResponse.java new file mode 100644 index 00000000..376a26a8 --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/dto/response/TeamAdminResponse.java @@ -0,0 +1,14 @@ +package com.amcamp.domain.team.dto.response; + +import com.amcamp.domain.member.domain.Member; +import io.swagger.v3.oas.annotations.media.Schema; + +public record TeamAdminResponse( + @Schema(description = "멤버 아이디", example = "1") Long memberId, + @Schema(description = "멤버 닉네임", example = "최현태") String nickname, + @Schema(description = "멤버 프로필 url", example = "Presigned URL") String profileImageUrl) { + public static TeamAdminResponse from(Member member) { + return new TeamAdminResponse( + member.getId(), member.getNickname(), member.getProfileImageUrl()); + } +} diff --git a/src/main/java/com/amcamp/domain/team/dto/response/TeamCheckResponse.java b/src/main/java/com/amcamp/domain/team/dto/response/TeamCheckResponse.java new file mode 100644 index 00000000..d513b69c --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/dto/response/TeamCheckResponse.java @@ -0,0 +1,7 @@ +package com.amcamp.domain.team.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TeamCheckResponse( + @Schema(description = "팀 아이디", example = "1") Long teamId, + @Schema(description = "팀 이름", example = "Side Effect") String teamName) {} diff --git a/src/main/java/com/amcamp/domain/team/dto/response/TeamInfoResponse.java b/src/main/java/com/amcamp/domain/team/dto/response/TeamInfoResponse.java new file mode 100644 index 00000000..937e6c25 --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/dto/response/TeamInfoResponse.java @@ -0,0 +1,16 @@ +package com.amcamp.domain.team.dto.response; + +import com.amcamp.domain.team.domain.Team; +import io.swagger.v3.oas.annotations.media.Schema; + +public record TeamInfoResponse( + @Schema(description = "팀 아이디", example = "1") Long teamId, + @Schema(description = "팀 이름", example = "Side Effect") String teamName, + @Schema(description = "팀 설명", example = "Lg cns am camp 1기 개발 스터디") String teamDescription, + @Schema(description = "팀 이모지", example = "🍇") String teamEmoji) { + + public static TeamInfoResponse from(Team team) { + return new TeamInfoResponse( + team.getId(), team.getName(), team.getDescription(), team.getEmoji()); + } +} diff --git a/src/main/java/com/amcamp/domain/team/dto/response/TeamInviteCodeResponse.java b/src/main/java/com/amcamp/domain/team/dto/response/TeamInviteCodeResponse.java new file mode 100644 index 00000000..a6f2c2a8 --- /dev/null +++ b/src/main/java/com/amcamp/domain/team/dto/response/TeamInviteCodeResponse.java @@ -0,0 +1,6 @@ +package com.amcamp.domain.team.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TeamInviteCodeResponse( + @Schema(description = "초대 코드", example = "FFbKeJvJ") String inviteCode) {} diff --git a/src/main/java/com/amcamp/global/common/constants/RedisConstants.java b/src/main/java/com/amcamp/global/common/constants/RedisConstants.java new file mode 100644 index 00000000..0bf5c898 --- /dev/null +++ b/src/main/java/com/amcamp/global/common/constants/RedisConstants.java @@ -0,0 +1,13 @@ +package com.amcamp.global.common.constants; + +import java.security.SecureRandom; +import java.util.Base64; + +public final class RedisConstants { + public static final SecureRandom secureRandom = new SecureRandom(); + public static final Base64.Encoder base64Encoder = Base64.getUrlEncoder(); + + public static final String TEAM_ID_PREFIX = "teamId=%d"; + public static final String INVITE_CODE_PREFIX = "inviteCode=%s"; + public static final long expirationTime = 24; +} diff --git a/src/main/java/com/amcamp/global/common/constants/SecurityConstants.java b/src/main/java/com/amcamp/global/common/constants/SecurityConstants.java new file mode 100644 index 00000000..9bb3dcb0 --- /dev/null +++ b/src/main/java/com/amcamp/global/common/constants/SecurityConstants.java @@ -0,0 +1,20 @@ +package com.amcamp.global.common.constants; + +public final class SecurityConstants { + public static final String TOKEN_ROLE_NAME = "role"; + public static final String TOKEN_PREFIX = "Bearer "; + + public static final String KAKAO_LOGIN_URL = "https://kauth.kakao.com"; + public static final String KAKAO_LOGIN_ENDPOINT = "/oauth/token"; + + public static final String GOOGLE_LOGIN_URL = "https://oauth2.googleapis.com"; + public static final String GOOGLE_LOGIN_ENDPOINT = "/token"; + + public static final String KAKAO_JWK_SET_URL = "https://kauth.kakao.com/.well-known/jwks.json"; + public static final String KAKAO_ISSUER = "https://kauth.kakao.com"; + + public static final String GOOGLE_JWK_SET_URL = "https://www.googleapis.com/oauth2/v3/certs"; + public static final String GOOGLE_ISSUER = "https://accounts.google.com"; + + public static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; +} diff --git a/src/main/java/com/amcamp/global/common/constants/UrlConstants.java b/src/main/java/com/amcamp/global/common/constants/UrlConstants.java new file mode 100644 index 00000000..0a5fda99 --- /dev/null +++ b/src/main/java/com/amcamp/global/common/constants/UrlConstants.java @@ -0,0 +1,13 @@ +package com.amcamp.global.common.constants; + +public final class UrlConstants { + public static final String PROD_SERVER_URL = "https://api.devfit.site"; + public static final String DEV_SERVER_URL = "https://dev-api.devfit.site"; + public static final String LOCAL_SERVER_URL = "http://localhost:8080"; + + public static final String PROD_DOMAIN_URL = "https://www.devfit.site"; + public static final String DEV_DOMAIN_URL = "https://www.dev.devfit.site"; + public static final String LOCAL_DOMAIN_URL = "http://localhost:3000"; + + public static final String IMAGE_URL = "https://devfit-bucket.s3.ap-northeast-2.amazonaws.com"; +} diff --git a/src/main/java/com/amcamp/global/common/response/CommonResponse.java b/src/main/java/com/amcamp/global/common/response/CommonResponse.java new file mode 100644 index 00000000..07e4a7d9 --- /dev/null +++ b/src/main/java/com/amcamp/global/common/response/CommonResponse.java @@ -0,0 +1,36 @@ +package com.amcamp.global.common.response; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Data +public class CommonResponse { + private boolean success; + private int status; + private T data; + private LocalDateTime timestamp; + + public static CommonResponse onSuccess(int status, T data) { + return CommonResponse.builder() + .success(true) + .status(status) + .data(data) + .timestamp(LocalDateTime.now()) + .build(); + } + + public static CommonResponse onFailure(int status, T data) { + return CommonResponse.builder() + .success(false) + .status(status) + .data(data) + .timestamp(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/amcamp/global/common/response/CommonResponseAdvice.java b/src/main/java/com/amcamp/global/common/response/CommonResponseAdvice.java new file mode 100644 index 00000000..1063ae80 --- /dev/null +++ b/src/main/java/com/amcamp/global/common/response/CommonResponseAdvice.java @@ -0,0 +1,43 @@ +package com.amcamp.global.common.response; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +@RestControllerAdvice(basePackages = "com.amcamp") +public class CommonResponseAdvice implements ResponseBodyAdvice { + @Override + public boolean supports(MethodParameter returnType, Class converterType) { + return true; // 어떤 응답을 가로채서 반환할 것인지 -> 모든 응답 가로채기 + } + + @Override + public Object beforeBodyWrite( + Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class selectedConverterType, + ServerHttpRequest request, + ServerHttpResponse response) { + HttpServletResponse httpServletResponse = + ((ServletServerHttpResponse) response).getServletResponse(); + int status = httpServletResponse.getStatus(); + HttpStatus resolve = HttpStatus.resolve(status); + + if (resolve == null || body instanceof String) { + return body; + } + + if (resolve.is2xxSuccessful()) { + return CommonResponse.onSuccess(status, body); + } + + return body; + } +} diff --git a/src/main/java/com/amcamp/global/config/feign/FeignConfig.java b/src/main/java/com/amcamp/global/config/feign/FeignConfig.java new file mode 100644 index 00000000..fae27474 --- /dev/null +++ b/src/main/java/com/amcamp/global/config/feign/FeignConfig.java @@ -0,0 +1,30 @@ +package com.amcamp.global.config.feign; + +import feign.RequestInterceptor; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableFeignClients(basePackages = "com.amcamp.infra.config.feign") +public class FeignConfig { + + @Bean + public RequestInterceptor requestInterceptor() { + return requestTemplate -> { + if (requestTemplate.url().contains("kakao") + || requestTemplate.url().contains("google")) { + requestTemplate.header( + "Content-Type", "application/x-www-form-urlencoded;charset=utf-8"); + + if (requestTemplate.body() == null) { + requestTemplate.body(""); + } + } + + if (requestTemplate.url().contains("openai")) { + requestTemplate.header("Content-Type", "application/json"); + } + }; + } +} diff --git a/src/main/java/com/amcamp/global/config/querydsl/QueryDslConfig.java b/src/main/java/com/amcamp/global/config/querydsl/QueryDslConfig.java new file mode 100644 index 00000000..b4beaa42 --- /dev/null +++ b/src/main/java/com/amcamp/global/config/querydsl/QueryDslConfig.java @@ -0,0 +1,18 @@ +package com.amcamp.global.config.querydsl; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/amcamp/global/config/scheduler/SchedulerConfig.java b/src/main/java/com/amcamp/global/config/scheduler/SchedulerConfig.java new file mode 100644 index 00000000..adf43e92 --- /dev/null +++ b/src/main/java/com/amcamp/global/config/scheduler/SchedulerConfig.java @@ -0,0 +1,8 @@ +package com.amcamp.global.config.scheduler; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@Configuration +public class SchedulerConfig {} diff --git a/src/main/java/com/amcamp/global/config/security/WebSecurityConfig.java b/src/main/java/com/amcamp/global/config/security/WebSecurityConfig.java new file mode 100644 index 00000000..766fa101 --- /dev/null +++ b/src/main/java/com/amcamp/global/config/security/WebSecurityConfig.java @@ -0,0 +1,141 @@ +package com.amcamp.global.config.security; + +import static org.springframework.http.HttpHeaders.SET_COOKIE; +import static org.springframework.security.config.Customizer.withDefaults; + +import com.amcamp.domain.auth.application.JwtTokenService; +import com.amcamp.global.common.constants.UrlConstants; +import com.amcamp.global.helper.SpringEnvironmentHelper; +import com.amcamp.global.security.JwtAuthenticationFilter; +import com.amcamp.global.util.CookieUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +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.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class WebSecurityConfig { + + private final SpringEnvironmentHelper springEnvironmentHelper; + private final JwtTokenService jwtTokenService; + private final CookieUtil cookieUtil; + + @Value("${swagger.username}") + private String swaggerUsername; + + @Value("${swagger.password}") + private String swaggerPassword; + + private void defaultFilterChain(HttpSecurity http) throws Exception { + http.httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .cors(withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + } + + @Bean + public InMemoryUserDetailsManager inMemoryUserDetailsManager() { + UserDetails user = + User.withUsername(swaggerUsername) + .password(passwordEncoder().encode(swaggerPassword)) + .roles("SWAGGER") + .build(); + + return new InMemoryUserDetailsManager(user); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + @Order(1) + @Profile({"prod", "dev", "local"}) + public SecurityFilterChain swaggerFilterChain(HttpSecurity http) throws Exception { + defaultFilterChain(http); + + http.securityMatcher("/swagger-ui/**", "/v3/api-docs/**").httpBasic(withDefaults()); + + http.authorizeHttpRequests( + springEnvironmentHelper.isProdProfile() || springEnvironmentHelper.isDevProfile() + ? auth -> auth.anyRequest().authenticated() + : auth -> auth.anyRequest().permitAll()); + + return http.build(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + defaultFilterChain(http); + + http.authorizeHttpRequests( + auth -> + auth.requestMatchers("/devfit-actuator/**") + .permitAll() + .requestMatchers("/feedbacks/**") + .authenticated() + .requestMatchers("/**") + .permitAll() + .requestMatchers("/feedbacks/**") + .authenticated() + .anyRequest() + .authenticated()); + + http.addFilterBefore( + jwtAuthenticationFilter(jwtTokenService, cookieUtil), + UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + if (springEnvironmentHelper.isProdProfile()) { + configuration.addAllowedOriginPattern(UrlConstants.PROD_DOMAIN_URL); + } + + if (springEnvironmentHelper.isDevProfile()) { + configuration.addAllowedOriginPattern(UrlConstants.DEV_DOMAIN_URL); + configuration.addAllowedOriginPattern(UrlConstants.LOCAL_DOMAIN_URL); + } + + configuration.addAllowedHeader("*"); + configuration.addAllowedMethod("*"); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + configuration.addExposedHeader(SET_COOKIE); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter( + JwtTokenService jwtTokenService, CookieUtil cookieUtil) { + return new JwtAuthenticationFilter(jwtTokenService, cookieUtil); + } +} diff --git a/src/main/java/com/amcamp/global/config/swagger/SwaggerConfig.java b/src/main/java/com/amcamp/global/config/swagger/SwaggerConfig.java new file mode 100644 index 00000000..dd65c010 --- /dev/null +++ b/src/main/java/com/amcamp/global/config/swagger/SwaggerConfig.java @@ -0,0 +1,72 @@ +package com.amcamp.global.config.swagger; + +import com.amcamp.global.common.constants.UrlConstants; +import com.amcamp.global.helper.SpringEnvironmentHelper; +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; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +@Slf4j +public class SwaggerConfig { + + private final SpringEnvironmentHelper springEnvironmentHelper; + + @Bean + public GroupedOpenApi publicApi() { + return GroupedOpenApi.builder().group("v1").pathsToMatch("/**").build(); + } + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info( + new Info() + .title("DevFit Server API") + .description("DevFit Server API 명세서입니다.") + .version("v0.0.1")) + .servers(getSwaggerServers()) + .components(authSetting()) + .addSecurityItem(securityRequirement()); + } + + private List getSwaggerServers() { + return List.of(new Server().url(getServerUrlByProfile())); + } + + private String getServerUrlByProfile() { + return switch (springEnvironmentHelper.getCurrentProfile()) { + case "prod" -> UrlConstants.PROD_SERVER_URL; + case "dev" -> UrlConstants.DEV_SERVER_URL; + default -> UrlConstants.LOCAL_SERVER_URL; + }; + } + + private Components authSetting() { + return new Components() + .addSecuritySchemes( + "accessToken", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization")); + } + + private SecurityRequirement securityRequirement() { + SecurityRequirement securityRequirement = new SecurityRequirement(); + securityRequirement.addList("accessToken"); + return securityRequirement; + } +} diff --git a/src/main/java/com/amcamp/global/exception/CommonException.java b/src/main/java/com/amcamp/global/exception/CommonException.java new file mode 100644 index 00000000..7cbb215c --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/CommonException.java @@ -0,0 +1,15 @@ +package com.amcamp.global.exception; + +import com.amcamp.global.exception.errorcode.BaseErrorCode; +import lombok.Getter; + +@Getter +public class CommonException extends RuntimeException { + + private final BaseErrorCode errorCode; + + public CommonException(BaseErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/amcamp/global/exception/ErrorResponse.java b/src/main/java/com/amcamp/global/exception/ErrorResponse.java new file mode 100644 index 00000000..c7c5774f --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/ErrorResponse.java @@ -0,0 +1,7 @@ +package com.amcamp.global.exception; + +public record ErrorResponse(String errorClassName, String message) { + public static ErrorResponse of(String errorClassName, String message) { + return new ErrorResponse(errorClassName, message); + } +} diff --git a/src/main/java/com/amcamp/global/exception/GlobalExceptionHandler.java b/src/main/java/com/amcamp/global/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..891f0710 --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,57 @@ +package com.amcamp.global.exception; + +import com.amcamp.global.common.response.CommonResponse; +import com.amcamp.global.exception.errorcode.BaseErrorCode; +import com.amcamp.global.exception.errorcode.GlobalErrorCode; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@RestControllerAdvice(basePackages = "com.amcamp") +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + @ExceptionHandler(CommonException.class) + public ResponseEntity handleCustomException(CommonException e) { + final BaseErrorCode errorCode = e.getErrorCode(); + final ErrorResponse errorResponse = + ErrorResponse.of(errorCode.errorClassName(), errorCode.getMessage()); + final CommonResponse response = + CommonResponse.onFailure(errorCode.getHttpStatus().value(), errorResponse); + + return ResponseEntity.status(errorCode.getHttpStatus()).body(response); + } + + @SneakyThrows + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + final String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + final ErrorResponse errorResponse = + ErrorResponse.of(e.getClass().getSimpleName(), errorMessage); + final CommonResponse response = + CommonResponse.onFailure(HttpStatus.BAD_REQUEST.value(), errorResponse); + + return ResponseEntity.status(status).body(response); + } + + @ExceptionHandler(Exception.class) + protected ResponseEntity handleException(Exception e) { + final BaseErrorCode errorCode = GlobalErrorCode.INTERNAL_SERVER_ERROR; + final ErrorResponse errorResponse = + ErrorResponse.of(e.getClass().getSimpleName(), errorCode.getMessage()); + final CommonResponse response = + CommonResponse.onFailure(errorCode.getHttpStatus().value(), errorResponse); + + return ResponseEntity.status(errorCode.getHttpStatus().value()).body(response); + } +} diff --git a/src/main/java/com/amcamp/global/exception/errorcode/AuthErrorCode.java b/src/main/java/com/amcamp/global/exception/errorcode/AuthErrorCode.java new file mode 100644 index 00000000..a816a636 --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/errorcode/AuthErrorCode.java @@ -0,0 +1,22 @@ +package com.amcamp.global.exception.errorcode; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AuthErrorCode implements BaseErrorCode { + ID_TOKEN_VERIFICATION_FAILED(HttpStatus.UNAUTHORIZED, "ID 토큰 검증에 실패했습니다."), + + AUTH_NOT_FOUND(HttpStatus.UNAUTHORIZED, "사용자 인증 정보를 찾을 수 없습니다. 올바른 토큰으로 요청해주세요."), + ; + + private final HttpStatus httpStatus; + private final String message; + + @Override + public String errorClassName() { + return this.name(); + } +} diff --git a/src/main/java/com/amcamp/global/exception/errorcode/BaseErrorCode.java b/src/main/java/com/amcamp/global/exception/errorcode/BaseErrorCode.java new file mode 100644 index 00000000..51a8a0ac --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/errorcode/BaseErrorCode.java @@ -0,0 +1,11 @@ +package com.amcamp.global.exception.errorcode; + +import org.springframework.http.HttpStatus; + +public interface BaseErrorCode { + HttpStatus getHttpStatus(); + + String getMessage(); + + String errorClassName(); +} diff --git a/src/main/java/com/amcamp/global/exception/errorcode/ContributionErrorCode.java b/src/main/java/com/amcamp/global/exception/errorcode/ContributionErrorCode.java new file mode 100644 index 00000000..2d274be3 --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/errorcode/ContributionErrorCode.java @@ -0,0 +1,19 @@ +package com.amcamp.global.exception.errorcode; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ContributionErrorCode implements BaseErrorCode { + CONTRIBUTION_NOT_FOUND(HttpStatus.NOT_FOUND, "기여도를 찾을 수 없습니다."); + + private final HttpStatus httpStatus; + private final String message; + + @Override + public String errorClassName() { + return this.name(); + } +} diff --git a/src/main/java/com/amcamp/global/exception/errorcode/FeedbackErrorCode.java b/src/main/java/com/amcamp/global/exception/errorcode/FeedbackErrorCode.java new file mode 100644 index 00000000..3b0c1d72 --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/errorcode/FeedbackErrorCode.java @@ -0,0 +1,31 @@ +package com.amcamp.global.exception.errorcode; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum FeedbackErrorCode implements BaseErrorCode { + RECEIVER_NOT_FOUND(HttpStatus.NOT_FOUND, "피드백을 받을 프로젝트 참여자를 찾을 수 없습니다."), + + INVALID_PROJECT_PARTICIPANT(HttpStatus.BAD_REQUEST, "같은 프로젝트에 속한 사람에게만 피드백을 보낼 수 있습니다."), + CANNOT_SEND_FEEDBACK_TO_SELF(HttpStatus.BAD_REQUEST, "본인에게 피드백을 보낼 수 없습니다."), + + FEEDBACK_ALREADY_SENT(HttpStatus.CONFLICT, "이미 해당 사용자에게 피드백을 보냈습니다."), + + FEEDBACK_DUE_DATE_ONLY(HttpStatus.BAD_REQUEST, "스프린트 마감 당일에만 피드백을 전송할 수 있습니다."), + + FEEDBACK_NOT_EXISTS(HttpStatus.NOT_FOUND, "해당 스프린트에서 받은 피드백이 존재하지 않습니다."), + + PARTICIPANT_IS_UNKNOWN(HttpStatus.BAD_REQUEST, "프로젝트에서 나간 사용자에게는 피드백을 전송할 수 없습니다."), + ; + + private final HttpStatus httpStatus; + private final String message; + + @Override + public String errorClassName() { + return this.name(); + } +} diff --git a/src/main/java/com/amcamp/global/exception/errorcode/GlobalErrorCode.java b/src/main/java/com/amcamp/global/exception/errorcode/GlobalErrorCode.java new file mode 100644 index 00000000..a9865870 --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/errorcode/GlobalErrorCode.java @@ -0,0 +1,20 @@ +package com.amcamp.global.exception.errorcode; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum GlobalErrorCode implements BaseErrorCode { + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류입니다. 관리자에게 문의해주세요."), + ; + + private final HttpStatus httpStatus; + private final String message; + + @Override + public String errorClassName() { + return this.name(); + } +} diff --git a/src/main/java/com/amcamp/global/exception/errorcode/ImageErrorCode.java b/src/main/java/com/amcamp/global/exception/errorcode/ImageErrorCode.java new file mode 100644 index 00000000..2944888a --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/errorcode/ImageErrorCode.java @@ -0,0 +1,20 @@ +package com.amcamp.global.exception.errorcode; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ImageErrorCode implements BaseErrorCode { + IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "이미지를 찾을 수 없습니다."), + ; + + private final HttpStatus httpStatus; + private final String message; + + @Override + public String errorClassName() { + return this.name(); + } +} diff --git a/src/main/java/com/amcamp/global/exception/errorcode/MeetingErrorCode.java b/src/main/java/com/amcamp/global/exception/errorcode/MeetingErrorCode.java new file mode 100644 index 00000000..3deda00d --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/errorcode/MeetingErrorCode.java @@ -0,0 +1,22 @@ +package com.amcamp.global.exception.errorcode; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum MeetingErrorCode implements BaseErrorCode { + MEETING_NOT_FOUND(HttpStatus.NOT_FOUND, "팀 미팅을 찾을 수 없습니다."), + UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), + INVALID_MEETING_TIME_RANGE(HttpStatus.BAD_REQUEST, "유효한 범위 내의 시간을 입력해야합니다."), + MEETING_DATE_OUT_OF_SPRINT(HttpStatus.BAD_REQUEST, "팀 미팅은 스프린트 기간 내에 생성되어야 합니다."), + MEETING_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 해당 시간대에 일정이 존재합니다."); + private final HttpStatus httpStatus; + private final String message; + + @Override + public String errorClassName() { + return this.name(); + } +} diff --git a/src/main/java/com/amcamp/global/exception/errorcode/MemberErrorCode.java b/src/main/java/com/amcamp/global/exception/errorcode/MemberErrorCode.java new file mode 100644 index 00000000..231624c3 --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/errorcode/MemberErrorCode.java @@ -0,0 +1,22 @@ +package com.amcamp.global.exception.errorcode; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum MemberErrorCode implements BaseErrorCode { + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."), + + MEMBER_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 탈퇴한 회원입니다."), + ; + + private final HttpStatus httpStatus; + private final String message; + + @Override + public String errorClassName() { + return this.name(); + } +} diff --git a/src/main/java/com/amcamp/global/exception/errorcode/ProjectErrorCode.java b/src/main/java/com/amcamp/global/exception/errorcode/ProjectErrorCode.java new file mode 100644 index 00000000..00812112 --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/errorcode/ProjectErrorCode.java @@ -0,0 +1,33 @@ +package com.amcamp.global.exception.errorcode; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ProjectErrorCode implements BaseErrorCode { + PROJECT_NOT_FOUND(HttpStatus.NOT_FOUND, "project 를 찾을 수 없습니다."), + PROJECT_PARTICIPANT_LIMIT_EXCEED(HttpStatus.BAD_REQUEST, "project 별 최대 참여 가능 참여자 수를 초과했습니다."), + PROJECT_PARTICIPATION_REQUIRED(HttpStatus.FORBIDDEN, "해당 프로젝트 참여자가 아닙니다."), + UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), + PROJECT_PARTICIPANT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 참여중인 프로젝트 참가자입니다."), + PROJECT_ADMIN_CANNOT_LEAVE( + HttpStatus.FORBIDDEN, "프로젝트 Admin은 다른 팀원에게 권한을 넘긴 후에만 프로젝트를 나갈 수 있습니다."), + PROJECT_REGISTRATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 프로젝트 가입 요청이 생성되어 있습니다"), + PROJECT_REGISTRATION_NOT_FOUND(HttpStatus.NOT_FOUND, "project 가입 요청을 찾을 수 없습니다."), + PROJECT_SPRINT_MISMATCH(HttpStatus.FORBIDDEN, "요청한 스프린트는 현재 참여 중인 프로젝트의 스프린트가 아닙니다."), + + PROJECT_PARTICIPANT_NOT_EXISTS(HttpStatus.NOT_FOUND, "프로젝트 참가자가 존재하지 않습니다."), + + PROJECT_DUE_DATE_BEFORE_START(HttpStatus.BAD_REQUEST, "프로젝트 마감일자는 시작일자 이후여야 합니다."); + ; + + private final HttpStatus httpStatus; + private final String message; + + @Override + public String errorClassName() { + return this.name(); + } +} diff --git a/src/main/java/com/amcamp/global/exception/errorcode/SprintErrorCode.java b/src/main/java/com/amcamp/global/exception/errorcode/SprintErrorCode.java new file mode 100644 index 00000000..cb44d430 --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/errorcode/SprintErrorCode.java @@ -0,0 +1,36 @@ +package com.amcamp.global.exception.errorcode; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SprintErrorCode implements BaseErrorCode { + SPRINT_NOT_FOUND(HttpStatus.NOT_FOUND, "스프린트를 찾을 수 없습니다."), + SPRINT_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "스프린트 삭제 권한이 없습니다."), + + TASK_NOT_CREATED_YET(HttpStatus.NOT_FOUND, "스프린트 내 태스크가 존재하지 않습니다."), + + SPRINT_DUE_DATE_BEFORE_START(HttpStatus.BAD_REQUEST, "스프린트 마감일자는 시작일자 이후여야 합니다."), + SPRINT_DUE_DATE_EXCEEDS_PROJECT_END(HttpStatus.BAD_REQUEST, "스프린트 마감일은 프로젝트 마감일 이내여야 합니다."), + PREVIOUS_SPRINT_NOT_ENDED(HttpStatus.BAD_REQUEST, "이전 스프린트가 아직 종료되지 않았습니다."), + SPRINT_DUE_DATE_CONFLICT_WITH_NEXT( + HttpStatus.BAD_REQUEST, "다음 스프린트가 존재할 경우, 마감일은 그 이전이어야 합니다."), + + INVALID_PAGING_REQUEST( + HttpStatus.BAD_REQUEST, "baseSprintId와 direction은 함께 전달되어야 합니다. 첫 요청 시에는 둘 다 생략하세요."), + + SPRINT_NOT_EXISTS(HttpStatus.NOT_FOUND, "스프린트가 존재하지 않습니다."), + NEXT_SPRINT_NOT_EXISTS(HttpStatus.NOT_FOUND, "다음 스프린트가 존재하지 않습니다."), + PREV_SPRINT_NOT_EXISTS(HttpStatus.NOT_FOUND, "이전 스프린트가 존재하지 않습니다."), + ; + + private final HttpStatus httpStatus; + private final String message; + + @Override + public String errorClassName() { + return this.name(); + } +} diff --git a/src/main/java/com/amcamp/global/exception/errorcode/TaskErrorCode.java b/src/main/java/com/amcamp/global/exception/errorcode/TaskErrorCode.java new file mode 100644 index 00000000..b3ada9e2 --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/errorcode/TaskErrorCode.java @@ -0,0 +1,24 @@ +package com.amcamp.global.exception.errorcode; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum TaskErrorCode implements BaseErrorCode { + TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "task를 찾을 수 없습니다."), + TASK_MODIFY_FORBIDDEN(HttpStatus.FORBIDDEN, "task 수정·삭제 권한이 없습니다. "), + TASK_ALREADY_ASSIGNED(HttpStatus.FORBIDDEN, "이미 담당자가 존재하는 task입니다."), + TASK_NOT_ASSIGNED(HttpStatus.FORBIDDEN, "아직 할당되지 않은 task입니다."), + TASK_ASSIGN_FORBIDDEN(HttpStatus.BAD_REQUEST, "본인에게 task를 할당할 수 없습니다"), + TASK_COMPLETE_FORBIDDEN(HttpStatus.BAD_REQUEST, "SOS 상태인 task는 완료 처리할 수 없습니다."); + + private final HttpStatus httpStatus; + private final String message; + + @Override + public String errorClassName() { + return this.name(); + } +} diff --git a/src/main/java/com/amcamp/global/exception/errorcode/TeamErrorCode.java b/src/main/java/com/amcamp/global/exception/errorcode/TeamErrorCode.java new file mode 100644 index 00000000..01022e4f --- /dev/null +++ b/src/main/java/com/amcamp/global/exception/errorcode/TeamErrorCode.java @@ -0,0 +1,26 @@ +package com.amcamp.global.exception.errorcode; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum TeamErrorCode implements BaseErrorCode { + TEAM_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 팀을 찾을 수 없습니다."), + MEMBER_ALREADY_JOINED(HttpStatus.BAD_REQUEST, "이미 이 팀에 참가한 회원입니다."), + TEAM_PARTICIPANT_REQUIRED(HttpStatus.FORBIDDEN, "팀 참여자가 아닙니다."), + INVALID_INVITE_CODE(HttpStatus.NOT_FOUND, "팀이 존재하지 않거나 코드가 만료되었습니다."), + UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED, "수정 권한이 없습니다."), + + TEAM_NOT_EXISTS(HttpStatus.NOT_FOUND, "회원이 참여한 팀이 존재하지 않습니다."), + ; + + private final HttpStatus httpStatus; + private final String message; + + @Override + public String errorClassName() { + return this.name(); + } +} diff --git a/src/main/java/com/amcamp/global/helper/SpringEnvironmentHelper.java b/src/main/java/com/amcamp/global/helper/SpringEnvironmentHelper.java new file mode 100644 index 00000000..46c96ee3 --- /dev/null +++ b/src/main/java/com/amcamp/global/helper/SpringEnvironmentHelper.java @@ -0,0 +1,37 @@ +package com.amcamp.global.helper; + +import java.util.Arrays; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SpringEnvironmentHelper { + + private final Environment environment; + + public static final String PROD = "prod"; + public static final String DEV = "dev"; + public static final String LOCAL = "local"; + + public String getCurrentProfile() { + return getActiveProfiles() + .filter(profile -> profile.equals(PROD) || profile.equals(DEV)) + .findFirst() + .orElse(LOCAL); + } + + public Boolean isProdProfile() { + return getActiveProfiles().anyMatch(PROD::equals); + } + + public Boolean isDevProfile() { + return getActiveProfiles().anyMatch(DEV::equals); + } + + private Stream getActiveProfiles() { + return Arrays.stream(environment.getActiveProfiles()); + } +} diff --git a/src/main/java/com/amcamp/global/security/JwtAuthenticationFilter.java b/src/main/java/com/amcamp/global/security/JwtAuthenticationFilter.java new file mode 100644 index 00000000..aa643867 --- /dev/null +++ b/src/main/java/com/amcamp/global/security/JwtAuthenticationFilter.java @@ -0,0 +1,107 @@ +package com.amcamp.global.security; + +import static com.amcamp.global.common.constants.SecurityConstants.REFRESH_TOKEN_COOKIE_NAME; +import static com.amcamp.global.common.constants.SecurityConstants.TOKEN_PREFIX; + +import com.amcamp.domain.auth.application.JwtTokenService; +import com.amcamp.domain.auth.dto.AccessTokenDto; +import com.amcamp.domain.auth.dto.RefreshTokenDto; +import com.amcamp.domain.member.domain.MemberRole; +import com.amcamp.global.util.CookieUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.WebUtils; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenService jwtTokenService; + private final CookieUtil cookieUtil; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String accessTokenHeaderValue = extractAccessTokenFromHeader(request); + String refreshTokenCookieValue = extractRefreshTokenFromCookie(request); + + // 헤더에 AT가 있으면 우선적으로 검증 + if (accessTokenHeaderValue != null) { + AccessTokenDto accessTokenDto = + jwtTokenService.retrieveAccessToken(accessTokenHeaderValue); + + // AT가 유효하면 통과 + if (accessTokenDto != null) { + setAuthenticationToken(accessTokenDto.memberId(), accessTokenDto.role()); + + filterChain.doFilter(request, response); + return; + } + } + + // AT가 유효하지 않다면 RT 파싱 + RefreshTokenDto refreshTokenDto = + jwtTokenService.retrieveRefreshToken(refreshTokenCookieValue); + + // RT가 유효하면 AT, RT 재발급 + if (refreshTokenDto != null) { + AccessTokenDto reissueAccessTokenDto = + jwtTokenService.reissueAccessTokenIfExpired(accessTokenHeaderValue); + RefreshTokenDto reissueRefreshTokenDto = + jwtTokenService.createRefreshTokenDto(refreshTokenDto.memberId()); + + HttpHeaders headers = + cookieUtil.generateRefreshTokenCookie( + reissueRefreshTokenDto.refreshTokenValue()); + + response.addHeader( + HttpHeaders.AUTHORIZATION, + TOKEN_PREFIX + reissueAccessTokenDto.accessTokenValue()); + response.addHeader(HttpHeaders.SET_COOKIE, headers.getFirst(HttpHeaders.SET_COOKIE)); + } + + // AT, RT가 모두 만료된 경우 실패 + filterChain.doFilter(request, response); + } + + private void setAuthenticationToken(Long memberId, MemberRole memberRole) { + UserDetails userDetails = new PrincipalDetails(memberId, memberRole); + + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(token); + } + + private String extractAccessTokenFromHeader(HttpServletRequest request) { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (header != null && header.startsWith(TOKEN_PREFIX)) { + return header.replace(TOKEN_PREFIX, ""); + } + + return null; + } + + private String extractRefreshTokenFromCookie(HttpServletRequest request) { + Cookie cookie = WebUtils.getCookie(request, REFRESH_TOKEN_COOKIE_NAME); + + if (cookie != null) { + return cookie.getValue(); + } + + return null; + } +} diff --git a/src/main/java/com/amcamp/global/security/PrincipalDetails.java b/src/main/java/com/amcamp/global/security/PrincipalDetails.java new file mode 100644 index 00000000..7df2c4f1 --- /dev/null +++ b/src/main/java/com/amcamp/global/security/PrincipalDetails.java @@ -0,0 +1,51 @@ +package com.amcamp.global.security; + +import com.amcamp.domain.member.domain.MemberRole; +import java.util.Collection; +import java.util.List; +import lombok.AllArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@AllArgsConstructor +public class PrincipalDetails implements UserDetails { + + private final Long memberId; + private final MemberRole memberRole; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(memberRole.name())); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return memberId.toString(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/amcamp/global/util/CookieUtil.java b/src/main/java/com/amcamp/global/util/CookieUtil.java new file mode 100644 index 00000000..c1a34650 --- /dev/null +++ b/src/main/java/com/amcamp/global/util/CookieUtil.java @@ -0,0 +1,59 @@ +package com.amcamp.global.util; + +import static com.amcamp.global.common.constants.SecurityConstants.REFRESH_TOKEN_COOKIE_NAME; + +import com.amcamp.global.helper.SpringEnvironmentHelper; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.server.Cookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CookieUtil { + + private final SpringEnvironmentHelper springEnvironmentHelper; + + public HttpHeaders generateRefreshTokenCookie(String refreshToken) { + ResponseCookie refreshTokenCookie = + ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken) + .path("/") + // .secure(true) + // .sameSite(determineSameSitePolicy()) + .secure(false) + .sameSite(Cookie.SameSite.NONE.attributeValue()) + .httpOnly(true) + .build(); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + + return headers; + } + + public HttpHeaders deleteRefreshTokenCookie() { + ResponseCookie refreshTokenCookie = + ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, "") + .path("/") + .maxAge(0) + // .secure(true) + // .sameSite(determineSameSitePolicy()) + .secure(false) + .sameSite(Cookie.SameSite.NONE.attributeValue()) + .httpOnly(true) + .build(); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + + return headers; + } + + private String determineSameSitePolicy() { + if (springEnvironmentHelper.isProdProfile()) { + return Cookie.SameSite.STRICT.attributeValue(); + } + return Cookie.SameSite.NONE.attributeValue(); + } +} diff --git a/src/main/java/com/amcamp/global/util/JwtUtil.java b/src/main/java/com/amcamp/global/util/JwtUtil.java new file mode 100644 index 00000000..31d0ff8c --- /dev/null +++ b/src/main/java/com/amcamp/global/util/JwtUtil.java @@ -0,0 +1,127 @@ +package com.amcamp.global.util; + +import static com.amcamp.global.common.constants.SecurityConstants.TOKEN_ROLE_NAME; + +import com.amcamp.domain.auth.dto.AccessTokenDto; +import com.amcamp.domain.auth.dto.RefreshTokenDto; +import com.amcamp.domain.member.domain.MemberRole; +import com.amcamp.infra.config.jwt.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtUtil { + + private final JwtProperties jwtProperties; + + public AccessTokenDto generateAccessTokenDto(Long memberId, MemberRole memberRole) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.accessTokenExpirationMilliTime()); + String accessTokenValue = buildAccessToken(memberId, memberRole, issuedAt, expiredAt); + return AccessTokenDto.of(memberId, memberRole, accessTokenValue); + } + + public String generateAccessToken(Long memberId, MemberRole memberRole) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.accessTokenExpirationMilliTime()); + return buildAccessToken(memberId, memberRole, issuedAt, expiredAt); + } + + public RefreshTokenDto generateRefreshTokenDto(Long memberId) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.refreshTokenExpirationMilliTime()); + String refreshTokenValue = buildRefreshToken(memberId, issuedAt, expiredAt); + return RefreshTokenDto.of( + memberId, refreshTokenValue, jwtProperties.refreshTokenExpirationTime()); + } + + public String generateRefreshToken(Long memberId) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.refreshTokenExpirationMilliTime()); + return buildRefreshToken(memberId, issuedAt, expiredAt); + } + + public AccessTokenDto parseAccessToken(String accessTokenValue) throws ExpiredJwtException { + try { + Jws claims = getClaims(accessTokenValue, getAccessTokenKey()); + + return AccessTokenDto.of( + Long.parseLong(claims.getBody().getSubject()), + MemberRole.valueOf(claims.getBody().get(TOKEN_ROLE_NAME, String.class)), + accessTokenValue); + } catch (ExpiredJwtException e) { + throw e; + } catch (Exception e) { + return null; + } + } + + public RefreshTokenDto parseRefreshToken(String refreshTokenValue) throws ExpiredJwtException { + try { + Jws claims = getClaims(refreshTokenValue, getRefreshTokenKey()); + + return RefreshTokenDto.of( + Long.parseLong(claims.getBody().getSubject()), + refreshTokenValue, + jwtProperties.refreshTokenExpirationTime()); + } catch (ExpiredJwtException e) { + throw e; + } catch (Exception e) { + return null; + } + } + + private Jws getClaims(String token, Key key) { + return Jwts.parserBuilder() + .requireIssuer(jwtProperties.issuer()) + .setSigningKey(key) + .build() + .parseClaimsJws(token); + } + + public long getRefreshTokenExpirationTime() { + return jwtProperties.refreshTokenExpirationTime(); + } + + private Key getAccessTokenKey() { + return Keys.hmacShaKeyFor(jwtProperties.accessTokenSecret().getBytes()); + } + + private Key getRefreshTokenKey() { + return Keys.hmacShaKeyFor(jwtProperties.refreshTokenSecret().getBytes()); + } + + private String buildAccessToken( + Long memberId, MemberRole memberRole, Date issuedAt, Date expiredAt) { + return Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject(memberId.toString()) + .claim(TOKEN_ROLE_NAME, memberRole.name()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(getAccessTokenKey()) + .compact(); + } + + private String buildRefreshToken(Long memberId, Date issuedAt, Date expiredAt) { + return Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject(memberId.toString()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(getRefreshTokenKey()) + .compact(); + } +} diff --git a/src/main/java/com/amcamp/global/util/MemberUtil.java b/src/main/java/com/amcamp/global/util/MemberUtil.java new file mode 100644 index 00000000..86292448 --- /dev/null +++ b/src/main/java/com/amcamp/global/util/MemberUtil.java @@ -0,0 +1,42 @@ +package com.amcamp.global.util; + +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.MemberStatus; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.AuthErrorCode; +import com.amcamp.global.exception.errorcode.MemberErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberUtil { + + private final MemberRepository memberRepository; + + public Member getCurrentMember() { + Member member = + memberRepository + .findById(getCurrentMemberId()) + .orElseThrow(() -> new CommonException(MemberErrorCode.MEMBER_NOT_FOUND)); + + if (member.getStatus() == MemberStatus.DELETED) { + throw new CommonException(MemberErrorCode.MEMBER_ALREADY_DELETED); + } + + return member; + } + + private Long getCurrentMemberId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + try { + return Long.parseLong(authentication.getName()); + } catch (Exception e) { + throw new CommonException(AuthErrorCode.AUTH_NOT_FOUND); + } + } +} diff --git a/src/main/java/com/amcamp/global/util/RandomUtil.java b/src/main/java/com/amcamp/global/util/RandomUtil.java new file mode 100644 index 00000000..ca49348f --- /dev/null +++ b/src/main/java/com/amcamp/global/util/RandomUtil.java @@ -0,0 +1,32 @@ +package com.amcamp.global.util; + +import static com.amcamp.global.common.constants.RedisConstants.*; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RandomUtil { + private final RedisUtil redisUtil; + + private static String codeGenerator(int length) { + byte[] randomBytes = new byte[length]; + secureRandom.nextBytes(randomBytes); + return base64Encoder.encodeToString(randomBytes); + } + + public String generateCode(Long teamId) { + final Optional existingCode = redisUtil.getData(TEAM_ID_PREFIX.formatted(teamId)); + + if (existingCode.isEmpty()) { + String inviteCode = codeGenerator(6); + redisUtil.setDataExpire(TEAM_ID_PREFIX.formatted(teamId), inviteCode, expirationTime); + redisUtil.setDataExpire( + INVITE_CODE_PREFIX.formatted(inviteCode), teamId.toString(), expirationTime); + return inviteCode; + } + return existingCode.get(); + } +} diff --git a/src/main/java/com/amcamp/global/util/RedisUtil.java b/src/main/java/com/amcamp/global/util/RedisUtil.java new file mode 100644 index 00000000..ff09e268 --- /dev/null +++ b/src/main/java/com/amcamp/global/util/RedisUtil.java @@ -0,0 +1,32 @@ +package com.amcamp.global.util; + +import java.time.Duration; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisUtil { + private final StringRedisTemplate redisTemplate; + + public void setDataExpire(final String key, String value, final long durationHours) { + final ValueOperations valueOperations = redisTemplate.opsForValue(); + final Duration expireDuration = Duration.ofHours(durationHours); + + valueOperations.set(key, value, expireDuration); + } + + public Optional getData(final String key) { + final ValueOperations valueOperations = redisTemplate.opsForValue(); + final String value = valueOperations.get(key); + + return Optional.ofNullable(value); + } + + public void deleteData(final String key) { + redisTemplate.unlink(key); + } +} diff --git a/src/main/java/com/amcamp/infra/config/feign/GoogleOauthClient.java b/src/main/java/com/amcamp/infra/config/feign/GoogleOauthClient.java new file mode 100644 index 00000000..950a8a8f --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/feign/GoogleOauthClient.java @@ -0,0 +1,21 @@ +package com.amcamp.infra.config.feign; + +import static com.amcamp.global.common.constants.SecurityConstants.GOOGLE_LOGIN_ENDPOINT; +import static com.amcamp.global.common.constants.SecurityConstants.GOOGLE_LOGIN_URL; + +import com.amcamp.domain.auth.dto.response.IdTokenResponse; +import com.amcamp.global.config.feign.FeignConfig; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "googleOauthClient", url = GOOGLE_LOGIN_URL, configuration = FeignConfig.class) +public interface GoogleOauthClient { + @PostMapping(value = GOOGLE_LOGIN_ENDPOINT) + IdTokenResponse getIdToken( + @RequestParam("grant_type") String grantType, + @RequestParam("client_id") String clientId, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam("code") String code, + @RequestParam("client_secret") String clientSecret); +} diff --git a/src/main/java/com/amcamp/infra/config/feign/KakaoOauthClient.java b/src/main/java/com/amcamp/infra/config/feign/KakaoOauthClient.java new file mode 100644 index 00000000..66bb0fcf --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/feign/KakaoOauthClient.java @@ -0,0 +1,21 @@ +package com.amcamp.infra.config.feign; + +import static com.amcamp.global.common.constants.SecurityConstants.KAKAO_LOGIN_ENDPOINT; +import static com.amcamp.global.common.constants.SecurityConstants.KAKAO_LOGIN_URL; + +import com.amcamp.domain.auth.dto.response.IdTokenResponse; +import com.amcamp.global.config.feign.FeignConfig; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "kakaoOauthClient", url = KAKAO_LOGIN_URL, configuration = FeignConfig.class) +public interface KakaoOauthClient { + @PostMapping(value = KAKAO_LOGIN_ENDPOINT) + IdTokenResponse getIdToken( + @RequestParam("grant_type") String grantType, + @RequestParam("client_id") String clientId, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam("code") String code, + @RequestParam("client_secret") String clientSecret); +} diff --git a/src/main/java/com/amcamp/infra/config/feign/OpenAiClient.java b/src/main/java/com/amcamp/infra/config/feign/OpenAiClient.java new file mode 100644 index 00000000..9c97907a --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/feign/OpenAiClient.java @@ -0,0 +1,21 @@ +package com.amcamp.infra.config.feign; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.amcamp.domain.feedback.dto.request.ChatRequest; +import com.amcamp.domain.feedback.dto.response.ChatResponse; +import com.amcamp.global.config.feign.FeignConfig; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient( + name = "openAiClient", + url = "https://api.openai.com", + configuration = FeignConfig.class) +public interface OpenAiClient { + @PostMapping(value = "/v1/chat/completions", consumes = APPLICATION_JSON_VALUE) + ChatResponse getAiFeedback( + @RequestHeader("Authorization") String apiKey, @RequestBody ChatRequest request); +} diff --git a/src/main/java/com/amcamp/infra/config/jpa/JpaAuditingConfig.java b/src/main/java/com/amcamp/infra/config/jpa/JpaAuditingConfig.java new file mode 100644 index 00000000..513cbeec --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/jpa/JpaAuditingConfig.java @@ -0,0 +1,8 @@ +package com.amcamp.infra.config.jpa; + +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/amcamp/infra/config/jwt/JwtProperties.java b/src/main/java/com/amcamp/infra/config/jwt/JwtProperties.java new file mode 100644 index 00000000..c8eae7e9 --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/jwt/JwtProperties.java @@ -0,0 +1,19 @@ +package com.amcamp.infra.config.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + String accessTokenSecret, + String refreshTokenSecret, + Long accessTokenExpirationTime, + Long refreshTokenExpirationTime, + String issuer) { + public Long accessTokenExpirationMilliTime() { + return accessTokenExpirationTime * 1000; + } + + public Long refreshTokenExpirationMilliTime() { + return refreshTokenExpirationTime * 1000; + } +} diff --git a/src/main/java/com/amcamp/infra/config/oauth/GoogleProperties.java b/src/main/java/com/amcamp/infra/config/oauth/GoogleProperties.java new file mode 100644 index 00000000..bb57b3c8 --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/oauth/GoogleProperties.java @@ -0,0 +1,7 @@ +package com.amcamp.infra.config.oauth; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.google") +public record GoogleProperties( + String clientId, String clientSecret, String redirectUri, String grantType) {} diff --git a/src/main/java/com/amcamp/infra/config/oauth/KakaoProperties.java b/src/main/java/com/amcamp/infra/config/oauth/KakaoProperties.java new file mode 100644 index 00000000..b29e6d52 --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/oauth/KakaoProperties.java @@ -0,0 +1,7 @@ +package com.amcamp.infra.config.oauth; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.kakao") +public record KakaoProperties( + String clientId, String clientSecret, String redirectUri, String grantType) {} diff --git a/src/main/java/com/amcamp/infra/config/openai/OpenAiProperties.java b/src/main/java/com/amcamp/infra/config/openai/OpenAiProperties.java new file mode 100644 index 00000000..c9dae648 --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/openai/OpenAiProperties.java @@ -0,0 +1,6 @@ +package com.amcamp.infra.config.openai; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "openai") +public record OpenAiProperties(String apiKey, String model) {} diff --git a/src/main/java/com/amcamp/infra/config/properties/PropertiesConfig.java b/src/main/java/com/amcamp/infra/config/properties/PropertiesConfig.java new file mode 100644 index 00000000..173c2620 --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/properties/PropertiesConfig.java @@ -0,0 +1,21 @@ +package com.amcamp.infra.config.properties; + +import com.amcamp.infra.config.jwt.JwtProperties; +import com.amcamp.infra.config.oauth.GoogleProperties; +import com.amcamp.infra.config.oauth.KakaoProperties; +import com.amcamp.infra.config.openai.OpenAiProperties; +import com.amcamp.infra.config.redis.RedisProperties; +import com.amcamp.infra.config.s3.S3Properties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@EnableConfigurationProperties({ + RedisProperties.class, + GoogleProperties.class, + KakaoProperties.class, + JwtProperties.class, + S3Properties.class, + OpenAiProperties.class +}) +@Configuration +public class PropertiesConfig {} diff --git a/src/main/java/com/amcamp/infra/config/redis/RedisConfig.java b/src/main/java/com/amcamp/infra/config/redis/RedisConfig.java new file mode 100644 index 00000000..e733acac --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/redis/RedisConfig.java @@ -0,0 +1,45 @@ +package com.amcamp.infra.config.redis; + +import java.time.Duration; +import lombok.RequiredArgsConstructor; +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.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisStandaloneConfig = + new RedisStandaloneConfiguration(redisProperties.host(), redisProperties.port()); + if (!redisProperties.password().isBlank()) { + redisStandaloneConfig.setPassword(redisProperties.password()); + } + + LettuceClientConfiguration lettuceClientConfig = + LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofSeconds(1)) + .shutdownTimeout(Duration.ZERO) + .build(); + + return new LettuceConnectionFactory(redisStandaloneConfig, lettuceClientConfig); + } + + @Bean + public StringRedisTemplate redisTemplate() { + final StringRedisTemplate redisTemplate = new StringRedisTemplate(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } +} diff --git a/src/main/java/com/amcamp/infra/config/redis/RedisProperties.java b/src/main/java/com/amcamp/infra/config/redis/RedisProperties.java new file mode 100644 index 00000000..1feea301 --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/redis/RedisProperties.java @@ -0,0 +1,6 @@ +package com.amcamp.infra.config.redis; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("spring.data.redis") +public record RedisProperties(String host, int port, String password) {} diff --git a/src/main/java/com/amcamp/infra/config/s3/S3Config.java b/src/main/java/com/amcamp/infra/config/s3/S3Config.java new file mode 100644 index 00000000..b8363a60 --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/s3/S3Config.java @@ -0,0 +1,31 @@ +package com.amcamp.infra.config.s3; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class S3Config { + + private final S3Properties s3Properties; + + @Bean + public AmazonS3 s3Client() { + BasicAWSCredentials credentials = + new BasicAWSCredentials(s3Properties.accessKey(), s3Properties.secretKey()); + AwsClientBuilder.EndpointConfiguration endpointConfiguration = + new AwsClientBuilder.EndpointConfiguration( + s3Properties.endpoint(), s3Properties.region()); + + return AmazonS3ClientBuilder.standard() + .withEndpointConfiguration(endpointConfiguration) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } +} diff --git a/src/main/java/com/amcamp/infra/config/s3/S3Properties.java b/src/main/java/com/amcamp/infra/config/s3/S3Properties.java new file mode 100644 index 00000000..dfbf08db --- /dev/null +++ b/src/main/java/com/amcamp/infra/config/s3/S3Properties.java @@ -0,0 +1,7 @@ +package com.amcamp.infra.config.s3; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("cloud.aws") +public record S3Properties( + String region, String accessKey, String secretKey, String bucket, String endpoint) {} diff --git a/src/main/java/com/amcamp/infra/scheduler/ImageCleanupScheduler.java b/src/main/java/com/amcamp/infra/scheduler/ImageCleanupScheduler.java new file mode 100644 index 00000000..04e15527 --- /dev/null +++ b/src/main/java/com/amcamp/infra/scheduler/ImageCleanupScheduler.java @@ -0,0 +1,18 @@ +package com.amcamp.infra.scheduler; + +import com.amcamp.domain.image.application.ImageService; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ImageCleanupScheduler { + + private final ImageService imageService; + + @Scheduled(cron = "00 00 00 * * *", zone = "Asia/Seoul") + public void deleteAllImages() { + imageService.deleteAllImage(); + } +} diff --git a/src/main/java/com/amcamp/infra/scheduler/MeetingStatusScheduler.java b/src/main/java/com/amcamp/infra/scheduler/MeetingStatusScheduler.java new file mode 100644 index 00000000..8f075051 --- /dev/null +++ b/src/main/java/com/amcamp/infra/scheduler/MeetingStatusScheduler.java @@ -0,0 +1,18 @@ +package com.amcamp.infra.scheduler; + +import com.amcamp.domain.meeting.application.MeetingService; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MeetingStatusScheduler { + + private final MeetingService meetingService; + + @Scheduled(fixedRate = 24 * 60 * 60000) // 하루 한번 체크(시간당 한번..?) + public void updateExpiredMeetings() { + meetingService.updateExpiredMeetings(); + } +} diff --git a/src/main/resources/application-actuator.yml b/src/main/resources/application-actuator.yml new file mode 100644 index 00000000..ed1b57d5 --- /dev/null +++ b/src/main/resources/application-actuator.yml @@ -0,0 +1,18 @@ +spring: + config: + activate: + on-profile: "actuator" +management: + endpoints: + jmx: + exposure: + exclude: "*" + web: + exposure: + include: health + base-path: /devfit-actuator + access: + default: none + endpoint: + health: + access: unrestricted diff --git a/src/main/resources/application-datasource.yml b/src/main/resources/application-datasource.yml new file mode 100644 index 00000000..0470f2b0 --- /dev/null +++ b/src/main/resources/application-datasource.yml @@ -0,0 +1,16 @@ +spring: + config: + activate: + on-profile: "datasource" + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + jpa: + properties: + hibernate: + default_batch_fetch_size: 100 +logging: + level: + org.hibernate.SQL: debug diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 00000000..516b2490 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,7 @@ +spring: + config: + activate: + on-profile: "dev" + jpa: + hibernate: + ddl-auto: update diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 00000000..c8b51b6b --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,19 @@ +spring: + config: + activate: + on-profile: "local" + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:tcp://localhost/~/devfit + username: sa + password: + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + open-in-view: false +logging: + level: + org.hibernate.SQL: debug diff --git a/src/main/resources/application-openai.yml b/src/main/resources/application-openai.yml new file mode 100644 index 00000000..8c89078d --- /dev/null +++ b/src/main/resources/application-openai.yml @@ -0,0 +1,8 @@ +spring: + config: + activate: + on-profile: "openai" + +openai: + api-key: ${OPENAI_API_KEY} + model: ${OPENAI_MODEL} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..5564164c --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,7 @@ +spring: + config: + activate: + on-profile: "prod" + jpa: + hibernate: + ddl-auto: none diff --git a/src/main/resources/application-redis.yml b/src/main/resources/application-redis.yml new file mode 100644 index 00000000..0f5ac38c --- /dev/null +++ b/src/main/resources/application-redis.yml @@ -0,0 +1,9 @@ +spring: + config: + activate: + on-profile: "redis" + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} diff --git a/src/main/resources/application-s3.yml b/src/main/resources/application-s3.yml new file mode 100644 index 00000000..3fc934b7 --- /dev/null +++ b/src/main/resources/application-s3.yml @@ -0,0 +1,11 @@ +spring: + config: + activate: + on-profile: "s3" +cloud: + aws: + region: ${AWS_REGION} + accessKey: ${AWS_ACCESS_KEY} + secretKey: ${AWS_SECRET_KEY} + bucket: ${AWS_BUCKET} + endpoint: ${S3_ENDPOINT:https://s3.ap-northeast-2.amazonaws.com} diff --git a/src/main/resources/application-security.yml b/src/main/resources/application-security.yml new file mode 100644 index 00000000..fc8cdb97 --- /dev/null +++ b/src/main/resources/application-security.yml @@ -0,0 +1,18 @@ +oauth: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: ${KAKAO_REDIRECT_URI} + grant-type: ${KAKAO_CLIENT_GRANT_TYPE} + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: ${GOOGLE_REDIRECT_URI} + grant-type: ${GOOGLE_CLIENT_GRANT_TYPE} + +jwt: + access-token-secret: ${JWT_ACCESS_TOKEN_SECRET} + refresh-token-secret: ${JWT_REFRESH_TOKEN_SECRET} + access-token-expiration-time: ${JWT_ACCESS_TOKEN_EXPIRATION_TIME:7200} + refresh-token-expiration-time: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME:172800} + issuer: ${JWT_ISSUER} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..7b6df5b5 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,26 @@ +spring: + profiles: + group: + test: "test" + local: "local" + dev: "dev, datasource, actuator" + prod: "prod, datasource, actuator" + include: + - redis + - security + - s3 + - openai + +spring-doc: + default-consumes-media-type: application/json + default-produces-media-type: application/json + swagger-ui: + tags-sorter: alpha + operations-sorter : method + disable-swagger-default-url: true + path: /swagger-ui + doc-expansion : none + +swagger: + username: ${SWAGGER_USERNAME:default} + password: ${SWAGGER_PASSWORD:default} diff --git a/src/test/java/com/amcamp/DatabaseCleaner.java b/src/test/java/com/amcamp/DatabaseCleaner.java new file mode 100644 index 00000000..b360f759 --- /dev/null +++ b/src/test/java/com/amcamp/DatabaseCleaner.java @@ -0,0 +1,49 @@ +package com.amcamp; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.stream.Collectors; +import org.hibernate.Session; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; + +@Component +public class DatabaseCleaner implements InitializingBean { + + @PersistenceContext private EntityManager entityManager; + + private List tableNames; + + @Override + public void afterPropertiesSet() { + entityManager.unwrap(Session.class).doWork(this::extractTableNames); + } + + private void extractTableNames(Connection conn) { + tableNames = + entityManager.getMetamodel().getEntities().stream() + .map(e -> e.getName().replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase()) + .collect(Collectors.toList()); + } + + public void execute() { + entityManager.unwrap(Session.class).doWork(this::cleanTables); + } + + private void cleanTables(Connection conn) throws SQLException { + Statement statement = conn.createStatement(); + statement.executeUpdate("SET REFERENTIAL_INTEGRITY FALSE"); + + for (String name : tableNames) { + statement.executeUpdate(String.format("TRUNCATE TABLE %s", name)); + statement.executeUpdate( + String.format("ALTER TABLE %s ALTER COLUMN %s_id RESTART WITH 1", name, name)); + } + + statement.executeUpdate("SET REFERENTIAL_INTEGRITY TRUE"); + } +} diff --git a/src/test/java/com/amcamp/DevFitApplicationTests.java b/src/test/java/com/amcamp/DevFitApplicationTests.java new file mode 100644 index 00000000..df9b12f8 --- /dev/null +++ b/src/test/java/com/amcamp/DevFitApplicationTests.java @@ -0,0 +1,13 @@ +package com.amcamp; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class DevFitApplicationTests { + + @Test + void contextLoads() {} +} diff --git a/src/test/java/com/amcamp/IntegrationTest.java b/src/test/java/com/amcamp/IntegrationTest.java new file mode 100644 index 00000000..87967f07 --- /dev/null +++ b/src/test/java/com/amcamp/IntegrationTest.java @@ -0,0 +1,18 @@ +package com.amcamp; + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public abstract class IntegrationTest { + + @Autowired protected DatabaseCleaner databaseCleaner; + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + } +} diff --git a/src/test/java/com/amcamp/domain/contribution/ContributionServiceTest.java b/src/test/java/com/amcamp/domain/contribution/ContributionServiceTest.java new file mode 100644 index 00000000..71f21521 --- /dev/null +++ b/src/test/java/com/amcamp/domain/contribution/ContributionServiceTest.java @@ -0,0 +1,244 @@ +package com.amcamp.domain.contribution; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.amcamp.IntegrationTest; +import com.amcamp.domain.contribution.application.ContributionService; +import com.amcamp.domain.contribution.dto.response.BasicContributionInfoResponse; +import com.amcamp.domain.contribution.dto.response.ContributionInfoResponse; +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.OauthInfo; +import com.amcamp.domain.project.dao.ProjectParticipantRepository; +import com.amcamp.domain.project.dao.ProjectRepository; +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.domain.ProjectParticipantRole; +import com.amcamp.domain.sprint.dao.SprintRepository; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.domain.task.application.TaskService; +import com.amcamp.domain.task.domain.TaskDifficulty; +import com.amcamp.domain.task.dto.request.TaskCreateRequest; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.dao.TeamRepository; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.domain.team.domain.TeamParticipantRole; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.ContributionErrorCode; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import com.amcamp.global.exception.errorcode.SprintErrorCode; +import com.amcamp.global.exception.errorcode.TeamErrorCode; +import com.amcamp.global.security.PrincipalDetails; +import com.amcamp.global.util.MemberUtil; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +public class ContributionServiceTest extends IntegrationTest { + @Autowired private MemberRepository memberRepository; + @Autowired private MemberUtil memberUtil; + @Autowired private TaskService taskService; + @Autowired private ContributionService contributionService; + @Autowired private TeamRepository teamRepository; + @Autowired private ProjectParticipantRepository projectParticipantRepository; + @Autowired private TeamParticipantRepository teamParticipantRepository; + @Autowired private ProjectRepository projectRepository; + @Autowired private SprintRepository sprintRepository; + + private ProjectParticipant participant; + private ProjectParticipant newParticipant; + private Sprint sprint; + private Sprint anotherSprint; + private Project project; + private Project anotherProject; + private Member newMember; + + private void loginAs(Member member) { + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + } + + @BeforeEach + void setUp() { + Member member = + memberRepository.save( + Member.createMember( + "testNickname", + "testProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"))); + + newMember = memberRepository.save(Member.createMember("member", null, null)); + + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + + Team team = teamRepository.save(Team.createTeam("testName", "testDescription")); + TeamParticipant teamParticipantAdmin = + teamParticipantRepository.save( + TeamParticipant.createParticipant(member, team, TeamParticipantRole.ADMIN)); + TeamParticipant teamParticipantUser = + teamParticipantRepository.save( + TeamParticipant.createParticipant( + newMember, team, TeamParticipantRole.USER)); + + project = + projectRepository.save( + Project.createProject( + team, "testTitle", "testDescription", LocalDate.of(2026, 12, 1))); + anotherProject = + projectRepository.save( + Project.createProject( + team, "testTitle", "testDescription", LocalDate.of(2026, 12, 1))); + + participant = + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipantAdmin, project, ProjectParticipantRole.ADMIN)); + newParticipant = + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipantUser, project, ProjectParticipantRole.MEMBER)); + + sprint = + sprintRepository.save( + Sprint.createSprint(project, "1차 스프린트", "아이디어 기획서 제출", LocalDate.now())); + + anotherSprint = + sprintRepository.save( + Sprint.createSprint( + project, "2차 스프린트", "기능 개발", LocalDate.of(2030, 12, 1))); + + // 상 2, 중 3, 하 4 + taskService.createTask(new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.HIGH)); + taskService.createTask(new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.HIGH)); + + taskService.createTask(new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID)); + taskService.createTask(new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID)); + taskService.createTask(new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID)); + + taskService.createTask(new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.LOW)); + taskService.createTask(new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.LOW)); + taskService.createTask(new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.LOW)); + taskService.createTask(new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.LOW)); + + // 내가 상 2개, 중 1개, 하 1개를 하고, 다른 팀원이 중 2개, 하 3개를 한 상황 가정 + taskService.assignTask(1L); + taskService.assignTask(2L); + taskService.assignTask(3L); + taskService.assignTask(9L); + + taskService.updateTaskStatus(1L); + taskService.updateTaskStatus(2L); + taskService.updateTaskStatus(3L); + taskService.updateTaskStatus(9L); + } + + @Nested + class 개별_기여도_조회_시 { + @Test + void 팀_참여자가_아니라면_에러반환() { + Member newTeamMember = memberRepository.save(Member.createMember("member", null, null)); + loginAs(newTeamMember); + + assertThatThrownBy(() -> contributionService.getContributionByMember(1L, 1L)) + .isInstanceOf(CommonException.class) + .hasMessage(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED.getMessage()); + } + + @Test + void 프로젝트_참여자가_아니라면_에러반환() { + loginAs(newMember); + assertThatThrownBy(() -> contributionService.getContributionByMember(2L, 1L)) + .isInstanceOf(CommonException.class) + .hasMessage(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED.getMessage()); + } + + @Test + void 유효한_프로젝트가_아니라면_에러반환() { + assertThatThrownBy(() -> contributionService.getContributionByMember(3L, 1L)) + .isInstanceOf(CommonException.class) + .hasMessage(ProjectErrorCode.PROJECT_NOT_FOUND.getMessage()); + } + + @Test + void 태스크가_존재하지_않으면_빈_값_반환() { + // when & then + assertThatThrownBy( + () -> + contributionService.getContributionByMember( + 1L, anotherSprint.getId())) + .isInstanceOf(CommonException.class) + .hasMessage(ContributionErrorCode.CONTRIBUTION_NOT_FOUND.getMessage()); + } + + @Test + void 스프린트가_존재하지_않으면_빈_값_반환() { + // when & then + assertThatThrownBy(() -> contributionService.getContributionByMember(1L, 999L)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_NOT_FOUND.getMessage()); + } + + @Test + void 프로젝트_참여자라면_기여도_반환() { + BasicContributionInfoResponse basicContributionInfoResponse = + contributionService.getContributionByMember(1L, 1L); + assertThat(basicContributionInfoResponse.projectParticipantId()) + .isEqualTo(participant.getId()); + assertThat(basicContributionInfoResponse.score()).isEqualTo(61); + } + } + + @Nested + class 프로젝트_기여도_조회_시 { + @Test + void 팀_참여자가_아니라면_에러반환() { + Member newMember = memberRepository.save(Member.createMember("member", null, null)); + loginAs(newMember); + + assertThatThrownBy(() -> contributionService.getContributionBySprint(1L)) + .isInstanceOf(CommonException.class) + .hasMessage(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED.getMessage()); + } + + @Test + void 스프린트가_존재하지_않으면_에러_반환() { + assertThatThrownBy(() -> contributionService.getContributionBySprint(999L)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_NOT_FOUND.getMessage()); + } + + @Test + void 태스크가_존재하지_않으면_빈_값_반환() { + // when + List contributions = + contributionService.getContributionBySprint(anotherSprint.getId()); + + // then + assertThat(contributions.isEmpty()).isEqualTo(true); + } + + @Test + void 팀_참여자라면_기여도_반환() { + List contributionInfoResponse = + contributionService.getContributionBySprint(1L); + assertThat(contributionInfoResponse.get(0).projectParticipantId()) + .isEqualTo(participant.getId()); + assertThat(contributionInfoResponse.get(0).score()).isEqualTo(61); + } + } +} diff --git a/src/test/java/com/amcamp/domain/feedback/application/FeedbackServiceTest.java b/src/test/java/com/amcamp/domain/feedback/application/FeedbackServiceTest.java new file mode 100644 index 00000000..72678705 --- /dev/null +++ b/src/test/java/com/amcamp/domain/feedback/application/FeedbackServiceTest.java @@ -0,0 +1,443 @@ +package com.amcamp.domain.feedback.application; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import com.amcamp.IntegrationTest; +import com.amcamp.domain.feedback.dao.FeedbackRepository; +import com.amcamp.domain.feedback.domain.Feedback; +import com.amcamp.domain.feedback.dto.request.FeedbackSendRequest; +import com.amcamp.domain.feedback.dto.response.FeedbackInfoResponse; +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.OauthInfo; +import com.amcamp.domain.project.dao.ProjectParticipantRepository; +import com.amcamp.domain.project.dao.ProjectRepository; +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.domain.ProjectParticipantRole; +import com.amcamp.domain.project.domain.ProjectParticipantStatus; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; +import com.amcamp.domain.sprint.dao.SprintRepository; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.dao.TeamRepository; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.domain.team.domain.TeamParticipantRole; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.FeedbackErrorCode; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import com.amcamp.global.exception.errorcode.SprintErrorCode; +import com.amcamp.global.security.PrincipalDetails; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Slice; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.transaction.annotation.Transactional; + +public class FeedbackServiceTest extends IntegrationTest { + + private final String feedbackMessage = "이번 스프린트에서 아주 잘해주셨습니다."; + + @Autowired private FeedbackService feedbackService; + @Autowired private FeedbackRepository feedbackRepository; + @Autowired private SprintRepository sprintRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private TeamRepository teamRepository; + @Autowired private TeamParticipantRepository teamParticipantRepository; + @Autowired private ProjectRepository projectRepository; + @Autowired private ProjectParticipantRepository projectParticipantRepository; + + private ProjectParticipant sender; + private ProjectParticipant anotherSender; + private ProjectParticipant receiver; + private ProjectParticipant anotherReceiver; + private ProjectParticipant notReceiver; + private ProjectParticipant unknownReceiver; + private Sprint sprint; + private Sprint anotherSprint; + private Project project; + private Project anotherProject; + + @BeforeEach + void setUp() { + Member senderMember = + memberRepository.save( + Member.createMember( + "testSenderNickname", + "testSenderProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"))); + Member receiverMember = + memberRepository.save( + Member.createMember( + "testReceiverNickname", + "testReceiverProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"))); + + Member notReceiverMember = + memberRepository.save( + Member.createMember( + "testNotReceiverNickname", + "testNotReceiverProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"))); + + // 초기에 로그인한 사용자를 sender로 설정 + setAuthenticatedUser(senderMember); + + Team team = teamRepository.save(Team.createTeam("testName", "testDescription")); + + TeamParticipant teamParticipantAdmin = + teamParticipantRepository.save( + TeamParticipant.createParticipant( + senderMember, team, TeamParticipantRole.ADMIN)); + TeamParticipant teamParticipantUser = + teamParticipantRepository.save( + TeamParticipant.createParticipant( + receiverMember, team, TeamParticipantRole.USER)); + + TeamParticipant teamParticipantUserNotReceiver = + teamParticipantRepository.save( + TeamParticipant.createParticipant( + notReceiverMember, team, TeamParticipantRole.USER)); + + project = + projectRepository.save( + Project.createProject( + team, "testTitle", "testDescription", LocalDate.of(2030, 1, 1))); + anotherProject = + projectRepository.save( + Project.createProject( + team, "testTitle", "testDescription", LocalDate.of(2030, 1, 1))); + + sender = + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipantAdmin, project, ProjectParticipantRole.ADMIN)); + + receiver = + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipantUser, project, ProjectParticipantRole.MEMBER)); + notReceiver = + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipantUserNotReceiver, + project, + ProjectParticipantRole.MEMBER)); + + anotherReceiver = + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipantUser, anotherProject, ProjectParticipantRole.ADMIN)); + + anotherSender = + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipantUser, anotherProject, ProjectParticipantRole.ADMIN)); + + unknownReceiver = + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipantUser, + anotherProject, + ProjectParticipantRole.MEMBER)); + + unknownReceiver.changeStatus(ProjectParticipantStatus.INACTIVE); + + sprint = + sprintRepository.save( + Sprint.createSprint(project, "testSprint", "testGoal", LocalDate.now())); + + anotherSprint = + sprintRepository.save( + Sprint.createSprint( + anotherProject, + "testAnotherSprint", + "testAnotherGoal", + LocalDate.now())); + } + + @Nested + class 피드백_메시지를_전송할_때 { + + @Test + @Transactional + void 입력_값이_정상이라면_피드백_메시지_전송에_성공한다() { + // given + FeedbackSendRequest request = + new FeedbackSendRequest(sprint.getId(), receiver.getId(), feedbackMessage); + + // when + feedbackService.sendFeedback(request); + + // then + Feedback feedback = feedbackRepository.findById(1L).get(); + assertThat(feedback.getSender()).isEqualTo(sender); + assertThat(feedback.getReceiver()).isEqualTo(receiver); + assertThat(feedback.getSprint()).isEqualTo(sprint); + assertThat(feedback.getMessage()).isEqualTo(feedbackMessage); + } + + @Test + void 본인에게_피드백_메시지를_전송하면_예외가_발생한다() { + // given + FeedbackSendRequest request = + new FeedbackSendRequest(sprint.getId(), sender.getId(), feedbackMessage); + + // when & then + assertThatThrownBy(() -> feedbackService.sendFeedback(request)) + .isInstanceOf(CommonException.class) + .hasMessage(FeedbackErrorCode.CANNOT_SEND_FEEDBACK_TO_SELF.getMessage()); + } + + @Test + void 같은_스프린트에서_특정_대상에게_피드백_메시지를_두_번_전송하면_예외가_발생한다() { + // given + FeedbackSendRequest request = + new FeedbackSendRequest(sprint.getId(), receiver.getId(), feedbackMessage); + feedbackService.sendFeedback(request); + + // when & then + FeedbackSendRequest duplicateRequest = + new FeedbackSendRequest(sprint.getId(), receiver.getId(), feedbackMessage); + assertThatThrownBy(() -> feedbackService.sendFeedback(duplicateRequest)) + .isInstanceOf(CommonException.class) + .hasMessage(FeedbackErrorCode.FEEDBACK_ALREADY_SENT.getMessage()); + } + + @Test + void 스프린트가_존재하지_않는다면_예외가_발생한다() { + // given + FeedbackSendRequest request = + new FeedbackSendRequest(999L, receiver.getId(), feedbackMessage); + + // when & then + assertThatThrownBy(() -> feedbackService.sendFeedback(request)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_NOT_FOUND.getMessage()); + } + + @Test + void 피드백을_받을_대상이_존재하지_않는다면_예외가_발생한다() { + // given + FeedbackSendRequest request = + new FeedbackSendRequest(sprint.getId(), 999L, feedbackMessage); + + // when & then + assertThatThrownBy(() -> feedbackService.sendFeedback(request)) + .isInstanceOf(CommonException.class) + .hasMessage(FeedbackErrorCode.RECEIVER_NOT_FOUND.getMessage()); + } + + @Test + void 같은_프로젝트에_속하지_않은_대상에게_피드백_메시지를_전송하면_예외가_발생한다() { + // given + FeedbackSendRequest request = + new FeedbackSendRequest( + sprint.getId(), anotherReceiver.getId(), feedbackMessage); + + // when & then + assertThatThrownBy(() -> feedbackService.sendFeedback(request)) + .isInstanceOf(CommonException.class) + .hasMessage(FeedbackErrorCode.INVALID_PROJECT_PARTICIPANT.getMessage()); + } + + @Test + @Transactional + void 스프린트_마감_당일_날이_아니라면_예외가_발생한다() { + // given + sprint = + sprintRepository.save( + Sprint.createSprint( + project, "testSprint", "testGoal", LocalDate.of(2030, 1, 1))); + + FeedbackSendRequest request = + new FeedbackSendRequest(sprint.getId(), receiver.getId(), feedbackMessage); + + // when & then + assertThatThrownBy(() -> feedbackService.sendFeedback(request)) + .isInstanceOf(CommonException.class) + .hasMessage(FeedbackErrorCode.FEEDBACK_DUE_DATE_ONLY.getMessage()); + } + + @Test + @Transactional + void 프로젝트에서_나간_사용자에게_피드백_메시지를_전송하면_예외가_발생한다() { + // given + FeedbackSendRequest request = + new FeedbackSendRequest( + sprint.getId(), unknownReceiver.getId(), feedbackMessage); + + // when & then + assertThatThrownBy(() -> feedbackService.sendFeedback(request)) + .isInstanceOf(CommonException.class) + .hasMessage(FeedbackErrorCode.PARTICIPANT_IS_UNKNOWN.getMessage()); + } + } + + @Nested + class 피드백_메시지를_조회할_때 { + + @BeforeEach + void 피드백_메시지_조회를_위해_로그인한_사용자를_receiver로_변경한다() { + setAuthenticatedUser(receiver.getTeamParticipant().getMember()); + } + + @Test + void 받은_피드백_메시지가_있다면_성공한다() { + // given + feedbackRepository.save( + Feedback.createFeedback(sender, receiver, sprint, feedbackMessage)); + + // when + Slice result = + feedbackService.findSprintFeedbacksByParticipant( + project.getId(), sprint.getId(), null, 1); + + // then + assertThat(result).isNotNull(); + assertThat(result.getContent().size()).isEqualTo(1); + assertThat(result.getContent().get(0).message()).isEqualTo("이번 스프린트에서 아주 잘해주셨습니다."); + } + + @Test + void 요청한_프로젝트가_로그인된_사용자가_참여한_프로젝트가_아니라면_예외가_발생한다() { + // given + + // sender는 project(1)에만 참여 중이고, + // receiver는 project(1)와 anotherProject(2) 둘 다 참여 중이므로 + // 검증을 명확히 하기 위해 로그인한 사용자를 sender로 변경 + setAuthenticatedUser(sender.getTeamParticipant().getMember()); + + // when & then + assertThatThrownBy( + () -> + feedbackService.findSprintFeedbacksByParticipant( + anotherProject.getId(), sprint.getId(), null, 1)) + .isInstanceOf(CommonException.class) + .hasMessage(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED.getMessage()); + } + + @Test + void 참여_중인_프로젝트_스프린트가_아닌_스프린트_ID로_요청하는_경우_예외가_발생한다() { + // when & then + assertThatThrownBy( + () -> + feedbackService.findSprintFeedbacksByParticipant( + project.getId(), anotherSprint.getId(), null, 1)) + .isInstanceOf(CommonException.class) + .hasMessage(ProjectErrorCode.PROJECT_SPRINT_MISMATCH.getMessage()); + } + + @Test + void 해당_스프린트에서_받은_피드백_메시지가_없는_경우_예외가_발생한다() { + // when & then + assertThatThrownBy( + () -> + feedbackService.findSprintFeedbacksByParticipant( + project.getId(), sprint.getId(), null, 1)) + .isInstanceOf(CommonException.class) + .hasMessage(FeedbackErrorCode.FEEDBACK_NOT_EXISTS.getMessage()); + } + + @Test + void 프로젝트가_존재하지_않는_경우_예외가_발생한다() { + // when & then + assertThatThrownBy( + () -> + feedbackService.findSprintFeedbacksByParticipant( + 999L, sprint.getId(), null, 1)) + .isInstanceOf(CommonException.class) + .hasMessage(ProjectErrorCode.PROJECT_NOT_FOUND.getMessage()); + } + + @Test + void 스프린트가_존재하지_않는_경우_예외가_발생한다() { + assertThatThrownBy( + () -> + feedbackService.findSprintFeedbacksByParticipant( + project.getId(), 999L, null, 1)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_NOT_FOUND.getMessage()); + } + } + + @Nested + class 스프린트별_동료평가_여부를_확인할_때 { + @Test + void 스프린트가_존재하지않으면_예외가_발생한다() { + // when & then + assertThatThrownBy( + () -> + feedbackService.findFeedbackStatusBySprint( + project.getId(), 999L, null, 1)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_NOT_FOUND.getMessage()); + } + + @Test + void 요청한_프로젝트가_로그인된_사용자가_참여한_프로젝트가_아니라면_예외가_발생한다() { + // given + // sender는 project(1)에만 참여 중이고, + // receiver는 project(1)와 anotherProject(2) 둘 다 참여 중이므로 + // 검증을 명확히 하기 위해 로그인한 사용자를 sender로 변경 + setAuthenticatedUser(sender.getTeamParticipant().getMember()); + + // when & then + assertThatThrownBy( + () -> + feedbackService.findFeedbackStatusBySprint( + anotherProject.getId(), sprint.getId(), null, 1)) + .isInstanceOf(CommonException.class) + .hasMessage(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED.getMessage()); + } + + @Test + void 팀원정보와_동료평가여부를_반환한다() { + feedbackRepository.save(Feedback.createFeedback(sender, receiver, sprint, "수고하셨습니다!")); + // when: sender 기준, 해당 스프린트에서의 팀원 평가 상태 조회 + Slice result = + feedbackService.findFeedbackStatusBySprint( + project.getId(), sprint.getId(), 0L, 10); + + // then + assertThat(result.getContent().size()) + .isEqualTo( + 3); // receiver 1명만 ACTIVE 상태로 있는 프로젝트 참가자, 본인과 피드백을 받지 않은 사람은 PENDING + + // (1) 동료평가를 받은 사람은 COMPLETED + ProjectParticipantFeedbackInfoResponse received_response = + result.getContent().stream() + .filter(r -> r.projectParticipantId().equals(receiver.getId())) + .findFirst() + .orElseThrow(() -> new AssertionError("receiver에 대한 응답이 존재하지 않음")); + + assertThat(received_response.nickname()).isEqualTo("testReceiverNickname"); + assertThat(received_response.feedbackStatus()).isEqualTo("COMPLETED"); + + // (1) 동료평가를 받지 사람 & sender는 PENDING + ProjectParticipantFeedbackInfoResponse sender_response = + result.getContent().stream() + .filter(r -> r.projectParticipantId().equals(sender.getId())) + .findFirst() + .orElseThrow(() -> new AssertionError("sender에 대한 응답이 존재하지 않음")); + + assertThat(sender_response.nickname()).isEqualTo("testSenderNickname"); + assertThat(sender_response.feedbackStatus()).isEqualTo("PENDING"); + } + } + + private void setAuthenticatedUser(Member member) { + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + } +} diff --git a/src/test/java/com/amcamp/domain/image/application/ImageServiceTest.java b/src/test/java/com/amcamp/domain/image/application/ImageServiceTest.java new file mode 100644 index 00000000..407d29c5 --- /dev/null +++ b/src/test/java/com/amcamp/domain/image/application/ImageServiceTest.java @@ -0,0 +1,113 @@ +package com.amcamp.domain.image.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.amcamp.IntegrationTest; +import com.amcamp.domain.image.dao.ImageRepository; +import com.amcamp.domain.image.domain.Image; +import com.amcamp.domain.image.domain.ImageFileExtension; +import com.amcamp.domain.image.dto.request.MemberImageUploadCompleteRequest; +import com.amcamp.domain.image.dto.request.MemberImageUploadRequest; +import com.amcamp.domain.image.dto.response.PresignedUrlResponse; +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.OauthInfo; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.ImageErrorCode; +import com.amcamp.global.exception.errorcode.MemberErrorCode; +import com.amcamp.global.security.PrincipalDetails; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +class ImageServiceTest extends IntegrationTest { + + @Autowired private ImageService imageService; + @Autowired private ImageRepository imageRepository; + @Autowired private MemberRepository memberRepository; + + @BeforeEach + void setUp() { + Member member = + memberRepository.save( + Member.createMember( + "testNickname", + "testProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"))); + + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + } + + @Nested + class 회원_프로필_이미지_PresignedUrl_생성_시 { + @Test + void 회원이_존재하지_않는다면_예외가_발생한다() { + // given + memberRepository.deleteAll(); + MemberImageUploadRequest request = + new MemberImageUploadRequest(ImageFileExtension.JPEG); + + // when & then + assertThatThrownBy(() -> imageService.createMemberImagePresignedUrl(request)) + .isInstanceOf(CommonException.class) + .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); + } + + @Test + void 회원이_존재하면_PresignedUrl이_생성된다() { + // given + MemberImageUploadRequest request = + new MemberImageUploadRequest(ImageFileExtension.JPEG); + + // when + PresignedUrlResponse response = imageService.createMemberImagePresignedUrl(request); + + // then + assertThat(response.presignedUrl()).isNotNull(); + assertThat(response.presignedUrl()) + .startsWith("https://s3.ap-northeast-2.amazonaws.com/"); + } + } + + @Nested + class 회원_프로필_이미지_업로드_완료_처리_시 { + @Test + void 회원이_업로드한_이미지가_존재하지_않는다면_예외가_발생한다() { + // given + MemberImageUploadCompleteRequest request = + new MemberImageUploadCompleteRequest(ImageFileExtension.JPEG); + + // when & then + assertThatThrownBy(() -> imageService.uploadCompleteMemberImage(request)) + .isInstanceOf(CommonException.class) + .hasMessage(ImageErrorCode.IMAGE_NOT_FOUND.getMessage()); + } + + @Test + void 이미지가_존재하면_회원의_프로필_이미지가_변경된다() { + // given + imageRepository.save(Image.createImage(1L, "testImageKey", ImageFileExtension.JPEG)); + + MemberImageUploadCompleteRequest request = + new MemberImageUploadCompleteRequest(ImageFileExtension.JPEG); + + // when + imageService.uploadCompleteMemberImage(request); + + // then + Member member = memberRepository.findById(1L).get(); + assertThat(member.getProfileImageUrl()) + .isEqualTo( + "https://devfit-bucket.s3.ap-northeast-2.amazonaws.com/1/testImageKey.jpeg"); + } + } +} diff --git a/src/test/java/com/amcamp/domain/meeting/MeetingServiceTest.java b/src/test/java/com/amcamp/domain/meeting/MeetingServiceTest.java new file mode 100644 index 00000000..7da6b598 --- /dev/null +++ b/src/test/java/com/amcamp/domain/meeting/MeetingServiceTest.java @@ -0,0 +1,477 @@ +package com.amcamp.domain.meeting; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.amcamp.IntegrationTest; +import com.amcamp.domain.meeting.application.MeetingService; +import com.amcamp.domain.meeting.dao.MeetingRepository; +import com.amcamp.domain.meeting.domain.Meeting; +import com.amcamp.domain.meeting.domain.MeetingStatus; +import com.amcamp.domain.meeting.dto.request.MeetingCreateRequest; +import com.amcamp.domain.meeting.dto.request.MeetingDtUpdateRequest; +import com.amcamp.domain.meeting.dto.request.MeetingTitleUpdateRequest; +import com.amcamp.domain.meeting.dto.response.MeetingInfoResponse; +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.OauthInfo; +import com.amcamp.domain.project.application.ProjectService; +import com.amcamp.domain.project.dao.ProjectParticipantRepository; +import com.amcamp.domain.project.dao.ProjectRegistrationRepository; +import com.amcamp.domain.project.dao.ProjectRepository; +import com.amcamp.domain.project.dto.request.ProjectCreateRequest; +import com.amcamp.domain.sprint.application.SprintService; +import com.amcamp.domain.sprint.dao.SprintRepository; +import com.amcamp.domain.sprint.dto.request.SprintCreateRequest; +import com.amcamp.domain.team.application.TeamService; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.dao.TeamRepository; +import com.amcamp.domain.team.dto.request.TeamCreateRequest; +import com.amcamp.domain.team.dto.request.TeamInviteCodeRequest; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.MeetingErrorCode; +import com.amcamp.global.security.PrincipalDetails; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Slice; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +public class MeetingServiceTest extends IntegrationTest { + @Autowired private SprintService sprintService; + @Autowired private ProjectService projectService; + @Autowired private TeamService teamService; + @Autowired private MemberRepository memberRepository; + @Autowired private TeamParticipantRepository teamParticipantRepository; + @Autowired private TeamRepository teamRepository; + @Autowired private ProjectRegistrationRepository projectRegistrationRepository; + @Autowired private ProjectParticipantRepository projectParticipantRepository; + @Autowired private ProjectRepository projectRepository; + @Autowired private SprintRepository sprintRepository; + @Autowired private MeetingRepository meetingRepository; + @Autowired private MeetingService meetingService; + + private Member memberAdmin; + private final String projectTitle = "projectTitle"; + private final String description = "projectDescription"; + private final String meetingTitle = "meetingTitle"; + private final LocalDate projectDueDt = LocalDate.of(2026, 12, 1); + private final LocalDate sprintDueDt = LocalDate.of(2026, 3, 31); + private final LocalDateTime meetingStart = LocalDateTime.of(2026, 3, 15, 17, 0); + private final LocalDateTime meetingEnd = LocalDateTime.of(2026, 3, 15, 18, 0); + + private void loginAs(Member member) { + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + } + + private void logout() { + SecurityContextHolder.clearContext(); + } + + private TeamInviteCodeRequest teamInviteCodeRequest; + + // 팀 가입 및 아이디 생성 + private Long getTeamId() { + TeamCreateRequest teamCreateRequest = new TeamCreateRequest("팀 이름", "팀 설명"); + String inviteCode = teamService.createTeam(teamCreateRequest).inviteCode(); + teamInviteCodeRequest = new TeamInviteCodeRequest(inviteCode); + return teamService.getTeamByCode(teamInviteCodeRequest).teamId(); + } + + // 프로젝트 생성 + void createTestProject() { + Long teamId = getTeamId(); + ProjectCreateRequest request = + new ProjectCreateRequest(teamId, projectTitle, projectDueDt, description); + + projectService.createProject(request); + } + + void createTestProject(Long teamId) { + ProjectCreateRequest request = + new ProjectCreateRequest(teamId, projectTitle, projectDueDt, description); + + projectService.createProject(request); + } + + void createTestProject(Long teamId, String title, LocalDate dueDt, String description) { + ProjectCreateRequest request = new ProjectCreateRequest(teamId, title, dueDt, description); + + projectService.createProject(request); + } + + // 스프린트 생성 + void createTestSprint(Long projectId) { + SprintCreateRequest request = + new SprintCreateRequest(projectId, "testSprintGoal", sprintDueDt); + sprintService.createSprint(request); + } + + // 미팅 생성 + void createTestMeeting(Long sprintId) { + MeetingCreateRequest request = + new MeetingCreateRequest(sprintId, meetingTitle, meetingStart, meetingEnd); + meetingService.createMeeting(request); + } + + void createTestMeeting(Long sprintId, LocalDateTime meetingStart, LocalDateTime meetingEnd) { + MeetingCreateRequest request = + new MeetingCreateRequest(sprintId, meetingTitle, meetingStart, meetingEnd); + meetingService.createMeeting(request); + } + + void createTestMeeting( + Long sprintId, + String meetingTitle, + LocalDateTime meetingStart, + LocalDateTime meetingEnd) { + MeetingCreateRequest request = + new MeetingCreateRequest(sprintId, meetingTitle, meetingStart, meetingEnd); + meetingService.createMeeting(request); + } + + @BeforeEach + public void setUp() { + memberAdmin = + Member.createMember( + "memberAdmin", + "testProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider")); + memberRepository.save(memberAdmin); + loginAs(memberAdmin); + createTestProject(); + createTestSprint(1L); + } + + @AfterEach + public void afterEach() { + logout(); + projectRegistrationRepository.deleteAll(); + projectParticipantRepository.deleteAll(); + projectRepository.deleteAll(); + teamParticipantRepository.deleteAll(); + teamRepository.deleteAll(); + memberRepository.deleteAll(); + meetingRepository.deleteAll(); + sprintRepository.deleteAll(); + } + + @Nested + class 미팅_생성 { + @Test + void 미팅을_생성하면_정상적으로_저장된다() { + // given + createTestMeeting(1L); + // then + Meeting meeting = meetingRepository.findById(1L).get(); + assertThat(meeting.getId()).isEqualTo(1L); + assertThat(meeting) + .extracting("id", "title", "meetingStart", "meetingEnd", "status") + .containsExactlyInAnyOrder( + 1L, meetingTitle, meetingStart, meetingEnd, MeetingStatus.OPEN); + } + + @Test + void 스프린트_시작일을_벗어나면_오류가_발생한다() { + // given + LocalDateTime wrongDtBeforeStartDt = LocalDateTime.of(2020, 3, 15, 17, 0); + + // then + assertThatThrownBy(() -> createTestMeeting(1L, wrongDtBeforeStartDt, meetingEnd)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_DATE_OUT_OF_SPRINT.getMessage()); + } + + @Test + void 스프린트_마감일을_벗어나면_오류가_발생한다() { + // given + LocalDateTime wrongDtAfterDueDt = LocalDateTime.of(2030, 3, 15, 17, 0); + // then + assertThatThrownBy(() -> createTestMeeting(1L, meetingStart, wrongDtAfterDueDt)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_DATE_OUT_OF_SPRINT.getMessage()); + } + + @Test + void 시작시간이_종료시간보다_느리거나_같으면_오류가_발생한다() { + + // then + assertThatThrownBy(() -> createTestMeeting(1L, meetingEnd, meetingStart)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.INVALID_MEETING_TIME_RANGE.getMessage()); + assertThatThrownBy(() -> createTestMeeting(1L, meetingStart, meetingStart)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.INVALID_MEETING_TIME_RANGE.getMessage()); + } + + @Test + void 시간이_8시부터_00시_범위를_벗어나면_오류가_발생한다() { + // given + LocalDateTime before8 = LocalDateTime.of(2026, 3, 15, 1, 0); + LocalDateTime after0 = LocalDateTime.of(2026, 3, 16, 0, 1); + // then + assertThatThrownBy(() -> createTestMeeting(1L, before8, meetingEnd)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.INVALID_MEETING_TIME_RANGE.getMessage()); + assertThatThrownBy(() -> createTestMeeting(1L, meetingStart, after0)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.INVALID_MEETING_TIME_RANGE.getMessage()); + } + + @Test + void 기존_미팅과_일시가_겹치면_오류가_발생한다() { + // given + createTestMeeting(1L); // 기존 meetingStart, meetingEnd로 생성 + // when + LocalDateTime beforeStart = LocalDateTime.of(2026, 3, 15, 16, 0); + LocalDateTime afterEnd = LocalDateTime.of(2026, 3, 15, 19, 0); + LocalDateTime afterStart = LocalDateTime.of(2026, 3, 15, 17, 20); + LocalDateTime beforeEnd = LocalDateTime.of(2026, 3, 15, 17, 40); + // then + assertThatThrownBy(() -> createTestMeeting(1L, meetingStart, meetingEnd)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_ALREADY_EXISTS.getMessage()); + assertThatThrownBy(() -> createTestMeeting(1L, beforeStart, meetingEnd)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_ALREADY_EXISTS.getMessage()); + assertThatThrownBy(() -> createTestMeeting(1L, meetingStart, afterEnd)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_ALREADY_EXISTS.getMessage()); + assertThatThrownBy(() -> createTestMeeting(1L, afterStart, beforeEnd)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_ALREADY_EXISTS.getMessage()); + } + } + + @Nested + class 미팅_업데이트 { + @Test + void 미팅_타이틀을_변경하면_정상적으로_변경된다() { + // given + createTestMeeting(1L); + MeetingTitleUpdateRequest request = new MeetingTitleUpdateRequest("new title"); + // when + meetingService.updateMeetingTitle(1L, request); + // then + Meeting meeting = meetingRepository.findById(1L).get(); + assertThat(meeting.getTitle()).isEqualTo("new title"); + } + + @Test + void 미팅_일시를_변경하면_정상적으로_변경된다() { + createTestMeeting(1L); + LocalDateTime modifiedStart = LocalDateTime.of(2026, 3, 25, 17, 0); + LocalDateTime modifiedEnd = LocalDateTime.of(2026, 3, 25, 18, 0); + MeetingDtUpdateRequest request = new MeetingDtUpdateRequest(modifiedStart, modifiedEnd); + // when + meetingService.updateMeetingDt(1L, request); + // then + Meeting meeting = meetingRepository.findById(1L).get(); + assertThat(meeting.getMeetingStart()).isEqualTo(modifiedStart); + assertThat(meeting.getMeetingEnd()).isEqualTo(modifiedEnd); + } + + @Test + void 미팅_일시를_하나만_변경해도_정상적으로_변경된다() { + // given + createTestMeeting(1L); + LocalDateTime modifiedStart = LocalDateTime.of(2026, 3, 15, 14, 0); + LocalDateTime modifiedEnd = LocalDateTime.of(2026, 3, 15, 20, 0); + + // when + MeetingDtUpdateRequest request1 = new MeetingDtUpdateRequest(modifiedStart, null); + MeetingDtUpdateRequest request2 = new MeetingDtUpdateRequest(null, modifiedEnd); + + // then + meetingService.updateMeetingDt(1L, request1); + assertThat(meetingRepository.findById(1L).get().getMeetingStart()) + .isEqualTo(modifiedStart); + assertThat(meetingRepository.findById(1L).get().getMeetingEnd()).isEqualTo(meetingEnd); + meetingService.updateMeetingDt(1L, request2); + assertThat(meetingRepository.findById(1L).get().getMeetingEnd()).isEqualTo(modifiedEnd); + } + + @Test + void 스프린트_시작일을_벗어나면_업데이트중_오류가_발생한다() { + // given + createTestMeeting(1L); + LocalDateTime modifiedStart = LocalDateTime.of(2026, 3, 25, 17, 0); + LocalDateTime modifiedEnd = LocalDateTime.of(2026, 3, 25, 18, 0); + LocalDateTime wrongDtBeforeStartDt = LocalDateTime.of(2020, 3, 15, 17, 0); + MeetingDtUpdateRequest request = + new MeetingDtUpdateRequest(wrongDtBeforeStartDt, modifiedEnd); + // then + assertThatThrownBy(() -> meetingService.updateMeetingDt(1L, request)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_DATE_OUT_OF_SPRINT.getMessage()); + } + + @Test + void 스프린트_마감일을_벗어나면_업데이트중_오류가_발생한다() { + // given + createTestMeeting(1L); + LocalDateTime modifiedStart = LocalDateTime.of(2026, 3, 25, 17, 0); + LocalDateTime wrongDtAfterDueDt = LocalDateTime.of(2030, 3, 15, 17, 0); + MeetingDtUpdateRequest request = + new MeetingDtUpdateRequest(modifiedStart, wrongDtAfterDueDt); + // then + assertThatThrownBy(() -> meetingService.updateMeetingDt(1L, request)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_DATE_OUT_OF_SPRINT.getMessage()); + } + + @Test + void 시작시간이_종료시간보다_느리거나_같으면_업데이트중_오류가_발생한다() { + createTestMeeting(1L); + MeetingDtUpdateRequest request1 = new MeetingDtUpdateRequest(meetingEnd, meetingStart); + MeetingDtUpdateRequest request2 = + new MeetingDtUpdateRequest(meetingStart, meetingStart); + // then + assertThatThrownBy(() -> meetingService.updateMeetingDt(1L, request1)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.INVALID_MEETING_TIME_RANGE.getMessage()); + assertThatThrownBy(() -> meetingService.updateMeetingDt(1L, request2)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.INVALID_MEETING_TIME_RANGE.getMessage()); + } + + @Test + void 시간이_8시부터_00시_범위를_벗어나면_업데이트중_오류가_발생한다() { + // given + createTestMeeting(1L); // 기존 meetingStart, meetingEnd로 생성 + // when + LocalDateTime before8 = LocalDateTime.of(2026, 3, 15, 1, 0); + LocalDateTime after0 = LocalDateTime.of(2026, 3, 16, 0, 1); + + MeetingDtUpdateRequest request1 = new MeetingDtUpdateRequest(before8, meetingEnd); + MeetingDtUpdateRequest request2 = new MeetingDtUpdateRequest(meetingStart, after0); + + // then + assertThatThrownBy(() -> meetingService.updateMeetingDt(1L, request1)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.INVALID_MEETING_TIME_RANGE.getMessage()); + assertThatThrownBy(() -> meetingService.updateMeetingDt(1L, request2)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.INVALID_MEETING_TIME_RANGE.getMessage()); + } + + @Test + void 기존_미팅과_일시가_겹치면_오류가_발생한다() { + // given + LocalDateTime anotherMeetingStart = LocalDateTime.of(2026, 3, 16, 17, 0); + LocalDateTime anotherMeetingEnd = LocalDateTime.of(2026, 3, 16, 18, 0); + + createTestMeeting(1L); // 기존 meetingStart, meetingEnd로 생성 + createTestMeeting(1L, anotherMeetingStart, anotherMeetingEnd); // 새로운 미팅 + + // when + LocalDateTime beforeStart = LocalDateTime.of(2026, 3, 15, 16, 0); + LocalDateTime afterEnd = LocalDateTime.of(2026, 3, 15, 19, 0); + LocalDateTime afterStart = LocalDateTime.of(2026, 3, 15, 17, 20); + LocalDateTime beforeEnd = LocalDateTime.of(2026, 3, 15, 17, 40); + + MeetingDtUpdateRequest request1 = new MeetingDtUpdateRequest(meetingStart, meetingEnd); + MeetingDtUpdateRequest request2 = new MeetingDtUpdateRequest(beforeStart, meetingEnd); + MeetingDtUpdateRequest request3 = new MeetingDtUpdateRequest(meetingStart, afterEnd); + MeetingDtUpdateRequest request4 = new MeetingDtUpdateRequest(afterStart, beforeEnd); + // then + assertThatThrownBy(() -> meetingService.updateMeetingDt(2L, request1)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_ALREADY_EXISTS.getMessage()); + assertThatThrownBy(() -> meetingService.updateMeetingDt(2L, request2)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_ALREADY_EXISTS.getMessage()); + assertThatThrownBy(() -> meetingService.updateMeetingDt(2L, request3)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_ALREADY_EXISTS.getMessage()); + assertThatThrownBy(() -> meetingService.updateMeetingDt(2L, request4)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_ALREADY_EXISTS.getMessage()); + } + } + + @Nested + class 미팅_삭제 { + + @Test + void 미팅을_삭제하면_정상적으로_삭제된다() { + createTestMeeting(1L); + // when + meetingService.deleteMeeting(1L); + // then + assertThatThrownBy(() -> meetingService.getMeeting(1L)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_NOT_FOUND.getMessage()); + } + + @Test + void 미팅이_존재하지않으면_삭제중_오류가_발생한다() { + + assertThatThrownBy(() -> meetingService.deleteMeeting(1L)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_NOT_FOUND.getMessage()); + } + } + + @Nested + class 미팅_조회 { + @Test + void 미팅아이디로_미팅을_조회하면_정상적으로_조회된다() { + createTestMeeting(1L); + // when + MeetingInfoResponse response = meetingService.getMeeting(1L); + // then + assertThat(response) + .extracting("meetingTitle", "meetingStart", "meetingEnd") + .containsExactlyInAnyOrder(meetingTitle, meetingStart, meetingEnd); + } + + @Test + void 미팅ID가_유효하지_않으면_예외가_발생한다() { + // given + createTestMeeting(1L); + Long invalidMeetingId = Long.MAX_VALUE; + // then + assertThatThrownBy(() -> meetingService.getMeeting(invalidMeetingId)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(MeetingErrorCode.MEETING_NOT_FOUND.getMessage()); + } + + @Test + void 스프린트ID로_조회하면_전체미팅목록이_정상적으로_조회된다() { + // given + String title1 = "title1"; + LocalDateTime start1 = LocalDateTime.of(2026, 3, 15, 17, 0); + LocalDateTime end1 = LocalDateTime.of(2026, 3, 15, 18, 0); + String title2 = "title2"; + LocalDateTime start2 = LocalDateTime.of(2026, 3, 16, 17, 0); + LocalDateTime end2 = LocalDateTime.of(2026, 3, 16, 18, 0); + String title3 = "title3"; + LocalDateTime start3 = LocalDateTime.of(2026, 3, 17, 17, 0); + LocalDateTime end3 = LocalDateTime.of(2026, 3, 17, 18, 0); + + createTestMeeting(1L, title1, start1, end1); + createTestMeeting(1L, title2, start2, end2); + createTestMeeting(1L, title3, start3, end3); + // when, then + Slice response = meetingService.getMeetingList(1L, null, 10); + + List titles = response.stream().map(MeetingInfoResponse::meetingTitle).toList(); + + assertThat(new HashSet<>(titles)).isEqualTo(Set.of(title1, title2, title3)); + } + } +} diff --git a/src/test/java/com/amcamp/domain/member/application/MemberServiceTest.java b/src/test/java/com/amcamp/domain/member/application/MemberServiceTest.java new file mode 100644 index 00000000..f92afb15 --- /dev/null +++ b/src/test/java/com/amcamp/domain/member/application/MemberServiceTest.java @@ -0,0 +1,232 @@ +package com.amcamp.domain.member.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.tuple; + +import com.amcamp.IntegrationTest; +import com.amcamp.domain.auth.dao.RefreshTokenRepository; +import com.amcamp.domain.auth.domain.RefreshToken; +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.MemberRole; +import com.amcamp.domain.member.domain.MemberStatus; +import com.amcamp.domain.member.domain.OauthInfo; +import com.amcamp.domain.member.dto.request.NicknameUpdateRequest; +import com.amcamp.domain.member.dto.response.BasicMemberResponse; +import com.amcamp.domain.member.dto.response.MemberInfoResponse; +import com.amcamp.domain.team.application.TeamService; +import com.amcamp.domain.team.dto.request.TeamCreateRequest; +import com.amcamp.domain.team.dto.request.TeamInviteCodeRequest; +import com.amcamp.domain.team.dto.response.TeamCheckResponse; +import com.amcamp.domain.team.dto.response.TeamInviteCodeResponse; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.MemberErrorCode; +import com.amcamp.global.security.PrincipalDetails; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Slice; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +class MemberServiceTest extends IntegrationTest { + + @Autowired private MemberService memberService; + @Autowired private TeamService teamService; + @Autowired private MemberRepository memberRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; + + private Member registerAuthenticatedMember() { + Member member = + Member.createMember( + "testNickname", + "testProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider")); + memberRepository.save(member); + + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + + return member; + } + + private void loginAs(Member member) { + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + } + + @Nested + class 로그아웃_시 { + @Test + void 로그아웃하면_리프레시_토큰이_삭제된다() { + // given + Member member = registerAuthenticatedMember(); + + RefreshToken refreshToken = + RefreshToken.builder() + .memberId(member.getId()) + .token("testRefreshToken") + .build(); + refreshTokenRepository.save(refreshToken); + + // when + memberService.logoutMember(); + + // then + assertThat(refreshTokenRepository.findById(member.getId())).isEmpty(); + } + } + + @Nested + class 회원_탈퇴_시 { + @Test + void 탈퇴하지_않은_유저면_성공한다() { + // given + Member member = registerAuthenticatedMember(); + + RefreshToken refreshToken = + RefreshToken.builder() + .memberId(member.getId()) + .token("testRefreshToken") + .build(); + refreshTokenRepository.save(refreshToken); + + // when + memberService.withdrawalMember(); + Member currentMember = memberRepository.findById(member.getId()).get(); + + // then + assertThat(refreshTokenRepository.findById(member.getId())).isEmpty(); + assertThat(currentMember.getStatus()).isEqualTo(MemberStatus.DELETED); + } + + @Test + void 탈퇴한_유저면_예외가_발생한다() { + // given + registerAuthenticatedMember(); + memberService.withdrawalMember(); + + // when & then + assertThatThrownBy(() -> memberService.withdrawalMember()) + .isInstanceOf(CommonException.class) + .hasMessage(MemberErrorCode.MEMBER_ALREADY_DELETED.getMessage()); + } + } + + @Nested + class 회원_닉네임_변경_시 { + @Test + void 닉네임이_NULL_이면_예외가_발생한다() { + // given + registerAuthenticatedMember(); + NicknameUpdateRequest request = new NicknameUpdateRequest(null); + + // when + memberService.updateMemberNickname(request); + Set> violations = + Validation.buildDefaultValidatorFactory().getValidator().validate(request); + + // then + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("닉네임은 비워둘 수 없습니다."); + } + + @Test + void 유효한_입력값이면_닉네임이_변경된다() { + // given + Member member = registerAuthenticatedMember(); + NicknameUpdateRequest request = new NicknameUpdateRequest("현태 최"); + + // when + memberService.updateMemberNickname(request); + Member currentMember = memberRepository.findById(member.getId()).get(); + + // then + assertThat(currentMember.getNickname()).isEqualTo("현태 최"); + } + } + + @Test + void 회원_정보를_조회한다() { + // given + registerAuthenticatedMember(); + + // when + MemberInfoResponse response = memberService.getMemberInfo(); + + // then + assertThat(response.nickname()).isEqualTo("testNickname"); + assertThat(response.profileImageUrl()).isEqualTo("testProfileImageUrl"); + assertThat(response.role()).isEqualTo(MemberRole.USER); + assertThat(response.status()).isEqualTo(MemberStatus.NORMAL); + } + + @Nested + class 내가_속한_팀의_멤버를_조회_시 { + @Test + void 팀장을_포함한_멤버가_1명() { + // given + registerAuthenticatedMember(); + TeamInviteCodeResponse teamInviteCodeResponse = + teamService.createTeam(TeamCreateRequest.of("MyTeam", "This is my team")); + String inviteCode = teamInviteCodeResponse.inviteCode(); + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode(new TeamInviteCodeRequest(inviteCode)); + + // when + Slice results = + memberService.findAllMembers(teamCheckResponse.teamId(), null, 3); + + // then + assertThat(results.getContent()).hasSize(0); + } + + @Test + void 팀장을_제외한_멤버가_3명() { + // given + registerAuthenticatedMember(); + TeamInviteCodeResponse teamInviteCodeResponse = + teamService.createTeam(TeamCreateRequest.of("MyTeam", "This is my team")); + String inviteCode = teamInviteCodeResponse.inviteCode(); + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode(new TeamInviteCodeRequest(inviteCode)); + + List requests = + List.of( + Member.createMember("member1", "url1", null), // 2L + Member.createMember("member2", "url2", null), // 3L + Member.createMember("member3", "url3", null), // 4L + Member.createMember("member4", "url4", null), // 5L + Member.createMember("member5", "url5", null)); // 6L + + for (Member member : requests) { + memberRepository.save(member); + loginAs(member); + teamService.joinTeam(new TeamInviteCodeRequest(inviteCode)); + } + + // when + Slice results = + memberService.findAllMembers(teamCheckResponse.teamId(), null, 2); + + // then + assertThat(results.getContent()).hasSize(2); + assertThat(results) + .extracting("memberId", "nickname", "profileImageUrl") + .containsExactlyInAnyOrder( + tuple(6L, "member5", "url5"), tuple(5L, "member4", "url4")); + } + } +} diff --git a/src/test/java/com/amcamp/domain/project/ProjectServiceTest.java b/src/test/java/com/amcamp/domain/project/ProjectServiceTest.java new file mode 100644 index 00000000..6637325a --- /dev/null +++ b/src/test/java/com/amcamp/domain/project/ProjectServiceTest.java @@ -0,0 +1,747 @@ +package com.amcamp.domain.project; + +import static org.assertj.core.api.AssertionsForClassTypes.*; + +import com.amcamp.IntegrationTest; +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.OauthInfo; +import com.amcamp.domain.project.application.ProjectService; +import com.amcamp.domain.project.dao.ProjectParticipantRepository; +import com.amcamp.domain.project.dao.ProjectRegistrationRepository; +import com.amcamp.domain.project.dao.ProjectRepository; +import com.amcamp.domain.project.domain.*; +import com.amcamp.domain.project.dto.request.ProjectCreateRequest; +import com.amcamp.domain.project.dto.request.ProjectUpdateRequest; +import com.amcamp.domain.project.dto.response.ProjectInfoResponse; +import com.amcamp.domain.project.dto.response.ProjectListInfoResponse; +import com.amcamp.domain.project.dto.response.ProjectParticipantInfoResponse; +import com.amcamp.domain.project.dto.response.ProjectRegisterDetailResponse; +import com.amcamp.domain.project.dto.response.ProjectRegistrationInfoResponse; +import com.amcamp.domain.team.application.TeamService; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.dao.TeamRepository; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.domain.team.dto.request.TeamCreateRequest; +import com.amcamp.domain.team.dto.request.TeamInviteCodeRequest; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import com.amcamp.global.exception.errorcode.TeamErrorCode; +import com.amcamp.global.security.PrincipalDetails; +import com.amcamp.global.util.MemberUtil; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Slice; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +public class ProjectServiceTest extends IntegrationTest { + @Autowired private MemberUtil memberUtil; + @Autowired private ProjectService projectService; + @Autowired private TeamService teamService; + @Autowired private MemberRepository memberRepository; + @Autowired private TeamParticipantRepository teamParticipantRepository; + @Autowired private TeamRepository teamRepository; + @Autowired private ProjectRegistrationRepository projectRegistrationRepository; + @Autowired private ProjectParticipantRepository projectParticipantRepository; + @Autowired private ProjectRepository projectRepository; + + private Member memberAdmin; + private Member member1; + private Member member2; + private final String title = "projectTitle"; + private final String description = "projectDescription"; + private final LocalDate dueDt = LocalDate.of(2026, 12, 1); + + private void loginAs(Member member) { + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + } + + private void logout() { + SecurityContextHolder.clearContext(); + } + + private TeamInviteCodeRequest teamInviteCodeRequest; + + private Long getTeamId() { + TeamCreateRequest teamCreateRequest = new TeamCreateRequest("팀 이름", "팀 설명"); + String inviteCode = teamService.createTeam(teamCreateRequest).inviteCode(); + teamInviteCodeRequest = new TeamInviteCodeRequest(inviteCode); + return teamService.getTeamByCode(teamInviteCodeRequest).teamId(); + } + + Project createTestProject() { + Member member = memberUtil.getCurrentMember(); + Long teamId = getTeamId(); + Team team = teamRepository.findById(teamId).orElseThrow(); + TeamParticipant participant = teamParticipantRepository.findById(1L).get(); + Project project = + projectRepository.save(Project.createProject(team, title, description, dueDt)); + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + participant, project, ProjectParticipantRole.ADMIN)); + return project; + } + + Project createTestProject(Long teamId, Long teamParticipantId) { + Member member = memberUtil.getCurrentMember(); + Team team = teamRepository.findById(teamId).orElseThrow(); + TeamParticipant participant = teamParticipantRepository.findById(teamParticipantId).get(); + Project project = + projectRepository.save(Project.createProject(team, title, description, dueDt)); + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + participant, project, ProjectParticipantRole.ADMIN)); + return project; + } + + Project createTestProject( + Long teamId, + Long teamParticipantId, + String title, + LocalDate dueDt, + String description) { + Member member = memberUtil.getCurrentMember(); + Team team = teamRepository.findById(teamId).orElseThrow(); + TeamParticipant participant = teamParticipantRepository.findById(teamParticipantId).get(); + Project project = + projectRepository.save(Project.createProject(team, title, description, dueDt)); + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + participant, project, ProjectParticipantRole.ADMIN)); + return project; + } + + @BeforeEach + public void setUp() { + memberAdmin = + Member.createMember( + "memberAdmin", + "testProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider")); + memberRepository.save(memberAdmin); + loginAs(memberAdmin); + + member1 = + Member.createMember( + "member1", + "testProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider")); + memberRepository.save(member1); + + member2 = + Member.createMember( + "member2", + "testProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider")); + memberRepository.save(member2); + } + + @AfterEach + public void afterEach() { + logout(); + projectRegistrationRepository.deleteAll(); + projectParticipantRepository.deleteAll(); + projectRepository.deleteAll(); + teamParticipantRepository.deleteAll(); + teamRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Test + void 프로젝트를_생성하면_정상적으로_저장된다() { + // given + Long teamId = getTeamId(); + // when + ProjectCreateRequest request = new ProjectCreateRequest(teamId, title, dueDt, description); + + projectService.createProject(request); + + // then + Project project = projectRepository.findById(1L).get(); + assertThat(project.getId()).isEqualTo(1L); + assertThat(project) + .extracting("id", "title", "description") + .containsExactlyInAnyOrder(1L, title, description); + } + + @Nested + class 프로젝트_조회 { + @Test + void 팀_ID로_조회하면_전체_프로젝트가_정상적으로_반환된다() { + // given + Long teamId = getTeamId(); + createTestProject(teamId, 1L, "project1", dueDt, description); + Project member1JoinProject = + createTestProject(teamId, 1L, "project2", dueDt, description); + + // member logout 후 anotherMember 로그인 + logout(); + loginAs(member1); + // 팀 참가 + teamService.joinTeam(teamInviteCodeRequest); + // anotherMember 새 프로젝트 생성 + createTestProject(teamId, 2L, "project3", dueDt, description); + // Project1에 일반 멤버로 참여 + TeamParticipant participant = teamParticipantRepository.findById(2L).get(); + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + participant, member1JoinProject, ProjectParticipantRole.MEMBER)); + // when + Slice response = + projectService.getProjectListInfo(teamId, null, 10); + response.getContent().forEach(System.out::println); + assertThat( + response.getContent().stream() + .filter(r -> !r.isParticipant()) + // .filter(ProjectListInfoResponse::isAdmin) + .map(ProjectListInfoResponse::projectInfo) + .findAny() + .get() + .projectTitle()) + .isEqualTo("project1"); + assertThat( + response.getContent().stream() + .filter(ProjectListInfoResponse::isParticipant) + .filter(f -> !f.isAdmin()) + .map(ProjectListInfoResponse::projectInfo) + .findAny() + .get() + .projectTitle()) + .isEqualTo("project2"); + assertThat( + response.getContent().stream() + .filter(ProjectListInfoResponse::isParticipant) + .filter(ProjectListInfoResponse::isAdmin) + .map(ProjectListInfoResponse::projectInfo) + .findAny() + .get() + .projectTitle()) + .isEqualTo("project3"); + } + + @Test + void 프로젝트를_ID로_조회하면_정상적으로_반환된다() { + // given + createTestProject(); + Project project = projectRepository.findById(1L).get(); + // when + ProjectInfoResponse foundResponse = projectService.getProjectInfo(1L); + // then + + assertThat(foundResponse) + .extracting( + "projectId", "projectTitle", "projectDescription", "startDt", "dueDt") + .containsExactlyInAnyOrder( + project.getId(), + project.getTitle(), + project.getDescription(), + project.getStartDt(), + project.getDueDt()); + } + + @Test + void ID가_유효하지_않으면_예외가_발생한다() { + // given + Long invalidProjectId = Long.MAX_VALUE; + // when, then + assertThatThrownBy(() -> projectService.getProjectInfo(invalidProjectId)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(ProjectErrorCode.PROJECT_NOT_FOUND.getMessage()); + } + } + + @Nested + class 프로젝트_업데이트 { + String originalTitle = "originalProjectTitle"; + String originalDescription = "originalProjectGoal"; + String updatedTitle = "updatedProjectTitle"; + String updatedDescription = "updatedProjectDescription"; + + void createOriginalProject() { + Long teamId = getTeamId(); + createTestProject(teamId, 1L, originalTitle, dueDt, originalDescription); + } + + @Test + void 프로젝트_기본정보를_수정하면_정상적으로_수정된다() { + // given + createOriginalProject(); + // when + projectService.updateProject( + 1L, new ProjectUpdateRequest(updatedTitle, updatedDescription, null)); + Project updatedProject = projectRepository.findById(1L).get(); + // then + assertThat(updatedProject.getTitle()).isEqualTo(updatedTitle); + assertThat(updatedProject.getDescription()).isEqualTo(updatedDescription); + } + + @Test + void 팀_참여자가_아닌_사용자는_프로젝트_수정이_제한된다() { + // given + createOriginalProject(); + logout(); + // 팀에 속하지 않은 사용자 로그인 + loginAs(member1); + + // when, then + assertThatThrownBy( + () -> + projectService.updateProject( + 1L, + new ProjectUpdateRequest( + updatedTitle, updatedDescription, null))) + .isInstanceOf(CommonException.class) + .hasMessageContaining(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED.getMessage()); + } + + @Test + void 프로젝트_참여자가_아닌_팀_참가자는_수정이_제한된다() { + // given + createOriginalProject(); + logout(); + // 다른 팀 멤버 + loginAs(member1); + teamService.joinTeam(teamInviteCodeRequest); + + // when, then + assertThatThrownBy( + () -> + projectService.updateProject( + 1L, + new ProjectUpdateRequest( + updatedTitle, updatedDescription, null))) + .isInstanceOf(CommonException.class) + .hasMessageContaining( + ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED.getMessage()); + } + + @Test + void 프로젝트_정보를_타이틀만_수정하면_타이틀만_수정된다() { + // given + createOriginalProject(); + // when + projectService.updateProject(1L, new ProjectUpdateRequest(updatedTitle, null, null)); + Project updatedProject = projectRepository.findById(1L).get(); + // then + assertThat(updatedProject.getTitle()).isEqualTo(updatedTitle); + assertThat(updatedProject.getDescription()).isEqualTo(originalDescription); + } + + @Test + void 프로젝트_정보를_상세설명만_수정하면_상세설명만_수정된다() { + // given + createOriginalProject(); + // when + projectService.updateProject( + 1L, new ProjectUpdateRequest(null, updatedDescription, null)); + Project updatedProject = projectRepository.findById(1L).get(); + // then + assertThat(updatedProject.getTitle()).isEqualTo(originalTitle); + assertThat(updatedProject.getDescription()).isEqualTo(updatedDescription); + } + + @Test + void 프로젝트_정보를_마감일자만_수정하면_마감일자만_수정된다() { + // given + createOriginalProject(); + // when + LocalDate updatedDueDt = LocalDate.of(2027, 12, 1); + projectService.updateProject(1L, new ProjectUpdateRequest(null, null, updatedDueDt)); + Project updatedProject = projectRepository.findById(1L).get(); + // then + assertThat(updatedProject.getDueDt()).isEqualTo(updatedDueDt); + } + + @Test + void 잘못된_날짜를_입력하면_오류가_발생한다() { + // given + createOriginalProject(); + // when + LocalDate updatedDueDt = LocalDate.of(2024, 1, 1); + ProjectUpdateRequest request = new ProjectUpdateRequest(null, null, updatedDueDt); + + // then + assertThatThrownBy(() -> projectService.updateProject(1L, request)) + .isInstanceOf(CommonException.class) + .hasMessageContaining( + ProjectErrorCode.PROJECT_DUE_DATE_BEFORE_START.getMessage()); + } + } + + @Nested + class 프로젝트_가입_신청 { + @Test + void 프로젝트_가입신청을_하면_정상적으로_요청이_생성된다() { + // given + Long teamId = getTeamId(); + createTestProject(teamId, 1L); + logout(); + loginAs(member1); + teamService.joinTeam(teamInviteCodeRequest); + + // when + projectService.requestToProjectRegistration(1L); + Team team = teamRepository.findById(teamId).get(); + TeamParticipant teamParticipant = + teamParticipantRepository.findByMemberAndTeam(member1, team).get(); + + // then + logout(); + loginAs(memberAdmin); + ProjectRegistrationInfoResponse registrationInfo = + projectService.getProjectRegistration(1L, 1L); + assertThat(registrationInfo.requesterId()).isEqualTo(teamParticipant.getId()); + assertThat( + registrationInfo.projectRegistrationStatus() + == ProjectRegistrationStatus.PENDING); + } + + @Test + void 프로젝트_멤버_수가_15명을_초과하면_가입신청이_제한된다() { + // given + Long teamId = getTeamId(); + createTestProject(teamId, 1L); + logout(); + + for (int i = 0; i < 14; i++) { + Member tmpMember = + Member.createMember( + "tmpMember" + i, + "testProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider")); + + memberRepository.save(tmpMember); + loginAs(tmpMember); + teamService.joinTeam(teamInviteCodeRequest); + projectService.requestToProjectRegistration(1L); + logout(); + } + + loginAs(memberAdmin); + for (long registrationId = 1L; registrationId < 15L; registrationId++) { + projectService.approveProjectRegistration(1L, (registrationId)); + } + logout(); + + loginAs(member1); + teamService.joinTeam(teamInviteCodeRequest); + + // when + assertThatThrownBy(() -> projectService.requestToProjectRegistration(1L)) + .isInstanceOf(CommonException.class) + .hasMessageContaining( + ProjectErrorCode.PROJECT_PARTICIPANT_LIMIT_EXCEED.getMessage()); + } + + @Test + void 프로젝트_가입신청_목록을_조회하면_정상적으로_조회된다() { + // given + Long teamId = getTeamId(); + Team team = teamRepository.findById(teamId).get(); + // 프로젝트 생성, 프로젝트 ID 1L 할당 + createTestProject(teamId, 1L); + logout(); + + // when: member1이 가입 신청 + loginAs(member1); + teamService.joinTeam(teamInviteCodeRequest); + TeamParticipant teamParticipant1 = + teamParticipantRepository.findByMemberAndTeam(member1, team).get(); + projectService.requestToProjectRegistration(1L); + + logout(); + + // when: member2도 가입 신청 + loginAs(member2); + teamService.joinTeam(teamInviteCodeRequest); + TeamParticipant teamParticipant2 = + teamParticipantRepository.findByMemberAndTeam(member2, team).get(); + projectService.requestToProjectRegistration(1L); + + // then: 팀 관리자인 memberAdmin이 가입 신청 목록을 조회 + logout(); + loginAs(memberAdmin); + Slice response = + projectService.getProjectRegistrationList(1L, null, 10); + List requesterIds = + response.stream().map(ProjectRegisterDetailResponse::requesterId).toList(); + assertThat(new HashSet<>(requesterIds)) + .isEqualTo(Set.of(teamParticipant1.getId(), teamParticipant2.getId())); + } + + @Test + void 이미_가입신청한_팀참여자는_신청하면_예외가_발생한다() { + // given + Long teamId = getTeamId(); + createTestProject(teamId, 1L); + logout(); + loginAs(member1); + teamService.joinTeam(teamInviteCodeRequest); + + // when: 최초 가입 신청 + projectService.requestToProjectRegistration(1L); + + // then: 이미 가입 신청한 팀 참여자가 다시 신청하면 예외 발생 + assertThatThrownBy(() -> projectService.requestToProjectRegistration(1L)) + .isInstanceOf(CommonException.class) + .hasMessageContaining( + ProjectErrorCode.PROJECT_REGISTRATION_ALREADY_EXISTS.getMessage()); + } + + @Test + void 이미_가입된_프로젝트_참여자가_가입신청하면_예외가_발생한다() { + // given + Long teamId = getTeamId(); + createTestProject(teamId, 1L); + + // when, then: 아직 가입 신청하지 않은 상태에서(즉, ACTIVE 상태로 이미 가입되어 있는 상태에서) 가입 신청을 시도하면 예외 발생 + assertThatThrownBy(() -> projectService.requestToProjectRegistration(1L)) + .isInstanceOf(CommonException.class) + .hasMessageContaining( + ProjectErrorCode.PROJECT_PARTICIPANT_ALREADY_EXISTS.getMessage()); + } + + @Test + void 프로젝트_가입을_승인하면_정상적으로_승인된다() { + // given + Long teamId = getTeamId(); + createTestProject(teamId, 1L); + logout(); + loginAs(member1); + teamService.joinTeam(teamInviteCodeRequest); + + // when + projectService.requestToProjectRegistration(1L); + Team team = teamRepository.findById(teamId).get(); + TeamParticipant teamParticipant = + teamParticipantRepository.findByMemberAndTeam(member1, team).get(); + + // then + logout(); + loginAs(memberAdmin); + ProjectRegistrationInfoResponse registrationInfo = + projectService.getProjectRegistration(1L, 1L); + projectService.approveProjectRegistration(1L, registrationInfo.registrationId()); + + // then + ProjectRegistrationInfoResponse approvedRegistration = + projectService.getProjectRegistration(1L, 1L); + assertThat(approvedRegistration.requesterId()).isEqualTo(teamParticipant.getId()); + assertThat( + approvedRegistration.projectRegistrationStatus() + == ProjectRegistrationStatus.APPROVED); + } + + @Test + void 프로젝트_가입을_거부하면_정상적으로_거부된다() { + // given + Long teamId = getTeamId(); + createTestProject(teamId, 1L); + logout(); + loginAs(member1); + teamService.joinTeam(teamInviteCodeRequest); + + // when + projectService.requestToProjectRegistration(1L); + Team team = teamRepository.findById(teamId).get(); + TeamParticipant teamParticipant = + teamParticipantRepository.findByMemberAndTeam(member1, team).get(); + + // then + logout(); + loginAs(memberAdmin); + ProjectRegistrationInfoResponse registrationInfo = + projectService.getProjectRegistration(1L, 1L); + projectService.rejectProjectRegistration(1L, registrationInfo.registrationId()); + + // then + ProjectRegistrationInfoResponse approvedRegistration = + projectService.getProjectRegistration(1L, 1L); + assertThat(approvedRegistration.requesterId()).isEqualTo(teamParticipant.getId()); + assertThat( + approvedRegistration.projectRegistrationStatus() + == ProjectRegistrationStatus.REJECTED); + } + + @Test + void 프로젝트_가입을_취소하면_요청이_정상적으로_삭제된다() { + // given + Long teamId = getTeamId(); + createTestProject(teamId, 1L); + logout(); + loginAs(member1); + teamService.joinTeam(teamInviteCodeRequest); + // when + projectService.requestToProjectRegistration(1L); + projectService.deleteProjectRegistration(1L); + // then + logout(); + loginAs(memberAdmin); + assertThatThrownBy(() -> projectService.getProjectRegistration(1L, 1L)) + .isInstanceOf(CommonException.class) + .hasMessageContaining( + ProjectErrorCode.PROJECT_REGISTRATION_NOT_FOUND.getMessage()); + } + + @Test + void 프로젝트_참여자를_조회하면_정상적으로_조회된다() { + // given + Long teamId = getTeamId(); + createTestProject(teamId, 1L); + logout(); // 어드민 로그아웃 + loginAs(member1); + teamService.joinTeam(teamInviteCodeRequest); + + // when + projectService.requestToProjectRegistration(1L); + + // then + logout(); // member project 가입 신청 후 로그아웃 + loginAs(memberAdmin); // admin 다시 로그인 후 가입 승인 + ProjectRegistrationInfoResponse registrationInfo = + projectService.getProjectRegistration(1L, 1L); + projectService.approveProjectRegistration(1L, registrationInfo.registrationId()); + + // then + logout(); + loginAs(member1); + projectService.getProjectParticipantList(1L, null, 1).forEach(System.out::println); + ProjectParticipantInfoResponse myInfo = projectService.getProjectParticipant(1L); + assertThat(myInfo.nickname()).isEqualTo(member1.getNickname()); + assertThat(myInfo.role()).isEqualTo(ProjectParticipantRole.MEMBER); + } + + @Test + void 프로젝트_참여자_목록을_조회하면_정상적으로_조회된다() { + // given + Long teamId = getTeamId(); + createTestProject(teamId, 1L); + logout(); // admin 로그아웃 + + // when + loginAs(member1); + teamService.joinTeam(teamInviteCodeRequest); + projectService.requestToProjectRegistration(1L); + logout(); // + + loginAs(member2); + teamService.joinTeam(teamInviteCodeRequest); + projectService.requestToProjectRegistration(1L); + logout(); + + loginAs(memberAdmin); + + projectService.getProjectRegistrationList(1L, null, 10).stream() + .map(ProjectRegisterDetailResponse::registrationId) + .forEach(i -> projectService.approveProjectRegistration(1L, i)); + + // then + List requesterIds = + projectService.getProjectParticipantList(1L, null, 3).stream() + .map(ProjectParticipantInfoResponse::nickname) + .toList(); + + assertThat(new HashSet<>(requesterIds)) + .isEqualTo( + Set.of( + memberAdmin.getNickname(), + member1.getNickname(), + member2.getNickname())); + } + } + + @Nested + class 프로젝트_삭제_및_나가기 { + + void composeProjectMembers() { + createTestProject(); + logout(); + loginAs(member1); + teamService.joinTeam(teamInviteCodeRequest); + + // when + projectService.requestToProjectRegistration(1L); + + // then + logout(); + loginAs(memberAdmin); + projectService.approveProjectRegistration(1L, 1L); + } + + @Test + void 프로젝트를_삭제하면_정상적으로_삭제된다() { + // given + createTestProject(); + // when + projectService.deleteProject(1L); + // then + assertThatThrownBy(() -> projectService.getProjectInfo(1L)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(ProjectErrorCode.PROJECT_NOT_FOUND.getMessage()); + } + + @Test + void 권한이_없으면_삭제가_거부된다() { + // given + composeProjectMembers(); + logout(); + loginAs(member1); + // when, then + assertThatThrownBy(() -> projectService.deleteProject(1L)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(ProjectErrorCode.UNAUTHORIZED_ACCESS.getMessage()); + } + + @Test + void 프로젝트_참가자가_2명_이상이면_admin은_프로젝트를_못나간다() { + // given + composeProjectMembers(); + + // when, then + assertThatThrownBy(() -> projectService.deleteProjectParticipant(1L)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(ProjectErrorCode.PROJECT_ADMIN_CANNOT_LEAVE.getMessage()); + } + + @Test + void 프로젝트_참가자가_admin_1명이면_프로젝트가_삭제된다() { + // given + createTestProject(); + // when + projectService.deleteProjectParticipant(1L); + // then + assertThatThrownBy(() -> projectService.getProjectInfo(1L)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(ProjectErrorCode.PROJECT_NOT_FOUND.getMessage()); + } + + @Test + void admin권한을_양도하면_정상적으로_프로젝트_참여자가_삭제된다() { + // given + composeProjectMembers(); + Long newAdminId = + projectService.getProjectParticipantList(1L, null, 3).stream() + .filter(r -> !r.nickname().equals(memberAdmin.getNickname())) + .map(ProjectParticipantInfoResponse::projectParticipantId) + .findAny() + .get(); + // when + projectService.changeProjectAdmin(1L, newAdminId); + projectService.deleteProjectParticipant(1L); + // then + assertThat(projectService.getProjectParticipant(1L).status()) + .isEqualTo(ProjectParticipantStatus.INACTIVE); + } + } +} diff --git a/src/test/java/com/amcamp/domain/sprint/application/SprintServiceTest.java b/src/test/java/com/amcamp/domain/sprint/application/SprintServiceTest.java new file mode 100644 index 00000000..1de7d9bf --- /dev/null +++ b/src/test/java/com/amcamp/domain/sprint/application/SprintServiceTest.java @@ -0,0 +1,419 @@ +package com.amcamp.domain.sprint.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.tuple; + +import com.amcamp.IntegrationTest; +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.OauthInfo; +import com.amcamp.domain.project.dao.ProjectParticipantRepository; +import com.amcamp.domain.project.dao.ProjectRepository; +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.domain.ProjectParticipantRole; +import com.amcamp.domain.sprint.dao.SprintRepository; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.domain.sprint.dto.request.SprintCreateRequest; +import com.amcamp.domain.sprint.dto.request.SprintUpdateRequest; +import com.amcamp.domain.sprint.dto.response.SprintDetailResponse; +import com.amcamp.domain.sprint.dto.response.SprintIdResponse; +import com.amcamp.domain.sprint.dto.response.SprintInfoResponse; +import com.amcamp.domain.task.dao.TaskRepository; +import com.amcamp.domain.task.domain.Task; +import com.amcamp.domain.task.domain.TaskDifficulty; +import com.amcamp.domain.task.domain.TaskStatus; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.dao.TeamRepository; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.domain.team.domain.TeamParticipantRole; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import com.amcamp.global.exception.errorcode.SprintErrorCode; +import com.amcamp.global.security.PrincipalDetails; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Slice; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.transaction.annotation.Transactional; + +public class SprintServiceTest extends IntegrationTest { + + private final LocalDate dueDt = LocalDate.of(2026, 3, 1); + + @Autowired private SprintService sprintService; + @Autowired private SprintRepository sprintRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private TeamRepository teamRepository; + @Autowired private TeamParticipantRepository teamParticipantRepository; + @Autowired private ProjectRepository projectRepository; + @Autowired private ProjectParticipantRepository projectParticipantRepository; + @Autowired private TaskRepository taskRepository; + + private Project project; + private Member newMember; + + private void loginAs(Member member) { + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + } + + @BeforeEach + void setUp() { + Member member = + memberRepository.save( + Member.createMember( + "testNickname", + "testProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"))); + + newMember = memberRepository.save(Member.createMember("member", null, null)); + + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + + Team team = Team.createTeam("testName", "testDescription"); + teamRepository.save(team); + TeamParticipant teamParticipant = + TeamParticipant.createParticipant(member, team, TeamParticipantRole.ADMIN); + teamParticipantRepository.save(teamParticipant); + TeamParticipant teamParticipantUser = + teamParticipantRepository.save( + TeamParticipant.createParticipant( + newMember, team, TeamParticipantRole.USER)); + + project = Project.createProject(team, "testTitle", "testDescription", dueDt); + projectRepository.save(project); + ProjectParticipant projectParticipant = + ProjectParticipant.createProjectParticipant( + teamParticipant, project, ProjectParticipantRole.ADMIN); + projectParticipantRepository.save(projectParticipant); + } + + @Nested + class 스프린트_생성할_때 { + @Test + void 프로젝트_기간_내라면_스프린트를_생성한다() { + // given + SprintCreateRequest request = + new SprintCreateRequest(project.getId(), "testGoal", dueDt); + + // when + SprintInfoResponse response = sprintService.createSprint(request); + + // then + assertThat(response.goal()).isEqualTo("testGoal"); + assertThat(response.dueDt()).isEqualTo(LocalDate.of(2026, 3, 1)); + } + + @Test + void 스프린트_마감_날짜가_프로젝트_마감_날짜_이후라면_예외가_발생한다() { + // given + SprintCreateRequest request = + new SprintCreateRequest(project.getId(), "testGoal", LocalDate.of(2026, 3, 2)); + + // when & then + assertThatThrownBy(() -> sprintService.createSprint(request)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_DUE_DATE_EXCEEDS_PROJECT_END.getMessage()); + } + + @Test + void 스프린트_마감_날짜가_현재_날짜_이전이라면_예외가_발생한다() { + // given + SprintCreateRequest request = + new SprintCreateRequest(project.getId(), "testGoal", LocalDate.of(2024, 1, 1)); + + // when + assertThatThrownBy(() -> sprintService.createSprint(request)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_DUE_DATE_BEFORE_START.getMessage()); + } + + @Test + void 기존_스프린트가_종료되지_않은_상태에서_새로운_스프린트를_생성하면_예외가_발생한다() { + // given + sprintRepository.save( + Sprint.createSprint( + project, "testTitle", "testGoal", LocalDate.of(2030, 1, 1))); + + SprintCreateRequest request = + new SprintCreateRequest(project.getId(), "testGoal", LocalDate.of(2031, 1, 1)); + + // when & then + assertThatThrownBy(() -> sprintService.createSprint(request)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.PREVIOUS_SPRINT_NOT_ENDED.getMessage()); + } + } + + @Nested + class 스프린트_수정할_때 { + @Test + void 스프린트가_존재하지_않으면_예외가_발생한다() { + // given + SprintUpdateRequest request = new SprintUpdateRequest("testGoal", null); + + // when & then + assertThatThrownBy(() -> sprintService.updateSprint(2L, request)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_NOT_FOUND.getMessage()); + } + + @Test + void 스프린트_마감_날짜가_프로젝트_마감_날짜_이내라면_성공한다() { + // given + sprintRepository.save( + Sprint.createSprint(project, "testTitle", "testDescription", dueDt)); + SprintUpdateRequest request = new SprintUpdateRequest(null, LocalDate.of(2026, 2, 28)); + + // when + SprintInfoResponse response = sprintService.updateSprint(1L, request); + + // then + assertThat(response.dueDt()).isEqualTo(LocalDate.of(2026, 2, 28)); + assertThat(response.title()).isEqualTo("testTitle"); + } + + @Test + void 스프린트_마감_날짜가_프로젝트_마감_날짜_이후라면_예외가_발생한다() { + // given + sprintRepository.save( + Sprint.createSprint(project, "testTitle", "testDescription", dueDt)); + SprintUpdateRequest request = new SprintUpdateRequest(null, LocalDate.of(2030, 1, 1)); + + // when & then + assertThatThrownBy(() -> sprintService.updateSprint(1L, request)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_DUE_DATE_EXCEEDS_PROJECT_END.getMessage()); + } + + @Test + void 스프린트_마감_날짜가_스프린트_시작_날짜_이전이라면_예외가_발생한다() { + // given + sprintRepository.save( + Sprint.createSprint(project, "testTitle", "testDescription", dueDt)); + SprintUpdateRequest request = new SprintUpdateRequest(null, LocalDate.of(2024, 1, 1)); + + // when & then + assertThatThrownBy(() -> sprintService.updateSprint(1L, request)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_DUE_DATE_BEFORE_START.getMessage()); + } + + @Test + void 다음_스프린트의_시작일_이전에_마감일을_설정하면_예외가_발생한다() { + // given + sprintRepository.save( + Sprint.createSprint(project, "sprint 1", "sprint 1", LocalDate.now())); + sprintRepository.save(Sprint.createSprint(project, "sprint 2", "sprint 2", dueDt)); + + SprintUpdateRequest request = new SprintUpdateRequest(null, LocalDate.of(2026, 1, 1)); + + // when & then + assertThatThrownBy(() -> sprintService.updateSprint(1L, request)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_DUE_DATE_CONFLICT_WITH_NEXT.getMessage()); + } + } + + @Nested + class 스프린트_삭제할_때 { + @Test + void 스프린트가_존재하지_않으면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> sprintService.deleteSprint(2L)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_NOT_FOUND.getMessage()); + } + + @Test + void 프로젝트_리더가_삭제할_경우_성공한다() { + // given + sprintRepository.save( + Sprint.createSprint(project, "testTitle", "testDescription", dueDt)); + + // when + sprintService.deleteSprint(1L); + + // then + assertThat(sprintRepository.findAll()).isEmpty(); + assertThat(sprintRepository.count()).isEqualTo(0); + } + } + + @Nested + class 스프린트_기본정보를_조회할_떄 { + @Test + void 스프린트가_존재하지_않으면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> sprintService.deleteSprint(2L)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_NOT_FOUND.getMessage()); + } + + @Test + void 스프린트_기본_정보를_조회한다() { + // given + List sprintList = + List.of( + Sprint.createSprint(project, "1", "testDescription1", dueDt), + Sprint.createSprint(project, "2", "testDescription2", dueDt), + Sprint.createSprint(project, "3", "testDescription3", dueDt)); + sprintRepository.saveAll(sprintList); + + SprintInfoResponse response = sprintService.findSprint(1L); + + assertThat(response.goal()).isEqualTo("testDescription1"); + } + } + + @Nested + class 프로젝트별_스프린트_목록을_조회할_때 { + @Test + void 프로젝트가_존재하지_않으면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> sprintService.findAllSprint(999L, null, null)) + .isInstanceOf(CommonException.class) + .hasMessage(ProjectErrorCode.PROJECT_NOT_FOUND.getMessage()); + } + + @Test + @Transactional + void 프로젝트가_존재한다면_첫_번째_스프린트를_반환한다() { + // given + List sprintList = + List.of( + Sprint.createSprint(project, "1", "testDescription1", dueDt), + Sprint.createSprint(project, "2", "testDescription2", dueDt), + Sprint.createSprint(project, "3", "testDescription3", dueDt)); + sprintRepository.saveAll(sprintList); + + Sprint sprint = sprintRepository.findById(3L).get(); + + taskRepository.save(Task.createTask(sprint, "태스크 조회 기능 구현1", TaskDifficulty.MID)); + taskRepository.save(Task.createTask(sprint, "태스크 조회 기능 구현2", TaskDifficulty.MID)); + + Task task = taskRepository.findById(1L).get(); + ProjectParticipant participant = projectParticipantRepository.findById(1L).get(); + + task.assignTask(participant); + task.updateTaskStatus(); + + // when + Slice results = + sprintService.findAllSprint(project.getId(), null, null); + + // then + assertThat(results.getSize()).isEqualTo(1); + assertThat(results) + .extracting("id", "title", "goal") + .containsExactlyInAnyOrder(tuple(3L, "3", "testDescription3")); + + assertThat(results.getContent().get(0).taskList().get(0).description()) + .isEqualTo(task.getDescription()); + assertThat(results.getContent().get(0).taskList().size()).isEqualTo(2); + assertThat(results.getContent().get(0).taskList().get(0).taskStatus()) + .isEqualTo(TaskStatus.COMPLETED); + } + } + + @Nested + class 프로젝트별_스프린트아이디_목록을_조회할_때 { + @Test + void 프로젝트가_존재하지_않으면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> sprintService.findAllSprint(999L, null, null)) + .isInstanceOf(CommonException.class) + .hasMessage(ProjectErrorCode.PROJECT_NOT_FOUND.getMessage()); + } + + @Test + void 프로젝트가_존재한다면_모든_스프린트아이디를_반환한다() { + // given + List sprintList = + List.of( + Sprint.createSprint(project, "1", "testDescription1", dueDt), + Sprint.createSprint(project, "2", "testDescription2", dueDt), + Sprint.createSprint(project, "3", "testDescription3", dueDt)); + sprintRepository.saveAll(sprintList); + + // when + List results = sprintService.findAllSprintId(1L); + + // given + assertThat(results.size()).isEqualTo(3); + assertThat(results).extracting("title").containsExactly("1", "2", "3"); // 순서까지 확인 + } + } + + @Nested + class 회원별_스프린트_목록을_조회할_떄 { + @Test + void 프로젝트_참가자가_아니면_예외가_발생한다() { + // given + loginAs(newMember); + + // when & then + assertThatThrownBy(() -> sprintService.findAllSprintByMember(1L, null, null)) + .isInstanceOf(CommonException.class) + .hasMessage(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED.getMessage()); + } + + @Test + @Transactional + void 프로젝트가_존재한다면_마지막_스프린트를_반환한다() { + // given + List sprintList = + List.of( + Sprint.createSprint( + project, "1", "testDescription1", LocalDate.of(2026, 1, 1)), + Sprint.createSprint( + project, "2", "testDescription2", LocalDate.of(2026, 2, 1)), + Sprint.createSprint( + project, "3", "testDescription3", LocalDate.of(2026, 3, 1))); + sprintRepository.saveAll(sprintList); + + Sprint sprint = sprintRepository.findById(3L).get(); + + taskRepository.save(Task.createTask(sprint, "태스크 조회 기능 구현1", TaskDifficulty.MID)); + taskRepository.save(Task.createTask(sprint, "태스크 조회 기능 구현2", TaskDifficulty.MID)); + + Task task = taskRepository.findById(1L).get(); + ProjectParticipant participant = projectParticipantRepository.findById(1L).get(); + + task.assignTask(participant); + task.updateTaskStatus(); + + // when + Slice results = + sprintService.findAllSprintByMember(project.getId(), null, null); + + // then + assertThat(results.getSize()).isEqualTo(1); + assertThat(results) + .extracting("id", "title", "goal") + .containsExactlyInAnyOrder(tuple(3L, "3", "testDescription3")); + + assertThat(results.getContent().get(0).taskList().get(0).description()) + .isEqualTo(task.getDescription()); + assertThat(results.getContent().get(0).taskList().size()).isEqualTo(1); + + assertThat(results.getContent().get(0).progress()).isInstanceOf(Integer.class); + } + } +} diff --git a/src/test/java/com/amcamp/domain/task/TaskServiceTest.java b/src/test/java/com/amcamp/domain/task/TaskServiceTest.java new file mode 100644 index 00000000..cb9a0593 --- /dev/null +++ b/src/test/java/com/amcamp/domain/task/TaskServiceTest.java @@ -0,0 +1,588 @@ +package com.amcamp.domain.task; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.amcamp.IntegrationTest; +import com.amcamp.domain.contribution.dao.ContributionRepository; +import com.amcamp.domain.contribution.domain.Contribution; +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.OauthInfo; +import com.amcamp.domain.project.dao.ProjectParticipantRepository; +import com.amcamp.domain.project.dao.ProjectRepository; +import com.amcamp.domain.project.domain.Project; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.domain.ProjectParticipantRole; +import com.amcamp.domain.sprint.dao.SprintRepository; +import com.amcamp.domain.sprint.domain.Sprint; +import com.amcamp.domain.task.application.TaskService; +import com.amcamp.domain.task.dao.TaskRepository; +import com.amcamp.domain.task.domain.*; +import com.amcamp.domain.task.dto.request.TaskBasicInfoUpdateRequest; +import com.amcamp.domain.task.dto.request.TaskCreateRequest; +import com.amcamp.domain.task.dto.response.TaskBasicInfoResponse; +import com.amcamp.domain.task.dto.response.TaskInfoResponse; +import com.amcamp.domain.team.application.TeamService; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.dao.TeamRepository; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.domain.team.domain.TeamParticipantRole; +import com.amcamp.domain.team.dto.request.TeamInviteCodeRequest; +import com.amcamp.domain.team.dto.response.TeamInviteCodeResponse; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.*; +import com.amcamp.global.security.PrincipalDetails; +import com.amcamp.global.util.MemberUtil; +import jakarta.transaction.Transactional; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Slice; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +public class TaskServiceTest extends IntegrationTest { + @Autowired private TeamRepository teamRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private MemberUtil memberUtil; + @Autowired private ProjectRepository projectRepository; + @Autowired private TeamService teamService; + @Autowired private TaskService taskService; + @Autowired private TaskRepository taskRepository; + @Autowired private SprintRepository sprintRepository; + @Autowired private ContributionRepository contributionRepository; + @Autowired private ProjectParticipantRepository projectParticipantRepository; + @Autowired private TeamParticipantRepository teamParticipantRepository; + + private ProjectParticipant participant; + private ProjectParticipant newParticipant; + private Sprint sprint; + private Sprint anotherSprint; + private Project project; + private Project anotherProject; + private Member newMember; + + private void loginAs(Member member) { + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + } + + @BeforeEach + void setUp() { + Member member = + memberRepository.save( + Member.createMember( + "testNickname", + "testProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"))); + + newMember = + memberRepository.save( + Member.createMember("newNickname", "newProfileImageUrl", null)); + + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + + Team team = teamRepository.save(Team.createTeam("testName", "testDescription")); + TeamParticipant teamParticipantAdmin = + teamParticipantRepository.save( + TeamParticipant.createParticipant(member, team, TeamParticipantRole.ADMIN)); + TeamParticipant teamParticipantUser = + teamParticipantRepository.save( + TeamParticipant.createParticipant( + newMember, team, TeamParticipantRole.USER)); + + project = + projectRepository.save( + Project.createProject( + team, "testTitle", "testDescription", LocalDate.of(2026, 12, 1))); + anotherProject = + projectRepository.save( + Project.createProject( + team, "testTitle", "testDescription", LocalDate.of(2026, 12, 1))); + + participant = + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipantAdmin, project, ProjectParticipantRole.ADMIN)); + newParticipant = + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipantUser, project, ProjectParticipantRole.MEMBER)); + + sprint = + sprintRepository.save( + Sprint.createSprint( + project, "1차 스프린트", "아이디어 기획서 제출", LocalDate.of(2026, 3, 1))); + + anotherSprint = + sprintRepository.save( + Sprint.createSprint( + project, "2차 스프린트", "기능 개발", LocalDate.of(2030, 12, 1))); + } + + @Test + void 태스크를_생성한다() { + // given + Member member = memberUtil.getCurrentMember(); + TaskCreateRequest taskRequest = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + + // when + taskService.createTask(taskRequest); + + // then + Task task = + taskRepository + .findById(1L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + assertThat(task.getSprint().getId()).isEqualTo(taskRequest.sprintId()); + assertThat(task.getDescription()).isEqualTo(taskRequest.description()); + assertThat(task.getTaskDifficulty()).isEqualTo(taskRequest.taskDifficulty()); + } + + @Nested + class 태스크_수정_시 { + @Test + void 태스크가_존재하지않으면_예외처리() { + assertThatThrownBy(() -> taskService.updateTaskStatus(1L)) + .isInstanceOf(CommonException.class) + .hasMessage(TaskErrorCode.TASK_NOT_FOUND.getMessage()); + } + + @Test + @Transactional + void 수정_권한이_없으면_예외처리() { + TaskCreateRequest taskRequest = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest); + taskService.assignTask(1L); + + Task task = + taskRepository + .findById(1L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + loginAs(newMember); + + assertThatThrownBy( + () -> + taskService.updateTaskBasicInfo( + 1L, + new TaskBasicInfoUpdateRequest( + "피그마 화면 설계 재수정", TaskDifficulty.LOW))) + .isInstanceOf(CommonException.class) + .hasMessage(TaskErrorCode.TASK_MODIFY_FORBIDDEN.getMessage()); + } + + @Test + @Transactional + void 태스크가_완료상태면_예외처리() { + // given + TaskCreateRequest taskRequest = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest); + + Task task = + taskRepository + .findById(1L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + // when + task.assignTask(participant); + task.updateTaskStatus(); + + // then + assertThatThrownBy( + () -> + taskService.updateTaskBasicInfo( + 1L, + new TaskBasicInfoUpdateRequest( + "피그마 화면 설계 재수정", TaskDifficulty.HIGH))) + .isInstanceOf(CommonException.class) + .hasMessage(TaskErrorCode.TASK_MODIFY_FORBIDDEN.getMessage()); + + assertThatThrownBy(() -> taskService.deleteTask(1L)) + .isInstanceOf(CommonException.class) + .hasMessage(TaskErrorCode.TASK_MODIFY_FORBIDDEN.getMessage()); + } + + @Test + @Transactional + void 태스크가_SOS상태이면서_본인을_할당하려는_경우_예외처리() { + // given + TaskCreateRequest taskRequest = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest); + + Task task = + taskRepository + .findById(1L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + task.assignTask(participant); + task.updateTaskSOS(); + + // when & then (1) 자기자신을 할당하는 경우 + assertThatThrownBy(() -> taskService.assignTask(1L)) + .isInstanceOf(CommonException.class) + .hasMessage(TaskErrorCode.TASK_ASSIGN_FORBIDDEN.getMessage()); + + // when & then (2) 그렇지 않은 경우 + loginAs(newMember); + task.assignTask(newParticipant); + assertThat(task.getSosStatus()).isEqualTo(SOSStatus.NOT_SOS); + assertThat(task.getAssignee()).isEqualTo(newParticipant); + } + + @Test + @Transactional + void 태스크가_SOS_상태면_예외처리() { + // given + TaskCreateRequest taskRequest = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest); + + Task task = + taskRepository + .findById(1L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + task.assignTask(participant); + task.updateTaskSOS(); + + // when & then + assertThatThrownBy(() -> taskService.updateTaskStatus(1L)) + .isInstanceOf(CommonException.class) + .hasMessage(TaskErrorCode.TASK_COMPLETE_FORBIDDEN.getMessage()); + } + + @Test + void 정상적으로_완료된_태스크를_미완료처리한다() { + // given + TaskCreateRequest taskRequest = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest); + + Task task = + taskRepository + .findById(1L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + task.assignTask(participant); + task.updateTaskStatus(); + + // when + task.updateTaskStatus(); + + // then + assertThat(task.getTaskStatus()).isEqualTo(TaskStatus.ON_GOING); + } + + @Test + void 정상적으로_기본_기한정보를_수정_한다() { + // given + Member member = memberUtil.getCurrentMember(); + TaskCreateRequest taskRequest = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest); + + // when - update basic info & assigned + TaskBasicInfoUpdateRequest taskBasicInfoUpdateRequest = + new TaskBasicInfoUpdateRequest("피그마 화면 설계 재수정", TaskDifficulty.HIGH); + + Task task = + taskRepository + .findById(1L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + TaskInfoResponse response = + taskService.updateTaskBasicInfo(1L, taskBasicInfoUpdateRequest); + response = taskService.assignTask(1L); + + // then + assertThat(task.getSprint().getId()).isEqualTo(taskRequest.sprintId()); + assertThat(response.description()).isEqualTo(taskBasicInfoUpdateRequest.description()); + assertThat(response.taskDifficulty()) + .isEqualTo(taskBasicInfoUpdateRequest.taskDifficulty()); + assertThat(response.taskStatus()).isEqualTo(TaskStatus.ON_GOING); + assertThat(response.assignedStatus()).isEqualTo(AssignedStatus.ASSIGNED); + } + + @Test + @Transactional + void 정상적으로_진행상태를_수정한다() { + Sprint sprint = + sprintRepository + .findById(1L) + .orElseThrow( + () -> new CommonException(SprintErrorCode.SPRINT_NOT_FOUND)); + + TeamParticipant teamParticipant = + teamParticipantRepository + .findByMemberAndTeam( + memberUtil.getCurrentMember(), sprint.getProject().getTeam()) + .orElseThrow( + () -> + new CommonException( + TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + + ProjectParticipant participant = + projectParticipantRepository + .findByProjectAndTeamParticipant(sprint.getProject(), teamParticipant) + .orElseThrow( + () -> + new CommonException( + ProjectErrorCode + .PROJECT_PARTICIPATION_REQUIRED)); + + // when & then # of completed Task is 0 + taskService.createTask(new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID)); + taskService.createTask(new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID)); + taskService.createTask(new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID)); + + assertThat(sprint.getProgress()).isEqualTo(0); + + // when & then # of completed Task is 1 + taskService.assignTask(1L); + taskService.updateTaskStatus(1L); + + Contribution contribution = + contributionRepository + .findBySprintAndParticipant(sprint, participant) + .orElseThrow( + () -> + new CommonException( + ContributionErrorCode.CONTRIBUTION_NOT_FOUND)); + + assertThat(contribution.getScore()).isEqualTo(33.333333333333336); + + Task task = + taskRepository + .findById(1L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + assertThat(task.getTaskStatus()).isEqualTo(TaskStatus.COMPLETED); + assertThat(sprint.getProgress()).isEqualTo(33.333333333333336); + + // when & then # of completed Task is 3 + taskService.assignTask(2L); + taskService.updateTaskStatus(2L); + taskService.assignTask(3L); + taskService.updateTaskStatus(3L); + + assertThat(sprint.getProgress()).isEqualTo(100.0); + assertThat(contribution.getScore()).isEqualTo(100.0); + } + } + + @Nested + class sos_실행_시 { + @Test + void 태스크가_할당되지않았을_경우_SOS_실행하지_않는다() { + // given + Member member = memberUtil.getCurrentMember(); + TaskCreateRequest taskRequest = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest); + + // when + Task task = + taskRepository + .findById(1L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> taskService.updateTaskSOS(task.getId())) + .isInstanceOf(CommonException.class) + .hasMessage(TaskErrorCode.TASK_NOT_ASSIGNED.getMessage()); + } + + @Test + void 태스크가_할당되었을_경우_SOS_실행한다() { + // given + Member member = memberUtil.getCurrentMember(); + TaskCreateRequest taskRequest = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest); + + // when & then + Task task = + taskRepository + .findById(1L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + TaskInfoResponse response = taskService.assignTask(task.getId()); + assertThat(response.sosStatus()).isEqualTo(SOSStatus.NOT_SOS); + + response = taskService.updateTaskSOS(task.getId()); + assertThat(response.sosStatus()).isEqualTo(SOSStatus.SOS); + } + + @Test + @Transactional + void sos인_상태에서_태스크_할당상태를_수정한다() { + // given + TaskCreateRequest taskRequest = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest); + + Task task = + taskRepository + .findById(1L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + taskService.assignTask(task.getId()); + taskService.updateTaskSOS(task.getId()); + + loginAs(newMember); + taskService.assignTask(task.getId()); + + assertThat(task.getAssignedStatus()).isEqualTo(AssignedStatus.ASSIGNED); + assertThat(task.getSosStatus()).isEqualTo(SOSStatus.NOT_SOS); + assertThat(task.getAssignee()).isEqualTo(newParticipant); + } + } + + @Nested + class 태스크_목록_조회_시 { + @Test + void 스프린트가_유효하지않으면_에러를_반환한다() { + // given + TaskCreateRequest taskRequest1 = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest1); + TaskCreateRequest taskRequest2 = + new TaskCreateRequest(1L, "mvp 완성", TaskDifficulty.HIGH); + taskService.createTask(taskRequest2); + + Task task = taskRepository.findById(1L).get(); + + taskService.assignTask(task.getId()); // 첫번쨰 태스크에만 담당자 배정 + + assertThatThrownBy(() -> taskService.getTasksBySprint(999L, 0L, 3)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_NOT_FOUND.getMessage()); + } + + @Test + void 내가_담당하는_태스크_조회_시_프로젝트_참가자가_아니면_에러를_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + TeamInviteCodeResponse teamInviteCodeResponse = teamService.getInviteCode(1L); + TaskCreateRequest taskRequest1 = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest1); + TaskCreateRequest taskRequest2 = + new TaskCreateRequest(1L, "mvp 완성", TaskDifficulty.HIGH); + taskService.createTask(taskRequest2); + + Member nonMember = + memberRepository.save( + Member.createMember("nonMember", "testProfileImageUrl", null)); + loginAs(nonMember); + + teamService.joinTeam(new TeamInviteCodeRequest(teamInviteCodeResponse.inviteCode())); + + // when & then + assertThatThrownBy(() -> taskService.getTasksByMember(1l, 0L, 3)) + .isInstanceOf(CommonException.class) + .hasMessage(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED.getMessage()); + } + + @Test + void 프로젝트_태스크_조회_시_팀_참가자가_아니면_에러를_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + TeamInviteCodeResponse teamInviteCodeResponse = teamService.getInviteCode(1L); + TaskCreateRequest taskRequest1 = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest1); + TaskCreateRequest taskRequest2 = + new TaskCreateRequest(1L, "mvp 완성", TaskDifficulty.HIGH); + taskService.createTask(taskRequest2); + + Member nonMember = + memberRepository.save( + Member.createMember("nonMember", "testProfileImageUrl", null)); + loginAs(nonMember); + + // when & then + assertThatThrownBy(() -> taskService.getTasksBySprint(1l, 0L, 3)) + .isInstanceOf(CommonException.class) + .hasMessage(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED.getMessage()); + } + + @Test + @Transactional + void 프로젝트별로_조회한다() { + // given + List taskList = + List.of( + Task.createTask(sprint, "피그마 화면 설계 수정", TaskDifficulty.MID), + Task.createTask(sprint, "mvp 완성", TaskDifficulty.HIGH)); + taskRepository.saveAll(taskList); + + for (Task task : taskList) { + task.assignTask(participant); + } + + for (Task task : taskList) { + System.out.println( + task.getAssignee().getTeamParticipant().getMember().getNickname()); + } + + // when + Slice result = taskService.getTasksBySprint(1L, null, 3); + + // then + assertThat(result.getContent()).hasSize(2); + System.out.println(result.getContent()); + } + + @Test + void 멤버별로_조회한다() { + // given + Member member = memberUtil.getCurrentMember(); + TaskCreateRequest taskRequest1 = + new TaskCreateRequest(1L, "피그마 화면 설계 수정", TaskDifficulty.MID); + taskService.createTask(taskRequest1); + TaskCreateRequest taskRequest2 = + new TaskCreateRequest(1L, "mvp 완성", TaskDifficulty.HIGH); + taskService.createTask(taskRequest2); + + Task task = + taskRepository + .findById(1L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + taskService.assignTask(task.getId()); // 첫번쨰 태스크에만 담당자 배정 + + Task task1 = + taskRepository + .findById(2L) + .orElseThrow(() -> new CommonException(TaskErrorCode.TASK_NOT_FOUND)); + + taskService.assignTask(task1.getId()); // 첫번쨰 태스크에만 담당자 배정 + + // when + Slice result = taskService.getTasksByMember(1L, 0L, 3); + + // then + assertThat(result.getContent()).hasSize(2); + } + } +} diff --git a/src/test/java/com/amcamp/domain/team/TeamServiceTest.java b/src/test/java/com/amcamp/domain/team/TeamServiceTest.java new file mode 100644 index 00000000..58f5181f --- /dev/null +++ b/src/test/java/com/amcamp/domain/team/TeamServiceTest.java @@ -0,0 +1,614 @@ +package com.amcamp.domain.team; + +import static com.amcamp.global.common.constants.RedisConstants.INVITE_CODE_PREFIX; +import static com.amcamp.global.common.constants.RedisConstants.TEAM_ID_PREFIX; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.tuple; + +import com.amcamp.IntegrationTest; +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.OauthInfo; +import com.amcamp.domain.team.application.TeamService; +import com.amcamp.domain.team.dao.TeamParticipantRepository; +import com.amcamp.domain.team.dao.TeamRepository; +import com.amcamp.domain.team.domain.Team; +import com.amcamp.domain.team.domain.TeamParticipant; +import com.amcamp.domain.team.domain.TeamParticipantRole; +import com.amcamp.domain.team.dto.request.TeamCreateRequest; +import com.amcamp.domain.team.dto.request.TeamInviteCodeRequest; +import com.amcamp.domain.team.dto.request.TeamUpdateRequest; +import com.amcamp.domain.team.dto.response.TeamAdminResponse; +import com.amcamp.domain.team.dto.response.TeamCheckResponse; +import com.amcamp.domain.team.dto.response.TeamInfoResponse; +import com.amcamp.domain.team.dto.response.TeamInviteCodeResponse; +import com.amcamp.global.exception.CommonException; +import com.amcamp.global.exception.errorcode.TeamErrorCode; +import com.amcamp.global.security.PrincipalDetails; +import com.amcamp.global.util.MemberUtil; +import com.amcamp.global.util.RedisUtil; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Slice; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.transaction.annotation.Transactional; + +public class TeamServiceTest extends IntegrationTest { + + @Autowired private TeamRepository teamRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private TeamParticipantRepository teamParticipantRepository; + @Autowired private TeamService teamService; + @Autowired private RedisUtil redisUtil; + @Autowired private MemberUtil memberUtil; + + private void loginAs(Member member) { + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + } + + @BeforeEach + void setUp() { + Member member = + memberRepository.save( + Member.createMember( + "testNickname", + "testProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"))); + + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + } + + @Nested + class 팀_생성_시 { + @Test + void 초대코드를_반환한다() { + // when + Member currentMember = memberUtil.getCurrentMember(); + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + // then + assertThat(inviteCodeResponse).isNotNull(); + assertThat(inviteCodeResponse.inviteCode()).isNotNull(); + assertThat(inviteCodeResponse.inviteCode()).hasSize(8); + + // 생성 시 자동으로 설정되는 정보 확인 + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + Team savedTeam = + teamRepository + .findById(teamCheckResponse.teamId()) + .orElseThrow(() -> new CommonException(TeamErrorCode.TEAM_NOT_FOUND)); + TeamParticipant teamParticipant = + teamParticipantRepository + .findByMemberAndTeam(currentMember, savedTeam) + .orElseThrow( + () -> + new CommonException( + TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + + assertThat(teamParticipant.getRole()).isEqualTo(TeamParticipantRole.ADMIN); + assertThat(savedTeam.getEmoji()).isEqualTo("🍇"); + } + } + + @Nested + class 팀_아이디로_코드확인_시 { + @Test + void 팀이_유효한_경우에는_초대코드를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + Long teamId = + teamService + .getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())) + .teamId(); + + // when + TeamInviteCodeResponse response = teamService.getInviteCode(teamId); + + // then + assertThat(response).isNotNull(); + assertThat(response.inviteCode()).isNotNull(); + assertThat(response.inviteCode()).hasSize(8); + } + + @Test + void 팀이_유효하지않는_경우에는_예외가_발생한다() { + // given + Long invalidTeamId = -999L; + // when & then + assertThatThrownBy(() -> teamService.getInviteCode(invalidTeamId)) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.TEAM_NOT_FOUND); + } + + @Test + void 팀_참가자가_아닌_경우에는_에러를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + Long teamId = + teamService + .getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())) + .teamId(); + + Member nonMember = + memberRepository.save( + Member.createMember("nonMember", "testProfileImageUrl", null)); + loginAs(nonMember); + + // when & then + assertThatThrownBy(() -> teamService.getInviteCode(teamId)) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED); + } + } + + @Nested + class 팀_참가_시 { + @Test + void 이미_팀에_참가한_경우에는_예외가_발생한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + // when & then + assertThatThrownBy( + () -> + teamService.joinTeam( + new TeamInviteCodeRequest( + inviteCodeResponse.inviteCode()))) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.MEMBER_ALREADY_JOINED); + } + + @Test + @Transactional + void 새롭게_참여하는_경우에는_팀에_USER로_등록된다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + String inviteCode = inviteCodeResponse.inviteCode(); + + // savedMember 로그인 처리 후 팀 참여 + Member newMember = memberRepository.save(Member.createMember("member", null, null)); + loginAs(newMember); + + // when + teamService.joinTeam(new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + + // then + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode(new TeamInviteCodeRequest(inviteCode)); + Team savedTeam = + teamRepository + .findById(teamCheckResponse.teamId()) + .orElseThrow(() -> new CommonException(TeamErrorCode.TEAM_NOT_FOUND)); + + TeamParticipant teamParticipant = + teamParticipantRepository + .findByMemberAndTeam(newMember, savedTeam) + .orElseThrow( + () -> + new CommonException( + TeamErrorCode.TEAM_PARTICIPANT_REQUIRED)); + + assertThat(teamParticipant.getMember()).isEqualTo(newMember); + assertThat(teamParticipant.getRole()).isEqualTo(TeamParticipantRole.USER); + assertThat(teamParticipant.getTeam()).isEqualTo(savedTeam); + } + } + + @Nested + class 초대코드로_팀_확인_시 { + @Test + void 코드가_유효한_경우에는_팀_정보를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + // when + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + + // then + assertThat(teamCheckResponse).isNotNull(); + assertThat(teamCheckResponse.teamId()).isNotNull(); + + Team savedTeam = + teamRepository + .findById(teamCheckResponse.teamId()) + .orElseThrow(() -> new CommonException(TeamErrorCode.TEAM_NOT_FOUND)); + + assertThat(teamCheckResponse.teamName()).isEqualTo(savedTeam.getName()); + assertThat(teamCheckResponse.teamId()).isEqualTo(savedTeam.getId()); + } + + @Test + void 코드가_유효하지않는_경우에는_예외를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + String invalidInviteCode = "invalidCode"; + + // when & then + assertThatThrownBy( + () -> + teamService.getTeamByCode( + new TeamInviteCodeRequest(invalidInviteCode))) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.INVALID_INVITE_CODE); + } + } + + @Nested + class 팀_수정_시 { + + @Test + @Transactional + void 팀이름과_팀설명을_수정한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + Long teamId = teamCheckResponse.teamId(); + + // when: all arguments provided + TeamUpdateRequest teamUpdateRequest = new TeamUpdateRequest("새 팀 이름", "새 팀 설명", "❤️"); + TeamInfoResponse teamInfoResponse = teamService.editTeam(teamId, teamUpdateRequest); + + // then + assertThat(teamInfoResponse).isNotNull(); + assertThat(teamInfoResponse.teamName()).isEqualTo("새 팀 이름"); + assertThat(teamInfoResponse.teamDescription()).isEqualTo("새 팀 설명"); + assertThat(teamInfoResponse.teamEmoji()).isEqualTo("❤️"); + + // when: new team name is missing + TeamUpdateRequest newTeamUpdateRequest = new TeamUpdateRequest(null, "새 팀 설명-2", "⭐️"); + TeamInfoResponse newTeamInfoResponse = + teamService.editTeam(teamId, newTeamUpdateRequest); + + // then + assertThat(newTeamInfoResponse).isNotNull(); + assertThat(newTeamInfoResponse.teamName()).isEqualTo("새 팀 이름"); + assertThat(newTeamInfoResponse.teamDescription()).isEqualTo("새 팀 설명-2"); + assertThat(newTeamInfoResponse.teamEmoji()).isEqualTo("⭐️"); + } + + @Test + void 팀이_유효하지않는_경우에는_예외를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + Long invalidTeamId = -999L; + + // when & then + assertThatThrownBy( + () -> + teamService.editTeam( + invalidTeamId, + new TeamUpdateRequest("새 팀 이름", "새 팀 설명", "❤️"))) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.TEAM_NOT_FOUND); + } + + @Test + void 로그인된_회원이_팀_참가자가_아닌_경우에는_예외를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + Long teamId = teamCheckResponse.teamId(); + + Member nonMember = + memberRepository.save( + Member.createMember("nonMember", "testProfileImageUrl", null)); + loginAs(nonMember); + + // when & then + assertThatThrownBy( + () -> + teamService.editTeam( + teamId, + new TeamUpdateRequest("새 팀 이름", "새 팀 설명", "❤️"))) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED); // 사용자 권한이 없을 때 + } + + @Test + void 로그인된_회원이_팀_관리자가_아닌_경우에는_예외를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + Long teamId = teamCheckResponse.teamId(); + + // 일반 사용자 로그인 및 팀 참가 + Member userMember = + memberRepository.save(Member.createMember("user", "testProfileImageUrl", null)); + loginAs(userMember); + teamService.joinTeam(new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + + // when & then + assertThatThrownBy( + () -> + teamService.editTeam( + teamId, + new TeamUpdateRequest("새 팀 이름", "새 팀 설명", "❤️"))) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.UNAUTHORIZED_ACCESS); // 팀 관리자 권한이 없을 경우 + } + } + + @Nested + class 팀_삭제_시 { + @Test + void 팀을_삭제한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + Long teamId = teamCheckResponse.teamId(); + + // when + teamService.deleteTeam(teamId); + + // then + assertThatThrownBy( + () -> + teamService.getTeamByCode( + new TeamInviteCodeRequest( + inviteCodeResponse.inviteCode()))) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.INVALID_INVITE_CODE); + + // 1. 팀 삭제 확인 + assertThat(teamRepository.findById(teamId)).isEmpty(); + // 2. 팀 참가자 삭제 확인 + assertThat(teamParticipantRepository.findByTeamId(teamId)).isEmpty(); + // 3. 초대 코드가 Redis에서 삭제되었는지 확인 + Optional inviteCodeInRedisAfterDelete = + redisUtil.getData(TEAM_ID_PREFIX.formatted(teamId)); + Optional teamIdInRedisAfterDelete = + redisUtil.getData( + INVITE_CODE_PREFIX.formatted(inviteCodeResponse.inviteCode())); + assertThat(inviteCodeInRedisAfterDelete).isEmpty(); + assertThat(teamIdInRedisAfterDelete).isEmpty(); + } + + @Test + void 팀이_유효하지않는_경우에는_예외를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + Long invalidTeamId = -999L; + + // when & then + assertThatThrownBy(() -> teamService.deleteTeam(invalidTeamId)) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.TEAM_NOT_FOUND); // 존재하지 않는 팀 삭제 시 예외 + } + + @Test + void 로그인된_회원이_팀_참가자가_아닌_경우에는_예외를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + Long teamId = teamCheckResponse.teamId(); + + Member nonMember = + memberRepository.save( + Member.createMember("nonMember", "testProfileImageUrl", null)); + loginAs(nonMember); + + // when & then + assertThatThrownBy(() -> teamService.deleteTeam(teamId)) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED); + } + + @Test + void 로그인된_회원이_팀_관리자가_아닌_경우에는_예외를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + Long teamId = teamCheckResponse.teamId(); + + Member userMember = + memberRepository.save(Member.createMember("user", "testProfileImageUrl", null)); + loginAs(userMember); + teamService.joinTeam(new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + + // when & then + assertThatThrownBy(() -> teamService.deleteTeam(teamId)) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.UNAUTHORIZED_ACCESS); + } + } + + @Nested + class 팀_정보_조회_시 { + @Test + void 팀이름과_팀설명을_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + Long teamId = teamCheckResponse.teamId(); + + // when + TeamInfoResponse teamInfoResponse = teamService.getTeamInfo(teamId); + + // then + assertThat(teamInfoResponse).isNotNull(); + assertThat(teamInfoResponse.teamName()).isEqualTo("팀 이름"); + assertThat(teamInfoResponse.teamDescription()).isEqualTo("팀 설명"); + } + + @Test + void 팀이_유효하지않는_경우에는_예외를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + Long invalidTeamId = -999L; + + // when & then + assertThatThrownBy(() -> teamService.getTeamInfo(invalidTeamId)) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.TEAM_NOT_FOUND); + } + + @Test + void 로그인된_회원이_팀_참가자가_아닌_경우에는_예외를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + Long teamId = teamCheckResponse.teamId(); + + Member nonMember = + memberRepository.save( + Member.createMember("nonMember", "testProfileImageUrl", null)); + loginAs(nonMember); + + // when & then + assertThatThrownBy(() -> teamService.getTeamInfo(teamId)) + .isInstanceOf(CommonException.class) + .extracting("errorCode") + .isEqualTo(TeamErrorCode.TEAM_PARTICIPANT_REQUIRED); + } + } + + @Nested + class 회원이_참여한_팀_목록_조회_시 { + @Test + void 회원이_참여한_팀_정보를_반환한다() { + // given + List requests = + List.of( + TeamCreateRequest.of("testTeamName1", "testTeamDescription1"), + TeamCreateRequest.of("testTeamName2", "testTeamDescription2"), + TeamCreateRequest.of("testTeamName3", "testTeamDescription3")); + + for (TeamCreateRequest request : requests) { + teamService.createTeam(request); + } + + // when + Slice results = teamService.findAllTeam(null, 3); + + // then + assertThat(results.getSize()).isEqualTo(3); + assertThat(results) + .extracting("teamId", "teamName", "teamDescription") + .containsExactlyInAnyOrder( + tuple(3L, "testTeamName3", "testTeamDescription3"), + tuple(2L, "testTeamName2", "testTeamDescription2"), + tuple(1L, "testTeamName1", "testTeamDescription1")); + } + + @Test + void 회원이_참여한_팀이_존재하지_않을_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> teamService.findAllTeam(0L, 4)) + .isInstanceOf(CommonException.class) + .hasMessage(TeamErrorCode.TEAM_NOT_EXISTS.getMessage()); + } + } + + @Test + void 회원이_참여한_팀의_팀장_정보를_반환한다() { + // given + TeamInviteCodeResponse inviteCodeResponse = + teamService.createTeam(new TeamCreateRequest("팀 이름", "팀 설명")); + + TeamCheckResponse teamCheckResponse = + teamService.getTeamByCode( + new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + Long teamId = teamCheckResponse.teamId(); + + // when + TeamAdminResponse result = teamService.findTeamAdmin(teamId); + + // then + assertThat(result) + .extracting("memberId", "nickname", "profileImageUrl") + .containsExactly(1L, "testNickname", "testProfileImageUrl"); + + // 다른 멤버가 팀 참가 후 팀장 정보를 조회했을 때도 동일한지 확인 + Member userMember = + memberRepository.save(Member.createMember("user", "testProfileImageUrl", null)); + loginAs(userMember); + teamService.joinTeam(new TeamInviteCodeRequest(inviteCodeResponse.inviteCode())); + + // when + TeamAdminResponse anotherResult = teamService.findTeamAdmin(teamId); + + // then + assertThat(anotherResult) + .extracting("memberId", "nickname", "profileImageUrl") + .containsExactly(1L, "testNickname", "testProfileImageUrl"); + } +} diff --git a/src/test/java/com/amcamp/global/exception/ExceptionHandlerTest.java b/src/test/java/com/amcamp/global/exception/ExceptionHandlerTest.java new file mode 100644 index 00000000..fa392d46 --- /dev/null +++ b/src/test/java/com/amcamp/global/exception/ExceptionHandlerTest.java @@ -0,0 +1,71 @@ +package com.amcamp.global.exception; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.amcamp.global.common.response.CommonResponse; +import com.amcamp.global.exception.errorcode.AuthErrorCode; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class ExceptionHandlerTest { + + AuthErrorCode authErrorCode = AuthErrorCode.ID_TOKEN_VERIFICATION_FAILED; + ProjectErrorCode projectErrorCode = ProjectErrorCode.PROJECT_NOT_FOUND; + + void throwExceptionWithAuthErrorCode(Object o) { + Optional mockObject = Optional.ofNullable(o); + if (!mockObject.isPresent()) { + throw new CommonException(authErrorCode); + } + } + + void throwExceptionWithProjectErrorCode(Object o) { + Optional mockObject = Optional.ofNullable(o); + if (!mockObject.isPresent()) { + throw new CommonException(projectErrorCode); + } + } + + @Test + @DisplayName("authError Test") + void commonExceptionWithAuthErrorTest() { + assertThatThrownBy(() -> throwExceptionWithAuthErrorCode(null)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(authErrorCode.getMessage()); + } + + @Test + @DisplayName("projectError Test") + void commonExceptionWithProjectErrorTest() { + assertThatThrownBy(() -> throwExceptionWithProjectErrorCode(null)) + .isInstanceOf(CommonException.class) + .hasMessageContaining(projectErrorCode.getMessage()); + } + + @Test + @DisplayName("ExceptionHandler Test") + void globalExceptionHandlerTest() { + + // given: 예외 핸들러와 예외 생성 + GlobalExceptionHandler globalExceptionHandler = new GlobalExceptionHandler(); + CommonException exception = new CommonException(ProjectErrorCode.PROJECT_NOT_FOUND); + + // when: 예외 핸들러 실행 + CommonResponse response = + globalExceptionHandler.handleCustomException(exception).getBody(); + + // then: 응답 객체 검증 + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(404); + + // ErrorResponse 객체 검증 + ErrorResponse errorResponse = (ErrorResponse) response.getData(); + assertThat(errorResponse.errorClassName()) + .isEqualTo(ProjectErrorCode.PROJECT_NOT_FOUND.name()); + assertThat(errorResponse.message()) + .isEqualTo(ProjectErrorCode.PROJECT_NOT_FOUND.getMessage()); + } +} diff --git a/src/test/java/com/amcamp/global/util/MemberUtilTest.java b/src/test/java/com/amcamp/global/util/MemberUtilTest.java new file mode 100644 index 00000000..c692721a --- /dev/null +++ b/src/test/java/com/amcamp/global/util/MemberUtilTest.java @@ -0,0 +1,54 @@ +package com.amcamp.global.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.amcamp.domain.member.dao.MemberRepository; +import com.amcamp.domain.member.domain.Member; +import com.amcamp.domain.member.domain.OauthInfo; +import com.amcamp.global.security.PrincipalDetails; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public class MemberUtilTest { + + @Autowired private MemberUtil memberUtil; + @Autowired private MemberRepository memberRepository; + + private Member registerAuthenticatedMember() { + Member member = + Member.createMember( + "testNickName", + "testProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider")); + memberRepository.save(member); + + UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(token); + + return member; + } + + @Test + void 로그인한_멤버의_정보를_조회한다() { + + // given + Member member = registerAuthenticatedMember(); + + // when + Member currentMember = memberUtil.getCurrentMember(); + + // then + assertThat(member.getId()).isEqualTo(currentMember.getId()); + assertThat(member.getRole()).isEqualTo(currentMember.getRole()); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..21d0d118 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,6 @@ +spring: + config: + activate: + on-profile: "test" + datasource: + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=MYSQL