diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index db97ea46..a41adcf4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @wjdtkdgns @yourzinc @Jeong-Hyeon-Lee @chaeshee0908 @suhhyun524 \ No newline at end of file +* @wjdtkdgns @yourzinc @hyunihs @chaeshee0908 @suhhyun524 \ No newline at end of file diff --git a/.github/workflows/deploy_dev.yml b/.github/workflows/deploy_dev.yml new file mode 100644 index 00000000..072eb32a --- /dev/null +++ b/.github/workflows/deploy_dev.yml @@ -0,0 +1,102 @@ +name: Deploy to EC2 + +on: + push: + branches: + - dev + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + # 기본 체크아웃 + - name: Checkout + uses: actions/checkout@v3 + + # JDK version 설정 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + # 그래들 캐싱 + - name: Gradle Caching + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Make application-secret.yml + run: | + touch ./src/main/resources/application-secret.yml + echo "${{ secrets.APPLICATION_SECRET }}" > ./src/main/resources/application-secret.yml + env: + PROPERTIES_DEV: ${{ secrets.APPLICATION_SECRET }} + + # Gradle build + - name: Build with Gradle + run: ./gradlew build -x test :spotlessApply + + - name: Docker meta + id: docker_meta + uses: crazy-max/ghaction-docker-meta@v1 + with: + images: ceos/ceos-server-dev + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Docker build & push + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/ceos-backend-dev + + - name: create remote directory + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST_DEV }} + username: ubuntu + key: ${{ secrets.EC2_KEY }} + script: mkdir -p ~/srv/ubuntu/ceos_web_dev + + - name: copy source via ssh key + uses: burnett01/rsync-deployments@4.1 + with: + switches: -avzr --delete + remote_path: ~/srv/ubuntu/ + remote_host: ${{ secrets.EC2_HOST_DEV }} + remote_user: ubuntu + remote_key: ${{ secrets.EC2_KEY }} + + - name: executing remote ssh commands using password + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST_DEV }} + username: ubuntu + key: ${{ secrets.EC2_KEY }} + script: | + sh ~/srv/ubuntu/config/scripts/deploy.sh + sudo docker stop $(sudo docker ps -a -q) + sudo docker rm $(sudo docker ps -a -q) + sudo docker rmi $(sudo docker images -q) + sudo docker-compose -f ~/srv/ubuntu/docker-compose.yml pull + sudo docker-compose -f ~/srv/ubuntu/docker-compose.yml up --build -d + diff --git a/.github/workflows/deploy_prod.yml b/.github/workflows/deploy_prod.yml new file mode 100644 index 00000000..b99dcf24 --- /dev/null +++ b/.github/workflows/deploy_prod.yml @@ -0,0 +1,102 @@ +name: Deploy to EC2 + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + # 기본 체크아웃 + - name: Checkout + uses: actions/checkout@v3 + + # JDK version 설정 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + # 그래들 캐싱 + - name: Gradle Caching + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Make application-secret.yml + run: | + touch ./src/main/resources/application-secret.yml + echo "${{ secrets.APPLICATION_SECRET }}" > ./src/main/resources/application-secret.yml + env: + PROPERTIES_DEV: ${{ secrets.APPLICATION_SECRET }} + + # Gradle build + - name: Build with Gradle + run: ./gradlew build -x test :spotlessApply + + - name: Docker meta + id: docker_meta + uses: crazy-max/ghaction-docker-meta@v1 + with: + images: ceos/ceos-server-dev + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Docker build & push + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/ceos-backend-dev + + - name: create remote directory + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST_PROD }} + username: ubuntu + key: ${{ secrets.EC2_KEY }} + script: mkdir -p ~/srv/ubuntu/ceos_web_dev + + - name: copy source via ssh key + uses: burnett01/rsync-deployments@4.1 + with: + switches: -avzr --delete + remote_path: ~/srv/ubuntu/ + remote_host: ${{ secrets.EC2_HOST_PROD }} + remote_user: ubuntu + remote_key: ${{ secrets.EC2_KEY }} + + - name: executing remote ssh commands using password + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST_PROD }} + username: ubuntu + key: ${{ secrets.EC2_KEY }} + script: | + sh ~/srv/ubuntu/config/scripts/deploy.sh + sudo docker stop $(sudo docker ps -a -q) + sudo docker rm $(sudo docker ps -a -q) + sudo docker rmi $(sudo docker images -q) + sudo docker-compose -f ~/srv/ubuntu/docker-compose.yml pull + sudo docker-compose -f ~/srv/ubuntu/docker-compose.yml up --build -d + diff --git a/.gitignore b/.gitignore index fc2fa084..7a498aae 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +/dump.rdb ### STS ### .apt_generated @@ -37,3 +38,6 @@ out/ .vscode/ .DS_Store + +### application ### +application-secret.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..7ab35426 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM openjdk:17 +EXPOSE 80 +ARG JAR_FILE=/build/libs/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java","-jar","-Duser.timezone=Asia/Seoul","-Dspring.profiles.active=dev","/app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index def85dbd..a033137d 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.0.6' id 'io.spring.dependency-management' version '1.1.0' + id 'com.diffplug.spotless' version '6.11.0' } group = 'ceos' @@ -21,14 +22,53 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + runtimeOnly 'com.mysql:mysql-connector-j' + + // slack + implementation("com.slack.api:slack-api-client:1.28.0") + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.0.0' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // ses + implementation group: 'software.amazon.awssdk', name: 'ses', version: "2.19.29" + implementation('org.springframework.boot:spring-boot-starter-thymeleaf') + + //s3 + implementation group: 'software.amazon.awssdk', name: 's3', version: "2.20.68" + + implementation 'com.fasterxml.jackson.core:jackson-databind:2.14.2' + + // Apache POI + implementation 'org.apache.poi:poi:4.1.2' + implementation 'org.apache.poi:poi-ooxml:4.1.2' } tasks.named('test') { useJUnitPlatform() } + +spotless { + java { + target("**/*.java") + googleJavaFormat().aosp() + importOrder() + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + } +} \ No newline at end of file diff --git a/config/nginx/Dockerfile b/config/nginx/Dockerfile new file mode 100644 index 00000000..30be99d3 --- /dev/null +++ b/config/nginx/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:1.23.2 +COPY ./default.conf /etc/nginx/conf.d/default.conf \ No newline at end of file diff --git a/config/nginx/default.conf b/config/nginx/default.conf new file mode 100644 index 00000000..d648d69f --- /dev/null +++ b/config/nginx/default.conf @@ -0,0 +1,12 @@ +server { + listen 80; + listen [::]:80; + + location / { + proxy_set_header Host $host; + proxy_pass http://backend-dev:8080/; + proxy_read_timeout 90; + + ## try_files $uri $uri/ =404; + } +} \ No newline at end of file diff --git a/config/scripts/deploy.sh b/config/scripts/deploy.sh new file mode 100644 index 00000000..47da310b --- /dev/null +++ b/config/scripts/deploy.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Installing docker engine if not exists +if ! type docker > /dev/null +then + echo "docker does not exist" + echo "Start installing docker" + sudo apt-get update + sudo apt install -y apt-transport-https ca-certificates curl software-properties-common + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - + sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable" + sudo apt update + apt-cache policy docker-ce + sudo apt install -y docker-ce +fi + +# Installing docker-compose if not exists +if ! type docker-compose > /dev/null +then + echo "docker-compose does not exist" + echo "Start installing docker-compose" + sudo curl -L "https://github.com/docker/compose/releases/download/1.27.3/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose +fi \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..bf06fc2a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: "3" + +services: + backend-dev: + image: ceos17/ceos-backend-dev + container_name: backend-dev + hostname: backend-dev + expose: + - "8080" + + nginx: + depends_on: + - backend-dev + restart: always + build: + dockerfile: Dockerfile + context: ./config/nginx + ports: + - "80:80" + + redis: + image: redis:latest + container_name: redis + hostname: redis + ports: + - "6379:6379" \ No newline at end of file diff --git a/src/main/java/ceos/backend/BackendApplication.java b/src/main/java/ceos/backend/BackendApplication.java index e22ca053..3127eed6 100644 --- a/src/main/java/ceos/backend/BackendApplication.java +++ b/src/main/java/ceos/backend/BackendApplication.java @@ -1,13 +1,17 @@ package ceos.backend; + import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication +@EnableAsync +@EnableJpaAuditing public class BackendApplication { - public static void main(String[] args) { - SpringApplication.run(BackendApplication.class, args); - } - + public static void main(String[] args) { + SpringApplication.run(BackendApplication.class, args); + } } diff --git a/src/main/java/ceos/backend/domain/activity/ActivityController.java b/src/main/java/ceos/backend/domain/activity/ActivityController.java new file mode 100644 index 00000000..6552b4e0 --- /dev/null +++ b/src/main/java/ceos/backend/domain/activity/ActivityController.java @@ -0,0 +1,68 @@ +package ceos.backend.domain.activity; + + +import ceos.backend.domain.activity.dto.ActivityRequest; +import ceos.backend.domain.activity.dto.ActivityResponse; +import ceos.backend.domain.activity.dto.GetAllActivitiesResponse; +import ceos.backend.domain.activity.service.ActivityService; +import ceos.backend.global.common.dto.AwsS3Url; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/activities") +@Tag(name = "Activity") +public class ActivityController { + + private final ActivityService activityService; + + @Operation(summary = "활동 생성하기") + @PostMapping + public void createActivity(@RequestBody @Valid ActivityRequest activityRequest) { + log.info("활동 생성하기"); + activityService.createActivity(activityRequest); + } + + @Operation(summary = "활동 조회하기") + @GetMapping("/{id}") + public ActivityResponse getActivity(@PathVariable Long id) { + log.info("활동 조회하기"); + return activityService.getActivity(id); + } + + @Operation(summary = "활동 전체 조회하기") + @GetMapping + public GetAllActivitiesResponse getAllActivities( + @RequestParam("pageNum") int pageNum, @RequestParam("limit") int limit) { + log.info("활동 전체 조회하기"); + return activityService.getAllActivities(pageNum, limit); + } + + @Operation(summary = "활동 수정하기") + @PutMapping("/{id}") + public ActivityResponse updateActivity( + @PathVariable Long id, @RequestBody @Valid ActivityRequest activityRequest) { + log.info("활동 수정하기"); + return activityService.updateActivity(id, activityRequest); + } + + @Operation(summary = "활동 삭제하기") + @DeleteMapping("/{id}") + public void deleteActivity(@PathVariable Long id) { + log.info("활동 삭제하기"); + activityService.deleteActivity(id); + } + + @Operation(summary = "활동 이미지 url 생성하기") + @GetMapping("/image") + public AwsS3Url getImageUrl() { + log.info("활동 이미지 url 생성하기"); + return activityService.getImageUrl(); + } +} diff --git a/src/main/java/ceos/backend/domain/activity/converter/ActivityConverter.java b/src/main/java/ceos/backend/domain/activity/converter/ActivityConverter.java new file mode 100644 index 00000000..fcc14c04 --- /dev/null +++ b/src/main/java/ceos/backend/domain/activity/converter/ActivityConverter.java @@ -0,0 +1,27 @@ +package ceos.backend.domain.activity.converter; + + +import ceos.backend.domain.activity.domain.Activity; +import ceos.backend.domain.activity.dto.ActivityResponse; +import ceos.backend.domain.activity.dto.GetAllActivitiesResponse; +import ceos.backend.global.common.dto.PageInfo; +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class ActivityConverter { + public ActivityResponse toDTO(Activity activity) { + return ActivityResponse.from(activity); + } + + public GetAllActivitiesResponse toActivitiesPage(List activities, PageInfo pageInfo) { + List activityResponses = new ArrayList<>(); + + for (Activity activity : activities) { + ActivityResponse activityResponse = ActivityResponse.from(activity); + activityResponses.add(activityResponse); + } + return GetAllActivitiesResponse.of(activityResponses, pageInfo); + } +} diff --git a/src/main/java/ceos/backend/domain/activity/domain/Activity.java b/src/main/java/ceos/backend/domain/activity/domain/Activity.java new file mode 100644 index 00000000..5d9a6bc1 --- /dev/null +++ b/src/main/java/ceos/backend/domain/activity/domain/Activity.java @@ -0,0 +1,52 @@ +package ceos.backend.domain.activity.domain; + + +import ceos.backend.domain.activity.dto.ActivityRequest; +import ceos.backend.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Activity extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "activity_id") + private Long id; + + @NotNull + @Size(max = 30) + private String name; + + @NotNull + @Size(max = 255) + private String content; + + @NotNull private String imageUrl; + + @Builder + private Activity(String name, String content, String imageUrl) { + + this.name = name; + this.content = content; + this.imageUrl = imageUrl; + } + + public static Activity from(ActivityRequest activityRequest) { + return Activity.builder() + .name(activityRequest.getName()) + .content(activityRequest.getContent()) + .imageUrl(activityRequest.getImageUrl()) + .build(); + } + + public void updateActivity(ActivityRequest activityRequest) { + name = activityRequest.getName(); + content = activityRequest.getContent(); + imageUrl = activityRequest.getImageUrl(); + } +} diff --git a/src/main/java/ceos/backend/domain/activity/dto/ActivityRequest.java b/src/main/java/ceos/backend/domain/activity/dto/ActivityRequest.java new file mode 100644 index 00000000..455dca1f --- /dev/null +++ b/src/main/java/ceos/backend/domain/activity/dto/ActivityRequest.java @@ -0,0 +1,37 @@ +package ceos.backend.domain.activity.dto; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ActivityRequest { + @Schema(defaultValue = "데모데이", description = "활동 이름") + @NotEmpty(message = "활동 이름을 입력해주세요") + @Valid + private String name; + + @Schema(defaultValue = "데모데이 짱입니다", description = "활동 설명") + @NotEmpty(message = "활동 설명을 입력해주세요") + @Valid + private String content; + + @Schema(defaultValue = "demoday.jpg", description = "활동 이미지") + @NotEmpty(message = "활동 이미지를 입력해주세요") + @Valid + private String imageUrl; + + @Builder + private ActivityRequest(String name, String content, String imageUrl) { + this.name = name; + this.content = content; + this.imageUrl = imageUrl; + } + + public static ActivityRequest of(String name, String content, String imageUrl) { + return ActivityRequest.builder().name(name).content(content).imageUrl(imageUrl).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/activity/dto/ActivityResponse.java b/src/main/java/ceos/backend/domain/activity/dto/ActivityResponse.java new file mode 100644 index 00000000..d227f235 --- /dev/null +++ b/src/main/java/ceos/backend/domain/activity/dto/ActivityResponse.java @@ -0,0 +1,35 @@ +package ceos.backend.domain.activity.dto; + + +import ceos.backend.domain.activity.domain.Activity; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ActivityResponse { + + private Long id; + + private String name; + + private String content; + + private String imageUrl; + + @Builder + private ActivityResponse(Long id, String name, String content, String imageUrl) { + this.id = id; + this.name = name; + this.content = content; + this.imageUrl = imageUrl; + } + + public static ActivityResponse from(Activity activity) { + return ActivityResponse.builder() + .id(activity.getId()) + .name(activity.getName()) + .content(activity.getContent()) + .imageUrl(activity.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/activity/dto/GetAllActivitiesResponse.java b/src/main/java/ceos/backend/domain/activity/dto/GetAllActivitiesResponse.java new file mode 100644 index 00000000..4effd0dc --- /dev/null +++ b/src/main/java/ceos/backend/domain/activity/dto/GetAllActivitiesResponse.java @@ -0,0 +1,24 @@ +package ceos.backend.domain.activity.dto; + + +import ceos.backend.global.common.dto.PageInfo; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetAllActivitiesResponse { + List content; + PageInfo pageInfo; + + @Builder + private GetAllActivitiesResponse(List activities, PageInfo pageInfo) { + this.content = activities; + this.pageInfo = pageInfo; + } + + public static GetAllActivitiesResponse of( + List activities, PageInfo pageInfo) { + return GetAllActivitiesResponse.builder().activities(activities).pageInfo(pageInfo).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/activity/exception/ActivityErrorCode.java b/src/main/java/ceos/backend/domain/activity/exception/ActivityErrorCode.java new file mode 100644 index 00000000..b32c797c --- /dev/null +++ b/src/main/java/ceos/backend/domain/activity/exception/ActivityErrorCode.java @@ -0,0 +1,24 @@ +package ceos.backend.domain.activity.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import ceos.backend.global.common.dto.ErrorReason; +import ceos.backend.global.error.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ActivityErrorCode implements BaseErrorCode { + ACTIVITY_NOT_FOUND(BAD_REQUEST, "ACTIVITY_404_1", "존재하지 않는 활동입니다."); + + private HttpStatus status; + private String code; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/ceos/backend/domain/activity/exception/ActivityNotFound.java b/src/main/java/ceos/backend/domain/activity/exception/ActivityNotFound.java new file mode 100644 index 00000000..0416436e --- /dev/null +++ b/src/main/java/ceos/backend/domain/activity/exception/ActivityNotFound.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.activity.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class ActivityNotFound extends BaseErrorException { + + public static final ActivityNotFound EXCEPTION = new ActivityNotFound(); + + public ActivityNotFound() { + super(ActivityErrorCode.ACTIVITY_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/activity/repository/ActivityRepository.java b/src/main/java/ceos/backend/domain/activity/repository/ActivityRepository.java new file mode 100644 index 00000000..48a6c08a --- /dev/null +++ b/src/main/java/ceos/backend/domain/activity/repository/ActivityRepository.java @@ -0,0 +1,7 @@ +package ceos.backend.domain.activity.repository; + + +import ceos.backend.domain.activity.domain.Activity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActivityRepository extends JpaRepository {} diff --git a/src/main/java/ceos/backend/domain/activity/service/ActivityService.java b/src/main/java/ceos/backend/domain/activity/service/ActivityService.java new file mode 100644 index 00000000..df99ddd2 --- /dev/null +++ b/src/main/java/ceos/backend/domain/activity/service/ActivityService.java @@ -0,0 +1,93 @@ +package ceos.backend.domain.activity.service; + + +import ceos.backend.domain.activity.converter.ActivityConverter; +import ceos.backend.domain.activity.domain.Activity; +import ceos.backend.domain.activity.dto.ActivityRequest; +import ceos.backend.domain.activity.dto.ActivityResponse; +import ceos.backend.domain.activity.dto.GetAllActivitiesResponse; +import ceos.backend.domain.activity.exception.ActivityNotFound; +import ceos.backend.domain.activity.repository.ActivityRepository; +import ceos.backend.global.common.dto.AwsS3Url; +import ceos.backend.global.common.dto.PageInfo; +import ceos.backend.infra.s3.AwsS3UrlHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ActivityService { + + private final ActivityRepository activityRepository; + private final ActivityConverter activityConverter; + private final AwsS3UrlHandler awsS3UrlHandler; + + /** 활동 추가 */ + @Transactional + public void createActivity(ActivityRequest activityRequest) { + Activity activity = Activity.from(activityRequest); + activityRepository.save(activity); + } + + /** 활동 조회 */ + @Transactional(readOnly = true) + public ActivityResponse getActivity(Long id) { + return activityConverter.toDTO( + activityRepository.findById(id).orElseThrow(() -> new ActivityNotFound())); + } + + /** 활동 전체 조회 */ + @Transactional(readOnly = true) + public GetAllActivitiesResponse getAllActivities(int pageNum, int limit) { + // 페이징 요청 정보 + PageRequest pageRequest = PageRequest.of(pageNum, limit, Sort.by("id").ascending()); + + Page pageActivities = activityRepository.findAll(pageRequest); + + // 페이징 정보 + PageInfo pageInfo = + PageInfo.of( + pageNum, + limit, + pageActivities.getTotalPages(), + pageActivities.getTotalElements()); + + // dto + GetAllActivitiesResponse response = + activityConverter.toActivitiesPage(pageActivities.getContent(), pageInfo); + + return response; + } + + /** 활동 수정 */ + @Transactional + public ActivityResponse updateActivity(Long id, ActivityRequest activityRequest) { + Activity activity = + activityRepository.findById(id).orElseThrow(() -> new ActivityNotFound()); + + activity.updateActivity(activityRequest); + activityRepository.save(activity); + + return activityConverter.toDTO(activity); + } + + /** 활동 삭제 */ + @Transactional + public void deleteActivity(Long id) { + Activity activity = + activityRepository.findById(id).orElseThrow(() -> new ActivityNotFound()); + + activityRepository.delete(activity); + } + + @Transactional(readOnly = true) + public AwsS3Url getImageUrl() { + return awsS3UrlHandler.handle("activities"); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/AdminController.java b/src/main/java/ceos/backend/domain/admin/AdminController.java new file mode 100644 index 00000000..a982cf6e --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/AdminController.java @@ -0,0 +1,108 @@ +package ceos.backend.domain.admin; + + +import ceos.backend.domain.admin.dto.request.*; +import ceos.backend.domain.admin.dto.response.*; +import ceos.backend.domain.admin.service.AdminService; +import ceos.backend.global.config.user.AdminDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/admin") +@Tag(name = "Admin") +public class AdminController { + + private final AdminService adminService; + + @Operation(summary = "닉네임 확인") + @PostMapping("/username") + public CheckUsernameResponse checkUsername( + @RequestBody @Valid CheckUsernameRequest checkUsernameRequest) { + return adminService.checkUsername(checkUsernameRequest); + } + + @Operation(summary = "회원가입") + @PostMapping("/signup") + public void signUp(@RequestBody @Valid SignUpRequest signUpRequest) { + log.info("회원가입"); + adminService.signUp(signUpRequest); + } + + @Operation(summary = "로그인") + @PostMapping("/signin") + public TokenResponse signIn(@RequestBody @Valid SignInRequest signInRequest) { + log.info("로그인"); + return adminService.signIn(signInRequest); + } + + @Operation(summary = "아이디 찾기") + @PostMapping("/id") + public FindIdResponse findId(@RequestBody @Valid FindIdRequest findIdRequest) { + log.info("아이디 찾기"); + return adminService.findId(findIdRequest); + } + + @Operation(summary = "비밀번호 찾기") + @PostMapping("/password") + public void findPwd(@RequestBody @Valid SendRandomPwdRequest sendRandomPwdRequest) { + log.info("임시 비밀번호 메일 전송"); + adminService.findPwd(sendRandomPwdRequest); + } + + @Operation(summary = "비밀번호 재설정") + @PostMapping("/newpassword") + public void resetPwd( + @RequestBody @Valid ResetPwdRequest resetPwdRequest, + @AuthenticationPrincipal AdminDetails adminUser) { + log.info("비밀번호 재설정"); + adminService.resetPwd(resetPwdRequest, adminUser); + } + + @Operation(summary = "로그아웃") + @PostMapping("/logout") + public void logout(@AuthenticationPrincipal AdminDetails adminUser) { + log.info("로그아웃"); + adminService.logout(adminUser); + } + + @Operation(summary = "토큰 재발급") + @PostMapping("/reissue") + public TokenResponse refreshToken(@RequestBody @Valid RefreshTokenRequest refreshTokenRequest) { + log.info("토큰 재발급"); + return adminService.reissueToken(refreshTokenRequest); + } + + @Operation(summary = "슈퍼유저 - 유저 목록 보기") + @GetMapping("/super") + public GetAdminsResponse getAdmins( + @AuthenticationPrincipal AdminDetails adminUser, + @RequestParam("pageNum") int pageNum, + @RequestParam("limit") int limit) { + log.info("슈퍼유저 - 유저 목록 보기"); + return adminService.getAdmins(adminUser, pageNum, limit); + } + + @Operation(summary = "슈퍼유저 - 유저 권한 변경") + @PostMapping("/super") + public void grantAuthority( + @AuthenticationPrincipal AdminDetails adminUser, + @RequestBody @Valid GrantAuthorityRequest grantAuthorityRequest) { + log.info("슈퍼유저 - 유저 권한 변경"); + adminService.grantAuthority(adminUser, grantAuthorityRequest); + } + + @Operation(summary = "슈퍼유저 - 유저 삭제") + @DeleteMapping("/super") + public void deleteAdmin(@AuthenticationPrincipal AdminDetails adminUser, Long adminId) { + log.info("슈퍼유저 - 유저 삭제"); + adminService.deleteAdmin(adminUser, adminId); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/domain/Admin.java b/src/main/java/ceos/backend/domain/admin/domain/Admin.java new file mode 100644 index 00000000..adc64dee --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/domain/Admin.java @@ -0,0 +1,107 @@ +package ceos.backend.domain.admin.domain; + + +import ceos.backend.domain.admin.dto.request.SignUpRequest; +import ceos.backend.global.common.entity.BaseEntity; +import ceos.backend.global.common.entity.Part; +import jakarta.annotation.Nullable; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Admin extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "admin_id") + private Long id; + + @NotNull + @Size(max = 20) + @Column(unique = true) + private String username; + + @NotNull + @Size(max = 128) + private String password; + + @NotNull private int generation; + + @NotNull + @Size(max = 30) + private String name; + + @NotNull + @Enumerated(EnumType.STRING) + private Part part; + + @NotNull + @Enumerated(EnumType.STRING) + private AdminRole role; + + @NotNull + @Size(max = 255) + private String email; + + @Nullable + @Size(max = 255) + private String refreshToken; + + // 생성자 + @Builder + private Admin( + String username, + String password, + int generation, + String name, + Part part, + AdminRole role, + String email) { + + this.username = username; + this.password = password; + this.generation = generation; + this.name = name; + this.part = part; + this.role = role; + this.email = email; + } + + public static Admin of(SignUpRequest signUpRequest, String encodedPassword, int generation) { + return Admin.builder() + .username(signUpRequest.getAdminVo().getUsername()) + .password(encodedPassword) + .generation(generation) + .name(signUpRequest.getAdminVo().getName()) + .part(signUpRequest.getAdminVo().getPart()) + .role(AdminRole.ROLE_ANONYMOUS) + .email(signUpRequest.getAdminVo().getEmail()) + .build(); + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public void updateRandomPwd(String randomPwd) { + this.password = randomPwd; + } + + public void updatePwd(String encodedPassword) { + this.password = encodedPassword; + } + + public void deleteRefreshToken() { + this.refreshToken = null; + } + + public void updateRole(AdminRole adminRole) { + this.role = adminRole; + } +} diff --git a/src/main/java/ceos/backend/domain/admin/domain/AdminRole.java b/src/main/java/ceos/backend/domain/admin/domain/AdminRole.java new file mode 100644 index 00000000..043e9cbd --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/domain/AdminRole.java @@ -0,0 +1,26 @@ +package ceos.backend.domain.admin.domain; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AdminRole { + ROLE_ROOT("루트"), + ROLE_ADMIN("운영진"), + ROLE_ANONYMOUS("임시"); + + @JsonValue private final String adminRole; + + @JsonCreator + public static AdminRole parsing(String inputValue) { + return Stream.of(AdminRole.values()) + .filter(category -> category.getAdminRole().equals(inputValue)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/Token.java b/src/main/java/ceos/backend/domain/admin/dto/Token.java new file mode 100644 index 00000000..a6f7616f --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/Token.java @@ -0,0 +1,29 @@ +package ceos.backend.domain.admin.dto; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class Token { + + @Schema(defaultValue = "access") + @NotNull() + private String accessToken; + + @Schema(defaultValue = "refresh") + @NotNull() + private String refreshToken; + + @Builder + private Token(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public static Token from(String accessToken, String refreshToken) { + return Token.builder().accessToken(accessToken).refreshToken(refreshToken).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/request/CheckUsernameRequest.java b/src/main/java/ceos/backend/domain/admin/dto/request/CheckUsernameRequest.java new file mode 100644 index 00000000..b0dc6f67 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/request/CheckUsernameRequest.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.admin.dto.request; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class CheckUsernameRequest { + + @Schema(defaultValue = "string") + @NotNull(message = "아이디를 입력해주세요.") + private String username; +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/request/FindIdRequest.java b/src/main/java/ceos/backend/domain/admin/dto/request/FindIdRequest.java new file mode 100644 index 00000000..e474666d --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/request/FindIdRequest.java @@ -0,0 +1,24 @@ +package ceos.backend.domain.admin.dto.request; + + +import ceos.backend.global.common.annotation.ValidEmail; +import ceos.backend.global.common.entity.Part; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class FindIdRequest { + + @Schema(defaultValue = "string") + @NotNull(message = "이름을 입력해주세요.") + private String name; + + @Schema() + @NotNull(message = "파트를 입력해주세요.") + private Part part; + + @Schema(defaultValue = "ceos@ceos-sinchon.com", description = "운영진 이메일") + @ValidEmail + private String email; +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/request/GrantAuthorityRequest.java b/src/main/java/ceos/backend/domain/admin/dto/request/GrantAuthorityRequest.java new file mode 100644 index 00000000..e4cd9462 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/request/GrantAuthorityRequest.java @@ -0,0 +1,21 @@ +package ceos.backend.domain.admin.dto.request; + + +import ceos.backend.domain.admin.domain.AdminRole; +import ceos.backend.global.common.annotation.ValidEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class GrantAuthorityRequest { + + @Schema(defaultValue = "id") + @NotNull(message = "사용자 ID를 입력해주세요.") + private Long id; + + @Schema(defaultValue = "role") + @ValidEnum(target = AdminRole.class) + @NotNull(message = "변경할 권한을 입력해주세요.") + private AdminRole adminRole; +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/request/RefreshTokenRequest.java b/src/main/java/ceos/backend/domain/admin/dto/request/RefreshTokenRequest.java new file mode 100644 index 00000000..329ccb1f --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/request/RefreshTokenRequest.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.admin.dto.request; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class RefreshTokenRequest { + + @Schema(defaultValue = "string") + @NotNull(message = "리프레시 토큰을 입력해주세요.") + private String refreshToken; +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/request/ResetPwdRequest.java b/src/main/java/ceos/backend/domain/admin/dto/request/ResetPwdRequest.java new file mode 100644 index 00000000..bf22bedd --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/request/ResetPwdRequest.java @@ -0,0 +1,22 @@ +package ceos.backend.domain.admin.dto.request; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class ResetPwdRequest { + + @Schema(defaultValue = "string") + @NotNull(message = "비밀번호를 입력해주세요.") + private String password; + + @Schema(defaultValue = "new_string") + @NotNull(message = "새 비밀번호를 입력해주세요.") + private String newPassword1; + + @Schema(defaultValue = "new_string") + @NotNull(message = "새 비밀번호를 입력해주세요.") + private String newPassword2; +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/request/SendRandomPwdRequest.java b/src/main/java/ceos/backend/domain/admin/dto/request/SendRandomPwdRequest.java new file mode 100644 index 00000000..d9c1535c --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/request/SendRandomPwdRequest.java @@ -0,0 +1,27 @@ +package ceos.backend.domain.admin.dto.request; + + +import ceos.backend.global.common.annotation.ValidEmail; +import ceos.backend.global.common.entity.Part; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class SendRandomPwdRequest { + @Schema(defaultValue = "string") + @NotNull(message = "아이디를 입력해주세요.") + private String username; + + @Schema(defaultValue = "string") + @NotNull(message = "이름을 입력해주세요.") + private String name; + + @Schema() + @NotNull(message = "파트를 입력해주세요.") + private Part part; + + @Schema(defaultValue = "ceos@ceos-sinchon.com", description = "운영진 이메일") + @ValidEmail + private String email; +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/request/SignInRequest.java b/src/main/java/ceos/backend/domain/admin/dto/request/SignInRequest.java new file mode 100644 index 00000000..3a55b968 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/request/SignInRequest.java @@ -0,0 +1,18 @@ +package ceos.backend.domain.admin.dto.request; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class SignInRequest { + + @Schema(defaultValue = "string") + @NotNull(message = "아이디를 입력해주세요.") + private String username; + + @Schema(defaultValue = "string") + @NotNull(message = "비밀번호를 입력해주세요.") + private String password; +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/request/SignUpRequest.java b/src/main/java/ceos/backend/domain/admin/dto/request/SignUpRequest.java new file mode 100644 index 00000000..615ee772 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/request/SignUpRequest.java @@ -0,0 +1,12 @@ +package ceos.backend.domain.admin.dto.request; + + +import ceos.backend.domain.admin.vo.AdminVo; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import lombok.Getter; + +@Getter +public class SignUpRequest { + + @JsonUnwrapped private AdminVo adminVo; +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/response/CheckUsernameResponse.java b/src/main/java/ceos/backend/domain/admin/dto/response/CheckUsernameResponse.java new file mode 100644 index 00000000..2cc8d6bc --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/response/CheckUsernameResponse.java @@ -0,0 +1,20 @@ +package ceos.backend.domain.admin.dto.response; + + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class CheckUsernameResponse { + + private boolean isAvailable; + + @Builder + private CheckUsernameResponse(boolean isAvailable) { + this.isAvailable = isAvailable; + } + + public static CheckUsernameResponse from(boolean isAvailable) { + return CheckUsernameResponse.builder().isAvailable(isAvailable).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/response/FindIdResponse.java b/src/main/java/ceos/backend/domain/admin/dto/response/FindIdResponse.java new file mode 100644 index 00000000..3bbc2fba --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/response/FindIdResponse.java @@ -0,0 +1,20 @@ +package ceos.backend.domain.admin.dto.response; + + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class FindIdResponse { + + private String username; + + @Builder + private FindIdResponse(String username) { + this.username = username; + } + + public static FindIdResponse from(String username) { + return FindIdResponse.builder().username(username).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/response/GetAdminsResponse.java b/src/main/java/ceos/backend/domain/admin/dto/response/GetAdminsResponse.java new file mode 100644 index 00000000..dbd08b61 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/response/GetAdminsResponse.java @@ -0,0 +1,28 @@ +package ceos.backend.domain.admin.dto.response; + + +import ceos.backend.domain.admin.vo.AdminBriefInfoVo; +import ceos.backend.global.common.dto.PageInfo; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetAdminsResponse { + private List adminBriefInfoVos; + private PageInfo pageInfo; + + @Builder + private GetAdminsResponse(List adminBriefInfoVos, PageInfo pageInfo) { + this.adminBriefInfoVos = adminBriefInfoVos; + this.pageInfo = pageInfo; + } + + public static GetAdminsResponse of( + List adminBriefInfoVos, PageInfo pageInfo) { + return GetAdminsResponse.builder() + .adminBriefInfoVos(adminBriefInfoVos) + .pageInfo(pageInfo) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/response/RefreshTokenResponse.java b/src/main/java/ceos/backend/domain/admin/dto/response/RefreshTokenResponse.java new file mode 100644 index 00000000..9998b6db --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/response/RefreshTokenResponse.java @@ -0,0 +1,20 @@ +package ceos.backend.domain.admin.dto.response; + + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class RefreshTokenResponse { + + private String accessToken; + + @Builder + private RefreshTokenResponse(String accessToken) { + this.accessToken = accessToken; + } + + public static RefreshTokenResponse from(String accessToken) { + return RefreshTokenResponse.builder().accessToken(accessToken).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/dto/response/TokenResponse.java b/src/main/java/ceos/backend/domain/admin/dto/response/TokenResponse.java new file mode 100644 index 00000000..02e05b1e --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/dto/response/TokenResponse.java @@ -0,0 +1,22 @@ +package ceos.backend.domain.admin.dto.response; + + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class TokenResponse { + + private String accessToken; + private String refreshToken; + + @Builder + private TokenResponse(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public static TokenResponse of(String accessToken, String refreshToken) { + return TokenResponse.builder().accessToken(accessToken).refreshToken(refreshToken).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/exception/AdminErrorCode.java b/src/main/java/ceos/backend/domain/admin/exception/AdminErrorCode.java new file mode 100644 index 00000000..64209b2f --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/exception/AdminErrorCode.java @@ -0,0 +1,37 @@ +package ceos.backend.domain.admin.exception; + +import static org.springframework.http.HttpStatus.*; + +import ceos.backend.global.common.dto.ErrorReason; +import ceos.backend.global.error.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AdminErrorCode implements BaseErrorCode { + /* Admin */ + DUPLICATE_ADMIN(BAD_REQUEST, "ADMIN_400_1", "이미 가입한 어드민입니다."), + INVALID_ACTION(BAD_REQUEST, "ADMIN_400_2", "자기 자신에 대한 작업은 수행할 수 없습니다."), + NOT_ALLOWED_TO_MODIFY(FORBIDDEN, "ADMIN_403_1", "지원 기간에는 수정할 수 없습니다."), + ADMIN_NOT_SIGN_UP(NOT_FOUND, "ADMIN_404_1", "회원으로 가입된 유저가 아닙니다"), + ADMIN_NOT_FOUND(NOT_FOUND, "ADMIN_404_2", "존재하지 않는 어드민입니다."), + + /* Data */ + MISMATCH_NEW_PASSWORD(BAD_REQUEST, "ADMIN_400_3", "새비밀번호가 일치하지 않습니다"), + MISMATCH_PASSWORD(BAD_REQUEST, "ADMIN_400_4", "비밀번호가 일치하지 않습니다"), + DUPLICATE_DATA(CONFLICT, "ADMIN_409_1", "이미 존재하는 데이터입니다"), + + /* REFRESH TOKEN */ + NOT_REFRESH_TOKEN(BAD_REQUEST, "ADMIN_400_5", "리프레시 토큰이 아닙니다"), + REFRESH_TOKEN_NOT_FOUND(NOT_FOUND, "ADMIN_404_2", "존재하지 않거나 만료된 리프레시 토큰입니다."); + private HttpStatus status; + private String code; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/exception/AdminNotFound.java b/src/main/java/ceos/backend/domain/admin/exception/AdminNotFound.java new file mode 100644 index 00000000..9bf3d147 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/exception/AdminNotFound.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.admin.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class AdminNotFound extends BaseErrorException { + + public static final AdminNotFound EXCEPTION = new AdminNotFound(); + + private AdminNotFound() { + super(AdminErrorCode.ADMIN_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/exception/AdminNotSignUp.java b/src/main/java/ceos/backend/domain/admin/exception/AdminNotSignUp.java new file mode 100644 index 00000000..279e6978 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/exception/AdminNotSignUp.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.admin.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class AdminNotSignUp extends BaseErrorException { + + public static final AdminNotSignUp EXCEPTION = new AdminNotSignUp(); + + private AdminNotSignUp() { + super(AdminErrorCode.ADMIN_NOT_SIGN_UP); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/exception/DuplicateAdmin.java b/src/main/java/ceos/backend/domain/admin/exception/DuplicateAdmin.java new file mode 100644 index 00000000..31b5381e --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/exception/DuplicateAdmin.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.admin.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class DuplicateAdmin extends BaseErrorException { + + public static final DuplicateAdmin EXCEPTION = new DuplicateAdmin(); + + private DuplicateAdmin() { + super(AdminErrorCode.DUPLICATE_ADMIN); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/exception/DuplicateData.java b/src/main/java/ceos/backend/domain/admin/exception/DuplicateData.java new file mode 100644 index 00000000..c2de2741 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/exception/DuplicateData.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.admin.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class DuplicateData extends BaseErrorException { + + public static final DuplicateData EXCEPTION = new DuplicateData(); + + private DuplicateData() { + super(AdminErrorCode.DUPLICATE_DATA); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/exception/InvalidAction.java b/src/main/java/ceos/backend/domain/admin/exception/InvalidAction.java new file mode 100644 index 00000000..f61f1316 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/exception/InvalidAction.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.admin.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class InvalidAction extends BaseErrorException { + + public static final InvalidAction EXCEPTION = new InvalidAction(); + + private InvalidAction() { + super(AdminErrorCode.INVALID_ACTION); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/exception/MismatchNewPassword.java b/src/main/java/ceos/backend/domain/admin/exception/MismatchNewPassword.java new file mode 100644 index 00000000..a7164278 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/exception/MismatchNewPassword.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.admin.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class MismatchNewPassword extends BaseErrorException { + + public static final MismatchNewPassword EXCEPTION = new MismatchNewPassword(); + + private MismatchNewPassword() { + super(AdminErrorCode.MISMATCH_NEW_PASSWORD); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/exception/MismatchPassword.java b/src/main/java/ceos/backend/domain/admin/exception/MismatchPassword.java new file mode 100644 index 00000000..4e8711f0 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/exception/MismatchPassword.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.admin.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class MismatchPassword extends BaseErrorException { + + public static final MismatchPassword EXCEPTION = new MismatchPassword(); + + private MismatchPassword() { + super(AdminErrorCode.MISMATCH_PASSWORD); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/exception/NotAllowedToModify.java b/src/main/java/ceos/backend/domain/admin/exception/NotAllowedToModify.java new file mode 100644 index 00000000..69451834 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/exception/NotAllowedToModify.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.admin.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class NotAllowedToModify extends BaseErrorException { + + public static final NotAllowedToModify EXCEPTION = new NotAllowedToModify(); + + public NotAllowedToModify() { + super(AdminErrorCode.NOT_ALLOWED_TO_MODIFY); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/exception/NotRefreshToken.java b/src/main/java/ceos/backend/domain/admin/exception/NotRefreshToken.java new file mode 100644 index 00000000..203a905a --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/exception/NotRefreshToken.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.admin.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class NotRefreshToken extends BaseErrorException { + + public static final NotRefreshToken EXCEPTION = new NotRefreshToken(); + + public NotRefreshToken() { + super(AdminErrorCode.NOT_REFRESH_TOKEN); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/exception/RefreshTokenNotFound.java b/src/main/java/ceos/backend/domain/admin/exception/RefreshTokenNotFound.java new file mode 100644 index 00000000..0e176c70 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/exception/RefreshTokenNotFound.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.admin.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class RefreshTokenNotFound extends BaseErrorException { + + public static final RefreshTokenNotFound EXCEPTION = new RefreshTokenNotFound(); + + private RefreshTokenNotFound() { + super(AdminErrorCode.REFRESH_TOKEN_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/helper/AdminHelper.java b/src/main/java/ceos/backend/domain/admin/helper/AdminHelper.java new file mode 100644 index 00000000..08a86a59 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/helper/AdminHelper.java @@ -0,0 +1,181 @@ +package ceos.backend.domain.admin.helper; + +import static ceos.backend.domain.admin.domain.AdminRole.ROLE_ANONYMOUS; + +import ceos.backend.domain.admin.domain.Admin; +import ceos.backend.domain.admin.domain.AdminRole; +import ceos.backend.domain.admin.dto.request.FindIdRequest; +import ceos.backend.domain.admin.dto.request.ResetPwdRequest; +import ceos.backend.domain.admin.dto.request.SendRandomPwdRequest; +import ceos.backend.domain.admin.dto.request.SignInRequest; +import ceos.backend.domain.admin.exception.*; +import ceos.backend.domain.admin.repository.AdminRepository; +import ceos.backend.domain.admin.vo.AdminVo; +import ceos.backend.domain.recruitment.helper.RecruitmentHelper; +import ceos.backend.global.common.dto.AwsSESPasswordMail; +import ceos.backend.global.common.event.Event; +import ceos.backend.global.config.user.AdminDetailsService; +import ceos.backend.global.error.exception.ForbiddenAdmin; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminHelper { + private final RedisTemplate redisTemplate; + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + private final RecruitmentHelper recruitmentHelper; + private final AdminRepository adminRepository; + private final AdminDetailsService adminDetailsService; + + public String encodePassword(String password) { + return passwordEncoder.encode(password); + } + + public boolean matchesPassword(String rawPassword, String encodedPassword) { + return passwordEncoder.matches(rawPassword, encodedPassword); + } + + public int takeGeneration() { + return recruitmentHelper.takeRecruitment().getGeneration(); + } + + public Authentication adminAuthorizationInput(Admin admin) { + + UserDetails userDetails = adminDetailsService.loadAdminByUsername(admin.getId()); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + userDetails, "", userDetails.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + return authentication; + } + + public void findDuplicateUsername(String username) { + if (adminRepository.findByUsername(username).isPresent()) { + throw DuplicateData.EXCEPTION; + } + } + + public void findDuplicateAdmin(AdminVo adminVo) { + if (adminRepository.findByNameAndEmail(adminVo.getName(), adminVo.getEmail()).isPresent()) { + throw DuplicateAdmin.EXCEPTION; + } + } + + public Admin findForSignIn(SignInRequest signInRequest) { + Admin findAdmin = + adminRepository + .findByUsername(signInRequest.getUsername()) + .orElseThrow( + () -> { + throw AdminNotFound.EXCEPTION; + }); + + if (!matchesPassword(signInRequest.getPassword(), findAdmin.getPassword())) { + throw MismatchPassword.EXCEPTION; + } + + return findAdmin; + } + + public Admin findForFindId(FindIdRequest findIdRequest) { + return adminRepository + .findByNameAndPartAndEmail( + findIdRequest.getName(), findIdRequest.getPart(), findIdRequest.getEmail()) + .orElseThrow( + () -> { + throw AdminNotFound.EXCEPTION; + }); + } + + public Admin findForSendRandomPwd(SendRandomPwdRequest sendRandomPwdRequest) { + return adminRepository + .findByUsernameAndNameAndPartAndEmail( + sendRandomPwdRequest.getUsername(), + sendRandomPwdRequest.getName(), + sendRandomPwdRequest.getPart(), + sendRandomPwdRequest.getEmail()) + .orElseThrow( + () -> { + throw AdminNotFound.EXCEPTION; + }); + } + + public void validateForResetPwd(ResetPwdRequest resetPwdRequest, Admin admin) { + final String password = resetPwdRequest.getPassword(); + final String newPassword1 = resetPwdRequest.getNewPassword1(); + final String newPassword2 = resetPwdRequest.getNewPassword2(); + + if (!matchesPassword(password, admin.getPassword())) { + throw MismatchPassword.EXCEPTION; + } + + if (!newPassword1.equals(newPassword2)) { + throw MismatchNewPassword.EXCEPTION; + } + } + + public String generateRandomPwd() { + return RandomStringUtils.randomAlphanumeric(10); + } + + public void setRandomPwd(Admin admin, String randomPwd) { + + final String tempPassword = passwordEncoder.encode(randomPwd); + admin.updateRandomPwd(tempPassword); + adminRepository.save(admin); + } + + public void sendEmail(String email, String name, String randomPwd) { + Event.raise(AwsSESPasswordMail.of(email, name, randomPwd)); + } + + public void resetPwd(ResetPwdRequest resetPwdRequest, Admin admin) { + + final String encodedPassword = passwordEncoder.encode(resetPwdRequest.getNewPassword1()); + admin.updatePwd(encodedPassword); + adminRepository.save(admin); + } + + public void matchesRefreshToken(String refreshToken, Admin admin) { + String savedToken = redisTemplate.opsForValue().get(admin.getId().toString()); + if (savedToken == null || !savedToken.equals(refreshToken)) { + throw RefreshTokenNotFound.EXCEPTION; + } + } + + public Admin findAdmin(Long adminId) { + return adminRepository + .findById(adminId) + .orElseThrow( + () -> { + throw AdminNotFound.EXCEPTION; + }); + } + + public void checkRole(Admin admin) { + if (admin.getRole().equals(ROLE_ANONYMOUS)) { + throw ForbiddenAdmin.EXCEPTION; + } + } + + public void changeRole(Admin admin, AdminRole adminRole) { + admin.updateRole(adminRole); + } + + public void validateAdmin(Admin superAdmin, Admin admin) { + if (superAdmin.getId().equals(admin.getId())) { + throw InvalidAction.EXCEPTION; + } + } +} diff --git a/src/main/java/ceos/backend/domain/admin/repository/AdminMapper.java b/src/main/java/ceos/backend/domain/admin/repository/AdminMapper.java new file mode 100644 index 00000000..f522a454 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/repository/AdminMapper.java @@ -0,0 +1,37 @@ +package ceos.backend.domain.admin.repository; + + +import ceos.backend.domain.admin.domain.Admin; +import ceos.backend.domain.admin.dto.request.SignUpRequest; +import ceos.backend.domain.admin.dto.response.*; +import ceos.backend.domain.admin.vo.AdminBriefInfoVo; +import ceos.backend.global.common.dto.PageInfo; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +@Component +public class AdminMapper { + public Admin toEntity(SignUpRequest signUpRequest, String encodedPassword, int generation) { + return Admin.of(signUpRequest, encodedPassword, generation); + } + + public CheckUsernameResponse toCheckUsernameResponse(boolean isAvailable) { + return CheckUsernameResponse.from(isAvailable); + } + + public TokenResponse toTokenResponse(String accessToken, String refreshToken) { + return TokenResponse.of(accessToken, refreshToken); + } + + public FindIdResponse toFindIdResponse(String username) { + return FindIdResponse.from(username); + } + + public GetAdminsResponse toGetAdmins(Page adminList, PageInfo pageInfo) { + + List adminBriefInfoVos = + adminList.stream().map(AdminBriefInfoVo::from).toList(); + return GetAdminsResponse.of(adminBriefInfoVos, pageInfo); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/repository/AdminRepository.java b/src/main/java/ceos/backend/domain/admin/repository/AdminRepository.java new file mode 100644 index 00000000..c076d93f --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/repository/AdminRepository.java @@ -0,0 +1,23 @@ +package ceos.backend.domain.admin.repository; + + +import ceos.backend.domain.admin.domain.Admin; +import ceos.backend.global.common.entity.Part; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdminRepository extends JpaRepository { + + Optional findByNameAndEmail(String name, String email); + + Optional findByUsername(String username); + + Optional findByNameAndPartAndEmail(String name, Part part, String email); + + Optional findByUsernameAndNameAndPartAndEmail( + String username, String name, Part part, String email); + + Page findAllByIdNot(PageRequest pageRequest, Long id); +} diff --git a/src/main/java/ceos/backend/domain/admin/service/AdminService.java b/src/main/java/ceos/backend/domain/admin/service/AdminService.java new file mode 100644 index 00000000..08c2c54e --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/service/AdminService.java @@ -0,0 +1,161 @@ +package ceos.backend.domain.admin.service; + + +import ceos.backend.domain.admin.domain.Admin; +import ceos.backend.domain.admin.domain.AdminRole; +import ceos.backend.domain.admin.dto.request.*; +import ceos.backend.domain.admin.dto.response.*; +import ceos.backend.domain.admin.helper.AdminHelper; +import ceos.backend.domain.admin.repository.AdminMapper; +import ceos.backend.domain.admin.repository.AdminRepository; +import ceos.backend.global.common.dto.PageInfo; +import ceos.backend.global.config.jwt.TokenProvider; +import ceos.backend.global.config.user.AdminDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminService { + + private final TokenProvider tokenProvider; + private final AdminHelper adminHelper; + private final AdminMapper adminMapper; + private final AdminRepository adminRepository; + + @Transactional + public CheckUsernameResponse checkUsername(CheckUsernameRequest checkUsernameRequest) { + // 중복 아이디 검사 + adminHelper.findDuplicateUsername(checkUsernameRequest.getUsername()); + + return adminMapper.toCheckUsernameResponse(true); + } + + @Transactional + public void signUp(SignUpRequest signUpRequest) { + // 중복 어드민 검사 + adminHelper.findDuplicateAdmin(signUpRequest.getAdminVo()); + + // 어드민 생성 및 저장 + final String encodedPassword = + adminHelper.encodePassword(signUpRequest.getAdminVo().getPassword()); + final int generation = adminHelper.takeGeneration(); + final Admin admin = adminMapper.toEntity(signUpRequest, encodedPassword, generation); + adminRepository.save(admin); + } + + @Transactional + public TokenResponse signIn(SignInRequest signInRequest) { + + final Admin admin = adminHelper.findForSignIn(signInRequest); + final Authentication authentication = adminHelper.adminAuthorizationInput(admin); + + adminHelper.checkRole(admin); + + // 토큰 발급 + final String accessToken = tokenProvider.createAccessToken(admin.getId(), authentication); + final String refreshToken = tokenProvider.createRefreshToken(admin.getId(), authentication); + + return adminMapper.toTokenResponse(accessToken, refreshToken); + } + + @Transactional + public FindIdResponse findId(FindIdRequest findIdRequest) { + // 어드민 검증 + final Admin admin = adminHelper.findForFindId(findIdRequest); + + return adminMapper.toFindIdResponse(admin.getUsername()); + } + + @Transactional + public void findPwd(SendRandomPwdRequest sendRandomPwdRequest) { + final Admin admin = adminHelper.findForSendRandomPwd(sendRandomPwdRequest); + final String randomPwd = adminHelper.generateRandomPwd(); + + // 임시 비밀번호 DB 저장 + adminHelper.setRandomPwd(admin, randomPwd); + + // 메일 전송 + adminHelper.sendEmail(admin.getEmail(), admin.getName(), randomPwd); + } + + @Transactional + public void resetPwd(ResetPwdRequest resetPwdRequest, AdminDetails adminUser) { + final Admin admin = adminUser.getAdmin(); + + // 입력값 검증 + adminHelper.validateForResetPwd(resetPwdRequest, admin); + + // 비밀번호 재설정 + adminHelper.resetPwd(resetPwdRequest, admin); + } + + @Transactional + public void logout(AdminDetails adminUser) { + final Admin admin = adminUser.getAdmin(); + + // 레디스 삭제 + tokenProvider.deleteRefreshToken(admin.getId()); + } + + @Transactional + public TokenResponse reissueToken(RefreshTokenRequest refreshTokenRequest) { + final String refreshToken = refreshTokenRequest.getRefreshToken(); + final Admin admin = + adminHelper.findAdmin(Long.parseLong(tokenProvider.getTokenUserId(refreshToken))); + final Authentication authentication = adminHelper.adminAuthorizationInput(admin); + + // 리프레시 토큰 검증 + tokenProvider.validateRefreshToken(refreshToken); + adminHelper.matchesRefreshToken(refreshToken, admin); + + // 토큰 재발급 + final String newAccessToken = + tokenProvider.createAccessToken(admin.getId(), authentication); + + return adminMapper.toTokenResponse(newAccessToken, refreshToken); + } + + @Transactional(readOnly = true) + public GetAdminsResponse getAdmins(AdminDetails adminUser, int pageNum, int limit) { + final Admin superAdmin = adminUser.getAdmin(); + PageRequest pageRequest = PageRequest.of(pageNum, limit); + Page adminList = adminRepository.findAllByIdNot(pageRequest, superAdmin.getId()); + PageInfo pageInfo = + PageInfo.of( + pageNum, limit, adminList.getTotalPages(), adminList.getTotalElements()); + return adminMapper.toGetAdmins(adminList, pageInfo); + } + + @Transactional + public void grantAuthority( + AdminDetails adminUser, GrantAuthorityRequest grantAuthorityRequest) { + final Admin superAdmin = adminUser.getAdmin(); + final Admin admin = adminHelper.findAdmin(grantAuthorityRequest.getId()); + final AdminRole adminRole = grantAuthorityRequest.getAdminRole(); + + // 어드민 작업 검증 + adminHelper.validateAdmin(superAdmin, admin); + + // 권한 변경 + adminHelper.changeRole(admin, adminRole); + } + + @Transactional + public void deleteAdmin(AdminDetails adminUser, Long adminId) { + final Admin superAdmin = adminUser.getAdmin(); + final Admin admin = adminHelper.findAdmin(adminId); + + // 어드민 작업 검증 + adminHelper.validateAdmin(superAdmin, admin); + + // 어드민 삭제 + adminRepository.delete(admin); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/vo/AdminBriefInfoVo.java b/src/main/java/ceos/backend/domain/admin/vo/AdminBriefInfoVo.java new file mode 100644 index 00000000..19c72474 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/vo/AdminBriefInfoVo.java @@ -0,0 +1,37 @@ +package ceos.backend.domain.admin.vo; + + +import ceos.backend.domain.admin.domain.Admin; +import ceos.backend.domain.admin.domain.AdminRole; +import ceos.backend.global.common.entity.Part; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class AdminBriefInfoVo { + + private Long id; + private String name; + private Part part; + private String email; + private AdminRole adminRole; + + @Builder + private AdminBriefInfoVo(Long id, String name, Part part, String email, AdminRole adminRole) { + this.id = id; + this.name = name; + this.part = part; + this.email = email; + this.adminRole = adminRole; + } + + public static AdminBriefInfoVo from(Admin admin) { + return AdminBriefInfoVo.builder() + .id(admin.getId()) + .name(admin.getName()) + .part(admin.getPart()) + .email(admin.getEmail()) + .adminRole(admin.getRole()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/admin/vo/AdminVo.java b/src/main/java/ceos/backend/domain/admin/vo/AdminVo.java new file mode 100644 index 00000000..5215efe4 --- /dev/null +++ b/src/main/java/ceos/backend/domain/admin/vo/AdminVo.java @@ -0,0 +1,33 @@ +package ceos.backend.domain.admin.vo; + + +import ceos.backend.global.common.annotation.ValidEmail; +import ceos.backend.global.common.annotation.ValidEnum; +import ceos.backend.global.common.entity.Part; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; + +@Getter +public class AdminVo { + @Schema() + @NotEmpty(message = "아이디를 입력해주세요.") + private String username; + + @Schema() + @NotEmpty(message = "비밀번호를 입력해주세요.") + private String password; + + @Schema() + @NotEmpty(message = "이름을 입력해주세요.") + private String name; + + @Schema() + @ValidEnum(target = Part.class) + @NotEmpty(message = "파트를 입력해주세요.") + private Part part; + + @Schema(defaultValue = "ceos@ceos-sinchon.com", description = "운영진 이메일") + @ValidEmail + private String email; +} diff --git a/src/main/java/ceos/backend/domain/application/ApplicationController.java b/src/main/java/ceos/backend/domain/application/ApplicationController.java new file mode 100644 index 00000000..f26effba --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/ApplicationController.java @@ -0,0 +1,175 @@ +package ceos.backend.domain.application; + + +import ceos.backend.domain.application.dto.request.*; +import ceos.backend.domain.application.dto.response.*; +import ceos.backend.domain.application.enums.SortPartType; +import ceos.backend.domain.application.enums.SortPassType; +import ceos.backend.domain.application.service.ApplicationExcelService; +import ceos.backend.domain.application.service.ApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.nio.file.Path; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/applications") +@Tag(name = "Application") +public class ApplicationController { + private final ApplicationService applicationService; + private final ApplicationExcelService applicationExcelService; + + @Operation(summary = "지원자 목록 보기") + @GetMapping + public GetApplications getApplications( + @RequestParam("part") SortPartType part, + @RequestParam("docPass") SortPassType docPass, + @RequestParam("finalPass") SortPassType finalPass, + @RequestParam("pageNum") int pageNum, + @RequestParam("limit") int limit) { + log.info("지원자 목록 보기"); + return applicationService.getApplications(pageNum, limit, part, docPass, finalPass); + } + + @Operation(summary = "지원하기", description = "startDateDoc ~ endDateDoc 전날") + @PostMapping + public void createApplication( + @RequestBody @Valid CreateApplicationRequest createApplicationRequest) { + log.info("지원하기"); + applicationService.createApplication(createApplicationRequest); + } + + @Operation(summary = "지원서 질문 가져오기") + @GetMapping(value = "/question") + public GetApplicationQuestion getApplicationQuestion() { + log.info("지원서 질문 가져오기"); + return applicationService.getApplicationQuestion(); + } + + @Operation(summary = "지원서 질문 수정", description = "~ startDateDoc 전날") + @PutMapping(value = "/question") + public void updateApplicationQuestion( + @RequestBody @Valid UpdateApplicationQuestion updateApplicationQuestion) { + log.info("지원서 질문 수정"); + applicationService.updateApplicationQuestion(updateApplicationQuestion); + } + + @Operation(summary = "서류 합격 여부 확인하기", description = "resultDateDoc ~ resultDateFinal 전날") + @GetMapping(value = "/document") + public GetResultResponse getDocumentResult( + @RequestParam("uuid") String uuid, @RequestParam("email") String email) { + log.info("서류 합격 여부 확인하기"); + return applicationService.getDocumentResult(uuid, email); + } + + @Operation(summary = "면접 참여 가능 여부 선택", description = "resultDateDoc ~ resultDateFinal 전날") + @PatchMapping(value = "/interview") + public void updateInterviewAttendance( + @RequestParam("uuid") String uuid, + @RequestParam("email") String email, + @RequestBody UpdateAttendanceRequest request) { + log.info("면접 참여 가능 여부 선택"); + applicationService.updateInterviewAttendance(uuid, email, request); + } + + @Operation(summary = "최종 합격 여부 확인하기", description = "resultDateFinal ~ resultDateFinal 4일 후") + @GetMapping(value = "/final") + public GetResultResponse getFinalResult( + @RequestParam("uuid") String uuid, @RequestParam("email") String email) { + log.info("최종 합격 여부 확인하기"); + return applicationService.getFinalResult(uuid, email); + } + + @Operation(summary = "활동 가능 여부 선택", description = "resultDateFinal ~ resultDateFinal 4일 후") + @PatchMapping(value = "/pass") + public void updateParticipationAvailability( + @RequestParam("uuid") String uuid, + @RequestParam("email") String email, + @RequestBody UpdateAttendanceRequest request) { + log.info("활동 가능 여부 선택"); + applicationService.updateParticipationAvailability(uuid, email, request); + } + + @Operation(summary = "지원자 자기소개서 보기") + @GetMapping(value = "/{applicationId}") + public GetApplication getApplication(@PathVariable("applicationId") Long applicationId) { + log.info("지원자 자기소개서 보기"); + return applicationService.getApplication(applicationId); + } + + @Operation(summary = "면접 시간 정보 가져오기") + @GetMapping(value = "/{applicationId}/interview") + public GetInterviewTime getInterviewTime(@PathVariable("applicationId") Long applicationId) { + log.info("면접 시간 정보 가져오기"); + return applicationService.getInterviewTime(applicationId); + } + + @Operation(summary = "면접 시간 결정하기", description = "startDateDoc ~ resultDateDoc 전날") + @PatchMapping(value = "/{applicationId}/interview") + public void updateInterviewTime( + @PathVariable("applicationId") Long applicationId, + @RequestBody @Valid UpdateInterviewTime updateInterviewTime) { + log.info("면접 시간 결정하기"); + applicationService.updateInterviewTime(applicationId, updateInterviewTime); + } + + @Operation(summary = "서류 합격 여부 변경", description = "startDateDoc ~ resultDateDoc 전날") + @PatchMapping(value = "/{applicationId}/document") + public void updateDocumentPassStatus( + @PathVariable("applicationId") Long applicationId, + @RequestBody @Valid UpdatePassStatus updatePassStatus) { + log.info("서류 합격 여부 변경"); + applicationService.updateDocumentPassStatus(applicationId, updatePassStatus); + } + + @Operation(summary = "최종 합격 여부 변경", description = "resultDateDoc ~ ResultDateFinal 전날") + @PatchMapping(value = "/{applicationId}/final") + public void updateFinalPassStatus( + @PathVariable("applicationId") Long applicationId, + @RequestBody @Valid UpdatePassStatus updatePassStatus) { + log.info("최종 합격 여부 변경"); + applicationService.updateFinalPassStatus(applicationId, updatePassStatus); + } + + @Operation(summary = "지원서 엑셀 파일 생성") + @GetMapping(value = "/file/create") + public GetCreationTime createApplicationExcel() { + log.info("지원서 엑셀 파일 생성"); + return applicationExcelService.createApplicationExcel(); + } + + @Operation(summary = "지원서 엑셀 다운로드") + @GetMapping(value = "/file/download") + public ResponseEntity getApplicationExcel() { + log.info("지원서 엑셀 다운로드"); + Path path = applicationExcelService.getApplicationExcel(); + + FileSystemResource resource = new FileSystemResource(path.toFile()); + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-Disposition", "attachment; filename=" + path.getFileName().toString()); + + return ResponseEntity.ok() + .headers(headers) + .contentLength(path.toFile().length()) + .contentType( + MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .body(resource); + } + + @Operation(summary = "지원서 엑셀 파일 생성 시각 확인") + @GetMapping(value = "/file/creationtime") + public GetCreationTime getApplicationExcelCreationTime() { + log.info("지원서 엑셀 파일 생성 시각 확인"); + return applicationExcelService.getApplicationExcelCreationTime(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/domain/ApplicantInfo.java b/src/main/java/ceos/backend/domain/application/domain/ApplicantInfo.java new file mode 100644 index 00000000..aaa33dd4 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/domain/ApplicantInfo.java @@ -0,0 +1,93 @@ +package ceos.backend.domain.application.domain; + + +import ceos.backend.domain.application.vo.ApplicantInfoVo; +import ceos.backend.global.common.entity.University; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ApplicantInfo { + + @NotNull + @Size(max = 30) + private String name; + + @NotNull + @Enumerated(EnumType.STRING) + private Gender gender; + + @NotNull private LocalDate birth; + + @NotNull + @Size(max = 255) + @Column(unique = true) + private String email; + + @NotNull + @Size(max = 11) + private String phoneNumber; + + @NotNull + @Size(max = 10) + @Enumerated(EnumType.STRING) + private University university; + + @NotNull + @Size(max = 20) + private String major; + + @Size(max = 100) + @Column(unique = true) + private String uuid; + + @NotNull @PositiveOrZero private int semestersLeftNumber; + + @Builder + private ApplicantInfo( + String name, + Gender gender, + LocalDate birth, + String email, + String phoneNumber, + University university, + String major, + String uuid, + int semestersLeftNumber) { + this.name = name; + this.gender = gender; + this.birth = birth; + this.email = email; + this.phoneNumber = phoneNumber; + this.university = university; + this.major = major; + this.uuid = uuid; + this.semestersLeftNumber = semestersLeftNumber; + } + + public static ApplicantInfo of(ApplicantInfoVo applicantInfoVo, String uuid) { + return ApplicantInfo.builder() + .name(applicantInfoVo.getName()) + .gender(applicantInfoVo.getGender()) + .birth(applicantInfoVo.getBirth()) + .email(applicantInfoVo.getEmail()) + .phoneNumber(applicantInfoVo.getPhoneNumber()) + .university(applicantInfoVo.getUniversity()) + .major(applicantInfoVo.getMajor()) + .uuid(uuid) + .semestersLeftNumber(applicantInfoVo.getSemestersLeftNumber()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/domain/Application.java b/src/main/java/ceos/backend/domain/application/domain/Application.java new file mode 100644 index 00000000..a08fc628 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/domain/Application.java @@ -0,0 +1,133 @@ +package ceos.backend.domain.application.domain; + + +import ceos.backend.domain.application.dto.request.CreateApplicationRequest; +import ceos.backend.domain.application.exception.exceptions.*; +import ceos.backend.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; + +@DynamicInsert +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Application extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "application_id") + private Long id; + + @Embedded private ApplicantInfo applicantInfo; + + @Embedded private ApplicationDetail applicationDetail; + + private String interviewDatetime; + + @NotNull + @ColumnDefault("false") + private boolean interviewCheck; + + @NotNull + @Enumerated(EnumType.STRING) + private Pass documentPass; + + @NotNull + @ColumnDefault("false") + private boolean finalCheck; + + @NotNull + @Enumerated(EnumType.STRING) + private Pass finalPass; + + @OneToMany(mappedBy = "application", cascade = CascadeType.ALL) + private List applicationAnswers = new ArrayList<>(); + + @OneToMany(mappedBy = "application", cascade = CascadeType.ALL) + private List applicationInterviews = new ArrayList<>(); + + @Builder + private Application(ApplicantInfo applicantInfo, ApplicationDetail applicationDetail) { + this.applicantInfo = applicantInfo; + this.applicationDetail = applicationDetail; + this.interviewDatetime = null; + this.documentPass = Pass.FAIL; + this.finalPass = Pass.FAIL; + } + + // 정적 팩토리 메서드 + public static Application of( + CreateApplicationRequest createApplicationRequest, int generation, String UUID) { + return Application.builder() + .applicantInfo( + ApplicantInfo.of(createApplicationRequest.getApplicantInfoVo(), UUID)) + .applicationDetail(ApplicationDetail.of(createApplicationRequest, generation)) + .build(); + } + + public void addApplicationAnswerList(List applicationAnswers) { + this.applicationAnswers = applicationAnswers; + } + + public void addApplicationInterviewList(List applicationInterviews) { + this.applicationInterviews = applicationInterviews; + } + + public void updateInterviewCheck(boolean check) { + this.interviewCheck = check; + } + + public void updateFinalCheck(boolean check) { + this.finalCheck = check; + } + + public void updateDocumentPass(Pass pass) { + if (this.documentPass == pass) { + throw SamePassStatus.EXCEPTION; + } + this.documentPass = pass; + } + + public void updateFinalPass(Pass pass) { + if (this.finalPass == pass) { + throw SamePassStatus.EXCEPTION; + } + this.finalPass = pass; + } + + public void validateDocumentPass() { + if (this.documentPass == Pass.FAIL) { + throw NotPassDocument.EXCEPTION; + } + } + + public void updateInterviewTime(String interviewTime) { + this.interviewDatetime = interviewTime; + } + + public void validateFinalPass() { + if (this.finalPass == Pass.FAIL) { + throw NotPassFinal.EXCEPTION; + } + } + + public void validateNotFinalCheck() { + if (this.isFinalCheck()) { + throw AlreadyCheckFinal.EXCEPTION; + } + } + + public void validateNotInterviewCheck() { + if (this.isInterviewCheck()) { + throw AlreadyCheckInterview.EXCEPTION; + } + } +} diff --git a/src/main/java/ceos/backend/domain/application/domain/ApplicationAnswer.java b/src/main/java/ceos/backend/domain/application/domain/ApplicationAnswer.java new file mode 100644 index 00000000..08b77be9 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/domain/ApplicationAnswer.java @@ -0,0 +1,53 @@ +package ceos.backend.domain.application.domain; + + +import ceos.backend.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class ApplicationAnswer extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "application_answer_id") + private Long id; + + // Question : Answer = 1:1 (단방향) + @OneToOne(cascade = CascadeType.ALL) + @JoinColumn(name = "application_question_id") + private ApplicationQuestion applicationQuestion; + + // Application : Answer : N:1 (양방향) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "application_id") + private Application application; + + @NotNull + @Column(columnDefinition = "TEXT") + private String answer; + + @Builder + private ApplicationAnswer( + ApplicationQuestion applicationQuestion, Application application, String answer) { + this.applicationQuestion = applicationQuestion; + this.application = application; + this.answer = answer; + } + + public static ApplicationAnswer of( + ApplicationQuestion applicationQuestion, Application application, String answer) { + return ApplicationAnswer.builder() + .applicationQuestion(applicationQuestion) + .application(application) + .answer(answer) + .build(); + } + + public void setApplication(Application application) { + this.application = application; + } +} diff --git a/src/main/java/ceos/backend/domain/application/domain/ApplicationDetail.java b/src/main/java/ceos/backend/domain/application/domain/ApplicationDetail.java new file mode 100644 index 00000000..126d02a1 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/domain/ApplicationDetail.java @@ -0,0 +1,61 @@ +package ceos.backend.domain.application.domain; + + +import ceos.backend.domain.application.dto.request.CreateApplicationRequest; +import ceos.backend.global.common.entity.Part; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ApplicationDetail { + @NotNull @Positive private int generation; + + @NotNull + @Size(max = 10) + @Enumerated(EnumType.STRING) + private Part part; + + @Size(max = 100) + @Column(columnDefinition = "TEXT") + private String otherActivities; + + @NotNull private LocalDate otDate; + + @NotNull private LocalDate demodayDate; + + @Builder + private ApplicationDetail( + int generation, + Part part, + String otherActivities, + LocalDate otDate, + LocalDate demodayDate) { + this.generation = generation; + this.part = part; + this.otherActivities = otherActivities; + this.otDate = otDate; + this.demodayDate = demodayDate; + } + + public static ApplicationDetail of(CreateApplicationRequest request, int generation) { + return ApplicationDetail.builder() + .generation(generation) + .part(request.getPart()) + .otherActivities(request.getOtherActivities()) + .otDate(request.getOtDate()) + .demodayDate(request.getDemodayDate()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/domain/ApplicationInterview.java b/src/main/java/ceos/backend/domain/application/domain/ApplicationInterview.java new file mode 100644 index 00000000..0d261ea2 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/domain/ApplicationInterview.java @@ -0,0 +1,42 @@ +package ceos.backend.domain.application.domain; + + +import ceos.backend.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class ApplicationInterview extends BaseEntity { + // 면접 불가능 시간이 저장됨 + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "application_interview_id") + private Long id; + + // Application : ApplicationInterview = N:1 (양방향) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "application_id") + private Application application; + + // Interview : ApplicationInterview = N:1 (단방향) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interview_id") + private Interview interview; + + @Builder + private ApplicationInterview(Application application, Interview interview) { + this.application = application; + this.interview = interview; + } + + public static ApplicationInterview of(Application application, Interview interview) { + return ApplicationInterview.builder().application(application).interview(interview).build(); + } + + public void setApplication(Application application) { + this.application = application; + } +} diff --git a/src/main/java/ceos/backend/domain/application/domain/ApplicationQuestion.java b/src/main/java/ceos/backend/domain/application/domain/ApplicationQuestion.java new file mode 100644 index 00000000..66f54923 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/domain/ApplicationQuestion.java @@ -0,0 +1,50 @@ +package ceos.backend.domain.application.domain; + + +import ceos.backend.domain.application.vo.QuestionVo; +import ceos.backend.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class ApplicationQuestion extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "application_question_id") + private Long id; + + @NotNull private int number; + + @NotNull + @Size(max = 255) + private String question; + + @NotNull private boolean multiline; + + @NotNull + @Enumerated(EnumType.STRING) + private QuestionCategory category; + + @Builder + private ApplicationQuestion( + int number, String question, boolean multiline, QuestionCategory category) { + this.number = number; + this.question = question; + this.multiline = multiline; + this.category = category; + } + + public static ApplicationQuestion of(QuestionVo questionVo, QuestionCategory category) { + return ApplicationQuestion.builder() + .category(category) + .question(questionVo.getQuestion()) + .number(questionVo.getQuestionIndex()) + .multiline(questionVo.isMultiline()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/domain/ApplicationQuestionDetail.java b/src/main/java/ceos/backend/domain/application/domain/ApplicationQuestionDetail.java new file mode 100644 index 00000000..6746f8b7 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/domain/ApplicationQuestionDetail.java @@ -0,0 +1,48 @@ +package ceos.backend.domain.application.domain; + + +import ceos.backend.domain.application.vo.QuestionDetailVo; +import ceos.backend.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class ApplicationQuestionDetail extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "application_question_detail_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "application_question_id") + private ApplicationQuestion applicationQuestion; + + @NotNull private String explaination; + + @NotNull + @Enumerated(EnumType.STRING) + private ExplainationColor color; + + @Builder + private ApplicationQuestionDetail( + ApplicationQuestion applicationQuestion, String explaination, ExplainationColor color) { + this.applicationQuestion = applicationQuestion; + this.explaination = explaination; + this.color = color; + } + + public static ApplicationQuestionDetail of( + ApplicationQuestion applicationQuestion, QuestionDetailVo questionDetailVo) { + return ApplicationQuestionDetail.builder() + .applicationQuestion(applicationQuestion) + .color(questionDetailVo.getColor()) + .explaination(questionDetailVo.getExplaination()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/domain/ExplainationColor.java b/src/main/java/ceos/backend/domain/application/domain/ExplainationColor.java new file mode 100644 index 00000000..b1aa46f5 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/domain/ExplainationColor.java @@ -0,0 +1,25 @@ +package ceos.backend.domain.application.domain; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ExplainationColor { + BLUE("blue"), + GRAY("gray"); + + @JsonValue private final String color; + + @JsonCreator + public static ExplainationColor parsing(String inputValue) { + return Stream.of(ExplainationColor.values()) + .filter(category -> category.getColor().equals(inputValue)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/ceos/backend/domain/application/domain/Gender.java b/src/main/java/ceos/backend/domain/application/domain/Gender.java new file mode 100644 index 00000000..e7ac698c --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/domain/Gender.java @@ -0,0 +1,26 @@ +package ceos.backend.domain.application.domain; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Gender { + F("여성"), + + M("남성"); + + @JsonValue private final String gender; + + @JsonCreator + public static Gender parsing(String inputValue) { + return Stream.of(Gender.values()) + .filter(category -> category.getGender().equals(inputValue)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/ceos/backend/domain/application/domain/Interview.java b/src/main/java/ceos/backend/domain/application/domain/Interview.java new file mode 100644 index 00000000..e30ccdcd --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/domain/Interview.java @@ -0,0 +1,33 @@ +package ceos.backend.domain.application.domain; + + +import ceos.backend.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Interview extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "interview_id") + private Long id; + + @NotNull private LocalDateTime fromDate; + + @NotNull private LocalDateTime toDate; + + @Builder + private Interview(Long id, LocalDateTime fromDate, LocalDateTime toDate) { + this.id = id; + this.fromDate = fromDate; + this.toDate = toDate; + } + + public static Interview of(LocalDateTime fromDate, LocalDateTime toDate) { + return Interview.builder().fromDate(fromDate).toDate(toDate).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/domain/Pass.java b/src/main/java/ceos/backend/domain/application/domain/Pass.java new file mode 100644 index 00000000..2251f477 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/domain/Pass.java @@ -0,0 +1,26 @@ +package ceos.backend.domain.application.domain; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Pass { + PASS("합격"), + + FAIL("불합격"); + + @JsonValue private final String result; + + @JsonCreator + public static Pass parsing(String inputValue) { + return Stream.of(Pass.values()) + .filter(category -> category.getResult().equals(inputValue)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/ceos/backend/domain/application/domain/QuestionCategory.java b/src/main/java/ceos/backend/domain/application/domain/QuestionCategory.java new file mode 100644 index 00000000..253d00fd --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/domain/QuestionCategory.java @@ -0,0 +1,9 @@ +package ceos.backend.domain.application.domain; + +public enum QuestionCategory { + COMMON, + PRODUCT, + DESIGN, + FRONTEND, + BACKEND; +} diff --git a/src/main/java/ceos/backend/domain/application/dto/request/CreateApplicationRequest.java b/src/main/java/ceos/backend/domain/application/dto/request/CreateApplicationRequest.java new file mode 100644 index 00000000..b0c3a36e --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/dto/request/CreateApplicationRequest.java @@ -0,0 +1,52 @@ +package ceos.backend.domain.application.dto.request; + + +import ceos.backend.domain.application.vo.*; +import ceos.backend.global.common.annotation.DateFormat; +import ceos.backend.global.common.annotation.ValidEnum; +import ceos.backend.global.common.entity.Part; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.List; +import lombok.Getter; + +@Getter +public class CreateApplicationRequest { + @Valid @JsonUnwrapped private ApplicantInfoVo applicantInfoVo; + + @Schema( + type = "string", + pattern = "yyyy.MM.dd", + defaultValue = "2023.03.20", + description = "ot 날짜") + @NotNull(message = "ot 날짜를 입력해주세요") + @DateFormat + private LocalDate otDate; + + @Schema( + type = "string", + pattern = "yyyy.MM.dd", + defaultValue = "2023.03.20", + description = "데모데이 날짜") + @NotNull(message = "데모데이 날짜를 입력해주세요") + @DateFormat + private LocalDate demodayDate; + + @Schema(defaultValue = "구르기", description = "지원자 다른 활동") + @NotEmpty(message = "지원자 다른 활동을 입력해주세요") + private String otherActivities; + + @Schema(defaultValue = "백엔드", description = "지원자 파트") + @ValidEnum(target = Part.class) + private Part part; + + @Valid private List commonAnswers; + + @Valid private List partAnswers; + + @Valid private List unableTimes; +} diff --git a/src/main/java/ceos/backend/domain/application/dto/request/UpdateApplicationQuestion.java b/src/main/java/ceos/backend/domain/application/dto/request/UpdateApplicationQuestion.java new file mode 100644 index 00000000..7082d124 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/dto/request/UpdateApplicationQuestion.java @@ -0,0 +1,24 @@ +package ceos.backend.domain.application.dto.request; + + +import ceos.backend.domain.application.vo.InterviewDateTimesVo; +import ceos.backend.domain.application.vo.QuestionVo; +import ceos.backend.global.common.annotation.ValidQuestionOrder; +import jakarta.validation.Valid; +import java.util.List; +import lombok.Getter; + +@Getter +public class UpdateApplicationQuestion { + @ValidQuestionOrder @Valid private List commonQuestions; + + @ValidQuestionOrder @Valid private List productQuestions; + + @ValidQuestionOrder @Valid private List designQuestions; + + @ValidQuestionOrder @Valid private List frontendQuestions; + + @ValidQuestionOrder @Valid private List backendQuestions; + + @Valid private List times; +} diff --git a/src/main/java/ceos/backend/domain/application/dto/request/UpdateAttendanceRequest.java b/src/main/java/ceos/backend/domain/application/dto/request/UpdateAttendanceRequest.java new file mode 100644 index 00000000..0b319ffe --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/dto/request/UpdateAttendanceRequest.java @@ -0,0 +1,16 @@ +package ceos.backend.domain.application.dto.request; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class UpdateAttendanceRequest { + @Schema(defaultValue = "true", description = "참여 가능 여부") + @NotNull + private boolean available; + + @Schema(defaultValue = "null", description = "참여 불가능 사유") + private String reason; +} diff --git a/src/main/java/ceos/backend/domain/application/dto/request/UpdateInterviewTime.java b/src/main/java/ceos/backend/domain/application/dto/request/UpdateInterviewTime.java new file mode 100644 index 00000000..1b100007 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/dto/request/UpdateInterviewTime.java @@ -0,0 +1,11 @@ +package ceos.backend.domain.application.dto.request; + + +import ceos.backend.global.common.dto.ParsedDuration; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import lombok.Getter; + +@Getter +public class UpdateInterviewTime { + @JsonUnwrapped private ParsedDuration parsedDuration; +} diff --git a/src/main/java/ceos/backend/domain/application/dto/request/UpdatePassStatus.java b/src/main/java/ceos/backend/domain/application/dto/request/UpdatePassStatus.java new file mode 100644 index 00000000..cb4dca5e --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/dto/request/UpdatePassStatus.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.dto.request; + + +import ceos.backend.domain.application.domain.Pass; +import ceos.backend.global.common.annotation.ValidEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +public class UpdatePassStatus { + @Schema(defaultValue = "탈락", description = "합격 여부") + @ValidEnum(target = Pass.class) + private Pass pass; +} diff --git a/src/main/java/ceos/backend/domain/application/dto/response/GetApplication.java b/src/main/java/ceos/backend/domain/application/dto/response/GetApplication.java new file mode 100644 index 00000000..49534214 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/dto/response/GetApplication.java @@ -0,0 +1,48 @@ +package ceos.backend.domain.application.dto.response; + + +import ceos.backend.domain.application.domain.Application; +import ceos.backend.domain.application.vo.*; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetApplication { + @JsonUnwrapped private ApplicantInfoVo applicantInfoVo; + @JsonUnwrapped private ApplicationDetailVo applicationDetailVo; + + private List commonQuestions; + private List partQuestions; + + private List times; + + @Builder + private GetApplication( + ApplicantInfoVo applicantInfoVo, + ApplicationDetailVo applicationDetailVo, + List commonQuestions, + List partQuestions, + List times) { + this.applicantInfoVo = applicantInfoVo; + this.applicationDetailVo = applicationDetailVo; + this.commonQuestions = commonQuestions; + this.partQuestions = partQuestions; + this.times = times; + } + + public static GetApplication of( + Application application, + List commonQuestions, + List partQuestions, + List times) { + return GetApplication.builder() + .applicantInfoVo(ApplicantInfoVo.from(application.getApplicantInfo())) + .applicationDetailVo(ApplicationDetailVo.from(application.getApplicationDetail())) + .commonQuestions(commonQuestions) + .partQuestions(partQuestions) + .times(times) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/dto/response/GetApplicationQuestion.java b/src/main/java/ceos/backend/domain/application/dto/response/GetApplicationQuestion.java new file mode 100644 index 00000000..caddd068 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/dto/response/GetApplicationQuestion.java @@ -0,0 +1,51 @@ +package ceos.backend.domain.application.dto.response; + + +import ceos.backend.domain.application.vo.InterviewDateTimesVo; +import ceos.backend.domain.application.vo.QuestionWithIdVo; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetApplicationQuestion { + private List commonQuestions; + private List productQuestions; + private List designQuestions; + private List frontendQuestions; + private List backendQuestions; + private List times; + + @Builder + private GetApplicationQuestion( + List commonQuestions, + List productQuestions, + List designQuestions, + List frontendQuestions, + List backendQuestions, + List times) { + this.commonQuestions = commonQuestions; + this.productQuestions = productQuestions; + this.designQuestions = designQuestions; + this.frontendQuestions = frontendQuestions; + this.backendQuestions = backendQuestions; + this.times = times; + } + + public static GetApplicationQuestion of( + List commonQuestions, + List productQuestions, + List designQuestions, + List frontendQuestions, + List backendQuestions, + List times) { + return GetApplicationQuestion.builder() + .commonQuestions(commonQuestions) + .productQuestions(productQuestions) + .designQuestions(designQuestions) + .frontendQuestions(frontendQuestions) + .backendQuestions(backendQuestions) + .times(times) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/dto/response/GetApplications.java b/src/main/java/ceos/backend/domain/application/dto/response/GetApplications.java new file mode 100644 index 00000000..c9cac138 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/dto/response/GetApplications.java @@ -0,0 +1,29 @@ +package ceos.backend.domain.application.dto.response; + + +import ceos.backend.domain.application.vo.ApplicationBriefInfoVo; +import ceos.backend.global.common.dto.PageInfo; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetApplications { + private List content; + PageInfo pageInfo; + + @Builder + private GetApplications( + List applicationBriefInfoVos, PageInfo pageInfo) { + this.content = applicationBriefInfoVos; + this.pageInfo = pageInfo; + } + + public static GetApplications of( + List applicationBriefInfoVos, PageInfo pageInfo) { + return GetApplications.builder() + .applicationBriefInfoVos(applicationBriefInfoVos) + .pageInfo(pageInfo) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/dto/response/GetCreationTime.java b/src/main/java/ceos/backend/domain/application/dto/response/GetCreationTime.java new file mode 100644 index 00000000..eb8e7817 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/dto/response/GetCreationTime.java @@ -0,0 +1,19 @@ +package ceos.backend.domain.application.dto.response; + + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetCreationTime { + private String createAt; + + @Builder + private GetCreationTime(String createAt) { + this.createAt = createAt; + } + + public static GetCreationTime from(String createAt) { + return GetCreationTime.builder().createAt(createAt).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/dto/response/GetInterviewTime.java b/src/main/java/ceos/backend/domain/application/dto/response/GetInterviewTime.java new file mode 100644 index 00000000..4bd54a5f --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/dto/response/GetInterviewTime.java @@ -0,0 +1,22 @@ +package ceos.backend.domain.application.dto.response; + + +import ceos.backend.domain.application.vo.InterviewTimeVo; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class GetInterviewTime { + private List times; + + @Builder + private GetInterviewTime(List times) { + this.times = times; + } + + public static GetInterviewTime from(List times) { + return GetInterviewTime.builder().times(times).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/dto/response/GetResultResponse.java b/src/main/java/ceos/backend/domain/application/dto/response/GetResultResponse.java new file mode 100644 index 00000000..d9f4d091 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/dto/response/GetResultResponse.java @@ -0,0 +1,75 @@ +package ceos.backend.domain.application.dto.response; + + +import ceos.backend.domain.application.domain.Application; +import ceos.backend.domain.application.domain.Pass; +import ceos.backend.domain.recruitment.domain.Recruitment; +import ceos.backend.global.common.dto.ParsedDuration; +import ceos.backend.global.util.ParsedDurationConvertor; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import java.time.LocalDate; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetResultResponse { + private Pass pass; + + private int generation; + + private String name; + + @JsonUnwrapped private ParsedDuration parsedDuration; + + private LocalDate otDate; + + private String openChatUrl; + + private boolean attendanceStatus; + + @Builder + private GetResultResponse( + Pass pass, + int generation, + String name, + ParsedDuration parsedDuration, + LocalDate otDate, + boolean attendanceStatus, + String openChatUrl) { + this.pass = pass; + this.generation = generation; + this.name = name; + this.parsedDuration = parsedDuration; + this.otDate = otDate; + this.attendanceStatus = attendanceStatus; + this.openChatUrl = openChatUrl; + } + + public static GetResultResponse toDocumentResult( + Application application, Recruitment recruitment) { + return GetResultResponse.builder() + .pass(application.getDocumentPass()) + .generation(recruitment.getGeneration()) + .name(application.getApplicantInfo().getName()) + .parsedDuration( + ParsedDurationConvertor.parsingDuration(application.getInterviewDatetime())) + .otDate(recruitment.getOtDate()) + .attendanceStatus(application.isInterviewCheck()) + .openChatUrl(recruitment.getOpenChatUrl()) + .build(); + } + + public static GetResultResponse toFinalResult( + Application application, Recruitment recruitment) { + return GetResultResponse.builder() + .pass(application.getFinalPass()) + .generation(recruitment.getGeneration()) + .name(application.getApplicantInfo().getName()) + .parsedDuration( + ParsedDurationConvertor.parsingDuration(application.getInterviewDatetime())) + .otDate(recruitment.getOtDate()) + .openChatUrl(recruitment.getOpenChatUrl()) + .attendanceStatus(application.isFinalCheck()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/enums/SortPartType.java b/src/main/java/ceos/backend/domain/application/enums/SortPartType.java new file mode 100644 index 00000000..a3c2c8c4 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/enums/SortPartType.java @@ -0,0 +1,9 @@ +package ceos.backend.domain.application.enums; + +public enum SortPartType { + ALL, + FRONTEND, + BACKEND, + PRODUCT, + DESIGN +} diff --git a/src/main/java/ceos/backend/domain/application/enums/SortPassType.java b/src/main/java/ceos/backend/domain/application/enums/SortPassType.java new file mode 100644 index 00000000..28f2b68f --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/enums/SortPassType.java @@ -0,0 +1,7 @@ +package ceos.backend.domain.application.enums; + +public enum SortPassType { + ALL, + PASS, + FAIL +} diff --git a/src/main/java/ceos/backend/domain/application/exception/ApplicationErrorCode.java b/src/main/java/ceos/backend/domain/application/exception/ApplicationErrorCode.java new file mode 100644 index 00000000..97295dad --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/ApplicationErrorCode.java @@ -0,0 +1,52 @@ +package ceos.backend.domain.application.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +import ceos.backend.global.common.dto.ErrorReason; +import ceos.backend.global.error.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ApplicationErrorCode implements BaseErrorCode { + /* Application */ + DUPLICATE_APPLICANT(BAD_REQUEST, "APPLICATION_400_1", "이미 지원한 지원자입니다."), + WRONG_GENERATION(BAD_REQUEST, "APPLICATION_400_2", "해당 기수를 지원할 수 없습니다."), + NOT_PASS_DOCUMENT(BAD_REQUEST, "APPLICATION_400_3", "서류 합격 상태가 아닙니다."), + ALREADY_CHECK_INTERVIEW(BAD_REQUEST, "APPLICATION_400_4", "면접 참여 여부를 이미 선택했습니다."), + NOT_PASS_FINAL(BAD_REQUEST, "APPLICATION_400_5", "최종 합격 상태가 아닙니다."), + ALREADY_CHECK_FINAL(BAD_REQUEST, "APPLICATION_400_6", "활동 여부를 이미 선택했습니다."), + SAME_PASS_STATUS(BAD_REQUEST, "APPLICATION_400_7", "같은 상태로 변경할 수 없습니다."), + NOT_SET_INTERVIEW_TIME(BAD_REQUEST, "APPLICATION_400_8", "면접 시간이 정해지지 않았습니다."), + APPLICATION_STILL_EXIST(BAD_REQUEST, "APPLICATION_400_9", "기존 지원자 데이터가 남아있습니다."), + + APPLICANT_NOT_FOUND(BAD_REQUEST, "APPLICATION_404_3", "존재하지 않는 지원자입니다."), + + /* Question */ + QUESTION_NOT_FOUND(BAD_REQUEST, "QUESTION_404_1", "존재하지 않는 질문입니다."), + + /* Answer */ + ANSWERS_STILL_EXIST(BAD_REQUEST, "ANSWER_400_1", "기존 질문에 대한 답변이 존재합니다."), + NOT_MATCHING_QNA(BAD_REQUEST, "ANSWER_400_2", "질문에 대한 답변이 없습니다."), + + /* Application Interview */ + APPLICATION_INTERVIEW_STILL_EXIST(BAD_REQUEST, "ANSWER_400_1", "기존 면접 시간에 대한 답변이 존재합니다."), + + /* Interview */ + INTERVIEW_NOT_FOUND(BAD_REQUEST, "INTERVIEW_404_1", "존재하지 않는 면접 시간입니다."), + + /* Excel File */ + FILE_CREATION_FAILED(INTERNAL_SERVER_ERROR, "APPLICATION_EXCEL_500_1", "파일 생성에 실패하였습니다."); + + private HttpStatus status; + private String code; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/AlreadyCheckFinal.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/AlreadyCheckFinal.java new file mode 100644 index 00000000..c57b2e70 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/AlreadyCheckFinal.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class AlreadyCheckFinal extends BaseErrorException { + + public static final AlreadyCheckFinal EXCEPTION = new AlreadyCheckFinal(); + + private AlreadyCheckFinal() { + super(ApplicationErrorCode.ALREADY_CHECK_FINAL); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/AlreadyCheckInterview.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/AlreadyCheckInterview.java new file mode 100644 index 00000000..b2ba5169 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/AlreadyCheckInterview.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class AlreadyCheckInterview extends BaseErrorException { + + public static final AlreadyCheckInterview EXCEPTION = new AlreadyCheckInterview(); + + private AlreadyCheckInterview() { + super(ApplicationErrorCode.ALREADY_CHECK_INTERVIEW); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/AnswerStillExist.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/AnswerStillExist.java new file mode 100644 index 00000000..0da2b1c3 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/AnswerStillExist.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class AnswerStillExist extends BaseErrorException { + + public static final AnswerStillExist EXCEPTION = new AnswerStillExist(); + + private AnswerStillExist() { + super(ApplicationErrorCode.ANSWERS_STILL_EXIST); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/ApplicantNotFound.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/ApplicantNotFound.java new file mode 100644 index 00000000..f3d0f3d9 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/ApplicantNotFound.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class ApplicantNotFound extends BaseErrorException { + + public static final ApplicantNotFound EXCEPTION = new ApplicantNotFound(); + + private ApplicantNotFound() { + super(ApplicationErrorCode.APPLICANT_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/ApplicationInterviewStillExist.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/ApplicationInterviewStillExist.java new file mode 100644 index 00000000..020381cd --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/ApplicationInterviewStillExist.java @@ -0,0 +1,15 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class ApplicationInterviewStillExist extends BaseErrorException { + + public static final ApplicationInterviewStillExist EXCEPTION = + new ApplicationInterviewStillExist(); + + private ApplicationInterviewStillExist() { + super(ApplicationErrorCode.APPLICATION_INTERVIEW_STILL_EXIST); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/ApplicationStillExist.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/ApplicationStillExist.java new file mode 100644 index 00000000..5ebf00fa --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/ApplicationStillExist.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class ApplicationStillExist extends BaseErrorException { + + public static final ApplicationStillExist EXCEPTION = new ApplicationStillExist(); + + private ApplicationStillExist() { + super(ApplicationErrorCode.ALREADY_CHECK_INTERVIEW); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/DuplicateApplicant.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/DuplicateApplicant.java new file mode 100644 index 00000000..87560641 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/DuplicateApplicant.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class DuplicateApplicant extends BaseErrorException { + + public static final DuplicateApplicant EXCEPTION = new DuplicateApplicant(); + + private DuplicateApplicant() { + super(ApplicationErrorCode.DUPLICATE_APPLICANT); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/FileCreationFailed.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/FileCreationFailed.java new file mode 100644 index 00000000..f4d1b817 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/FileCreationFailed.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class FileCreationFailed extends BaseErrorException { + + public static final FileCreationFailed EXCEPTION = new FileCreationFailed(); + + private FileCreationFailed() { + super(ApplicationErrorCode.FILE_CREATION_FAILED); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/InterviewNotFound.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/InterviewNotFound.java new file mode 100644 index 00000000..02742de6 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/InterviewNotFound.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class InterviewNotFound extends BaseErrorException { + + public static final InterviewNotFound EXCEPTION = new InterviewNotFound(); + + private InterviewNotFound() { + super(ApplicationErrorCode.INTERVIEW_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/NotMatchingQnA.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/NotMatchingQnA.java new file mode 100644 index 00000000..9ce4c3e8 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/NotMatchingQnA.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class NotMatchingQnA extends BaseErrorException { + + public static final NotMatchingQnA EXCEPTION = new NotMatchingQnA(); + + private NotMatchingQnA() { + super(ApplicationErrorCode.NOT_MATCHING_QNA); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/NotPassDocument.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/NotPassDocument.java new file mode 100644 index 00000000..b9d7cb56 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/NotPassDocument.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class NotPassDocument extends BaseErrorException { + + public static final NotPassDocument EXCEPTION = new NotPassDocument(); + + private NotPassDocument() { + super(ApplicationErrorCode.NOT_PASS_DOCUMENT); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/NotPassFinal.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/NotPassFinal.java new file mode 100644 index 00000000..8a0a08f8 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/NotPassFinal.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class NotPassFinal extends BaseErrorException { + + public static final NotPassFinal EXCEPTION = new NotPassFinal(); + + private NotPassFinal() { + super(ApplicationErrorCode.NOT_PASS_FINAL); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/NotSetInterviewTime.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/NotSetInterviewTime.java new file mode 100644 index 00000000..4b0b1798 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/NotSetInterviewTime.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class NotSetInterviewTime extends BaseErrorException { + + public static final NotSetInterviewTime EXCEPTION = new NotSetInterviewTime(); + + private NotSetInterviewTime() { + super(ApplicationErrorCode.NOT_SET_INTERVIEW_TIME); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/QuestionNotFound.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/QuestionNotFound.java new file mode 100644 index 00000000..a8359040 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/QuestionNotFound.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class QuestionNotFound extends BaseErrorException { + + public static final QuestionNotFound EXCEPTION = new QuestionNotFound(); + + private QuestionNotFound() { + super(ApplicationErrorCode.QUESTION_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/SamePassStatus.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/SamePassStatus.java new file mode 100644 index 00000000..b96cfe7b --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/SamePassStatus.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class SamePassStatus extends BaseErrorException { + + public static final SamePassStatus EXCEPTION = new SamePassStatus(); + + private SamePassStatus() { + super(ApplicationErrorCode.SAME_PASS_STATUS); + } +} diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/WrongGeneration.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/WrongGeneration.java new file mode 100644 index 00000000..540590ec --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/WrongGeneration.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.application.exception.exceptions; + + +import ceos.backend.domain.application.exception.ApplicationErrorCode; +import ceos.backend.global.error.BaseErrorException; + +public class WrongGeneration extends BaseErrorException { + + public static final WrongGeneration EXCEPTION = new WrongGeneration(); + + private WrongGeneration() { + super(ApplicationErrorCode.WRONG_GENERATION); + } +} diff --git a/src/main/java/ceos/backend/domain/application/helper/ApplicationExcelHelper.java b/src/main/java/ceos/backend/domain/application/helper/ApplicationExcelHelper.java new file mode 100644 index 00000000..623f1632 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/helper/ApplicationExcelHelper.java @@ -0,0 +1,52 @@ +package ceos.backend.domain.application.helper; + + +import ceos.backend.domain.application.domain.ApplicationInterview; +import ceos.backend.domain.application.domain.ApplicationQuestion; +import ceos.backend.domain.application.domain.Interview; +import ceos.backend.domain.application.repository.InterviewRepository; +import ceos.backend.global.util.InterviewConvertor; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ApplicationExcelHelper { + + private final InterviewRepository interviewRepository; + + public Map getQuestionIndexMap( + List headers, List questionList) { + Map questionIndexMap = new HashMap<>(); + int colIndex = headers.size(); + for (ApplicationQuestion applicationQuestion : questionList) { + headers.add(applicationQuestion.getQuestion()); + questionIndexMap.put(applicationQuestion.getId(), colIndex++); // Map : 정렬된 질문 id, index + } + return questionIndexMap; + } + + public Map getInterviewTimeMap() { + Map interviewTimeMap = new HashMap<>(); + + List interviewList = interviewRepository.findAll(); + + for (Interview interview : interviewList) { + String duration = InterviewConvertor.interviewDateFormatter(interview); + interviewTimeMap.put(interview.getId(), duration); // Map : 면접 시간 id, 면접 시간 format + } + return interviewTimeMap; + } + + public String getUnableInterview( + Map interviewTimeMap, List applicationInterviews) { + String unableInterview = ""; + for (ApplicationInterview interview : applicationInterviews) { + unableInterview += interviewTimeMap.get(interview.getInterview().getId()) + "\n"; + } + return unableInterview; + } +} diff --git a/src/main/java/ceos/backend/domain/application/helper/ApplicationHelper.java b/src/main/java/ceos/backend/domain/application/helper/ApplicationHelper.java new file mode 100644 index 00000000..d2a39d08 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/helper/ApplicationHelper.java @@ -0,0 +1,148 @@ +package ceos.backend.domain.application.helper; + + +import ceos.backend.domain.application.domain.*; +import ceos.backend.domain.application.dto.request.CreateApplicationRequest; +import ceos.backend.domain.application.dto.request.UpdateAttendanceRequest; +import ceos.backend.domain.application.enums.SortPartType; +import ceos.backend.domain.application.enums.SortPassType; +import ceos.backend.domain.application.exception.exceptions.ApplicantNotFound; +import ceos.backend.domain.application.mapper.ApplicationMapper; +import ceos.backend.domain.application.repository.*; +import ceos.backend.global.common.dto.AwsSESMail; +import ceos.backend.global.common.dto.SlackUnavailableReason; +import ceos.backend.global.common.entity.Part; +import ceos.backend.global.common.event.Event; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ApplicationHelper { + private final ApplicationMapper applicationMapper; + private final ApplicationRepository applicationRepository; + private final ApplicationQuestionRepository applicationQuestionRepository; + + public Page getApplications( + SortPassType docPass, + SortPassType finalPass, + SortPartType sortType, + PageRequest pageRequest) { + Page pageManagements = null; + Part part = toPart(sortType); + if (docPass == SortPassType.ALL && finalPass == SortPassType.ALL) { + switch (sortType) { + case ALL -> pageManagements = applicationRepository.findAll(pageRequest); + default -> pageManagements = + applicationRepository.findAllByPart(toPart(sortType), pageRequest); + } + } else if (docPass != SortPassType.ALL && finalPass == SortPassType.ALL) { + Pass pass = toPass(docPass); + switch (sortType) { + case ALL -> pageManagements = + applicationRepository.findAllByDocumentPass(pass, pageRequest); + default -> pageManagements = + applicationRepository.findAllByPartAndDocumentPass( + toPart(sortType), pass, pageRequest); + } + } else if (docPass == SortPassType.ALL && finalPass != SortPassType.ALL) { + Pass pass = toPass(finalPass); + switch (sortType) { + case ALL -> pageManagements = + applicationRepository.findAllByFinalPass(pass, pageRequest); + default -> pageManagements = + applicationRepository.findAllByPartAndFinalPass( + toPart(sortType), pass, pageRequest); + } + } else { + Pass convertedDocPass = toPass(docPass); + Pass convertedFinalPass = toPass(finalPass); + switch (sortType) { + case ALL -> pageManagements = + applicationRepository.findAllByDocumentPassAndFinalPass( + convertedDocPass, convertedFinalPass, pageRequest); + default -> pageManagements = + applicationRepository.findAllByPartAndDocumentPassAndFinalPass( + toPart(sortType), + convertedDocPass, + convertedFinalPass, + pageRequest); + } + } + return pageManagements; + } + + public String generateUUID() { + String newUUID; + while (true) { + newUUID = UUID.randomUUID().toString(); + if (applicationRepository.findByUuid(newUUID).isEmpty()) { + break; + } + } + return newUUID; + } + + public void sendEmail(CreateApplicationRequest request, int generation, String UUID) { + final List applicationQuestions = + applicationQuestionRepository.findAll(); + Event.raise(AwsSESMail.of(request, applicationQuestions, generation, UUID)); + } + + public void sendSlackUnableReasonMessage( + Application application, UpdateAttendanceRequest request, boolean isfinal) { + final SlackUnavailableReason reason = + SlackUnavailableReason.of(application, request.getReason(), isfinal); + Event.raise(reason); + } + + public Application getApplicationById(Long id) { + return applicationRepository + .findById(id) + .orElseThrow( + () -> { + throw ApplicantNotFound.EXCEPTION; + }); + } + + public Application getApplicationByUuidAndEmail(String uuid, String email) { + return applicationRepository + .findByUuidAndEmail(uuid, email) + .orElseThrow( + () -> { + throw ApplicantNotFound.EXCEPTION; + }); + } + + private Pass toPass(SortPassType passType) { + Pass pass = Pass.FAIL; + if (passType == SortPassType.PASS) { + pass = Pass.PASS; + } + return pass; + } + + private Part toPart(SortPartType sortType) { + switch (sortType) { + case DESIGN -> { + return Part.DESIGN; + } + case BACKEND -> { + return Part.BACKEND; + } + case PRODUCT -> { + return Part.PRODUCT; + } + case FRONTEND -> { + return Part.FRONTEND; + } + } + return null; + } +} diff --git a/src/main/java/ceos/backend/domain/application/mapper/ApplicationMapper.java b/src/main/java/ceos/backend/domain/application/mapper/ApplicationMapper.java new file mode 100644 index 00000000..00f83c0c --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/mapper/ApplicationMapper.java @@ -0,0 +1,328 @@ +package ceos.backend.domain.application.mapper; + +import static java.util.Map.*; + +import ceos.backend.domain.application.domain.*; +import ceos.backend.domain.application.dto.request.CreateApplicationRequest; +import ceos.backend.domain.application.dto.request.UpdateApplicationQuestion; +import ceos.backend.domain.application.dto.response.*; +import ceos.backend.domain.application.exception.exceptions.InterviewNotFound; +import ceos.backend.domain.application.exception.exceptions.QuestionNotFound; +import ceos.backend.domain.application.vo.*; +import ceos.backend.domain.recruitment.domain.Recruitment; +import ceos.backend.global.common.dto.PageInfo; +import ceos.backend.global.common.dto.ParsedDuration; +import ceos.backend.global.common.entity.Part; +import ceos.backend.global.util.InterviewConvertor; +import ceos.backend.global.util.ParsedDurationConvertor; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +@Component +public class ApplicationMapper { + public Application toEntity(CreateApplicationRequest request, int generation, String UUID) { + return Application.of(request, generation, UUID); + } + + public List toAnswerList( + CreateApplicationRequest request, + Application application, + List questions) { + final Part part = request.getPart(); + // common + List answers = + new java.util.ArrayList<>( + request.getCommonAnswers().stream() + .map( + answerVo -> + toApplicationAnswer( + questions, + application, + answerVo, + part, + true)) + .toList()); + // part + request.getPartAnswers() + .forEach( + answerVo -> { + answers.add( + toApplicationAnswer( + questions, application, answerVo, part, false)); + }); + return answers; + } + + private ApplicationAnswer toApplicationAnswer( + List questions, + Application application, + AnswerVo answerVo, + Part part, + boolean isCommon) { + final String category = isCommon ? "COMMON" : part.toString(); + + ApplicationQuestion question = + questions.stream() + .filter(q -> q.getCategory().toString().equals(category)) + .filter(q -> Objects.equals(q.getId(), answerVo.getQuestionId())) + .findFirst() + .orElseThrow(() -> QuestionNotFound.EXCEPTION); + return ApplicationAnswer.of(question, application, answerVo.getAnswer()); + } + + public List toApplicationInterviewList( + List unableTimes, Application application, List interviews) { + List applicationInterviews = + interviews.stream() + .filter( + interview -> + unableTimes.contains( + InterviewConvertor.interviewDateFormatter( + interview))) + .map(interview -> ApplicationInterview.of(application, interview)) + .toList(); + if (applicationInterviews.isEmpty() && !unableTimes.isEmpty()) { + throw InterviewNotFound.EXCEPTION; + } + return applicationInterviews; + } + + public GetResultResponse toGetResultResponse( + Application application, Recruitment recruitment, boolean isDocument) { + if (isDocument) { + return GetResultResponse.toDocumentResult(application, recruitment); + } + return GetResultResponse.toFinalResult(application, recruitment); + } + + public QuestionListVo toQuestionList(UpdateApplicationQuestion updateApplicationQuestion) { + final List commonQuestions = updateApplicationQuestion.getCommonQuestions(); + final List productQuestions = updateApplicationQuestion.getProductQuestions(); + final List designQuestions = updateApplicationQuestion.getDesignQuestions(); + final List frontendQuestions = updateApplicationQuestion.getFrontendQuestions(); + final List backendQuestions = updateApplicationQuestion.getBackendQuestions(); + + List questions = new ArrayList<>(); + List questionDetails = new ArrayList<>(); + parsingQuestion(questions, questionDetails, commonQuestions, QuestionCategory.COMMON); + parsingQuestion(questions, questionDetails, productQuestions, QuestionCategory.PRODUCT); + parsingQuestion(questions, questionDetails, designQuestions, QuestionCategory.DESIGN); + parsingQuestion(questions, questionDetails, frontendQuestions, QuestionCategory.FRONTEND); + parsingQuestion(questions, questionDetails, backendQuestions, QuestionCategory.BACKEND); + return QuestionListVo.of(questions, questionDetails); + } + + private void parsingQuestion( + List questions, + List questionDetails, + List ansQuestions, + QuestionCategory category) { + ansQuestions.forEach( + questionVo -> { + final ApplicationQuestion applicationQuestion = + ApplicationQuestion.of(questionVo, category); + questions.add(applicationQuestion); + questionVo + .getQuestionDetail() + .forEach( + questionDetailVo -> { + questionDetails.add( + ApplicationQuestionDetail.of( + applicationQuestion, questionDetailVo)); + }); + }); + } + + public List toInterviewList(List times) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss"); + List interviews = new ArrayList<>(); + times.forEach( + time -> { + final String[] strTimes = time.split(" - "); + final LocalDateTime fromTime = LocalDateTime.parse(strTimes[0], formatter); + final LocalDateTime toTime = LocalDateTime.parse(strTimes[1], formatter); + interviews.add(Interview.of(fromTime, toTime)); + }); + return interviews; + } + + public GetInterviewTime toGetInterviewTime( + List interviews, List applicationInterviews) { + return GetInterviewTime.builder() + .times(toInterviewTimeVoList(interviews, applicationInterviews)) + .build(); + } + + public GetApplicationQuestion toGetApplicationQuestion( + List applicationQuestions, + List applicationQuestionDetails, + List interviews) { + List commonQuestions = new ArrayList<>(); + List productQuestions = new ArrayList<>(); + List frontendQuestions = new ArrayList<>(); + List backendQuestions = new ArrayList<>(); + List designQuestions = new ArrayList<>(); + applicationQuestions.forEach( + applicationQuestion -> { + final List questionDetailVos = + applicationQuestionDetails.stream() + .filter( + details -> + details.getApplicationQuestion() + .equals(applicationQuestion)) + .map(QuestionDetailVo::from) + .toList(); + final QuestionWithIdVo questionVo = + QuestionWithIdVo.of(applicationQuestion, questionDetailVos); + switch (applicationQuestion.getCategory()) { + case COMMON -> commonQuestions.add(questionVo); + case PRODUCT -> productQuestions.add(questionVo); + case DESIGN -> designQuestions.add(questionVo); + case FRONTEND -> frontendQuestions.add(questionVo); + case BACKEND -> backendQuestions.add(questionVo); + } + }); + + final List parsedDurations = + interviews.stream() + .map(InterviewConvertor::interviewDateFormatter) + .map(ParsedDurationConvertor::parsingYearDuration) + .toList(); + final Set dateSets = + parsedDurations.stream().map(ParsedDuration::getDate).collect(Collectors.toSet()); + + final Comparator order = + Comparator.comparing(InterviewDateTimesVo::getDate); + + final List interviewDateTimesVos = + dateSets.stream() + .map( + dateSet -> + InterviewDateTimesVo.of( + dateSet, + parsedDurations.stream() + .filter( + parsedDuration -> + parsedDuration + .getDate() + .equals(dateSet)) + .map(ParsedDuration::getDuration) + .toList())) + .sorted(order) + .toList(); + + return GetApplicationQuestion.of( + commonQuestions, + productQuestions, + designQuestions, + frontendQuestions, + backendQuestions, + interviewDateTimesVos); + } + + public GetApplication toGetApplication( + Application application, + List interviews, + List applicationInterviews, + List applicationQuestions, + List applicationQuestionDetails, + List applicationAnswers) { + // qna common + final List commonQuestions = + applicationQuestions.stream() + .filter(question -> question.getCategory() == QuestionCategory.COMMON) + .map( + question -> + toQnAVo( + applicationAnswers, + applicationQuestionDetails, + question)) + .toList(); + // qna part + final Part part = application.getApplicationDetail().getPart(); + final List partQuestions = + applicationQuestions.stream() + .filter( + question -> + question.getCategory().toString().equals(part.toString())) + .map( + question -> + toQnAVo( + applicationAnswers, + applicationQuestionDetails, + question)) + .toList(); + + // interview + List times = toInterviewTimeVoList(interviews, applicationInterviews); + return GetApplication.of(application, commonQuestions, partQuestions, times); + } + + private QnAVo toQnAVo( + List applicationAnswers, + List applicationQuestionDetails, + ApplicationQuestion question) { + return applicationAnswers.stream() + .filter( + applicationAnswer -> + applicationAnswer.getApplicationQuestion().equals(question)) + .map( + answer -> { + final List detailVos = + applicationQuestionDetails.stream() + .filter( + details -> + details.getApplicationQuestion() + .equals(question)) + .map(QuestionDetailVo::from) + .toList(); + return QnAVo.of(question, detailVos, answer); + }) + .findFirst() + .orElseThrow(); + } + + private List toInterviewTimeVoList( + List interviews, List applicationInterviews) { + return interviews.stream() + .map( + interview -> { + final String duration = + InterviewConvertor.interviewDateFormatter(interview); + final ParsedDuration parsedDuration = + ParsedDurationConvertor.parsingYearDuration(duration); + if (applicationInterviews.stream() + .anyMatch( + applicationInterview -> + applicationInterview + .getInterview() + .equals(interview))) { + return InterviewTimeVo.of(true, parsedDuration); + } + return InterviewTimeVo.of(false, parsedDuration); + }) + .toList(); + } + + public GetApplications toGetApplications(Page pageManagements, PageInfo pageInfo) { + List applicationBriefInfoVos = + pageManagements.stream() + .map( + vo -> + ApplicationBriefInfoVo.of( + vo, toParsedDuration(vo.getInterviewDatetime()))) + .toList(); + return GetApplications.of(applicationBriefInfoVos, pageInfo); + } + + private ParsedDuration toParsedDuration(String time) { + if (time == null) { + return ParsedDuration.toNullParsedDuration(); + } + return ParsedDurationConvertor.parsingYearDuration(time); + } +} diff --git a/src/main/java/ceos/backend/domain/application/repository/ApplicationAnswerRepository.java b/src/main/java/ceos/backend/domain/application/repository/ApplicationAnswerRepository.java new file mode 100644 index 00000000..56357814 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/repository/ApplicationAnswerRepository.java @@ -0,0 +1,11 @@ +package ceos.backend.domain.application.repository; + + +import ceos.backend.domain.application.domain.Application; +import ceos.backend.domain.application.domain.ApplicationAnswer; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ApplicationAnswerRepository extends JpaRepository { + List findAllByApplication(Application application); +} diff --git a/src/main/java/ceos/backend/domain/application/repository/ApplicationInterviewRepository.java b/src/main/java/ceos/backend/domain/application/repository/ApplicationInterviewRepository.java new file mode 100644 index 00000000..f23e9b1b --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/repository/ApplicationInterviewRepository.java @@ -0,0 +1,11 @@ +package ceos.backend.domain.application.repository; + + +import ceos.backend.domain.application.domain.Application; +import ceos.backend.domain.application.domain.ApplicationInterview; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ApplicationInterviewRepository extends JpaRepository { + List findAllByApplication(Application application); +} diff --git a/src/main/java/ceos/backend/domain/application/repository/ApplicationQuestionDetailRepository.java b/src/main/java/ceos/backend/domain/application/repository/ApplicationQuestionDetailRepository.java new file mode 100644 index 00000000..634ab5f8 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/repository/ApplicationQuestionDetailRepository.java @@ -0,0 +1,8 @@ +package ceos.backend.domain.application.repository; + + +import ceos.backend.domain.application.domain.ApplicationQuestionDetail; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ApplicationQuestionDetailRepository + extends JpaRepository {} diff --git a/src/main/java/ceos/backend/domain/application/repository/ApplicationQuestionRepository.java b/src/main/java/ceos/backend/domain/application/repository/ApplicationQuestionRepository.java new file mode 100644 index 00000000..0a8bf334 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/repository/ApplicationQuestionRepository.java @@ -0,0 +1,7 @@ +package ceos.backend.domain.application.repository; + + +import ceos.backend.domain.application.domain.ApplicationQuestion; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ApplicationQuestionRepository extends JpaRepository {} diff --git a/src/main/java/ceos/backend/domain/application/repository/ApplicationRepository.java b/src/main/java/ceos/backend/domain/application/repository/ApplicationRepository.java new file mode 100644 index 00000000..b01a25e1 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/repository/ApplicationRepository.java @@ -0,0 +1,65 @@ +package ceos.backend.domain.application.repository; + + +import ceos.backend.domain.application.domain.Application; +import ceos.backend.domain.application.domain.Pass; +import ceos.backend.global.common.entity.Part; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ApplicationRepository extends JpaRepository { + @Query("select distinct a from Application a" + " where a.applicantInfo.email = :email") + Optional findByEmail(@Param("email") String email); + + @Query("select distinct a from Application a" + " where a.applicantInfo.uuid = :uuid") + Optional findByUuid(@Param("uuid") String uuid); + + @Query( + "select a from Application a" + + " where a.applicantInfo.uuid = :uuid" + + " and a.applicantInfo.email = :email") + Optional findByUuidAndEmail( + @Param("uuid") String uuid, @Param("email") String email); + + @Query("select count(a) > 0 from Application a" + " where a.applicantInfo.email = :email") + boolean existsByEmail(@Param("email") String email); + + @Query("select a from Application a" + " where a.applicationDetail.part = :part") + Page findAllByPart(@Param("part") Part part, PageRequest pageRequest); + + Page findAllByFinalPass(Pass fail, PageRequest pageRequest); + + Page findAllByDocumentPass(Pass fail, PageRequest pageRequest); + + @Query( + "select a from Application a" + + " where a.applicationDetail.part = :part" + + " and a.documentPass = :pass") + Page findAllByPartAndDocumentPass( + @Param("part") Part backend, @Param("pass") Pass pass, PageRequest pageRequest); + + @Query( + "select a from Application a" + + " where a.applicationDetail.part = :part" + + " and a.finalPass = :pass") + Page findAllByPartAndFinalPass( + @Param("part") Part product, @Param("pass") Pass pass, PageRequest pageRequest); + + Page findAllByDocumentPassAndFinalPass( + Pass documentPass, Pass finalPass, PageRequest pageRequest); + + @Query( + "select a from Application a" + + " where a.applicationDetail.part = :part" + + " and a.documentPass = :convertedDocPass" + + " and a.finalPass = :convertedFinalPass") + Page findAllByPartAndDocumentPassAndFinalPass( + @Param("part") Part backend, + @Param("convertedDocPass") Pass convertedDocPass, + @Param("convertedFinalPass") Pass convertedFinalPass, + PageRequest pageRequest); +} diff --git a/src/main/java/ceos/backend/domain/application/repository/InterviewRepository.java b/src/main/java/ceos/backend/domain/application/repository/InterviewRepository.java new file mode 100644 index 00000000..c7d5fad1 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/repository/InterviewRepository.java @@ -0,0 +1,7 @@ +package ceos.backend.domain.application.repository; + + +import ceos.backend.domain.application.domain.Interview; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InterviewRepository extends JpaRepository {} diff --git a/src/main/java/ceos/backend/domain/application/service/ApplicationExcelService.java b/src/main/java/ceos/backend/domain/application/service/ApplicationExcelService.java new file mode 100644 index 00000000..939655a2 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/service/ApplicationExcelService.java @@ -0,0 +1,183 @@ +package ceos.backend.domain.application.service; + + +import ceos.backend.domain.application.domain.Application; +import ceos.backend.domain.application.domain.ApplicationAnswer; +import ceos.backend.domain.application.domain.ApplicationInterview; +import ceos.backend.domain.application.domain.ApplicationQuestion; +import ceos.backend.domain.application.dto.response.GetCreationTime; +import ceos.backend.domain.application.exception.exceptions.FileCreationFailed; +import ceos.backend.domain.application.helper.ApplicationExcelHelper; +import ceos.backend.domain.application.repository.ApplicationQuestionRepository; +import ceos.backend.domain.application.repository.ApplicationRepository; +import ceos.backend.domain.recruitment.domain.Recruitment; +import ceos.backend.domain.recruitment.repository.RecruitmentRepository; +import ceos.backend.global.common.entity.Part; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ApplicationExcelService { + private final RecruitmentRepository recruitmentRepository; + private final ApplicationQuestionRepository applicationQuestionRepository; + private final ApplicationRepository applicationRepository; + private final ApplicationExcelHelper applicationExcelHelper; + + @Transactional + public Path getApplicationExcel() { + return Paths.get("ApplicationList.xlsx"); + } + + @Transactional + public GetCreationTime createApplicationExcel() { + try { + // 지원서 엑셀 생성 + createApplicationExcelFile(); + } catch (IOException e) { + throw FileCreationFailed.EXCEPTION; + } + + LocalDateTime dateTime = LocalDateTime.now(); + + Recruitment recruitment = recruitmentRepository.findAll().get(0); + recruitment.updateApplicationExcelCreatedAt(dateTime); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + return GetCreationTime.from(dateTime.format(formatter)); + } + + @Transactional(readOnly = true) + public void createApplicationExcelFile() throws IOException { + + Workbook workbook = new XSSFWorkbook(); + Sheet sheet = workbook.createSheet("Sheet1"); + + // Header + List headers = + new ArrayList<>( + List.of( + "", "파트", "이름", "성별", "생년월일", "email", "전화번호", "대학교", "전공", + "남은 학기 수", "OT", "데모데이", "다른 활동")); + + // 지원서 질문 + List questionList = applicationQuestionRepository.findAll(); + questionList.sort( + Comparator.comparing(ApplicationQuestion::getCategory) + .thenComparing(ApplicationQuestion::getNumber)); + + List applicationList = applicationRepository.findAll(); + + Map interviewTimeMap = applicationExcelHelper.getInterviewTimeMap(); + Map questionIndexMap = + applicationExcelHelper.getQuestionIndexMap(headers, questionList); + + // Header + headers.addAll(List.of("면접 불가능 시간", "서류 합격 여부", "면접 시간")); + + int colIndex = 0; + int rowIndex = 0; + + Row row = sheet.createRow(rowIndex++); + + for (String header : headers) { + row.createCell(colIndex++).setCellValue(header); + } + + // 날짜 형식 설정 + CellStyle cellStyle = workbook.createCellStyle(); + CreationHelper creationHelper = workbook.getCreationHelper(); + cellStyle.setDataFormat(creationHelper.createDataFormat().getFormat("yyyy-mm-dd")); + + Cell cell; + + // 지원서 + for (Application application : applicationList) { + + List applicationAnswers = application.getApplicationAnswers(); + List applicationInterviews = + application.getApplicationInterviews(); + + Part part = application.getApplicationDetail().getPart(); + + colIndex = 0; + row = sheet.createRow(rowIndex); + row.createCell(colIndex++).setCellValue(rowIndex); + row.createCell(colIndex++) + .setCellValue(application.getApplicationDetail().getPart().getPart()); + row.createCell(colIndex++).setCellValue(application.getApplicantInfo().getName()); + row.createCell(colIndex++) + .setCellValue(application.getApplicantInfo().getGender().getGender()); + + cell = row.createCell(colIndex++); + cell.setCellValue(application.getApplicantInfo().getBirth()); + cell.setCellStyle(cellStyle); + + row.createCell(colIndex++).setCellValue(application.getApplicantInfo().getEmail()); + row.createCell(colIndex++) + .setCellValue(application.getApplicantInfo().getPhoneNumber()); + row.createCell(colIndex++) + .setCellValue(application.getApplicantInfo().getUniversity().getUniversity()); + row.createCell(colIndex++).setCellValue(application.getApplicantInfo().getMajor()); + row.createCell(colIndex++) + .setCellValue(application.getApplicantInfo().getSemestersLeftNumber()); + + cell = row.createCell(colIndex++); + cell.setCellValue(application.getApplicationDetail().getOtDate()); + cell.setCellStyle(cellStyle); + + cell = row.createCell(colIndex++); + cell.setCellValue(application.getApplicationDetail().getDemodayDate()); + cell.setCellStyle(cellStyle); + + row.createCell(colIndex++) + .setCellValue((application.getApplicationDetail().getOtherActivities())); + + // 질문 답변 입력 + for (ApplicationAnswer answer : applicationAnswers) { + int index = (int) questionIndexMap.get(answer.getApplicationQuestion().getId()); + row.createCell(index).setCellValue(answer.getAnswer()); + } + colIndex += questionList.size(); + + row.createCell(colIndex++) + .setCellValue( + applicationExcelHelper.getUnableInterview( + interviewTimeMap, applicationInterviews)); // 면접 불가능 시간 + row.createCell(colIndex++).setCellValue(application.getDocumentPass().getResult()); + row.createCell(colIndex++).setCellValue(application.getInterviewDatetime()); + + rowIndex++; + } + + // 파일로 저장 + FileOutputStream fileOutputStream = new FileOutputStream("ApplicationList.xlsx"); + workbook.write(fileOutputStream); + fileOutputStream.close(); + + // 워크북 및 자원 해제 + workbook.close(); + } + + @Transactional(readOnly = true) + public GetCreationTime getApplicationExcelCreationTime() { + Recruitment recruitment = recruitmentRepository.findAll().get(0); + LocalDateTime applicationExcelCreatedAt = recruitment.getApplicationExcelCreatedAt(); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + return GetCreationTime.from(applicationExcelCreatedAt.format(formatter)); + } +} diff --git a/src/main/java/ceos/backend/domain/application/service/ApplicationService.java b/src/main/java/ceos/backend/domain/application/service/ApplicationService.java new file mode 100644 index 00000000..4167084b --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/service/ApplicationService.java @@ -0,0 +1,251 @@ +package ceos.backend.domain.application.service; + + +import ceos.backend.domain.application.domain.*; +import ceos.backend.domain.application.dto.request.*; +import ceos.backend.domain.application.dto.response.*; +import ceos.backend.domain.application.enums.SortPartType; +import ceos.backend.domain.application.enums.SortPassType; +import ceos.backend.domain.application.helper.ApplicationHelper; +import ceos.backend.domain.application.mapper.ApplicationMapper; +import ceos.backend.domain.application.repository.*; +import ceos.backend.domain.application.validator.ApplicationValidator; +import ceos.backend.domain.application.vo.QuestionListVo; +import ceos.backend.domain.recruitment.domain.Recruitment; +import ceos.backend.domain.recruitment.helper.RecruitmentHelper; +import ceos.backend.domain.recruitment.validator.RecruitmentValidator; +import ceos.backend.global.common.dto.PageInfo; +import ceos.backend.global.util.InterviewDateTimeConvertor; +import ceos.backend.global.util.ParsedDurationConvertor; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ApplicationService { + private final ApplicationRepository applicationRepository; + private final ApplicationAnswerRepository applicationAnswerRepository; + private final ApplicationQuestionRepository applicationQuestionRepository; + private final InterviewRepository interviewRepository; + private final ApplicationInterviewRepository applicationInterviewRepository; + private final ApplicationQuestionDetailRepository applicationQuestionDetailRepository; + + private final ApplicationMapper applicationMapper; + private final ApplicationHelper applicationHelper; + private final ApplicationValidator applicationValidator; + + private final RecruitmentHelper recruitmentHelper; + private final RecruitmentValidator recruitmentValidator; + + @Transactional(readOnly = true) + public GetApplications getApplications( + int pageNum, + int limit, + SortPartType sortType, + SortPassType docPass, + SortPassType finalPass) { + PageRequest pageRequest = PageRequest.of(pageNum, limit); + Page pageManagements = + applicationHelper.getApplications(docPass, finalPass, sortType, pageRequest); + PageInfo pageInfo = + PageInfo.of( + pageNum, + limit, + pageManagements.getTotalPages(), + pageManagements.getTotalElements()); + return applicationMapper.toGetApplications(pageManagements, pageInfo); + } + + @Transactional + public void createApplication(CreateApplicationRequest createApplicationRequest) { + recruitmentValidator.validateBetweenStartDateDocAndEndDateDoc(); // 제출 기간 + applicationValidator.validateFirstApplication( + createApplicationRequest.getApplicantInfoVo()); // 중복 검사 + applicationValidator.validateQAMatching(createApplicationRequest); // 질문 다 채웠나 검사 + + final String UUID = applicationHelper.generateUUID(); + final int generation = recruitmentHelper.takeRecruitment().getGeneration(); + final Application application = + applicationMapper.toEntity(createApplicationRequest, generation, UUID); + applicationRepository.save(application); + + final List applicationQuestions = + applicationQuestionRepository.findAll(); + final List applicationAnswers = + applicationMapper.toAnswerList( + createApplicationRequest, application, applicationQuestions); + applicationAnswerRepository.saveAll(applicationAnswers); + + final List unableTimes = + InterviewDateTimeConvertor.toStringDuration( + createApplicationRequest.getUnableTimes()); + final List interviews = interviewRepository.findAll(); + final List applicationInterviews = + applicationMapper.toApplicationInterviewList(unableTimes, application, interviews); + applicationInterviewRepository.saveAll(applicationInterviews); + + application.addApplicationAnswerList(applicationAnswers); + application.addApplicationInterviewList(applicationInterviews); + + // 이메일 전송 + applicationHelper.sendEmail(createApplicationRequest, generation, UUID); + } + + @Transactional(readOnly = true) + public GetApplicationQuestion getApplicationQuestion() { + final List applicationQuestions = + applicationQuestionRepository.findAll(); + final List applicationQuestionDetails = + applicationQuestionDetailRepository.findAll(); + final List interviews = interviewRepository.findAll(); + return applicationMapper.toGetApplicationQuestion( + applicationQuestions, applicationQuestionDetails, interviews); + } + + @Transactional + public void updateApplicationQuestion(UpdateApplicationQuestion updateApplicationQuestion) { + recruitmentValidator.validateBeforeStartDateDoc(); // 기간 확인 + applicationValidator.validateRemainApplications(); // 남은 응답 확인 + + applicationQuestionRepository.deleteAll(); + applicationQuestionDetailRepository.deleteAll(); + interviewRepository.deleteAll(); + + final QuestionListVo questionListVo = + applicationMapper.toQuestionList(updateApplicationQuestion); + applicationQuestionRepository.saveAll(questionListVo.getApplicationQuestions()); + applicationQuestionDetailRepository.saveAll(questionListVo.getApplicationQuestionDetails()); + + List times = + InterviewDateTimeConvertor.toStringDuration(updateApplicationQuestion.getTimes()); + final List interviews = applicationMapper.toInterviewList(times); + interviewRepository.saveAll(interviews); + } + + @Transactional(readOnly = true) + public GetResultResponse getDocumentResult(String uuid, String email) { + recruitmentValidator.validateBetweenResultDateDocAndResultDateFinal(); // 서류 합격 기간 검증 + applicationValidator.validateApplicantAccessible(uuid, email); // 유저 검증 + applicationValidator.validateInterviewTimeExist(uuid, email); // 유저 검증 + + final Application application = applicationHelper.getApplicationByUuidAndEmail(uuid, email); + final Recruitment recruitment = recruitmentHelper.takeRecruitment(); + return applicationMapper.toGetResultResponse(application, recruitment, true); + } + + @Transactional + public void updateInterviewAttendance( + String uuid, String email, UpdateAttendanceRequest request) { + recruitmentValidator.validateBetweenResultDateDocAndResultDateFinal(); // 서류 합격 기간 검증 + applicationValidator.validateApplicantAccessible(uuid, email); // 유저 검증 + final Application application = applicationHelper.getApplicationByUuidAndEmail(uuid, email); + applicationValidator.validateApplicantInterviewCheckStatus(application); // 서류합격, 인터뷰 체크 검증 + + if (request.isAvailable()) { + application.updateInterviewCheck(true); + applicationRepository.save(application); + } else { + applicationHelper.sendSlackUnableReasonMessage(application, request, false); + } + } + + @Transactional(readOnly = true) + public GetResultResponse getFinalResult(String uuid, String email) { + recruitmentValidator.validateFinalResultAbleDuration(); // 최종 합격 기간 검증 + applicationValidator.validateApplicantAccessible(uuid, email); // 유저 검증 + applicationValidator.validateInterviewTimeExist(uuid, email); // 유저 검증 + final Application application = applicationHelper.getApplicationByUuidAndEmail(uuid, email); + applicationValidator.validateApplicantDocumentPass(application); // 유저 서류 합격 여부 검증 + + final Recruitment recruitment = recruitmentHelper.takeRecruitment(); + return applicationMapper.toGetResultResponse(application, recruitment, false); + } + + @Transactional + public void updateParticipationAvailability( + String uuid, String email, UpdateAttendanceRequest request) { + recruitmentValidator.validateFinalResultAbleDuration(); // 최종 합격 기간 검증 + applicationValidator.validateApplicantAccessible(uuid, email); // 유저 검증 + final Application application = applicationHelper.getApplicationByUuidAndEmail(uuid, email); + applicationValidator.validateApplicantActivityCheckStatus(application); // 유저 확인 여부 검증 + + if (request.isAvailable()) { + application.updateFinalCheck(true); + applicationRepository.save(application); + } else { + applicationHelper.sendSlackUnableReasonMessage(application, request, true); + } + } + + @Transactional(readOnly = true) + public GetApplication getApplication(Long applicationId) { + applicationValidator.validateExistingApplicant(applicationId); // 유저 검증 + + final Application application = applicationHelper.getApplicationById(applicationId); + final List interviews = interviewRepository.findAll(); + final List applicationInterviews = + applicationInterviewRepository.findAllByApplication(application); + final List applicationQuestions = + applicationQuestionRepository.findAll(); + final List applicationQuestionDetails = + applicationQuestionDetailRepository.findAll(); + final List applicationAnswers = + applicationAnswerRepository.findAllByApplication(application); + return applicationMapper.toGetApplication( + application, + interviews, + applicationInterviews, + applicationQuestions, + applicationQuestionDetails, + applicationAnswers); + } + + @Transactional(readOnly = true) + public GetInterviewTime getInterviewTime(Long applicationId) { + applicationValidator.validateExistingApplicant(applicationId); // 유저 검증 + final Application application = applicationHelper.getApplicationById(applicationId); + applicationValidator.validateDocumentPassStatus(application); // 서류 통과 검증 + + final List interviews = interviewRepository.findAll(); + final List applicationInterviews = + applicationInterviewRepository.findAllByApplication(application); + return applicationMapper.toGetInterviewTime(interviews, applicationInterviews); + } + + @Transactional + public void updateInterviewTime(Long applicationId, UpdateInterviewTime updateInterviewTime) { + recruitmentValidator.validateBetweenStartDateDocAndResultDateDoc(); // 기간 검증 + applicationValidator.validateExistingApplicant(applicationId); // 유저 검증 + final Application application = applicationHelper.getApplicationById(applicationId); + applicationValidator.validateDocumentPassStatus(application); // 서류 통과 검증 + final List interviews = interviewRepository.findAll(); + final String duration = + ParsedDurationConvertor.toStringDuration(updateInterviewTime.getParsedDuration()); + applicationValidator.validateInterviewTime(interviews, duration); // 인터뷰 시간 검증 + + application.updateInterviewTime(duration); + } + + @Transactional + public void updateDocumentPassStatus(Long applicationId, UpdatePassStatus updatePassStatus) { + recruitmentValidator.validateBetweenStartDateDocAndResultDateDoc(); // 기간 검증 + applicationValidator.validateExistingApplicant(applicationId); // 유저 검증 + + final Application application = applicationHelper.getApplicationById(applicationId); + application.updateDocumentPass(updatePassStatus.getPass()); + } + + @Transactional + public void updateFinalPassStatus(Long applicationId, UpdatePassStatus updatePassStatus) { + recruitmentValidator.validateBetweenResultDateDocAndResultDateFinal(); // 기간 검증 + applicationValidator.validateExistingApplicant(applicationId); // 유저 검증 + final Application application = applicationHelper.getApplicationById(applicationId); + applicationValidator.validateDocumentPassStatus(application); // 서류 통과 검증 + + application.updateFinalPass(updatePassStatus.getPass()); + } +} diff --git a/src/main/java/ceos/backend/domain/application/validator/ApplicationValidator.java b/src/main/java/ceos/backend/domain/application/validator/ApplicationValidator.java new file mode 100644 index 00000000..f1e879b2 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/validator/ApplicationValidator.java @@ -0,0 +1,126 @@ +package ceos.backend.domain.application.validator; + + +import ceos.backend.domain.application.domain.Application; +import ceos.backend.domain.application.domain.ApplicationQuestion; +import ceos.backend.domain.application.domain.Interview; +import ceos.backend.domain.application.domain.QuestionCategory; +import ceos.backend.domain.application.dto.request.CreateApplicationRequest; +import ceos.backend.domain.application.exception.exceptions.*; +import ceos.backend.domain.application.repository.ApplicationAnswerRepository; +import ceos.backend.domain.application.repository.ApplicationInterviewRepository; +import ceos.backend.domain.application.repository.ApplicationQuestionRepository; +import ceos.backend.domain.application.repository.ApplicationRepository; +import ceos.backend.domain.application.vo.ApplicantInfoVo; +import ceos.backend.global.common.entity.Part; +import ceos.backend.global.util.InterviewConvertor; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ApplicationValidator { + private final ApplicationRepository applicationRepository; + private final ApplicationQuestionRepository applicationQuestionRepository; + private final ApplicationInterviewRepository applicationInterviewRepository; + private final ApplicationAnswerRepository applicationAnswerRepository; + + public void validateFirstApplication(ApplicantInfoVo applicantInfoVo) { + if (applicationRepository.existsByEmail(applicantInfoVo.getEmail())) { + throw DuplicateApplicant.EXCEPTION; + } + } + + public void validateApplicantAccessible(String uuid, String email) { + applicationRepository + .findByUuidAndEmail(uuid, email) + .orElseThrow( + () -> { + throw ApplicantNotFound.EXCEPTION; + }); + } + + public void validateApplicantDocumentPass(Application application) { + application.validateDocumentPass(); + } + + public void validateApplicantInterviewCheckStatus(Application application) { + application.validateDocumentPass(); + application.validateNotInterviewCheck(); + } + + public void validateApplicantActivityCheckStatus(Application application) { + application.validateDocumentPass(); + application.validateFinalPass(); + application.validateNotFinalCheck(); + } + + public void validateExistingApplicant(Long applicationId) { + applicationRepository + .findById(applicationId) + .orElseThrow( + () -> { + throw ApplicantNotFound.EXCEPTION; + }); + } + + public void validateDocumentPassStatus(Application application) { + application.validateDocumentPass(); + } + + public void validateInterviewTime(List interviews, String interviewTime) { + if (interviews.stream() + .noneMatch( + interview -> + interviewTime.equals( + InterviewConvertor.interviewDateFormatter(interview)))) { + throw InterviewNotFound.EXCEPTION; + } + } + + public void validateRemainApplications() { + if (applicationRepository.count() != 0) { + throw ApplicationStillExist.EXCEPTION; + } + if (applicationAnswerRepository.count() != 0) { + throw AnswerStillExist.EXCEPTION; + } + if (applicationInterviewRepository.count() != 0) { + throw ApplicationInterviewStillExist.EXCEPTION; + } + } + + public void validateQAMatching(CreateApplicationRequest createApplicationRequest) { + final List applicationQuestions = + applicationQuestionRepository.findAll(); + if (applicationQuestions.stream() + .filter(question -> question.getCategory() == QuestionCategory.COMMON) + .count() + != createApplicationRequest.getCommonAnswers().size()) { + throw NotMatchingQnA.EXCEPTION; + } + final Part part = createApplicationRequest.getPart(); + if (applicationQuestions.stream() + .filter( + question -> + question.getCategory().toString().equals(part.toString())) + .count() + != createApplicationRequest.getPartAnswers().size()) { + throw NotMatchingQnA.EXCEPTION; + } + } + + public void validateInterviewTimeExist(String uuid, String email) { + Application application = + applicationRepository + .findByUuidAndEmail(uuid, email) + .orElseThrow( + () -> { + throw ApplicantNotFound.EXCEPTION; + }); + if (application.getInterviewDatetime() == null) { + throw NotSetInterviewTime.EXCEPTION; + } + } +} diff --git a/src/main/java/ceos/backend/domain/application/vo/AnswerVo.java b/src/main/java/ceos/backend/domain/application/vo/AnswerVo.java new file mode 100644 index 00000000..1c9cc347 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/vo/AnswerVo.java @@ -0,0 +1,33 @@ +package ceos.backend.domain.application.vo; + + +import ceos.backend.domain.application.domain.ApplicationAnswer; +import ceos.backend.domain.application.domain.ApplicationQuestion; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class AnswerVo { + @Schema(defaultValue = "1", description = "질문 고유번호") + @NotNull(message = "질문의 고유번호를 입력해주세요") + private Long questionId; + + @Schema(defaultValue = "대답", description = "질문 답변") + @NotEmpty(message = "답변을 입력해주세요") + private String answer; + + @Builder + private AnswerVo(Long questionId, String answer) { + this.questionId = questionId; + this.answer = answer; + } + + public static AnswerVo of(ApplicationQuestion question, ApplicationAnswer answer) { + return AnswerVo.builder().questionId(question.getId()).answer(answer.getAnswer()).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/vo/ApplicantInfoVo.java b/src/main/java/ceos/backend/domain/application/vo/ApplicantInfoVo.java new file mode 100644 index 00000000..d8c50888 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/vo/ApplicantInfoVo.java @@ -0,0 +1,107 @@ +package ceos.backend.domain.application.vo; + + +import ceos.backend.domain.application.domain.ApplicantInfo; +import ceos.backend.domain.application.domain.Application; +import ceos.backend.domain.application.domain.Gender; +import ceos.backend.global.common.annotation.DateFormat; +import ceos.backend.global.common.annotation.ValidEmail; +import ceos.backend.global.common.annotation.ValidEnum; +import ceos.backend.global.common.annotation.ValidPhone; +import ceos.backend.global.common.entity.University; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.time.LocalDate; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ApplicantInfoVo { + @Schema(defaultValue = "김영한", description = "지원자 이름") + @NotEmpty(message = "지원자 이름을 입력해주세요") + private String name; + + @Schema(defaultValue = "남성", description = "지원자 이름") + @ValidEnum(target = Gender.class) + private Gender gender; + + @Schema( + type = "string", + pattern = "yyyy.MM.dd", + defaultValue = "2023.03.20", + description = "지원자 생년월일") + @NotNull(message = "지원자 생년월일을 입력해주세요") + @DateFormat + private LocalDate birth; + + @Schema(defaultValue = "ceos@ceos-sinchon.com", description = "지원자 이메일") + @ValidEmail + private String email; + + @Schema(defaultValue = "010-1234-5678", description = "지원자 전화번호") + @ValidPhone + private String phoneNumber; + + @Schema(defaultValue = "홍익대학교", description = "지원자 대학교") + @ValidEnum(target = University.class) + private University university; + + @Schema(defaultValue = "컴퓨터 공학과", description = "지원자 전공") + @NotEmpty(message = "지원자 전공을 입력해주세요") + private String major; + + @Schema(defaultValue = "99999999", description = "지원자 남은 학기 수") + @NotNull(message = "지원자 남은 학기 수를 입력해주세요") + @Positive + private int semestersLeftNumber; + + @Builder + private ApplicantInfoVo( + String name, + Gender gender, + LocalDate birth, + String email, + String phoneNumber, + University university, + String major, + int semestersLeftNumber) { + this.name = name; + this.gender = gender; + this.birth = birth; + this.email = email; + this.phoneNumber = phoneNumber; + this.university = university; + this.major = major; + this.semestersLeftNumber = semestersLeftNumber; + } + + public static ApplicantInfoVo from(ApplicantInfo applicantInfo) { + return ApplicantInfoVo.builder() + .name(applicantInfo.getName()) + .gender(applicantInfo.getGender()) + .birth(applicantInfo.getBirth()) + .email(applicantInfo.getEmail()) + .phoneNumber(applicantInfo.getPhoneNumber()) + .university(applicantInfo.getUniversity()) + .major(applicantInfo.getMajor()) + .semestersLeftNumber(applicantInfo.getSemestersLeftNumber()) + .build(); + } + + public static ApplicantInfoVo from(Application application) { + return ApplicantInfoVo.builder() + .name(application.getApplicantInfo().getName()) + .gender(application.getApplicantInfo().getGender()) + .birth(application.getApplicantInfo().getBirth()) + .email(application.getApplicantInfo().getEmail()) + .phoneNumber(application.getApplicantInfo().getPhoneNumber()) + .university(application.getApplicantInfo().getUniversity()) + .major(application.getApplicantInfo().getMajor()) + .semestersLeftNumber(application.getApplicantInfo().getSemestersLeftNumber()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/vo/ApplicationBriefInfoVo.java b/src/main/java/ceos/backend/domain/application/vo/ApplicationBriefInfoVo.java new file mode 100644 index 00000000..bc7d6279 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/vo/ApplicationBriefInfoVo.java @@ -0,0 +1,50 @@ +package ceos.backend.domain.application.vo; + + +import ceos.backend.domain.application.domain.ApplicantInfo; +import ceos.backend.domain.application.domain.Application; +import ceos.backend.domain.application.domain.Pass; +import ceos.backend.global.common.dto.ParsedDuration; +import ceos.backend.global.common.entity.Part; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ApplicationBriefInfoVo { + @JsonUnwrapped private ApplicantInfo applicantInfo; + + private Part part; + private Long id; + private Pass documentPass; + private Pass finalPass; + + @JsonUnwrapped private ParsedDuration interviewTime; + + @Builder + private ApplicationBriefInfoVo( + ApplicantInfo applicantInfo, + Long id, + Part part, + Pass documentPass, + Pass finalPass, + ParsedDuration interviewTime) { + this.applicantInfo = applicantInfo; + this.id = id; + this.part = part; + this.documentPass = documentPass; + this.finalPass = finalPass; + this.interviewTime = interviewTime; + } + + public static ApplicationBriefInfoVo of(Application application, ParsedDuration interviewTime) { + return ApplicationBriefInfoVo.builder() + .applicantInfo(application.getApplicantInfo()) + .id(application.getId()) + .part(application.getApplicationDetail().getPart()) + .documentPass(application.getDocumentPass()) + .finalPass(application.getFinalPass()) + .interviewTime(interviewTime) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/vo/ApplicationDetailVo.java b/src/main/java/ceos/backend/domain/application/vo/ApplicationDetailVo.java new file mode 100644 index 00000000..b1f6317e --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/vo/ApplicationDetailVo.java @@ -0,0 +1,86 @@ +package ceos.backend.domain.application.vo; + + +import ceos.backend.domain.application.domain.Application; +import ceos.backend.domain.application.domain.ApplicationDetail; +import ceos.backend.global.common.annotation.DateFormat; +import ceos.backend.global.common.annotation.ValidEnum; +import ceos.backend.global.common.entity.Part; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ApplicationDetailVo { + @Schema(defaultValue = "99999999", description = "지원 기수") + @NotNull(message = "지원 기수를 입력해주세요") + @Positive + private int generation; + + @Schema( + type = "string", + pattern = "yyyy.MM.dd", + defaultValue = "2023.03.20", + description = "ot 날짜") + @NotNull(message = "ot 날짜를 입력해주세요") + @DateFormat + private LocalDate otDate; + + @Schema( + type = "string", + pattern = "yyyy.MM.dd", + defaultValue = "2023.03.20", + description = "데모데이 날짜") + @NotNull(message = "데모데이 날짜를 입력해주세요") + @DateFormat + private LocalDate demodayDate; + + @Schema(defaultValue = "구르기", description = "지원자 다른 활동") + @NotEmpty(message = "지원자 다른 활동을 입력해주세요") + private String otherActivities; + + @Schema(defaultValue = "백엔드", description = "지원자 파트") + @ValidEnum(target = Part.class) + private Part part; + + @Builder + private ApplicationDetailVo( + int generation, + LocalDate otDate, + LocalDate demodayDate, + String otherActivities, + Part part) { + this.generation = generation; + this.otDate = otDate; + this.demodayDate = demodayDate; + this.otherActivities = otherActivities; + this.part = part; + } + + public static ApplicationDetailVo from(ApplicationDetail applicationDetail) { + return ApplicationDetailVo.builder() + .generation(applicationDetail.getGeneration()) + .otDate(applicationDetail.getOtDate()) + .demodayDate(applicationDetail.getDemodayDate()) + .otherActivities(applicationDetail.getOtherActivities()) + .part(applicationDetail.getPart()) + .build(); + } + + public static ApplicationDetailVo from(Application application) { + return ApplicationDetailVo.builder() + .generation(application.getApplicationDetail().getGeneration()) + .otDate(application.getApplicationDetail().getOtDate()) + .demodayDate(application.getApplicationDetail().getDemodayDate()) + .otherActivities(application.getApplicationDetail().getOtherActivities()) + .part(application.getApplicationDetail().getPart()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/vo/InterviewDateTimesVo.java b/src/main/java/ceos/backend/domain/application/vo/InterviewDateTimesVo.java new file mode 100644 index 00000000..145dd64d --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/vo/InterviewDateTimesVo.java @@ -0,0 +1,35 @@ +package ceos.backend.domain.application.vo; + + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class InterviewDateTimesVo { + @Schema(defaultValue = "2023/07/07", description = "날짜") + private String date; + + @ArraySchema( + schema = + @Schema( + description = "불가능 시간 선택 ", + type = "00:00-00:30", + defaultValue = "00:00-00:00")) + private List durations; + + @Builder + private InterviewDateTimesVo(String date, List durations) { + this.date = date; + this.durations = durations; + } + + public static InterviewDateTimesVo of(String date, List durations) { + return InterviewDateTimesVo.builder().date(date).durations(durations).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/vo/InterviewTimeVo.java b/src/main/java/ceos/backend/domain/application/vo/InterviewTimeVo.java new file mode 100644 index 00000000..9399f2ff --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/vo/InterviewTimeVo.java @@ -0,0 +1,27 @@ +package ceos.backend.domain.application.vo; + + +import ceos.backend.global.common.dto.ParsedDuration; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class InterviewTimeVo { + private boolean isUnavailable; + + @JsonUnwrapped private ParsedDuration parsedDuration; + + @Builder + private InterviewTimeVo(boolean isUnavailable, ParsedDuration parsedDuration) { + this.isUnavailable = isUnavailable; + this.parsedDuration = parsedDuration; + } + + public static InterviewTimeVo of(boolean isUnavailable, ParsedDuration parsedDuration) { + return InterviewTimeVo.builder() + .isUnavailable(isUnavailable) + .parsedDuration(parsedDuration) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/vo/QnAVo.java b/src/main/java/ceos/backend/domain/application/vo/QnAVo.java new file mode 100644 index 00000000..79d818c2 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/vo/QnAVo.java @@ -0,0 +1,32 @@ +package ceos.backend.domain.application.vo; + + +import ceos.backend.domain.application.domain.ApplicationAnswer; +import ceos.backend.domain.application.domain.ApplicationQuestion; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class QnAVo { + @JsonUnwrapped private QuestionVo questionVo; + + @JsonUnwrapped private AnswerVo answerVo; + + @Builder + private QnAVo(QuestionVo questionVo, AnswerVo answerVo) { + this.questionVo = questionVo; + this.answerVo = answerVo; + } + + public static QnAVo of( + ApplicationQuestion question, + List questionDetailVos, + ApplicationAnswer answer) { + return QnAVo.builder() + .questionVo(QuestionVo.of(question, questionDetailVos)) + .answerVo(AnswerVo.of(question, answer)) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/vo/QuestionDetailVo.java b/src/main/java/ceos/backend/domain/application/vo/QuestionDetailVo.java new file mode 100644 index 00000000..6435a47f --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/vo/QuestionDetailVo.java @@ -0,0 +1,37 @@ +package ceos.backend.domain.application.vo; + + +import ceos.backend.domain.application.domain.ApplicationQuestionDetail; +import ceos.backend.domain.application.domain.ExplainationColor; +import ceos.backend.global.common.annotation.ValidEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class QuestionDetailVo { + + @Schema(defaultValue = "설명", description = "질문 설명") + @NotEmpty(message = "질문 설명을 입력해주세요") + private String explaination; + + @Schema(defaultValue = "gray", description = "글자 색상") + @ValidEnum(target = ExplainationColor.class) + private ExplainationColor color; + + @Builder + private QuestionDetailVo(String explaination, ExplainationColor color) { + this.explaination = explaination; + this.color = color; + } + + public static QuestionDetailVo from(ApplicationQuestionDetail detail) { + return QuestionDetailVo.builder() + .explaination(detail.getExplaination()) + .color(detail.getColor()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/vo/QuestionListVo.java b/src/main/java/ceos/backend/domain/application/vo/QuestionListVo.java new file mode 100644 index 00000000..b97a7e21 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/vo/QuestionListVo.java @@ -0,0 +1,33 @@ +package ceos.backend.domain.application.vo; + + +import ceos.backend.domain.application.domain.ApplicationQuestion; +import ceos.backend.domain.application.domain.ApplicationQuestionDetail; +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class QuestionListVo { + List applicationQuestions; + List applicationQuestionDetails; + + @Builder + private QuestionListVo( + List applicationQuestions, + List applicationQuestionDetails) { + this.applicationQuestions = applicationQuestions; + this.applicationQuestionDetails = applicationQuestionDetails; + } + + public static QuestionListVo of( + List applicationQuestions, + List applicationQuestionDetails) { + return QuestionListVo.builder() + .applicationQuestions(applicationQuestions) + .applicationQuestionDetails(applicationQuestionDetails) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/vo/QuestionVo.java b/src/main/java/ceos/backend/domain/application/vo/QuestionVo.java new file mode 100644 index 00000000..5e144360 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/vo/QuestionVo.java @@ -0,0 +1,52 @@ +package ceos.backend.domain.application.vo; + + +import ceos.backend.domain.application.domain.ApplicationQuestion; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class QuestionVo { + @Schema(defaultValue = "1", description = "질문 번호 (순서)") + @NotNull(message = "질문 번호를 입력해주세요.") + private int questionIndex; + + @Schema(defaultValue = "질문", description = "질문") + @NotEmpty(message = "질문을 입력해주세요") + private String question; + + @Schema(defaultValue = "false", description = "입력창 크기") + @NotNull(message = "입력창 크기를 입력해주세요") + private boolean multiline; + + @Valid private List questionDetail; + + @Builder + private QuestionVo( + int questionIndex, + String question, + boolean multiline, + List questionDetail) { + this.questionIndex = questionIndex; + this.question = question; + this.multiline = multiline; + this.questionDetail = questionDetail; + } + + public static QuestionVo of( + ApplicationQuestion applicationQuestion, List questionDetailVos) { + return QuestionVo.builder() + .question(applicationQuestion.getQuestion()) + .questionIndex(applicationQuestion.getNumber()) + .multiline(applicationQuestion.isMultiline()) + .questionDetail(questionDetailVos) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/application/vo/QuestionWithIdVo.java b/src/main/java/ceos/backend/domain/application/vo/QuestionWithIdVo.java new file mode 100644 index 00000000..818104b6 --- /dev/null +++ b/src/main/java/ceos/backend/domain/application/vo/QuestionWithIdVo.java @@ -0,0 +1,33 @@ +package ceos.backend.domain.application.vo; + + +import ceos.backend.domain.application.domain.ApplicationQuestion; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class QuestionWithIdVo { + @JsonUnwrapped private QuestionVo questionVo; + + @Schema(defaultValue = "1", description = "질문 고유번호") + private Long questionId; + + @Builder + private QuestionWithIdVo(QuestionVo questionVo, Long questionId) { + this.questionVo = questionVo; + this.questionId = questionId; + } + + public static QuestionWithIdVo of( + ApplicationQuestion applicationQuestion, List questionDetailVos) { + return QuestionWithIdVo.builder() + .questionVo(QuestionVo.of(applicationQuestion, questionDetailVos)) + .questionId(applicationQuestion.getId()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/awards/AwardsController.java b/src/main/java/ceos/backend/domain/awards/AwardsController.java new file mode 100644 index 00000000..f10186fc --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/AwardsController.java @@ -0,0 +1,62 @@ +package ceos.backend.domain.awards; + + +import ceos.backend.domain.awards.dto.request.AwardsRequest; +import ceos.backend.domain.awards.dto.response.AllAwardsResponse; +import ceos.backend.domain.awards.dto.response.GenerationAwardsResponse; +import ceos.backend.domain.awards.service.AwardsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/awards") +@Tag(name = "Awards") +public class AwardsController { + + private final AwardsService awardsService; + + @Operation(summary = "수상이력 추가하기") + @PostMapping + public void createAwards(@RequestBody @Valid AwardsRequest awardsRequest) { + log.info("수상이력 추가하기"); + awardsService.createAwards(awardsRequest); + } + + @Operation(summary = "수상이력 전체보기") + @GetMapping + public AllAwardsResponse getAllAwards( + @RequestParam("pageNum") int pageNum, @RequestParam("limit") int limit) { + log.info("수상이력 전체보기"); + return awardsService.getAllAwards(pageNum, limit); + } + + @Operation(summary = "기수별 수상이력 보기") + @GetMapping("/{generation}") + public GenerationAwardsResponse getGenerationAwards( + @PathVariable(name = "generation") int generation) { + log.info("기수별 수상이력 보기"); + return awardsService.getGenerationAwards(generation); + } + + @Operation(summary = "수상이력 수정하기") + @PutMapping("/{generation}") + public void updateAwards( + @PathVariable(name = "generation") int generation, + @RequestBody AwardsRequest awardsRequest) { + log.info("수상이력 수정하기"); + awardsService.updateAwards(generation, awardsRequest); + } + + @Operation(summary = "수상이력 삭제하기") + @DeleteMapping("/{generation}") + public void deleteAwards(@PathVariable(name = "generation") int generation) { + log.info("수상이력 삭제하기"); + awardsService.deleteAwards(generation); + } +} diff --git a/src/main/java/ceos/backend/domain/awards/domain/Awards.java b/src/main/java/ceos/backend/domain/awards/domain/Awards.java new file mode 100644 index 00000000..8c029566 --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/domain/Awards.java @@ -0,0 +1,37 @@ +package ceos.backend.domain.awards.domain; + + +import ceos.backend.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Awards extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "awards_id") + private Long id; + + @NotNull private int generation; + + @NotNull + @Size(max = 100) + private String content; + + // 생성자 + @Builder + private Awards(int generation, String content) { + this.generation = generation; + this.content = content; + } + + // 정적 팩토리 메서드 + public static Awards of(int generation, String content) { + return Awards.builder().generation(generation).content(content).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/awards/domain/StartDate.java b/src/main/java/ceos/backend/domain/awards/domain/StartDate.java new file mode 100644 index 00000000..1ea6cf56 --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/domain/StartDate.java @@ -0,0 +1,39 @@ +package ceos.backend.domain.awards.domain; + + +import ceos.backend.domain.awards.dto.request.AwardsRequest; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class StartDate { + + @Id private int generation; + + @NotNull private String startDate; + + // 생성자 + @Builder + public StartDate(int generation, String startDate) { + this.generation = generation; + this.startDate = startDate; + } + + // 정적 팩토리 메서드 + public static StartDate from(AwardsRequest awardsRequest) { + return StartDate.builder() + .generation(awardsRequest.getGeneration()) + .startDate(awardsRequest.getStartDate()) + .build(); + } + + public void updateStartDate(String startDate) { + this.startDate = startDate; + } +} diff --git a/src/main/java/ceos/backend/domain/awards/dto/request/AwardsRequest.java b/src/main/java/ceos/backend/domain/awards/dto/request/AwardsRequest.java new file mode 100644 index 00000000..0f68230a --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/dto/request/AwardsRequest.java @@ -0,0 +1,43 @@ +package ceos.backend.domain.awards.dto.request; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class AwardsRequest { + @Schema(defaultValue = "17", description = "수상 팀 활동 기수") + @NotNull(message = "수상 팀의 활동 기수를 입력해주세요") + @Valid + private int generation; + + @Schema(description = "활동 시작 시기") + @NotNull(message = "활동 시작 시기를 입력해주세요") + @Valid + private String startDate; + + @Schema(description = "수상 기록 리스트") + @NotEmpty(message = "수상 기록을 입력해주세요") + @Valid + private List content; + + @Builder + public AwardsRequest(int generation, String startDate, List content) { + this.generation = generation; + this.startDate = startDate; + this.content = content; + } + + public static AwardsRequest of(int generation, String startDate, List content) { + return AwardsRequest.builder() + .generation(generation) + .startDate(startDate) + .content(content) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/awards/dto/response/AllAwardsResponse.java b/src/main/java/ceos/backend/domain/awards/dto/response/AllAwardsResponse.java new file mode 100644 index 00000000..ae6a2e12 --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/dto/response/AllAwardsResponse.java @@ -0,0 +1,27 @@ +package ceos.backend.domain.awards.dto.response; + + +import ceos.backend.global.common.dto.PageInfo; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class AllAwardsResponse { + private List content; + PageInfo pageInfo; + + @Builder + private AllAwardsResponse(List generationAwards, PageInfo pageInfo) { + this.content = generationAwards; + this.pageInfo = pageInfo; + } + + public static AllAwardsResponse of( + List generationAwards, PageInfo pageInfo) { + return AllAwardsResponse.builder() + .generationAwards(generationAwards) + .pageInfo(pageInfo) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/awards/dto/response/AwardsResponse.java b/src/main/java/ceos/backend/domain/awards/dto/response/AwardsResponse.java new file mode 100644 index 00000000..aaa2ba9a --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/dto/response/AwardsResponse.java @@ -0,0 +1,23 @@ +package ceos.backend.domain.awards.dto.response; + + +import ceos.backend.domain.awards.domain.Awards; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class AwardsResponse { + + private Long id; + private String content; + + @Builder + private AwardsResponse(Long id, String content) { + this.id = id; + this.content = content; + } + + public static AwardsResponse to(Awards awards) { + return AwardsResponse.builder().id(awards.getId()).content(awards.getContent()).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/awards/dto/response/GenerationAwardsResponse.java b/src/main/java/ceos/backend/domain/awards/dto/response/GenerationAwardsResponse.java new file mode 100644 index 00000000..1c6576f6 --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/dto/response/GenerationAwardsResponse.java @@ -0,0 +1,40 @@ +package ceos.backend.domain.awards.dto.response; + + +import ceos.backend.domain.awards.vo.ProjectInfoVo; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GenerationAwardsResponse { + private int generation; + private String startDate; + private List awards; + private List projects; + + @Builder + public GenerationAwardsResponse( + int generation, + String startDate, + List awards, + List projects) { + this.generation = generation; + this.startDate = startDate; + this.awards = awards; + this.projects = projects; + } + + public static GenerationAwardsResponse of( + int generation, + String startDate, + List awards, + List projects) { + return GenerationAwardsResponse.builder() + .generation(generation) + .startDate(startDate) + .awards(awards) + .projects(projects) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/awards/exception/AwardNotFound.java b/src/main/java/ceos/backend/domain/awards/exception/AwardNotFound.java new file mode 100644 index 00000000..c3e2e3a5 --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/exception/AwardNotFound.java @@ -0,0 +1,12 @@ +package ceos.backend.domain.awards.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class AwardNotFound extends BaseErrorException { + public static final AwardNotFound EXCEPTION = new AwardNotFound(); + + private AwardNotFound() { + super(AwardsErrorCode.AWARD_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/awards/exception/AwardsErrorCode.java b/src/main/java/ceos/backend/domain/awards/exception/AwardsErrorCode.java new file mode 100644 index 00000000..f34db541 --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/exception/AwardsErrorCode.java @@ -0,0 +1,26 @@ +package ceos.backend.domain.awards.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; + +import ceos.backend.global.common.dto.ErrorReason; +import ceos.backend.global.error.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AwardsErrorCode implements BaseErrorCode { + AWARD_NOT_FOUND(BAD_REQUEST, "AWARD_404_1", "해당 수상이력이 존재하지 않습니다"), + DUPLICATE_GENERATION(CONFLICT, "AWARD_409_1", "해당 기수의 데이터가 이미 존재합니다"); + + private HttpStatus status; + private String code; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/ceos/backend/domain/awards/exception/DuplicateGeneration.java b/src/main/java/ceos/backend/domain/awards/exception/DuplicateGeneration.java new file mode 100644 index 00000000..3376c75c --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/exception/DuplicateGeneration.java @@ -0,0 +1,12 @@ +package ceos.backend.domain.awards.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class DuplicateGeneration extends BaseErrorException { + public static final DuplicateGeneration EXCEPTION = new DuplicateGeneration(); + + private DuplicateGeneration() { + super(AwardsErrorCode.DUPLICATE_GENERATION); + } +} diff --git a/src/main/java/ceos/backend/domain/awards/helper/AwardsHelper.java b/src/main/java/ceos/backend/domain/awards/helper/AwardsHelper.java new file mode 100644 index 00000000..f845f168 --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/helper/AwardsHelper.java @@ -0,0 +1,54 @@ +package ceos.backend.domain.awards.helper; + + +import ceos.backend.domain.awards.domain.Awards; +import ceos.backend.domain.awards.domain.StartDate; +import ceos.backend.domain.awards.dto.response.AwardsResponse; +import ceos.backend.domain.awards.exception.DuplicateGeneration; +import ceos.backend.domain.awards.repository.AwardsRepository; +import ceos.backend.domain.awards.repository.StartDateRepository; +import ceos.backend.domain.awards.vo.ProjectInfoVo; +import ceos.backend.domain.project.domain.Project; +import ceos.backend.domain.project.repository.ProjectRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AwardsHelper { + + private final ProjectRepository projectRepository; + private final AwardsRepository awardsRepository; + private final StartDateRepository startDateRepository; + + public List getProjectVo(int generation) { + List projectList = projectRepository.findByGeneration(generation); + List projectsVoList = new ArrayList<>(); + for (Project project : projectList) { + ProjectInfoVo projectInfoVo = + ProjectInfoVo.of(project.getName(), project.getDescription()); + projectsVoList.add(projectInfoVo); + } + return projectsVoList; + } + + public List getAwardsDto(int generation) { + List awardsList = awardsRepository.findByGeneration(generation); + List awardsResponseList = new ArrayList<>(); + for (Awards award : awardsList) { + AwardsResponse awardsResponse = AwardsResponse.to(award); + awardsResponseList.add(awardsResponse); + } + return awardsResponseList; + } + + public void validateGeneration(int generation) { + Optional startDate = startDateRepository.findById(generation); + if (startDate.isPresent()) { + throw DuplicateGeneration.EXCEPTION; + } + } +} diff --git a/src/main/java/ceos/backend/domain/awards/repository/AwardsRepository.java b/src/main/java/ceos/backend/domain/awards/repository/AwardsRepository.java new file mode 100644 index 00000000..0ac93b52 --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/repository/AwardsRepository.java @@ -0,0 +1,10 @@ +package ceos.backend.domain.awards.repository; + + +import ceos.backend.domain.awards.domain.Awards; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AwardsRepository extends JpaRepository { + List findByGeneration(int generation); +} diff --git a/src/main/java/ceos/backend/domain/awards/repository/StartDateRepository.java b/src/main/java/ceos/backend/domain/awards/repository/StartDateRepository.java new file mode 100644 index 00000000..1055ecad --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/repository/StartDateRepository.java @@ -0,0 +1,7 @@ +package ceos.backend.domain.awards.repository; + + +import ceos.backend.domain.awards.domain.StartDate; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StartDateRepository extends JpaRepository {} diff --git a/src/main/java/ceos/backend/domain/awards/service/AwardsService.java b/src/main/java/ceos/backend/domain/awards/service/AwardsService.java new file mode 100644 index 00000000..34616e90 --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/service/AwardsService.java @@ -0,0 +1,134 @@ +package ceos.backend.domain.awards.service; + + +import ceos.backend.domain.awards.domain.Awards; +import ceos.backend.domain.awards.domain.StartDate; +import ceos.backend.domain.awards.dto.request.AwardsRequest; +import ceos.backend.domain.awards.dto.response.AllAwardsResponse; +import ceos.backend.domain.awards.dto.response.AwardsResponse; +import ceos.backend.domain.awards.dto.response.GenerationAwardsResponse; +import ceos.backend.domain.awards.helper.AwardsHelper; +import ceos.backend.domain.awards.repository.AwardsRepository; +import ceos.backend.domain.awards.repository.StartDateRepository; +import ceos.backend.domain.awards.vo.ProjectInfoVo; +import ceos.backend.domain.project.repository.ProjectRepository; +import ceos.backend.global.common.dto.PageInfo; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AwardsService { + + private final AwardsRepository awardsRepository; + private final StartDateRepository startDateRepository; + private final ProjectRepository projectRepository; + private final AwardsHelper awardsHelper; + + @Transactional + public void createAwards(AwardsRequest awardsRequest) { + // 이미 기수의 수상 정보가 있다면 다시 추가할 수 없음 + awardsHelper.validateGeneration(awardsRequest.getGeneration()); + + // 활동 시작 시기 저장 + StartDate startDate = StartDate.from(awardsRequest); + startDateRepository.save(startDate); + + // 수상 내역 저장 + List contentList = awardsRequest.getContent(); + for (String content : contentList) { + Awards awards = Awards.of(awardsRequest.getGeneration(), content); + awardsRepository.save(awards); + } + } + + @Transactional(readOnly = true) + public AllAwardsResponse getAllAwards(int pageNum, int limit) { + List generationAwardsResponses = new ArrayList<>(); + + int maxGeneration = projectRepository.findMaxGeneration(); + for (int i = maxGeneration; i >= 0; i--) { + if (i <= 9 && i >= 1) continue; // 1~9기 정확한 정보 없어서 0으로 합쳐서 저장함 + Optional s = startDateRepository.findById(i); + String startDate = null; + if (s.isPresent()) { + startDate = s.get().getStartDate(); + } + List awardsList = awardsHelper.getAwardsDto(i); + List projectList = awardsHelper.getProjectVo(i); + // awards 0기인 데이터에 1~9기 프로젝트 데이터 매칭 + if (i == 0) { + for (int j = 1; j <= 9; j++) { + projectList.addAll(awardsHelper.getProjectVo(j)); + } + } + GenerationAwardsResponse generationAwardsResponse = + GenerationAwardsResponse.of(i, startDate, awardsList, projectList); + generationAwardsResponses.add(generationAwardsResponse); + } + + int startIndex = pageNum * limit; + int endIndex = Math.min(startIndex + limit, generationAwardsResponses.size()); + int totalElements = generationAwardsResponses.size(); + int totalPages = (int) Math.ceil((double) totalElements / limit); + List pageAwards = new ArrayList<>(); + + if (startIndex < endIndex) { + pageAwards = generationAwardsResponses.subList(startIndex, endIndex); + } + + // 페이징 정보 + PageInfo pageInfo = PageInfo.of(pageNum, limit, totalPages, totalElements); + + return AllAwardsResponse.of(pageAwards, pageInfo); + } + + @Transactional(readOnly = true) + public GenerationAwardsResponse getGenerationAwards(int generation) { + Optional s = startDateRepository.findById(generation); + String startDate = null; + if (s.isPresent()) { + startDate = s.get().getStartDate(); + } + return GenerationAwardsResponse.of( + generation, + startDate, + awardsHelper.getAwardsDto(generation), + awardsHelper.getProjectVo(generation)); + } + + @Transactional + public void updateAwards(int generation, AwardsRequest awardsRequest) { + // 기존 수상내역 데이터 삭제 + deleteAwards(generation); + + // 활동시작시기 업데이트 + Optional startDate = startDateRepository.findById(awardsRequest.getGeneration()); + if (startDate.isEmpty()) { + // 활동 시작 시기 저장 + StartDate s = StartDate.from(awardsRequest); + startDateRepository.save(s); + } else { // 활동 시작 시기 수정 + startDate.get().updateStartDate(awardsRequest.getStartDate()); + } + + // 수상 내역 저장 + List contentList = awardsRequest.getContent(); + for (String content : contentList) { + Awards awards = Awards.of(awardsRequest.getGeneration(), content); + awardsRepository.save(awards); + } + } + + @Transactional + public void deleteAwards(int generation) { + List awardsList = awardsRepository.findByGeneration(generation); + awardsRepository.deleteAllInBatch(awardsList); + } +} diff --git a/src/main/java/ceos/backend/domain/awards/vo/ProjectInfoVo.java b/src/main/java/ceos/backend/domain/awards/vo/ProjectInfoVo.java new file mode 100644 index 00000000..e4557ec2 --- /dev/null +++ b/src/main/java/ceos/backend/domain/awards/vo/ProjectInfoVo.java @@ -0,0 +1,22 @@ +package ceos.backend.domain.awards.vo; + + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ProjectInfoVo { + + private String name; + private String description; + + @Builder + public ProjectInfoVo(String name, String description) { + this.name = name; + this.description = description; + } + + public static ProjectInfoVo of(String name, String description) { + return ProjectInfoVo.builder().name(name).description(description).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/faq/FaqController.java b/src/main/java/ceos/backend/domain/faq/FaqController.java new file mode 100644 index 00000000..ced3b389 --- /dev/null +++ b/src/main/java/ceos/backend/domain/faq/FaqController.java @@ -0,0 +1,54 @@ +package ceos.backend.domain.faq; + + +import ceos.backend.domain.faq.dto.FaqDto; +import ceos.backend.domain.faq.dto.response.GetCategoryFaqResponse; +import ceos.backend.domain.faq.service.FaqService; +import ceos.backend.domain.faq.vo.FaqVo; +import ceos.backend.domain.faq.vo.UpdateFaqRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/faq") +@Tag(name = "Faq") +public class FaqController { + + private final FaqService faqService; + + @Operation(summary = "FAQ 추가하기") + @PostMapping + public void createFaq(@RequestBody @Valid FaqVo faqVo) { + log.info("FAQ 추가하기"); + faqService.createFaq(faqVo); + } + + @Operation(summary = "카테고리별 질문, 답변 불러오기") + @GetMapping + public GetCategoryFaqResponse getCategoryFaq( + @RequestParam(value = "category", defaultValue = "RECRUIT") String faqCategory) { + log.info("카테고리별 질문, 답변 불러오기"); + return faqService.getCategoryFaq(faqCategory); + } + + @Operation(summary = "FAQ 수정하기") + @PatchMapping("/{faqId}") + public FaqDto updateFaq( + @PathVariable("faqId") Long faqId, @RequestBody UpdateFaqRequest updateFaqRequest) { + log.info("FAQ 수정하기"); + return faqService.updateFaq(faqId, updateFaqRequest); + } + + @Operation(summary = "FAQ 삭제하기") + @DeleteMapping("/{faqId}") + public void deleteFaq(@PathVariable("faqId") Long faqId) { + log.info("FAQ 삭제하기"); + faqService.deleteFaq(faqId); + } +} diff --git a/src/main/java/ceos/backend/domain/faq/domain/Faq.java b/src/main/java/ceos/backend/domain/faq/domain/Faq.java new file mode 100644 index 00000000..60953520 --- /dev/null +++ b/src/main/java/ceos/backend/domain/faq/domain/Faq.java @@ -0,0 +1,59 @@ +package ceos.backend.domain.faq.domain; + + +import ceos.backend.domain.faq.vo.FaqVo; +import ceos.backend.domain.faq.vo.UpdateFaqRequest; +import ceos.backend.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Faq extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "faq_id") + private Long id; + + @NotNull + @Enumerated(EnumType.STRING) + private FaqCategory category; + + @NotNull + @Size(max = 255) + private String question; + + @NotNull + @Size(max = 255) + private String answer; + + // 생성자 + @Builder + private Faq(FaqCategory category, String question, String answer) { + this.category = category; + this.question = question; + this.answer = answer; + } + + // 정적 팩토리 메서드 + public static Faq from(FaqVo faqVo) { + return Faq.builder() + .category(faqVo.getCategory()) + .question(faqVo.getQuestion()) + .answer(faqVo.getAnswer()) + .build(); + } + + public void update(UpdateFaqRequest updateFaqRequest) { + if (updateFaqRequest.getQuestion() != null) { + this.question = updateFaqRequest.getQuestion(); + } + if (updateFaqRequest.getAnswer() != null) { + this.answer = updateFaqRequest.getAnswer(); + } + } +} diff --git a/src/main/java/ceos/backend/domain/faq/domain/FaqCategory.java b/src/main/java/ceos/backend/domain/faq/domain/FaqCategory.java new file mode 100644 index 00000000..4ceb6a8f --- /dev/null +++ b/src/main/java/ceos/backend/domain/faq/domain/FaqCategory.java @@ -0,0 +1,27 @@ +package ceos.backend.domain.faq.domain; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum FaqCategory { + RECRUIT("RECRUIT", "리쿠르팅"), + ACTIVITY("ACTIVITY", "활동"), + PART("PART", "파트"); + + @JsonValue private final String faqCategory; + private final String label; + + @JsonCreator + public static FaqCategory parsing(String inputValue) { + return Stream.of(FaqCategory.values()) + .filter(category -> category.getFaqCategory().equals(inputValue)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/ceos/backend/domain/faq/dto/FaqDto.java b/src/main/java/ceos/backend/domain/faq/dto/FaqDto.java new file mode 100644 index 00000000..1c9b35b4 --- /dev/null +++ b/src/main/java/ceos/backend/domain/faq/dto/FaqDto.java @@ -0,0 +1,33 @@ +package ceos.backend.domain.faq.dto; + + +import ceos.backend.domain.faq.domain.Faq; +import ceos.backend.domain.faq.domain.FaqCategory; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class FaqDto { + + private Long id; + private FaqCategory category; + private String question; + private String answer; + + @Builder + private FaqDto(Long id, FaqCategory category, String question, String answer) { + this.id = id; + this.category = category; + this.question = question; + this.answer = answer; + } + + public static FaqDto entityToDto(Faq faq) { + return FaqDto.builder() + .id(faq.getId()) + .category(faq.getCategory()) + .question(faq.getQuestion()) + .answer(faq.getAnswer()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/faq/dto/response/GetCategoryFaqResponse.java b/src/main/java/ceos/backend/domain/faq/dto/response/GetCategoryFaqResponse.java new file mode 100644 index 00000000..85fd2665 --- /dev/null +++ b/src/main/java/ceos/backend/domain/faq/dto/response/GetCategoryFaqResponse.java @@ -0,0 +1,22 @@ +package ceos.backend.domain.faq.dto.response; + + +import ceos.backend.domain.faq.dto.FaqDto; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetCategoryFaqResponse { + + private List categoryFaqList; + + @Builder + private GetCategoryFaqResponse(List categoryFaqList) { + this.categoryFaqList = categoryFaqList; + } + + public static GetCategoryFaqResponse from(List categoryFaqList) { + return GetCategoryFaqResponse.builder().categoryFaqList(categoryFaqList).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/faq/exception/FaqErrorCode.java b/src/main/java/ceos/backend/domain/faq/exception/FaqErrorCode.java new file mode 100644 index 00000000..b380d24f --- /dev/null +++ b/src/main/java/ceos/backend/domain/faq/exception/FaqErrorCode.java @@ -0,0 +1,25 @@ +package ceos.backend.domain.faq.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import ceos.backend.global.common.dto.ErrorReason; +import ceos.backend.global.error.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum FaqErrorCode implements BaseErrorCode { + /* Faq */ + FAQ_NOT_FOUND(BAD_REQUEST, "FAQ_404_1", "해당 FAQ는 존재하지 않습니다"); + + private HttpStatus status; + private String code; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/ceos/backend/domain/faq/exception/FaqNotFound.java b/src/main/java/ceos/backend/domain/faq/exception/FaqNotFound.java new file mode 100644 index 00000000..a2217ea2 --- /dev/null +++ b/src/main/java/ceos/backend/domain/faq/exception/FaqNotFound.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.faq.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class FaqNotFound extends BaseErrorException { + + public static final FaqNotFound EXCEPTION = new FaqNotFound(); + + private FaqNotFound() { + super(FaqErrorCode.FAQ_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/faq/mapper/FaqMapper.java b/src/main/java/ceos/backend/domain/faq/mapper/FaqMapper.java new file mode 100644 index 00000000..513ae066 --- /dev/null +++ b/src/main/java/ceos/backend/domain/faq/mapper/FaqMapper.java @@ -0,0 +1,22 @@ +package ceos.backend.domain.faq.mapper; + + +import ceos.backend.domain.faq.domain.Faq; +import ceos.backend.domain.faq.dto.FaqDto; +import ceos.backend.domain.faq.dto.response.GetCategoryFaqResponse; +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class FaqMapper { + + public GetCategoryFaqResponse toCategoryFaqResponse(List categoryFaqList) { + List categoryFaqDtoList = new ArrayList<>(); + for (Faq faq : categoryFaqList) { + FaqDto faqDto = FaqDto.entityToDto(faq); + categoryFaqDtoList.add(faqDto); + } + return GetCategoryFaqResponse.from(categoryFaqDtoList); + } +} diff --git a/src/main/java/ceos/backend/domain/faq/repository/FaqRepository.java b/src/main/java/ceos/backend/domain/faq/repository/FaqRepository.java new file mode 100644 index 00000000..6cae2e76 --- /dev/null +++ b/src/main/java/ceos/backend/domain/faq/repository/FaqRepository.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.faq.repository; + + +import ceos.backend.domain.faq.domain.Faq; +import ceos.backend.domain.faq.domain.FaqCategory; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface FaqRepository extends JpaRepository { + @Query("SELECT f FROM Faq f WHERE f.category = :category") + List findAllByCategory(@Param("category") FaqCategory category); +} diff --git a/src/main/java/ceos/backend/domain/faq/service/FaqService.java b/src/main/java/ceos/backend/domain/faq/service/FaqService.java new file mode 100644 index 00000000..28fce1fb --- /dev/null +++ b/src/main/java/ceos/backend/domain/faq/service/FaqService.java @@ -0,0 +1,64 @@ +package ceos.backend.domain.faq.service; + + +import ceos.backend.domain.faq.domain.Faq; +import ceos.backend.domain.faq.domain.FaqCategory; +import ceos.backend.domain.faq.dto.FaqDto; +import ceos.backend.domain.faq.dto.response.GetCategoryFaqResponse; +import ceos.backend.domain.faq.exception.FaqNotFound; +import ceos.backend.domain.faq.mapper.FaqMapper; +import ceos.backend.domain.faq.repository.FaqRepository; +import ceos.backend.domain.faq.vo.FaqVo; +import ceos.backend.domain.faq.vo.UpdateFaqRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FaqService { + + private final FaqRepository faqRepository; + private final FaqMapper faqMapper; + + @Transactional + public void createFaq(FaqVo faqVo) { + Faq newFaq = Faq.from(faqVo); + faqRepository.save(newFaq); + } + + @Transactional(readOnly = true) + public GetCategoryFaqResponse getCategoryFaq(String faqCategory) { + List findCategoryFaqList = + faqRepository.findAllByCategory(FaqCategory.parsing(faqCategory)); + return faqMapper.toCategoryFaqResponse(findCategoryFaqList); + } + + @Transactional + public FaqDto updateFaq(Long id, UpdateFaqRequest updateFaqRequest) { + Faq findFaq = + faqRepository + .findById(id) + .orElseThrow( + () -> { + throw FaqNotFound.EXCEPTION; + }); + findFaq.update(updateFaqRequest); + return FaqDto.entityToDto(findFaq); + } + + @Transactional + public void deleteFaq(Long id) { + Faq findFaq = + faqRepository + .findById(id) + .orElseThrow( + () -> { + throw FaqNotFound.EXCEPTION; + }); + faqRepository.delete(findFaq); + } +} diff --git a/src/main/java/ceos/backend/domain/faq/vo/FaqVo.java b/src/main/java/ceos/backend/domain/faq/vo/FaqVo.java new file mode 100644 index 00000000..43d13d83 --- /dev/null +++ b/src/main/java/ceos/backend/domain/faq/vo/FaqVo.java @@ -0,0 +1,26 @@ +package ceos.backend.domain.faq.vo; + + +import ceos.backend.domain.faq.domain.FaqCategory; +import ceos.backend.global.common.annotation.ValidEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; + +@Getter +public class FaqVo { + + @Schema(defaultValue = "PART", description = "질문 카테고리") + @ValidEnum(target = FaqCategory.class) + private FaqCategory category; + + @Schema(defaultValue = "지원하려면 어느 정도의 개발 관련 지식이나 경험이 필요한가요?", description = "질문 내용") + @NotEmpty(message = "질문을 입력해주세요") + private String question; + + @Schema( + defaultValue = "기초적인 프로그래밍 능력만 있다면 가능합니다. 웹/앱 개발 경험이 없어도 개발 스터디에 잘 참여해 주신다면 충분히 가능합니다.", + description = "답변 내용") + @NotEmpty(message = "답변을 입력해주세요") + private String answer; +} diff --git a/src/main/java/ceos/backend/domain/faq/vo/UpdateFaqRequest.java b/src/main/java/ceos/backend/domain/faq/vo/UpdateFaqRequest.java new file mode 100644 index 00000000..98c2788f --- /dev/null +++ b/src/main/java/ceos/backend/domain/faq/vo/UpdateFaqRequest.java @@ -0,0 +1,15 @@ +package ceos.backend.domain.faq.vo; + + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +public class UpdateFaqRequest { + + @Schema(description = "수정할 질문 내용") + private String question; + + @Schema(description = "수정할 답변 내용") + private String answer; +} diff --git a/src/main/java/ceos/backend/domain/management/ManagementController.java b/src/main/java/ceos/backend/domain/management/ManagementController.java new file mode 100644 index 00000000..1b72adcc --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/ManagementController.java @@ -0,0 +1,79 @@ +package ceos.backend.domain.management; + + +import ceos.backend.domain.management.dto.ManagementDto; +import ceos.backend.domain.management.dto.request.CreateManagementRequest; +import ceos.backend.domain.management.dto.request.UpdateManagementRequest; +import ceos.backend.domain.management.dto.response.GetAllManagementsResponse; +import ceos.backend.domain.management.dto.response.GetAllPartManagementsResponse; +import ceos.backend.domain.management.service.ManagementService; +import ceos.backend.global.common.dto.AwsS3Url; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/managements") +@Tag(name = "Management") +public class ManagementController { + + private final ManagementService managementService; + + @Operation(summary = "임원진 추가하기") + @PostMapping + public void createManagement( + @RequestBody @Valid CreateManagementRequest createManagementRequest) { + log.info("임원진 추가히기"); + managementService.createManagement(createManagementRequest); + } + + @Operation(summary = "임원진 전체 보기") + @GetMapping + public GetAllManagementsResponse getAllManagements( + @RequestParam("pageNum") int pageNum, @RequestParam("limit") int limit) { + log.info("임원진 전체 보기"); + return managementService.getAllManagements(pageNum, limit); + } + + @Operation(summary = "파트별 운영진 전체 보기") + @GetMapping("/part") + public GetAllPartManagementsResponse getAllPartManagements() { + log.info("파트별 운영진 전체 보기"); + return managementService.getAllPartManagements(); + } + + @Operation(summary = "임원진 하나 보기") + @GetMapping("/{managerId}") + public ManagementDto getManagement(@PathVariable(name = "managerId") Long managerId) { + log.info("임원진 하나 보기"); + return managementService.getManagement(managerId); + } + + @Operation(summary = "임원진 정보 수정") + @PatchMapping("/{managerId}") + public ManagementDto updateManagement( + @PathVariable(name = "managerId") Long managerId, + @RequestBody UpdateManagementRequest updateManagementRequest) { + log.info("임원진 정보 수정"); + return managementService.updateManagementInfo(managerId, updateManagementRequest); + } + + @Operation(summary = "임원진 삭제") + @DeleteMapping("/{managerId}") + public void deleteManagement(@PathVariable(name = "managerId") Long managerId) { + log.info("임원진 삭제"); + managementService.deleteManagement(managerId); + } + + @Operation(summary = "임원진 이미지 url 생성하기") + @GetMapping("/image") + public AwsS3Url getImageUrl() { + log.info("임원진 이미지 url 생성하기"); + return managementService.getImageUrl(); + } +} diff --git a/src/main/java/ceos/backend/domain/management/domain/Management.java b/src/main/java/ceos/backend/domain/management/domain/Management.java new file mode 100644 index 00000000..9c500e68 --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/domain/Management.java @@ -0,0 +1,124 @@ +package ceos.backend.domain.management.domain; + + +import ceos.backend.domain.management.dto.request.UpdateManagementRequest; +import ceos.backend.domain.management.vo.ManagementVo; +import ceos.backend.global.common.entity.BaseEntity; +import ceos.backend.global.common.entity.University; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.Optional; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; + +@DynamicInsert +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Management extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "management_id") + private Long id; + + @NotNull + @Size(max = 30) + private String name; + + @NotNull + @Enumerated(EnumType.STRING) + private ManagementRole role; + + @NotNull + @Enumerated(EnumType.STRING) + private ManagementPart part; + + private int generation; + + @NotNull private int managementGeneration; + + @NotNull + @Enumerated(EnumType.STRING) + private University university; + + @NotNull + @Size(max = 20) + private String major; + + @NotNull + @Size(max = 50) + private String company; + + private String imageUrl; + + // 생성자 + @Builder + private Management( + String name, + ManagementRole role, + ManagementPart part, + int generation, + int managementGeneration, + University university, + String major, + String company, + String imageUrl) { + this.name = name; + this.role = role; + this.part = part; + this.generation = generation; + this.managementGeneration = managementGeneration; + this.university = university; + this.major = major; + this.company = company; + this.imageUrl = imageUrl; + } + + // 정적 팩토리 메서드 + public static Management from(ManagementVo managementVo) { + return Management.builder() + .name(managementVo.getName()) + .role(managementVo.getRole()) + .part(managementVo.getPart()) + .generation(managementVo.getGeneration()) + .managementGeneration(managementVo.getManagementGeneration()) + .university(managementVo.getUniversity()) + .major(managementVo.getMajor()) + .company(managementVo.getCompany()) + .imageUrl(managementVo.getImageUrl()) + .build(); + } + + public void update(UpdateManagementRequest request) { + ManagementVo managementInfo = request.getManagementVo(); + if (managementInfo.getName() != null) { + this.name = managementInfo.getName(); + } + if (managementInfo.getRole() != null) { + this.role = managementInfo.getRole(); + } + if (managementInfo.getPart() != null) { + this.part = managementInfo.getPart(); + } + if (Optional.ofNullable(managementInfo.getGeneration()).orElse(0) != 0) { + this.generation = managementInfo.getGeneration(); + } + if (Optional.ofNullable(managementInfo.getManagementGeneration()).orElse(0) != 0) { + this.managementGeneration = managementInfo.getManagementGeneration(); + } + if (managementInfo.getUniversity() != null) { + this.university = managementInfo.getUniversity(); + } + if (managementInfo.getMajor() != null) { + this.major = managementInfo.getMajor(); + } + if (managementInfo.getCompany() != null) { + this.company = managementInfo.getCompany(); + } + if (managementInfo.getImageUrl() != null) { + this.imageUrl = managementInfo.getImageUrl(); + } + } +} diff --git a/src/main/java/ceos/backend/domain/management/domain/ManagementPart.java b/src/main/java/ceos/backend/domain/management/domain/ManagementPart.java new file mode 100644 index 00000000..09bb617d --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/domain/ManagementPart.java @@ -0,0 +1,31 @@ +package ceos.backend.domain.management.domain; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ManagementPart { + CHAIRMAN("회장"), + VICE_CHAIRMAN("부회장"), + CO_PRESIDENT("공동회장"), + PLAN("기획"), + DESIGN("디자인"), + FRONTEND("프론트엔드"), + BACKEND("백엔드"), + DEVELOPMENT("개발"); + + @JsonValue private final String managementPart; + + @JsonCreator + public static ManagementPart parsing(String inputValue) { + return Stream.of(ManagementPart.values()) + .filter(category -> category.getManagementPart().equals(inputValue)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/ceos/backend/domain/management/domain/ManagementRole.java b/src/main/java/ceos/backend/domain/management/domain/ManagementRole.java new file mode 100644 index 00000000..f8b4b452 --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/domain/ManagementRole.java @@ -0,0 +1,28 @@ +package ceos.backend.domain.management.domain; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ManagementRole { + PRESIDENCY("회장단"), + GENERAL_AFFAIRS("총무"), + PART_LEADER("파트장"), + MANAGEMENT("운영진"), + MENTOR("멘토"); + + @JsonValue private final String managementRole; + + @JsonCreator + public static ManagementRole parsing(String inputValue) { + return Stream.of(ManagementRole.values()) + .filter(category -> category.getManagementRole().equals(inputValue)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/ceos/backend/domain/management/dto/ManagementDto.java b/src/main/java/ceos/backend/domain/management/dto/ManagementDto.java new file mode 100644 index 00000000..c93db23f --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/dto/ManagementDto.java @@ -0,0 +1,78 @@ +package ceos.backend.domain.management.dto; + + +import ceos.backend.domain.management.domain.Management; +import ceos.backend.domain.management.domain.ManagementPart; +import ceos.backend.domain.management.domain.ManagementRole; +import ceos.backend.domain.management.vo.ManagementVo; +import ceos.backend.global.common.entity.University; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ManagementDto { + + private Long id; + private String name; + private ManagementRole role; + private ManagementPart part; + private int generation; + private int managementGeneration; + private University university; + private String major; + private String company; + private String imageUrl; + + @Builder + private ManagementDto( + Long id, + String name, + ManagementRole role, + ManagementPart part, + int generation, + int managementGeneration, + University university, + String major, + String company, + String imageUrl) { + this.id = id; + this.name = name; + this.role = role; + this.part = part; + this.generation = generation; + this.managementGeneration = managementGeneration; + this.university = university; + this.major = major; + this.company = company; + this.imageUrl = imageUrl; + } + + public static ManagementDto voToDto(ManagementVo managementVo) { + return ManagementDto.builder() + .name(managementVo.getName()) + .role(managementVo.getRole()) + .part(managementVo.getPart()) + .generation(managementVo.getGeneration()) + .managementGeneration(managementVo.getManagementGeneration()) + .university(managementVo.getUniversity()) + .major(managementVo.getMajor()) + .company(managementVo.getCompany()) + .imageUrl(managementVo.getImageUrl()) + .build(); + } + + public static ManagementDto entityToDto(Management management) { + return ManagementDto.builder() + .id(management.getId()) + .name(management.getName()) + .role(management.getRole()) + .part(management.getPart()) + .generation(management.getGeneration()) + .managementGeneration(management.getManagementGeneration()) + .university(management.getUniversity()) + .major(management.getMajor()) + .company(management.getCompany()) + .imageUrl(management.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/management/dto/request/CreateManagementRequest.java b/src/main/java/ceos/backend/domain/management/dto/request/CreateManagementRequest.java new file mode 100644 index 00000000..e1ffa948 --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/dto/request/CreateManagementRequest.java @@ -0,0 +1,12 @@ +package ceos.backend.domain.management.dto.request; + + +import ceos.backend.domain.management.vo.ManagementVo; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import lombok.Getter; + +@Getter +public class CreateManagementRequest { + + @JsonUnwrapped private ManagementVo managementVo; +} diff --git a/src/main/java/ceos/backend/domain/management/dto/request/UpdateManagementRequest.java b/src/main/java/ceos/backend/domain/management/dto/request/UpdateManagementRequest.java new file mode 100644 index 00000000..f0d97473 --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/dto/request/UpdateManagementRequest.java @@ -0,0 +1,12 @@ +package ceos.backend.domain.management.dto.request; + + +import ceos.backend.domain.management.vo.ManagementVo; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import lombok.Getter; + +@Getter +public class UpdateManagementRequest { + + @JsonUnwrapped private ManagementVo managementVo; +} diff --git a/src/main/java/ceos/backend/domain/management/dto/response/GetAllManagementsResponse.java b/src/main/java/ceos/backend/domain/management/dto/response/GetAllManagementsResponse.java new file mode 100644 index 00000000..1f5a637e --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/dto/response/GetAllManagementsResponse.java @@ -0,0 +1,25 @@ +package ceos.backend.domain.management.dto.response; + + +import ceos.backend.domain.management.dto.ManagementDto; +import ceos.backend.global.common.dto.PageInfo; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetAllManagementsResponse { + + List content; + PageInfo pageInfo; + + @Builder + private GetAllManagementsResponse(List managers, PageInfo pageInfo) { + this.content = managers; + this.pageInfo = pageInfo; + } + + public static GetAllManagementsResponse of(List managers, PageInfo pageInfo) { + return GetAllManagementsResponse.builder().managers(managers).pageInfo(pageInfo).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/management/dto/response/GetAllPartManagementsResponse.java b/src/main/java/ceos/backend/domain/management/dto/response/GetAllPartManagementsResponse.java new file mode 100644 index 00000000..78c9fac9 --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/dto/response/GetAllPartManagementsResponse.java @@ -0,0 +1,41 @@ +package ceos.backend.domain.management.dto.response; + + +import ceos.backend.domain.management.dto.ManagementDto; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetAllPartManagementsResponse { + + List presidency; + List generalAffairs; + List partLeaders; + List managers; + + @Builder + public GetAllPartManagementsResponse( + List presidency, + List generalAffairs, + List partLeaders, + List managers) { + this.presidency = presidency; + this.generalAffairs = generalAffairs; + this.partLeaders = partLeaders; + this.managers = managers; + } + + public static GetAllPartManagementsResponse of( + List presidency, + List generalAffairs, + List partLeaders, + List managers) { + return GetAllPartManagementsResponse.builder() + .presidency(presidency) + .generalAffairs(generalAffairs) + .partLeaders(partLeaders) + .managers(managers) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/management/exception/ManagementErrorCode.java b/src/main/java/ceos/backend/domain/management/exception/ManagementErrorCode.java new file mode 100644 index 00000000..2f517b1a --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/exception/ManagementErrorCode.java @@ -0,0 +1,25 @@ +package ceos.backend.domain.management.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import ceos.backend.global.common.dto.ErrorReason; +import ceos.backend.global.error.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ManagementErrorCode implements BaseErrorCode { + /* Management */ + MANAGER_NOT_FOUND(BAD_REQUEST, "MANAGEMENT_404_1", "해당 임원진이 존재하지 않습니다"); + + private HttpStatus status; + private String code; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/ceos/backend/domain/management/exception/ManagerNotFound.java b/src/main/java/ceos/backend/domain/management/exception/ManagerNotFound.java new file mode 100644 index 00000000..34a1c5e5 --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/exception/ManagerNotFound.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.management.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class ManagerNotFound extends BaseErrorException { + + public static final ManagerNotFound EXCEPTION = new ManagerNotFound(); + + private ManagerNotFound() { + super(ManagementErrorCode.MANAGER_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/management/helper/ManagementHelper.java b/src/main/java/ceos/backend/domain/management/helper/ManagementHelper.java new file mode 100644 index 00000000..0b11df93 --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/helper/ManagementHelper.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.management.helper; + + +import ceos.backend.domain.management.repository.ManagementRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ManagementHelper { + private final ManagementRepository managementRepository; +} diff --git a/src/main/java/ceos/backend/domain/management/mapper/ManagementMapper.java b/src/main/java/ceos/backend/domain/management/mapper/ManagementMapper.java new file mode 100644 index 00000000..8a900886 --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/mapper/ManagementMapper.java @@ -0,0 +1,64 @@ +package ceos.backend.domain.management.mapper; + + +import ceos.backend.domain.management.domain.Management; +import ceos.backend.domain.management.domain.ManagementPart; +import ceos.backend.domain.management.dto.ManagementDto; +import ceos.backend.domain.management.dto.response.GetAllManagementsResponse; +import ceos.backend.domain.management.dto.response.GetAllPartManagementsResponse; +import ceos.backend.domain.management.vo.ManagementVo; +import ceos.backend.global.common.dto.PageInfo; +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class ManagementMapper { + public Management toEntity(ManagementVo managementVo) { + return Management.from(managementVo); + } + + public GetAllManagementsResponse toManagementsPage( + List managements, PageInfo pageInfo) { + List managementDtoList = new ArrayList<>(); + for (Management m : managements) { + ManagementDto managementDto = ManagementDto.entityToDto(m); + managementDtoList.add(managementDto); + } + return GetAllManagementsResponse.of(managementDtoList, pageInfo); + } + + public GetAllPartManagementsResponse toPartManagementList( + List presidency, + List generalAffairs, + List partLeaders, + List managements) { + List presidencyList = toManagementDtoList(toOrderByPart(presidency)); + List generalAffairsList = toManagementDtoList(toOrderByPart(generalAffairs)); + List partLeadersList = toManagementDtoList(toOrderByPart(partLeaders)); + List managementsList = toManagementDtoList(toOrderByPart(managements)); + return GetAllPartManagementsResponse.of( + presidencyList, generalAffairsList, partLeadersList, managementsList); + } + + public List toManagementDtoList(List managements) { + List managementsDtoList = new ArrayList<>(); + for (Management m : managements) { + ManagementDto managementDto = ManagementDto.entityToDto(m); + managementsDtoList.add(managementDto); + } + return managementsDtoList; + } + + public List toOrderByPart(List managements) { + List orderedManagementList = new ArrayList<>(); + for (ManagementPart p : ManagementPart.values()) { + for (Management m : managements) { + if (p.equals(m.getPart())) { + orderedManagementList.add(m); + } + } + } + return orderedManagementList; + } +} diff --git a/src/main/java/ceos/backend/domain/management/repository/ManagementRepository.java b/src/main/java/ceos/backend/domain/management/repository/ManagementRepository.java new file mode 100644 index 00000000..4e69e7d9 --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/repository/ManagementRepository.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.management.repository; + + +import ceos.backend.domain.management.domain.Management; +import ceos.backend.domain.management.domain.ManagementRole; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ManagementRepository extends JpaRepository { + @Query("SELECT m FROM Management m WHERE m.role = :role ORDER BY m.name ASC") + List findManagementAllByRoleOrderByNameAsc(@Param("role") ManagementRole role); +} diff --git a/src/main/java/ceos/backend/domain/management/service/ManagementService.java b/src/main/java/ceos/backend/domain/management/service/ManagementService.java new file mode 100644 index 00000000..0820e373 --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/service/ManagementService.java @@ -0,0 +1,128 @@ +package ceos.backend.domain.management.service; + + +import ceos.backend.domain.management.domain.Management; +import ceos.backend.domain.management.domain.ManagementRole; +import ceos.backend.domain.management.dto.ManagementDto; +import ceos.backend.domain.management.dto.request.CreateManagementRequest; +import ceos.backend.domain.management.dto.request.UpdateManagementRequest; +import ceos.backend.domain.management.dto.response.GetAllManagementsResponse; +import ceos.backend.domain.management.dto.response.GetAllPartManagementsResponse; +import ceos.backend.domain.management.exception.ManagerNotFound; +import ceos.backend.domain.management.helper.ManagementHelper; +import ceos.backend.domain.management.mapper.ManagementMapper; +import ceos.backend.domain.management.repository.ManagementRepository; +import ceos.backend.global.common.dto.AwsS3Url; +import ceos.backend.global.common.dto.PageInfo; +import ceos.backend.infra.s3.AwsS3UrlHandler; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ManagementService { + + private final ManagementRepository managementRepository; + private final ManagementMapper managementMapper; + private final ManagementHelper managementHelper; + private final AwsS3UrlHandler awsS3UrlHandler; + + @Transactional + public void createManagement(CreateManagementRequest createManagementRequest) { + Management newManagement = + managementMapper.toEntity(createManagementRequest.getManagementVo()); + managementRepository.save(newManagement); + } + + @Transactional(readOnly = true) + public GetAllManagementsResponse getAllManagements(int pageNum, int limit) { + // 페이징 요청 정보 + PageRequest pageRequest = PageRequest.of(pageNum, limit, Sort.by("id").ascending()); + + Page pageManagements = managementRepository.findAll(pageRequest); + // 페이징 정보 + PageInfo pageInfo = + PageInfo.of( + pageNum, + limit, + pageManagements.getTotalPages(), + pageManagements.getTotalElements()); + // dto + GetAllManagementsResponse response = + managementMapper.toManagementsPage(pageManagements.getContent(), pageInfo); + + return response; + } + + @Transactional(readOnly = true) + public GetAllPartManagementsResponse getAllPartManagements() { + List findPresidency = + managementRepository.findManagementAllByRoleOrderByNameAsc( + ManagementRole.PRESIDENCY); + List findGeneralAffairs = + managementRepository.findManagementAllByRoleOrderByNameAsc( + ManagementRole.GENERAL_AFFAIRS); + List findPartLeaders = + managementRepository.findManagementAllByRoleOrderByNameAsc( + ManagementRole.PART_LEADER); + List findManagements = + managementRepository.findManagementAllByRoleOrderByNameAsc( + ManagementRole.MANAGEMENT); + + GetAllPartManagementsResponse response = + managementMapper.toPartManagementList( + findPresidency, findGeneralAffairs, findPartLeaders, findManagements); + + return response; + } + + @Transactional + public ManagementDto getManagement(Long id) { + Management findManagement = + managementRepository + .findById(id) + .orElseThrow( + () -> { + throw ManagerNotFound.EXCEPTION; + }); + return ManagementDto.entityToDto(findManagement); + } + + @Transactional + public ManagementDto updateManagementInfo( + Long id, UpdateManagementRequest updateManagementRequest) { + Management findManagement = + managementRepository + .findById(id) + .orElseThrow( + () -> { + throw ManagerNotFound.EXCEPTION; + }); + findManagement.update(updateManagementRequest); + return ManagementDto.entityToDto(findManagement); + } + + @Transactional + public void deleteManagement(Long id) { + Management findManagement = + managementRepository + .findById(id) + .orElseThrow( + () -> { + throw ManagerNotFound.EXCEPTION; + }); + managementRepository.delete(findManagement); + } + + @Transactional(readOnly = true) + public AwsS3Url getImageUrl() { + return awsS3UrlHandler.handle("managements"); + } +} diff --git a/src/main/java/ceos/backend/domain/management/vo/ManagementVo.java b/src/main/java/ceos/backend/domain/management/vo/ManagementVo.java new file mode 100644 index 00000000..063a8bba --- /dev/null +++ b/src/main/java/ceos/backend/domain/management/vo/ManagementVo.java @@ -0,0 +1,55 @@ +package ceos.backend.domain.management.vo; + + +import ceos.backend.domain.management.domain.ManagementPart; +import ceos.backend.domain.management.domain.ManagementRole; +import ceos.backend.global.common.annotation.ValidEnum; +import ceos.backend.global.common.entity.University; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Getter; + +@Getter +public class ManagementVo { + + @Schema(defaultValue = "세오스", description = "임원진 이름") + @NotEmpty(message = "임원진 이름을 입력해주세요") + private String name; + + @Schema(defaultValue = "회장단", description = "임원진 역할(회장단/총무/파트장/운영진/멘토)") + @ValidEnum(target = ManagementRole.class) + private ManagementRole role; + + @Schema(defaultValue = "기획", description = "임원진 파트(회장/부회장/공동회장/기획/디자인/프론트엔드/백엔드)") + @ValidEnum(target = ManagementPart.class) + private ManagementPart part; + + @Schema(defaultValue = "16", description = "기수") + @Positive + private int generation; + + @Schema(defaultValue = "17", description = "운영진 기수") + @Positive + @NotNull(message = "임원진 운영진 기수를 입력해주세요") + private int managementGeneration; + + @Schema(defaultValue = "홍익대학교", description = "임원진 대학교") + @ValidEnum(target = University.class) + private University university; + + @Schema(defaultValue = "컴퓨터공학과", description = "임원진 전공") + @NotEmpty(message = "임원진 전공을 입력해주세요") + private String major; + + @Schema(defaultValue = "네이버", description = "임원진 소속") + @NotEmpty(message = "임원진 소속을 입력해주세요") + private String company; + + @Schema( + defaultValue = + "https://s3.ap-northeast-2.amazonaws.com/ceos-sinchon.com-image/image/2490u509u020f", + description = "사진 url") + private String imageUrl; +} diff --git a/src/main/java/ceos/backend/domain/project/ProjectController.java b/src/main/java/ceos/backend/domain/project/ProjectController.java new file mode 100644 index 00000000..6de00476 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/ProjectController.java @@ -0,0 +1,69 @@ +package ceos.backend.domain.project; + + +import ceos.backend.domain.project.dto.request.ProjectRequest; +import ceos.backend.domain.project.dto.response.GetProjectResponse; +import ceos.backend.domain.project.dto.response.GetProjectsResponse; +import ceos.backend.domain.project.service.ProjectService; +import ceos.backend.global.common.dto.AwsS3Url; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/projects") +@Tag(name = "Project") +public class ProjectController { + + private final ProjectService projectService; + + @Operation(summary = "프로젝트 목록 보기") + @GetMapping + public GetProjectsResponse getProjects( + @RequestParam("pageNum") int pageNum, @RequestParam("limit") int limit) { + log.info("프로젝트 목록 보기"); + return projectService.getProjects(pageNum, limit); + } + + @Operation(summary = "프로젝트 하나 보기") + @GetMapping("/{projectId}") + public GetProjectResponse getProject(@PathVariable("projectId") Long projectId) { + log.info("프로젝트 하나 보기"); + return projectService.getProject(projectId); + } + + @Operation(summary = "프로젝트 생성하기") + @PostMapping + public void createProject(@RequestBody @Valid ProjectRequest projectRequest) { + log.info("프로젝트 생성하기"); + projectService.createProject(projectRequest); + } + + @Operation(summary = "프로젝트 수정하기") + @PatchMapping("/{projectId}") + public void updateProject( + @PathVariable("projectId") Long projectId, + @RequestBody @Valid ProjectRequest projectRequest) { + log.info("프로젝트 수정하기"); + projectService.updateProject(projectId, projectRequest); + } + + @Operation(summary = "프로젝트 삭제하기") + @DeleteMapping("/{projectId}") + public void deleteActivity(@PathVariable Long projectId) { + log.info("프로젝트 삭제하기"); + projectService.deleteProject(projectId); + } + + @Operation(summary = "프로젝트 이미지 url 생성하기") + @GetMapping("/image") + public AwsS3Url getImageUrl() { + log.info("프로젝트 이미지 url 생성하기"); + return projectService.getImageUrl(); + } +} diff --git a/src/main/java/ceos/backend/domain/project/domain/Participant.java b/src/main/java/ceos/backend/domain/project/domain/Participant.java new file mode 100644 index 00000000..fa528ee3 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/domain/Participant.java @@ -0,0 +1,57 @@ +package ceos.backend.domain.project.domain; + + +import ceos.backend.domain.project.vo.ParticipantVo; +import ceos.backend.global.common.entity.BaseEntity; +import ceos.backend.global.common.entity.Part; +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.annotation.Nullable; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Participant extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "participant_id") + private Long id; + + @NotNull + @Enumerated(EnumType.STRING) + private Part part; + + @Nullable + @Size(max = 30) + private String name; + + @ManyToOne(fetch = FetchType.LAZY) // Cascade + @JsonBackReference + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + // 생성자 + @Builder + private Participant(Part part, String name, Project project) { + this.part = part; + this.name = name; + this.project = project; + } + + // 정적 팩토리 메서드 + public static Participant of(ParticipantVo participantVo, Project project) { + return Participant.builder() + .part(participantVo.getPart()) + .name(participantVo.getName()) + .project(project) + .build(); + } + + public void update(String name) { + this.name = name; + } +} diff --git a/src/main/java/ceos/backend/domain/project/domain/Project.java b/src/main/java/ceos/backend/domain/project/domain/Project.java new file mode 100644 index 00000000..f3fe4d87 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/domain/Project.java @@ -0,0 +1,73 @@ +package ceos.backend.domain.project.domain; + + +import ceos.backend.domain.project.vo.ProjectInfoVo; +import ceos.backend.global.common.entity.BaseEntity; +import com.fasterxml.jackson.annotation.JsonManagedReference; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Project extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "project_id") + private Long id; + + @NotNull + @Size(max = 30) + private String name; + + @NotNull + @Size(max = 100) + private String description; + + @NotNull private int generation; + + // Project : ProjectUrl = 1:N + @OneToMany(mappedBy = "project") + @JsonManagedReference + private List projectUrls = new ArrayList<>(); + + // Project : ProjectImage = 1:N + @OneToMany(mappedBy = "project") + @JsonManagedReference + private List projectImages = new ArrayList<>(); + + // Project : Participant = 1:N + @OneToMany(mappedBy = "project") + @JsonManagedReference + private List participants = new ArrayList<>(); + + // 생성자 + @Builder + private Project(String name, String description, int generation) { + this.name = name; + this.description = description; + this.generation = generation; + } + + public static Project from(ProjectInfoVo projectInfoVo) { + return Project.builder() + .name(projectInfoVo.getName()) + .description(projectInfoVo.getDescription()) + .generation(projectInfoVo.getGeneration()) + .build(); + } + + public void update(ProjectInfoVo projectInfoVo) { + this.name = projectInfoVo.getName(); + this.description = projectInfoVo.getDescription(); + this.generation = projectInfoVo.getGeneration(); + } +} diff --git a/src/main/java/ceos/backend/domain/project/domain/ProjectImage.java b/src/main/java/ceos/backend/domain/project/domain/ProjectImage.java new file mode 100644 index 00000000..efe7f1cb --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/domain/ProjectImage.java @@ -0,0 +1,52 @@ +package ceos.backend.domain.project.domain; + + +import ceos.backend.domain.project.vo.ProjectImageVo; +import ceos.backend.global.common.entity.BaseEntity; +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class ProjectImage extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "project_image_id") + private Long id; + + @NotNull + @Enumerated(EnumType.STRING) + private ProjectImageCategory category; + + @NotNull private String imageUrl; + + @ManyToOne(fetch = FetchType.LAZY) // Cascade + @JsonBackReference + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + // 생성자 + @Builder + private ProjectImage(ProjectImageCategory category, String imageUrl, Project project) { + this.category = category; + this.imageUrl = imageUrl; + this.project = project; + } + + // 정적 팩토리 메서드 + public static ProjectImage of(ProjectImageVo projectImageVo, Project project) { + return ProjectImage.builder() + .category(projectImageVo.getCategory()) + .imageUrl(projectImageVo.getImageUrl()) + .project(project) + .build(); + } + + public void update(String imageUrl) { + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/ceos/backend/domain/project/domain/ProjectImageCategory.java b/src/main/java/ceos/backend/domain/project/domain/ProjectImageCategory.java new file mode 100644 index 00000000..708749c3 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/domain/ProjectImageCategory.java @@ -0,0 +1,25 @@ +package ceos.backend.domain.project.domain; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ProjectImageCategory { + THUMBNAIL("썸네일"), + DETAIL("상세"); + + @JsonValue private final String projectImageCategory; + + @JsonCreator + public static ProjectImageCategory parsing(String inputValue) { + return Stream.of(ProjectImageCategory.values()) + .filter(category -> category.getProjectImageCategory().equals(inputValue)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/ceos/backend/domain/project/domain/ProjectUrl.java b/src/main/java/ceos/backend/domain/project/domain/ProjectUrl.java new file mode 100644 index 00000000..2c70fb29 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/domain/ProjectUrl.java @@ -0,0 +1,53 @@ +package ceos.backend.domain.project.domain; + + +import ceos.backend.domain.project.vo.ProjectUrlVo; +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class ProjectUrl { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "project_url_id") + private Long id; + + @NotNull + @Enumerated(EnumType.STRING) + private ProjectUrlCategory category; + + @NotNull private String linkUrl; + + @ManyToOne(fetch = FetchType.LAZY) // Cascade + @JsonBackReference + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + @Builder + private ProjectUrl(ProjectUrlCategory category, String linkUrl, Project project) { + this.category = category; + this.linkUrl = linkUrl; + this.project = project; + } + + // 정적 팩토리 메서드 + public static ProjectUrl of(ProjectUrlVo projectUrlVo, Project project) { + return ProjectUrl.builder() + .category(projectUrlVo.getCategory()) + .linkUrl(projectUrlVo.getLinkUrl()) + .project(project) + .build(); + } + + public void update(String linkUrl) { + this.linkUrl = linkUrl; + } +} diff --git a/src/main/java/ceos/backend/domain/project/domain/ProjectUrlCategory.java b/src/main/java/ceos/backend/domain/project/domain/ProjectUrlCategory.java new file mode 100644 index 00000000..b77ca798 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/domain/ProjectUrlCategory.java @@ -0,0 +1,27 @@ +package ceos.backend.domain.project.domain; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ProjectUrlCategory { + SERVICE("서비스"), + GITHUB("깃허브"), + BEHANCE("비핸스"), + INSTAGRAM("인스타"); + + @JsonValue private final String projectUrlCategory; + + @JsonCreator + public static ProjectUrlCategory parsing(String inputValue) { + return Stream.of(ProjectUrlCategory.values()) + .filter(category -> category.getProjectUrlCategory().equals(inputValue)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/ceos/backend/domain/project/dto/request/ProjectRequest.java b/src/main/java/ceos/backend/domain/project/dto/request/ProjectRequest.java new file mode 100644 index 00000000..fbb960f4 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/dto/request/ProjectRequest.java @@ -0,0 +1,23 @@ +package ceos.backend.domain.project.dto.request; + + +import ceos.backend.domain.project.vo.ParticipantVo; +import ceos.backend.domain.project.vo.ProjectImageVo; +import ceos.backend.domain.project.vo.ProjectInfoVo; +import ceos.backend.domain.project.vo.ProjectUrlVo; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import jakarta.validation.Valid; +import java.util.List; +import lombok.Getter; + +@Getter +public class ProjectRequest { + + @JsonUnwrapped private ProjectInfoVo projectInfoVo; + + @Valid private List projectUrls; + + @Valid private List projectImages; + + @Valid private List participants; +} diff --git a/src/main/java/ceos/backend/domain/project/dto/response/GetProjectResponse.java b/src/main/java/ceos/backend/domain/project/dto/response/GetProjectResponse.java new file mode 100644 index 00000000..e3a5d747 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/dto/response/GetProjectResponse.java @@ -0,0 +1,52 @@ +package ceos.backend.domain.project.dto.response; + + +import ceos.backend.domain.project.domain.Participant; +import ceos.backend.domain.project.domain.Project; +import ceos.backend.domain.project.domain.ProjectImage; +import ceos.backend.domain.project.domain.ProjectUrl; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetProjectResponse { + + private Long projectId; + private String name; + private String description; + private int generation; + private List projectUrls; + private List projectImages; + private List participants; + + @Builder + public GetProjectResponse( + Long projectId, + String name, + String description, + int generation, + List projectUrls, + List projectImages, + List participants) { + this.projectId = projectId; + this.name = name; + this.description = description; + this.generation = generation; + this.projectUrls = projectUrls; + this.projectImages = projectImages; + this.participants = participants; + } + + public static GetProjectResponse from(Project project) { + return GetProjectResponse.builder() + .projectId(project.getId()) + .name(project.getName()) + .description(project.getDescription()) + .generation(project.getGeneration()) + .projectUrls(project.getProjectUrls()) + .projectImages(project.getProjectImages()) + .participants(project.getParticipants()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/project/dto/response/GetProjectsResponse.java b/src/main/java/ceos/backend/domain/project/dto/response/GetProjectsResponse.java new file mode 100644 index 00000000..435d2fdb --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/dto/response/GetProjectsResponse.java @@ -0,0 +1,29 @@ +package ceos.backend.domain.project.dto.response; + + +import ceos.backend.domain.project.vo.ProjectBriefInfoVo; +import ceos.backend.global.common.dto.PageInfo; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetProjectsResponse { + + private List content; + private PageInfo pageInfo; + + @Builder + private GetProjectsResponse(List ProjectBriefInfoVos, PageInfo pageInfo) { + this.content = ProjectBriefInfoVos; + this.pageInfo = pageInfo; + } + + public static GetProjectsResponse of( + List ProjectBriefInfoVos, PageInfo pageInfo) { + return GetProjectsResponse.builder() + .ProjectBriefInfoVos(ProjectBriefInfoVos) + .pageInfo(pageInfo) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/project/exception/DataNotFound.java b/src/main/java/ceos/backend/domain/project/exception/DataNotFound.java new file mode 100644 index 00000000..ce31a1fa --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/exception/DataNotFound.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.project.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class DataNotFound extends BaseErrorException { + + public static final DataNotFound EXCEPTION = new DataNotFound(); + + private DataNotFound() { + super(ProjectErrorCode.DATA_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/project/exception/DuplicateProject.java b/src/main/java/ceos/backend/domain/project/exception/DuplicateProject.java new file mode 100644 index 00000000..c3f7d5b3 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/exception/DuplicateProject.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.project.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class DuplicateProject extends BaseErrorException { + + public static final DuplicateProject EXCEPTION = new DuplicateProject(); + + private DuplicateProject() { + super(ProjectErrorCode.DUPLICATE_PROJECT); + } +} diff --git a/src/main/java/ceos/backend/domain/project/exception/InvalidData.java b/src/main/java/ceos/backend/domain/project/exception/InvalidData.java new file mode 100644 index 00000000..c548b430 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/exception/InvalidData.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.project.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class InvalidData extends BaseErrorException { + + public static final InvalidData EXCEPTION = new InvalidData(); + + private InvalidData() { + super(ProjectErrorCode.INVALID_DATA); + } +} diff --git a/src/main/java/ceos/backend/domain/project/exception/ProjectErrorCode.java b/src/main/java/ceos/backend/domain/project/exception/ProjectErrorCode.java new file mode 100644 index 00000000..55149a63 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/exception/ProjectErrorCode.java @@ -0,0 +1,30 @@ +package ceos.backend.domain.project.exception; + +import static org.springframework.http.HttpStatus.*; + +import ceos.backend.global.common.dto.ErrorReason; +import ceos.backend.global.error.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ProjectErrorCode implements BaseErrorCode { + /* Project */ + DUPLICATE_PROJECT(BAD_REQUEST, "PROJECT_400_1", "이미 존재하는 프로젝트입니다."), + PROJECT_NOT_FOUND(NOT_FOUND, "PROJECT_404_1", "존재하지 않는 프로젝트입니다."), + + /* Data */ + DATA_NOT_FOUND(CONFLICT, "PROJECT_404_2", "존재하지 않는 데이터입니다"), + INVALID_DATA(BAD_REQUEST, "PROJECT_400_2", "데이터 유효성 검증에 실패하였습니다."); + + private HttpStatus status; + private String code; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/ceos/backend/domain/project/exception/ProjectNotFound.java b/src/main/java/ceos/backend/domain/project/exception/ProjectNotFound.java new file mode 100644 index 00000000..793d7d00 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/exception/ProjectNotFound.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.project.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class ProjectNotFound extends BaseErrorException { + + public static final ProjectNotFound EXCEPTION = new ProjectNotFound(); + + private ProjectNotFound() { + super(ProjectErrorCode.PROJECT_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/project/helper/ProjectHelper.java b/src/main/java/ceos/backend/domain/project/helper/ProjectHelper.java new file mode 100644 index 00000000..ca13107e --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/helper/ProjectHelper.java @@ -0,0 +1,97 @@ +package ceos.backend.domain.project.helper; + + +import ceos.backend.domain.project.domain.*; +import ceos.backend.domain.project.exception.DataNotFound; +import ceos.backend.domain.project.exception.DuplicateProject; +import ceos.backend.domain.project.exception.ProjectNotFound; +import ceos.backend.domain.project.repository.*; +import ceos.backend.domain.project.vo.ParticipantVo; +import ceos.backend.domain.project.vo.ProjectImageVo; +import ceos.backend.domain.project.vo.ProjectInfoVo; +import ceos.backend.domain.project.vo.ProjectUrlVo; +import ceos.backend.global.common.entity.Part; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProjectHelper { + + private final ProjectRepository projectRepository; + private final ProjectImageRepository projectImageRepository; + private final ProjectUrlRepository projectUrlRepository; + private final ParticipantRepository participantRepository; + private final ProjectMapper projectMapper; + + public Project findById(Long projectId) { + return projectRepository + .findById(projectId) + .orElseThrow( + () -> { + throw ProjectNotFound.EXCEPTION; + }); + } + + public void findDuplicateProject(ProjectInfoVo projectInfoVo) { + if (projectRepository + .findByNameAndGeneration(projectInfoVo.getName(), projectInfoVo.getGeneration()) + .isPresent()) { + throw DuplicateProject.EXCEPTION; + } + } + + public void updateImages(Project project, List projectImageVos) { + + for (ProjectImageVo projectImageVo : projectImageVos) { + ProjectImageCategory category = projectImageVo.getCategory(); + + // 데이터베이스에서 이미지를 조회 + ProjectImage image = + projectImageRepository + .findByProjectAndCategory(project, category) + .orElseThrow( + () -> { + throw DataNotFound.EXCEPTION; + }); + + image.update(projectImageVo.getImageUrl()); + projectImageRepository.save(image); + } + } + + public void updateUrls(Project project, List projectUrlVos) { + + for (ProjectUrlVo projectUrlVo : projectUrlVos) { + ProjectUrlCategory category = projectUrlVo.getCategory(); + + // 데이터베이스에서 Url을 조회 + Optional url = + projectUrlRepository.findByProjectAndCategory(project, category); + + if (url.isPresent()) { + ProjectUrl existingUrl = url.get(); + existingUrl.update(projectUrlVo.getLinkUrl()); + projectUrlRepository.save(existingUrl); + } else { + ProjectUrl newUrl = ProjectUrl.of(projectUrlVo, project); + projectUrlRepository.save(newUrl); + } + } + } + + public void updateParticipants(Project project, List participantVos) { + + for (Part part : Part.values()) { + List filteredList = + participantVos.stream() + .filter(participant -> participant.getPart().equals(part)) + .toList(); + + participantRepository.deleteAllByProjectAndPart(project, part); + participantRepository.saveAll(projectMapper.toParticipantList(project, filteredList)); + } + } +} diff --git a/src/main/java/ceos/backend/domain/project/repository/ParticipantRepository.java b/src/main/java/ceos/backend/domain/project/repository/ParticipantRepository.java new file mode 100644 index 00000000..742f6d23 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/repository/ParticipantRepository.java @@ -0,0 +1,11 @@ +package ceos.backend.domain.project.repository; + + +import ceos.backend.domain.project.domain.Participant; +import ceos.backend.domain.project.domain.Project; +import ceos.backend.global.common.entity.Part; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ParticipantRepository extends JpaRepository { + void deleteAllByProjectAndPart(Project project, Part part); +} diff --git a/src/main/java/ceos/backend/domain/project/repository/ProjectImageRepository.java b/src/main/java/ceos/backend/domain/project/repository/ProjectImageRepository.java new file mode 100644 index 00000000..e295b654 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/repository/ProjectImageRepository.java @@ -0,0 +1,12 @@ +package ceos.backend.domain.project.repository; + + +import ceos.backend.domain.project.domain.Project; +import ceos.backend.domain.project.domain.ProjectImage; +import ceos.backend.domain.project.domain.ProjectImageCategory; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProjectImageRepository extends JpaRepository { + Optional findByProjectAndCategory(Project project, ProjectImageCategory category); +} diff --git a/src/main/java/ceos/backend/domain/project/repository/ProjectMapper.java b/src/main/java/ceos/backend/domain/project/repository/ProjectMapper.java new file mode 100644 index 00000000..eda1a9a6 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/repository/ProjectMapper.java @@ -0,0 +1,58 @@ +package ceos.backend.domain.project.repository; + + +import ceos.backend.domain.project.domain.Participant; +import ceos.backend.domain.project.domain.Project; +import ceos.backend.domain.project.domain.ProjectImage; +import ceos.backend.domain.project.domain.ProjectUrl; +import ceos.backend.domain.project.dto.request.ProjectRequest; +import ceos.backend.domain.project.dto.response.GetProjectResponse; +import ceos.backend.domain.project.dto.response.GetProjectsResponse; +import ceos.backend.domain.project.vo.ParticipantVo; +import ceos.backend.domain.project.vo.ProjectBriefInfoVo; +import ceos.backend.domain.project.vo.ProjectImageVo; +import ceos.backend.domain.project.vo.ProjectUrlVo; +import ceos.backend.global.common.dto.PageInfo; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class ProjectMapper { + + public GetProjectsResponse toGetProjects(List projectList, PageInfo pageInfo) { + List projectBriefInfoVos = + projectList.stream().map(ProjectBriefInfoVo::from).toList(); + return GetProjectsResponse.of(projectBriefInfoVos, pageInfo); + } + + public GetProjectResponse toGetProject(Project project) { + return GetProjectResponse.from(project); + } + + public Project toEntity(ProjectRequest projectRequest) { + return Project.from(projectRequest.getProjectInfoVo()); + } + + public List toProjectImageList( + Project project, List projectImageVos) { + + return projectImageVos.stream() + .map(projectImageVo -> ProjectImage.of(projectImageVo, project)) + .toList(); + } + + public List toProjectUrlList(Project project, List projectUrlVos) { + + return projectUrlVos.stream() + .map(projectUrlVo -> ProjectUrl.of(projectUrlVo, project)) + .toList(); + } + + public List toParticipantList( + Project project, List participantVos) { + + return participantVos.stream() + .map(participantVo -> Participant.of(participantVo, project)) + .toList(); + } +} diff --git a/src/main/java/ceos/backend/domain/project/repository/ProjectRepository.java b/src/main/java/ceos/backend/domain/project/repository/ProjectRepository.java new file mode 100644 index 00000000..40e5e5cc --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/repository/ProjectRepository.java @@ -0,0 +1,21 @@ +package ceos.backend.domain.project.repository; + + +import ceos.backend.domain.project.domain.Project; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface ProjectRepository extends JpaRepository { + Page findAllByOrderByGenerationDescIdAsc(PageRequest pageRequest); + + Optional findByNameAndGeneration(String name, int generation); + + List findByGeneration(int generation); + + @Query("SELECT MAX(p.generation) FROM Project p") + int findMaxGeneration(); +} diff --git a/src/main/java/ceos/backend/domain/project/repository/ProjectUrlRepository.java b/src/main/java/ceos/backend/domain/project/repository/ProjectUrlRepository.java new file mode 100644 index 00000000..6df912ac --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/repository/ProjectUrlRepository.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.project.repository; + + +import ceos.backend.domain.project.domain.*; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProjectUrlRepository extends JpaRepository { + Optional findByProjectAndCategory(Project project, ProjectUrlCategory category); + + List findByCategory(ProjectUrlCategory category); +} diff --git a/src/main/java/ceos/backend/domain/project/service/ProjectService.java b/src/main/java/ceos/backend/domain/project/service/ProjectService.java new file mode 100644 index 00000000..33140271 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/service/ProjectService.java @@ -0,0 +1,115 @@ +package ceos.backend.domain.project.service; + +import static ceos.backend.domain.project.domain.ProjectImageCategory.THUMBNAIL; + +import ceos.backend.domain.project.domain.*; +import ceos.backend.domain.project.dto.request.ProjectRequest; +import ceos.backend.domain.project.dto.response.GetProjectResponse; +import ceos.backend.domain.project.dto.response.GetProjectsResponse; +import ceos.backend.domain.project.helper.ProjectHelper; +import ceos.backend.domain.project.repository.*; +import ceos.backend.domain.project.vo.ParticipantVo; +import ceos.backend.domain.project.vo.ProjectImageVo; +import ceos.backend.domain.project.vo.ProjectUrlVo; +import ceos.backend.global.common.dto.AwsS3Url; +import ceos.backend.global.common.dto.PageInfo; +import ceos.backend.infra.s3.AwsS3UrlHandler; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProjectService { + + private final ProjectHelper projectHelper; + private final ProjectMapper projectMapper; + private final ProjectRepository projectRepository; + private final ProjectImageRepository projectImageRepository; + private final ProjectUrlRepository projectUrlRepository; + private final ParticipantRepository participantRepository; + private final AwsS3UrlHandler awsS3UrlHandler; + + @Transactional(readOnly = true) + public GetProjectsResponse getProjects(int pageNum, int limit) { + PageRequest pageRequest = PageRequest.of(pageNum, limit); + Page projectList = + projectRepository.findAllByOrderByGenerationDescIdAsc(pageRequest); + List filteredProjects = + projectList.getContent().stream() + .filter( + project -> + !project.getProjectImages().stream() + .filter( + image -> + image.getCategory() + .equals(THUMBNAIL)) + .toList() + .isEmpty()) + .toList(); + PageInfo pageInfo = + PageInfo.of( + pageNum, + limit, + projectList.getTotalPages(), + projectList.getTotalElements()); + return projectMapper.toGetProjects(filteredProjects, pageInfo); + } + + @Transactional(readOnly = true) + public GetProjectResponse getProject(Long projectId) { + return projectMapper.toGetProject(projectHelper.findById(projectId)); + } + + @Transactional + public void createProject(ProjectRequest projectRequest) { + // 프로젝트 중복 검사 + projectHelper.findDuplicateProject(projectRequest.getProjectInfoVo()); + + // 프로젝트 생성 + final Project project = projectMapper.toEntity(projectRequest); + projectRepository.save(project); + + // 프로젝트 이미지 저장 + final List projectImageVos = projectRequest.getProjectImages(); + projectImageRepository.saveAll(projectMapper.toProjectImageList(project, projectImageVos)); + + // 프로젝트 Url 저장 + final List projectUrlVos = projectRequest.getProjectUrls(); + projectUrlRepository.saveAll(projectMapper.toProjectUrlList(project, projectUrlVos)); + + // 프로젝트 팀원 저장 + final List participantVos = projectRequest.getParticipants(); + participantRepository.saveAll(projectMapper.toParticipantList(project, participantVos)); + } + + @Transactional + public void updateProject(Long projectId, ProjectRequest projectRequest) { + Project project = projectHelper.findById(projectId); + + // 프로젝트 업데이트 + project.update(projectRequest.getProjectInfoVo()); + + projectHelper.updateImages(project, projectRequest.getProjectImages()); + projectHelper.updateUrls(project, projectRequest.getProjectUrls()); + projectHelper.updateParticipants(project, projectRequest.getParticipants()); + } + + @Transactional + public void deleteProject(Long projectId) { + Project project = projectHelper.findById(projectId); + + // 프로젝트 삭제 + projectRepository.delete(project); + } + + @Transactional(readOnly = true) + public AwsS3Url getImageUrl() { + return awsS3UrlHandler.handle("projects"); + } +} diff --git a/src/main/java/ceos/backend/domain/project/vo/ParticipantVo.java b/src/main/java/ceos/backend/domain/project/vo/ParticipantVo.java new file mode 100644 index 00000000..73a21f8e --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/vo/ParticipantVo.java @@ -0,0 +1,32 @@ +package ceos.backend.domain.project.vo; + + +import ceos.backend.domain.project.domain.*; +import ceos.backend.global.common.annotation.ValidEnum; +import ceos.backend.global.common.entity.Part; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ParticipantVo { + + @Schema() + @ValidEnum(target = Part.class) + private Part part; + + @Schema() private String name; + + @Builder + public ParticipantVo(Part part, String name) { + this.part = part; + this.name = name; + } + + public static ParticipantVo from(Participant participant) { + return ParticipantVo.builder() + .part(participant.getPart()) + .name(participant.getName()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/project/vo/ProjectBriefInfoVo.java b/src/main/java/ceos/backend/domain/project/vo/ProjectBriefInfoVo.java new file mode 100644 index 00000000..421b53a5 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/vo/ProjectBriefInfoVo.java @@ -0,0 +1,43 @@ +package ceos.backend.domain.project.vo; + +import static ceos.backend.domain.project.domain.ProjectImageCategory.THUMBNAIL; + +import ceos.backend.domain.project.domain.Project; +import ceos.backend.domain.project.domain.ProjectImage; +import ceos.backend.domain.project.exception.DataNotFound; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ProjectBriefInfoVo { + + private Long id; + private String name; + private String description; + private int generation; + private ProjectImage thumbnailImage; + + @Builder + public ProjectBriefInfoVo( + Long id, String name, String description, int generation, ProjectImage thumbnailImage) { + this.id = id; + this.name = name; + this.description = description; + this.generation = generation; + this.thumbnailImage = thumbnailImage; + } + + public static ProjectBriefInfoVo from(Project project) { + return ProjectBriefInfoVo.builder() + .id(project.getId()) + .name(project.getName()) + .description(project.getDescription()) + .generation(project.getGeneration()) + .thumbnailImage( + project.getProjectImages().stream() + .filter(image -> image.getCategory().equals(THUMBNAIL)) + .findFirst() + .orElseThrow(() -> DataNotFound.EXCEPTION)) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/project/vo/ProjectImageVo.java b/src/main/java/ceos/backend/domain/project/vo/ProjectImageVo.java new file mode 100644 index 00000000..f8da0f26 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/vo/ProjectImageVo.java @@ -0,0 +1,32 @@ +package ceos.backend.domain.project.vo; + + +import ceos.backend.domain.project.domain.ProjectImage; +import ceos.backend.domain.project.domain.ProjectImageCategory; +import ceos.backend.global.common.annotation.ValidEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ProjectImageVo { + + @Schema() + @ValidEnum(target = ProjectImageCategory.class) + private ProjectImageCategory category; + + @Schema() private String imageUrl; + + @Builder + public ProjectImageVo(ProjectImageCategory category, String imageUrl) { + this.category = category; + this.imageUrl = imageUrl; + } + + public static ProjectImageVo from(ProjectImage projectImage) { + return ProjectImageVo.builder() + .category(projectImage.getCategory()) + .imageUrl(projectImage.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/project/vo/ProjectInfoVo.java b/src/main/java/ceos/backend/domain/project/vo/ProjectInfoVo.java new file mode 100644 index 00000000..efb1f92d --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/vo/ProjectInfoVo.java @@ -0,0 +1,39 @@ +package ceos.backend.domain.project.vo; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; + +@Getter +public class ProjectInfoVo { + @Schema() + @NotEmpty(message = "프로젝트 이름를 입력해주세요.") + private String name; + + @Schema() + @NotEmpty(message = "설명을 입력해주세요.") + private String description; + + @Schema() + @NotEmpty(message = "기수를 입력해주세요.") + private int generation; + + // @Builder + // public ProjectInfoVo(String name, + // String description, + // int generation + // ) { + // this.name = name; + // this.description = description; + // this.generation = generation; + // } + // + // public static ProjectInfoVo from(Project project) { + // return ProjectInfoVo.builder() + // .name(project.getName()) + // .description(project.getDescription()) + // .generation(project.getGeneration()) + // .build(); + // } +} diff --git a/src/main/java/ceos/backend/domain/project/vo/ProjectUrlVo.java b/src/main/java/ceos/backend/domain/project/vo/ProjectUrlVo.java new file mode 100644 index 00000000..f7f40fa3 --- /dev/null +++ b/src/main/java/ceos/backend/domain/project/vo/ProjectUrlVo.java @@ -0,0 +1,35 @@ +package ceos.backend.domain.project.vo; + + +import ceos.backend.domain.project.domain.ProjectUrl; +import ceos.backend.domain.project.domain.ProjectUrlCategory; +import ceos.backend.global.common.annotation.ValidEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ProjectUrlVo { + + @Schema() + @ValidEnum(target = ProjectUrlCategory.class) + private ProjectUrlCategory category; + + @Schema() + @NotEmpty(message = "Url을 입력해주세요.") + private String linkUrl; + + @Builder + public ProjectUrlVo(ProjectUrlCategory category, String linkUrl) { + this.category = category; + this.linkUrl = linkUrl; + } + + public static ProjectUrlVo from(ProjectUrl projectUrl) { + return ProjectUrlVo.builder() + .category(projectUrl.getCategory()) + .linkUrl(projectUrl.getLinkUrl()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/RecruitmentController.java b/src/main/java/ceos/backend/domain/recruitment/RecruitmentController.java new file mode 100644 index 00000000..26191de1 --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/RecruitmentController.java @@ -0,0 +1,43 @@ +package ceos.backend.domain.recruitment; + + +import ceos.backend.domain.recruitment.dto.RecruitmentDTO; +import ceos.backend.domain.recruitment.dto.UserRecruitmentDTO; +import ceos.backend.domain.recruitment.service.RecruitmentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/recruitments") +@Tag(name = "Recruitment") +public class RecruitmentController { + + private final RecruitmentService recruitmentService; + + @Operation(summary = "모든 리크루팅 정보 보기 (오픈채팅 포함)") + @GetMapping("/all") + public RecruitmentDTO getRecruitment() { + log.info("리크루팅 정보 보기"); + return recruitmentService.getRecruitment(); + } + + @Operation(summary = "리크루팅 정보 보기") + @GetMapping + public UserRecruitmentDTO getUserRecruitment() { + log.info("리크루팅 정보 보기"); + return recruitmentService.getUserRecruitment(); + } + + @Operation(summary = "리크루팅 정보 수정") + @PutMapping + public void updateRecruitment(@RequestBody @Valid RecruitmentDTO recruitmentDTO) { + log.info("리크루팅 정보 수정"); + recruitmentService.updateRecruitment(recruitmentDTO); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/domain/Recruitment.java b/src/main/java/ceos/backend/domain/recruitment/domain/Recruitment.java new file mode 100644 index 00000000..5149eb29 --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/domain/Recruitment.java @@ -0,0 +1,163 @@ +package ceos.backend.domain.recruitment.domain; + + +import ceos.backend.domain.admin.exception.NotAllowedToModify; +import ceos.backend.domain.application.exception.exceptions.WrongGeneration; +import ceos.backend.domain.recruitment.dto.RecruitmentDTO; +import ceos.backend.domain.recruitment.exception.*; +import ceos.backend.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Recruitment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "recruitment_id") + private Long id; + + @NotNull private int generation; + + @NotNull private String prodStudyUrl; + + @NotNull private String designStudyUrl; + + @NotNull private String devStudyUrl; + + @NotNull private LocalDate startDateDoc; + + @NotNull private LocalDate endDateDoc; + + @NotNull private LocalDate resultDateDoc; + + @NotNull private LocalDate startDateInterview; + + @NotNull private LocalDate endDateInterview; + + @NotNull private LocalDate resultDateFinal; + + @NotNull private String openChatUrl; + + @NotNull private LocalDate otDate; + + @NotNull private LocalDate demodayDate; + + private LocalDateTime applicationExcelCreatedAt; + + // 생성자 + @Builder + private Recruitment( + int generation, + String prodStudyUrl, + String designStudyUrl, + String devStudyUrl, + LocalDate startDateDoc, + LocalDate endDateDoc, + LocalDate resultDateDoc, + LocalDate startDateInterview, + LocalDate endDateInterview, + LocalDate resultDateFinal, + String openChatUrl, + LocalDate otDate, + LocalDate demodayDate, + LocalDateTime applicationExcelCreatedAt) { + this.generation = generation; + this.prodStudyUrl = prodStudyUrl; + this.designStudyUrl = designStudyUrl; + this.devStudyUrl = devStudyUrl; + this.startDateDoc = startDateDoc; + this.endDateDoc = endDateDoc; + this.resultDateDoc = resultDateDoc; + this.startDateInterview = startDateInterview; + this.endDateInterview = endDateInterview; + this.resultDateFinal = resultDateFinal; + this.openChatUrl = openChatUrl; + this.otDate = otDate; + this.demodayDate = demodayDate; + this.applicationExcelCreatedAt = applicationExcelCreatedAt; + } + + public void updateRecruitment(RecruitmentDTO recruitmentDTO) { + this.generation = recruitmentDTO.getGeneration(); + this.prodStudyUrl = recruitmentDTO.getProdStudyUrl(); + this.designStudyUrl = recruitmentDTO.getDesignStudyUrl(); + this.devStudyUrl = recruitmentDTO.getDevStudyUrl(); + this.startDateDoc = recruitmentDTO.getStartDateDoc(); + this.endDateDoc = recruitmentDTO.getEndDateDoc(); + this.resultDateDoc = recruitmentDTO.getResultDateDoc(); + this.startDateInterview = recruitmentDTO.getStartDateInterview(); + this.endDateInterview = recruitmentDTO.getEndDateInterview(); + this.resultDateFinal = recruitmentDTO.getResultDateFinal(); + this.openChatUrl = recruitmentDTO.getOpenChatUrl(); + this.otDate = recruitmentDTO.getOtDate(); + this.demodayDate = recruitmentDTO.getDemodayDate(); + } + + public void updateApplicationExcelCreatedAt(LocalDateTime createdAt) { + this.applicationExcelCreatedAt = createdAt; + } + + // Validation 관련 + public void validateGeneration(int generation) { + if (generation != this.generation) { + throw WrongGeneration.EXCEPTION; + } + } + + public void validateBetweenStartDateDocAndEndDateDoc(LocalDate now) { + if (now.compareTo(this.getStartDateDoc()) < 0) { + throw NotApplicationDuration.EXCEPTION; + } + if (now.isAfter(this.getEndDateDoc())) { + throw NotApplicationDuration.EXCEPTION; + } + } + + public void validateFinalResultAbleDuration(LocalDate now) { + if (now.compareTo(this.resultDateFinal.plusDays(5)) >= 0) { + throw NotFinalResultCheckDuration.EXCEPTION; + } + if (now.compareTo(this.resultDateFinal) < 0) { + throw NotFinalResultCheckDuration.EXCEPTION; + } + } + + public void validateBetweenStartDateDocAndResultDateDoc(LocalDate now) { + if (now.compareTo(this.startDateDoc) < 0) { + throw NotDocumentPassDuration.EXCEPTION; + } + if (now.compareTo(this.resultDateDoc) >= 0) { + throw NotDocumentPassDuration.EXCEPTION; + } + } + + public void validateBetweenResultDateDocAndResultDateFinal(LocalDate now) { + if (now.compareTo(this.resultDateDoc) < 0) { + throw NotFinalPassDuration.EXCEPTION; + } + if (now.compareTo(this.resultDateFinal) >= 0) { + throw NotFinalPassDuration.EXCEPTION; + } + } + + public void validateBeforeStartDateDoc(LocalDate now) { + if (now.compareTo(this.startDateDoc) >= 0) { + throw AlreadyApplicationDuration.EXCEPTION; + } + } + + public void validAmenablePeriod(LocalDate now) { + if (now.compareTo(this.startDateDoc) >= 0 && now.compareTo(this.resultDateFinal) <= 0) { + throw NotAllowedToModify.EXCEPTION; + } + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/dto/RecruitmentDTO.java b/src/main/java/ceos/backend/domain/recruitment/dto/RecruitmentDTO.java new file mode 100644 index 00000000..fd73857f --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/dto/RecruitmentDTO.java @@ -0,0 +1,72 @@ +package ceos.backend.domain.recruitment.dto; + + +import ceos.backend.domain.recruitment.domain.Recruitment; +import java.time.LocalDate; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class RecruitmentDTO { + private int generation; + private String prodStudyUrl; + private String designStudyUrl; + private String devStudyUrl; + private LocalDate startDateDoc; + private LocalDate endDateDoc; + private LocalDate resultDateDoc; + private LocalDate startDateInterview; + private LocalDate endDateInterview; + private LocalDate resultDateFinal; + private String openChatUrl; + private LocalDate otDate; + private LocalDate demodayDate; + + @Builder + public RecruitmentDTO( + int generation, + String prodStudyUrl, + String designStudyUrl, + String devStudyUrl, + LocalDate startDateDoc, + LocalDate endDateDoc, + LocalDate resultDateDoc, + LocalDate startDateInterview, + LocalDate endDateInterview, + LocalDate resultDateFinal, + String openChatUrl, + LocalDate otDate, + LocalDate demodayDate) { + this.generation = generation; + this.prodStudyUrl = prodStudyUrl; + this.designStudyUrl = designStudyUrl; + this.devStudyUrl = devStudyUrl; + this.startDateDoc = startDateDoc; + this.endDateDoc = endDateDoc; + this.resultDateDoc = resultDateDoc; + this.startDateInterview = startDateInterview; + this.endDateInterview = endDateInterview; + this.resultDateFinal = resultDateFinal; + this.openChatUrl = openChatUrl; + this.otDate = otDate; + this.demodayDate = demodayDate; + } + + public static RecruitmentDTO from(Recruitment recruitment) { + return RecruitmentDTO.builder() + .generation(recruitment.getGeneration()) + .prodStudyUrl(recruitment.getProdStudyUrl()) + .designStudyUrl(recruitment.getDesignStudyUrl()) + .devStudyUrl(recruitment.getDevStudyUrl()) + .startDateDoc(recruitment.getStartDateDoc()) + .endDateDoc(recruitment.getEndDateDoc()) + .resultDateDoc(recruitment.getResultDateDoc()) + .startDateInterview(recruitment.getStartDateInterview()) + .endDateInterview(recruitment.getEndDateInterview()) + .resultDateFinal(recruitment.getResultDateFinal()) + .openChatUrl(recruitment.getOpenChatUrl()) + .otDate(recruitment.getOtDate()) + .demodayDate(recruitment.getDemodayDate()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/dto/UserRecruitmentDTO.java b/src/main/java/ceos/backend/domain/recruitment/dto/UserRecruitmentDTO.java new file mode 100644 index 00000000..4b64ed45 --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/dto/UserRecruitmentDTO.java @@ -0,0 +1,68 @@ +package ceos.backend.domain.recruitment.dto; + + +import ceos.backend.domain.recruitment.domain.Recruitment; +import java.time.LocalDate; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class UserRecruitmentDTO { + private int generation; + private String prodStudyUrl; + private String designStudyUrl; + private String devStudyUrl; + private LocalDate startDateDoc; + private LocalDate endDateDoc; + private LocalDate resultDateDoc; + private LocalDate startDateInterview; + private LocalDate endDateInterview; + private LocalDate resultDateFinal; + private LocalDate otDate; + private LocalDate demodayDate; + + @Builder + public UserRecruitmentDTO( + int generation, + String prodStudyUrl, + String designStudyUrl, + String devStudyUrl, + LocalDate startDateDoc, + LocalDate endDateDoc, + LocalDate resultDateDoc, + LocalDate startDateInterview, + LocalDate endDateInterview, + LocalDate resultDateFinal, + LocalDate otDate, + LocalDate demodayDate) { + this.generation = generation; + this.prodStudyUrl = prodStudyUrl; + this.designStudyUrl = designStudyUrl; + this.devStudyUrl = devStudyUrl; + this.startDateDoc = startDateDoc; + this.endDateDoc = endDateDoc; + this.resultDateDoc = resultDateDoc; + this.startDateInterview = startDateInterview; + this.endDateInterview = endDateInterview; + this.resultDateFinal = resultDateFinal; + this.otDate = otDate; + this.demodayDate = demodayDate; + } + + public static UserRecruitmentDTO from(Recruitment recruitment) { + return UserRecruitmentDTO.builder() + .generation(recruitment.getGeneration()) + .prodStudyUrl(recruitment.getProdStudyUrl()) + .designStudyUrl(recruitment.getDesignStudyUrl()) + .devStudyUrl(recruitment.getDevStudyUrl()) + .startDateDoc(recruitment.getStartDateDoc()) + .endDateDoc(recruitment.getEndDateDoc()) + .resultDateDoc(recruitment.getResultDateDoc()) + .startDateInterview(recruitment.getStartDateInterview()) + .endDateInterview(recruitment.getEndDateInterview()) + .resultDateFinal(recruitment.getResultDateFinal()) + .otDate(recruitment.getOtDate()) + .demodayDate(recruitment.getDemodayDate()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/exception/AlreadyApplicationDuration.java b/src/main/java/ceos/backend/domain/recruitment/exception/AlreadyApplicationDuration.java new file mode 100644 index 00000000..d7b17998 --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/exception/AlreadyApplicationDuration.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.recruitment.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class AlreadyApplicationDuration extends BaseErrorException { + + public static final AlreadyApplicationDuration EXCEPTION = new AlreadyApplicationDuration(); + + private AlreadyApplicationDuration() { + super(RecruitmentErrorCode.ALREADY_APPLICATION_DURATION); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/exception/NotApplicationDuration.java b/src/main/java/ceos/backend/domain/recruitment/exception/NotApplicationDuration.java new file mode 100644 index 00000000..5d7e56e7 --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/exception/NotApplicationDuration.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.recruitment.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class NotApplicationDuration extends BaseErrorException { + + public static final NotApplicationDuration EXCEPTION = new NotApplicationDuration(); + + private NotApplicationDuration() { + super(RecruitmentErrorCode.NOT_APPLICATION_DURATION); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/exception/NotDocumentPassDuration.java b/src/main/java/ceos/backend/domain/recruitment/exception/NotDocumentPassDuration.java new file mode 100644 index 00000000..b7c3ae7c --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/exception/NotDocumentPassDuration.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.recruitment.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class NotDocumentPassDuration extends BaseErrorException { + + public static final NotDocumentPassDuration EXCEPTION = new NotDocumentPassDuration(); + + private NotDocumentPassDuration() { + super(RecruitmentErrorCode.NOT_DOCUMENT_PASS_DURATION); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/exception/NotDocumentResultCheckDuration.java b/src/main/java/ceos/backend/domain/recruitment/exception/NotDocumentResultCheckDuration.java new file mode 100644 index 00000000..00696f05 --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/exception/NotDocumentResultCheckDuration.java @@ -0,0 +1,14 @@ +package ceos.backend.domain.recruitment.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class NotDocumentResultCheckDuration extends BaseErrorException { + + public static final NotDocumentResultCheckDuration EXCEPTION = + new NotDocumentResultCheckDuration(); + + private NotDocumentResultCheckDuration() { + super(RecruitmentErrorCode.NOT_DOCUMENT_RESULT_CHECK_DURATION); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/exception/NotFinalPassDuration.java b/src/main/java/ceos/backend/domain/recruitment/exception/NotFinalPassDuration.java new file mode 100644 index 00000000..9d97d9a6 --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/exception/NotFinalPassDuration.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.recruitment.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class NotFinalPassDuration extends BaseErrorException { + + public static final NotFinalPassDuration EXCEPTION = new NotFinalPassDuration(); + + private NotFinalPassDuration() { + super(RecruitmentErrorCode.NOT_FINAL_PASS_DURATION); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/exception/NotFinalResultCheckDuration.java b/src/main/java/ceos/backend/domain/recruitment/exception/NotFinalResultCheckDuration.java new file mode 100644 index 00000000..8328ed42 --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/exception/NotFinalResultCheckDuration.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.recruitment.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class NotFinalResultCheckDuration extends BaseErrorException { + + public static final NotFinalResultCheckDuration EXCEPTION = new NotFinalResultCheckDuration(); + + private NotFinalResultCheckDuration() { + super(RecruitmentErrorCode.NOT_FINAL_RESULT_CHECK_DURATION); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/exception/RecruitmentErrorCode.java b/src/main/java/ceos/backend/domain/recruitment/exception/RecruitmentErrorCode.java new file mode 100644 index 00000000..06429527 --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/exception/RecruitmentErrorCode.java @@ -0,0 +1,31 @@ +package ceos.backend.domain.recruitment.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import ceos.backend.global.common.dto.ErrorReason; +import ceos.backend.global.error.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum RecruitmentErrorCode implements BaseErrorCode { + RECRUITMENT_NOT_FOUND(BAD_REQUEST, "RECRUITMENT_400_1", "리크루팅 정보가 설정되어있지 않습니다."), + NOT_APPLICATION_DURATION(BAD_REQUEST, "RECRUITMENT_400_2", "지원 기간이 아닙니다."), + NOT_DOCUMENT_RESULT_CHECK_DURATION(BAD_REQUEST, "RECRUITMENT_400_3", "서류 결과 확인 기간이 아닙니다."), + NOT_FINAL_RESULT_CHECK_DURATION(BAD_REQUEST, "RECRUITMENT_400_4", "최종 결과 확인 기간이 아닙니다."), + NOT_DOCUMENT_PASS_DURATION(BAD_REQUEST, "RECRUITMENT_400_5", "서류 합격 여부 변경 가능 기간이 아닙니다."), + NOT_FINAL_PASS_DURATION(BAD_REQUEST, "RECRUITMENT_400_6", "최종 합격 여부 변경 가능 기간이 아닙니다."), + ALREADY_APPLICATION_DURATION(BAD_REQUEST, "RECRUITMENT_400_6", "이미 지원 기간입니다."), + ; + + private HttpStatus status; + private String code; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/exception/RecruitmentNotFound.java b/src/main/java/ceos/backend/domain/recruitment/exception/RecruitmentNotFound.java new file mode 100644 index 00000000..2b1bf4ba --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/exception/RecruitmentNotFound.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.recruitment.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class RecruitmentNotFound extends BaseErrorException { + + public static final RecruitmentNotFound EXCEPTION = new RecruitmentNotFound(); + + private RecruitmentNotFound() { + super(RecruitmentErrorCode.RECRUITMENT_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/helper/RecruitmentHelper.java b/src/main/java/ceos/backend/domain/recruitment/helper/RecruitmentHelper.java new file mode 100644 index 00000000..d4093b7f --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/helper/RecruitmentHelper.java @@ -0,0 +1,20 @@ +package ceos.backend.domain.recruitment.helper; + + +import ceos.backend.domain.recruitment.domain.Recruitment; +import ceos.backend.domain.recruitment.exception.RecruitmentNotFound; +import ceos.backend.domain.recruitment.repository.RecruitmentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RecruitmentHelper { + private final RecruitmentRepository recruitmentRepository; + + public Recruitment takeRecruitment() { + return recruitmentRepository.findAll().stream() + .findFirst() + .orElseThrow(() -> RecruitmentNotFound.EXCEPTION); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/repository/RecruitmentRepository.java b/src/main/java/ceos/backend/domain/recruitment/repository/RecruitmentRepository.java new file mode 100644 index 00000000..b46fa665 --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/repository/RecruitmentRepository.java @@ -0,0 +1,7 @@ +package ceos.backend.domain.recruitment.repository; + + +import ceos.backend.domain.recruitment.domain.Recruitment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecruitmentRepository extends JpaRepository {} diff --git a/src/main/java/ceos/backend/domain/recruitment/service/RecruitmentService.java b/src/main/java/ceos/backend/domain/recruitment/service/RecruitmentService.java new file mode 100644 index 00000000..a50a9752 --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/service/RecruitmentService.java @@ -0,0 +1,42 @@ +package ceos.backend.domain.recruitment.service; + + +import ceos.backend.domain.recruitment.domain.Recruitment; +import ceos.backend.domain.recruitment.dto.RecruitmentDTO; +import ceos.backend.domain.recruitment.dto.UserRecruitmentDTO; +import ceos.backend.domain.recruitment.helper.RecruitmentHelper; +import ceos.backend.domain.recruitment.repository.RecruitmentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RecruitmentService { + + private final RecruitmentRepository recruitmentRepository; + + private final RecruitmentHelper recruitmentHelper; + + @Transactional(readOnly = true) + public RecruitmentDTO getRecruitment() { + Recruitment recruitment = recruitmentHelper.takeRecruitment(); + return RecruitmentDTO.from(recruitment); + } + + public UserRecruitmentDTO getUserRecruitment() { + Recruitment recruitment = recruitmentHelper.takeRecruitment(); + return UserRecruitmentDTO.from(recruitment); + } + + @Transactional + public void updateRecruitment(RecruitmentDTO recruitmentDTO) { + Recruitment recruitment = recruitmentHelper.takeRecruitment(); + + // 객체 업데이트 + recruitment.updateRecruitment(recruitmentDTO); + recruitmentRepository.save(recruitment); + } +} diff --git a/src/main/java/ceos/backend/domain/recruitment/validator/RecruitmentValidator.java b/src/main/java/ceos/backend/domain/recruitment/validator/RecruitmentValidator.java new file mode 100644 index 00000000..1910a5e1 --- /dev/null +++ b/src/main/java/ceos/backend/domain/recruitment/validator/RecruitmentValidator.java @@ -0,0 +1,39 @@ +package ceos.backend.domain.recruitment.validator; + + +import ceos.backend.domain.recruitment.helper.RecruitmentHelper; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RecruitmentValidator { + private final RecruitmentHelper recruitmentHelper; + + public void validateBetweenStartDateDocAndEndDateDoc() { + recruitmentHelper + .takeRecruitment() + .validateBetweenStartDateDocAndEndDateDoc(LocalDate.now()); + } + + public void validateBeforeStartDateDoc() { + recruitmentHelper.takeRecruitment().validateBeforeStartDateDoc(LocalDate.now()); + } + + public void validateBetweenResultDateDocAndResultDateFinal() { + recruitmentHelper + .takeRecruitment() + .validateBetweenResultDateDocAndResultDateFinal(LocalDate.now()); + } + + public void validateFinalResultAbleDuration() { + recruitmentHelper.takeRecruitment().validateFinalResultAbleDuration(LocalDate.now()); + } + + public void validateBetweenStartDateDocAndResultDateDoc() { + recruitmentHelper + .takeRecruitment() + .validateBetweenStartDateDocAndResultDateDoc(LocalDate.now()); + } +} diff --git a/src/main/java/ceos/backend/domain/sponsor/SponsorController.java b/src/main/java/ceos/backend/domain/sponsor/SponsorController.java new file mode 100644 index 00000000..5dd95a12 --- /dev/null +++ b/src/main/java/ceos/backend/domain/sponsor/SponsorController.java @@ -0,0 +1,68 @@ +package ceos.backend.domain.sponsor; + + +import ceos.backend.domain.sponsor.dto.SponsorDto; +import ceos.backend.domain.sponsor.dto.response.GetAllSponsorsResponse; +import ceos.backend.domain.sponsor.service.SponsorService; +import ceos.backend.domain.sponsor.vo.SponsorVo; +import ceos.backend.global.common.dto.AwsS3Url; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/sponsors") +@Tag(name = "Sponsors") +public class SponsorController { + + private final SponsorService sponsorService; + + @Operation(summary = "스폰서 추가하기") + @PostMapping + public void createSponsor(@RequestBody @Valid SponsorVo sponsorVo) { + log.info("스폰서 추가하기"); + sponsorService.createSponsor(sponsorVo); + } + + @Operation(summary = "스폰서 전체 보기") + @GetMapping + public GetAllSponsorsResponse getAllSponsors( + @RequestParam("pageNum") int pageNum, @RequestParam("limit") int limit) { + log.info("스폰서 전체 보기"); + return sponsorService.getAllSponsors(pageNum, limit); + } + + @Operation(summary = "스폰서 하나 보기") + @GetMapping("/{sponsorId}") + public SponsorDto getSponsor(@PathVariable(name = "sponsorId") Long sponsorId) { + log.info("스폰서 하나 보기"); + return sponsorService.getSponsor(sponsorId); + } + + @Operation(summary = "스폰서 정보 수정") + @PatchMapping("/{sponsorId}") + public SponsorDto updateSponsor( + @PathVariable(name = "sponsorId") Long sponsorId, @RequestBody SponsorVo sponsorVo) { + log.info("스폰서 정보 수정"); + return sponsorService.updateSponsor(sponsorId, sponsorVo); + } + + @Operation(summary = "스폰서 삭제") + @DeleteMapping("/{sponsorId}") + public void deleteSponsor(@PathVariable(name = "sponsorId") Long sponsorId) { + log.info("스폰서 삭제"); + sponsorService.deleteSponsor(sponsorId); + } + + @Operation(summary = "스폰서 이미지 url 생성하기") + @GetMapping("/image") + public AwsS3Url getImageUrl() { + log.info("스폰서 이미지 url 생성하기"); + return sponsorService.getImageUrl(); + } +} diff --git a/src/main/java/ceos/backend/domain/sponsor/domain/Sponsor.java b/src/main/java/ceos/backend/domain/sponsor/domain/Sponsor.java new file mode 100644 index 00000000..4577be5c --- /dev/null +++ b/src/main/java/ceos/backend/domain/sponsor/domain/Sponsor.java @@ -0,0 +1,50 @@ +package ceos.backend.domain.sponsor.domain; + + +import ceos.backend.domain.sponsor.vo.SponsorVo; +import ceos.backend.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Sponsor extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "sponsor_id") + private Long id; + + @NotNull + @Size(max = 30) + private String name; + + @NotNull private String imageUrl; + + // 생성자 + @Builder + private Sponsor(String name, String imageUrl) { + this.name = name; + this.imageUrl = imageUrl; + } + + // 정적 팩토리 메서드 + public static Sponsor from(SponsorVo sponsorVo) { + return Sponsor.builder() + .name(sponsorVo.getName()) + .imageUrl(sponsorVo.getImageUrl()) + .build(); + } + + public void update(SponsorVo sponsorVo) { + if (sponsorVo.getName() != null) { + this.name = sponsorVo.getName(); + } + if (sponsorVo.getImageUrl() != null) { + this.imageUrl = sponsorVo.getImageUrl(); + } + } +} diff --git a/src/main/java/ceos/backend/domain/sponsor/dto/SponsorDto.java b/src/main/java/ceos/backend/domain/sponsor/dto/SponsorDto.java new file mode 100644 index 00000000..e1e163d5 --- /dev/null +++ b/src/main/java/ceos/backend/domain/sponsor/dto/SponsorDto.java @@ -0,0 +1,37 @@ +package ceos.backend.domain.sponsor.dto; + + +import ceos.backend.domain.sponsor.domain.Sponsor; +import ceos.backend.domain.sponsor.vo.SponsorVo; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class SponsorDto { + + private Long id; + private String name; + private String imageUrl; + + @Builder + private SponsorDto(Long id, String name, String imageUrl) { + this.id = id; + this.name = name; + this.imageUrl = imageUrl; + } + + public static SponsorDto voToDto(SponsorVo sponsorVo) { + return SponsorDto.builder() + .name(sponsorVo.getName()) + .imageUrl(sponsorVo.getImageUrl()) + .build(); + } + + public static SponsorDto entityToDto(Sponsor sponsor) { + return SponsorDto.builder() + .id(sponsor.getId()) + .name(sponsor.getName()) + .imageUrl(sponsor.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/domain/sponsor/dto/response/GetAllSponsorsResponse.java b/src/main/java/ceos/backend/domain/sponsor/dto/response/GetAllSponsorsResponse.java new file mode 100644 index 00000000..274b5519 --- /dev/null +++ b/src/main/java/ceos/backend/domain/sponsor/dto/response/GetAllSponsorsResponse.java @@ -0,0 +1,25 @@ +package ceos.backend.domain.sponsor.dto.response; + + +import ceos.backend.domain.sponsor.dto.SponsorDto; +import ceos.backend.global.common.dto.PageInfo; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GetAllSponsorsResponse { + + List content; + PageInfo pageInfo; + + @Builder + private GetAllSponsorsResponse(List sponsors, PageInfo pageInfo) { + this.content = sponsors; + this.pageInfo = pageInfo; + } + + public static GetAllSponsorsResponse of(List sponsors, PageInfo pageInfo) { + return GetAllSponsorsResponse.builder().sponsors(sponsors).pageInfo(pageInfo).build(); + } +} diff --git a/src/main/java/ceos/backend/domain/sponsor/exception/SponsorErrorCode.java b/src/main/java/ceos/backend/domain/sponsor/exception/SponsorErrorCode.java new file mode 100644 index 00000000..d57a24b3 --- /dev/null +++ b/src/main/java/ceos/backend/domain/sponsor/exception/SponsorErrorCode.java @@ -0,0 +1,25 @@ +package ceos.backend.domain.sponsor.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import ceos.backend.global.common.dto.ErrorReason; +import ceos.backend.global.error.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SponsorErrorCode implements BaseErrorCode { + /* Sponsor */ + SPONSOR_NOT_FOUND(BAD_REQUEST, "SPONSOR_404_1", "해당 후원사는 존재하지 않습니다"); + + private HttpStatus status; + private String code; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/ceos/backend/domain/sponsor/exception/SponsorNotFound.java b/src/main/java/ceos/backend/domain/sponsor/exception/SponsorNotFound.java new file mode 100644 index 00000000..a65c9da4 --- /dev/null +++ b/src/main/java/ceos/backend/domain/sponsor/exception/SponsorNotFound.java @@ -0,0 +1,13 @@ +package ceos.backend.domain.sponsor.exception; + + +import ceos.backend.global.error.BaseErrorException; + +public class SponsorNotFound extends BaseErrorException { + + public static final SponsorNotFound EXCEPTION = new SponsorNotFound(); + + private SponsorNotFound() { + super(SponsorErrorCode.SPONSOR_NOT_FOUND); + } +} diff --git a/src/main/java/ceos/backend/domain/sponsor/mapper/SponsorMapper.java b/src/main/java/ceos/backend/domain/sponsor/mapper/SponsorMapper.java new file mode 100644 index 00000000..29a23c78 --- /dev/null +++ b/src/main/java/ceos/backend/domain/sponsor/mapper/SponsorMapper.java @@ -0,0 +1,22 @@ +package ceos.backend.domain.sponsor.mapper; + + +import ceos.backend.domain.sponsor.domain.Sponsor; +import ceos.backend.domain.sponsor.dto.SponsorDto; +import ceos.backend.domain.sponsor.dto.response.GetAllSponsorsResponse; +import ceos.backend.global.common.dto.PageInfo; +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class SponsorMapper { + public GetAllSponsorsResponse toManagementsPage(List sponsors, PageInfo pageInfo) { + List sponsorDtoList = new ArrayList<>(); + for (Sponsor sponsor : sponsors) { + SponsorDto sponsorDto = SponsorDto.entityToDto(sponsor); + sponsorDtoList.add(sponsorDto); + } + return GetAllSponsorsResponse.of(sponsorDtoList, pageInfo); + } +} diff --git a/src/main/java/ceos/backend/domain/sponsor/repository/SponsorRepository.java b/src/main/java/ceos/backend/domain/sponsor/repository/SponsorRepository.java new file mode 100644 index 00000000..dc83e1d0 --- /dev/null +++ b/src/main/java/ceos/backend/domain/sponsor/repository/SponsorRepository.java @@ -0,0 +1,7 @@ +package ceos.backend.domain.sponsor.repository; + + +import ceos.backend.domain.sponsor.domain.Sponsor; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SponsorRepository extends JpaRepository {} diff --git a/src/main/java/ceos/backend/domain/sponsor/service/SponsorService.java b/src/main/java/ceos/backend/domain/sponsor/service/SponsorService.java new file mode 100644 index 00000000..681457cb --- /dev/null +++ b/src/main/java/ceos/backend/domain/sponsor/service/SponsorService.java @@ -0,0 +1,98 @@ +package ceos.backend.domain.sponsor.service; + + +import ceos.backend.domain.sponsor.domain.Sponsor; +import ceos.backend.domain.sponsor.dto.SponsorDto; +import ceos.backend.domain.sponsor.dto.response.GetAllSponsorsResponse; +import ceos.backend.domain.sponsor.exception.SponsorNotFound; +import ceos.backend.domain.sponsor.mapper.SponsorMapper; +import ceos.backend.domain.sponsor.repository.SponsorRepository; +import ceos.backend.domain.sponsor.vo.SponsorVo; +import ceos.backend.global.common.dto.AwsS3Url; +import ceos.backend.global.common.dto.PageInfo; +import ceos.backend.infra.s3.AwsS3UrlHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SponsorService { + + private final SponsorRepository sponsorRepository; + private final SponsorMapper sponsorMapper; + private final AwsS3UrlHandler awsS3UrlHandler; + + @Transactional + public void createSponsor(SponsorVo sponsorVo) { + Sponsor newSponsor = Sponsor.from(sponsorVo); + sponsorRepository.save(newSponsor); + } + + @Transactional(readOnly = true) + public GetAllSponsorsResponse getAllSponsors(int pageNum, int limit) { + // 페이징 요청 정보 + PageRequest pageRequest = PageRequest.of(pageNum, limit, Sort.by("id").descending()); // 최신순 + + Page pageSponsors = sponsorRepository.findAll(pageRequest); + // 페이징 정보 + PageInfo pageInfo = + PageInfo.of( + pageNum, + limit, + pageSponsors.getTotalPages(), + pageSponsors.getTotalElements()); + // dto + GetAllSponsorsResponse response = + sponsorMapper.toManagementsPage(pageSponsors.getContent(), pageInfo); + + return response; + } + + @Transactional(readOnly = true) + public SponsorDto getSponsor(Long id) { + Sponsor findSponsor = + sponsorRepository + .findById(id) + .orElseThrow( + () -> { + throw SponsorNotFound.EXCEPTION; + }); + return SponsorDto.entityToDto(findSponsor); + } + + @Transactional + public SponsorDto updateSponsor(Long id, SponsorVo sponsorVo) { + Sponsor findSponsor = + sponsorRepository + .findById(id) + .orElseThrow( + () -> { + throw SponsorNotFound.EXCEPTION; + }); + findSponsor.update(sponsorVo); + return SponsorDto.entityToDto(findSponsor); + } + + @Transactional + public void deleteSponsor(Long id) { + Sponsor findSponsor = + sponsorRepository + .findById(id) + .orElseThrow( + () -> { + throw SponsorNotFound.EXCEPTION; + }); + sponsorRepository.delete(findSponsor); + } + + @Transactional(readOnly = true) + public AwsS3Url getImageUrl() { + return awsS3UrlHandler.handle("sponsors"); + } +} diff --git a/src/main/java/ceos/backend/domain/sponsor/vo/SponsorVo.java b/src/main/java/ceos/backend/domain/sponsor/vo/SponsorVo.java new file mode 100644 index 00000000..1d343177 --- /dev/null +++ b/src/main/java/ceos/backend/domain/sponsor/vo/SponsorVo.java @@ -0,0 +1,20 @@ +package ceos.backend.domain.sponsor.vo; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; + +@Getter +public class SponsorVo { + + @Schema(defaultValue = "세오스", description = "후원사 이름") + @NotEmpty(message = "후원사 이름을 입력해주세요") + private String name; + + @Schema( + defaultValue = + "https://s3.ap-northeast-2.amazonaws.com/ceos-sinchon.com-image/image/2490u509u020f", + description = "사진 url") + private String imageUrl; +} diff --git a/src/main/java/ceos/backend/global/common/annotation/DateFormat.java b/src/main/java/ceos/backend/global/common/annotation/DateFormat.java new file mode 100644 index 00000000..961bedf5 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/annotation/DateFormat.java @@ -0,0 +1,16 @@ +package ceos.backend.global.common.annotation; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.TYPE_USE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; +import com.fasterxml.jackson.annotation.JsonFormat; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Target({TYPE_USE, FIELD}) +@Retention(RUNTIME) +@JacksonAnnotationsInside +@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy.MM.dd", timezone = "Asia/Seoul") +public @interface DateFormat {} diff --git a/src/main/java/ceos/backend/global/common/annotation/DateTimeFormat.java b/src/main/java/ceos/backend/global/common/annotation/DateTimeFormat.java new file mode 100644 index 00000000..809982a4 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/annotation/DateTimeFormat.java @@ -0,0 +1,18 @@ +package ceos.backend.global.common.annotation; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; +import com.fasterxml.jackson.annotation.JsonFormat; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Target({TYPE_USE, FIELD, PARAMETER, METHOD}) +@Retention(RUNTIME) +@JacksonAnnotationsInside +@JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy.MM.dd HH:mm:ss", + timezone = "Asia/Seoul") +public @interface DateTimeFormat {} diff --git a/src/main/java/ceos/backend/global/common/annotation/ValidDateList.java b/src/main/java/ceos/backend/global/common/annotation/ValidDateList.java new file mode 100644 index 00000000..8c5b17d4 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/annotation/ValidDateList.java @@ -0,0 +1,23 @@ +package ceos.backend.global.common.annotation; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import ceos.backend.global.common.validator.DateListValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Documented +@Target({TYPE_USE, METHOD, FIELD, PARAMETER}) +@Retention(RUNTIME) +@Constraint(validatedBy = {DateListValidator.class}) +public @interface ValidDateList { + String message() default "올바른 형식의 값을 입력해주세요"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/ceos/backend/global/common/annotation/ValidDuration.java b/src/main/java/ceos/backend/global/common/annotation/ValidDuration.java new file mode 100644 index 00000000..a8f8f1da --- /dev/null +++ b/src/main/java/ceos/backend/global/common/annotation/ValidDuration.java @@ -0,0 +1,23 @@ +package ceos.backend.global.common.annotation; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import ceos.backend.global.common.validator.DurationValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Documented +@Target({TYPE_USE, METHOD, FIELD, PARAMETER}) +@Retention(RUNTIME) +@Constraint(validatedBy = {DurationValidator.class}) +public @interface ValidDuration { + String message() default "올바른 형식의 값을 입력해주세요"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/ceos/backend/global/common/annotation/ValidEmail.java b/src/main/java/ceos/backend/global/common/annotation/ValidEmail.java new file mode 100644 index 00000000..80ce6a47 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/annotation/ValidEmail.java @@ -0,0 +1,23 @@ +package ceos.backend.global.common.annotation; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import ceos.backend.global.common.validator.EmailValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Documented +@Target({METHOD, FIELD, PARAMETER}) +@Retention(RUNTIME) +@Constraint(validatedBy = {EmailValidator.class}) +public @interface ValidEmail { + String message() default "올바른 형식의 값을 입력해주세요"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/ceos/backend/global/common/annotation/ValidEnum.java b/src/main/java/ceos/backend/global/common/annotation/ValidEnum.java new file mode 100644 index 00000000..94e7223b --- /dev/null +++ b/src/main/java/ceos/backend/global/common/annotation/ValidEnum.java @@ -0,0 +1,25 @@ +package ceos.backend.global.common.annotation; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import ceos.backend.global.common.validator.EnumValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Documented +@Target({METHOD, FIELD, PARAMETER}) +@Retention(RUNTIME) +@Constraint(validatedBy = {EnumValidator.class}) +public @interface ValidEnum { + String message() default "올바른 값을 입력해주세요"; + + Class[] groups() default {}; + + Class[] payload() default {}; + + Class> target(); +} diff --git a/src/main/java/ceos/backend/global/common/annotation/ValidPhone.java b/src/main/java/ceos/backend/global/common/annotation/ValidPhone.java new file mode 100644 index 00000000..3d765236 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/annotation/ValidPhone.java @@ -0,0 +1,23 @@ +package ceos.backend.global.common.annotation; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import ceos.backend.global.common.validator.PhoneValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Documented +@Target({METHOD, FIELD, PARAMETER}) +@Retention(RUNTIME) +@Constraint(validatedBy = {PhoneValidator.class}) +public @interface ValidPhone { + String message() default "올바른 형식의 값을 입력해주세요"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/ceos/backend/global/common/annotation/ValidQuestionOrder.java b/src/main/java/ceos/backend/global/common/annotation/ValidQuestionOrder.java new file mode 100644 index 00000000..bbbf2863 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/annotation/ValidQuestionOrder.java @@ -0,0 +1,23 @@ +package ceos.backend.global.common.annotation; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import ceos.backend.global.common.validator.QuestionOrderValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Documented +@Target({TYPE_USE, METHOD, FIELD, PARAMETER}) +@Retention(RUNTIME) +@Constraint(validatedBy = {QuestionOrderValidator.class}) +public @interface ValidQuestionOrder { + String message() default "올바른 순서를 입력해주세요"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/ceos/backend/global/common/annotation/ValidTimeDuration.java b/src/main/java/ceos/backend/global/common/annotation/ValidTimeDuration.java new file mode 100644 index 00000000..1a611fb4 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/annotation/ValidTimeDuration.java @@ -0,0 +1,23 @@ +package ceos.backend.global.common.annotation; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import ceos.backend.global.common.validator.TimeDurationValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Documented +@Target({TYPE_USE, METHOD, FIELD, PARAMETER}) +@Retention(RUNTIME) +@Constraint(validatedBy = {TimeDurationValidator.class}) +public @interface ValidTimeDuration { + String message() default "올바른 형식의 값을 입력해주세요"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/ceos/backend/global/common/dto/AwsS3Url.java b/src/main/java/ceos/backend/global/common/dto/AwsS3Url.java new file mode 100644 index 00000000..3e041ecf --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/AwsS3Url.java @@ -0,0 +1,19 @@ +package ceos.backend.global.common.dto; + + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class AwsS3Url { + private final String url; + + @Builder + private AwsS3Url(String url) { + this.url = url; + } + + public static AwsS3Url to(String url) { + return AwsS3Url.builder().url(url).build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/AwsSESMail.java b/src/main/java/ceos/backend/global/common/dto/AwsSESMail.java new file mode 100644 index 00000000..6c5b8de4 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/AwsSESMail.java @@ -0,0 +1,41 @@ +package ceos.backend.global.common.dto; + + +import ceos.backend.domain.application.domain.ApplicationQuestion; +import ceos.backend.domain.application.dto.request.CreateApplicationRequest; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class AwsSESMail { + private CreateApplicationRequest createApplicationRequest; + private List applicationQuestions; + private int generation; + private String UUID; + + @Builder + private AwsSESMail( + CreateApplicationRequest createApplicationRequest, + List applicationQuestions, + int generation, + String UUID) { + this.createApplicationRequest = createApplicationRequest; + this.applicationQuestions = applicationQuestions; + this.generation = generation; + this.UUID = UUID; + } + + public static AwsSESMail of( + CreateApplicationRequest createApplicationRequest, + List applicationQuestions, + int generation, + String UUID) { + return AwsSESMail.builder() + .createApplicationRequest(createApplicationRequest) + .applicationQuestions(applicationQuestions) + .generation(generation) + .UUID(UUID) + .build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/AwsSESPasswordMail.java b/src/main/java/ceos/backend/global/common/dto/AwsSESPasswordMail.java new file mode 100644 index 00000000..116aed96 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/AwsSESPasswordMail.java @@ -0,0 +1,23 @@ +package ceos.backend.global.common.dto; + + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class AwsSESPasswordMail { + private String email; + private String name; + private String randomPwd; + + @Builder + private AwsSESPasswordMail(String email, String name, String randomPwd) { + this.email = email; + this.name = name; + this.randomPwd = randomPwd; + } + + public static AwsSESPasswordMail of(String email, String name, String randomPwd) { + return AwsSESPasswordMail.builder().email(email).name(name).randomPwd(randomPwd).build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/ErrorReason.java b/src/main/java/ceos/backend/global/common/dto/ErrorReason.java new file mode 100644 index 00000000..82eaf5da --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/ErrorReason.java @@ -0,0 +1,23 @@ +package ceos.backend.global.common.dto; + + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ErrorReason { + private final Integer status; + private final String code; + private final String reason; + + @Builder + private ErrorReason(Integer status, String code, String reason) { + this.status = status; + this.code = code; + this.reason = reason; + } + + public static ErrorReason of(Integer status, String code, String reason) { + return ErrorReason.builder().status(status).code(code).reason(reason).build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/PageInfo.java b/src/main/java/ceos/backend/global/common/dto/PageInfo.java new file mode 100644 index 00000000..fb6f1c55 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/PageInfo.java @@ -0,0 +1,31 @@ +package ceos.backend.global.common.dto; + + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class PageInfo { + + private int pageNum; + private int limit; + private int totalPages; + private long totalElements; + + @Builder + private PageInfo(int pageNum, int limit, int totalPages, long totalElements) { + this.pageNum = pageNum; + this.limit = limit; + this.totalPages = totalPages; + this.totalElements = totalElements; + } + + public static PageInfo of(int pageNum, int limit, int totalPages, long totalElements) { + return PageInfo.builder() + .pageNum(pageNum) + .limit(limit) + .totalPages(totalPages) + .totalElements(totalElements) + .build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/ParsedDuration.java b/src/main/java/ceos/backend/global/common/dto/ParsedDuration.java new file mode 100644 index 00000000..bd1b0342 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/ParsedDuration.java @@ -0,0 +1,32 @@ +package ceos.backend.global.common.dto; + + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ParsedDuration { + @Schema(type = "string", defaultValue = "2023/03/20", description = "면접 날짜") + private String date; + + @Schema(type = "string", defaultValue = "12:00-12:30", description = "면접 시간") + private String duration; + + @Builder + private ParsedDuration(String date, String duration) { + this.date = date; + this.duration = duration; + } + + public static ParsedDuration of(String date, String duration) { + return ParsedDuration.builder().date(date).duration(duration).build(); + } + + public static ParsedDuration toNullParsedDuration() { + return ParsedDuration.builder().date(null).duration(null).build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/SlackErrorMessage.java b/src/main/java/ceos/backend/global/common/dto/SlackErrorMessage.java new file mode 100644 index 00000000..3a8f2e57 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/SlackErrorMessage.java @@ -0,0 +1,26 @@ +package ceos.backend.global.common.dto; + + +import lombok.Builder; +import lombok.Getter; +import org.springframework.web.util.ContentCachingRequestWrapper; + +@Getter +public class SlackErrorMessage { + private Exception exception; + private ContentCachingRequestWrapper contentCachingRequestWrapper; + + @Builder + private SlackErrorMessage( + Exception exception, ContentCachingRequestWrapper contentCachingRequestWrapper) { + this.exception = exception; + this.contentCachingRequestWrapper = contentCachingRequestWrapper; + } + + public static SlackErrorMessage of(Exception e, ContentCachingRequestWrapper requestWrapper) { + return SlackErrorMessage.builder() + .exception(e) + .contentCachingRequestWrapper(requestWrapper) + .build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/SlackUnavailableReason.java b/src/main/java/ceos/backend/global/common/dto/SlackUnavailableReason.java new file mode 100644 index 00000000..606873cc --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/SlackUnavailableReason.java @@ -0,0 +1,29 @@ +package ceos.backend.global.common.dto; + + +import ceos.backend.domain.application.domain.Application; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class SlackUnavailableReason { + private Application application; + private String reason; + private boolean isFinal; + + @Builder + private SlackUnavailableReason(Application application, String reason, boolean isFinal) { + this.application = application; + this.reason = reason; + this.isFinal = isFinal; + } + + public static SlackUnavailableReason of( + Application application, String reason, boolean isFinal) { + return SlackUnavailableReason.builder() + .application(application) + .reason(reason) + .isFinal(isFinal) + .build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/mail/CeosQuestionInfo.java b/src/main/java/ceos/backend/global/common/dto/mail/CeosQuestionInfo.java new file mode 100644 index 00000000..230267ad --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/mail/CeosQuestionInfo.java @@ -0,0 +1,29 @@ +package ceos.backend.global.common.dto.mail; + + +import ceos.backend.domain.application.dto.request.CreateApplicationRequest; +import java.time.format.DateTimeFormatter; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class CeosQuestionInfo { + private String otDate; + private String demodayDate; + private String otherActivities; + + @Builder + private CeosQuestionInfo(String otDate, String demodayDate, String otherActivities) { + this.otDate = otDate; + this.demodayDate = demodayDate; + this.otherActivities = otherActivities; + } + + public static CeosQuestionInfo from(CreateApplicationRequest request) { + return CeosQuestionInfo.builder() + .otDate(request.getOtDate().format(DateTimeFormatter.ISO_DATE)) + .demodayDate(request.getDemodayDate().format(DateTimeFormatter.ISO_DATE)) + .otherActivities(request.getOtherActivities()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/mail/CommonQuestionInfo.java b/src/main/java/ceos/backend/global/common/dto/mail/CommonQuestionInfo.java new file mode 100644 index 00000000..cc920fea --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/mail/CommonQuestionInfo.java @@ -0,0 +1,22 @@ +package ceos.backend.global.common.dto.mail; + + +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class CommonQuestionInfo { + private List questions; + private List answers; + + @Builder + private CommonQuestionInfo(List questions, List answers) { + this.questions = questions; + this.answers = answers; + } + + public static CommonQuestionInfo of(List questions, List answers) { + return CommonQuestionInfo.builder().questions(questions).answers(answers).build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/mail/GreetInfo.java b/src/main/java/ceos/backend/global/common/dto/mail/GreetInfo.java new file mode 100644 index 00000000..7b93e135 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/mail/GreetInfo.java @@ -0,0 +1,25 @@ +package ceos.backend.global.common.dto.mail; + + +import ceos.backend.domain.application.dto.request.CreateApplicationRequest; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class GreetInfo { + private String name; + private String generation; + + @Builder + private GreetInfo(String name, String generation) { + this.name = name; + this.generation = generation; + } + + public static GreetInfo of(CreateApplicationRequest request, int generation) { + return GreetInfo.builder() + .generation(Integer.toString(generation)) + .name(request.getApplicantInfoVo().getName()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/mail/InterviewDateInfo.java b/src/main/java/ceos/backend/global/common/dto/mail/InterviewDateInfo.java new file mode 100644 index 00000000..575cd104 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/mail/InterviewDateInfo.java @@ -0,0 +1,22 @@ +package ceos.backend.global.common.dto.mail; + + +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class InterviewDateInfo { + private List> notAvailabletime; + private List date; + + @Builder + private InterviewDateInfo(List> notAvailabletime, List date) { + this.notAvailabletime = notAvailabletime; + this.date = date; + } + + public static InterviewDateInfo of(List> notAvailabletime, List date) { + return InterviewDateInfo.builder().notAvailabletime(notAvailabletime).date(date).build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/mail/PartQuestionInfo.java b/src/main/java/ceos/backend/global/common/dto/mail/PartQuestionInfo.java new file mode 100644 index 00000000..f0e3b18b --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/mail/PartQuestionInfo.java @@ -0,0 +1,24 @@ +package ceos.backend.global.common.dto.mail; + + +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class PartQuestionInfo { + private String part; + private List questions; + private List answers; + + @Builder + private PartQuestionInfo(String part, List questions, List answers) { + this.part = part; + this.questions = questions; + this.answers = answers; + } + + public static PartQuestionInfo of(String part, List questions, List answers) { + return PartQuestionInfo.builder().part(part).questions(questions).answers(answers).build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/mail/PasswordInfo.java b/src/main/java/ceos/backend/global/common/dto/mail/PasswordInfo.java new file mode 100644 index 00000000..06e97846 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/mail/PasswordInfo.java @@ -0,0 +1,28 @@ +package ceos.backend.global.common.dto.mail; + + +import ceos.backend.global.common.dto.AwsSESPasswordMail; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class PasswordInfo { + private String email; + private String name; + private String randomPwd; + + @Builder + private PasswordInfo(String email, String name, String randomPwd) { + this.email = email; + this.name = name; + this.randomPwd = randomPwd; + } + + public static PasswordInfo from(AwsSESPasswordMail awsSESPasswordMail) { + return PasswordInfo.builder() + .email(awsSESPasswordMail.getEmail()) + .name(awsSESPasswordMail.getName()) + .randomPwd(awsSESPasswordMail.getRandomPwd()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/mail/PersonalInfo.java b/src/main/java/ceos/backend/global/common/dto/mail/PersonalInfo.java new file mode 100644 index 00000000..e8349cf7 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/mail/PersonalInfo.java @@ -0,0 +1,35 @@ +package ceos.backend.global.common.dto.mail; + + +import ceos.backend.domain.application.vo.ApplicantInfoVo; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class PersonalInfo { + private String name; + private String gender; + private String birth; + private String email; + private String phoneNumber; + + @Builder + private PersonalInfo( + String name, String gender, String birth, String email, String phoneNumber) { + this.name = name; + this.gender = gender; + this.birth = birth; + this.email = email; + this.phoneNumber = phoneNumber; + } + + public static PersonalInfo from(ApplicantInfoVo applicantInfoVo) { + return PersonalInfo.builder() + .name(applicantInfoVo.getName()) + .gender(applicantInfoVo.getGender().toString()) + .birth(applicantInfoVo.getBirth().toString()) + .email(applicantInfoVo.getEmail()) + .phoneNumber(applicantInfoVo.getPhoneNumber()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/mail/SchoolInfo.java b/src/main/java/ceos/backend/global/common/dto/mail/SchoolInfo.java new file mode 100644 index 00000000..7ed053cf --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/mail/SchoolInfo.java @@ -0,0 +1,28 @@ +package ceos.backend.global.common.dto.mail; + + +import ceos.backend.domain.application.vo.ApplicantInfoVo; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class SchoolInfo { + private String university; + private String major; + private String semestersLeftNumber; + + @Builder + private SchoolInfo(String university, String major, String semestersLeftNumber) { + this.university = university; + this.major = major; + this.semestersLeftNumber = semestersLeftNumber; + } + + public static SchoolInfo from(ApplicantInfoVo applicantInfoVo) { + return SchoolInfo.builder() + .university(applicantInfoVo.getUniversity().toString()) + .major(applicantInfoVo.getMajor()) + .semestersLeftNumber(Integer.toString(applicantInfoVo.getSemestersLeftNumber())) + .build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/dto/mail/UuidInfo.java b/src/main/java/ceos/backend/global/common/dto/mail/UuidInfo.java new file mode 100644 index 00000000..093433ba --- /dev/null +++ b/src/main/java/ceos/backend/global/common/dto/mail/UuidInfo.java @@ -0,0 +1,22 @@ +package ceos.backend.global.common.dto.mail; + + +import ceos.backend.domain.application.vo.ApplicantInfoVo; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class UuidInfo { + private String name; + private String uuid; + + @Builder + private UuidInfo(String name, String uuid) { + this.name = name; + this.uuid = uuid; + } + + public static UuidInfo of(ApplicantInfoVo applicantInfoVo, String UUID) { + return UuidInfo.builder().uuid(UUID).name(applicantInfoVo.getName()).build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/entity/BaseEntity.java b/src/main/java/ceos/backend/global/common/entity/BaseEntity.java new file mode 100644 index 00000000..c4485d71 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/entity/BaseEntity.java @@ -0,0 +1,22 @@ +package ceos.backend.global.common.entity; + + +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; + +@EntityListeners({AuditingEntityListener.class}) +@Getter +@MappedSuperclass +public class BaseEntity { + @CreatedDate + @Column(updatable = false) + private LocalDateTime created_at; + + @LastModifiedDate private LocalDateTime updated_at; +} diff --git a/src/main/java/ceos/backend/global/common/entity/Part.java b/src/main/java/ceos/backend/global/common/entity/Part.java new file mode 100644 index 00000000..a91e4488 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/entity/Part.java @@ -0,0 +1,27 @@ +package ceos.backend.global.common.entity; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Part { + PRODUCT("기획"), + DESIGN("디자인"), + FRONTEND("프론트엔드"), + BACKEND("백엔드"); + + @JsonValue private final String part; + + @JsonCreator + public static Part parsing(String inputValue) { + return Stream.of(Part.values()) + .filter(category -> category.getPart().equals(inputValue)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/ceos/backend/global/common/entity/University.java b/src/main/java/ceos/backend/global/common/entity/University.java new file mode 100644 index 00000000..f87de834 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/entity/University.java @@ -0,0 +1,28 @@ +package ceos.backend.global.common.entity; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum University { + SOGANG("서강대학교"), + YONSEI("연세대학교"), + EWHA("이화여자대학교"), + HONGIK("홍익대학교"), + NEWYORK("뉴욕대학교"); + + @JsonValue private final String university; + + @JsonCreator + public static University parsing(String inputValue) { + return Stream.of(University.values()) + .filter(category -> category.getUniversity().equals(inputValue)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/ceos/backend/global/common/event/Event.java b/src/main/java/ceos/backend/global/common/event/Event.java new file mode 100644 index 00000000..b99fe507 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/event/Event.java @@ -0,0 +1,18 @@ +package ceos.backend.global.common.event; + + +import org.springframework.context.ApplicationEventPublisher; + +public class Event { + private static ApplicationEventPublisher publisher; + + static void setPublisher(ApplicationEventPublisher publisher) { + Event.publisher = publisher; + } + + public static void raise(Object event) { + if (publisher != null) { + publisher.publishEvent(event); + } + } +} diff --git a/src/main/java/ceos/backend/global/common/event/EventConfig.java b/src/main/java/ceos/backend/global/common/event/EventConfig.java new file mode 100644 index 00000000..bba8f5a5 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/event/EventConfig.java @@ -0,0 +1,18 @@ +package ceos.backend.global.common.event; + + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class EventConfig { + @Autowired private ApplicationEventPublisher applicationEventPublisher; + + @Bean + public InitializingBean eventsInitializer() { + return () -> Event.setPublisher(applicationEventPublisher); + } +} diff --git a/src/main/java/ceos/backend/global/common/filter/AccessDeniedFilter.java b/src/main/java/ceos/backend/global/common/filter/AccessDeniedFilter.java new file mode 100644 index 00000000..65e98503 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/filter/AccessDeniedFilter.java @@ -0,0 +1,60 @@ +package ceos.backend.global.common.filter; + + +import ceos.backend.global.error.BaseErrorCode; +import ceos.backend.global.error.BaseErrorException; +import ceos.backend.global.error.ErrorResponse; +import ceos.backend.global.error.exception.ForbiddenAdmin; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Component; +import org.springframework.util.PatternMatchUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +@Component +public class AccessDeniedFilter extends OncePerRequestFilter { + private final ObjectMapper objectMapper; + private final String[] SwaggerPatterns = { + "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html" + }; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String servletPath = request.getServletPath(); + return PatternMatchUtils.simpleMatch(SwaggerPatterns, servletPath); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (BaseErrorException e) { + responseToClient(response, getErrorResponse(e.getErrorCode())); + } catch (AccessDeniedException e) { + responseToClient(response, getErrorResponse(ForbiddenAdmin.EXCEPTION.getErrorCode())); + } + } + + private ErrorResponse getErrorResponse(BaseErrorCode errorCode) { + + return ErrorResponse.from(errorCode.getErrorReason()); + } + + private void responseToClient(HttpServletResponse response, ErrorResponse errorResponse) + throws IOException { + response.setCharacterEncoding("UTF-8"); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(Integer.parseInt(errorResponse.getStatus())); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/ceos/backend/global/common/filter/MultiReadInputStream.java b/src/main/java/ceos/backend/global/common/filter/MultiReadInputStream.java new file mode 100644 index 00000000..e9acf9be --- /dev/null +++ b/src/main/java/ceos/backend/global/common/filter/MultiReadInputStream.java @@ -0,0 +1,61 @@ +package ceos.backend.global.common.filter; + + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import java.io.*; +import org.apache.tomcat.util.http.fileupload.IOUtils; + +public class MultiReadInputStream extends HttpServletRequestWrapper { + private ByteArrayOutputStream cachedBytes; + + public MultiReadInputStream(HttpServletRequest request) { + super(request); + } + + @Override + public ServletInputStream getInputStream() throws IOException { + if (cachedBytes == null) cacheInputStream(); + return new CachedServletInputStream(cachedBytes.toByteArray()); + } + + @Override + public BufferedReader getReader() throws IOException { + return new BufferedReader(new InputStreamReader(getInputStream())); + } + + private void cacheInputStream() throws IOException { + cachedBytes = new ByteArrayOutputStream(); + IOUtils.copy(super.getInputStream(), cachedBytes); + } + + private static class CachedServletInputStream extends ServletInputStream { + private final ByteArrayInputStream buffer; + + public CachedServletInputStream(byte[] contents) { + this.buffer = new ByteArrayInputStream(contents); + } + + @Override + public int read() { + return buffer.read(); + } + + @Override + public boolean isFinished() { + return buffer.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener listener) { + throw new RuntimeException("Not implemented"); + } + } +} diff --git a/src/main/java/ceos/backend/global/common/filter/MultiReadInputStreamFilter.java b/src/main/java/ceos/backend/global/common/filter/MultiReadInputStreamFilter.java new file mode 100644 index 00000000..83e8fbd2 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/filter/MultiReadInputStreamFilter.java @@ -0,0 +1,28 @@ +package ceos.backend.global.common.filter; + + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import org.springframework.stereotype.Component; + +@Component +public class MultiReadInputStreamFilter implements Filter { + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + MultiReadInputStream multiReadRequest = + new MultiReadInputStream((HttpServletRequest) request); + chain.doFilter(multiReadRequest, response); + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + Filter.super.init(filterConfig); + } + + @Override + public void destroy() { + Filter.super.destroy(); + } +} diff --git a/src/main/java/ceos/backend/global/common/helper/SpringEnvironmentHelper.java b/src/main/java/ceos/backend/global/common/helper/SpringEnvironmentHelper.java new file mode 100644 index 00000000..d6233f6f --- /dev/null +++ b/src/main/java/ceos/backend/global/common/helper/SpringEnvironmentHelper.java @@ -0,0 +1,37 @@ +package ceos.backend.global.common.helper; + + +import java.util.Arrays; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SpringEnvironmentHelper { + private final Environment environment; + + private final String PROD = "prod"; + private final String DEV = "dev"; + + public Boolean isProdProfile() { + List currentProfile = getCurrentProfile(); + return currentProfile.contains(PROD); + } + + public Boolean isDevProfile() { + List currentProfile = getCurrentProfile(); + return currentProfile.contains(DEV); + } + + public Boolean isProdAndDevProfile() { + List currentProfile = getCurrentProfile(); + return currentProfile.contains(PROD) || currentProfile.contains(DEV); + } + + private List getCurrentProfile() { + String[] activeProfiles = environment.getActiveProfiles(); + return Arrays.stream(activeProfiles).toList(); + } +} diff --git a/src/main/java/ceos/backend/global/common/response/ResponseAdvicer.java b/src/main/java/ceos/backend/global/common/response/ResponseAdvicer.java new file mode 100644 index 00000000..f98a3c43 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/response/ResponseAdvicer.java @@ -0,0 +1,66 @@ +package ceos.backend.global.common.response; + + +import jakarta.annotation.Nullable; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; +import org.springframework.web.util.ContentCachingRequestWrapper; + +/** status code가 200번일 경우, response 형식을 변환해줍니다. */ +@Slf4j +@RestControllerAdvice +public class ResponseAdvicer implements ResponseBodyAdvice { + private final String[] EscapePatterns = {"/v3/api-docs", "/applications/file/download"}; + + @Override + public boolean supports( + MethodParameter returnType, Class> converterType) { + return true; + } + + @Nullable + public Object beforeBodyWrite( + Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class selectedConverterType, + ServerHttpRequest request, + ServerHttpResponse response) { + HttpServletResponse servletResponse = + ((ServletServerHttpResponse) response).getServletResponse(); + // httpservletRequest에서 값을 사용할 경우, 그 값이 변경되므로 wrapper로 감싸서 사용해야합니다. + // 추후 재사용을 위해 사용했습니다. + ContentCachingRequestWrapper servletRequest = + new ContentCachingRequestWrapper( + ((ServletServerHttpRequest) request).getServletRequest()); + + for (String escapePattern : EscapePatterns) { + if (servletRequest.getRequestURL().toString().contains(escapePattern)) return body; + } + + HttpStatus resolve = HttpStatus.resolve(servletResponse.getStatus()); + + if (resolve == null) return body; + + if (resolve.is2xxSuccessful()) + return SuccessResponse.onSuccess(statusProvider(servletRequest.getMethod()), body); + + return body; + } + + private int statusProvider(String method) { + if (method.equals("POST")) return 201; + if (method.equals("DELETE")) return 204; + return 200; + } +} diff --git a/src/main/java/ceos/backend/global/common/response/SuccessResponse.java b/src/main/java/ceos/backend/global/common/response/SuccessResponse.java new file mode 100644 index 00000000..6f18702b --- /dev/null +++ b/src/main/java/ceos/backend/global/common/response/SuccessResponse.java @@ -0,0 +1,36 @@ +package ceos.backend.global.common.response; + + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Getter; + +/** api 공통 응답 형식입니다 */ +@Getter +public class SuccessResponse { + @JsonProperty("status") + private int code; + + @JsonProperty("message") + private String message; + + @JsonProperty("data") + @JsonInclude(JsonInclude.Include.NON_NULL) + private T data; + + @Builder + private SuccessResponse(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + public static SuccessResponse onSuccess(int code) { + return SuccessResponse.builder().code(code).message("요청에 성공하였습니다.").data(null).build(); + } + + public static SuccessResponse onSuccess(int code, T data) { + return SuccessResponse.builder().code(code).message("요청에 성공하였습니다.").data(data).build(); + } +} diff --git a/src/main/java/ceos/backend/global/common/validator/DateListValidator.java b/src/main/java/ceos/backend/global/common/validator/DateListValidator.java new file mode 100644 index 00000000..4a2291b7 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/validator/DateListValidator.java @@ -0,0 +1,22 @@ +package ceos.backend.global.common.validator; + + +import ceos.backend.global.common.annotation.ValidDateList; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.List; +import java.util.regex.Pattern; + +public class DateListValidator implements ConstraintValidator> { + private final String pattern = + "^\\d{4}([.]{1})(0[1-9]|1[012])([.]{1})(0[1-9]|[12][0-9]|3[01])$"; + + @Override + public boolean isValid(List values, ConstraintValidatorContext context) { + if (values == null) return false; + for (String value : values) { + if (!Pattern.matches(pattern, value)) return false; + } + return values.stream().distinct().count() == values.size(); + } +} diff --git a/src/main/java/ceos/backend/global/common/validator/DurationValidator.java b/src/main/java/ceos/backend/global/common/validator/DurationValidator.java new file mode 100644 index 00000000..95b29a50 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/validator/DurationValidator.java @@ -0,0 +1,18 @@ +package ceos.backend.global.common.validator; + + +import ceos.backend.global.common.annotation.ValidDuration; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.regex.Pattern; + +public class DurationValidator implements ConstraintValidator { + private final String pattern = + "^\\d{4}([.]{1})(0[1-9]|1[012])([.]{1})(0[1-9]|[12][0-9]|3[01])([ ]{1})(0[0-9]|1[012])([:]{1})([0-5][0-9])([:]{1})([0-5][0-9])([ ]{1})([-]{1})([ ]{1})\\d{4}([.]{1})(0[1-9]|1[012])([.]{1})(0[1-9]|[12][0-9]|3[01])([ ]{1})(0[0-9]|1[012])([:]{1})([0-5][0-9])([:]{1})([0-5][0-9])$"; + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) return false; + return Pattern.matches(pattern, value); + } +} diff --git a/src/main/java/ceos/backend/global/common/validator/EmailValidator.java b/src/main/java/ceos/backend/global/common/validator/EmailValidator.java new file mode 100644 index 00000000..e760da12 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/validator/EmailValidator.java @@ -0,0 +1,18 @@ +package ceos.backend.global.common.validator; + + +import ceos.backend.global.common.annotation.ValidEmail; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.regex.Pattern; + +public class EmailValidator implements ConstraintValidator { + private final String pattern = + "^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]*$"; + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) return false; + return Pattern.matches(pattern, value); + } +} diff --git a/src/main/java/ceos/backend/global/common/validator/EnumValidator.java b/src/main/java/ceos/backend/global/common/validator/EnumValidator.java new file mode 100644 index 00000000..3c4eca87 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/validator/EnumValidator.java @@ -0,0 +1,32 @@ +package ceos.backend.global.common.validator; + + +import ceos.backend.global.common.annotation.ValidEnum; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class EnumValidator implements ConstraintValidator { + private ValidEnum annotation; + + @Override + public void initialize(ValidEnum constraintAnnotation) { + this.annotation = constraintAnnotation; + } + + @Override + public boolean isValid(Enum value, ConstraintValidatorContext context) { + if (value == null) return false; + + Object[] enumValues = this.annotation.target().getEnumConstants(); + if (enumValues != null) { + for (Object enumValue : enumValues) { + if (value.toString().equals(enumValue.toString())) { + return true; + } + } + } + return false; + } +} diff --git a/src/main/java/ceos/backend/global/common/validator/PhoneValidator.java b/src/main/java/ceos/backend/global/common/validator/PhoneValidator.java new file mode 100644 index 00000000..225e817c --- /dev/null +++ b/src/main/java/ceos/backend/global/common/validator/PhoneValidator.java @@ -0,0 +1,17 @@ +package ceos.backend.global.common.validator; + + +import ceos.backend.global.common.annotation.ValidPhone; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.regex.Pattern; + +public class PhoneValidator implements ConstraintValidator { + private final String pattern = "^\\d{3}-\\d{3,4}-\\d{4}$"; + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) return false; + return Pattern.matches(pattern, value); + } +} diff --git a/src/main/java/ceos/backend/global/common/validator/QuestionOrderValidator.java b/src/main/java/ceos/backend/global/common/validator/QuestionOrderValidator.java new file mode 100644 index 00000000..3550a652 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/validator/QuestionOrderValidator.java @@ -0,0 +1,22 @@ +package ceos.backend.global.common.validator; + + +import ceos.backend.domain.application.vo.QuestionVo; +import ceos.backend.global.common.annotation.ValidQuestionOrder; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.List; + +public class QuestionOrderValidator + implements ConstraintValidator> { + @Override + public boolean isValid(List values, ConstraintValidatorContext context) { + if (values == null) return false; + for (int i = 1; i <= values.size(); i++) { + if (values.get(i - 1).getQuestionIndex() != i) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/ceos/backend/global/common/validator/TimeDurationValidator.java b/src/main/java/ceos/backend/global/common/validator/TimeDurationValidator.java new file mode 100644 index 00000000..3844fca0 --- /dev/null +++ b/src/main/java/ceos/backend/global/common/validator/TimeDurationValidator.java @@ -0,0 +1,18 @@ +package ceos.backend.global.common.validator; + + +import ceos.backend.global.common.annotation.ValidTimeDuration; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.regex.Pattern; + +public class TimeDurationValidator implements ConstraintValidator { + private final String pattern = + "(0[0-9]|1[012])([:]{1})([0-5][0-9])([:]{1})([0-5][0-9])([ ]{1})([-]{1})([ ]{1})(0[0-9]|1[012])([:]{1})([0-5][0-9])([:]{1})([0-5][0-9])"; + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) return false; + return Pattern.matches(pattern, value); + } +} diff --git a/src/main/java/ceos/backend/global/config/OpenApiConfig.java b/src/main/java/ceos/backend/global/config/OpenApiConfig.java new file mode 100644 index 00000000..ac594639 --- /dev/null +++ b/src/main/java/ceos/backend/global/config/OpenApiConfig.java @@ -0,0 +1,59 @@ +package ceos.backend.global.config; + + +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.Arrays; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Value("${server.url}") + private String SERVER_URL; + + @Bean + public OpenAPI openAPI(@Value("${springdoc.version}") String springdocVersion) { + Info info = + new Info() + .title("CEOS WEB API") + .version(springdocVersion) + .description("CEOS WEB API 입니다."); + + // JWT 설정 + String jwtSchemeName = "jwtAuth"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + Components components = + new Components() + .addSecuritySchemes( + jwtSchemeName, + new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) // HTTP 방식 + .scheme("bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .servers(Arrays.asList(new Server().url(SERVER_URL))) + .info(info) + .addSecurityItem(securityRequirement) + .components(components); + } + + // 공개용 + @Bean + public GroupedOpenApi api() { + return GroupedOpenApi.builder() + .group("ceos") + .packagesToScan("ceos.backend") + .pathsToMatch("/**") + .build(); + } +} diff --git a/src/main/java/ceos/backend/global/config/RedisConfig.java b/src/main/java/ceos/backend/global/config/RedisConfig.java new file mode 100644 index 00000000..a1570ec5 --- /dev/null +++ b/src/main/java/ceos/backend/global/config/RedisConfig.java @@ -0,0 +1,38 @@ +package ceos.backend.global.config; + + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.host}") + private String host; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + // redisTemplate를 받아와서 set, get, delete를 사용 + RedisTemplate redisTemplate = new RedisTemplate<>(); + // setKeySerializer, setValueSerializer 설정 + // redis-cli을 통해 직접 데이터를 조회 시 알아볼 수 없는 형태로 출력되는 것을 방지 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + return redisTemplate; + } +} diff --git a/src/main/java/ceos/backend/global/config/WebSecurityConfig.java b/src/main/java/ceos/backend/global/config/WebSecurityConfig.java new file mode 100644 index 00000000..0de5b0a4 --- /dev/null +++ b/src/main/java/ceos/backend/global/config/WebSecurityConfig.java @@ -0,0 +1,199 @@ +package ceos.backend.global.config; + + +import ceos.backend.global.common.filter.AccessDeniedFilter; +import ceos.backend.global.common.helper.SpringEnvironmentHelper; +import ceos.backend.global.config.jwt.JwtAccessDeniedHandler; +import ceos.backend.global.config.jwt.JwtAuthenticationEntryPoint; +import ceos.backend.global.config.jwt.JwtAuthenticationFilter; +import ceos.backend.global.config.jwt.JwtExceptionHandlerFilter; +import java.util.Arrays; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +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.core.userdetails.UserDetailsService; +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.access.intercept.AuthorizationFilter; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@EnableWebSecurity +@Configuration() +@ConditionalOnDefaultWebSecurity +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@RequiredArgsConstructor +public class WebSecurityConfig { + + @Value("${server.url}") + private String SERVER_URL; + + @Value("${server.user_url}") + private String USER_URL; + + @Value("${server.admin_url}") + private String ADMIN_URL; + + @Value("${swagger.user}") + private String swaggerUser; + + @Value("${swagger.pwd}") + private String swaggerPassword; + + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtExceptionHandlerFilter jwtExceptionHandlerFilter; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final SpringEnvironmentHelper springEnvironmentHelper; + private final AccessDeniedFilter accessDeniedFilter; + + private final String[] SwaggerPatterns = { + "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html" + }; + + private final String[] AdminPatterns = { + "/admin/newpassword", + "/admin/logout", + "/applications/**", + "/recruitments/**", + "/projects/**", + "/activities/**", + "/awards/**", + "/managements/**", + "/faq/**", + "/sponsors/**" + }; + + private final String[] GetPermittedPatterns = { + "/awards/**", + "/recruitments/**", + "/projects/**", + "/activities/**", + "/managements/**", + "/faq/**", + "/sponsors/**", + "/applications/question", + "/applications/document", + "/applications/final", + "/admin/reissue" + }; + + private final String[] PostPermittedPatterns = {"/applications"}; + + private final String[] PatchPermittedPatterns = { + "/applications/interview", "/applications/pass" + }; + + private final String[] RootPatterns = {"/admin/super"}; + + @Bean + public UserDetailsService userDetailsService() { + UserDetails user = + User.withUsername(swaggerUser) + .password(passwordEncoder().encode(swaggerPassword)) + .roles("SWAGGER") + .build(); + return new InMemoryUserDetailsManager(user); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(8); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.cors() + .configurationSource(corsConfigurationSource()) + .and() + .csrf() + .disable() + .headers() + .frameOptions() + .sameOrigin() + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); + + if (springEnvironmentHelper.isProdAndDevProfile()) { + http.authorizeHttpRequests() + .requestMatchers(SwaggerPatterns) + .authenticated() + .and() + .httpBasic(Customizer.withDefaults()); + } + + http.authorizeHttpRequests() + .requestMatchers(CorsUtils::isPreFlightRequest) + .permitAll() + .requestMatchers(HttpMethod.GET, GetPermittedPatterns) + .permitAll() + .requestMatchers(HttpMethod.PATCH, PatchPermittedPatterns) + .permitAll() + .requestMatchers(HttpMethod.POST, PostPermittedPatterns) + .permitAll() + .requestMatchers(SwaggerPatterns) + .permitAll() + .requestMatchers(AdminPatterns) + .hasAnyRole("ROOT", "ADMIN") + .requestMatchers(RootPatterns) + .hasRole("ROOT") + .anyRequest() + .permitAll() + .and() + .headers() + .frameOptions() + .disable(); + + http.exceptionHandling().accessDeniedHandler(jwtAccessDeniedHandler); + // .authenticationEntryPoint(jwtAuthenticationEntryPoint); + + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(jwtExceptionHandlerFilter, JwtAuthenticationFilter.class); + http.addFilterBefore(accessDeniedFilter, AuthorizationFilter.class); + + return http.build(); + } + + // @Bean + protected CorsConfigurationSource corsConfigurationSource() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", getDefaultCorsConfiguration()); + + return source; + } + + private CorsConfiguration getDefaultCorsConfiguration() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins( + Arrays.asList( + "http://localhost:8080", + "http://localhost:3000", + "http://localhost:3001", + USER_URL, + ADMIN_URL, + SERVER_URL)); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowedMethods(List.of("*")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + return configuration; + } +} diff --git a/src/main/java/ceos/backend/global/config/jwt/JwtAccessDeniedHandler.java b/src/main/java/ceos/backend/global/config/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..288845e4 --- /dev/null +++ b/src/main/java/ceos/backend/global/config/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,44 @@ +package ceos.backend.global.config.jwt; + + +import ceos.backend.global.error.BaseErrorCode; +import ceos.backend.global.error.ErrorResponse; +import ceos.backend.global.error.exception.ForbiddenAdmin; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) + throws IOException { + responseToClient(response, getErrorResponse(ForbiddenAdmin.EXCEPTION.getErrorCode())); + } + + private ErrorResponse getErrorResponse(BaseErrorCode errorCode) { + + return ErrorResponse.from(errorCode.getErrorReason()); + } + + private void responseToClient(HttpServletResponse response, ErrorResponse errorResponse) + throws IOException { + response.setCharacterEncoding("UTF-8"); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(Integer.parseInt(errorResponse.getStatus())); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/ceos/backend/global/config/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/ceos/backend/global/config/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..5730c038 --- /dev/null +++ b/src/main/java/ceos/backend/global/config/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,45 @@ +package ceos.backend.global.config.jwt; + + +import ceos.backend.global.error.BaseErrorCode; +import ceos.backend.global.error.ErrorResponse; +import ceos.backend.global.error.exception.NoTokenException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) + throws IOException { + + responseToClient(response, getErrorResponse(NoTokenException.EXCEPTION.getErrorCode())); + } + + private ErrorResponse getErrorResponse(BaseErrorCode errorCode) { + + return ErrorResponse.from(errorCode.getErrorReason()); + } + + private void responseToClient(HttpServletResponse response, ErrorResponse errorResponse) + throws IOException { + response.setCharacterEncoding("UTF-8"); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(Integer.parseInt(errorResponse.getStatus())); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/ceos/backend/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/ceos/backend/global/config/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..5f2e1911 --- /dev/null +++ b/src/main/java/ceos/backend/global/config/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,42 @@ +package ceos.backend.global.config.jwt; + + +import ceos.backend.global.error.exception.TokenValidateException; +import io.micrometer.common.util.StringUtils; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final TokenProvider tokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String token = tokenProvider.getAccessToken(request); + + if (token == null) { + filterChain.doFilter(request, response); + return; + } + if (StringUtils.isNotBlank(token) && tokenProvider.validateAccessToken(token)) { + Authentication authentication = tokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } else { + throw TokenValidateException.EXCEPTION; + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/ceos/backend/global/config/jwt/JwtExceptionHandlerFilter.java b/src/main/java/ceos/backend/global/config/jwt/JwtExceptionHandlerFilter.java new file mode 100644 index 00000000..070e3fcf --- /dev/null +++ b/src/main/java/ceos/backend/global/config/jwt/JwtExceptionHandlerFilter.java @@ -0,0 +1,47 @@ +package ceos.backend.global.config.jwt; + + +import ceos.backend.global.error.BaseErrorCode; +import ceos.backend.global.error.ErrorResponse; +import ceos.backend.global.error.exception.TokenValidateException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +@Component +public class JwtExceptionHandlerFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (TokenValidateException e) { + responseToClient(response, getErrorResponse(e.getErrorCode())); + } + } + + private ErrorResponse getErrorResponse(BaseErrorCode errorCode) { + + return ErrorResponse.from(errorCode.getErrorReason()); + } + + private void responseToClient(HttpServletResponse response, ErrorResponse errorResponse) + throws IOException { + response.setCharacterEncoding("UTF-8"); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(Integer.parseInt(errorResponse.getStatus())); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/ceos/backend/global/config/jwt/TokenProvider.java b/src/main/java/ceos/backend/global/config/jwt/TokenProvider.java new file mode 100644 index 00000000..d9f7ca90 --- /dev/null +++ b/src/main/java/ceos/backend/global/config/jwt/TokenProvider.java @@ -0,0 +1,162 @@ +package ceos.backend.global.config.jwt; + + +import ceos.backend.domain.admin.exception.NotRefreshToken; +import ceos.backend.global.config.user.AdminDetails; +import ceos.backend.global.config.user.AdminDetailsService; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import java.security.Key; +import java.util.Calendar; +import java.util.Date; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class TokenProvider implements InitializingBean { + + private final RedisTemplate redisTemplate; + private final AdminDetailsService adminDetailsService; + private static final String AUTHORITIES_KEY = "auth"; + private static final String ACCESS_KEY = "access"; + private static final String REFRESH_KEY = "refresh"; + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.accesstoken-validity-in-seconds}") + private int accessExpirationTime; + + @Value("${jwt.refreshtoken-validity-in-seconds}") + private int refreshExpirationTime; + + private Key key; + + @Override + public void afterPropertiesSet() { + byte[] keyBytes = Decoders.BASE64.decode(secret); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String getAccessToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring("Bearer ".length()); + } + return null; + } + + public String createAccessToken(Long id, Authentication authentication) { + String authorities = + authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.SECOND, accessExpirationTime); // 만료일 하루 + + final Date issuedAt = new Date(); + final Date validity = new Date(cal.getTimeInMillis()); + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setSubject(id.toString()) + .claim(AUTHORITIES_KEY, authorities) + .claim("type", ACCESS_KEY) + .setIssuedAt(issuedAt) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS512) + .compact(); + } + + public String createRefreshToken(Long id, Authentication authentication) { + String authorities = + authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.SECOND, refreshExpirationTime); // 만료일 14일 + + final Date issuedAt = new Date(); + final Date validity = new Date(cal.getTimeInMillis()); + + String refreshToken = + Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setSubject(id.toString()) + .claim(AUTHORITIES_KEY, authorities) + .claim("type", REFRESH_KEY) + .setIssuedAt(issuedAt) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS512) + .compact(); + + redisTemplate + .opsForValue() + .set(id.toString(), refreshToken, refreshExpirationTime, TimeUnit.SECONDS); + + return refreshToken; + } + + public void deleteRefreshToken(Long id) { + redisTemplate.delete(id.toString()); + } + + public String getTokenUserId(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + public Authentication getAuthentication(String token) { + AdminDetails adminDetails = + (AdminDetails) + adminDetailsService.loadAdminByUsername( + Long.parseLong(getTokenUserId(token))); + return new UsernamePasswordAuthenticationToken( + adminDetails, token, adminDetails.getAuthorities()); + } + + public boolean validateAccessToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.info("잘못된 JWT 서명입니다."); + } catch (ExpiredJwtException e) { + log.info(e.toString()); + log.info("만료된 JWT 토큰입니다."); + } catch (UnsupportedJwtException e) { + log.info("지원되지 않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { + log.info("JWT 토큰이 잘못되었습니다."); + } + return false; + } + + public void validateRefreshToken(String token) { + Claims claims = + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); + String typeValue = claims.get("type", String.class); + if (!typeValue.equals("refresh")) { + throw NotRefreshToken.EXCEPTION; + } + } +} diff --git a/src/main/java/ceos/backend/global/config/user/AdminDetails.java b/src/main/java/ceos/backend/global/config/user/AdminDetails.java new file mode 100644 index 00000000..4fe2894a --- /dev/null +++ b/src/main/java/ceos/backend/global/config/user/AdminDetails.java @@ -0,0 +1,58 @@ +package ceos.backend.global.config.user; + + +import ceos.backend.domain.admin.domain.Admin; +import java.util.ArrayList; +import java.util.Collection; +import lombok.Data; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@Data +public class AdminDetails implements UserDetails { + + private Admin admin; + + public AdminDetails(Admin admin) { + this.admin = admin; + } + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + // collection.add((GrantedAuthority) () -> String.valueOf(admin.getRole())); + collection.add((GrantedAuthority) () -> String.valueOf(admin.getRole())); + + return collection; + } + + @Override + public String getPassword() { + return admin.getPassword(); + } + + @Override + public String getUsername() { + return null; + } + + @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/ceos/backend/global/config/user/AdminDetailsService.java b/src/main/java/ceos/backend/global/config/user/AdminDetailsService.java new file mode 100644 index 00000000..97d4089c --- /dev/null +++ b/src/main/java/ceos/backend/global/config/user/AdminDetailsService.java @@ -0,0 +1,29 @@ +package ceos.backend.global.config.user; + + +import ceos.backend.domain.admin.domain.Admin; +import ceos.backend.domain.admin.repository.AdminRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component("adminDetailsService") +@RequiredArgsConstructor +public class AdminDetailsService { + + public final AdminRepository adminRepository; + + @Transactional + public UserDetails loadAdminByUsername(Long id) throws UsernameNotFoundException { + return adminRepository + .findById(id) + .map(admin -> createAdmin(id, admin)) + .orElseThrow(() -> new UsernameNotFoundException(id + " -> DB에서 찾을 수 없음")); + } + + private AdminDetails createAdmin(Long id, Admin admin) { + return new AdminDetails(admin); + } +} diff --git a/src/main/java/ceos/backend/global/error/BaseErrorCode.java b/src/main/java/ceos/backend/global/error/BaseErrorCode.java new file mode 100644 index 00000000..ce45c6c1 --- /dev/null +++ b/src/main/java/ceos/backend/global/error/BaseErrorCode.java @@ -0,0 +1,8 @@ +package ceos.backend.global.error; + + +import ceos.backend.global.common.dto.ErrorReason; + +public interface BaseErrorCode { + public ErrorReason getErrorReason(); +} diff --git a/src/main/java/ceos/backend/global/error/BaseErrorException.java b/src/main/java/ceos/backend/global/error/BaseErrorException.java new file mode 100644 index 00000000..166a5476 --- /dev/null +++ b/src/main/java/ceos/backend/global/error/BaseErrorException.java @@ -0,0 +1,16 @@ +package ceos.backend.global.error; + + +import ceos.backend.global.common.dto.ErrorReason; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class BaseErrorException extends RuntimeException { + private BaseErrorCode errorCode; + + public ErrorReason getErrorReason() { + return this.errorCode.getErrorReason(); + } +} diff --git a/src/main/java/ceos/backend/global/error/ErrorResponse.java b/src/main/java/ceos/backend/global/error/ErrorResponse.java new file mode 100644 index 00000000..983a7c39 --- /dev/null +++ b/src/main/java/ceos/backend/global/error/ErrorResponse.java @@ -0,0 +1,32 @@ +package ceos.backend.global.error; + + +import ceos.backend.global.common.dto.ErrorReason; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ErrorResponse { + private final String timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME); + private final boolean success = false; + private String code; + private String status; + private String reason; + + @Builder + private ErrorResponse(String code, String status, String reason) { + this.code = code; + this.status = status; + this.reason = reason; + } + + public static ErrorResponse from(ErrorReason errorReason) { + return ErrorResponse.builder() + .code(errorReason.getCode()) + .status(errorReason.getStatus().toString()) + .reason(errorReason.getReason()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/global/error/GlobalErrorCode.java b/src/main/java/ceos/backend/global/error/GlobalErrorCode.java new file mode 100644 index 00000000..c7c05382 --- /dev/null +++ b/src/main/java/ceos/backend/global/error/GlobalErrorCode.java @@ -0,0 +1,38 @@ +package ceos.backend.global.error; + +import static org.springframework.http.HttpStatus.*; + +import ceos.backend.global.common.dto.ErrorReason; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum GlobalErrorCode implements BaseErrorCode { + + /* global error */ + HTTP_MESSAGE_NOT_READABLE(BAD_REQUEST, "GLOBAL_400_1", "잘못된 형식의 값을 입력했습니다."), + _INTERNAL_SERVER_ERROR(INTERNAL_SERVER_ERROR, "GLOBAL_500_1", "서버 오류. 관리자에게 문의 부탁드립니다."), + + /*토큰 에러*/ + NO_TOKEN(UNAUTHORIZED, "AUTH_401_1", "토큰이 존재하지 않습니다"), + INVALID_AUTH_TOKEN(UNAUTHORIZED, "AUTH_401_2", "액세스 토큰이 유효하지 않습니다"), + EXPIRED_TOKEN(UNAUTHORIZED, "AUTH_401_3", "만료된 엑세스 토큰입니다"), + INVALID_REFRESH_TOKEN(UNAUTHORIZED, "AUTH_401_4", "리프레시 토큰이 유효하지 않습니다"), + EXPIRED_REFRESH_TOKEN(UNAUTHORIZED, "AUTH_401_5", "만료된 리프레시 토큰입니다"), + MISMATCH_REFRESH_TOKEN(UNAUTHORIZED, "AUTH_401_6", "리프레시 토큰의 유저 정보가 일치하지 않습니다"), + FORBIDDEN_ADMIN(FORBIDDEN, "AUTH_403_1", "권한이 부여되지 않은 사용자입니다"), + UNSUPPORTED_TOKEN(UNAUTHORIZED, "AUTH_401_7", "지원하지 않는 토큰입니다"), + INVALID_SIGNATURE(UNAUTHORIZED, "AUTH_401_8", "잘못된 JWT 서명입니다"), + ; + + private HttpStatus status; + private String code; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/ceos/backend/global/error/GlobalExceptionHandler.java b/src/main/java/ceos/backend/global/error/GlobalExceptionHandler.java new file mode 100644 index 00000000..10968525 --- /dev/null +++ b/src/main/java/ceos/backend/global/error/GlobalExceptionHandler.java @@ -0,0 +1,109 @@ +package ceos.backend.global.error; + +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +import ceos.backend.global.common.dto.ErrorReason; +import ceos.backend.global.common.dto.SlackErrorMessage; +import ceos.backend.global.common.event.Event; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.lang.Nullable; +import org.springframework.validation.FieldError; +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; +import org.springframework.web.util.ContentCachingRequestWrapper; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + @Override + protected ResponseEntity handleExceptionInternal( + Exception ex, + @Nullable Object body, + HttpHeaders headers, + HttpStatusCode statusCode, + WebRequest request) { + log.error("HandleInternalException", ex); + final HttpStatus status = (HttpStatus) statusCode; + final ErrorReason errorReason = + ErrorReason.of(status.value(), status.name(), ex.getMessage()); + final ErrorResponse errorResponse = ErrorResponse.from(errorReason); + return super.handleExceptionInternal(ex, errorResponse, headers, status, request); + } + + @Nullable + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + final HttpStatus httpStatus = (HttpStatus) status; + final List errors = ex.getBindingResult().getFieldErrors(); + final Map fieldAndErrorMessages = + errors.stream() + .collect( + Collectors.toMap( + FieldError::getField, FieldError::getDefaultMessage)); + final String errorsToJsonString = + fieldAndErrorMessages.entrySet().stream() + .map(e -> e.getKey() + " : " + e.getValue()) + .collect(Collectors.joining("|")); + final ErrorReason errorReason = + ErrorReason.of(status.value(), httpStatus.name(), errorsToJsonString); + final ErrorResponse errorResponse = ErrorResponse.from(errorReason); + return ResponseEntity.status(HttpStatus.valueOf(errorReason.getStatus())) + .body(errorResponse); + } + + @Nullable + @Override + protected ResponseEntity handleHttpMessageNotReadable( + HttpMessageNotReadableException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + log.error("HttpMessageNotReadableException", ex); + final GlobalErrorCode globalErrorCode = GlobalErrorCode.HTTP_MESSAGE_NOT_READABLE; + final ErrorReason errorReason = globalErrorCode.getErrorReason(); + final ErrorResponse errorResponse = ErrorResponse.from(errorReason); + return ResponseEntity.status(INTERNAL_SERVER_ERROR).body(errorResponse); + } + + // 비즈니스 로직 에러 처리 + @ExceptionHandler(BaseErrorException.class) + public ResponseEntity handleBaseErrorException( + BaseErrorException e, HttpServletRequest request) { + log.error("BaseErrorException", e); + final ErrorReason errorReason = e.getErrorCode().getErrorReason(); + final ErrorResponse errorResponse = ErrorResponse.from(errorReason); + return ResponseEntity.status(HttpStatus.valueOf(errorReason.getStatus())) + .body(errorResponse); + } + + // 위에서 따로 처리하지 않은 에러를 모두 처리해줍니다. + @ExceptionHandler(Exception.class) + protected ResponseEntity handleException( + Exception e, HttpServletRequest request) { + log.error("Exception", e); + + // 슬랙 에러 알림 + final ContentCachingRequestWrapper cachingRequest = + new ContentCachingRequestWrapper(request); + final SlackErrorMessage errorMessage = SlackErrorMessage.of(e, cachingRequest); + Event.raise(errorMessage); + + final GlobalErrorCode globalErrorCode = GlobalErrorCode._INTERNAL_SERVER_ERROR; + final ErrorReason errorReason = globalErrorCode.getErrorReason(); + final ErrorResponse errorResponse = ErrorResponse.from(errorReason); + return ResponseEntity.status(INTERNAL_SERVER_ERROR).body(errorResponse); + } +} diff --git a/src/main/java/ceos/backend/global/error/exception/ForbiddenAdmin.java b/src/main/java/ceos/backend/global/error/exception/ForbiddenAdmin.java new file mode 100644 index 00000000..13e98283 --- /dev/null +++ b/src/main/java/ceos/backend/global/error/exception/ForbiddenAdmin.java @@ -0,0 +1,14 @@ +package ceos.backend.global.error.exception; + + +import ceos.backend.global.error.BaseErrorException; +import ceos.backend.global.error.GlobalErrorCode; + +public class ForbiddenAdmin extends BaseErrorException { + + public static final BaseErrorException EXCEPTION = new ForbiddenAdmin(); + + private ForbiddenAdmin() { + super(GlobalErrorCode.FORBIDDEN_ADMIN); + } +} diff --git a/src/main/java/ceos/backend/global/error/exception/NoTokenException.java b/src/main/java/ceos/backend/global/error/exception/NoTokenException.java new file mode 100644 index 00000000..3db25a5b --- /dev/null +++ b/src/main/java/ceos/backend/global/error/exception/NoTokenException.java @@ -0,0 +1,14 @@ +package ceos.backend.global.error.exception; + + +import ceos.backend.global.error.BaseErrorException; +import ceos.backend.global.error.GlobalErrorCode; + +public class NoTokenException extends BaseErrorException { + + public static final BaseErrorException EXCEPTION = new NoTokenException(); + + private NoTokenException() { + super(GlobalErrorCode.NO_TOKEN); + } +} diff --git a/src/main/java/ceos/backend/global/error/exception/TokenValidateException.java b/src/main/java/ceos/backend/global/error/exception/TokenValidateException.java new file mode 100644 index 00000000..eab97ce1 --- /dev/null +++ b/src/main/java/ceos/backend/global/error/exception/TokenValidateException.java @@ -0,0 +1,14 @@ +package ceos.backend.global.error.exception; + + +import ceos.backend.global.error.BaseErrorException; +import ceos.backend.global.error.GlobalErrorCode; + +public class TokenValidateException extends BaseErrorException { + + public static final BaseErrorException EXCEPTION = new TokenValidateException(); + + private TokenValidateException() { + super(GlobalErrorCode.INVALID_AUTH_TOKEN); + } +} diff --git a/src/main/java/ceos/backend/global/util/InterviewConvertor.java b/src/main/java/ceos/backend/global/util/InterviewConvertor.java new file mode 100644 index 00000000..33eaeefe --- /dev/null +++ b/src/main/java/ceos/backend/global/util/InterviewConvertor.java @@ -0,0 +1,14 @@ +package ceos.backend.global.util; + + +import ceos.backend.domain.application.domain.Interview; +import java.time.format.DateTimeFormatter; + +public class InterviewConvertor { + public static String interviewDateFormatter(Interview interview) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss"); + return interview.getFromDate().format(formatter) + + " - " + + interview.getToDate().format(formatter); + } +} diff --git a/src/main/java/ceos/backend/global/util/InterviewDateTimeConvertor.java b/src/main/java/ceos/backend/global/util/InterviewDateTimeConvertor.java new file mode 100644 index 00000000..9940263b --- /dev/null +++ b/src/main/java/ceos/backend/global/util/InterviewDateTimeConvertor.java @@ -0,0 +1,38 @@ +package ceos.backend.global.util; + + +import ceos.backend.domain.application.vo.InterviewDateTimesVo; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class InterviewDateTimeConvertor { + public static List toStringDuration(List times) { + List stringDuration = new ArrayList<>(); + times.forEach( + timesVo -> { + stringDuration.addAll(dateTimeAdder(timesVo)); + }); + return stringDuration; + } + + private static List dateTimeAdder(InterviewDateTimesVo timesVo) { + final String date = dateformatter(timesVo.getDate()); + return timesVo.getDurations().stream() + .map(timeVo -> toDuration(date, timeVo)) + .collect(Collectors.toList()); + } + + private static String dateformatter(String date) { + return date.replaceAll("/", "."); + } + + private static String toDuration(String date, String timeVo) { + final String[] times = timeParser(timeVo); + return date + ' ' + times[0] + ":00 - " + date + ' ' + times[1] + ":00"; + } + + private static String[] timeParser(String time) { + return time.split("-"); + } +} diff --git a/src/main/java/ceos/backend/global/util/ParsedDurationConvertor.java b/src/main/java/ceos/backend/global/util/ParsedDurationConvertor.java new file mode 100644 index 00000000..f1d18954 --- /dev/null +++ b/src/main/java/ceos/backend/global/util/ParsedDurationConvertor.java @@ -0,0 +1,65 @@ +package ceos.backend.global.util; + + +import ceos.backend.global.common.dto.ParsedDuration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ParsedDurationConvertor { + private static final DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss"); + private static final DateTimeFormatter yearDateSlashFormmatter = + DateTimeFormatter.ofPattern("yyyy/MM/dd"); + private static final DateTimeFormatter yearDateDotFormmatter = + DateTimeFormatter.ofPattern("yyyy.MM.dd"); + private static final DateTimeFormatter dateFormmatter = DateTimeFormatter.ofPattern("MM/dd"); + private static final DateTimeFormatter timeFormmatter = DateTimeFormatter.ofPattern("HH:mm"); + private static final DateTimeFormatter timeSecondFormmatter = + DateTimeFormatter.ofPattern("HH:mm:ss"); + + public static ParsedDuration parsingDuration(String duration) { + final String[] strTimes = duration.split(" - "); + final String date = doDateFormatting(strTimes, false); + final String time = doTimeFormatting(strTimes); + return ParsedDuration.of(date, time); + } + + public static ParsedDuration parsingYearDuration(String duration) { + final String[] strTimes = duration.split(" - "); + final String date = doDateFormatting(strTimes, true); + final String time = doTimeFormatting(strTimes); + return ParsedDuration.of(date, time); + } + + public static String toStringDuration(ParsedDuration parsedDuration) { + String date = parsedDuration.getDate().replace("/", "."); + String[] strTimes = parsedDuration.getDuration().split("-"); + List times = Arrays.stream(strTimes).map(time -> time + ":00").toList(); + return date + " " + times.get(0) + " - " + date + " " + times.get(1); + } + + private static String doDateFormatting(String[] times, Boolean includeYear) { + return Arrays.stream(times) + .map(time -> LocalDateTime.parse(time, formatter)) + .map(time -> time.toLocalDate().format(selectFormatter(includeYear))) + .findFirst() + .orElseThrow(); + } + + private static DateTimeFormatter selectFormatter(Boolean includeYear) { + return includeYear ? yearDateSlashFormmatter : dateFormmatter; + } + + private static String doTimeFormatting(String[] strTimes) { + final List times = + Arrays.stream(strTimes) + .map(time -> LocalDateTime.parse(time, formatter)) + .map(formattedTime -> formattedTime.format(timeFormmatter)) + .toList(); + return times.get(0) + "-" + times.get(1); + } +} diff --git a/src/main/java/ceos/backend/infra/s3/AwsS3Config.java b/src/main/java/ceos/backend/infra/s3/AwsS3Config.java new file mode 100644 index 00000000..705e6e8f --- /dev/null +++ b/src/main/java/ceos/backend/infra/s3/AwsS3Config.java @@ -0,0 +1,31 @@ +package ceos.backend.infra.s3; + + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class AwsS3Config { + @Value("${aws.s3.access-key}") + private String AWS_ACCESS_KEY_ID; + + @Value("${aws.s3.secret-key}") + private String AWS_SECRET_KEY; + + @Bean + public S3Presigner s3Presigner() { + final AwsBasicCredentials awsBasicCredentials = + AwsBasicCredentials.create(AWS_ACCESS_KEY_ID, AWS_SECRET_KEY); + final StaticCredentialsProvider staticCredentialsProvider = + StaticCredentialsProvider.create(awsBasicCredentials); + return S3Presigner.builder() + .credentialsProvider(staticCredentialsProvider) + .region(Region.AP_NORTHEAST_2) + .build(); + } +} diff --git a/src/main/java/ceos/backend/infra/s3/AwsS3UrlGenerator.java b/src/main/java/ceos/backend/infra/s3/AwsS3UrlGenerator.java new file mode 100644 index 00000000..5aff077e --- /dev/null +++ b/src/main/java/ceos/backend/infra/s3/AwsS3UrlGenerator.java @@ -0,0 +1,38 @@ +package ceos.backend.infra.s3; + + +import java.time.Duration; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +@Component +@RequiredArgsConstructor +public class AwsS3UrlGenerator { + @Value("${aws.s3.bucket}") + private String BUCKET; + + private final AwsS3Config awsS3Config; + + public String generateUrl(String prefix) { + String filename = UUID.randomUUID().toString(); + + PutObjectRequest objectRequest = + PutObjectRequest.builder().bucket(BUCKET).key(prefix + "/" + filename).build(); + + PutObjectPresignRequest presignRequest = + PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .putObjectRequest(objectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = + awsS3Config.s3Presigner().presignPutObject(presignRequest); + + return presignedRequest.url().toString(); + } +} diff --git a/src/main/java/ceos/backend/infra/s3/AwsS3UrlHandler.java b/src/main/java/ceos/backend/infra/s3/AwsS3UrlHandler.java new file mode 100644 index 00000000..4bfb3429 --- /dev/null +++ b/src/main/java/ceos/backend/infra/s3/AwsS3UrlHandler.java @@ -0,0 +1,20 @@ +package ceos.backend.infra.s3; + + +import ceos.backend.global.common.dto.AwsS3Url; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AwsS3UrlHandler { + + private final AwsS3UrlGenerator awsS3UrlGenerator; + + public AwsS3Url handle(String prefix) { + final String url = awsS3UrlGenerator.generateUrl(prefix); + return AwsS3Url.to(url); + } +} diff --git a/src/main/java/ceos/backend/infra/ses/AwsSESConfig.java b/src/main/java/ceos/backend/infra/ses/AwsSESConfig.java new file mode 100644 index 00000000..45e42962 --- /dev/null +++ b/src/main/java/ceos/backend/infra/ses/AwsSESConfig.java @@ -0,0 +1,31 @@ +package ceos.backend.infra.ses; + + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.ses.SesAsyncClient; + +@Configuration +public class AwsSESConfig { + @Value("${aws.ses.access-key}") + private String AWS_ACCESS_KEY_ID; + + @Value("${aws.ses.secret-key}") + private String AWS_SECRET_KEY; + + @Bean + public SesAsyncClient sesAsyncClient() { + final AwsBasicCredentials awsBasicCredentials = + AwsBasicCredentials.create(AWS_ACCESS_KEY_ID, AWS_SECRET_KEY); + final StaticCredentialsProvider staticCredentialsProvider = + StaticCredentialsProvider.create(awsBasicCredentials); + return SesAsyncClient.builder() + .credentialsProvider(staticCredentialsProvider) + .region(Region.AP_NORTHEAST_2) + .build(); + } +} diff --git a/src/main/java/ceos/backend/infra/ses/AwsSESMailGenerator.java b/src/main/java/ceos/backend/infra/ses/AwsSESMailGenerator.java new file mode 100644 index 00000000..904157f7 --- /dev/null +++ b/src/main/java/ceos/backend/infra/ses/AwsSESMailGenerator.java @@ -0,0 +1,131 @@ +package ceos.backend.infra.ses; + + +import ceos.backend.domain.application.domain.ApplicationQuestion; +import ceos.backend.domain.application.domain.QuestionCategory; +import ceos.backend.domain.application.dto.request.CreateApplicationRequest; +import ceos.backend.domain.application.exception.exceptions.QuestionNotFound; +import ceos.backend.domain.application.vo.AnswerVo; +import ceos.backend.global.common.dto.AwsSESMail; +import ceos.backend.global.common.dto.AwsSESPasswordMail; +import ceos.backend.global.common.dto.ParsedDuration; +import ceos.backend.global.common.dto.mail.*; +import ceos.backend.global.common.entity.Part; +import ceos.backend.global.util.InterviewDateTimeConvertor; +import ceos.backend.global.util.ParsedDurationConvertor; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.thymeleaf.context.Context; + +@Component +@RequiredArgsConstructor +public class AwsSESMailGenerator { + + public Context generateApplicationMailContext(AwsSESMail awsSESMail) { + final CreateApplicationRequest request = awsSESMail.getCreateApplicationRequest(); + final List questions = awsSESMail.getApplicationQuestions(); + final String UUID = awsSESMail.getUUID(); + + List commonQ = new ArrayList<>(), commonA = new ArrayList<>(); + questions.stream() + .filter(question -> question.getCategory() == QuestionCategory.COMMON) + .sorted(Comparator.comparing(ApplicationQuestion::getNumber)) + .forEach( + question -> { + commonQ.add(generateQuestion(question)); + commonA.add(generateAnswer(request.getCommonAnswers(), question)); + }); + + List partQ = new ArrayList<>(), partA = new ArrayList<>(); + final Part part = request.getPart(); + questions.stream() + .filter(question -> question.getCategory().toString().equals(part.toString())) + .sorted(Comparator.comparing(ApplicationQuestion::getNumber)) + .forEach( + question -> { + partQ.add(generateQuestion(question)); + partA.add(generateAnswer(request.getPartAnswers(), question)); + }); + + final List unableTimes = + InterviewDateTimeConvertor.toStringDuration(request.getUnableTimes()); + List parsedDurations = + unableTimes.stream() + .map(ParsedDurationConvertor::parsingDuration) + .sorted(Comparator.comparing(ParsedDuration::getDuration)) + .sorted(Comparator.comparing(ParsedDuration::getDate)) + .toList(); + + List dates = + parsedDurations.stream() + .map(ParsedDuration::getDate) + .distinct() + .collect(Collectors.toList()); + + List> times = new ArrayList<>(); + dates.forEach( + date -> + times.add( + parsedDurations.stream() + .filter( + parsedDuration -> + Objects.equals( + parsedDuration.getDate(), date)) + .map(ParsedDuration::getDuration) + .collect(Collectors.toList()))); + + Context context = new Context(); + context.setVariable("greetInfo", GreetInfo.of(request, awsSESMail.getGeneration())); + context.setVariable("uuidInfo", UuidInfo.of(request.getApplicantInfoVo(), UUID)); + context.setVariable("personalInfo", PersonalInfo.from(request.getApplicantInfoVo())); + context.setVariable("schoolInfo", SchoolInfo.from(request.getApplicantInfoVo())); + context.setVariable("ceosQuestionInfo", CeosQuestionInfo.from(request)); + context.setVariable("commonQuestionInfo", CommonQuestionInfo.of(commonQ, commonA)); + context.setVariable( + "partQuestionInfo", PartQuestionInfo.of(request.getPart().getPart(), partQ, partA)); + context.setVariable("interviewDateInfo", InterviewDateInfo.of(times, dates)); + + return context; + } + + private String generateQuestion(ApplicationQuestion applicationQuestion) { + return applicationQuestion.getNumber() + " : " + applicationQuestion.getQuestion(); + } + + private String generateAnswer( + List answerVos, ApplicationQuestion applicationQuestion) { + final AnswerVo ans = + answerVos.stream() + .filter( + answerVo -> + applicationQuestion + .getId() + .equals(answerVo.getQuestionId())) + .findFirst() + .orElseThrow( + () -> { + throw QuestionNotFound.EXCEPTION; + }); + return ans.getAnswer(); + } + + public String generateApplicationMailSubject(int generation) { + return "세오스 " + Integer.toString(generation) + "기 지원 알림드립니다."; + } + + public Context generatePasswordMailContext(AwsSESPasswordMail awsSESPasswordMail) { + Context context = new Context(); + context.setVariable("passwordInfo", PasswordInfo.from(awsSESPasswordMail)); + + return context; + } + + public String generatePasswordMailSubject() { + return "세오스 관리자 페이지 임시 비밀번호 발급"; + } +} diff --git a/src/main/java/ceos/backend/infra/ses/AwsSESSendMailHandler.java b/src/main/java/ceos/backend/infra/ses/AwsSESSendMailHandler.java new file mode 100644 index 00000000..f9fbb472 --- /dev/null +++ b/src/main/java/ceos/backend/infra/ses/AwsSESSendMailHandler.java @@ -0,0 +1,38 @@ +package ceos.backend.infra.ses; + + +import ceos.backend.domain.application.dto.request.CreateApplicationRequest; +import ceos.backend.global.common.dto.AwsSESMail; +import ceos.backend.global.common.dto.AwsSESPasswordMail; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.thymeleaf.context.Context; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AwsSESSendMailHandler { + private final AwsSESUtils awsSesUtils; + private final AwsSESMailGenerator awsSESMailGenerator; + + @EventListener(AwsSESMail.class) + public void handle(AwsSESMail awsSESMail) { + final CreateApplicationRequest request = awsSESMail.getCreateApplicationRequest(); + + final String TO = request.getApplicantInfoVo().getEmail(); + final String SUBJECT = + awsSESMailGenerator.generateApplicationMailSubject(awsSESMail.getGeneration()); + final Context CONTEXT = awsSESMailGenerator.generateApplicationMailContext(awsSESMail); + awsSesUtils.singleEmailRequest(TO, SUBJECT, "sendApplicationMail", CONTEXT); + } + + @EventListener(AwsSESPasswordMail.class) + public void handle(AwsSESPasswordMail awsSESPasswordMail) { + final String TO = awsSESPasswordMail.getEmail(); + final String SUBJECT = awsSESMailGenerator.generatePasswordMailSubject(); + final Context CONTEXT = awsSESMailGenerator.generatePasswordMailContext(awsSESPasswordMail); + awsSesUtils.singleEmailRequest(TO, SUBJECT, "sendPasswordMail", CONTEXT); + } +} diff --git a/src/main/java/ceos/backend/infra/ses/AwsSESUtils.java b/src/main/java/ceos/backend/infra/ses/AwsSESUtils.java new file mode 100644 index 00000000..d3e4be97 --- /dev/null +++ b/src/main/java/ceos/backend/infra/ses/AwsSESUtils.java @@ -0,0 +1,37 @@ +package ceos.backend.infra.ses; + + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import software.amazon.awssdk.services.ses.SesAsyncClient; +import software.amazon.awssdk.services.ses.model.*; + +@Component +@RequiredArgsConstructor +public class AwsSESUtils { + private final SesAsyncClient sesAsyncClient; + private final SpringTemplateEngine templateEngine; + + public void singleEmailRequest(String to, String subject, String template, Context context) { + final String html = templateEngine.process(template, context); + + final SendEmailRequest.Builder sendEmailRequestBuilder = SendEmailRequest.builder(); + sendEmailRequestBuilder.destination(Destination.builder().toAddresses(to).build()); + sendEmailRequestBuilder + .message(newMessage(subject, html)) + .source("ceos@ceos-sinchon.com") + .build(); + + sesAsyncClient.sendEmail(sendEmailRequestBuilder.build()); + } + + private Message newMessage(String subject, String html) { + final Content content = Content.builder().data(subject).build(); + return Message.builder() + .subject(content) + .body(Body.builder().html(builder -> builder.data(html)).build()) + .build(); + } +} diff --git a/src/main/java/ceos/backend/infra/slack/SlackHelper.java b/src/main/java/ceos/backend/infra/slack/SlackHelper.java new file mode 100644 index 00000000..83b55d15 --- /dev/null +++ b/src/main/java/ceos/backend/infra/slack/SlackHelper.java @@ -0,0 +1,49 @@ +package ceos.backend.infra.slack; + + +import ceos.backend.global.common.helper.SpringEnvironmentHelper; +import com.slack.api.Slack; +import com.slack.api.webhook.Payload; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SlackHelper { + private final SpringEnvironmentHelper springEnvironmentHelper; + + @Value("${slack.webhook.dev_url}") + String devUrl; + + @Value("${slack.webhook.prod_url}") + String prodUrl; + + @Value("${slack.webhook.unavailable_url}") + String unavailableUrl; + + public void sendErrorNotification(Payload payload) { + final Slack slack = Slack.getInstance(); + + try { + if (springEnvironmentHelper.isDevProfile()) { + slack.send(devUrl, payload); + } else { + slack.send(prodUrl, payload); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void sendUnavailableReason(Payload payload) { + final Slack slack = Slack.getInstance(); + + try { + slack.send(unavailableUrl, payload); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/ceos/backend/infra/slack/SlackMessageGenerater.java b/src/main/java/ceos/backend/infra/slack/SlackMessageGenerater.java new file mode 100644 index 00000000..6f004753 --- /dev/null +++ b/src/main/java/ceos/backend/infra/slack/SlackMessageGenerater.java @@ -0,0 +1,127 @@ +package ceos.backend.infra.slack; + +import static com.slack.api.model.block.composition.BlockCompositions.plainText; + +import ceos.backend.domain.application.domain.ApplicantInfo; +import ceos.backend.global.common.dto.SlackErrorMessage; +import ceos.backend.global.common.dto.SlackUnavailableReason; +import com.slack.api.model.block.*; +import com.slack.api.model.block.composition.MarkdownTextObject; +import com.slack.api.model.block.composition.TextObject; +import com.slack.api.webhook.Payload; +import jakarta.servlet.ServletInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; +import org.springframework.web.util.ContentCachingRequestWrapper; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SlackMessageGenerater { + private final int MaxLen = 500; + + public Payload generateErrorMsg(SlackErrorMessage slackErrorMessage) throws IOException { + final ContentCachingRequestWrapper cachedRequest = + slackErrorMessage.getContentCachingRequestWrapper(); + final Exception e = slackErrorMessage.getException(); + + List layoutBlocks = new ArrayList<>(); + // 제목 + layoutBlocks.add(HeaderBlock.builder().text(plainText("에러 알림")).build()); + // 구분선 + layoutBlocks.add(new DividerBlock()); + // IP + Method, Addr + layoutBlocks.add(makeSection(getIP(cachedRequest), getAddr(cachedRequest))); + // RequestBody + RequestParam + layoutBlocks.add(makeSection(getBody(cachedRequest), getParam(cachedRequest))); + // 구분선 + layoutBlocks.add(new DividerBlock()); + // IP + Method, Addr + layoutBlocks.add(makeSection(getErrMessage(e), getErrStack(e))); + + return Payload.builder().text("에러 알림").blocks(layoutBlocks).build(); + } + + private LayoutBlock makeSection(TextObject first, TextObject second) { + return Blocks.section(section -> section.fields(List.of(first, second))); + } + + private MarkdownTextObject getIP(ContentCachingRequestWrapper c) { + final String errorIP = c.getRemoteAddr(); + return MarkdownTextObject.builder().text("* User IP :*\n" + errorIP).build(); + } + + private MarkdownTextObject getAddr(ContentCachingRequestWrapper c) { + final String method = c.getMethod(); + final String url = c.getRequestURL().toString(); + return MarkdownTextObject.builder() + .text("* Request Addr :*\n" + method + " : " + url) + .build(); + } + + private MarkdownTextObject getBody(ContentCachingRequestWrapper c) throws IOException { + ServletInputStream inputStream = c.getInputStream(); + String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + return MarkdownTextObject.builder().text("* Request Body :*\n" + messageBody).build(); + } + + private MarkdownTextObject getParam(ContentCachingRequestWrapper c) { + final String param = c.getQueryString(); + return MarkdownTextObject.builder().text("* Request Param :*\n" + param).build(); + } + + private MarkdownTextObject getErrMessage(Exception e) { + final String errorMessage = e.getMessage(); + return MarkdownTextObject.builder().text("* Message :*\n" + errorMessage).build(); + } + + private MarkdownTextObject getErrStack(Throwable throwable) { + String exceptionAsString = Arrays.toString(throwable.getStackTrace()); + int cutLength = Math.min(exceptionAsString.length(), MaxLen); + String errorStack = exceptionAsString.substring(0, cutLength); + return MarkdownTextObject.builder().text("* Stack Trace :*\n" + errorStack).build(); + } + + public Payload generateUnavailableReason(SlackUnavailableReason slackUnavailableReason) { + final ApplicantInfo info = slackUnavailableReason.getApplication().getApplicantInfo(); + final String reason = slackUnavailableReason.getReason(); + final boolean isFinal = slackUnavailableReason.isFinal(); + + final String title = isFinal ? "활동 불가능" : "면접 불가능"; + + List layoutBlocks = new ArrayList<>(); + // 제목 + layoutBlocks.add(HeaderBlock.builder().text(plainText(title)).build()); + // 구분선 + layoutBlocks.add(new DividerBlock()); + // name + uuid + layoutBlocks.add(makeSection(getName(info), getUuid(info))); + // reason + layoutBlocks.add(getReason(reason)); + + return Payload.builder().text(title).blocks(layoutBlocks).build(); + } + + private MarkdownTextObject getName(ApplicantInfo applicantInfo) { + final String name = applicantInfo.getName(); + return MarkdownTextObject.builder().text("* Name :*\n" + name).build(); + } + + private MarkdownTextObject getUuid(ApplicantInfo applicantInfo) { + final String UUID = applicantInfo.getUuid(); + return MarkdownTextObject.builder().text("* UUID :*\n" + UUID).build(); + } + + private SectionBlock getReason(String reason) { + TextObject reasonObj = + (TextObject) MarkdownTextObject.builder().text("* Reason :*\n" + reason).build(); + return Blocks.section(section -> section.fields(List.of(reasonObj))); + } +} diff --git a/src/main/java/ceos/backend/infra/slack/SlackSendMessageHandler.java b/src/main/java/ceos/backend/infra/slack/SlackSendMessageHandler.java new file mode 100644 index 00000000..d06c4cdf --- /dev/null +++ b/src/main/java/ceos/backend/infra/slack/SlackSendMessageHandler.java @@ -0,0 +1,39 @@ +package ceos.backend.infra.slack; + + +import ceos.backend.global.common.dto.SlackErrorMessage; +import ceos.backend.global.common.dto.SlackUnavailableReason; +import com.slack.api.webhook.Payload; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SlackSendMessageHandler { + private final SlackMessageGenerater slackMessageGenerater; + private final SlackHelper slackHelper; + + @Async + @EventListener(SlackErrorMessage.class) + public void HandleError(SlackErrorMessage slackErrorMessage) throws IOException { + Payload payload = slackMessageGenerater.generateErrorMsg(slackErrorMessage); + slackHelper.sendErrorNotification(payload); + } + + @Async + @TransactionalEventListener( + value = SlackUnavailableReason.class, + phase = TransactionPhase.AFTER_COMMIT) + public void HandleUnavailableReason(SlackUnavailableReason slackUnavailableReason) + throws IOException { + Payload payload = slackMessageGenerater.generateUnavailableReason(slackUnavailableReason); + slackHelper.sendUnavailableReason(payload); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b137891..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..b80a9760 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,28 @@ +spring: + profiles: + active: + - dev + include: + - secret + jpa: + hibernate: +# ddl-auto: validate + properties: + hibernate: +# show_sql: true +# format_sql: true + default_batch_fetch_size: 1000 + data: + - secret + +springdoc: + version: 0.0.1 + default-consumes-media-type: application/json # media-type 디폴트값 설정 + default-produces-media-type: application/json + swagger-ui: + operations-sorter: alpha # 오름차순 + path: /swagger-ui.html + +logging.level: + org.hibernate.SQL: debug +# org.hibernate.orm.jdbc.bind: trace \ No newline at end of file diff --git a/src/main/resources/templates/component/adminButton.html b/src/main/resources/templates/component/adminButton.html new file mode 100644 index 00000000..47113978 --- /dev/null +++ b/src/main/resources/templates/component/adminButton.html @@ -0,0 +1,35 @@ + + + + + + + diff --git a/src/main/resources/templates/component/button.html b/src/main/resources/templates/component/button.html new file mode 100644 index 00000000..22b6fbe8 --- /dev/null +++ b/src/main/resources/templates/component/button.html @@ -0,0 +1,35 @@ + + + + + + + diff --git a/src/main/resources/templates/component/ceosQuestionInfo.html b/src/main/resources/templates/component/ceosQuestionInfo.html new file mode 100644 index 00000000..e2481ede --- /dev/null +++ b/src/main/resources/templates/component/ceosQuestionInfo.html @@ -0,0 +1,89 @@ + + + + + +
+
+
+ + CEOS OT 날짜는? : + + + 오티 날짜 + +
+ +
+ +
+ + CEOS 데모데이 날짜는? : + + + 데모 + +
+ +
+ +
+ + 이번 학기 세오스 활동 외 어떤 활동을 하는지 간략히 적어주세요 : + + + 백수 + +
+
+
+ \ No newline at end of file diff --git a/src/main/resources/templates/component/commonQuestionInfo.html b/src/main/resources/templates/component/commonQuestionInfo.html new file mode 100644 index 00000000..09bfcf11 --- /dev/null +++ b/src/main/resources/templates/component/commonQuestionInfo.html @@ -0,0 +1,61 @@ + + + + + +
+
+
+ + 공통 질문 + +
+ + + + + +
+
+
+ + 질문 + +
+
+
+ + 답 + +
+
+
+
+ \ No newline at end of file diff --git a/src/main/resources/templates/component/copyright.html b/src/main/resources/templates/component/copyright.html new file mode 100644 index 00000000..f96c442f --- /dev/null +++ b/src/main/resources/templates/component/copyright.html @@ -0,0 +1,27 @@ + + + + + +
+ + © 2016-2023 Ceos ALL RIGHTS RESERVED. + +
+ diff --git a/src/main/resources/templates/component/divider.html b/src/main/resources/templates/component/divider.html new file mode 100644 index 00000000..21e3ac3b --- /dev/null +++ b/src/main/resources/templates/component/divider.html @@ -0,0 +1,13 @@ + + + + + +
+
+ \ No newline at end of file diff --git a/src/main/resources/templates/component/greeting.html b/src/main/resources/templates/component/greeting.html new file mode 100644 index 00000000..e011244d --- /dev/null +++ b/src/main/resources/templates/component/greeting.html @@ -0,0 +1,172 @@ + + + + + +
+
+
+ + CEOS + + + 18 + + 기 지원서 제출 완료 + +
+ +
+ +
+ + 안녕하세요, + + + 000 + + + 님. 세오스 + + + 18 + + + 기 지원서가 정상적으로 접수되었습니다. +
+ 작성하신 메일 주소로 +
+ + 000 + + + + 님의 고유번호와 지원서 작성 내용을 전달드립니다. +
+ 하단의 고유번호를 통해 홈페이지에서 합격 결과 확인이 가능합니다. +
+ 세오스에 관심을 갖고 지원해주셔서 감사드립니다. +
+
+
+
+ diff --git a/src/main/resources/templates/component/interviewDateInfo.html b/src/main/resources/templates/component/interviewDateInfo.html new file mode 100644 index 00000000..eae686d3 --- /dev/null +++ b/src/main/resources/templates/component/interviewDateInfo.html @@ -0,0 +1,82 @@ + + + + + +
+
+
+ + 면접 날짜 - 불가능한 시간 + +
+ +
+ +
+
+ + 날짜 + + +  :  + +
+ + , + + + 시간 + +
+
+
+
+
+ \ No newline at end of file diff --git a/src/main/resources/templates/component/partQuestionInfo.html b/src/main/resources/templates/component/partQuestionInfo.html new file mode 100644 index 00000000..3ae43215 --- /dev/null +++ b/src/main/resources/templates/component/partQuestionInfo.html @@ -0,0 +1,72 @@ + + + + + +
+
+
+ + 파트별 질문 - + + + 파트 + +
+ + + + + +
+
+
+ + 질문 + +
+
+
+ + 답 + +
+
+
+
+ \ No newline at end of file diff --git a/src/main/resources/templates/component/password.html b/src/main/resources/templates/component/password.html new file mode 100644 index 00000000..0493501e --- /dev/null +++ b/src/main/resources/templates/component/password.html @@ -0,0 +1,42 @@ + + + + + +
+
+ + 000 + + + 님의 임시 비밀번호 + + +
+ +
uuid
+
+
+ \ No newline at end of file diff --git a/src/main/resources/templates/component/personalInfo.html b/src/main/resources/templates/component/personalInfo.html new file mode 100644 index 00000000..fdab058c --- /dev/null +++ b/src/main/resources/templates/component/personalInfo.html @@ -0,0 +1,141 @@ + + + + + +
+
+
+ + 이름 : + + + 000 + +
+ +
+ +
+ + 성별 : + + + 성별 + +
+ +
+ +
+ + 생년월일 : + + + 생년월일 + +
+ +
+ +
+ + 이메일 : + + + 이메일 + +
+ +
+ +
+ + 전화번호 : + + + 전화번호 + +
+
+
+ diff --git a/src/main/resources/templates/component/schoolInfo.html b/src/main/resources/templates/component/schoolInfo.html new file mode 100644 index 00000000..ee330bc4 --- /dev/null +++ b/src/main/resources/templates/component/schoolInfo.html @@ -0,0 +1,89 @@ + + + + + +
+
+
+ + 학교 : + + + 혼긱대학교 + +
+ +
+ +
+ + 전공 : + + + 감귤 포장학과 + +
+ +
+ +
+ + 졸업까지 남은 학기 : + + + 졸업까지 남은 학기 수 + +
+
+
+ diff --git a/src/main/resources/templates/component/title.html b/src/main/resources/templates/component/title.html new file mode 100644 index 00000000..cf7a0f32 --- /dev/null +++ b/src/main/resources/templates/component/title.html @@ -0,0 +1,16 @@ + + + + + +
+
+ +
+
+ \ No newline at end of file diff --git a/src/main/resources/templates/component/uuid.html b/src/main/resources/templates/component/uuid.html new file mode 100644 index 00000000..004de16d --- /dev/null +++ b/src/main/resources/templates/component/uuid.html @@ -0,0 +1,42 @@ + + + + + +
+
+ + 000 + + + 님의 고유번호 + + +
+ +
uuid
+
+
+ \ No newline at end of file diff --git a/src/main/resources/templates/component/uuidBox.html b/src/main/resources/templates/component/uuidBox.html new file mode 100644 index 00000000..0bfce34e --- /dev/null +++ b/src/main/resources/templates/component/uuidBox.html @@ -0,0 +1,36 @@ + + + + + +
+
+ + UUID + +
+
+ diff --git a/src/main/resources/templates/sendApplicationMail.html b/src/main/resources/templates/sendApplicationMail.html new file mode 100644 index 00000000..45714f7c --- /dev/null +++ b/src/main/resources/templates/sendApplicationMail.html @@ -0,0 +1,55 @@ + + + + + + + + + + +
+
title
+
divider
+
greeting
+
+
uuid
+
divider
+
uuid
+
divider
+
school
+
divider
+
ceos question
+
divider
+
common question
+
divider
+
part question
+
divider
+
part question
+
+
+ button +
+
+
copyright
+
+ + diff --git a/src/main/resources/templates/sendPasswordMail.html b/src/main/resources/templates/sendPasswordMail.html new file mode 100644 index 00000000..a13888f2 --- /dev/null +++ b/src/main/resources/templates/sendPasswordMail.html @@ -0,0 +1,43 @@ + + + + + + + + + + +
+
title
+
divider
+
+
password
+
divider
+
+
+ button +
+
+
copyright
+
+ + diff --git a/src/main/resources/templates/static/image/CEOSlogo.png b/src/main/resources/templates/static/image/CEOSlogo.png new file mode 100644 index 00000000..31d8ecb9 Binary files /dev/null and b/src/main/resources/templates/static/image/CEOSlogo.png differ diff --git a/src/main/resources/templates/test.html b/src/main/resources/templates/test.html new file mode 100644 index 00000000..df8f7181 --- /dev/null +++ b/src/main/resources/templates/test.html @@ -0,0 +1,12 @@ + + + + + + + + + +

+ + \ No newline at end of file diff --git a/src/test/java/ceos/backend/BackendApplicationTests.java b/src/test/java/ceos/backend/BackendApplicationTests.java index cdd28780..7d7756e0 100644 --- a/src/test/java/ceos/backend/BackendApplicationTests.java +++ b/src/test/java/ceos/backend/BackendApplicationTests.java @@ -1,13 +1,12 @@ package ceos.backend; + import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class BackendApplicationTests { - @Test - void contextLoads() { - } - + @Test + void contextLoads() {} } diff --git a/src/test/java/ceos/backend/domain/activity/service/ActivityServiceTest.java b/src/test/java/ceos/backend/domain/activity/service/ActivityServiceTest.java new file mode 100644 index 00000000..6a4a16e1 --- /dev/null +++ b/src/test/java/ceos/backend/domain/activity/service/ActivityServiceTest.java @@ -0,0 +1,86 @@ +// package ceos.backend.domain.activity.service; +// +// import ceos.backend.domain.activity.dto.ActivityRequest; +// import ceos.backend.domain.activity.dto.ActivityResponse; +// import ceos.backend.domain.activity.exception.ActivityNotFound; +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.boot.test.context.SpringBootTest; +// import org.springframework.transaction.annotation.Transactional; +// +// import static org.assertj.core.api.Assertions.*; +// import static org.junit.jupiter.api.Assertions.assertThrows; +// +// @SpringBootTest +// @Transactional +// class ActivityServiceTest { +// +// @Autowired +// private ActivityService activityService; +// +// @Test +// @DisplayName("활동 추가") +// void createActivity() { +// //given +// ActivityRequest activityRequest = ActivityRequest.builder() +// .name("해커톤") +// .content("해커톤은 좋아요") +// .imageUrl("hackathon.jpg") +// .build(); +// +// //when +// Long id = activityService.createActivity(activityRequest).getId(); +// ActivityResponse activityResponse = activityService.getActivity(id); +// +// //then +// assertThat(activityResponse.getName()).isEqualTo("해커톤"); +// } +// +// @Test +// @DisplayName("활동 수정") +// void updateActivity() { +// //given +// ActivityRequest activityRequest = ActivityRequest.builder() +// .name("데모데이") +// .content("데모데이는 좋아요") +// .imageUrl("demoday.jpg") +// .build(); +// +// Long id = activityService.createActivity(activityRequest).getId(); +// +// //when +// ActivityRequest activityRequest2 = ActivityRequest.builder() +// .name("데모데이") +// .content("데모데이는 정말 좋아요") +// .imageUrl("demoday.jpg") +// .build(); +// +// ActivityResponse activityResponse = activityService.updateActivity(id, activityRequest2); +// +// //then +// assertThat(activityResponse.getContent()).isEqualTo("데모데이는 정말 좋아요"); +// } +// +// @Test +// @DisplayName("활동 삭제") +// void deleteActivity() { +// //given +// ActivityRequest activityRequest = ActivityRequest.builder() +// .name("데모데이") +// .content("데모데이는 좋아요") +// .imageUrl("demoday.jpg") +// .build(); +// +// Long id = activityService.createActivity(activityRequest).getId(); +// +// //when +// activityService.deleteActivity(id); +// +// //then +// assertThrows(ActivityNotFound.class, () -> { +// activityService.getActivity(id); +// throw new ActivityNotFound(); +// }); +// } +// } diff --git a/src/test/java/ceos/backend/domain/application/ApplicationControllerTest.java b/src/test/java/ceos/backend/domain/application/ApplicationControllerTest.java new file mode 100644 index 00000000..31ca80f7 --- /dev/null +++ b/src/test/java/ceos/backend/domain/application/ApplicationControllerTest.java @@ -0,0 +1,25 @@ +package ceos.backend.domain.application; + + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +@SpringBootTest +@AutoConfigureMockMvc +public class ApplicationControllerTest { + + @Autowired private MockMvc mockMvc; + + @DisplayName("지원서 엑셀 파일 생성 시각 API - 권한 X") + @Test + void getApplicationExcelCreationTime() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/applications/file/creationtime")) + .andExpect(MockMvcResultMatchers.status().is(401)); + } +} diff --git a/src/test/java/ceos/backend/domain/application/ApplicationExcelServiceTest.java b/src/test/java/ceos/backend/domain/application/ApplicationExcelServiceTest.java new file mode 100644 index 00000000..3d1e2419 --- /dev/null +++ b/src/test/java/ceos/backend/domain/application/ApplicationExcelServiceTest.java @@ -0,0 +1,24 @@ +package ceos.backend.domain.application; + + +import ceos.backend.domain.application.dto.response.GetCreationTime; +import ceos.backend.domain.application.service.ApplicationExcelService; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class ApplicationExcelServiceTest { + + @Autowired ApplicationExcelService applicationExcelService; + + @DisplayName("지원서 엑셀 파일 생성 시각") + @Test + public void getApplicationExcelCreationTime() throws Exception { + GetCreationTime creationTime = applicationExcelService.getApplicationExcelCreationTime(); + Assertions.assertThat(creationTime.getCreateAt().charAt(4)).isEqualTo('-'); + Assertions.assertThat(creationTime.getCreateAt().charAt(7)).isEqualTo('-'); + } +} diff --git a/src/test/java/ceos/backend/domain/recruitments/domain/SettingsTest.java b/src/test/java/ceos/backend/domain/recruitments/domain/SettingsTest.java new file mode 100644 index 00000000..869547e7 --- /dev/null +++ b/src/test/java/ceos/backend/domain/recruitments/domain/SettingsTest.java @@ -0,0 +1,59 @@ +package ceos.backend.domain.recruitments.domain; + +import static org.junit.jupiter.api.Assertions.*; + +import ceos.backend.domain.admin.exception.NotAllowedToModify; +import ceos.backend.domain.recruitment.domain.Recruitment; +import ceos.backend.domain.recruitment.helper.RecruitmentHelper; +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SettingsTest { + + @Autowired private RecruitmentHelper recruitmentHelper; + + @Test + @DisplayName("지원 기간에는 수정할 수 없음") + void validAmenablePeriod_X() { + // given + Recruitment recruitment = recruitmentHelper.takeRecruitment(); + + // when + LocalDate startDateDoc = recruitment.getStartDateDoc(); + LocalDate date = + LocalDate.of( + startDateDoc.getYear(), + startDateDoc.getMonth(), + startDateDoc.getDayOfMonth() + 1); + + // then + assertThrows( + NotAllowedToModify.class, + () -> { + recruitment.validAmenablePeriod(date); + throw new NotAllowedToModify(); + }); + } + + @Test + @DisplayName("지원 기간이 아닐 때는 수정할 수 있음") + void validAmenablePeriod_O() { + // given + Recruitment recruitment = recruitmentHelper.takeRecruitment(); + + // when + LocalDate startDateDoc = recruitment.getStartDateDoc(); + LocalDate date = + LocalDate.of( + startDateDoc.getYear(), + startDateDoc.getMonth(), + startDateDoc.getDayOfMonth() - 1); + + // then + recruitment.validAmenablePeriod(date); + } +}