diff --git a/src/main/java/dmu/dasom/api/domain/applicant/dto/ApplicantDetailsResponseDto.java b/src/main/java/dmu/dasom/api/domain/applicant/dto/ApplicantDetailsResponseDto.java new file mode 100644 index 0000000..2b8632f --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/applicant/dto/ApplicantDetailsResponseDto.java @@ -0,0 +1,40 @@ +package dmu.dasom.api.domain.applicant.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@AllArgsConstructor +@SuperBuilder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Schema(name = "ApplicantDetailsResponseDto", description = "지원자 상세 응답 DTO") +public class ApplicantDetailsResponseDto extends ApplicantResponseDto { + + @Schema(description = "연락처", example = "010-1234-5678") + private String contact; + + @Schema(description = "이메일", example = "test@example.com") + private String email; + + @Schema(description = "학년", example = "3") + private int grade; + + @Schema(description = "지원 동기", example = "동아리 활동을 통해 새로운 경험을 쌓고 싶어서 지원합니다.") + private String reasonForApply; + + @Schema(description = "희망 활동", example = "프로젝트") + private String activityWish; + + @Schema(description = "개인정보 처리방침 동의 여부", example = "true") + private Boolean isPrivacyPolicyAgreed; + + @Schema(description = "지원일시", example = "2021-10-01T00:00:00") + private LocalDateTime createdAt; + + @Schema(description = "수정일시", example = "2021-10-01T00:00:00") + private LocalDateTime updatedAt; + +} diff --git a/src/main/java/dmu/dasom/api/domain/applicant/dto/ApplicantResponseDto.java b/src/main/java/dmu/dasom/api/domain/applicant/dto/ApplicantResponseDto.java new file mode 100644 index 0000000..7a78606 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/applicant/dto/ApplicantResponseDto.java @@ -0,0 +1,27 @@ +package dmu.dasom.api.domain.applicant.dto; + +import dmu.dasom.api.domain.applicant.enums.ApplicantStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@AllArgsConstructor +@SuperBuilder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Schema(name = "ApplicantResponseDto", description = "지원자 응답 DTO") +public class ApplicantResponseDto { + + @Schema(description = "id", example = "1") + private Long id; + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "학번", example = "20210000") + private String studentNo; + + @Schema(description = "상태", example = "PENDING") + private ApplicantStatus status; + +} diff --git a/src/main/java/dmu/dasom/api/domain/applicant/dto/ApplicantStatusUpdateRequestDto.java b/src/main/java/dmu/dasom/api/domain/applicant/dto/ApplicantStatusUpdateRequestDto.java new file mode 100644 index 0000000..a451756 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/applicant/dto/ApplicantStatusUpdateRequestDto.java @@ -0,0 +1,14 @@ +package dmu.dasom.api.domain.applicant.dto; + +import dmu.dasom.api.domain.applicant.enums.ApplicantStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +@Schema(name = "ApplicantStatusUpdateRequestDto", description = "지원자 상태 변경 요청 DTO") +public class ApplicantStatusUpdateRequestDto { + + @Schema(description = "상태", example = "DOCUMENT_PASSED") + private ApplicantStatus status; + +} diff --git a/src/main/java/dmu/dasom/api/domain/applicant/entity/Applicant.java b/src/main/java/dmu/dasom/api/domain/applicant/entity/Applicant.java index 7c31e43..89e7c6a 100644 --- a/src/main/java/dmu/dasom/api/domain/applicant/entity/Applicant.java +++ b/src/main/java/dmu/dasom/api/domain/applicant/entity/Applicant.java @@ -1,5 +1,7 @@ package dmu.dasom.api.domain.applicant.entity; +import dmu.dasom.api.domain.applicant.dto.ApplicantDetailsResponseDto; +import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto; import dmu.dasom.api.domain.applicant.enums.ApplicantStatus; import jakarta.persistence.*; import jakarta.validation.constraints.*; @@ -26,6 +28,10 @@ public class Applicant { @Id private Long id; + @Column(name = "name", nullable = false, length = 16) + @Size(max = 16) + private String name; + @Column(name = "student_no", nullable = false, length = 8) @Pattern(regexp = "^[0-9]{8}$") @Size(min = 8, max = 8) @@ -73,4 +79,30 @@ public void updateStatus(final ApplicantStatus status) { this.status = status; } + public ApplicantResponseDto toApplicantResponse() { + return ApplicantResponseDto.builder() + .id(this.id) + .name(this.name) + .studentNo(this.studentNo) + .status(this.status) + .build(); + } + + public ApplicantDetailsResponseDto toApplicantDetailsResponse() { + return ApplicantDetailsResponseDto.builder() + .id(this.id) + .name(this.name) + .studentNo(this.studentNo) + .contact(this.contact) + .email(this.email) + .grade(this.grade) + .reasonForApply(this.reasonForApply) + .activityWish(this.activityWish) + .isPrivacyPolicyAgreed(this.isPrivacyPolicyAgreed) + .status(this.status) + .createdAt(this.createdAt) + .updatedAt(this.updatedAt) + .build(); + } + } diff --git a/src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java b/src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java index de6b1e8..24aae15 100644 --- a/src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java +++ b/src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java @@ -1,7 +1,14 @@ package dmu.dasom.api.domain.applicant.repository; import dmu.dasom.api.domain.applicant.entity.Applicant; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface ApplicantRepository extends JpaRepository { + + @Query("SELECT a FROM Applicant a ORDER BY a.id DESC") + Page findAllWithPageRequest(final Pageable pageable); + } diff --git a/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java b/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java index 2e0da88..1015833 100644 --- a/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java +++ b/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java @@ -1,9 +1,19 @@ package dmu.dasom.api.domain.applicant.service; import dmu.dasom.api.domain.applicant.dto.ApplicantCreateRequestDto; +import dmu.dasom.api.domain.applicant.dto.ApplicantDetailsResponseDto; +import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto; +import dmu.dasom.api.domain.applicant.dto.ApplicantStatusUpdateRequestDto; +import dmu.dasom.api.global.dto.PageResponse; public interface ApplicantService { void apply(final ApplicantCreateRequestDto request); + PageResponse getApplicants(final int page); + + ApplicantDetailsResponseDto getApplicant(final Long id); + + ApplicantDetailsResponseDto updateApplicantStatus(final Long id, final ApplicantStatusUpdateRequestDto request); + } diff --git a/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java b/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java index 0792fba..8164831 100644 --- a/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java +++ b/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java @@ -1,14 +1,25 @@ package dmu.dasom.api.domain.applicant.service; import dmu.dasom.api.domain.applicant.dto.ApplicantCreateRequestDto; +import dmu.dasom.api.domain.applicant.dto.ApplicantDetailsResponseDto; +import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto; +import dmu.dasom.api.domain.applicant.dto.ApplicantStatusUpdateRequestDto; +import dmu.dasom.api.domain.applicant.entity.Applicant; import dmu.dasom.api.domain.applicant.repository.ApplicantRepository; +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.global.dto.PageResponse; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @RequiredArgsConstructor @Service public class ApplicantServiceImpl implements ApplicantService { + private final static int DEFAULT_PAGE_SIZE = 20; + private final ApplicantRepository applicantRepository; // 지원자 저장 @@ -17,4 +28,37 @@ public void apply(final ApplicantCreateRequestDto request) { applicantRepository.save(request.toEntity()); } + // 지원자 조회 + @Override + public PageResponse getApplicants(final int page) { + final Page applicants = applicantRepository.findAllWithPageRequest(PageRequest.of(page, DEFAULT_PAGE_SIZE)); + + // 조회 조건에 따라 결과가 없을 수 있음 + if (applicants.isEmpty()) + throw new CustomException(ErrorCode.EMPTY_RESULT); + + return PageResponse.from(applicants.map(Applicant::toApplicantResponse)); + } + + // 지원자 상세 조회 + @Override + public ApplicantDetailsResponseDto getApplicant(final Long id) { + return findById(id).toApplicantDetailsResponse(); + } + + // 지원자 상태 변경 + @Override + public ApplicantDetailsResponseDto updateApplicantStatus(final Long id, final ApplicantStatusUpdateRequestDto request) { + final Applicant applicant = findById(id); + applicant.updateStatus(request.getStatus()); + + return applicant.toApplicantDetailsResponse(); + } + + // Repository에서 ID로 지원자 조회 + private Applicant findById(final Long id) { + return applicantRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.EMPTY_RESULT)); + } + } diff --git a/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java index 472da76..1d8a0bf 100644 --- a/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java +++ b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java @@ -17,7 +17,8 @@ public enum ErrorCode { TOKEN_NOT_VALID(400, "C008", "토큰이 올바르지 않습니다."), INTERNAL_SERVER_ERROR(500, "C009", "서버에 문제가 발생하였습니다."), NOT_FOUND(404, "C010", "해당 리소스를 찾을 수 없습니다."), - WRITE_FAIL(400, "C011", "데이터를 쓰는데 실패하였습니다.") + WRITE_FAIL(400, "C011", "데이터를 쓰는데 실패하였습니다."), + EMPTY_RESULT(400, "C012", "조회 결과가 없습니다.") ; private final int status; diff --git a/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java b/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java new file mode 100644 index 0000000..baf12ae --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java @@ -0,0 +1,34 @@ +package dmu.dasom.api.domain.recruit.controller; + +import dmu.dasom.api.domain.applicant.dto.ApplicantCreateRequestDto; +import dmu.dasom.api.domain.applicant.service.ApplicantService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/recruit") +@RequiredArgsConstructor +public class RecruitController { + + private final ApplicantService applicantService; + + // 지원하기 + @Operation(summary = "부원 지원하기") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "지원 성공") + }) + @PostMapping("/apply") + public ResponseEntity apply(@Valid @RequestBody final ApplicantCreateRequestDto request) { + applicantService.apply(request); + return ResponseEntity.ok().build(); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/admin/controller/AdminController.java b/src/main/java/dmu/dasom/api/global/admin/controller/AdminController.java new file mode 100644 index 0000000..ebfadc2 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/admin/controller/AdminController.java @@ -0,0 +1,82 @@ +package dmu.dasom.api.global.admin.controller; + +import dmu.dasom.api.domain.applicant.dto.ApplicantDetailsResponseDto; +import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto; +import dmu.dasom.api.domain.applicant.dto.ApplicantStatusUpdateRequestDto; +import dmu.dasom.api.domain.applicant.service.ApplicantService; +import dmu.dasom.api.global.dto.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminController { + + private final ApplicantService applicantService; + + // 지원자 조회 + @Operation(summary = "지원자 전체 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "지원자 조회 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "조회 결과 없음", + value = "{ \"code\": \"C012\", \"message\": \"조회 결과가 없습니다.\" }" + ) + } + ) + ) + }) + @GetMapping("/applicants") + public ResponseEntity> getApplicants( + @RequestParam(value = "page", defaultValue = "0") @Min(0) final int page + ) { + return ResponseEntity.ok(applicantService.getApplicants(page)); + } + + // 지원자 상세 조회 + @Operation(summary = "지원자 상세 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "지원자 상세 조회 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "조회 결과 없음", + value = "{ \"code\": \"C012\", \"message\": \"조회 결과가 없습니다.\" }" + ) + } + ) + ) + }) + @GetMapping("/applicants/{id}") + public ResponseEntity getApplicant(@PathVariable("id") @Min(0) final Long id) { + return ResponseEntity.ok(applicantService.getApplicant(id)); + } + + // 지원자 상태 변경 + @Operation(summary = "지원자 상태 변경") + @PatchMapping("/applicants/{id}/status") + public ResponseEntity updateApplicantStatus(@PathVariable("id") @Min(0) final Long id, + @Valid @RequestBody final ApplicantStatusUpdateRequestDto request) { + return ResponseEntity.ok(applicantService.updateApplicantStatus(id, request)); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/dto/PageResponse.java b/src/main/java/dmu/dasom/api/global/dto/PageResponse.java new file mode 100644 index 0000000..59f150f --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/dto/PageResponse.java @@ -0,0 +1,50 @@ +package dmu.dasom.api.global.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +@Builder +@Schema(name = "PageResponse", description = "페이징 응답 DTO") +public class PageResponse { + + @Schema(description = "데이터") + @NotEmpty + private List content; + + @Schema(description = "현재 페이지 번호") + private int number; + + @Schema(description = "페이지 크기") + private int size; + + @Schema(description = "전체 데이터 개수") + private long totalElements; + + @Schema(description = "전체 페이지 개수") + private int totalPages; + + @Schema(description = "첫 페이지 여부") + private boolean first; + + @Schema(description = "마지막 페이지 여부") + private boolean last; + + public static PageResponse from(Page page) { + return PageResponse.builder() + .content(page.getContent()) + .number(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .first(page.isFirst()) + .last(page.isLast()) + .build(); + } + +} diff --git a/src/test/java/dmu/dasom/api/domain/applicant/ApplicantServiceTest.java b/src/test/java/dmu/dasom/api/domain/applicant/ApplicantServiceTest.java new file mode 100644 index 0000000..7c77a5c --- /dev/null +++ b/src/test/java/dmu/dasom/api/domain/applicant/ApplicantServiceTest.java @@ -0,0 +1,91 @@ +package dmu.dasom.api.domain.applicant; + +import dmu.dasom.api.domain.applicant.dto.ApplicantCreateRequestDto; +import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto; +import dmu.dasom.api.domain.applicant.entity.Applicant; +import dmu.dasom.api.domain.applicant.repository.ApplicantRepository; +import dmu.dasom.api.domain.applicant.service.ApplicantServiceImpl; +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.global.dto.PageResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ApplicantServiceTest { + + @Mock + private ApplicantRepository applicantRepository; + + @InjectMocks + private ApplicantServiceImpl applicantService; + + @Test + @DisplayName("지원자 저장 - 성공") + void apply_success() { + // given + ApplicantCreateRequestDto request = mock(ApplicantCreateRequestDto.class); + + // when + applicantService.apply(request); + + // then + verify(applicantRepository).save(request.toEntity()); + } + + @Test + @DisplayName("지원자 저장 - 실패") + void apply_fail() { + } + + @Test + @DisplayName("지원자 조회 - 성공") + void getApplicants_success() { + // given + int page = 0; + PageRequest pageRequest = PageRequest.of(page, 20); + Applicant applicant = mock(Applicant.class); + Page applicants = new PageImpl<>(Collections.singletonList(applicant), pageRequest, 1); + when(applicantRepository.findAllWithPageRequest(pageRequest)).thenReturn(applicants); + when(applicant.toApplicantResponse()).thenReturn(mock(ApplicantResponseDto.class)); + + // when + PageResponse response = applicantService.getApplicants(page); + + // then + assertNotNull(response); + assertEquals(1, response.getContent().size()); + verify(applicantRepository).findAllWithPageRequest(pageRequest); + } + + @Test + @DisplayName("지원자 조회 - 실패 (결과 없음)") + void getApplicants_fail_emptyResult() { + // given + int page = 0; + PageRequest pageRequest = PageRequest.of(page, 20); + Page applicants = new PageImpl<>(Collections.emptyList(), pageRequest, 0); + when(applicantRepository.findAllWithPageRequest(pageRequest)).thenReturn(applicants); + + // when + CustomException exception = assertThrows(CustomException.class, () -> { + applicantService.getApplicants(page); + }); + + // then + assertEquals(ErrorCode.EMPTY_RESULT, exception.getErrorCode()); + verify(applicantRepository).findAllWithPageRequest(pageRequest); + } +} \ No newline at end of file