diff --git a/src/main/java/com/bmilab/backend/domain/leave/controller/AdminLeaveApi.java b/src/main/java/com/bmilab/backend/domain/leave/controller/AdminLeaveApi.java index 7ea1b82..157b640 100644 --- a/src/main/java/com/bmilab/backend/domain/leave/controller/AdminLeaveApi.java +++ b/src/main/java/com/bmilab/backend/domain/leave/controller/AdminLeaveApi.java @@ -74,7 +74,7 @@ ResponseEntity getLeaves( @PageableDefault(size = 10, sort = "applicatedAt", direction = Sort.Direction.DESC) @ParameterObject Pageable pageable ); - @Operation(summary = "승인된 휴가 수정", description = "승인된 휴가 정보를 수정하는 PATCH API") + @Operation(summary = "승인된 휴가 수정", description = "승인된 휴가 정보를 수정하는 PATCH API (시작일이 지나지 않은 휴가만 수정 가능)") @ApiResponses( value = { @ApiResponse( @@ -83,7 +83,7 @@ ResponseEntity getLeaves( ), @ApiResponse( responseCode = "400", - description = "승인된 휴가만 수정할 수 있습니다." + description = "승인된 휴가만 수정할 수 있습니다. / 이미 지난 휴가는 수정할 수 없습니다." ), @ApiResponse( responseCode = "404", @@ -95,4 +95,19 @@ ResponseEntity updateLeave( @PathVariable long leaveId, AdminUpdateLeaveRequest request ); + + @Operation(summary = "휴가 삭제", description = "휴가를 삭제하는 DELETE API (승인된 휴가 삭제 시 휴가 일수 복원)") + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "휴가 삭제 성공" + ), + @ApiResponse( + responseCode = "404", + description = "휴가 정보를 찾을 수 없습니다." + ) + } + ) + ResponseEntity deleteLeave(@PathVariable long leaveId); } diff --git a/src/main/java/com/bmilab/backend/domain/leave/controller/AdminLeaveController.java b/src/main/java/com/bmilab/backend/domain/leave/controller/AdminLeaveController.java index 3a09fec..809ca7f 100644 --- a/src/main/java/com/bmilab/backend/domain/leave/controller/AdminLeaveController.java +++ b/src/main/java/com/bmilab/backend/domain/leave/controller/AdminLeaveController.java @@ -15,6 +15,7 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -68,4 +69,11 @@ public ResponseEntity updateLeave( leaveService.updateLeaveByAdmin(leaveId, request); return ResponseEntity.ok().build(); } + + @Override + @DeleteMapping("/{leaveId}") + public ResponseEntity deleteLeave(@PathVariable long leaveId) { + leaveService.deleteLeaveByAdmin(leaveId); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/bmilab/backend/domain/leave/controller/LeaveApi.java b/src/main/java/com/bmilab/backend/domain/leave/controller/LeaveApi.java index 80d438e..9a8f1fc 100644 --- a/src/main/java/com/bmilab/backend/domain/leave/controller/LeaveApi.java +++ b/src/main/java/com/bmilab/backend/domain/leave/controller/LeaveApi.java @@ -17,6 +17,7 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "Leave", description = "휴가 API") @@ -62,4 +63,26 @@ ResponseEntity applyLeave( @AuthenticationPrincipal UserAuthInfo userAuthInfo, ApplyLeaveRequest request ); + + @Operation(summary = "휴가 취소", description = "대기 상태인 휴가를 취소하는 DELETE API") + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "취소 성공" + ), + @ApiResponse( + responseCode = "400", + description = "대기 상태인 휴가만 취소할 수 있습니다." + ), + @ApiResponse( + responseCode = "404", + description = "휴가 정보를 찾을 수 없습니다." + ) + } + ) + ResponseEntity cancelLeave( + @AuthenticationPrincipal UserAuthInfo userAuthInfo, + @PathVariable long leaveId + ); } diff --git a/src/main/java/com/bmilab/backend/domain/leave/controller/LeaveController.java b/src/main/java/com/bmilab/backend/domain/leave/controller/LeaveController.java index afe6563..6aed1a6 100644 --- a/src/main/java/com/bmilab/backend/domain/leave/controller/LeaveController.java +++ b/src/main/java/com/bmilab/backend/domain/leave/controller/LeaveController.java @@ -14,7 +14,9 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -53,4 +55,13 @@ public ResponseEntity applyLeave( leaveService.applyLeave(userAuthInfo.getUserId(), request); return ResponseEntity.ok().build(); } + + @DeleteMapping("/{leaveId}") + public ResponseEntity cancelLeave( + @AuthenticationPrincipal UserAuthInfo userAuthInfo, + @PathVariable long leaveId + ) { + leaveService.cancelLeaveByUser(userAuthInfo.getUserId(), leaveId); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/bmilab/backend/domain/leave/entity/Leave.java b/src/main/java/com/bmilab/backend/domain/leave/entity/Leave.java index e8f030d..0a6aab9 100644 --- a/src/main/java/com/bmilab/backend/domain/leave/entity/Leave.java +++ b/src/main/java/com/bmilab/backend/domain/leave/entity/Leave.java @@ -91,6 +91,10 @@ public void reject(String rejectReason, User processor, LocalDateTime now) { processedAt = now; } + public boolean isPending() { + return status == LeaveStatus.PENDING; + } + public boolean isNotPending() { return status != LeaveStatus.PENDING; } diff --git a/src/main/java/com/bmilab/backend/domain/leave/exception/LeaveErrorCode.java b/src/main/java/com/bmilab/backend/domain/leave/exception/LeaveErrorCode.java index 153759f..6e851ed 100644 --- a/src/main/java/com/bmilab/backend/domain/leave/exception/LeaveErrorCode.java +++ b/src/main/java/com/bmilab/backend/domain/leave/exception/LeaveErrorCode.java @@ -16,6 +16,8 @@ public enum LeaveErrorCode implements ErrorCode { USER_LEAVE_NOT_FOUND("사용자의 휴가 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), HOLIDAY_API_ERROR("공휴일 API 조회 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), ACCESS_DENIED("신청 권한이 없습니다.", HttpStatus.FORBIDDEN), + LEAVE_CANNOT_CANCEL_NOT_PENDING("대기 상태인 휴가만 취소할 수 있습니다.", HttpStatus.BAD_REQUEST), + LEAVE_ALREADY_PASSED("이미 지난 휴가는 수정할 수 없습니다.", HttpStatus.BAD_REQUEST), ; private final String message; diff --git a/src/main/java/com/bmilab/backend/domain/leave/service/LeaveService.java b/src/main/java/com/bmilab/backend/domain/leave/service/LeaveService.java index 7ddd925..00ef913 100644 --- a/src/main/java/com/bmilab/backend/domain/leave/service/LeaveService.java +++ b/src/main/java/com/bmilab/backend/domain/leave/service/LeaveService.java @@ -149,6 +149,10 @@ public void updateLeaveByAdmin(long leaveId, AdminUpdateLeaveRequest request) { throw new ApiException(LeaveErrorCode.LEAVE_NOT_APPROVED); } + if (leave.getStartDate().isBefore(LocalDate.now())) { + throw new ApiException(LeaveErrorCode.LEAVE_ALREADY_PASSED); + } + User user = leave.getUser(); UserLeave userLeave = userLeaveRepository.findByUserId(user.getId()) .orElseThrow(() -> new ApiException(LeaveErrorCode.USER_LEAVE_NOT_FOUND)); @@ -179,6 +183,22 @@ public void updateLeaveByAdmin(long leaveId, AdminUpdateLeaveRequest request) { leave.update(startDate, request.endDate(), newType, newLeaveCount, request.reason()); } + @Transactional + public void cancelLeaveByUser(Long userId, long leaveId) { + Leave leave = leaveRepository.findById(leaveId) + .orElseThrow(() -> new ApiException(LeaveErrorCode.LEAVE_NOT_FOUND)); + + if (!leave.getUser().getId().equals(userId)) { + throw new ApiException(LeaveErrorCode.ACCESS_DENIED); + } + + if (!leave.isPending()) { + throw new ApiException(LeaveErrorCode.LEAVE_CANNOT_CANCEL_NOT_PENDING); + } + + leaveRepository.delete(leave); + } + public int countDatesByUserIdWithMonth(long userId, YearMonth yearMonth) { return leaveRepository.findAllByUserId(userId) .stream() @@ -187,6 +207,23 @@ public int countDatesByUserIdWithMonth(long userId, YearMonth yearMonth) { .sum(); } + @Transactional + public void deleteLeaveByAdmin(long leaveId) { + Leave leave = leaveRepository.findById(leaveId) + .orElseThrow(() -> new ApiException(LeaveErrorCode.LEAVE_NOT_FOUND)); + + // 승인된 휴가인 경우 휴가 일수 복원 + if (leave.isApproved()) { + User user = leave.getUser(); + UserLeave userLeave = userLeaveRepository.findByUserId(user.getId()) + .orElseThrow(() -> new ApiException(LeaveErrorCode.USER_LEAVE_NOT_FOUND)); + + userLeave.restoreLeave(leave.getLeaveCount(), leave.isAnnualLeave()); + } + + leaveRepository.delete(leave); + } + private void validateLeaveTypeAccessPermission(User user, LeaveType type) { if ((!user.isAdmin()) && type == LeaveType.ALL) { throw new ApiException(LeaveErrorCode.ACCESS_DENIED); diff --git a/src/main/java/com/bmilab/backend/domain/research/paper/dto/request/CreatePaperRequest.java b/src/main/java/com/bmilab/backend/domain/research/paper/dto/request/CreatePaperRequest.java index 4386f5e..750cac0 100644 --- a/src/main/java/com/bmilab/backend/domain/research/paper/dto/request/CreatePaperRequest.java +++ b/src/main/java/com/bmilab/backend/domain/research/paper/dto/request/CreatePaperRequest.java @@ -42,8 +42,10 @@ public record CreatePaperRequest( ProfessorRole professorRole, @Schema(description = "대표 실적 여부", example = "true") boolean isRepresentative, - @Schema(description = "연계 과제 ID (선택적)", example = "1") + @Schema(description = "연계 과제 ID (선택)", example = "1") Long taskId, + @Schema(description = "연계 프로젝트 ID (선택)", example = "1") + Long projectId, @Schema(description = "첨부 파일 ID 목록", example = "[\"a1b2c3d4-e5f6-7890-1234-567890abcdef\"]") List fileIds ) { diff --git a/src/main/java/com/bmilab/backend/domain/research/paper/dto/request/UpdatePaperRequest.java b/src/main/java/com/bmilab/backend/domain/research/paper/dto/request/UpdatePaperRequest.java index 38dc84b..59abc69 100644 --- a/src/main/java/com/bmilab/backend/domain/research/paper/dto/request/UpdatePaperRequest.java +++ b/src/main/java/com/bmilab/backend/domain/research/paper/dto/request/UpdatePaperRequest.java @@ -42,8 +42,10 @@ public record UpdatePaperRequest( ProfessorRole professorRole, @Schema(description = "대표 실적 여부", example = "false") boolean isRepresentative, - @Schema(description = "연계 과제 ID (선택적)", example = "1") + @Schema(description = "연계 과제 ID (선택)", example = "1") Long taskId, + @Schema(description = "연계 프로젝트 ID (선택)", example = "1") + Long projectId, @Schema(description = "첨부 파일 ID 목록", example = "[\"a1b2c3d4-e5f6-7890-1234-567890abcdef\"]") List fileIds ) { diff --git a/src/main/java/com/bmilab/backend/domain/research/paper/dto/response/PaperResponse.java b/src/main/java/com/bmilab/backend/domain/research/paper/dto/response/PaperResponse.java index 8b9e4c7..5df58ab 100644 --- a/src/main/java/com/bmilab/backend/domain/research/paper/dto/response/PaperResponse.java +++ b/src/main/java/com/bmilab/backend/domain/research/paper/dto/response/PaperResponse.java @@ -51,6 +51,12 @@ public record PaperResponse( boolean isRepresentative, @Schema(description = "연계 과제 ID") Long taskId, + @Schema(description = "연계 과제명") + String taskName, + @Schema(description = "연계 프로젝트 ID") + Long projectId, + @Schema(description = "연계 프로젝트명") + String projectName, @Schema(description = "첨부 파일 목록") List files ) { @@ -76,6 +82,9 @@ public PaperResponse(Paper paper, List correspondingAu paper.getProfessorRole().getDescription(), paper.getIsRepresentative(), paper.getTask() != null ? paper.getTask().getId() : null, + paper.getTask() != null ? paper.getTask().getTitle() : null, + paper.getProject() != null ? paper.getProject().getId() : null, + paper.getProject() != null ? paper.getProject().getTitle() : null, files ); } diff --git a/src/main/java/com/bmilab/backend/domain/research/paper/entity/Paper.java b/src/main/java/com/bmilab/backend/domain/research/paper/entity/Paper.java index ae80111..d239bbf 100644 --- a/src/main/java/com/bmilab/backend/domain/research/paper/entity/Paper.java +++ b/src/main/java/com/bmilab/backend/domain/research/paper/entity/Paper.java @@ -1,5 +1,6 @@ package com.bmilab.backend.domain.research.paper.entity; +import com.bmilab.backend.domain.project.entity.Project; import com.bmilab.backend.domain.research.paper.enums.ProfessorRole; import com.bmilab.backend.domain.task.entity.Task; import com.bmilab.backend.global.entity.BaseTimeEntity; @@ -73,8 +74,12 @@ public class Paper extends BaseTimeEntity { @JoinColumn(name = "task_id") private Task task; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id") + private Project project; + @Builder - public Paper(LocalDate acceptDate, LocalDate publishDate, Journal journal, String paperTitle, String allAuthors, int authorCount, String firstAuthor, String coAuthors, String vol, String page, String paperLink, String doi, String pmid, Integer citations, ProfessorRole professorRole, Boolean isRepresentative, Task task) { + public Paper(LocalDate acceptDate, LocalDate publishDate, Journal journal, String paperTitle, String allAuthors, int authorCount, String firstAuthor, String coAuthors, String vol, String page, String paperLink, String doi, String pmid, Integer citations, ProfessorRole professorRole, Boolean isRepresentative, Task task, Project project) { this.acceptDate = acceptDate; this.publishDate = publishDate; this.journal = journal; @@ -92,9 +97,10 @@ public Paper(LocalDate acceptDate, LocalDate publishDate, Journal journal, Strin this.professorRole = professorRole; this.isRepresentative = isRepresentative; this.task = task; + this.project = project; } - public void update(LocalDate acceptDate, LocalDate publishDate, Journal journal, String paperTitle, String allAuthors, int authorCount, String firstAuthor, String coAuthors, String vol, String page, String paperLink, String doi, String pmid, Integer citations, ProfessorRole professorRole, Boolean isRepresentative, Task task) { + public void update(LocalDate acceptDate, LocalDate publishDate, Journal journal, String paperTitle, String allAuthors, int authorCount, String firstAuthor, String coAuthors, String vol, String page, String paperLink, String doi, String pmid, Integer citations, ProfessorRole professorRole, Boolean isRepresentative, Task task, Project project) { this.acceptDate = acceptDate; this.publishDate = publishDate; this.journal = journal; @@ -112,6 +118,7 @@ public void update(LocalDate acceptDate, LocalDate publishDate, Journal journal, this.professorRole = professorRole; this.isRepresentative = isRepresentative; this.task = task; + this.project = project; } } \ No newline at end of file diff --git a/src/main/java/com/bmilab/backend/domain/research/paper/service/PaperService.java b/src/main/java/com/bmilab/backend/domain/research/paper/service/PaperService.java index 1f138c4..84162ff 100644 --- a/src/main/java/com/bmilab/backend/domain/research/paper/service/PaperService.java +++ b/src/main/java/com/bmilab/backend/domain/research/paper/service/PaperService.java @@ -4,7 +4,10 @@ import com.bmilab.backend.domain.file.enums.FileDomainType; import com.bmilab.backend.domain.file.service.FileService; import com.bmilab.backend.domain.project.entity.ExternalProfessor; +import com.bmilab.backend.domain.project.entity.Project; +import com.bmilab.backend.domain.project.exception.ProjectErrorCode; import com.bmilab.backend.domain.project.repository.ExternalProfessorRepository; +import com.bmilab.backend.domain.project.repository.ProjectRepository; import com.bmilab.backend.domain.research.paper.exception.PaperErrorCode; import com.bmilab.backend.domain.research.paper.dto.request.CreatePaperRequest; import com.bmilab.backend.domain.research.paper.dto.request.UpdatePaperRequest; @@ -47,6 +50,7 @@ public class PaperService { private final JournalRepository journalRepository; private final ExternalProfessorRepository externalProfessorRepository; private final TaskRepository taskRepository; + private final ProjectRepository projectRepository; private final FileService fileService; private final AuthorSyncService authorSyncService; @@ -56,6 +60,9 @@ public PaperResponse createPaper(CreatePaperRequest dto) { Task task = dto.taskId() != null ? taskRepository.findById(dto.taskId()).orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)) : null; + Project project = dto.projectId() != null + ? projectRepository.findById(dto.projectId()).orElseThrow(() -> new ApiException(ProjectErrorCode.PROJECT_NOT_FOUND)) + : null; int authorCount = (dto.allAuthors() != null) ? dto.allAuthors().split(",").length : 0; Paper newPaper = Paper.builder() .acceptDate(dto.acceptDate()) @@ -75,6 +82,7 @@ public PaperResponse createPaper(CreatePaperRequest dto) { .professorRole(dto.professorRole()) .isRepresentative(dto.isRepresentative()) .task(task) + .project(project) .build(); paperRepository.save(newPaper); @@ -154,8 +162,11 @@ public PaperResponse updatePaper(Long paperId, UpdatePaperRequest dto) { Task task = dto.taskId() != null ? taskRepository.findById(dto.taskId()).orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)) : null; + Project project = dto.projectId() != null + ? projectRepository.findById(dto.projectId()).orElseThrow(() -> new ApiException(ProjectErrorCode.PROJECT_NOT_FOUND)) + : null; int authorCount = (dto.allAuthors() != null) ? dto.allAuthors().split(",").length : 0; - paper.update(dto.acceptDate(), dto.publishDate(), journal, dto.paperTitle(), dto.allAuthors(), authorCount, dto.firstAuthor(), dto.coAuthors(), dto.vol(), dto.page(), dto.paperLink(), dto.doi(), dto.pmid(), dto.citations(), dto.professorRole(), dto.isRepresentative(), task); + paper.update(dto.acceptDate(), dto.publishDate(), journal, dto.paperTitle(), dto.allAuthors(), authorCount, dto.firstAuthor(), dto.coAuthors(), dto.vol(), dto.page(), dto.paperLink(), dto.doi(), dto.pmid(), dto.citations(), dto.professorRole(), dto.isRepresentative(), task, project); // Handle PaperCorrespondingAuthor linking paperCorrespondingAuthorRepository.deleteAllByPaperId(paperId); diff --git a/src/main/java/com/bmilab/backend/domain/seminar/controller/SeminarApi.java b/src/main/java/com/bmilab/backend/domain/seminar/controller/SeminarApi.java new file mode 100644 index 0000000..605f63e --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/controller/SeminarApi.java @@ -0,0 +1,85 @@ +package com.bmilab.backend.domain.seminar.controller; + +import com.bmilab.backend.domain.seminar.dto.request.CreateSeminarRequest; +import com.bmilab.backend.domain.seminar.dto.request.UpdateSeminarRequest; +import com.bmilab.backend.domain.seminar.dto.response.SeminarFindAllResponse; +import com.bmilab.backend.domain.seminar.dto.response.SeminarResponse; +import com.bmilab.backend.domain.seminar.enums.SeminarLabel; +import com.bmilab.backend.global.security.UserAuthInfo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Seminar", description = "세미나/학회 캘린더 API") +public interface SeminarApi { + + @Operation(summary = "날짜별 세미나/학회 조회", description = "기간 내 세미나/학회 일정을 조회하는 GET API (캘린더 뷰)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공") + }) + ResponseEntity getSeminarsByDateRange( + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate + ); + + @Operation(summary = "세미나/학회 검색", description = "키워드와 라벨로 세미나/학회를 검색하는 GET API (리스트 뷰)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공") + }) + ResponseEntity searchSeminars( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) SeminarLabel label, + @PageableDefault(size = 10, sort = "startDate", direction = Sort.Direction.DESC) + @ParameterObject Pageable pageable + ); + + @Operation(summary = "세미나/학회 상세 조회", description = "세미나/학회 상세 정보를 조회하는 GET API") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "404", description = "세미나/학회 일정을 찾을 수 없습니다.") + }) + ResponseEntity getSeminarById(@PathVariable Long seminarId); + + @Operation(summary = "세미나/학회 일정 생성", description = "새로운 세미나/학회 일정을 생성하는 POST API") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "생성 성공") + }) + ResponseEntity createSeminar( + @AuthenticationPrincipal UserAuthInfo userAuthInfo, + @RequestBody CreateSeminarRequest request + ); + + @Operation(summary = "세미나/학회 일정 수정", description = "세미나/학회 일정을 수정하는 PUT API") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "수정 성공"), + @ApiResponse(responseCode = "403", description = "수정/삭제 권한이 없습니다."), + @ApiResponse(responseCode = "404", description = "세미나/학회 일정을 찾을 수 없습니다.") + }) + ResponseEntity updateSeminar( + @AuthenticationPrincipal UserAuthInfo userAuthInfo, + @PathVariable Long seminarId, + @RequestBody UpdateSeminarRequest request + ); + + @Operation(summary = "세미나/학회 일정 삭제", description = "세미나/학회 일정을 삭제하는 DELETE API") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "삭제 성공"), + @ApiResponse(responseCode = "403", description = "수정/삭제 권한이 없습니다."), + @ApiResponse(responseCode = "404", description = "세미나/학회 일정을 찾을 수 없습니다.") + }) + ResponseEntity deleteSeminar( + @AuthenticationPrincipal UserAuthInfo userAuthInfo, + @PathVariable Long seminarId + ); +} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/controller/SeminarController.java b/src/main/java/com/bmilab/backend/domain/seminar/controller/SeminarController.java new file mode 100644 index 0000000..aa69bfb --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/controller/SeminarController.java @@ -0,0 +1,92 @@ +package com.bmilab.backend.domain.seminar.controller; + +import com.bmilab.backend.domain.seminar.dto.request.CreateSeminarRequest; +import com.bmilab.backend.domain.seminar.dto.request.UpdateSeminarRequest; +import com.bmilab.backend.domain.seminar.dto.response.SeminarFindAllResponse; +import com.bmilab.backend.domain.seminar.dto.response.SeminarResponse; +import com.bmilab.backend.domain.seminar.enums.SeminarLabel; +import com.bmilab.backend.domain.seminar.service.SeminarService; +import com.bmilab.backend.global.security.UserAuthInfo; +import jakarta.validation.Valid; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/seminars") +@RequiredArgsConstructor +public class SeminarController implements SeminarApi { + private final SeminarService seminarService; + + @Override + @GetMapping("/calendar") + public ResponseEntity getSeminarsByDateRange( + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate + ) { + return ResponseEntity.ok(seminarService.getSeminarsByDateRange(startDate, endDate)); + } + + @Override + @GetMapping("/search") + public ResponseEntity searchSeminars( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) SeminarLabel label, + @PageableDefault(size = 10, sort = "startDate", direction = Sort.Direction.DESC) + @ParameterObject Pageable pageable + ) { + return ResponseEntity.ok(seminarService.searchSeminars(keyword, label, pageable)); + } + + @Override + @GetMapping("/{seminarId}") + public ResponseEntity getSeminarById(@PathVariable Long seminarId) { + return ResponseEntity.ok(seminarService.getSeminarById(seminarId)); + } + + @Override + @PostMapping + public ResponseEntity createSeminar( + @AuthenticationPrincipal UserAuthInfo userAuthInfo, + @RequestBody @Valid CreateSeminarRequest request + ) { + seminarService.createSeminar(userAuthInfo.getUserId(), request); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @Override + @PutMapping("/{seminarId}") + public ResponseEntity updateSeminar( + @AuthenticationPrincipal UserAuthInfo userAuthInfo, + @PathVariable Long seminarId, + @RequestBody @Valid UpdateSeminarRequest request + ) { + seminarService.updateSeminar(userAuthInfo.getUserId(), seminarId, request); + return ResponseEntity.ok().build(); + } + + @Override + @DeleteMapping("/{seminarId}") + public ResponseEntity deleteSeminar( + @AuthenticationPrincipal UserAuthInfo userAuthInfo, + @PathVariable Long seminarId + ) { + seminarService.deleteSeminar(userAuthInfo.getUserId(), seminarId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/dto/request/CreateSeminarRequest.java b/src/main/java/com/bmilab/backend/domain/seminar/dto/request/CreateSeminarRequest.java new file mode 100644 index 0000000..0ec37e2 --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/dto/request/CreateSeminarRequest.java @@ -0,0 +1,27 @@ +package com.bmilab.backend.domain.seminar.dto.request; + +import com.bmilab.backend.domain.seminar.enums.SeminarLabel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +public record CreateSeminarRequest( + @Schema(description = "라벨 (SEMINAR: 세미나, CONFERENCE: 학회)", example = "CONFERENCE") + @NotNull(message = "라벨은 필수입니다.") + SeminarLabel label, + + @Schema(description = "제목", example = "대한의료정보학회 춘계학술대회") + @NotBlank(message = "제목은 필수입니다.") + String title, + + @Schema(description = "시작일", example = "2025-03-15") + @NotNull(message = "시작일은 필수입니다.") + LocalDate startDate, + + @Schema(description = "종료일 (선택)", example = "2025-03-17") + LocalDate endDate, + + @Schema(description = "발표/준비/참석 메모 (선택)", example = "발표 예정") + String note +) {} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/dto/request/UpdateSeminarRequest.java b/src/main/java/com/bmilab/backend/domain/seminar/dto/request/UpdateSeminarRequest.java new file mode 100644 index 0000000..2c4b914 --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/dto/request/UpdateSeminarRequest.java @@ -0,0 +1,27 @@ +package com.bmilab.backend.domain.seminar.dto.request; + +import com.bmilab.backend.domain.seminar.enums.SeminarLabel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +public record UpdateSeminarRequest( + @Schema(description = "라벨 (SEMINAR: 세미나, CONFERENCE: 학회)", example = "CONFERENCE") + @NotNull(message = "라벨은 필수입니다.") + SeminarLabel label, + + @Schema(description = "제목", example = "대한의료정보학회 춘계학술대회") + @NotBlank(message = "제목은 필수입니다.") + String title, + + @Schema(description = "시작일", example = "2025-03-15") + @NotNull(message = "시작일은 필수입니다.") + LocalDate startDate, + + @Schema(description = "종료일 (선택)", example = "2025-03-17") + LocalDate endDate, + + @Schema(description = "기타 메모 (선택)", example = "발표 예정") + String note +) {} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/dto/response/SeminarFindAllResponse.java b/src/main/java/com/bmilab/backend/domain/seminar/dto/response/SeminarFindAllResponse.java new file mode 100644 index 0000000..a211b4b --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/dto/response/SeminarFindAllResponse.java @@ -0,0 +1,20 @@ +package com.bmilab.backend.domain.seminar.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record SeminarFindAllResponse( + @Schema(description = "세미나/학회 목록") + List seminars, + + @Schema(description = "전체 페이지 수") + Integer totalPages +) { + public static SeminarFindAllResponse of(List seminars) { + return new SeminarFindAllResponse(seminars, null); + } + + public static SeminarFindAllResponse of(List seminars, int totalPages) { + return new SeminarFindAllResponse(seminars, totalPages); + } +} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/dto/response/SeminarResponse.java b/src/main/java/com/bmilab/backend/domain/seminar/dto/response/SeminarResponse.java new file mode 100644 index 0000000..c47cfa7 --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/dto/response/SeminarResponse.java @@ -0,0 +1,50 @@ +package com.bmilab.backend.domain.seminar.dto.response; + +import com.bmilab.backend.domain.seminar.entity.Seminar; +import com.bmilab.backend.domain.seminar.enums.SeminarLabel; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public record SeminarResponse( + @Schema(description = "세미나/학회 ID") + Long id, + + @Schema(description = "라벨") + SeminarLabel label, + + @Schema(description = "제목") + String title, + + @Schema(description = "시작일") + LocalDate startDate, + + @Schema(description = "종료일") + LocalDate endDate, + + @Schema(description = "기타 메모") + String note, + + @Schema(description = "등록자 ID") + Long userId, + + @Schema(description = "등록자 이름") + String userName, + + @Schema(description = "생성일시") + LocalDateTime createdAt +) { + public static SeminarResponse from(Seminar seminar) { + return new SeminarResponse( + seminar.getId(), + seminar.getLabel(), + seminar.getTitle(), + seminar.getStartDate(), + seminar.getEndDate(), + seminar.getNote(), + seminar.getUser().getId(), + seminar.getUser().getName(), + seminar.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/entity/Seminar.java b/src/main/java/com/bmilab/backend/domain/seminar/entity/Seminar.java new file mode 100644 index 0000000..c26765d --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/entity/Seminar.java @@ -0,0 +1,65 @@ +package com.bmilab.backend.domain.seminar.entity; + +import com.bmilab.backend.domain.seminar.enums.SeminarLabel; +import com.bmilab.backend.domain.user.entity.User; +import com.bmilab.backend.global.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +@Entity +@Table(name = "seminars") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Seminar extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "seminar_id") + private Long id; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "label", nullable = false) + private SeminarLabel label; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Column(name = "note", length = 1000) + private String note; + + public void update(SeminarLabel label, String title, LocalDate startDate, LocalDate endDate, String note) { + this.label = label; + this.title = title; + this.startDate = startDate; + this.endDate = endDate; + this.note = note; + } +} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/enums/SeminarLabel.java b/src/main/java/com/bmilab/backend/domain/seminar/enums/SeminarLabel.java new file mode 100644 index 0000000..a0fcadf --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/enums/SeminarLabel.java @@ -0,0 +1,13 @@ +package com.bmilab.backend.domain.seminar.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum SeminarLabel { + SEMINAR("세미나"), + CONFERENCE("학회"); + + private final String description; +} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/exception/SeminarErrorCode.java b/src/main/java/com/bmilab/backend/domain/seminar/exception/SeminarErrorCode.java new file mode 100644 index 0000000..d09d594 --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/exception/SeminarErrorCode.java @@ -0,0 +1,17 @@ +package com.bmilab.backend.domain.seminar.exception; + +import com.bmilab.backend.global.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SeminarErrorCode implements ErrorCode { + SEMINAR_NOT_FOUND("세미나/학회 일정을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + ACCESS_DENIED("수정/삭제 권한이 없습니다.", HttpStatus.FORBIDDEN), + ; + + private final String message; + private final HttpStatus httpStatus; +} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/repository/SeminarRepository.java b/src/main/java/com/bmilab/backend/domain/seminar/repository/SeminarRepository.java new file mode 100644 index 0000000..2f18081 --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/repository/SeminarRepository.java @@ -0,0 +1,7 @@ +package com.bmilab.backend.domain.seminar.repository; + +import com.bmilab.backend.domain.seminar.entity.Seminar; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SeminarRepository extends JpaRepository, SeminarRepositoryCustom { +} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/repository/SeminarRepositoryCustom.java b/src/main/java/com/bmilab/backend/domain/seminar/repository/SeminarRepositoryCustom.java new file mode 100644 index 0000000..251eb03 --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/repository/SeminarRepositoryCustom.java @@ -0,0 +1,14 @@ +package com.bmilab.backend.domain.seminar.repository; + +import com.bmilab.backend.domain.seminar.entity.Seminar; +import com.bmilab.backend.domain.seminar.enums.SeminarLabel; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface SeminarRepositoryCustom { + List findAllByDateRange(LocalDate startDate, LocalDate endDate); + + Page searchSeminars(String keyword, SeminarLabel label, Pageable pageable); +} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/repository/SeminarRepositoryCustomImpl.java b/src/main/java/com/bmilab/backend/domain/seminar/repository/SeminarRepositoryCustomImpl.java new file mode 100644 index 0000000..2329d06 --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/repository/SeminarRepositoryCustomImpl.java @@ -0,0 +1,86 @@ +package com.bmilab.backend.domain.seminar.repository; + +import com.bmilab.backend.domain.seminar.entity.QSeminar; +import com.bmilab.backend.domain.seminar.entity.Seminar; +import com.bmilab.backend.domain.seminar.enums.SeminarLabel; +import com.bmilab.backend.domain.user.entity.QUser; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +@RequiredArgsConstructor +public class SeminarRepositoryCustomImpl implements SeminarRepositoryCustom { + private final JPAQueryFactory queryFactory; + + @Override + public List findAllByDateRange(LocalDate startDate, LocalDate endDate) { + QSeminar seminar = QSeminar.seminar; + QUser user = QUser.user; + + return queryFactory + .selectFrom(seminar) + .leftJoin(seminar.user, user).fetchJoin() + .where(dateRangeCondition(seminar, startDate, endDate)) + .orderBy(seminar.startDate.asc()) + .fetch(); + } + + @Override + public Page searchSeminars(String keyword, SeminarLabel label, Pageable pageable) { + QSeminar seminar = QSeminar.seminar; + QUser user = QUser.user; + + List content = queryFactory + .selectFrom(seminar) + .leftJoin(seminar.user, user).fetchJoin() + .where( + keywordCondition(seminar, keyword), + labelCondition(seminar, label) + ) + .orderBy(seminar.startDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(seminar.count()) + .from(seminar) + .where( + keywordCondition(seminar, keyword), + labelCondition(seminar, label) + ); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + private BooleanExpression dateRangeCondition(QSeminar seminar, LocalDate startDate, LocalDate endDate) { + if (startDate == null || endDate == null) { + return null; + } + // 세미나 기간이 조회 기간과 겹치는 경우 조회 + // (seminar.startDate <= endDate) AND (seminar.endDate >= startDate OR (seminar.endDate IS NULL AND seminar.startDate >= startDate)) + return seminar.startDate.loe(endDate) + .and( + seminar.endDate.goe(startDate) + .or(seminar.endDate.isNull().and(seminar.startDate.goe(startDate))) + ); + } + + private BooleanExpression keywordCondition(QSeminar seminar, String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return seminar.title.containsIgnoreCase(keyword) + .or(seminar.note.containsIgnoreCase(keyword)); + } + + private BooleanExpression labelCondition(QSeminar seminar, SeminarLabel label) { + return label != null ? seminar.label.eq(label) : null; + } +} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/service/SeminarService.java b/src/main/java/com/bmilab/backend/domain/seminar/service/SeminarService.java new file mode 100644 index 0000000..8256d58 --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/service/SeminarService.java @@ -0,0 +1,99 @@ +package com.bmilab.backend.domain.seminar.service; + +import com.bmilab.backend.domain.seminar.dto.request.CreateSeminarRequest; +import com.bmilab.backend.domain.seminar.dto.request.UpdateSeminarRequest; +import com.bmilab.backend.domain.seminar.dto.response.SeminarFindAllResponse; +import com.bmilab.backend.domain.seminar.dto.response.SeminarResponse; +import com.bmilab.backend.domain.seminar.entity.Seminar; +import com.bmilab.backend.domain.seminar.enums.SeminarLabel; +import com.bmilab.backend.domain.seminar.exception.SeminarErrorCode; +import com.bmilab.backend.domain.seminar.repository.SeminarRepository; +import com.bmilab.backend.domain.user.entity.User; +import com.bmilab.backend.domain.user.service.UserService; +import com.bmilab.backend.global.exception.ApiException; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SeminarService { + private final SeminarRepository seminarRepository; + private final UserService userService; + + public SeminarFindAllResponse getSeminarsByDateRange(LocalDate startDate, LocalDate endDate) { + List seminars = seminarRepository.findAllByDateRange(startDate, endDate); + List responses = seminars.stream() + .map(SeminarResponse::from) + .toList(); + return SeminarFindAllResponse.of(responses); + } + + public SeminarFindAllResponse searchSeminars(String keyword, SeminarLabel label, Pageable pageable) { + Page seminars = seminarRepository.searchSeminars(keyword, label, pageable); + List responses = seminars.getContent().stream() + .map(SeminarResponse::from) + .toList(); + return SeminarFindAllResponse.of(responses, seminars.getTotalPages()); + } + + public SeminarResponse getSeminarById(Long seminarId) { + Seminar seminar = getSeminar(seminarId); + return SeminarResponse.from(seminar); + } + + @Transactional + public Long createSeminar(Long userId, CreateSeminarRequest request) { + User user = userService.findUserById(userId); + + Seminar seminar = Seminar.builder() + .user(user) + .label(request.label()) + .title(request.title()) + .startDate(request.startDate()) + .endDate(request.endDate()) + .note(request.note()) + .build(); + + return seminarRepository.save(seminar).getId(); + } + + @Transactional + public void updateSeminar(Long userId, Long seminarId, UpdateSeminarRequest request) { + Seminar seminar = getSeminar(seminarId); + validateOwnerOrAdmin(userId, seminar); + + seminar.update( + request.label(), + request.title(), + request.startDate(), + request.endDate(), + request.note() + ); + } + + @Transactional + public void deleteSeminar(Long userId, Long seminarId) { + Seminar seminar = getSeminar(seminarId); + validateOwnerOrAdmin(userId, seminar); + + seminarRepository.delete(seminar); + } + + private Seminar getSeminar(Long seminarId) { + return seminarRepository.findById(seminarId) + .orElseThrow(() -> new ApiException(SeminarErrorCode.SEMINAR_NOT_FOUND)); + } + + private void validateOwnerOrAdmin(Long userId, Seminar seminar) { + User user = userService.findUserById(userId); + if (!seminar.getUser().getId().equals(userId) && !user.isAdmin()) { + throw new ApiException(SeminarErrorCode.ACCESS_DENIED); + } + } +} diff --git a/src/main/java/com/bmilab/backend/domain/task/dto/response/TaskSummaryResponse.java b/src/main/java/com/bmilab/backend/domain/task/dto/response/TaskSummaryResponse.java index 575a686..1ec07d8 100644 --- a/src/main/java/com/bmilab/backend/domain/task/dto/response/TaskSummaryResponse.java +++ b/src/main/java/com/bmilab/backend/domain/task/dto/response/TaskSummaryResponse.java @@ -67,6 +67,9 @@ public record TaskSummaryResponse( @Schema(description = "김광수교수님 역할") TaskProfessorRole professorRole, + @Schema(description = "실무 책임자 ID") + Long practicalManagerId, + @Schema(description = "실무 책임자 이름") String practicalManagerName, @@ -105,6 +108,7 @@ public static TaskSummaryResponse from(Task task, List