diff --git a/build.gradle b/build.gradle index e7bccc9..07ed4e5 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' implementation 'org.postgresql:postgresql' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/gdgoc/study_group/curriculum/domain/Curriculum.java b/src/main/java/com/gdgoc/study_group/curriculum/domain/Curriculum.java index 5fc3795..4f2112e 100644 --- a/src/main/java/com/gdgoc/study_group/curriculum/domain/Curriculum.java +++ b/src/main/java/com/gdgoc/study_group/curriculum/domain/Curriculum.java @@ -7,12 +7,13 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import lombok.Getter; -import lombok.Setter; +import lombok.*; @Entity @Getter -@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Curriculum { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -24,4 +25,8 @@ public class Curriculum { private Integer week; private String subject; // 해당 회차의 주제 + + public static Curriculum create(Study study, Integer week, String subject) { + return Curriculum.builder().study(study).week(week).subject(subject).build(); + } } diff --git a/src/main/java/com/gdgoc/study_group/curriculum/dto/CurriculumDTO.java b/src/main/java/com/gdgoc/study_group/curriculum/dto/CurriculumDTO.java new file mode 100644 index 0000000..1f21577 --- /dev/null +++ b/src/main/java/com/gdgoc/study_group/curriculum/dto/CurriculumDTO.java @@ -0,0 +1,14 @@ +package com.gdgoc.study_group.curriculum.dto; + +import com.gdgoc.study_group.curriculum.domain.Curriculum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record CurriculumDTO( + @NotBlank(message = "스터디 주차를 입력해 주세요.") @Schema(description = "스터디 주차") Integer week, + @NotBlank(message = "주제를 입력해 주세요.") @Schema(description = "해당 주차의 주제") String subject) { + + public static CurriculumDTO from(Curriculum curriculum) { + return new CurriculumDTO(curriculum.getWeek(), curriculum.getSubject()); + } +} diff --git a/src/main/java/com/gdgoc/study_group/day/domain/Day.java b/src/main/java/com/gdgoc/study_group/day/domain/Day.java index 4c299b7..f8b4665 100644 --- a/src/main/java/com/gdgoc/study_group/day/domain/Day.java +++ b/src/main/java/com/gdgoc/study_group/day/domain/Day.java @@ -1,5 +1,6 @@ package com.gdgoc.study_group.day.domain; +import com.fasterxml.jackson.annotation.JsonFormat; import com.gdgoc.study_group.study.domain.Study; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -8,12 +9,13 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import java.time.LocalTime; -import lombok.Getter; -import lombok.Setter; +import lombok.*; @Entity @Getter -@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Day { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -24,5 +26,11 @@ public class Day { private Study study; private String day; + + @JsonFormat(pattern = "HH:mm") private LocalTime startTime; + + public static Day create(Study study, String day, LocalTime startTime) { + return Day.builder().study(study).day(day).startTime(startTime).build(); + } } diff --git a/src/main/java/com/gdgoc/study_group/day/dto/DayDTO.java b/src/main/java/com/gdgoc/study_group/day/dto/DayDTO.java new file mode 100644 index 0000000..5f85a95 --- /dev/null +++ b/src/main/java/com/gdgoc/study_group/day/dto/DayDTO.java @@ -0,0 +1,19 @@ +package com.gdgoc.study_group.day.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.gdgoc.study_group.day.domain.Day; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import java.time.LocalTime; + +public record DayDTO( + @NotBlank(message = "스터디 요일을 입력해 주세요.") @Schema(description = "스터디 요일") String day, + @JsonFormat(pattern = "HH:mm") // "startTime": "14:00" 형식으로 입력 + @NotBlank(message = "스터디 시간을 입력해 주세요") + @Schema(description = "스터디 시간") + LocalTime startTime) { + + public static DayDTO from(Day day) { + return new DayDTO(day.getDay(), day.getStartTime()); + } +} diff --git a/src/main/java/com/gdgoc/study_group/exception/CustomException.java b/src/main/java/com/gdgoc/study_group/exception/CustomException.java new file mode 100644 index 0000000..578cd17 --- /dev/null +++ b/src/main/java/com/gdgoc/study_group/exception/CustomException.java @@ -0,0 +1,19 @@ +package com.gdgoc.study_group.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomException(ErrorCode errorCode, String errorMessage) { + super(errorMessage); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/gdgoc/study_group/exception/ErrorCode.java b/src/main/java/com/gdgoc/study_group/exception/ErrorCode.java new file mode 100644 index 0000000..bab6cb5 --- /dev/null +++ b/src/main/java/com/gdgoc/study_group/exception/ErrorCode.java @@ -0,0 +1,18 @@ +package com.gdgoc.study_group.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 에러가 발생했습니다."), + + // study + STUDY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 스터디입니다."), + ; + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/gdgoc/study_group/exception/ErrorResponse.java b/src/main/java/com/gdgoc/study_group/exception/ErrorResponse.java new file mode 100644 index 0000000..f91d2d8 --- /dev/null +++ b/src/main/java/com/gdgoc/study_group/exception/ErrorResponse.java @@ -0,0 +1,16 @@ +package com.gdgoc.study_group.exception; + +public record ErrorResponse(String errorCodeName, String errorMessage) { + + public static ErrorResponse of(ErrorCode errorCode) { + return new ErrorResponse(errorCode.name(), errorCode.getMessage()); + } + + public static ErrorResponse of(ErrorCode errorCode, String errorMessage) { + return new ErrorResponse(errorCode.name(), errorMessage); + } + + public static ErrorResponse of(String errorCodeName, String errorMessage) { + return new ErrorResponse(errorCodeName, errorMessage); + } +} diff --git a/src/main/java/com/gdgoc/study_group/exception/GlobalExceptionHandler.java b/src/main/java/com/gdgoc/study_group/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..8e11887 --- /dev/null +++ b/src/main/java/com/gdgoc/study_group/exception/GlobalExceptionHandler.java @@ -0,0 +1,26 @@ +package com.gdgoc.study_group.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e) { + log.info("CustomException : {}", e.getMessage()); + return ResponseEntity.status(e.getErrorCode().getStatus()) + .body(ErrorResponse.of(e.getErrorCode())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.error("INTERNAL_SERVER_ERROR : {}", e.getMessage()); + return ResponseEntity.status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus()) + .body(ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR)); + } +} diff --git a/src/main/java/com/gdgoc/study_group/study/api/StudyController.java b/src/main/java/com/gdgoc/study_group/study/api/StudyController.java new file mode 100644 index 0000000..aae21cb --- /dev/null +++ b/src/main/java/com/gdgoc/study_group/study/api/StudyController.java @@ -0,0 +1,65 @@ +package com.gdgoc.study_group.study.api; + +import com.gdgoc.study_group.study.application.LeaderStudyService; +import com.gdgoc.study_group.study.application.StudentStudyService; +import com.gdgoc.study_group.study.dto.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/studies") +@Tag(name = "Study", description = "스터디 API") +@RequiredArgsConstructor +public class StudyController { + + public final StudentStudyService studentStudyService; + public final LeaderStudyService leaderStudyService; + + @Operation(summary = "스터디 생성", description = "자율스터디를 생성합니다.") + @PostMapping() + public ResponseEntity createStudy(@RequestBody StudyCreateUpdateRequest request) { + Long studyId = studentStudyService.createStudy(request); + + return ResponseEntity.ok(studyId); + } + + @Operation(summary = "전체 스터디 조회", description = "모든 스터디를 조회합니다.") + @GetMapping() + public ResponseEntity> getStudyList() { + List studyList = studentStudyService.getAllStudies(); + + return ResponseEntity.status(HttpStatus.OK).body(studyList); + } + + @Operation(summary = "개별 스터디 조회", description = "스터디 하나의 정보를 조회합니다.") + @GetMapping("/{studyId}") + public ResponseEntity getStudyDetail(@PathVariable("studyId") Long studyId) { + StudyResponse studyDetail = studentStudyService.getStudyDetail(studyId); + + return ResponseEntity.status(HttpStatus.OK).body(studyDetail); + } + + @Operation(summary = "스터디 수정", description = "스터디 정보를 수정합니다. 스터디장만 수정할 수 있습니다.") + @PatchMapping("/{studyId}") + public ResponseEntity updateStudy( + @PathVariable("studyId") Long studyId, @RequestBody StudyCreateUpdateRequest updateRequest) { + + leaderStudyService.updateStudy(studyId, updateRequest); + + return ResponseEntity.ok().build(); + } + + @Operation(summary = "스터디 삭제", description = "스터디를 삭제합니다. 스터디장만 삭제할 수 있습니다.") + @DeleteMapping("/{studyId}") + public ResponseEntity deleteStudy(@PathVariable("studyId") Long studyId) { + + leaderStudyService.deleteStudy(studyId); + + return ResponseEntity.status(HttpStatus.RESET_CONTENT).body("스터디가 삭제되었습니다."); + } +} diff --git a/src/main/java/com/gdgoc/study_group/study/application/LeaderStudyService.java b/src/main/java/com/gdgoc/study_group/study/application/LeaderStudyService.java new file mode 100644 index 0000000..2d65edf --- /dev/null +++ b/src/main/java/com/gdgoc/study_group/study/application/LeaderStudyService.java @@ -0,0 +1,58 @@ +package com.gdgoc.study_group.study.application; + +import static com.gdgoc.study_group.exception.ErrorCode.STUDY_NOT_FOUND; + +import com.gdgoc.study_group.exception.CustomException; +import com.gdgoc.study_group.study.dao.StudyRepository; +import com.gdgoc.study_group.study.domain.Study; +import com.gdgoc.study_group.study.dto.StudyCreateUpdateRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LeaderStudyService { + + // TODO: 스터디장 권한 확인 필요 + + public final StudyRepository studyRepository; + + /** + * 스터디 정보를 수정합니다. + * + * @param studyId 수정할 스터디 ID + * @param request 수정할 스터디의 정보 + */ + @Transactional(readOnly = false) + public void updateStudy(Long studyId, StudyCreateUpdateRequest request) { + Study study = + studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + + study.update( + request.name(), + request.description(), + request.requirement(), + request.question(), + request.maxParticipants(), + request.studyStatus(), + request.curriculums(), + request.days()); + + studyRepository.save(study); + } + + /** + * 스터디장 권한 확인 필요 스터디를 삭제합니다 + * + * @param studyId 삭제할 스터디의 아이디 + * @return 해당하는 스터디의 존재 여부 + */ + @Transactional(readOnly = false) + public void deleteStudy(Long studyId) { + Study study = + studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + studyRepository.delete(study); + } +} diff --git a/src/main/java/com/gdgoc/study_group/study/application/StudentStudyService.java b/src/main/java/com/gdgoc/study_group/study/application/StudentStudyService.java new file mode 100644 index 0000000..60fbe16 --- /dev/null +++ b/src/main/java/com/gdgoc/study_group/study/application/StudentStudyService.java @@ -0,0 +1,67 @@ +package com.gdgoc.study_group.study.application; + +import static com.gdgoc.study_group.exception.ErrorCode.STUDY_NOT_FOUND; + +import com.gdgoc.study_group.exception.CustomException; +import com.gdgoc.study_group.study.dao.StudyRepository; +import com.gdgoc.study_group.study.domain.Study; +import com.gdgoc.study_group.study.dto.*; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StudentStudyService { + + public final StudyRepository studyRepository; + + /** + * 스터디를 생성합니다. + * + * @param request 스터디 생성 DTO + * @return ResponseDTO 반환 + */ + @Transactional(readOnly = false) + public Long createStudy(StudyCreateUpdateRequest request) { + + Study study = + Study.create( + request.name(), + request.description(), + request.requirement(), + request.question(), + request.maxParticipants(), + request.studyStatus()); + + // TODO: 스터디를 생성한 유저를 스터디장으로 설정한 뒤 studyMembers에 추가 + + study.addInfo(request.curriculums(), request.days()); + studyRepository.save(study); + + return study.getId(); + } + + /** + * 스터디 전체 목록을 조회합니다. + * + * @return 스터디 전체 목록 리스트 + */ + public List getAllStudies() { + return studyRepository.findAll().stream().map(StudyResponse::from).toList(); + } + + /** + * 스터디 상세 정보를 조회합니다. + * + * @param studyId 조회할 스터디 아이디 + * @return 스터디 정보 반환 + */ + public StudyResponse getStudyDetail(Long studyId) { + Study study = + studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + return StudyResponse.from(study); + } +} diff --git a/src/main/java/com/gdgoc/study_group/study/domain/Status.java b/src/main/java/com/gdgoc/study_group/study/domain/Status.java deleted file mode 100644 index 37dc8a6..0000000 --- a/src/main/java/com/gdgoc/study_group/study/domain/Status.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.gdgoc.study_group.study.domain; - -public enum Status { - OFFLINE, - ONLINE, - FINISHED -} diff --git a/src/main/java/com/gdgoc/study_group/study/domain/Study.java b/src/main/java/com/gdgoc/study_group/study/domain/Study.java index 5ed44a7..acb4424 100644 --- a/src/main/java/com/gdgoc/study_group/study/domain/Study.java +++ b/src/main/java/com/gdgoc/study_group/study/domain/Study.java @@ -2,29 +2,36 @@ import com.gdgoc.study_group.answer.domain.Answer; import com.gdgoc.study_group.curriculum.domain.Curriculum; +import com.gdgoc.study_group.curriculum.dto.CurriculumDTO; import com.gdgoc.study_group.day.domain.Day; +import com.gdgoc.study_group.day.dto.DayDTO; import com.gdgoc.study_group.round.domain.Round; import com.gdgoc.study_group.studyMember.domain.StudyMember; import jakarta.persistence.*; import java.util.ArrayList; import java.util.List; +import lombok.*; @Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Study { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @OneToMany(mappedBy = "study") + @OneToMany(mappedBy = "study", cascade = CascadeType.PERSIST) private List studyMembers = new ArrayList<>(); @OneToMany(mappedBy = "study") private List rounds = new ArrayList<>(); - @OneToMany(mappedBy = "study") + @OneToMany(mappedBy = "study", cascade = CascadeType.ALL, orphanRemoval = true) private List curriculums = new ArrayList<>(); - @OneToMany(mappedBy = "study") + @OneToMany(mappedBy = "study", cascade = CascadeType.ALL, orphanRemoval = true) private List days = new ArrayList<>(); @OneToMany(mappedBy = "study") @@ -39,4 +46,69 @@ public class Study { private String question; // 지원 질문, nullable: 지원 답변 없이 바로 신청 가능 private Integer maxParticipants; // null == 인원 제한 X private Boolean isApplicationClosed = false; // 멤버 지원 종료 여부(기본값은 지원 가능) + + public static Study create( + String name, + String description, + String requirement, + String question, + Integer maxParticipants, + StudyStatus status) { + return Study.builder() + .name(name) + .description(description) + .requirement(requirement) + .question(question) + .maxParticipants(maxParticipants) + .studyStatus(status) + .isApplicationClosed(false) + .build(); + } + + public void addInfo(List curriculumDTOs, List dayDTOs) { + // 등록된 커리큘럼이 있다면 엔티티로 변환하여 리스트에 추가 + this.curriculums = + curriculumDTOs.stream() + .map( + curriculumDTO -> + Curriculum.create(this, curriculumDTO.week(), curriculumDTO.subject())) + .toList(); + + // 등록된 스터디 날짜가 있다면 엔티티로 변환하여 리스트에 추가 + this.days = + dayDTOs.stream().map(dayDTO -> Day.create(this, dayDTO.day(), dayDTO.startTime())).toList(); + } + + public void update( + String name, + String description, + String requirement, + String question, + Integer maxParticipants, + StudyStatus studyStatus, + List curriculumDTOs, + List dayDTOs) { + this.name = name; + this.description = description; + this.requirement = requirement; + this.question = question; + this.maxParticipants = maxParticipants; + this.studyStatus = studyStatus; + + this.getCurriculums().clear(); + this.getCurriculums() + .addAll( + curriculumDTOs.stream() + .map( + curriculumDTO -> + Curriculum.create(this, curriculumDTO.week(), curriculumDTO.subject())) + .toList()); + + this.getDays().clear(); + this.getDays() + .addAll( + dayDTOs.stream() + .map(dayDTO -> Day.create(this, dayDTO.day(), dayDTO.startTime())) + .toList()); + } } diff --git a/src/main/java/com/gdgoc/study_group/study/dto/StudyCreateUpdateRequest.java b/src/main/java/com/gdgoc/study_group/study/dto/StudyCreateUpdateRequest.java new file mode 100644 index 0000000..5ceb5eb --- /dev/null +++ b/src/main/java/com/gdgoc/study_group/study/dto/StudyCreateUpdateRequest.java @@ -0,0 +1,20 @@ +package com.gdgoc.study_group.study.dto; + +import com.gdgoc.study_group.curriculum.dto.CurriculumDTO; +import com.gdgoc.study_group.day.dto.DayDTO; +import com.gdgoc.study_group.study.domain.StudyStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record StudyCreateUpdateRequest( + @NotBlank(message = "스터디 이름을 입력해 주세요.") @Schema(description = "스터디 이름") String name, + @NotBlank(message = "스터디 소개를 입력해 주세요.") @Schema(description = "스터디 소개") String description, + @Schema(description = "스터디 자원 자격") String requirement, + @Schema(description = "스터디 지원 질문") String question, + @Schema(description = "스터디 최대 인원") Integer maxParticipants, + @Schema(description = "스터디 커리큘럼") List curriculums, + @Schema(description = "스터디 요일 및 시간") List days, + @NotNull(message = "모임 방식을 선택해 주세요.") @Schema(description = "모임 방식(온/오프라인)") + StudyStatus studyStatus) {} diff --git a/src/main/java/com/gdgoc/study_group/study/dto/StudyResponse.java b/src/main/java/com/gdgoc/study_group/study/dto/StudyResponse.java new file mode 100644 index 0000000..ff19729 --- /dev/null +++ b/src/main/java/com/gdgoc/study_group/study/dto/StudyResponse.java @@ -0,0 +1,32 @@ +package com.gdgoc.study_group.study.dto; + +import com.gdgoc.study_group.curriculum.dto.CurriculumDTO; +import com.gdgoc.study_group.day.dto.DayDTO; +import com.gdgoc.study_group.study.domain.Study; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record StudyResponse( + Long id, + @Schema(description = "스터디 이름") String name, + @Schema(description = "스터디 설명") String description, + @Schema(description = "스터디 지원 조건") String requirement, + @Schema(description = "스터디 지원 질문") String question, + @Schema(description = "스터디 커리큘럼") List curriculums, + @Schema(description = "스터디 요일 및 시간") List days, + @Schema(description = "스터디 최대 인원") Integer maxParticipants, + @Schema(description = "스터디 모집 마감 여부") boolean isApplicationClosed) { + + public static StudyResponse from(Study study) { + return new StudyResponse( + study.getId(), + study.getName(), + study.getDescription(), + study.getRequirement(), + study.getQuestion(), + study.getCurriculums().stream().map(CurriculumDTO::from).toList(), + study.getDays().stream().map(DayDTO::from).toList(), + study.getMaxParticipants(), + study.getIsApplicationClosed()); + } +}